| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- """任务执行器:单 worker 后台线程,从 SQLite tasks 表 FIFO 消费任务。
- 支持两种模式:
- - full : 走完整的注册→付款→上传 CPA 流程(cfg.account_count 强制为 1)
- - pay_only : 传入已有 ChatGPT session JSON,跳过注册直接付款
- 任务整体失败会重试,最多 attempts = max_attempts 次(默认 3)。
- """
- from __future__ import annotations
- import threading
- import time
- import traceback
- import uuid
- from typing import Callable, Optional
- from chatgpt_flow import run_full, run_pay_only
- from config import AppConfig
- from storage import (
- get_next_queued_task,
- get_task,
- init_db,
- update_task,
- )
- class TaskRunner:
- def __init__(self, *, log: Callable[[str], None] = print):
- self.log = log
- self._thread: threading.Thread | None = None
- self._stopping = threading.Event()
- self._current_task_id: str | None = None
- self._cancel_flags: dict[str, bool] = {}
- self._lock = threading.Lock()
- # ----- public API -----
- def start(self):
- if self._thread and self._thread.is_alive():
- return
- self._stopping.clear()
- self._thread = threading.Thread(target=self._loop, daemon=True, name="task-runner")
- self._thread.start()
- self.log("[runner] worker 已启动")
- def shutdown(self, timeout: float = 5.0):
- self._stopping.set()
- if self._thread:
- self._thread.join(timeout=timeout)
- self.log("[runner] worker 已停止")
- def cancel(self, task_id: str) -> bool:
- with self._lock:
- self._cancel_flags[task_id] = True
- # 若是当前正在跑的 → 让 stop_check 抛出
- return True
- def current_task_id(self) -> str | None:
- return self._current_task_id
- # ----- internals -----
- def _loop(self):
- init_db()
- while not self._stopping.is_set():
- try:
- task = get_next_queued_task()
- except Exception as exc:
- self.log(f"[runner] 取任务异常: {exc!r}")
- task = None
- if not task:
- # 没活干,sleep 1s 再轮询
- self._stopping.wait(1.0)
- continue
- try:
- self._run_task(task)
- except Exception as exc:
- self.log(f"[runner] 执行任务 {task.get('task_id')} 顶层异常: {exc!r}")
- self.log(traceback.format_exc())
- def _run_task(self, task: dict):
- task_id = task["task_id"]
- mode = task.get("mode") or "full"
- max_attempts = int(task.get("max_attempts") or 3)
- params = task.get("params") or {}
- self._current_task_id = task_id
- update_task(task_id, {"status": "running", "started_at": int(time.time() * 1000), "stage": "queued→running"})
- last_error = ""
- for attempt in range(1, max_attempts + 1):
- if self._is_cancelled(task_id):
- update_task(task_id, {"status": "cancelled", "stage": "cancelled", "finished_at": int(time.time() * 1000)})
- self._current_task_id = None
- return
- update_task(task_id, {"attempts": attempt, "stage": f"attempt {attempt}/{max_attempts}"})
- self.log(f"[runner] task={task_id} mode={mode} 第 {attempt}/{max_attempts} 次尝试")
- try:
- if mode == "full":
- record = self._do_full(task_id, params)
- elif mode == "pay_only":
- record = self._do_pay_only(task_id, params)
- else:
- raise RuntimeError(f"未知 mode: {mode}")
- except _TaskCancelled:
- update_task(task_id, {
- "status": "cancelled", "stage": "cancelled",
- "finished_at": int(time.time() * 1000),
- })
- self._current_task_id = None
- return
- except Exception as exc:
- last_error = repr(exc)
- self.log(f"[runner] task={task_id} 第 {attempt} 次执行异常: {last_error}")
- self.log(traceback.format_exc())
- update_task(task_id, {
- "last_error": last_error,
- "stage": f"attempt {attempt} failed",
- })
- if attempt >= max_attempts:
- break
- # sleep 5s 让外部资源喘口气
- time.sleep(5)
- continue
- # 看 record["stage"] 决定是成功还是失败
- stage = (record or {}).get("stage", "")
- if stage in ("cpa_uploaded", "cpa_skipped"):
- update_task(task_id, {
- "status": "success",
- "stage": stage,
- "result": record,
- "email": record.get("email") or task.get("email"),
- "plan_type": record.get("planType"),
- "cpa_file_name": (record.get("cpa") or {}).get("fileName") if record.get("cpa") else None,
- "finished_at": int(time.time() * 1000),
- "last_error": "",
- })
- self.log(f"[runner] task={task_id} 成功 stage={stage}")
- self._current_task_id = None
- return
- else:
- # plus_check_failed / error 之类视为失败,进入重试
- last_error = (record or {}).get("error") or f"stage={stage}"
- self.log(f"[runner] task={task_id} 第 {attempt} 次完成但未成功: {last_error}")
- update_task(task_id, {"last_error": last_error, "stage": f"attempt {attempt}: {stage}"})
- if attempt >= max_attempts:
- break
- time.sleep(5)
- # 走到这说明全部 attempt 都失败
- update_task(task_id, {
- "status": "failed",
- "stage": "exhausted",
- "last_error": last_error or "all attempts failed",
- "finished_at": int(time.time() * 1000),
- })
- self.log(f"[runner] task={task_id} 失败({max_attempts}/{max_attempts} 次都失败)")
- self._current_task_id = None
- def _do_full(self, task_id: str, params: dict) -> dict:
- cfg = AppConfig.load()
- cfg.account_count = 1 # 单任务只跑一个账号
- # params 可覆盖 cfg
- for k in ("headless", "use_promo", "phone_e164", "sms_api_url", "cpa_url",
- "cpa_management_key", "proxy_url", "paypal_only_proxy",
- "mail_helper_url", "mail_domain"):
- if k in (params or {}):
- setattr(cfg, k, params[k])
- def stop_check():
- if self._is_cancelled(task_id):
- raise _TaskCancelled()
- def task_log(msg: str):
- self.log(f"[task:{task_id[:8]}] {msg}")
- def task_stage(stage: str):
- update_task(task_id, {"stage": stage[:200]})
- # run_full 是为多账号写的,这里复用但只跑 1 个
- # 把 stop hook 注入:run_full 内部用 full_ctx.check_stop,我们没法直接钩,
- # 但 Stop API 通过 cancel 设标志位 → 在每次 update_task 时也 stop_check
- # 简化:包一层 thread 跑,10s 内查一次 cancel
- stopping_holder = {"stop": False}
- result_holder: dict = {}
- def run():
- try:
- full_ctx = run_full(cfg, log=task_log, on_stage=task_stage)
- # full_ctx.accounts[0] 就是结果
- if full_ctx.accounts:
- result_holder["record"] = full_ctx.accounts[0]
- else:
- result_holder["record"] = {"stage": "error", "error": "no account record"}
- except Exception as exc:
- result_holder["error"] = repr(exc)
- # 因为 run_full 内部在阻塞调用 sync_playwright,cancel 只能等当前 attempt 跑完
- run()
- if "error" in result_holder:
- raise RuntimeError(result_holder["error"])
- if self._is_cancelled(task_id):
- raise _TaskCancelled()
- return result_holder.get("record") or {"stage": "error", "error": "empty record"}
- def _do_pay_only(self, task_id: str, params: dict) -> dict:
- cfg = AppConfig.load()
- cfg.account_count = 1
- for k in ("headless", "use_promo", "phone_e164", "sms_api_url", "cpa_url",
- "cpa_management_key", "proxy_url", "paypal_only_proxy"):
- if k in (params or {}):
- setattr(cfg, k, params[k])
- session = params.get("session")
- if not isinstance(session, dict) or not session.get("accessToken"):
- raise RuntimeError("pay_only 需要 params.session 是 JSON 且包含 accessToken")
- def stop_check():
- if self._is_cancelled(task_id):
- raise _TaskCancelled()
- def task_log(msg: str):
- self.log(f"[task:{task_id[:8]}] {msg}")
- def task_stage(stage: str):
- update_task(task_id, {"stage": stage[:200]})
- return run_pay_only(
- cfg,
- session=session,
- log=task_log,
- on_stage=task_stage,
- stop_check=stop_check,
- )
- def _is_cancelled(self, task_id: str) -> bool:
- with self._lock:
- return self._cancel_flags.get(task_id, False)
- class _TaskCancelled(Exception):
- pass
- # ------------------ public helpers ------------------
- def make_task_id() -> str:
- return f"t-{int(time.time())}-{uuid.uuid4().hex[:8]}"
- _GLOBAL_RUNNER: TaskRunner | None = None
- _GLOBAL_RUNNER_LOCK = threading.Lock()
- def get_runner(log: Callable[[str], None] = print) -> TaskRunner:
- global _GLOBAL_RUNNER
- with _GLOBAL_RUNNER_LOCK:
- if _GLOBAL_RUNNER is None:
- _GLOBAL_RUNNER = TaskRunner(log=log)
- _GLOBAL_RUNNER.start()
- return _GLOBAL_RUNNER
|