server.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822
  1. """本地 Web 控制台:SSO 注册、账号库与任务 API。"""
  2. from __future__ import annotations
  3. import json
  4. import queue
  5. import threading
  6. import time
  7. from dataclasses import asdict
  8. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  9. from pathlib import Path
  10. from chatgpt_flow import FullRunContext, run_sso_batch
  11. from config import AppConfig
  12. from cpa_uploader import build_cpa_auth_payload
  13. from recheck import recheck_account
  14. from storage import (
  15. count_accounts,
  16. create_task,
  17. get_account,
  18. get_task,
  19. init_db,
  20. list_accounts,
  21. list_events,
  22. list_tasks,
  23. )
  24. from task_runner import get_runner, make_task_id
  25. HOST = "127.0.0.1" # main() 会读 cfg.api_host 覆盖
  26. PORT = 7791
  27. BASE_DIR = Path(__file__).resolve().parent
  28. UI_DIR = BASE_DIR / "ui"
  29. STATIC_TYPES = {
  30. ".css": "text/css; charset=utf-8",
  31. ".js": "application/javascript; charset=utf-8",
  32. ".html": "text/html; charset=utf-8",
  33. }
  34. class JobManager:
  35. def __init__(self):
  36. self.lock = threading.Lock()
  37. self.full_ctx: FullRunContext | None = None
  38. self.thread: threading.Thread | None = None
  39. self.log_queue: queue.Queue[str] = queue.Queue()
  40. self.history: list[str] = []
  41. self.stage: str = ""
  42. def _log(self, msg: str):
  43. line = f"[{time.strftime('%H:%M:%S')}] {msg}"
  44. self.history.append(line)
  45. if len(self.history) > 4000:
  46. self.history = self.history[-3000:]
  47. self.log_queue.put(line)
  48. def _on_stage(self, name: str):
  49. self.stage = name
  50. # stage 也写到日志,便于复盘
  51. self._log(f"[STAGE] {name}")
  52. def start_sso(self, account_count: int, sso_mail_domain: str,
  53. cpa_url: str, cpa_management_key: str,
  54. headless: bool, proxy_url: str) -> str:
  55. with self.lock:
  56. if self.thread and self.thread.is_alive():
  57. return "已有任务在运行"
  58. self.history.clear()
  59. while not self.log_queue.empty():
  60. self.log_queue.get_nowait()
  61. self.stage = ""
  62. def runner():
  63. try:
  64. self.full_ctx = run_sso_batch(
  65. account_count=account_count,
  66. sso_mail_domain=sso_mail_domain,
  67. cpa_url=cpa_url,
  68. cpa_management_key=cpa_management_key,
  69. headless=headless,
  70. proxy_url=proxy_url,
  71. log=self._log,
  72. on_stage=self._on_stage,
  73. )
  74. except Exception as exc:
  75. import traceback
  76. self._log(f"[server] SSO 任务异常: {exc!r}")
  77. self._log(traceback.format_exc())
  78. self.thread = threading.Thread(target=runner, daemon=True)
  79. self.thread.start()
  80. return ""
  81. def stop(self):
  82. if self.full_ctx:
  83. self.full_ctx.state = "stopped"
  84. self._log("[user] 已请求停止")
  85. def status(self) -> dict:
  86. running = bool(self.thread and self.thread.is_alive())
  87. ctx = self.full_ctx
  88. accounts = []
  89. state = "idle"
  90. if ctx:
  91. state = ctx.state
  92. for a in ctx.accounts:
  93. accounts.append({
  94. "email": a.get("email"),
  95. "stage": a.get("stage"),
  96. "planType": a.get("planType"),
  97. "error": a.get("error"),
  98. "cpaFile": (a.get("cpa") or {}).get("fileName") if a.get("cpa") else None,
  99. })
  100. return {
  101. "running": running,
  102. "state": state,
  103. "stage": self.stage,
  104. "accounts": accounts,
  105. }
  106. JOB = JobManager()
  107. def _read_json(handler) -> dict:
  108. length = int(handler.headers.get("content-length") or "0")
  109. if length <= 0:
  110. return {}
  111. raw = handler.rfile.read(length).decode("utf-8", errors="replace")
  112. return json.loads(raw or "{}")
  113. class Handler(BaseHTTPRequestHandler):
  114. def do_GET(self):
  115. path = self.path.split("?", 1)[0]
  116. query = self.path.split("?", 1)[1] if "?" in self.path else ""
  117. if path in ("/", "/index.html"):
  118. self._send_static_file(UI_DIR / "index.html")
  119. return
  120. if path.startswith("/static/"):
  121. name = path[len("/static/"):]
  122. if "/" in name or "\\" in name or not name:
  123. self._send_json(404, {"error": "not found"})
  124. return
  125. self._send_static_file(UI_DIR / name)
  126. return
  127. if path in ("/docs", "/docs/"):
  128. self._send(200, SWAGGER_HTML.encode("utf-8"), "text/html; charset=utf-8")
  129. return
  130. if path == "/openapi.json":
  131. self._send_json(200, _build_openapi_spec())
  132. return
  133. if not self._check_auth():
  134. return
  135. if path == "/api/status":
  136. self._send_json(200, JOB.status())
  137. return
  138. if path == "/api/config":
  139. self._send_json(200, asdict(AppConfig.load()))
  140. return
  141. if path == "/api/log":
  142. self._stream_log()
  143. return
  144. # ===== 任务化 API(GET)=====
  145. if path == "/api/tasks":
  146. from urllib.parse import parse_qs
  147. q = parse_qs(query)
  148. status = (q.get("status") or [""])[0] or None
  149. limit = int((q.get("limit") or ["100"])[0])
  150. try:
  151. tasks = list_tasks(limit=limit, status=status)
  152. self._send_json(200, {"tasks": tasks})
  153. except Exception as exc:
  154. self._send_json(500, {"error": str(exc)})
  155. return
  156. if path.startswith("/api/tasks/"):
  157. from urllib.parse import unquote
  158. task_id = unquote(path[len("/api/tasks/"):])
  159. t = get_task(task_id)
  160. if not t:
  161. self._send_json(404, {"error": "task not found"})
  162. return
  163. self._send_json(200, {"task": t})
  164. return
  165. if path == "/api/accounts":
  166. try:
  167. from urllib.parse import parse_qs
  168. q = parse_qs(query)
  169. status = (q.get("status") or [""])[0] or None
  170. page_num = max(1, int((q.get("page") or ["1"])[0]))
  171. page_size = max(1, min(100, int((q.get("pageSize") or ["20"])[0])))
  172. offset = (page_num - 1) * page_size
  173. total = count_accounts(status=status)
  174. accounts = list_accounts(limit=page_size, status=status, offset=offset)
  175. slim = []
  176. for a in accounts:
  177. slim.append({k: a.get(k) for k in (
  178. "email", "plan_type", "final_status", "cpa_file_name",
  179. "long_link", "last_error", "created_at", "updated_at",
  180. "cpa_uploaded_at", "trial_eligible", "trial_state",
  181. "is_trial_account", "can_retry_payment"
  182. )})
  183. self._send_json(200, {"accounts": slim, "total": total, "page": page_num, "pageSize": page_size})
  184. except Exception as exc:
  185. self._send_json(500, {"error": str(exc)})
  186. return
  187. if path.startswith("/api/account/") and path.endswith("/cpa.json"):
  188. from urllib.parse import unquote
  189. email = unquote(path[len("/api/account/"):-len("/cpa.json")])
  190. acc = get_account(email)
  191. if not acc:
  192. self._send_json(404, {"error": "account not found"})
  193. return
  194. session = acc.get("plus_session") or acc.get("initial_session")
  195. if not session:
  196. self._send_json(404, {"error": "该账号没有可下载的 session"})
  197. return
  198. try:
  199. payload = build_cpa_auth_payload(session, email_hint=email)
  200. except Exception as exc:
  201. self._send_json(500, {"error": f"构造 CPA auth JSON 失败: {exc}"})
  202. return
  203. file_name = acc.get("cpa_file_name") or payload["fileName"]
  204. content = json.dumps(payload["authJson"], ensure_ascii=False, indent=2).encode("utf-8")
  205. self.send_response(200)
  206. self.send_header("Content-Type", "application/json; charset=utf-8")
  207. self.send_header("Content-Disposition", f'attachment; filename="{file_name}"')
  208. self.send_header("Cache-Control", "no-store")
  209. self.send_header("Content-Length", str(len(content)))
  210. self.end_headers()
  211. self.wfile.write(content)
  212. return
  213. if path.startswith("/api/account/"):
  214. from urllib.parse import unquote
  215. email = unquote(path[len("/api/account/"):])
  216. acc = get_account(email)
  217. if not acc:
  218. self._send_json(404, {"error": "account not found"})
  219. return
  220. events = list_events(email, limit=200)
  221. self._send_json(200, {"account": acc, "events": events})
  222. return
  223. self._send_json(404, {"error": "not found"})
  224. def do_POST(self):
  225. path = self.path.split("?", 1)[0]
  226. if not self._check_auth():
  227. return
  228. if path == "/api/config":
  229. try:
  230. body = _read_json(self)
  231. cfg = AppConfig.load().update(body or {})
  232. self._send_json(200, asdict(cfg))
  233. except Exception as exc:
  234. self._send_json(500, {"error": str(exc)})
  235. return
  236. # ===== 任务化 API =====
  237. if path == "/api/tasks":
  238. try:
  239. body = _read_json(self) or {}
  240. mode = (body.get("mode") or "full").strip().lower()
  241. if mode == "full":
  242. self._send_json(410, {"error": "full 全自动注册任务已停用,请使用 SSO 注册入口"})
  243. return
  244. if mode != "pay_only":
  245. self._send_json(400, {"error": "mode 必须是 pay_only(full 已停用)"})
  246. return
  247. params = body.get("params") or {}
  248. if mode == "pay_only":
  249. sess = params.get("session")
  250. if not isinstance(sess, dict) or not sess.get("accessToken"):
  251. self._send_json(400, {"error": "pay_only 需要 params.session 是 JSON 且包含 accessToken"})
  252. return
  253. max_attempts = int(body.get("max_attempts") or 3)
  254. max_attempts = max(1, min(10, max_attempts))
  255. task_id = make_task_id()
  256. t = create_task(task_id, mode, params, max_attempts=max_attempts)
  257. # 启动 runner(幂等)
  258. get_runner(log=lambda m: JOB._log(m))
  259. self._send_json(200, {"task_id": task_id, "task": t})
  260. except Exception as exc:
  261. self._send_json(500, {"error": str(exc)})
  262. return
  263. if path.startswith("/api/tasks/") and path.endswith("/cancel"):
  264. from urllib.parse import unquote
  265. task_id = unquote(path[len("/api/tasks/"):-len("/cancel")])
  266. t = get_task(task_id)
  267. if not t:
  268. self._send_json(404, {"error": "task not found"})
  269. return
  270. runner = get_runner(log=lambda m: JOB._log(m))
  271. runner.cancel(task_id)
  272. self._send_json(200, {"ok": True, "task_id": task_id})
  273. return
  274. if path == "/api/start":
  275. self._send_json(410, {"error": "ChatGPT Plus 全自动注册已停用,请使用 SSO 注册入口"})
  276. return
  277. if path.startswith("/api/account/") and path.endswith("/recheck"):
  278. from urllib.parse import unquote
  279. email = unquote(path[len("/api/account/"):-len("/recheck")])
  280. cfg = AppConfig.load()
  281. try:
  282. result = recheck_account(
  283. email,
  284. cpa_url=cfg.cpa_url,
  285. cpa_management_key=cfg.cpa_management_key,
  286. log=lambda msg: JOB._log(f"[acc:{email[:24]}] {msg}"),
  287. )
  288. self._send_json(200, result)
  289. except Exception as exc:
  290. self._send_json(500, {"error": str(exc)})
  291. return
  292. if path.startswith("/api/account/") and path.endswith("/retry_payment"):
  293. from urllib.parse import unquote
  294. email = unquote(path[len("/api/account/"):-len("/retry_payment")])
  295. acc = get_account(email)
  296. if not acc:
  297. self._send_json(404, {"error": "账号不存在"})
  298. return
  299. if not acc.get("can_retry_payment"):
  300. self._send_json(400, {"error": "该账号当前不支持直接重新付款"})
  301. return
  302. session = acc.get("plus_session") or acc.get("initial_session")
  303. if not session or not isinstance(session, dict) or not session.get("accessToken"):
  304. self._send_json(400, {"error": "该账号没有可用的 session(缺少 accessToken)"})
  305. return
  306. try:
  307. task_id = make_task_id()
  308. t = create_task(task_id, "pay_only", {"session": session, "email": email}, max_attempts=3)
  309. get_runner(log=lambda m: JOB._log(m))
  310. self._send_json(200, {"ok": True, "task_id": task_id, "task": t})
  311. except Exception as exc:
  312. self._send_json(500, {"error": str(exc)})
  313. return
  314. if path == "/api/start-sso":
  315. try:
  316. body = _read_json(self)
  317. account_count = int(body.get("account_count") or 0)
  318. if account_count < 1:
  319. self._send_json(400, {"error": "account_count 必须 >= 1"})
  320. return
  321. cfg = AppConfig.load()
  322. err = JOB.start_sso(
  323. account_count=account_count,
  324. sso_mail_domain=str(body.get("sso_mail_domain") or cfg.sso_mail_domain or "aef.claudeai.life"),
  325. cpa_url=str(body.get("cpa_url") or cfg.cpa_url or ""),
  326. cpa_management_key=str(body.get("cpa_management_key") or cfg.cpa_management_key or ""),
  327. headless=bool(body.get("headless")) if "headless" in body else cfg.headless,
  328. proxy_url=str(body.get("proxy_url") or ""),
  329. )
  330. if err:
  331. self._send_json(409, {"error": err})
  332. else:
  333. self._send_json(200, {"ok": True})
  334. except Exception as exc:
  335. self._send_json(500, {"error": str(exc)})
  336. return
  337. if path == "/api/stop":
  338. JOB.stop()
  339. self._send_json(200, {"ok": True})
  340. return
  341. self._send_json(404, {"error": "not found"})
  342. def _stream_log(self):
  343. self.send_response(200)
  344. self.send_header("Content-Type", "text/event-stream; charset=utf-8")
  345. self.send_header("Cache-Control", "no-cache")
  346. self.send_header("Connection", "keep-alive")
  347. self.end_headers()
  348. try:
  349. for line in JOB.history[-300:]:
  350. self._sse_send(line)
  351. while True:
  352. try:
  353. line = JOB.log_queue.get(timeout=15)
  354. self._sse_send(line)
  355. except queue.Empty:
  356. self.wfile.write(b": ping\n\n")
  357. self.wfile.flush()
  358. except (BrokenPipeError, ConnectionResetError):
  359. return
  360. def _sse_send(self, line: str):
  361. for piece in line.splitlines() or [""]:
  362. self.wfile.write(b"data: " + piece.encode("utf-8") + b"\n")
  363. self.wfile.write(b"\n")
  364. self.wfile.flush()
  365. def _send_json(self, status: int, payload: dict):
  366. self._send(status, json.dumps(payload, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
  367. def _send_static_file(self, file_path: Path):
  368. try:
  369. resolved = file_path.resolve()
  370. if UI_DIR.resolve() not in resolved.parents and resolved != (UI_DIR / "index.html").resolve():
  371. self._send_json(404, {"error": "not found"})
  372. return
  373. content = resolved.read_bytes()
  374. except Exception:
  375. self._send_json(404, {"error": "not found"})
  376. return
  377. content_type = STATIC_TYPES.get(resolved.suffix.lower(), "application/octet-stream")
  378. self._send(200, content, content_type)
  379. def _send(self, status: int, content: bytes, content_type: str):
  380. self.send_response(status)
  381. self.send_header("Content-Type", content_type)
  382. self.send_header("Cache-Control", "no-store")
  383. self.send_header("Content-Length", str(len(content)))
  384. # CORS(仅 /api/* 需要时由调用方决定,但统一发也无害)
  385. try:
  386. cfg = AppConfig.load()
  387. origin = (cfg.api_cors_origin or "*").strip()
  388. self.send_header("Access-Control-Allow-Origin", origin)
  389. self.send_header("Access-Control-Allow-Credentials", "true")
  390. except Exception:
  391. self.send_header("Access-Control-Allow-Origin", "*")
  392. self.end_headers()
  393. self.wfile.write(content)
  394. def do_OPTIONS(self):
  395. # CORS preflight
  396. self.send_response(204)
  397. try:
  398. cfg = AppConfig.load()
  399. origin = (cfg.api_cors_origin or "*").strip()
  400. except Exception:
  401. origin = "*"
  402. self.send_header("Access-Control-Allow-Origin", origin)
  403. self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
  404. self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
  405. self.send_header("Access-Control-Max-Age", "86400")
  406. self.send_header("Access-Control-Allow-Credentials", "true")
  407. self.end_headers()
  408. def _check_auth(self) -> bool:
  409. """非空 api_token 时校验 Authorization: Bearer。返回 True 表示放行。"""
  410. try:
  411. cfg = AppConfig.load()
  412. token = (cfg.api_token or "").strip()
  413. except Exception:
  414. token = ""
  415. if not token:
  416. return True
  417. # 公开接口豁免:根页面、OpenAPI 文档、Swagger UI、static 静态
  418. path = self.path.split("?", 1)[0]
  419. public = ("/", "/index.html", "/docs", "/docs/", "/openapi.json", "/openapi.yaml")
  420. if path in public:
  421. return True
  422. auth = self.headers.get("Authorization", "")
  423. if auth == f"Bearer {token}":
  424. return True
  425. # 也支持 ?token=xxx
  426. if "token=" in (self.path.split("?", 1)[1] if "?" in self.path else ""):
  427. from urllib.parse import parse_qs
  428. q = parse_qs(self.path.split("?", 1)[1])
  429. if (q.get("token") or [""])[0] == token:
  430. return True
  431. self._send_json(401, {"error": "missing or invalid Bearer token"})
  432. return False
  433. def log_message(self, fmt, *args):
  434. return
  435. def handle_one_request(self):
  436. try:
  437. return super().handle_one_request()
  438. except (ConnectionResetError, BrokenPipeError):
  439. # 浏览器主动断开 SSE / fetch 时打印栈很碍眼,直接静音
  440. self.close_connection = True
  441. SWAGGER_HTML = r"""<!doctype html>
  442. <html lang="zh-CN">
  443. <head>
  444. <meta charset="utf-8" />
  445. <title>API 文档 · ChatGPT Plus 自动化</title>
  446. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui.css" />
  447. <style>body{margin:0}#swagger-ui{max-width:1280px;margin:0 auto}</style>
  448. </head>
  449. <body>
  450. <div id="swagger-ui"></div>
  451. <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-bundle.js"></script>
  452. <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-standalone-preset.js"></script>
  453. <script>
  454. window.onload = () => {
  455. window.ui = SwaggerUIBundle({
  456. url: '/openapi.json',
  457. dom_id: '#swagger-ui',
  458. deepLinking: true,
  459. presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
  460. layout: 'StandaloneLayout',
  461. persistAuthorization: true,
  462. tryItOutEnabled: true,
  463. });
  464. };
  465. </script>
  466. </body>
  467. </html>"""
  468. def _build_openapi_spec() -> dict:
  469. """生成 OpenAPI 3.1 规范。"""
  470. cfg = AppConfig.load()
  471. return {
  472. "openapi": "3.1.0",
  473. "info": {
  474. "title": "Auto PayPal API",
  475. "version": "1.0.0",
  476. "description": (
  477. "SSO 注册、账号库查询与已有 session 付款任务 API。\n\n"
  478. "**可用任务模式**:\n"
  479. "- `pay_only` — 传入已有 session JSON,跳过注册直接付款→上传 CPA\n"
  480. "- `full` 全自动注册模式已停用;请使用 Web UI 的 SSO 注册入口。\n\n"
  481. "**调用流程**:\n"
  482. "1. POST `/api/tasks` 创建任务,立即拿到 `task_id`\n"
  483. "2. 轮询 GET `/api/tasks/{task_id}` 看 `status` 和 `stage`\n"
  484. "3. `status` 变 `success` 时可调 GET `/api/account/{email}/cpa.json` 下载 CPA 文件\n\n"
  485. "**重试**:每个任务整体失败会重试 `max_attempts` 次(默认 3)。"
  486. ),
  487. },
  488. "servers": [
  489. {"url": f"http://{cfg.api_host or '127.0.0.1'}:{cfg.api_port or 7791}", "description": "当前实例"},
  490. ],
  491. "components": {
  492. "securitySchemes": {
  493. "BearerAuth": {
  494. "type": "http",
  495. "scheme": "bearer",
  496. "description": "如果配置了 `api_token`,所有 /api/* 请求需带 `Authorization: Bearer <token>`。也支持 `?token=xxx` 查询参数。",
  497. }
  498. },
  499. "schemas": {
  500. "Task": {
  501. "type": "object",
  502. "properties": {
  503. "task_id": {"type": "string", "example": "t-1779470219-65e44d55"},
  504. "mode": {"type": "string", "enum": ["full", "pay_only"]},
  505. "status": {"type": "string", "enum": ["queued", "running", "success", "failed", "cancelled"]},
  506. "stage": {"type": "string", "description": "当前阶段描述"},
  507. "attempts": {"type": "integer"},
  508. "max_attempts": {"type": "integer"},
  509. "params": {"type": "object", "description": "创建任务时传入的参数(脱敏后)"},
  510. "result": {"type": "object", "nullable": True, "description": "成功时的结果(含 CPA 文件名等)"},
  511. "last_error": {"type": "string", "nullable": True},
  512. "email": {"type": "string", "nullable": True, "description": "注册成功的 ChatGPT 邮箱"},
  513. "plan_type": {"type": "string", "nullable": True, "example": "plus"},
  514. "cpa_file_name": {"type": "string", "nullable": True, "example": "codex-foo@example.com-plus.json"},
  515. "created_at": {"type": "integer", "description": "毫秒时间戳"},
  516. "updated_at": {"type": "integer"},
  517. "started_at": {"type": "integer", "nullable": True},
  518. "finished_at": {"type": "integer", "nullable": True},
  519. },
  520. },
  521. "CreateTaskRequest": {
  522. "type": "object",
  523. "required": ["mode"],
  524. "properties": {
  525. "mode": {"type": "string", "enum": ["pay_only"]},
  526. "max_attempts": {"type": "integer", "default": 3, "minimum": 1, "maximum": 10},
  527. "params": {
  528. "type": "object",
  529. "description": "可覆盖全局配置;pay_only 模式必须包含 session 字段",
  530. "properties": {
  531. "session": {
  532. "type": "object",
  533. "description": "ChatGPT /api/auth/session 完整 JSON(仅 pay_only 模式必填)",
  534. "properties": {
  535. "accessToken": {"type": "string"},
  536. "user": {"type": "object"},
  537. "account": {"type": "object"},
  538. },
  539. },
  540. "headless": {"type": "boolean"},
  541. "use_promo": {"type": "boolean"},
  542. "phone_e164": {"type": "string", "example": "+15822201173"},
  543. "sms_api_url": {"type": "string"},
  544. "cpa_url": {"type": "string"},
  545. "cpa_management_key": {"type": "string"},
  546. "proxy_url": {"type": "string"},
  547. "paypal_only_proxy": {"type": "string"},
  548. "mail_helper_url": {"type": "string"},
  549. "mail_domain": {"type": "string"},
  550. },
  551. },
  552. },
  553. },
  554. "Account": {
  555. "type": "object",
  556. "properties": {
  557. "email": {"type": "string"},
  558. "plan_type": {"type": "string", "nullable": True},
  559. "final_status": {"type": "string"},
  560. "cpa_file_name": {"type": "string", "nullable": True},
  561. "long_link": {"type": "string", "nullable": True},
  562. "last_error": {"type": "string", "nullable": True},
  563. "created_at": {"type": "integer"},
  564. "updated_at": {"type": "integer"},
  565. "cpa_uploaded_at": {"type": "integer", "nullable": True},
  566. },
  567. },
  568. "Error": {
  569. "type": "object",
  570. "properties": {"error": {"type": "string"}},
  571. },
  572. },
  573. },
  574. "security": [{"BearerAuth": []}] if cfg.api_token else [],
  575. "paths": {
  576. "/api/tasks": {
  577. "post": {
  578. "tags": ["Tasks"],
  579. "summary": "创建任务",
  580. "description": "创建一个 pay_only 任务,立即返回 task_id,任务异步执行。full 全自动注册已停用。",
  581. "requestBody": {
  582. "required": True,
  583. "content": {
  584. "application/json": {
  585. "schema": {"$ref": "#/components/schemas/CreateTaskRequest"},
  586. "examples": {
  587. "pay_only": {
  588. "summary": "传入 session 直接付款",
  589. "value": {
  590. "mode": "pay_only",
  591. "max_attempts": 3,
  592. "params": {
  593. "session": {
  594. "accessToken": "eyJxxx...",
  595. "user": {"email": "user@example.com"},
  596. "account": {"planType": "free"},
  597. }
  598. },
  599. },
  600. },
  601. },
  602. }
  603. },
  604. },
  605. "responses": {
  606. "200": {
  607. "description": "任务已创建",
  608. "content": {
  609. "application/json": {
  610. "schema": {
  611. "type": "object",
  612. "properties": {
  613. "task_id": {"type": "string"},
  614. "task": {"$ref": "#/components/schemas/Task"},
  615. },
  616. }
  617. }
  618. },
  619. },
  620. "400": {"description": "参数错误", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
  621. "401": {"description": "Bearer token 缺失或无效"},
  622. "410": {"description": "full 全自动注册已停用"},
  623. },
  624. },
  625. "get": {
  626. "tags": ["Tasks"],
  627. "summary": "列出任务",
  628. "parameters": [
  629. {"name": "status", "in": "query", "schema": {"type": "string", "enum": ["queued", "running", "success", "failed", "cancelled"]}},
  630. {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 100}},
  631. ],
  632. "responses": {
  633. "200": {
  634. "content": {
  635. "application/json": {
  636. "schema": {
  637. "type": "object",
  638. "properties": {"tasks": {"type": "array", "items": {"$ref": "#/components/schemas/Task"}}},
  639. }
  640. }
  641. }
  642. }
  643. },
  644. },
  645. },
  646. "/api/tasks/{task_id}": {
  647. "get": {
  648. "tags": ["Tasks"],
  649. "summary": "查询任务进度",
  650. "description": "轮询此接口查任务实时 status 和 stage。建议 5-10 秒间隔。",
  651. "parameters": [{"name": "task_id", "in": "path", "required": True, "schema": {"type": "string"}}],
  652. "responses": {
  653. "200": {"content": {"application/json": {"schema": {"type": "object", "properties": {"task": {"$ref": "#/components/schemas/Task"}}}}}},
  654. "404": {"description": "任务不存在"},
  655. },
  656. }
  657. },
  658. "/api/tasks/{task_id}/cancel": {
  659. "post": {
  660. "tags": ["Tasks"],
  661. "summary": "取消任务",
  662. "description": "请求取消任务。如果任务已经在跑,会在下一个 stop 检查点退出。",
  663. "parameters": [{"name": "task_id", "in": "path", "required": True, "schema": {"type": "string"}}],
  664. "responses": {"200": {"description": "已请求取消"}, "404": {"description": "任务不存在"}},
  665. }
  666. },
  667. "/api/accounts": {
  668. "get": {
  669. "tags": ["Accounts"],
  670. "summary": "列出已注册账号",
  671. "parameters": [
  672. {"name": "status", "in": "query", "schema": {"type": "string"}, "description": "如 cpa_uploaded / plus_check_failed"},
  673. {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 200}},
  674. ],
  675. "responses": {
  676. "200": {
  677. "content": {
  678. "application/json": {
  679. "schema": {
  680. "type": "object",
  681. "properties": {"accounts": {"type": "array", "items": {"$ref": "#/components/schemas/Account"}}},
  682. }
  683. }
  684. }
  685. }
  686. },
  687. }
  688. },
  689. "/api/account/{email}": {
  690. "get": {
  691. "tags": ["Accounts"],
  692. "summary": "查询账号详情(含完整 session 和事件流)",
  693. "parameters": [{"name": "email", "in": "path", "required": True, "schema": {"type": "string"}}],
  694. "responses": {"200": {"description": "OK"}, "404": {"description": "账号不存在"}},
  695. }
  696. },
  697. "/api/account/{email}/cpa.json": {
  698. "get": {
  699. "tags": ["Accounts"],
  700. "summary": "下载 CPA codex auth JSON",
  701. "description": "返回该账号当时上传给 CPA 的完整 codex auth JSON 文件。带 Content-Disposition 头,浏览器会自动下载。",
  702. "parameters": [{"name": "email", "in": "path", "required": True, "schema": {"type": "string"}}],
  703. "responses": {
  704. "200": {"description": "OK", "content": {"application/json": {}}},
  705. "404": {"description": "账号或 session 不存在"},
  706. },
  707. }
  708. },
  709. "/api/account/{email}/recheck": {
  710. "post": {
  711. "tags": ["Accounts"],
  712. "summary": "对失败账号补救",
  713. "description": "用 DB 里存的 access_token 调 backend-api/me,若已 plus 则自动重传 CPA。",
  714. "parameters": [{"name": "email", "in": "path", "required": True, "schema": {"type": "string"}}],
  715. "responses": {"200": {"description": "OK"}, "404": {"description": "账号不存在"}},
  716. }
  717. },
  718. "/api/config": {
  719. "get": {"tags": ["Config"], "summary": "读取当前配置", "responses": {"200": {"description": "OK"}}},
  720. "post": {
  721. "tags": ["Config"],
  722. "summary": "更新配置",
  723. "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}},
  724. "responses": {"200": {"description": "OK"}},
  725. },
  726. },
  727. "/api/status": {
  728. "get": {"tags": ["Misc"], "summary": "(旧)读取 UI 任务状态", "responses": {"200": {"description": "OK"}}}
  729. },
  730. "/api/log": {
  731. "get": {"tags": ["Misc"], "summary": "实时日志(Server-Sent Events)", "responses": {"200": {"description": "text/event-stream"}}}
  732. },
  733. },
  734. "tags": [
  735. {"name": "Tasks", "description": "任务化 API(推荐用法)"},
  736. {"name": "Accounts", "description": "账号库"},
  737. {"name": "Config", "description": "服务配置"},
  738. {"name": "Misc", "description": "其他"},
  739. ],
  740. }
  741. def _silence_threading_excepthook():
  742. """ThreadingHTTPServer 在 worker 线程里仍可能抛 ConnectionResetError;接住它。"""
  743. import threading
  744. prev = threading.excepthook
  745. def hook(args):
  746. if isinstance(args.exc_value, (ConnectionResetError, BrokenPipeError)):
  747. return
  748. prev(args)
  749. threading.excepthook = hook
  750. def main():
  751. init_db()
  752. _silence_threading_excepthook()
  753. # 启动后台任务 worker(幂等)
  754. get_runner(log=lambda m: JOB._log(m))
  755. cfg = AppConfig.load()
  756. host = (cfg.api_host or HOST).strip() or HOST
  757. port = int(cfg.api_port or PORT)
  758. server = ThreadingHTTPServer((host, port), Handler)
  759. print(f"Auto PayPal Console:")
  760. print(f" Web UI: http://{host}:{port}/")
  761. print(f" Docs: http://{host}:{port}/docs")
  762. print(f" OpenAPI: http://{host}:{port}/openapi.json")
  763. if cfg.api_token:
  764. print(f" Auth: Bearer <token>(已启用)")
  765. if host == "0.0.0.0":
  766. print(f" ⚠️ 当前监听所有网卡,外网可访问。建议设置 api_token。")
  767. try:
  768. server.serve_forever()
  769. except KeyboardInterrupt:
  770. print("\nStopped.")
  771. if __name__ == "__main__":
  772. main()