#!/usr/bin/env python3 """Small local HTTP worker for the YYB admin integration. The worker owns per-job sessions and invokes the already verified protocol scripts. It intentionally exposes QR images and state only; cookies and raw payment links never leave the worker API. """ from __future__ import annotations import argparse import base64 import importlib.util import json import os import re import shutil import subprocess import sys import threading import time import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) DEFAULT_DATA = ROOT / "config" / "worker-jobs" DEFAULT_EVIDENCE_TTL_HOURS = 72 WORKER_KEY = os.environ.get("YYB_WORKER_KEY", "") _jobs: dict[str, dict] = {} _lock = threading.Lock() from pyvm.payment_errors import describe_payment_failure # noqa: E402 def _payment_failure_message(job: dict, phase: str, fallback: str) -> str: """从阶段日志提取业务码,向管理端返回准确且可操作的失败信息。""" detail = "\n".join(job.get("logs", [])) message = describe_payment_failure(str(job.get("provider", "")), phase, detail) return message if message else fallback def _load_selector(): path = ROOT / "scripts" / "select-peace-elite.py" spec = importlib.util.spec_from_file_location("yyb_worker_selector", path) if not spec or not spec.loader: raise RuntimeError("无法加载和平精英选择器") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def _job_dir(job_id: str) -> Path: return Path(_jobs[job_id]["directory"]) def _cleanup_expired_jobs(data_dir: Path, ttl_hours: int) -> int: """清理超过保留期的任务证据;只处理 data_dir 的一级任务目录。""" if ttl_hours <= 0 or not data_dir.exists(): return 0 deadline = time.time() - ttl_hours * 3600 removed = 0 for directory in data_dir.iterdir(): if not directory.is_dir() or directory.is_symlink(): continue try: if directory.stat().st_mtime >= deadline: continue shutil.rmtree(directory) removed += 1 except OSError as exc: print(f"无法清理过期任务证据 {directory.name}: {exc}", flush=True) return removed def _safe_log(job: dict, line: str) -> None: # Do not persist cookies, payment URI, or long opaque tokens in the worker API. clean = re.sub(r"weixin://\S+", "[付款链接已隐藏]", line) clean = re.sub(r"HOLD_[A-Za-z0-9_-]+", "[订单已隐藏]", clean) clean = re.sub(r"(订单\s*[::]\s*)\S+", r"\1[订单已隐藏]", clean) clean = re.sub(r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", clean, flags=re.I) clean = re.sub(r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+", "[敏感字段已隐藏]", clean, flags=re.I) timestamp = time.strftime("%H:%M:%S") with _lock: job["logs"] = (job.get("logs", []) + [f"[{timestamp}] {clean.strip()}"])[-100:] def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool = True) -> int: """Run one stage process and return its exit code. When ``mark_success`` is False the caller owns the post-success state transition (used by the staged payment flow). """ job = _jobs[job_id] environment = os.environ.copy() environment.pop("NODE_OPTIONS", None) # 子进程输出经管道实时转存日志;关闭块缓冲避免日志滞后。 environment["PYTHONUNBUFFERED"] = "1" with _lock: job["phase"] = phase job["status"] = "running" stage_started = time.monotonic() _safe_log(job, f"[{phase}] 开始执行") try: process = subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, env=environment) with _lock: job["process_pid"] = process.pid assert process.stdout is not None for line in process.stdout: _safe_log(job, line) code = process.wait() _safe_log(job, f"[{phase}] 执行结束,耗时 {time.monotonic() - stage_started:.1f} 秒") with _lock: job["process_pid"] = None if code != 0: if job.get("status") == "stopped": # 停止请求已 kill 本进程:保留停止语义,不被阶段失败覆盖。 job["message"] = "任务已停止" else: job["status"] = "failed" job["phase"] = phase job["message"] = f"{phase}失败(退出码 {code})" elif phase == "login": job["status"] = "ready" job["phase"] = "selection" job["message"] = "登录成功,请选择平台、点券、区服和角色" elif mark_success: job["status"] = "success" job["phase"] = "completed" job["message"] = "付款流程已完成" return code except Exception as exc: # noqa: BLE001 _safe_log(job, f"[{phase}] 执行异常,耗时 {time.monotonic() - stage_started:.1f} 秒") with _lock: job["status"] = "failed" job["message"] = str(exc) return 1 def _start_login(job_id: str, provider: str, timeout: int) -> None: job = _jobs[job_id] directory = _job_dir(job_id) session = directory / "mall-session.json" qr = directory / ("qq-login.jpg" if provider == "qq" else "wechat-login.jpg") command = [sys.executable, f"scripts/login-{provider}.py", "--session", str(session), "--qr", str(qr), "--timeout", str(timeout)] with _lock: job["provider"] = provider job["qr_path"] = str(qr) job["session_path"] = str(session) job["status"] = "waiting_login" job["phase"] = "login" threading.Thread(target=_run_process, args=(job_id, command, "login"), daemon=True).start() def _selection_options(job_id: str, platform: str, points: int | None, zone_id: str | None = None) -> dict: if job_id not in _jobs: raise ValueError("任务不存在") selector = _load_selector() session = json.loads((_job_dir(job_id) / "mall-session.json").read_text(encoding="utf-8")) cookies = session.get("cookies", {}) products = selector.product_options(cookies, platform) if points is not None and not any(int(item["points"]) == points for item in products): raise ValueError("当前登录态不支持该点券档位") product = next((item for item in products if int(item["points"]) == points), None) if points else None if product is None: product = products[0] cmall = selector.Cmall(cookies, str(product["offer_id"]), platform) zones = cmall.zones() selected_zone = next((zone for zone in zones if str(zone["zone_id"]) == str(zone_id)), None) if zone_id and selected_zone is None: raise ValueError("区服不存在") selected_zone = selected_zone or (zones[0] if zones else None) roles = cmall.roles(selected_zone["zone_id"]) if selected_zone else [] return {"products": products, "zones": zones, "roles": roles, "default_product": product, "default_zone": selected_zone} def _payment_stage(job_id: str, command: list[str], phase: str, running_message: str, failed_message: str) -> bool: """Run one payment stage; return True on success without touching final state.""" job = _jobs[job_id] with _lock: if job.get("status") == "stopped": return False job["status"] = "running" job["phase"] = phase job["message"] = running_message code = _run_process(job_id, command, phase, mark_success=False) if code != 0: with _lock: if job.get("status") == "stopped": return False job["status"] = "failed" job["phase"] = phase job["message"] = _payment_failure_message(job, phase, failed_message) return False return True PAYMENT_CHECK_INTERVAL = 3 PAYMENT_CHECK_TIMEOUT = 300 def _check_payment_once(job_id: str) -> int: """Read-only completion check; returns 0=confirmed, 1=not yet, 2=check failed.""" job = _jobs[job_id] directory = _job_dir(job_id) command = [sys.executable, "scripts/jsdom-pay.py", "--check-only", "--session", str(directory / "mall-session.json"), "--out-dir", str(directory / "jsdom-order")] environment = os.environ.copy() environment.pop("NODE_OPTIONS", None) check_started = time.monotonic() _safe_log(job, "[到账检测] 开始执行") try: result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, env=environment, timeout=90) except subprocess.TimeoutExpired: _safe_log(job, f"[到账检测] 执行超时,耗时 {time.monotonic() - check_started:.1f} 秒") with _lock: job["payment_last_checked_at"] = int(time.time()) return 2 for line in (result.stdout or "").splitlines(): _safe_log(job, line) for line in (result.stderr or "").splitlines(): _safe_log(job, line) _safe_log(job, f"[到账检测] 执行结束,耗时 {time.monotonic() - check_started:.1f} 秒") with _lock: job["payment_last_checked_at"] = int(time.time()) return result.returncode def _monitor_payment(job_id: str) -> None: """Background loop that re-checks completion until timeout or confirmation.""" job = _jobs[job_id] deadline = time.monotonic() + PAYMENT_CHECK_TIMEOUT while time.monotonic() < deadline: time.sleep(PAYMENT_CHECK_INTERVAL) with _lock: if job.get("status") in {"stopped", "success"}: return code = _check_payment_once(job_id) if code == 0: with _lock: job["status"] = "success" job["phase"] = "completed" job["message"] = "已确认到账,充值完成" return if code == 2: with _lock: job["message"] = "到账检测暂时失败,将继续重试" with _lock: job["status"] = "payment_timeout" job["phase"] = "payment" job["message"] = "付款码已生成,但未在时限内确认到账,请人工核对" def _payment_flow(job_id: str, selection: dict) -> None: """Staged payment: create mall order -> create payment QR -> monitor arrival. The QR is generated before any waiting, so the UI switches to waiting_payment immediately; only a timeout moves to payment_timeout. """ job = _jobs[job_id] directory = _job_dir(job_id) session = directory / "mall-session.json" response = directory / "mall-order-response.json" output = directory / "jsdom-order" with _lock: job["status"] = "ordering" job["phase"] = "payment" job["message"] = "正在创建商城订单" order_cmd = [sys.executable, "main.py", "mall", "auto", "--session", str(session), "--order-template", str(ROOT / "config" / "mall-order-template.json"), "--quantity", "1", "--product-id", str(selection["product_id"]), "--offer-id", str(selection["offer_id"]), "--role-id", str(selection["role_id"]), "--role-name", str(selection["role_name"]), "--zone-id", str(selection["zone_id"]), "--zone-name", str(selection["zone_name"]), "--output", str(response)] if selection.get("area"): order_cmd.extend(["--area", str(selection["area"])]) if selection.get("partition"): order_cmd.extend(["--partition", str(selection["partition"])]) if selection.get("order_pf"): order_cmd.extend(["--pf", str(selection["order_pf"])]) if not _payment_stage(job_id, order_cmd, "order", "正在创建商城订单", "创建商城订单失败"): return pay_cmd = [sys.executable, "scripts/jsdom-pay.py", "--session", str(session), "--mall-response", str(response), "--out-dir", str(output), "--zone-id", str(selection["zone_id"]), "--pf", str(selection.get("order_pf", "")), "--amount-fen", str(selection["price_fen"]), "--skip-payment-check"] if not _payment_stage(job_id, pay_cmd, "payment", "正在生成微信付款码", "生成付款码失败"): return meta_path = output / "payment-meta.json" try: meta = json.loads(meta_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): meta = {} with _lock: job["status"] = "waiting_payment" job["phase"] = "payment" job["message"] = "付款码已生成,请扫码付款" job["payment_qr_created_at"] = meta.get("qr_created_at", int(time.time())) _monitor_payment(job_id) def _start_payment(job_id: str, selection: dict) -> None: with _lock: if _jobs[job_id].get("status") == "stopped": return _jobs[job_id]["status"] = "ordering" _jobs[job_id]["phase"] = "payment" _jobs[job_id]["message"] = "正在创建商城订单" threading.Thread(target=_payment_flow, args=(job_id, selection), daemon=True).start() def _stop_job(job_id: str) -> None: if job_id not in _jobs: raise ValueError("任务不存在") job = _jobs[job_id] pid = job.get("process_pid") if pid: try: os.kill(int(pid), 15) except ProcessLookupError: pass with _lock: if job.get("status") == "waiting_payment": job["status"] = "stopped" job["phase"] = "payment" job["message"] = "已放弃到账追踪;商城订单仍可能完成,请人工核对" else: job["status"] = "stopped" job["phase"] = "stopped" job["message"] = "任务已停止" def _public_job(job_id: str) -> dict: job = _jobs[job_id] result = {key: value for key, value in job.items() if key not in {"directory", "session_path", "process_pid"}} qr_path = job.get("qr_path", "") if qr_path and Path(qr_path).exists(): qr_bytes = Path(qr_path).read_bytes() result["qr_data"] = base64.b64encode(qr_bytes).decode("ascii") result["qr_mime_type"] = "image/jpeg" if qr_bytes.startswith(b"\xff\xd8\xff") else "image/png" output = Path(job["directory"]) / "jsdom-order" for name in ("wechat-pay.png", "payment-status.json"): path = None if output.exists(): direct = output / name path = direct if direct.exists() else next(output.glob(f"*/{name}"), None) if path and name.endswith(".png"): payment_qr_bytes = path.read_bytes() result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode("ascii") result["payment_qr_mime_type"] = "image/jpeg" if payment_qr_bytes.startswith(b"\xff\xd8\xff") else "image/png" elif path: try: status = json.loads(path.read_text(encoding="utf-8")) matched = status.get("matched_completion") if isinstance(status, dict) else None result["payment_status"] = { "checked_at": status.get("checked_at") if isinstance(status, dict) else None, "matched_completion": { "is_finished": matched.get("is_finished"), "status": matched.get("status"), } if isinstance(matched, dict) else None, } except (OSError, json.JSONDecodeError): pass for key in ("payment_qr_created_at", "payment_last_checked_at"): if job.get(key) is not None: result[key] = job[key] return result class Handler(BaseHTTPRequestHandler): server_version = "YYBWorker/1" def _json(self, status: int, value: dict) -> None: body = json.dumps(value, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _body(self) -> dict: length = int(self.headers.get("Content-Length", "0")) return json.loads(self.rfile.read(length) or b"{}") def _authorized(self) -> bool: if not WORKER_KEY: return True value = self.headers.get("Authorization", "") return value == f"Bearer {WORKER_KEY}" def do_POST(self) -> None: # noqa: N802 if not self._authorized(): return self._json(401, {"detail": "未授权"}) path = urlparse(self.path).path.strip("/").split("/") try: if path == ["v1", "jobs"]: job_id = uuid.uuid4().hex[:16] directory = DEFAULT_DATA / job_id directory.mkdir(parents=True, exist_ok=True) os.chmod(directory, 0o700) _jobs[job_id] = {"job_id": job_id, "status": "created", "phase": "login", "logs": [], "directory": str(directory), "created_at": int(time.time())} return self._json(201, _public_job(job_id)) if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "login": job_id = path[2] body = self._body() if job_id not in _jobs or body.get("provider") not in {"qq", "wechat"}: return self._json(400, {"detail": "无效任务或登录方式"}) _start_login(job_id, body["provider"], int(body.get("timeout", 600))) return self._json(202, _public_job(job_id)) if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection-options": job_id = path[2] body = self._body() options = _selection_options(job_id, str(body.get("platform", "android")), body.get("points"), body.get("zone_id")) _jobs[job_id]["selection_options"] = options return self._json(200, options) if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection": job_id = path[2] body = self._body() required = ("platform", "points", "product_id", "role_id", "role_name", "zone_id") if job_id not in _jobs or any(not body.get(key) for key in required): return self._json(400, {"detail": "选择参数不完整"}) selector = _load_selector() if body["platform"] not in selector.PLATFORMS: return self._json(400, {"detail": "不支持的平台"}) session_path = _job_dir(job_id) / "mall-session.json" cookies = json.loads(session_path.read_text(encoding="utf-8")).get("cookies", {}) product = next((item for item in selector.product_options(cookies, body["platform"]) if str(item["product_id"]) == str(body["product_id"]) and int(item["points"]) == int(body["points"])), None) if product is None: return self._json(400, {"detail": "商品已失效,请重新选择"}) cmall = selector.Cmall(cookies, str(product["offer_id"]), body["platform"]) zone = next((item for item in cmall.zones() if str(item["zone_id"]) == str(body["zone_id"])), None) if zone is None: return self._json(400, {"detail": "区服已失效,请重新选择"}) role = next((item for item in cmall.roles(zone["zone_id"]) if str(item["role_id"]) == str(body["role_id"])), None) if role is None or role.get("ban_status") == "1": return self._json(400, {"detail": "角色不可充值,请重新选择"}) _jobs[job_id]["selection"] = { "platform": body["platform"], "points": product["points"], "price_fen": product["price_fen"], "product_id": product["product_id"], "offer_id": product["offer_id"], "zone_id": zone["zone_id"], "zone_name": zone["name"], "role_id": role["role_id"], "role_name": role["name"], "area": role.get("area", ""), "partition": role.get("partition", ""), "order_pf": selector.PLATFORMS[body["platform"]]["order_pf"], } _jobs[job_id]["phase"] = "payment" result = _public_job(job_id) result["selection"] = _jobs[job_id]["selection"] return self._json(200, result) if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "payment": job_id = path[2] if job_id not in _jobs: return self._json(400, {"detail": "任务不存在"}) job = _jobs[job_id] with _lock: if job.get("status") == "stopped": return self._json(400, {"detail": "任务已停止,不能生成付款码"}) if job.get("status") in {"ordering", "waiting_payment", "payment_timeout"} \ or job.get("process_pid"): return self._json(400, {"detail": "已有进行中的支付流程,请勿重复操作"}) if not job.get("selection"): return self._json(400, {"detail": "请先完成角色选择"}) _start_payment(job_id, job["selection"]) return self._json(202, _public_job(job_id)) if len(path) == 5 and path[:2] == ["v1", "jobs"] and path[3:5] == ["payment", "check"]: job_id = path[2] if job_id not in _jobs: return self._json(400, {"detail": "任务不存在"}) job = _jobs[job_id] with _lock: if job.get("status") not in {"waiting_payment", "payment_timeout"}: return self._json(400, {"detail": "当前任务状态不支持检测到账"}) code = _check_payment_once(job_id) with _lock: if code == 0: job["status"] = "success" job["phase"] = "completed" job["message"] = "已确认到账,充值完成" elif code == 1: # 保持 payment_timeout:检测由人工反复触发,避免无人监控的 waiting_payment。 job["message"] = "仍未确认到账,请继续人工核对" else: job["message"] = "到账检测暂时失败,请稍后重试" return self._json(200, _public_job(job_id)) if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "stop": _stop_job(path[2]) return self._json(200, _public_job(path[2])) return self._json(404, {"detail": "接口不存在"}) except Exception as exc: # noqa: BLE001 return self._json(500, {"detail": str(exc)}) def do_GET(self) -> None: # noqa: N802 if urlparse(self.path).path == "/health": return self._json(200, {"status": "ok"}) if not self._authorized(): return self._json(401, {"detail": "未授权"}) path = urlparse(self.path).path.strip("/").split("/") if len(path) == 3 and path[:2] == ["v1", "jobs"] and path[2] in _jobs: return self._json(200, _public_job(path[2])) return self._json(404, {"detail": "接口不存在"}) def log_message(self, fmt: str, *args) -> None: return def _restore_jobs(data_dir: Path) -> int: """Rebuild in-memory jobs from task directories after a restart. Only payment-phase jobs with a persisted QR baseline are restored so that the payment QR stays visible and the read-only check can be re-run. """ restored = 0 for directory in data_dir.iterdir(): if not directory.is_dir(): continue job_id = directory.name if job_id in _jobs: continue session = directory / "mall-session.json" output = directory / "jsdom-order" meta_path = output / "payment-meta.json" if not session.exists(): continue if not meta_path.exists(): _jobs[job_id] = { "job_id": job_id, "status": "ready", "phase": "selection", "logs": ["服务重启,已从任务目录恢复登录会话"], "directory": str(directory), "created_at": int(time.time()), "message": "已恢复:登录会话有效,请选择充值信息", } restored += 1 continue try: meta = json.loads(meta_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue status = "waiting_payment" phase = "payment" message = "已恢复:付款码已生成,可重新检测到账" last_checked = None status_path = output / "payment-status.json" if status_path.exists(): try: record = json.loads(status_path.read_text(encoding="utf-8")) last_checked = record.get("checked_at") if record.get("matched_completion"): status = "success" phase = "completed" message = "已恢复:已确认到账" except (OSError, json.JSONDecodeError): pass _jobs[job_id] = { "job_id": job_id, "status": status, "phase": phase, "logs": ["服务重启,已从任务目录恢复本任务"], "directory": str(directory), "created_at": int(time.time()), "message": message, "payment_qr_created_at": meta.get("qr_created_at"), "payment_last_checked_at": last_checked, } restored += 1 return restored def main() -> int: global DEFAULT_DATA, WORKER_KEY parser = argparse.ArgumentParser(description="YYB admin worker") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8810) parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA) parser.add_argument("--evidence-ttl-hours", type=int, default=DEFAULT_EVIDENCE_TTL_HOURS, help="任务原始证据保留时长;0 表示不自动清理") parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY") args = parser.parse_args() # 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。 DEFAULT_DATA = Path(args.data_dir).resolve() if args.key is not None: WORKER_KEY = args.key if args.host not in {"127.0.0.1", "localhost", "::1"} and not WORKER_KEY: parser.error("监听非本机地址时必须设置 --key 或 YYB_WORKER_KEY") if args.evidence_ttl_hours < 0: parser.error("--evidence-ttl-hours 不能小于 0") DEFAULT_DATA.mkdir(parents=True, exist_ok=True) os.chmod(DEFAULT_DATA, 0o700) removed = _cleanup_expired_jobs(DEFAULT_DATA, args.evidence_ttl_hours) if removed: print(f"已清理 {removed} 个过期任务证据(保留期 {args.evidence_ttl_hours} 小时)", flush=True) restored = _restore_jobs(DEFAULT_DATA) if restored: print(f"已从任务目录恢复 {restored} 个支付任务", flush=True) server = ThreadingHTTPServer((args.host, args.port), Handler) print(f"YYB worker listening on {args.host}:{args.port}", flush=True) server.serve_forever() return 0 if __name__ == "__main__": raise SystemExit(main())