task_runner.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. """任务执行器:单 worker 后台线程,从 SQLite tasks 表 FIFO 消费任务。
  2. 支持两种模式:
  3. - full : 走完整的注册→付款→上传 CPA 流程(cfg.account_count 强制为 1)
  4. - pay_only : 传入已有 ChatGPT session JSON,跳过注册直接付款
  5. 任务整体失败会重试,最多 attempts = max_attempts 次(默认 3)。
  6. """
  7. from __future__ import annotations
  8. import threading
  9. import time
  10. import traceback
  11. import uuid
  12. from typing import Callable, Optional
  13. from chatgpt_flow import run_full, run_pay_only
  14. from config import AppConfig
  15. from storage import (
  16. get_next_queued_task,
  17. get_task,
  18. init_db,
  19. update_task,
  20. )
  21. class TaskRunner:
  22. def __init__(self, *, log: Callable[[str], None] = print):
  23. self.log = log
  24. self._thread: threading.Thread | None = None
  25. self._stopping = threading.Event()
  26. self._current_task_id: str | None = None
  27. self._cancel_flags: dict[str, bool] = {}
  28. self._lock = threading.Lock()
  29. # ----- public API -----
  30. def start(self):
  31. if self._thread and self._thread.is_alive():
  32. return
  33. self._stopping.clear()
  34. self._thread = threading.Thread(target=self._loop, daemon=True, name="task-runner")
  35. self._thread.start()
  36. self.log("[runner] worker 已启动")
  37. def shutdown(self, timeout: float = 5.0):
  38. self._stopping.set()
  39. if self._thread:
  40. self._thread.join(timeout=timeout)
  41. self.log("[runner] worker 已停止")
  42. def cancel(self, task_id: str) -> bool:
  43. with self._lock:
  44. self._cancel_flags[task_id] = True
  45. # 若是当前正在跑的 → 让 stop_check 抛出
  46. return True
  47. def current_task_id(self) -> str | None:
  48. return self._current_task_id
  49. # ----- internals -----
  50. def _loop(self):
  51. init_db()
  52. while not self._stopping.is_set():
  53. try:
  54. task = get_next_queued_task()
  55. except Exception as exc:
  56. self.log(f"[runner] 取任务异常: {exc!r}")
  57. task = None
  58. if not task:
  59. # 没活干,sleep 1s 再轮询
  60. self._stopping.wait(1.0)
  61. continue
  62. try:
  63. self._run_task(task)
  64. except Exception as exc:
  65. self.log(f"[runner] 执行任务 {task.get('task_id')} 顶层异常: {exc!r}")
  66. self.log(traceback.format_exc())
  67. def _run_task(self, task: dict):
  68. task_id = task["task_id"]
  69. mode = task.get("mode") or "full"
  70. max_attempts = int(task.get("max_attempts") or 3)
  71. params = task.get("params") or {}
  72. self._current_task_id = task_id
  73. update_task(task_id, {"status": "running", "started_at": int(time.time() * 1000), "stage": "queued→running"})
  74. last_error = ""
  75. for attempt in range(1, max_attempts + 1):
  76. if self._is_cancelled(task_id):
  77. update_task(task_id, {"status": "cancelled", "stage": "cancelled", "finished_at": int(time.time() * 1000)})
  78. self._current_task_id = None
  79. return
  80. update_task(task_id, {"attempts": attempt, "stage": f"attempt {attempt}/{max_attempts}"})
  81. self.log(f"[runner] task={task_id} mode={mode} 第 {attempt}/{max_attempts} 次尝试")
  82. try:
  83. if mode == "full":
  84. record = self._do_full(task_id, params)
  85. elif mode == "pay_only":
  86. record = self._do_pay_only(task_id, params)
  87. else:
  88. raise RuntimeError(f"未知 mode: {mode}")
  89. except _TaskCancelled:
  90. update_task(task_id, {
  91. "status": "cancelled", "stage": "cancelled",
  92. "finished_at": int(time.time() * 1000),
  93. })
  94. self._current_task_id = None
  95. return
  96. except Exception as exc:
  97. last_error = repr(exc)
  98. self.log(f"[runner] task={task_id} 第 {attempt} 次执行异常: {last_error}")
  99. self.log(traceback.format_exc())
  100. update_task(task_id, {
  101. "last_error": last_error,
  102. "stage": f"attempt {attempt} failed",
  103. })
  104. if attempt >= max_attempts:
  105. break
  106. # sleep 5s 让外部资源喘口气
  107. time.sleep(5)
  108. continue
  109. # 看 record["stage"] 决定是成功还是失败
  110. stage = (record or {}).get("stage", "")
  111. if stage in ("cpa_uploaded", "cpa_skipped"):
  112. update_task(task_id, {
  113. "status": "success",
  114. "stage": stage,
  115. "result": record,
  116. "email": record.get("email") or task.get("email"),
  117. "plan_type": record.get("planType"),
  118. "cpa_file_name": (record.get("cpa") or {}).get("fileName") if record.get("cpa") else None,
  119. "finished_at": int(time.time() * 1000),
  120. "last_error": "",
  121. })
  122. self.log(f"[runner] task={task_id} 成功 stage={stage}")
  123. self._current_task_id = None
  124. return
  125. else:
  126. # plus_check_failed / error 之类视为失败,进入重试
  127. last_error = (record or {}).get("error") or f"stage={stage}"
  128. self.log(f"[runner] task={task_id} 第 {attempt} 次完成但未成功: {last_error}")
  129. update_task(task_id, {"last_error": last_error, "stage": f"attempt {attempt}: {stage}"})
  130. if attempt >= max_attempts:
  131. break
  132. time.sleep(5)
  133. # 走到这说明全部 attempt 都失败
  134. update_task(task_id, {
  135. "status": "failed",
  136. "stage": "exhausted",
  137. "last_error": last_error or "all attempts failed",
  138. "finished_at": int(time.time() * 1000),
  139. })
  140. self.log(f"[runner] task={task_id} 失败({max_attempts}/{max_attempts} 次都失败)")
  141. self._current_task_id = None
  142. def _do_full(self, task_id: str, params: dict) -> dict:
  143. cfg = AppConfig.load()
  144. cfg.account_count = 1 # 单任务只跑一个账号
  145. # params 可覆盖 cfg
  146. for k in ("headless", "use_promo", "phone_e164", "sms_api_url", "cpa_url",
  147. "cpa_management_key", "proxy_url", "paypal_only_proxy",
  148. "mail_helper_url", "mail_domain"):
  149. if k in (params or {}):
  150. setattr(cfg, k, params[k])
  151. def stop_check():
  152. if self._is_cancelled(task_id):
  153. raise _TaskCancelled()
  154. def task_log(msg: str):
  155. self.log(f"[task:{task_id[:8]}] {msg}")
  156. def task_stage(stage: str):
  157. update_task(task_id, {"stage": stage[:200]})
  158. # run_full 是为多账号写的,这里复用但只跑 1 个
  159. # 把 stop hook 注入:run_full 内部用 full_ctx.check_stop,我们没法直接钩,
  160. # 但 Stop API 通过 cancel 设标志位 → 在每次 update_task 时也 stop_check
  161. # 简化:包一层 thread 跑,10s 内查一次 cancel
  162. stopping_holder = {"stop": False}
  163. result_holder: dict = {}
  164. def run():
  165. try:
  166. full_ctx = run_full(cfg, log=task_log, on_stage=task_stage)
  167. # full_ctx.accounts[0] 就是结果
  168. if full_ctx.accounts:
  169. result_holder["record"] = full_ctx.accounts[0]
  170. else:
  171. result_holder["record"] = {"stage": "error", "error": "no account record"}
  172. except Exception as exc:
  173. result_holder["error"] = repr(exc)
  174. # 因为 run_full 内部在阻塞调用 sync_playwright,cancel 只能等当前 attempt 跑完
  175. run()
  176. if "error" in result_holder:
  177. raise RuntimeError(result_holder["error"])
  178. if self._is_cancelled(task_id):
  179. raise _TaskCancelled()
  180. return result_holder.get("record") or {"stage": "error", "error": "empty record"}
  181. def _do_pay_only(self, task_id: str, params: dict) -> dict:
  182. cfg = AppConfig.load()
  183. cfg.account_count = 1
  184. for k in ("headless", "use_promo", "phone_e164", "sms_api_url", "cpa_url",
  185. "cpa_management_key", "proxy_url", "paypal_only_proxy"):
  186. if k in (params or {}):
  187. setattr(cfg, k, params[k])
  188. session = params.get("session")
  189. if not isinstance(session, dict) or not session.get("accessToken"):
  190. raise RuntimeError("pay_only 需要 params.session 是 JSON 且包含 accessToken")
  191. def stop_check():
  192. if self._is_cancelled(task_id):
  193. raise _TaskCancelled()
  194. def task_log(msg: str):
  195. self.log(f"[task:{task_id[:8]}] {msg}")
  196. def task_stage(stage: str):
  197. update_task(task_id, {"stage": stage[:200]})
  198. return run_pay_only(
  199. cfg,
  200. session=session,
  201. log=task_log,
  202. on_stage=task_stage,
  203. stop_check=stop_check,
  204. )
  205. def _is_cancelled(self, task_id: str) -> bool:
  206. with self._lock:
  207. return self._cancel_flags.get(task_id, False)
  208. class _TaskCancelled(Exception):
  209. pass
  210. # ------------------ public helpers ------------------
  211. def make_task_id() -> str:
  212. return f"t-{int(time.time())}-{uuid.uuid4().hex[:8]}"
  213. _GLOBAL_RUNNER: TaskRunner | None = None
  214. _GLOBAL_RUNNER_LOCK = threading.Lock()
  215. def get_runner(log: Callable[[str], None] = print) -> TaskRunner:
  216. global _GLOBAL_RUNNER
  217. with _GLOBAL_RUNNER_LOCK:
  218. if _GLOBAL_RUNNER is None:
  219. _GLOBAL_RUNNER = TaskRunner(log=log)
  220. _GLOBAL_RUNNER.start()
  221. return _GLOBAL_RUNNER