579 lines
26 KiB
Python
579 lines
26 KiB
Python
#!/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 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"
|
||
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 _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)
|
||
with _lock:
|
||
job["logs"] = (job.get("logs", []) + [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"
|
||
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()
|
||
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
|
||
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)
|
||
try:
|
||
result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True,
|
||
env=environment, timeout=90)
|
||
except subprocess.TimeoutExpired:
|
||
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)
|
||
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)
|
||
_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("--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")
|
||
DEFAULT_DATA.mkdir(parents=True, exist_ok=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())
|