811 lines
30 KiB
Python
811 lines
30 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 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
|
||
|
||
|
||
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.IGNORECASE
|
||
)
|
||
clean = re.sub(
|
||
r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+",
|
||
"[敏感字段已隐藏]",
|
||
clean,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
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:
|
||
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:
|
||
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())
|