Files
live-hub-py/services/yyb-worker/runtime/scripts/yyb-worker.py
T
2026-08-12 17:30:00 +08:00

341 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 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
DEFAULT_DATA = ROOT / "config" / "worker-jobs"
WORKER_KEY = os.environ.get("YYB_WORKER_KEY", "")
_jobs: dict[str, dict] = {}
_lock = threading.Lock()
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) -> None:
job = _jobs[job_id]
environment = os.environ.copy()
environment.pop("NODE_OPTIONS", None)
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:
job["status"] = "failed"
job["phase"] = phase
job["message"] = f"{phase}失败(退出码 {code}"
elif phase == "login":
job["status"] = "ready"
job["phase"] = "selection"
job["message"] = "登录成功,请选择平台、点券、区服和角色"
else:
job["status"] = "success"
job["phase"] = "completed"
job["message"] = "付款流程已完成"
except Exception as exc: # noqa: BLE001
with _lock:
job["status"] = "failed"
job["message"] = str(exc)
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 = ["python3", 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 _start_payment(job_id: str, selection: dict) -> None:
directory = _job_dir(job_id)
session = directory / "mall-session.json"
response = directory / "mall-order-response.json"
output = directory / "jsdom-order"
command = ["python3", "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("order_pf"):
command.extend(["--pf", str(selection["order_pf"])])
def run() -> None:
_run_process(job_id, command, "order")
job = _jobs[job_id]
if job.get("status") != "success" or not response.exists():
return
pay_command = ["python3", "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"])]
_run_process(job_id, pay_command, "payment")
threading.Thread(target=run, daemon=True).start()
def _stop_job(job_id: str) -> None:
if job_id not in _jobs:
raise ValueError("任务不存在")
pid = _jobs[job_id].get("process_pid")
if pid:
try:
os.kill(int(pid), 15)
except ProcessLookupError:
pass
with _lock:
_jobs[job_id]["status"] = "failed"
_jobs[job_id]["phase"] = "stopped"
_jobs[job_id]["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
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"],
"order_pf": selector.PLATFORMS[body["platform"]]["order_pf"],
}
_jobs[job_id]["phase"] = "payment"
return self._json(200, _public_job(job_id))
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "payment":
job_id = path[2]
if job_id not in _jobs or not _jobs[job_id].get("selection"):
return self._json(400, {"detail": "请先完成角色选择"})
_start_payment(job_id, _jobs[job_id]["selection"])
return self._json(202, _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 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()
DEFAULT_DATA = args.data_dir
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)
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())