api.py 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222
  1. """Dashboard payload builders and runtime helpers for zhuce6."""
  2. from __future__ import annotations
  3. from collections import deque
  4. from dataclasses import replace
  5. from datetime import date, datetime, time as datetime_time
  6. import json
  7. import math
  8. import os
  9. from pathlib import Path
  10. import sys
  11. import time
  12. from typing import Any
  13. from urllib.parse import urlsplit, urlunsplit
  14. try:
  15. from fastapi import FastAPI, HTTPException
  16. except ModuleNotFoundError:
  17. FastAPI = Any # type: ignore[assignment]
  18. class HTTPException(Exception):
  19. def __init__(self, status_code: int, detail: str = "") -> None:
  20. super().__init__(detail)
  21. self.status_code = status_code
  22. self.detail = detail
  23. from core.paths import DEFAULT_DASHBOARD_LOG_FILE
  24. from core.registry import list_platforms
  25. from core.settings import AppSettings
  26. from ops.account_survival import account_survival_once, load_account_survival_state, print_account_survival_summary
  27. from ops.common import CpaClient, create_backend_client
  28. from ops.responses_survival import (
  29. load_responses_survival_state,
  30. print_responses_survival_summary,
  31. responses_survival_once,
  32. )
  33. from ops.d1_cleanup import d1_cleanup_once
  34. from ops.rotate_log import rotate_log_tail as _rotate_log_tail
  35. from ops.service import RepeatedTask
  36. FREE_ACCOUNT_WEEKLY_TOKENS = max(
  37. 1,
  38. int(str(os.getenv("ZHUCE6_FREE_ACCOUNT_WEEKLY_TOKENS", "5000000")).strip() or "5000000"),
  39. )
  40. OVERVIEW_CACHE_TTL_SECONDS = 30.0
  41. def _cleanup_once(*args, **kwargs): # type: ignore[no-untyped-def]
  42. from ops.cleanup import cleanup_once
  43. return cleanup_once(*args, **kwargs)
  44. def _validate_once(*args, **kwargs): # type: ignore[no-untyped-def]
  45. from ops.validate import validate_once
  46. return validate_once(*args, **kwargs)
  47. def _print_validate_summary(*args, **kwargs): # type: ignore[no-untyped-def]
  48. from ops.validate import print_validate_summary
  49. return print_validate_summary(*args, **kwargs)
  50. def _rotate_once(*args, **kwargs): # type: ignore[no-untyped-def]
  51. from ops.rotate import rotate_once
  52. return rotate_once(*args, **kwargs)
  53. def _print_rotate_summary(*args, **kwargs): # type: ignore[no-untyped-def]
  54. from ops.rotate import print_rotate_summary
  55. return print_rotate_summary(*args, **kwargs)
  56. def _fetch_validate_management_auth_files(*args, **kwargs): # type: ignore[no-untyped-def]
  57. from ops import validate as validate_ops
  58. return validate_ops._fetch_management_auth_files(*args, **kwargs) # type: ignore[attr-defined]
  59. def _compat_main_attr(name: str, default: object) -> object:
  60. main_module = sys.modules.get("main")
  61. if main_module is None:
  62. return default
  63. return getattr(main_module, name, default)
  64. def _invoke_count_cpa_files(fn: object, settings: AppSettings) -> int:
  65. return int(fn(settings)) # type: ignore[misc]
  66. def _build_background_tasks(settings: AppSettings) -> list[RepeatedTask]:
  67. tasks: list[RepeatedTask] = []
  68. if settings.cleanup_enabled:
  69. tasks.append(
  70. RepeatedTask(
  71. "cleanup",
  72. lambda: _cleanup_once(
  73. client=create_backend_client(settings),
  74. proxy=settings.cleanup_proxy,
  75. management_base_url=settings.cpa_management_base_url,
  76. management_key=settings.cpa_management_key,
  77. pool_dir=settings.pool_dir,
  78. ),
  79. settings.cleanup_interval,
  80. )
  81. )
  82. if settings.d1_cleanup_enabled:
  83. tasks.append(
  84. RepeatedTask(
  85. "d1_cleanup",
  86. lambda: d1_cleanup_once(
  87. database_id=settings.d1_database_id,
  88. mail_retention_hours=settings.d1_mail_retention_hours,
  89. address_retention_hours=settings.d1_address_retention_hours,
  90. ),
  91. settings.d1_cleanup_interval,
  92. )
  93. )
  94. if settings.validate_enabled:
  95. tasks.append(
  96. RepeatedTask(
  97. "validate",
  98. lambda: _print_validate_summary(
  99. _validate_once(
  100. client=create_backend_client(settings),
  101. proxy=settings.validate_proxy,
  102. dry_run=False,
  103. max_workers=settings.validate_max_workers,
  104. pool_dir=settings.pool_dir,
  105. scope=settings.validate_scope,
  106. management_base_url=settings.cpa_management_base_url,
  107. management_key=settings.cpa_management_key,
  108. )
  109. ),
  110. settings.validate_interval,
  111. )
  112. )
  113. if settings.rotate_enabled:
  114. tasks.append(
  115. RepeatedTask(
  116. "rotate",
  117. lambda: _print_rotate_summary(
  118. _rotate_once(
  119. pool_dir=settings.pool_dir,
  120. client=create_backend_client(settings),
  121. management_base_url=settings.cpa_management_base_url,
  122. cpa_management_key=settings.cpa_management_key,
  123. rotate_probe_workers=settings.rotate_probe_workers,
  124. fresh_grace_seconds=settings.rotate_fresh_grace_seconds,
  125. cpa_runtime_reconcile_enabled=settings.cpa_runtime_reconcile_enabled,
  126. cpa_runtime_reconcile_cooldown_seconds=settings.cpa_runtime_reconcile_cooldown_seconds,
  127. cpa_runtime_reconcile_restart_enabled=settings.cpa_runtime_reconcile_restart_enabled,
  128. )
  129. ),
  130. settings.rotate_interval,
  131. )
  132. )
  133. if settings.account_survival_enabled:
  134. tasks.append(
  135. RepeatedTask(
  136. "account_survival",
  137. lambda: print_responses_survival_summary(
  138. responses_survival_once(
  139. pool_dir=settings.pool_dir,
  140. state_file=settings.responses_survival_state_file,
  141. cohort_size=settings.account_survival_cohort_size,
  142. proxy=settings.account_survival_proxy,
  143. timeout_seconds=settings.account_survival_timeout_seconds,
  144. settings=settings,
  145. require_provenance=settings.responses_survival_require_provenance,
  146. recent_window_seconds=settings.responses_survival_recent_window_seconds,
  147. warmup_min_age_seconds=settings.warmup_min_age_seconds,
  148. warmup_min_successful_probes=settings.warmup_min_successful_probes,
  149. )
  150. ),
  151. settings.account_survival_interval,
  152. )
  153. )
  154. return tasks
  155. def _count_pool_files(pool_dir: Path) -> int:
  156. if not pool_dir.is_dir():
  157. return 0
  158. try:
  159. return sum(1 for path in pool_dir.iterdir() if path.is_file() and path.suffix == ".json")
  160. except Exception:
  161. return 0
  162. def _count_cpa_files(settings: AppSettings) -> int:
  163. try:
  164. client = create_backend_client(settings)
  165. return len(
  166. [
  167. entry
  168. for entry in getattr(client, "list_auth_files")()
  169. if "@" in str(entry.get("name") or "").strip()
  170. ]
  171. )
  172. except Exception:
  173. return 0
  174. def _fetch_management_auth_files(settings: AppSettings) -> tuple[bool, list[dict[str, object]]]:
  175. if settings.runtime_mode == "lite":
  176. return False, []
  177. try:
  178. client = create_backend_client(settings)
  179. if not getattr(client, "health_check")():
  180. return False, []
  181. files = [
  182. item
  183. for item in getattr(client, "list_auth_files")()
  184. if isinstance(item, dict)
  185. ]
  186. except Exception:
  187. return False, []
  188. return True, files
  189. def _is_regular_free_account(item: dict[str, object]) -> bool:
  190. name = str(item.get("name") or "")
  191. if "@" not in name:
  192. return False
  193. id_token = item.get("id_token") or {}
  194. if isinstance(id_token, dict):
  195. plan_type = str(id_token.get("plan_type") or "").strip().lower()
  196. if plan_type:
  197. return plan_type == "free"
  198. return True
  199. def _classify_regular_account_status(item: dict[str, object]) -> str | None:
  200. if not _is_regular_free_account(item):
  201. return None
  202. status_message = str(item.get("status_message") or "")
  203. unavailable = bool(item.get("unavailable"))
  204. lowered_status = status_message.lower()
  205. if "unauthorized" in lowered_status or "invalidated" in lowered_status:
  206. return "invalid"
  207. if unavailable:
  208. if "usage_limit_reached" in lowered_status or item.get("next_retry_after"):
  209. return "waiting_reset"
  210. return "other"
  211. return "available"
  212. def _classify_regular_accounts(files: list[dict[str, object]], *, source_available: bool) -> dict[str, object]:
  213. stats: dict[str, object] = {
  214. "total": 0,
  215. "available": 0,
  216. "waiting_reset": 0,
  217. "invalid": 0,
  218. "other": 0,
  219. "source": "management",
  220. "source_available": source_available,
  221. "source_error": None if source_available else "management_data_unavailable",
  222. }
  223. if not source_available:
  224. return stats
  225. for item in files:
  226. status = _classify_regular_account_status(item)
  227. if status is None:
  228. continue
  229. stats["total"] = int(stats["total"]) + 1
  230. stats[status] = int(stats[status]) + 1
  231. return stats
  232. def _estimate_tokens(regular_accounts: dict[str, object]) -> dict[str, object]:
  233. available = int(regular_accounts.get("available") or 0)
  234. waiting_reset = int(regular_accounts.get("waiting_reset") or 0)
  235. relevant_accounts = available + waiting_reset
  236. source_available = bool(regular_accounts.get("source_available"))
  237. return {
  238. "per_account": FREE_ACCOUNT_WEEKLY_TOKENS,
  239. "available_now": available * FREE_ACCOUNT_WEEKLY_TOKENS,
  240. "available_with_reset": relevant_accounts * FREE_ACCOUNT_WEEKLY_TOKENS,
  241. "period": "weekly",
  242. "estimation_mode": "count_based",
  243. "baseline_source": "configured",
  244. "relevant_accounts": relevant_accounts,
  245. "matched_accounts": 0,
  246. "weighted_accounts": 0,
  247. "fallback_accounts": relevant_accounts,
  248. "fallback_reason": None if source_available else "missing_management_inventory",
  249. "snapshot_timestamp": None,
  250. "snapshot_age_seconds": None,
  251. "snapshot_fresh": False,
  252. }
  253. def _count_today_new(pool_dir: Path) -> int:
  254. if not pool_dir.is_dir():
  255. return 0
  256. try:
  257. today_start = datetime.combine(date.today(), datetime_time.min).timestamp()
  258. return sum(
  259. 1
  260. for path in pool_dir.iterdir()
  261. if path.is_file() and path.suffix == ".json" and path.stat().st_mtime >= today_start
  262. )
  263. except Exception:
  264. return 0
  265. def _dashboard_overview_payload(app: FastAPI) -> dict[str, object]:
  266. cache = getattr(app.state, "dashboard_overview_cache", None)
  267. now_monotonic = time.monotonic()
  268. if isinstance(cache, dict):
  269. created_at = float(cache.get("created_at") or 0.0)
  270. cached_payload = cache.get("payload")
  271. if now_monotonic - created_at <= OVERVIEW_CACHE_TTL_SECONDS and isinstance(cached_payload, dict):
  272. return cached_payload
  273. settings: AppSettings = app.state.settings
  274. runtime = _runtime_payload(app)
  275. register_task = next((task for task in runtime["task_states"] if task.get("name") == "register"), {})
  276. if settings.runtime_mode == "lite":
  277. cpa_count = None
  278. regular_accounts = None
  279. tokens = None
  280. observed_loss = None
  281. cpa_inventory = {
  282. "management_available": False,
  283. "count_source": "lite_mode",
  284. "auth_file_count": None,
  285. }
  286. else:
  287. fetch_management_auth_files = _compat_main_attr("_fetch_management_auth_files", _fetch_management_auth_files)
  288. count_cpa_files = _compat_main_attr("_count_cpa_files", _count_cpa_files)
  289. management_ok, auth_files = fetch_management_auth_files(settings) # type: ignore[misc]
  290. cpa_count = len(auth_files) if management_ok else _invoke_count_cpa_files(count_cpa_files, settings)
  291. regular_accounts = _classify_regular_accounts(auth_files, source_available=management_ok)
  292. tokens = _estimate_tokens(regular_accounts)
  293. observed_loss = int(regular_accounts.get("waiting_reset") or 0) + int(regular_accounts.get("invalid") or 0)
  294. cpa_inventory = {
  295. "management_available": management_ok,
  296. "count_source": "backend_api" if management_ok else "api_unavailable",
  297. "auth_file_count": cpa_count,
  298. }
  299. total_attempts = int(register_task.get("total_attempts") or 0)
  300. registered_success_total = int(register_task.get("total_success_registered") or register_task.get("total_success") or 0)
  301. cpa_sync_success_total = int(register_task.get("total_cpa_sync_success") or 0)
  302. cpa_sync_failure_total = int(register_task.get("total_cpa_sync_failure") or 0)
  303. payload = {
  304. "generated_at": datetime.now().isoformat(timespec="seconds"),
  305. "pool_count": runtime["pool_count"],
  306. "cpa_count": cpa_count,
  307. "cpa_inventory": cpa_inventory,
  308. "regular_accounts": regular_accounts,
  309. "tokens": tokens,
  310. "today_new": _compat_main_attr("_count_today_new", _count_today_new)(settings.pool_dir), # type: ignore[misc]
  311. "success_rate": register_task.get("success_rate") if total_attempts > 0 else None,
  312. "registered_success_total": registered_success_total,
  313. "cpa_sync_success_total": cpa_sync_success_total,
  314. "cpa_sync_failure_total": cpa_sync_failure_total,
  315. "registered_success_rate": round(registered_success_total / max(total_attempts, 1) * 100, 1) if total_attempts > 0 else None,
  316. "cpa_sync_success_rate": round(cpa_sync_success_total / max(total_attempts, 1) * 100, 1) if total_attempts > 0 else None,
  317. "burn_rate": None,
  318. "observed_loss": observed_loss,
  319. }
  320. app.state.dashboard_overview_cache = {
  321. "created_at": now_monotonic,
  322. "payload": payload,
  323. }
  324. return payload
  325. def _recent_pool_files(pool_dir: Path, limit: int = 8) -> list[dict[str, object]]:
  326. if not pool_dir.is_dir():
  327. return []
  328. try:
  329. normalized_limit = max(1, int(limit))
  330. files = [
  331. path
  332. for path in pool_dir.iterdir()
  333. if path.is_file() and path.suffix == ".json"
  334. ]
  335. files.sort(key=lambda item: item.stat().st_mtime, reverse=True)
  336. except Exception:
  337. return []
  338. out: list[dict[str, object]] = []
  339. for path in files[:normalized_limit]:
  340. try:
  341. stat = path.stat()
  342. out.append({
  343. "name": path.name,
  344. "path": str(path),
  345. "size_bytes": stat.st_size,
  346. "modified_at": stat.st_mtime,
  347. "modified_at_iso": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"),
  348. })
  349. except OSError:
  350. continue
  351. return out
  352. def _register_log_tail(settings: AppSettings, limit: int = 80) -> dict[str, object]:
  353. log_path_raw = str(settings.register_log_file or "").strip()
  354. if not log_path_raw:
  355. return {
  356. "available": False,
  357. "path": "",
  358. "updated_at": None,
  359. "updated_at_iso": None,
  360. "error": "register log file not configured",
  361. "lines": [],
  362. }
  363. log_path = Path(log_path_raw).expanduser()
  364. if not log_path.exists():
  365. return {
  366. "available": False,
  367. "path": str(log_path),
  368. "updated_at": None,
  369. "updated_at_iso": None,
  370. "error": "register log file not found",
  371. "lines": [],
  372. }
  373. try:
  374. with log_path.open("r", encoding="utf-8", errors="replace") as fh:
  375. lines = deque((line.rstrip("\r\n") for line in fh), maxlen=limit)
  376. stat = log_path.stat()
  377. except OSError as exc:
  378. return {
  379. "available": False,
  380. "path": str(log_path),
  381. "updated_at": None,
  382. "updated_at_iso": None,
  383. "error": str(exc),
  384. "lines": [],
  385. }
  386. return {
  387. "available": True,
  388. "path": str(log_path),
  389. "updated_at": stat.st_mtime,
  390. "updated_at_iso": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"),
  391. "error": None,
  392. "lines": list(lines),
  393. }
  394. def _runtime_state_file_meta(settings: AppSettings) -> dict[str, object]:
  395. state_file = Path(settings.runtime_state_file)
  396. if not state_file.exists():
  397. return {
  398. "exists": False,
  399. "path": str(state_file),
  400. "updated_at": None,
  401. "updated_at_iso": None,
  402. }
  403. stat = state_file.stat()
  404. return {
  405. "exists": True,
  406. "path": str(state_file),
  407. "updated_at": stat.st_mtime,
  408. "updated_at_iso": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"),
  409. }
  410. def _format_duration_hms(value: object) -> str | None:
  411. try:
  412. total = int(value) # type: ignore[arg-type]
  413. except Exception:
  414. return None
  415. if total < 0:
  416. total = 0
  417. hours, remainder = divmod(total, 3600)
  418. minutes, seconds = divmod(remainder, 60)
  419. parts: list[str] = []
  420. if hours > 0:
  421. parts.append(f"{hours}h")
  422. if hours > 0 or minutes > 0:
  423. parts.append(f"{minutes}m")
  424. parts.append(f"{seconds}s")
  425. return " ".join(parts)
  426. def _attach_survival_duration_fields(payload: dict[str, object]) -> dict[str, object]:
  427. result = dict(payload)
  428. members = result.get("members")
  429. if isinstance(members, list):
  430. enriched_members: list[dict[str, object]] = []
  431. for item in members:
  432. if not isinstance(item, dict):
  433. continue
  434. member = dict(item)
  435. survival_text = _format_duration_hms(member.get("survival_seconds"))
  436. if survival_text is not None:
  437. member["survival_text"] = survival_text
  438. enriched_members.append(member)
  439. result["members"] = enriched_members
  440. changes = result.get("changes")
  441. if isinstance(changes, list):
  442. enriched_changes: list[dict[str, object]] = []
  443. for item in changes:
  444. if not isinstance(item, dict):
  445. continue
  446. change = dict(item)
  447. survival_text = _format_duration_hms(change.get("survival_seconds"))
  448. if survival_text is not None:
  449. change["survival_text"] = survival_text
  450. enriched_changes.append(change)
  451. result["changes"] = enriched_changes
  452. return result
  453. def _latest_fresh_unauthorized_state(state_dir: Path) -> dict[str, object]:
  454. candidates = sorted(
  455. state_dir.glob("track_new8_unauthorized*.json"),
  456. key=lambda path: path.stat().st_mtime,
  457. reverse=True,
  458. )
  459. for path in candidates:
  460. try:
  461. payload = json.loads(path.read_text(encoding="utf-8"))
  462. except Exception:
  463. continue
  464. if isinstance(payload, dict):
  465. payload = dict(payload)
  466. payload["path"] = str(path)
  467. payload["updated_at_iso"] = datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds")
  468. return payload
  469. return {}
  470. def _fresh_unauthorized_experiment_payload(settings: AppSettings) -> dict[str, object]:
  471. payload = _latest_fresh_unauthorized_state(settings.state_dir)
  472. if not payload:
  473. return {
  474. "available": False,
  475. "path": "",
  476. "summary": {
  477. "tracked": 0,
  478. "first_401_count": 0,
  479. "completed": 0,
  480. "pending": 0,
  481. },
  482. "members": [],
  483. }
  484. members_raw = payload.get("members")
  485. enriched_members: list[dict[str, object]] = []
  486. first_401_count = 0
  487. completed = 0
  488. if isinstance(members_raw, list):
  489. for item in members_raw:
  490. if not isinstance(item, dict):
  491. continue
  492. member = dict(item)
  493. first_401_text = _format_duration_hms(member.get("first_401_seconds"))
  494. if first_401_text is not None:
  495. member["first_401_text"] = first_401_text
  496. if str(member.get("first_401_at") or "").strip():
  497. first_401_count += 1
  498. completed += 1
  499. enriched_members.append(member)
  500. payload["members"] = enriched_members
  501. payload["available"] = True
  502. payload["summary"] = {
  503. "tracked": len(enriched_members),
  504. "first_401_count": first_401_count,
  505. "completed": completed,
  506. "pending": max(0, len(enriched_members) - completed),
  507. }
  508. return payload
  509. def _derive_survival_promotion_stats(payload: dict[str, object]) -> dict[str, int]:
  510. members = payload.get("members")
  511. if not isinstance(members, list):
  512. return {
  513. "promoted_success_total": 0,
  514. "promoted_failure_total": 0,
  515. }
  516. success_total = 0
  517. failure_total = 0
  518. for item in members:
  519. if not isinstance(item, dict):
  520. continue
  521. path_raw = str(item.get("path") or "").strip()
  522. if not path_raw:
  523. continue
  524. try:
  525. record = json.loads(Path(path_raw).read_text(encoding="utf-8"))
  526. except Exception:
  527. continue
  528. if not isinstance(record, dict) or not bool(record.get("warmup_required")):
  529. continue
  530. status = str(record.get("cpa_sync_status") or "").strip().lower()
  531. if status == "synced":
  532. success_total += 1
  533. elif status == "failed":
  534. failure_total += 1
  535. return {
  536. "promoted_success_total": success_total,
  537. "promoted_failure_total": failure_total,
  538. }
  539. def _account_survival_payload(settings: AppSettings) -> dict[str, object]:
  540. responses_state_file = Path(settings.responses_survival_state_file)
  541. responses_payload = load_responses_survival_state(responses_state_file)
  542. if responses_payload:
  543. payload = _attach_survival_duration_fields(dict(responses_payload))
  544. payload["promotion_stats"] = _derive_survival_promotion_stats(payload)
  545. payload["fresh_unauthorized_experiment"] = _fresh_unauthorized_experiment_payload(settings)
  546. payload["enabled"] = settings.account_survival_enabled
  547. payload["available"] = True
  548. payload["path"] = str(responses_state_file)
  549. payload.setdefault("probe_mode", "responses")
  550. return payload
  551. state_file = Path(settings.account_survival_state_file)
  552. payload = load_account_survival_state(state_file)
  553. if not payload:
  554. return {
  555. "enabled": settings.account_survival_enabled,
  556. "available": False,
  557. "path": str(state_file),
  558. "error": "account survival state file not found",
  559. }
  560. payload = _attach_survival_duration_fields(dict(payload))
  561. payload["fresh_unauthorized_experiment"] = _fresh_unauthorized_experiment_payload(settings)
  562. payload["enabled"] = settings.account_survival_enabled
  563. payload["available"] = True
  564. payload["path"] = str(state_file)
  565. return payload
  566. def _responses_survival_promotion_stats(settings: AppSettings) -> dict[str, int]:
  567. payload = load_responses_survival_state(Path(settings.responses_survival_state_file))
  568. stats = payload.get("promotion_stats") if isinstance(payload, dict) else None
  569. if not isinstance(stats, dict):
  570. return {
  571. "promoted_success_total": 0,
  572. "promoted_failure_total": 0,
  573. }
  574. return {
  575. "promoted_success_total": int(stats.get("promoted_success_total") or 0),
  576. "promoted_failure_total": int(stats.get("promoted_failure_total") or 0),
  577. }
  578. def _parse_runtime_timestamp(value: object) -> datetime | None:
  579. raw = str(value or "").strip()
  580. if not raw:
  581. return None
  582. try:
  583. parsed = datetime.fromisoformat(raw)
  584. except Exception:
  585. return None
  586. if parsed.tzinfo is None:
  587. return parsed.astimezone()
  588. return parsed
  589. def _count_runtime_warmup_promotions(settings: AppSettings, *, runtime_started_at: object) -> int:
  590. started_at = _parse_runtime_timestamp(runtime_started_at)
  591. if started_at is None or not settings.pool_dir.is_dir():
  592. return 0
  593. total = 0
  594. for path in settings.pool_dir.iterdir():
  595. if not path.is_file() or path.suffix != ".json":
  596. continue
  597. try:
  598. payload = json.loads(path.read_text(encoding="utf-8"))
  599. except Exception:
  600. continue
  601. if not isinstance(payload, dict):
  602. continue
  603. if not bool(payload.get("warmup_required")):
  604. continue
  605. if str(payload.get("cpa_sync_status") or "").strip().lower() != "synced":
  606. continue
  607. created_at = _parse_runtime_timestamp(payload.get("created_at"))
  608. if created_at is None:
  609. try:
  610. created_at = datetime.fromtimestamp(path.stat().st_mtime).astimezone()
  611. except Exception:
  612. continue
  613. if created_at >= started_at:
  614. total += 1
  615. return total
  616. def _count_runtime_current_warmup_backlog(settings: AppSettings, *, runtime_started_at: object) -> int:
  617. started_at = _parse_runtime_timestamp(runtime_started_at)
  618. if started_at is None or not settings.pool_dir.is_dir():
  619. return 0
  620. total = 0
  621. for path in settings.pool_dir.iterdir():
  622. if not path.is_file() or path.suffix != ".json":
  623. continue
  624. try:
  625. payload = json.loads(path.read_text(encoding="utf-8"))
  626. except Exception:
  627. continue
  628. if not isinstance(payload, dict):
  629. continue
  630. if str(payload.get("cpa_sync_status") or "").strip().lower() != "warmup_pending":
  631. continue
  632. created_at = _parse_runtime_timestamp(payload.get("created_at"))
  633. if created_at is None:
  634. try:
  635. created_at = datetime.fromtimestamp(path.stat().st_mtime).astimezone()
  636. except Exception:
  637. continue
  638. if created_at >= started_at:
  639. total += 1
  640. return total
  641. def _apply_warmup_promotion_metrics(task_snapshots: list[dict[str, object]], settings: AppSettings) -> list[dict[str, object]]:
  642. updated_snapshots: list[dict[str, object]] = []
  643. for snapshot in task_snapshots:
  644. if not isinstance(snapshot, dict) or snapshot.get("name") != "register":
  645. updated_snapshots.append(snapshot)
  646. continue
  647. current = dict(snapshot)
  648. promoted_success_total = _count_runtime_warmup_promotions(
  649. settings,
  650. runtime_started_at=current.get("last_started_at"),
  651. )
  652. total_attempts = int(current.get("total_attempts") or 0)
  653. direct_success_total = int(current.get("total_success_direct") or current.get("total_success_registered") or current.get("total_success") or 0)
  654. direct_cpa_sync_total = int(current.get("total_cpa_sync_success_direct") or current.get("total_cpa_sync_success") or 0)
  655. effective_success_total = direct_success_total + promoted_success_total
  656. effective_cpa_sync_total = direct_cpa_sync_total + promoted_success_total
  657. threads_total = int(current.get("threads_total") or 0)
  658. retry_sidecar_threads = 1 if threads_total > 0 and isinstance(current.get("pending_token_queue"), dict) else 0
  659. register_worker_threads = max(0, threads_total - retry_sidecar_threads)
  660. current_warmup_backlog = _count_runtime_current_warmup_backlog(
  661. settings,
  662. runtime_started_at=current.get("last_started_at"),
  663. )
  664. current["total_success_direct"] = direct_success_total
  665. current["total_success_promoted"] = promoted_success_total
  666. current["total_success"] = effective_success_total
  667. current["total_success_registered"] = effective_success_total
  668. current["total_cpa_sync_success_direct"] = direct_cpa_sync_total
  669. current["total_cpa_sync_success"] = effective_cpa_sync_total
  670. current["register_worker_threads"] = register_worker_threads
  671. current["retry_sidecar_threads"] = retry_sidecar_threads
  672. current["current_warmup_backlog"] = current_warmup_backlog
  673. current["success_rate"] = round(effective_success_total / max(total_attempts, 1) * 100, 1) if total_attempts > 0 else 0.0
  674. current["registered_success_rate"] = round(effective_success_total / max(total_attempts, 1) * 100, 1) if total_attempts > 0 else 0.0
  675. current["cpa_sync_success_rate"] = round(effective_cpa_sync_total / max(total_attempts, 1) * 100, 1) if total_attempts > 0 else 0.0
  676. updated_snapshots.append(current)
  677. return updated_snapshots
  678. def _task_snapshots(background_tasks: list[RepeatedTask], registration_loop: RegistrationLoop | None = None) -> list[dict[str, object]]:
  679. snapshots = [task.snapshot() for task in background_tasks]
  680. if registration_loop:
  681. snapshots.append(registration_loop.snapshot())
  682. return snapshots
  683. def _external_runtime_state(settings: AppSettings) -> dict[str, object] | None:
  684. state_file = Path(settings.runtime_state_file)
  685. if not state_file.is_file():
  686. return None
  687. try:
  688. payload = json.loads(state_file.read_text(encoding="utf-8"))
  689. except Exception:
  690. return None
  691. if not isinstance(payload, dict):
  692. return None
  693. return payload
  694. def _proxy_pool_payload(
  695. settings: AppSettings,
  696. registration_loop: RegistrationLoop | None = None,
  697. ) -> dict[str, object]:
  698. pool = getattr(registration_loop, "_proxy_pool", None) if registration_loop is not None else None
  699. if pool is None:
  700. external = _external_runtime_state(settings)
  701. proxy_pool = external.get("proxy_pool") if isinstance(external, dict) else None
  702. if isinstance(proxy_pool, dict):
  703. return proxy_pool
  704. nodes: list[dict[str, object]] = []
  705. snapshot_error: str | None = None
  706. if pool is not None:
  707. try:
  708. snapshot = pool.snapshot()
  709. except Exception as exc:
  710. snapshot_error = str(exc)
  711. else:
  712. if isinstance(snapshot, list):
  713. nodes = [item for item in snapshot if isinstance(item, dict)]
  714. return {
  715. "configured": bool(settings.proxy_pool_configured or pool is not None),
  716. "enabled": pool is not None,
  717. "snapshot_error": snapshot_error,
  718. "node_count": len(nodes),
  719. "in_use_count": sum(1 for item in nodes if item.get("in_use")),
  720. "disabled_count": sum(1 for item in nodes if item.get("disabled")),
  721. "nodes": nodes,
  722. }
  723. def _runtime_payload(app: FastAPI) -> dict[str, object]:
  724. runtime_settings: AppSettings = app.state.settings
  725. background_tasks = getattr(app.state, "background_tasks", [])
  726. registration_loop = getattr(app.state, "registration_loop", None)
  727. task_snapshots = _task_snapshots(background_tasks, registration_loop)
  728. if registration_loop is None:
  729. external = _external_runtime_state(runtime_settings)
  730. register_snapshot = external.get("register_snapshot") if isinstance(external, dict) else None
  731. if isinstance(register_snapshot, dict):
  732. task_snapshots.append(register_snapshot)
  733. task_snapshots = _apply_warmup_promotion_metrics(task_snapshots, runtime_settings)
  734. return {
  735. "runtime_mode": runtime_settings.runtime_mode,
  736. "architecture": "single-process-fastapi" if registration_loop is not None else "split-runtime-fastapi+loop",
  737. "cleanup_enabled": runtime_settings.cleanup_enabled,
  738. "validate_enabled": runtime_settings.validate_enabled,
  739. "cleanup_interval": runtime_settings.cleanup_interval,
  740. "validate_interval": runtime_settings.validate_interval,
  741. "validate_scope": runtime_settings.validate_scope,
  742. "pool_dir": str(runtime_settings.pool_dir),
  743. "pool_count": _count_pool_files(runtime_settings.pool_dir),
  744. "backend": runtime_settings.backend,
  745. "cpa_management_base_url": runtime_settings.cpa_management_base_url,
  746. "account_survival_enabled": runtime_settings.account_survival_enabled,
  747. "account_survival_interval": runtime_settings.account_survival_interval,
  748. "account_survival_cohort_size": runtime_settings.account_survival_cohort_size,
  749. "account_survival_state_file": str(runtime_settings.account_survival_state_file),
  750. "rotate_enabled": runtime_settings.rotate_enabled,
  751. "rotate_interval": runtime_settings.rotate_interval,
  752. "rotate_fresh_grace_seconds": runtime_settings.rotate_fresh_grace_seconds,
  753. "register_fresh_proxy_regions": list(runtime_settings.register_fresh_proxy_regions),
  754. "responses_survival_recent_window_seconds": runtime_settings.responses_survival_recent_window_seconds,
  755. "responses_survival_require_provenance": runtime_settings.responses_survival_require_provenance,
  756. "warmup_min_age_seconds": runtime_settings.warmup_min_age_seconds,
  757. "warmup_min_successful_probes": runtime_settings.warmup_min_successful_probes,
  758. "registered_tasks": [task["name"] for task in task_snapshots],
  759. "task_states": task_snapshots,
  760. "proxy_pool": _proxy_pool_payload(runtime_settings, registration_loop),
  761. }
  762. def _register_burst_plan_payload(settings: AppSettings) -> dict[str, object]:
  763. interval_seconds = max(60, int(settings.register_batch_interval_seconds))
  764. target_count = max(1, int(settings.register_batch_target_count))
  765. batches_per_day = max(1, math.floor(86400 / interval_seconds))
  766. accounts_per_day = target_count * batches_per_day
  767. return {
  768. "mode": "burst",
  769. "threads": max(1, int(settings.register_batch_threads)),
  770. "target_count": target_count,
  771. "interval_seconds": interval_seconds,
  772. "accounts_per_day": accounts_per_day,
  773. "accounts_needed_for_one_day_target": target_count,
  774. "accounts_needed_for_sustained_daily_target": max(accounts_per_day - target_count, 0),
  775. }
  776. def _summary_payload(app: FastAPI) -> dict[str, object]:
  777. runtime = _runtime_payload(app)
  778. settings: AppSettings = app.state.settings
  779. overview = _dashboard_overview_payload(app)
  780. register_task = next((task for task in runtime["task_states"] if task.get("name") == "register"), {})
  781. rotate_task = next((task for task in runtime["task_states"] if task.get("name") == "rotate"), {})
  782. account_survival = _account_survival_payload(settings)
  783. rotate_log_tail = _compat_main_attr("_rotate_log_tail", _rotate_log_tail)()
  784. return {
  785. "project": "zhuce6",
  786. "generated_at": overview["generated_at"],
  787. "runtime": runtime,
  788. "platforms": list_platforms(),
  789. "pool_count": overview["pool_count"],
  790. "cpa_count": overview["cpa_count"],
  791. "cpa_inventory": overview["cpa_inventory"],
  792. "regular_accounts": overview["regular_accounts"],
  793. "tokens": overview["tokens"],
  794. "today_new": overview["today_new"],
  795. "success_rate": overview["success_rate"],
  796. "registered_success_total": overview["registered_success_total"],
  797. "cpa_sync_success_total": overview["cpa_sync_success_total"],
  798. "cpa_sync_failure_total": overview["cpa_sync_failure_total"],
  799. "registered_success_rate": overview["registered_success_rate"],
  800. "cpa_sync_success_rate": overview["cpa_sync_success_rate"],
  801. "burn_rate": overview["burn_rate"],
  802. "observed_loss": overview["observed_loss"],
  803. "register_failure_by_stage": register_task.get("failure_by_stage") or {},
  804. "register_failure_signals": register_task.get("failure_signals") or {},
  805. "register_recent_failure_hotspots": register_task.get("recent_failure_hotspots") or [],
  806. "register_recent_attempts": register_task.get("recent_attempts") or [],
  807. "register_cfmail_domain_pool": register_task.get("cfmail_domain_pool") or {},
  808. "register_cfmail_add_phone_stoploss": register_task.get("cfmail_add_phone_stoploss") or {},
  809. "register_cfmail_wait_otp_stoploss": register_task.get("cfmail_wait_otp_stoploss") or {},
  810. "register_burst_plan": _register_burst_plan_payload(settings),
  811. "rotate_task": rotate_task,
  812. "rotate_log_tail": rotate_log_tail,
  813. "rotate_latest_summary": rotate_log_tail.get("latest_summary"),
  814. "rotate_current_summary": rotate_log_tail.get("current_summary"),
  815. "account_survival": account_survival,
  816. "runtime_state_file": _runtime_state_file_meta(settings),
  817. "recent_pool_files": _recent_pool_files(Path(str(runtime["pool_dir"]))),
  818. "register_log_tail": _register_log_tail(settings),
  819. "routes": {
  820. "healthz": "/healthz",
  821. "platforms": "/api/platforms",
  822. "runtime": "/api/runtime",
  823. "summary": "/api/summary",
  824. "settings": "/api/settings",
  825. "health_dependencies": "/api/health/dependencies",
  826. "register_control": "/api/control/register",
  827. "account_survival": "/api/account-survival",
  828. "chatgpt_preflight": "/api/register/chatgpt/preflight",
  829. "chatgpt_register_once": "/api/register/chatgpt/run",
  830. "chatgpt_callback_exchange": "/api/register/chatgpt/callback-exchange",
  831. "zhuce6": "/zhuce6",
  832. },
  833. "commands": {
  834. "start": "uv run python main.py --mode full",
  835. "chatgpt_preflight": "uv run python scripts/chatgpt_preflight.py --json",
  836. "chatgpt_register_once": "uv run python scripts/chatgpt_register_once.py --json --mail-provider cfmail",
  837. "chatgpt_callback_exchange": "uv run python scripts/chatgpt_exchange_callback.py --json --callback-url '<url>' --state '<state>' --code-verifier '<verifier>'",
  838. "cleanup_once": "uv run python -m ops.cleanup --once",
  839. "validate_used_dry_run": "uv run python -m ops.validate --scope used --dry-run --once",
  840. "validate_all_dry_run": "uv run python -m ops.validate --scope all --dry-run --once --limit 20",
  841. "scan_local_pool": "uv run python -m ops.scan --limit 20",
  842. "update_priority_dry_run": "uv run python -m ops.update_priority --dry-run --limit 20",
  843. },
  844. "manual_test": [
  845. "Start the service and visit /zhuce6.",
  846. "Run scripts/chatgpt_preflight.py with a working mailbox provider and network.",
  847. "Run scripts/chatgpt_register_once.py with a working mailbox provider, proxy, and upstream availability if you want a full attempt.",
  848. "Complete the OAuth login in a browser, then run scripts/chatgpt_exchange_callback.py to write a pool file.",
  849. "Run ops.cleanup / ops.validate only when backend API is reachable.",
  850. "Live CPA invalid account cleanup remains manual_test and should be checked via quota probe plus rotate summary.",
  851. ],
  852. }
  853. def _cpa_management_root(settings: AppSettings) -> str:
  854. parsed = urlsplit(settings.cpa_management_base_url)
  855. path = parsed.path or ""
  856. suffix = "/v0/management"
  857. if path.endswith(suffix):
  858. path = path[: -len(suffix)]
  859. return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")).rstrip("/")
  860. def _settings_payload(app: FastAPI) -> dict[str, object]:
  861. settings: AppSettings = app.state.settings
  862. registration_loop = getattr(app.state, "registration_loop", None)
  863. missing_cfmail = settings.validate_cfmail_env()
  864. return {
  865. "mode": settings.runtime_mode,
  866. "register": {
  867. "enabled": bool(registration_loop is not None or settings.register_enabled),
  868. "threads": settings.register_threads,
  869. "batch_target_count": settings.register_batch_target_count,
  870. "batch_interval_seconds": settings.register_batch_interval_seconds,
  871. "mail_provider": settings.register_mail_provider,
  872. "proxy": settings.register_proxy,
  873. "fresh_proxy_regions": ",".join(settings.register_fresh_proxy_regions),
  874. },
  875. "proxy_pool": {
  876. "enabled": settings.enable_proxy_pool,
  877. "size": settings.proxy_pool_size,
  878. "config_path": str(settings.proxy_pool_config) if settings.proxy_pool_config else "",
  879. "direct_urls": settings.proxy_pool_direct_urls,
  880. "regions": ",".join(settings.proxy_pool_regions),
  881. },
  882. "cfmail": {
  883. "configured": len(missing_cfmail) == 0,
  884. "zone_name": str(os.getenv("ZHUCE6_CFMAIL_ZONE_NAME", "")).strip(),
  885. "worker_name": str(os.getenv("ZHUCE6_CFMAIL_WORKER_NAME", "")).strip(),
  886. "rotation_window": settings.cfmail_rotation_window,
  887. "rotation_blacklist_threshold": settings.cfmail_rotation_blacklist_threshold,
  888. },
  889. "cpa": {
  890. "configured": settings.runtime_mode != "lite" and settings.backend == "cpa",
  891. "backend": settings.backend,
  892. "management_url": _cpa_management_root(settings),
  893. "rotate_enabled": settings.rotate_enabled,
  894. "rotate_interval": settings.rotate_interval,
  895. "rotate_fresh_grace_seconds": settings.rotate_fresh_grace_seconds,
  896. },
  897. "survival": {
  898. "recent_window_seconds": settings.responses_survival_recent_window_seconds,
  899. "require_provenance": settings.responses_survival_require_provenance,
  900. "warmup_min_age_seconds": settings.warmup_min_age_seconds,
  901. "warmup_min_successful_probes": settings.warmup_min_successful_probes,
  902. },
  903. }
  904. def _encode_env_value(value: object) -> str:
  905. text = "" if value is None else str(value)
  906. if not text:
  907. return ""
  908. if any(ch.isspace() for ch in text) or "#" in text:
  909. return json.dumps(text)
  910. return text
  911. def _persist_env_updates(path: Path, updates: dict[str, object]) -> None:
  912. existing_lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
  913. normalized_updates = {key: _encode_env_value(value) for key, value in updates.items()}
  914. handled: set[str] = set()
  915. output_lines: list[str] = []
  916. for line in existing_lines:
  917. stripped = line.strip()
  918. candidate = stripped[7:] if stripped.startswith("export ") else stripped
  919. key, sep, _value = candidate.partition("=")
  920. if sep and key in normalized_updates:
  921. if key in handled:
  922. continue
  923. output_lines.append(f"{key}={normalized_updates[key]}")
  924. handled.add(key)
  925. continue
  926. output_lines.append(line)
  927. for key, value in normalized_updates.items():
  928. if key not in handled:
  929. output_lines.append(f"{key}={value}")
  930. path.parent.mkdir(parents=True, exist_ok=True)
  931. path.write_text("\n".join(output_lines).rstrip() + "\n", encoding="utf-8")
  932. def _parse_settings_patch(changes: dict[str, object]) -> tuple[dict[str, object], dict[str, object]]:
  933. updates: dict[str, object] = {}
  934. env_updates: dict[str, object] = {}
  935. def parse_regions(value: object) -> tuple[str, ...]:
  936. return tuple(part.strip().lower() for part in str(value or "").split(",") if part.strip())
  937. allowed: dict[str, tuple[str, str, object]] = {
  938. "register.threads": ("register_threads", "ZHUCE6_REGISTER_THREADS", lambda value: max(1, int(value))),
  939. "register.batch_target_count": (
  940. "register_batch_target_count",
  941. "ZHUCE6_REGISTER_BATCH_TARGET_COUNT",
  942. lambda value: max(1, int(value)),
  943. ),
  944. "register.batch_interval_seconds": (
  945. "register_batch_interval_seconds",
  946. "ZHUCE6_REGISTER_BATCH_INTERVAL_SECONDS",
  947. lambda value: max(60, int(value)),
  948. ),
  949. "register.mail_provider": (
  950. "register_mail_provider",
  951. "ZHUCE6_REGISTER_MAIL_PROVIDER",
  952. lambda value: str(value or "").strip() or "cfmail",
  953. ),
  954. "register.proxy": ("register_proxy", "ZHUCE6_REGISTER_PROXY", lambda value: str(value or "").strip()),
  955. "register.fresh_proxy_regions": (
  956. "register_fresh_proxy_regions",
  957. "ZHUCE6_REGISTER_FRESH_PROXY_REGIONS",
  958. parse_regions,
  959. ),
  960. "proxy_pool.size": ("proxy_pool_size", "ZHUCE6_PROXY_POOL_SIZE", lambda value: max(1, int(value))),
  961. "proxy_pool.direct_urls": (
  962. "proxy_pool_direct_urls",
  963. "ZHUCE6_PROXY_POOL_DIRECT_URLS",
  964. lambda value: str(value or "").strip(),
  965. ),
  966. "proxy_pool.regions": ("proxy_pool_regions", "ZHUCE6_PROXY_POOL_REGIONS", parse_regions),
  967. "cpa.rotate_interval": ("rotate_interval", "ZHUCE6_ROTATE_INTERVAL", lambda value: max(1, int(value))),
  968. "cpa.rotate_fresh_grace_seconds": (
  969. "rotate_fresh_grace_seconds",
  970. "ZHUCE6_ROTATE_FRESH_GRACE_SECONDS",
  971. lambda value: max(0, int(value)),
  972. ),
  973. "survival.recent_window_seconds": (
  974. "responses_survival_recent_window_seconds",
  975. "ZHUCE6_RESPONSES_SURVIVAL_RECENT_WINDOW_SECONDS",
  976. lambda value: max(0, int(value)),
  977. ),
  978. "survival.require_provenance": (
  979. "responses_survival_require_provenance",
  980. "ZHUCE6_RESPONSES_SURVIVAL_REQUIRE_PROVENANCE",
  981. lambda value: str(value or "").strip().lower() in {"1", "true", "yes", "on"},
  982. ),
  983. "survival.warmup_min_age_seconds": (
  984. "warmup_min_age_seconds",
  985. "ZHUCE6_WARMUP_MIN_AGE_SECONDS",
  986. lambda value: max(0, int(value)),
  987. ),
  988. "survival.warmup_min_successful_probes": (
  989. "warmup_min_successful_probes",
  990. "ZHUCE6_WARMUP_MIN_SUCCESSFUL_PROBES",
  991. lambda value: max(1, int(value)),
  992. ),
  993. }
  994. for key, value in changes.items():
  995. spec = allowed.get(key)
  996. if spec is None:
  997. raise HTTPException(status_code=400, detail=f"unsupported setting: {key}")
  998. field_name, env_name, parser = spec
  999. parsed_value = parser(value)
  1000. updates[field_name] = parsed_value
  1001. if isinstance(parsed_value, tuple):
  1002. env_updates[env_name] = ",".join(str(item) for item in parsed_value)
  1003. else:
  1004. env_updates[env_name] = parsed_value
  1005. return updates, env_updates
  1006. def _cfmail_dependency_payload(settings: AppSettings) -> dict[str, object]:
  1007. if "cfmail" not in {part.strip() for part in settings.register_mail_provider.split(",") if part.strip()}:
  1008. return {"status": "unconfigured", "detail": "register_mail_provider_not_cfmail"}
  1009. missing = settings.validate_cfmail_env()
  1010. if missing:
  1011. return {"status": "unconfigured", "detail": f"missing: {', '.join(missing)}"}
  1012. return {"status": "ok", "detail": "configuration_present"}
  1013. def _proxy_pool_dependency_payload(app: FastAPI) -> dict[str, object]:
  1014. settings: AppSettings = app.state.settings
  1015. if not settings.enable_proxy_pool:
  1016. return {"status": "unconfigured", "detail": "proxy_pool_disabled", "active_nodes": 0, "total_nodes": 0}
  1017. if not settings.proxy_pool_configured:
  1018. return {"status": "unconfigured", "detail": "proxy_pool_not_configured", "active_nodes": 0, "total_nodes": 0}
  1019. proxy_pool = _proxy_pool_payload(settings, getattr(app.state, "registration_loop", None))
  1020. total_nodes = int(proxy_pool.get("node_count") or 0)
  1021. active_nodes = max(0, total_nodes - int(proxy_pool.get("disabled_count") or 0))
  1022. snapshot_error = str(proxy_pool.get("snapshot_error") or "").strip()
  1023. if snapshot_error:
  1024. return {
  1025. "status": "error",
  1026. "detail": snapshot_error,
  1027. "active_nodes": active_nodes,
  1028. "total_nodes": total_nodes,
  1029. }
  1030. return {
  1031. "status": "ok" if total_nodes > 0 else "error",
  1032. "detail": "ok" if total_nodes > 0 else "no_proxy_nodes",
  1033. "active_nodes": active_nodes,
  1034. "total_nodes": total_nodes,
  1035. }
  1036. def _cpa_dependency_payload(settings: AppSettings) -> dict[str, object]:
  1037. if settings.runtime_mode == "lite":
  1038. return {
  1039. "status": "unconfigured",
  1040. "management_reachable": False,
  1041. }
  1042. if settings.backend == "sub2api":
  1043. return {
  1044. "status": "unconfigured",
  1045. "management_reachable": False,
  1046. }
  1047. management_reachable = False
  1048. try:
  1049. management_reachable = CpaClient.from_settings(settings).health_check()
  1050. except Exception:
  1051. management_reachable = False
  1052. return {
  1053. "status": "ok" if management_reachable else "error",
  1054. "management_reachable": management_reachable,
  1055. }
  1056. def _sub2api_dependency_payload(settings: AppSettings) -> dict[str, object]:
  1057. if settings.runtime_mode == "lite":
  1058. return {"status": "unconfigured", "error": "lite_mode", "auth_configured": False}
  1059. if settings.backend != "sub2api":
  1060. return {"status": "unconfigured", "error": "backend_cpa", "auth_configured": False}
  1061. auth_configured = bool(settings.sub2api_api_key or (settings.sub2api_admin_email and settings.sub2api_admin_password))
  1062. if not auth_configured:
  1063. return {"status": "error", "error": "missing_auth", "auth_configured": False}
  1064. reachable = False
  1065. try:
  1066. reachable = bool(create_backend_client(settings).health_check())
  1067. except Exception:
  1068. reachable = False
  1069. return {
  1070. "status": "ok" if reachable else "error",
  1071. "error": None if reachable else "unreachable",
  1072. "auth_configured": True,
  1073. "base_url": settings.sub2api_base_url,
  1074. }
  1075. build_background_tasks = _build_background_tasks
  1076. count_pool_files = _count_pool_files
  1077. count_cpa_files = _count_cpa_files
  1078. fetch_management_auth_files = _fetch_management_auth_files
  1079. is_regular_free_account = _is_regular_free_account
  1080. classify_regular_account_status = _classify_regular_account_status
  1081. classify_regular_accounts = _classify_regular_accounts
  1082. estimate_tokens = _estimate_tokens
  1083. count_today_new = _count_today_new
  1084. dashboard_overview_payload = _dashboard_overview_payload
  1085. recent_pool_files = _recent_pool_files
  1086. register_log_tail = _register_log_tail
  1087. runtime_state_file_meta = _runtime_state_file_meta
  1088. account_survival_payload = _account_survival_payload
  1089. task_snapshots = _task_snapshots
  1090. external_runtime_state = _external_runtime_state
  1091. proxy_pool_payload = _proxy_pool_payload
  1092. runtime_payload = _runtime_payload
  1093. register_burst_plan_payload = _register_burst_plan_payload
  1094. summary_payload = _summary_payload
  1095. settings_payload = _settings_payload
  1096. cpa_management_root = _cpa_management_root
  1097. encode_env_value = _encode_env_value
  1098. persist_env_updates = _persist_env_updates
  1099. parse_settings_patch = _parse_settings_patch
  1100. cfmail_dependency_payload = _cfmail_dependency_payload
  1101. proxy_pool_dependency_payload = _proxy_pool_dependency_payload
  1102. cpa_dependency_payload = _cpa_dependency_payload
  1103. sub2api_dependency_payload = _sub2api_dependency_payload