From 5074faf9f6cf0e8fbee9e07077b4705f2a56978d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 12 Aug 2026 19:12:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=E9=83=A8=E5=88=86?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=AE=9D=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dev.sh | 4 + .../yyb-worker/runtime/scripts/jsdom-pay.py | 120 ++++- .../runtime/scripts/login-wechat.py | 11 +- .../yyb-worker/runtime/scripts/yyb-worker.py | 288 +++++++++-- .../20260812_0022_yyb_payment_meta.py | 26 + web/backend/models.py | 4 + web/backend/permissions.py | 2 + web/backend/routers/yyb.py | 119 ++++- web/backend/services/yyb_service.py | 42 +- web/backend/services/yyb_worker_client.py | 3 + web/frontend/src/api/types.ts | 5 + web/frontend/src/api/yyb.ts | 5 +- web/frontend/src/pages/YybRechargePage.tsx | 468 ++++++++++++++++-- 13 files changed, 980 insertions(+), 117 deletions(-) create mode 100644 web/backend/migrations/versions/20260812_0022_yyb_payment_meta.py diff --git a/dev.sh b/dev.sh index 7265b18..2d83b71 100755 --- a/dev.sh +++ b/dev.sh @@ -138,6 +138,10 @@ mkdir -p "$YYB_WORKER_DATA_DIR" trap cleanup EXIT INT TERM start_yyb_worker() { + if curl -fsS "${DEV_YYB_WORKER_URL}/health" >/dev/null 2>&1; then + echo "本地 YYB Worker 已运行: ${DEV_YYB_WORKER_URL}(复用现有进程,如需重启请先停掉该进程)" + return + fi if [ ! -d "$YYB_WORKER_NODE_MODULES/jsdom" ]; then echo "正在安装本地 YYB Worker Node 依赖..." (cd "$ROOT_DIR/services/yyb-worker" && npm ci) diff --git a/services/yyb-worker/runtime/scripts/jsdom-pay.py b/services/yyb-worker/runtime/scripts/jsdom-pay.py index 7c6b50c..b0375c7 100644 --- a/services/yyb-worker/runtime/scripts/jsdom-pay.py +++ b/services/yyb-worker/runtime/scripts/jsdom-pay.py @@ -212,6 +212,80 @@ def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], m path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, bool], document: dict) -> None: + """Persist the order identifiers and pre-payment baseline needed by a later check. + + ``check`` only re-reads this file and never touches web_save, so re-running a + check is side-effect free. + """ + from pyvm.order_status import order_ids + + record = { + "order_identifiers": { + "token_id": order.get("token_id", ""), + "transaction_id": order.get("transaction_id", ""), + "out_trade_no": order.get("out_trade_no", ""), + }, + "baseline_order_states": baseline, + "baseline_order_ids": sorted(order_ids(document)), + "qr_created_at": int(time.time()), + } + path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _match_finished_by_identifiers(listed: list[dict], identifiers: dict[str, str]) -> dict | None: + """Match a finished order by the identifiers recorded at QR creation time. + + The official order list may not carry token_id/out_trade_no, so no fallback + to "any newly completed order" is allowed: an unrelated order finishing on + the same account must not be reported as this payment. + """ + from pyvm.order_status import is_finished + + for item in listed: + if is_finished(item) and any( + identifiers.get(key) and str(item.get(key, "")) == str(identifiers[key]) + for key in identifiers + ): + return item + return None + + +def cmd_check_only(session_path: Path, out_dir: Path) -> int: + """Read-only payment completion check bound to a previously created QR. + + Only an order identifier recorded at QR creation time counts as a match; + a fallback to "any newly completed order" is intentionally NOT used because + the order list cannot be proven to carry our token/out_trade_no fields, + and another order completing on the same account could be misjudged. + + Exit codes: 0 = confirmed, 1 = not confirmed yet, 2 = check failed. + """ + from pyvm.order_status import ( + completion_summary, + get_official_orders, + is_finished, + ) + + session = load_json(session_path) + cookies = dict(session.get("cookies", {})) + if not cookies: + raise ValueError("会话 cookies 为空,无法检测到账") + meta = load_json(out_dir / "payment-meta.json") + baseline = meta.get("baseline_order_states", {}) + print("[jsdom-pay:check] 拉取官方订单列表...") + document = get_official_orders(cookies) + listed = [item for item in document.get("list", []) if isinstance(item, dict)] + matched = _match_finished_by_identifiers(listed, meta.get("order_identifiers", {})) + status_path = out_dir / "payment-status.json" + save_payment_status(status_path, document, baseline, matched) + if matched: + print(f"[jsdom-pay:check] 已按订单标识确认到账: {completion_summary(matched)}") + return 0 + print("[jsdom-pay:check] 未匹配到本次订单标识,保持未确认状态,请人工核对") + return 1 + + def main() -> int: parser = argparse.ArgumentParser(description="YYB: jsdom DeviceFP + web_save -> 微信付款码") parser.add_argument("--session", default=str(ROOT / "config/mall-session.json")) @@ -228,12 +302,20 @@ def main() -> int: parser.add_argument("--payment-interval", type=float, default=3, help="订单完成状态检查间隔秒数") parser.add_argument("--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成") + parser.add_argument("--check-only", action="store_true", + help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单") args = parser.parse_args() if args.payment_timeout <= 0 or args.payment_interval <= 0: raise ValueError("--payment-timeout 和 --payment-interval 必须为正数") session_path = Path(args.session).resolve() response_path = Path(args.mall_response).resolve() + stamp = time.strftime("%Y%m%d-%H%M%S") + out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}" + if args.check_only: + out_dir.mkdir(parents=True, exist_ok=True) + return cmd_check_only(session_path, out_dir) + session = load_json(session_path) cookies = dict(session.get("cookies", {})) if not cookies: @@ -312,6 +394,17 @@ def main() -> int: order, cookies, web_token, anti_token, encrypt_msg, args.zone_id, payment_pf, args.amount_fen, ) + from pyvm.order_status import order_completion_states, get_official_orders + + # 尽量在发起支付前建立基线,缩小极快支付造成的检测窗口;失败不阻塞支付,付款码生成后重试。 + baseline_document = None + baseline_states = None + try: + baseline_document = get_official_orders(cookies) + baseline_states = order_completion_states(baseline_document) + print("[jsdom-pay] 支付前订单基线已建立") + except Exception as exc: # noqa: BLE001 + print(f"[jsdom-pay] 支付前建立基线失败({exc}),将在付款码生成后重试") print("[jsdom-pay] 提交 web_save...") raw = request_bytes(SAVE_URL, cookies, body.encode()) (out_dir / "web-save-response.json").write_text(raw, encoding="utf-8") @@ -331,23 +424,36 @@ def main() -> int: make_qr(sign, qr_path) print("[jsdom-pay] 微信付款码已生成") print(f"[jsdom-pay] PNG: {qr_path}") - print(f"[jsdom-pay] 付款链接: {sign}") + + if baseline_document is None: + try: + baseline_document = get_official_orders(cookies) + baseline_states = order_completion_states(baseline_document) + except Exception as exc: # noqa: BLE001 + # 付款码已生成,基线缺失只影响确认精度(仅按订单标识确认),不能阻断付款。 + print(f"[jsdom-pay] 基线获取失败: {exc};将仅按订单标识确认到账") + baseline_document = {"list": []} + baseline_states = {} + print("[jsdom-pay] 记录付款前订单基线与本次订单标识...") + save_payment_meta(out_dir / "payment-meta.json", order, baseline_states, baseline_document) if args.skip_payment_check: - print("[jsdom-pay] 已跳过付款结果检查。") + print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测") return 0 - from pyvm.order_status import find_completed_order, get_official_orders, order_completion_states - print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...") - baseline_document = get_official_orders(cookies) - baseline_states = order_completion_states(baseline_document) status_path = out_dir / "payment-status.json" save_payment_status(status_path, baseline_document, baseline_states) + identifiers = {key: value for key, value in { + "token_id": order.get("token_id", ""), + "transaction_id": order.get("transaction_id", ""), + "out_trade_no": order.get("out_trade_no", ""), + }.items() if value} deadline = time.monotonic() + args.payment_timeout while time.monotonic() < deadline: time.sleep(args.payment_interval) document = get_official_orders(cookies) - completed = find_completed_order(document, baseline_states) + completed = _match_finished_by_identifiers( + [item for item in document.get("list", []) if isinstance(item, dict)], identifiers) save_payment_status(status_path, document, baseline_states, completed) if completed: print("[jsdom-pay] 微信付款成功,商城订单已完成。") diff --git a/services/yyb-worker/runtime/scripts/login-wechat.py b/services/yyb-worker/runtime/scripts/login-wechat.py index 8ce43fb..e85990c 100644 --- a/services/yyb-worker/runtime/scripts/login-wechat.py +++ b/services/yyb-worker/runtime/scripts/login-wechat.py @@ -175,13 +175,16 @@ def main() -> int: poll_query["last"] = last poll = client.request(f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url) errcode, code = parse_poll(poll.text) - if errcode == 405: + if errcode == 405 and code: break - if errcode == 402: + if errcode == 405: + # 已扫码、手机待确认:继续轮询,不能立即退出(否则 code 为空导致登录失败)。 + last = "405" + elif errcode == 402: raise RuntimeError("二维码已过期,请重新执行登录") - if errcode == 403: + elif errcode == 403: raise RuntimeError("用户取消了扫码登录") - if errcode == 404: + else: last = "404" time.sleep(args.interval) else: diff --git a/services/yyb-worker/runtime/scripts/yyb-worker.py b/services/yyb-worker/runtime/scripts/yyb-worker.py index 42aae38..a44bb66 100644 --- a/services/yyb-worker/runtime/scripts/yyb-worker.py +++ b/services/yyb-worker/runtime/scripts/yyb-worker.py @@ -55,10 +55,17 @@ def _safe_log(job: dict, line: str) -> None: job["logs"] = (job.get("logs", []) + [clean.strip()])[-100:] -def _run_process(job_id: str, command: list[str], phase: str) -> None: +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" @@ -75,21 +82,27 @@ def _run_process(job_id: str, command: list[str], phase: str) -> None: with _lock: job["process_pid"] = None if code != 0: - job["status"] = "failed" - job["phase"] = phase - job["message"] = f"{phase}失败(退出码 {code})" + 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"] = "登录成功,请选择平台、点券、区服和角色" - else: + 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: @@ -131,47 +144,158 @@ def _selection_options(job_id: str, platform: str, points: int | None, zone_id: "default_product": product, "default_zone": selected_zone} -def _start_payment(job_id: str, selection: dict) -> None: +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"] = f"{failed_message}(退出码 {code})" + 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" - command = [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)] + 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("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(): + 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 - pay_command = [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"])] - _run_process(job_id, pay_command, "payment") - threading.Thread(target=run, daemon=True).start() + _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("任务不存在") - pid = _jobs[job_id].get("process_pid") + job = _jobs[job_id] + pid = job.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"] = "任务已停止" + 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: @@ -206,6 +330,9 @@ def _public_job(job_id: str) -> dict: } 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 @@ -289,13 +416,44 @@ class Handler(BaseHTTPRequestHandler): "order_pf": selector.PLATFORMS[body["platform"]]["order_pf"], } _jobs[job_id]["phase"] = "payment" - return self._json(200, _public_job(job_id)) + 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 or not _jobs[job_id].get("selection"): + 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, _jobs[job_id]["selection"]) + _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])) @@ -317,6 +475,66 @@ class Handler(BaseHTTPRequestHandler): 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") @@ -325,12 +543,16 @@ def main() -> int: 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 + # 强制绝对路径:子进程以 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() diff --git a/web/backend/migrations/versions/20260812_0022_yyb_payment_meta.py b/web/backend/migrations/versions/20260812_0022_yyb_payment_meta.py new file mode 100644 index 0000000..b980902 --- /dev/null +++ b/web/backend/migrations/versions/20260812_0022_yyb_payment_meta.py @@ -0,0 +1,26 @@ +"""应用宝支付元数据字段:金额与支付时间线""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260812_0022" +down_revision: Union[str, None] = "20260812_0021" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("yyb_recharge_tasks", sa.Column("price_fen", sa.Integer(), nullable=True)) + op.add_column("yyb_recharge_tasks", sa.Column("payment_started_at", sa.DateTime(), nullable=True)) + op.add_column("yyb_recharge_tasks", sa.Column("payment_qr_created_at", sa.DateTime(), nullable=True)) + op.add_column("yyb_recharge_tasks", sa.Column("payment_last_checked_at", sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("yyb_recharge_tasks", "payment_last_checked_at") + op.drop_column("yyb_recharge_tasks", "payment_qr_created_at") + op.drop_column("yyb_recharge_tasks", "payment_started_at") + op.drop_column("yyb_recharge_tasks", "price_fen") diff --git a/web/backend/models.py b/web/backend/models.py index 2d6fde5..c9ef83a 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -144,12 +144,16 @@ class YybRechargeTask(Base): zone_name = Column(String(128), default="") role_id = Column(String(64), default="") role_name = Column(String(128), default="") + price_fen = Column(Integer, nullable=True) status = Column(String(32), default="created", nullable=False, index=True) phase = Column(String(32), default="login", nullable=False) message = Column(String(512), default="") result = Column(JSON, nullable=True) login_qr_data = Column(EncryptedText(), default="") payment_qr_data = Column(EncryptedText(), default="") + payment_started_at = Column(DateTime, nullable=True) + payment_qr_created_at = Column(DateTime, nullable=True) + payment_last_checked_at = Column(DateTime, nullable=True) created_at = Column(DateTime, default=_utcnow) finished_at = Column(DateTime, nullable=True) diff --git a/web/backend/permissions.py b/web/backend/permissions.py index cc93228..b5ba135 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -28,6 +28,7 @@ PERMISSIONS = { "douyu:config": "斗鱼配置管理", "yyb:session": "应用宝扫码登录与角色查询", "yyb:recharge": "应用宝充值与付款", + "yyb:manage": "接管并操作他人的应用宝任务", "yyb:history": "查看应用宝充值历史", # 虎牙 "huya:account": "虎牙账号管理(兼容旧权限)", @@ -67,6 +68,7 @@ ROLE_PERMISSIONS = { "douyu:config", "yyb:session", "yyb:recharge", + "yyb:manage", "yyb:history", "huya:account", "huya:view_all", diff --git a/web/backend/routers/yyb.py b/web/backend/routers/yyb.py index 96bad03..f556df7 100644 --- a/web/backend/routers/yyb.py +++ b/web/backend/routers/yyb.py @@ -12,18 +12,29 @@ from ..deps import get_current_user, require_permission from ..models import User, YybRechargeTask from ..permissions import user_has_permission from ..schemas import YybLoginRequest, YybSelectionRequest, YybTaskCreateRequest -from ..services.yyb_service import public_task, sync_task +from ..services.yyb_service import _utcnow, public_task, sync_task from ..services.yyb_worker_client import YybWorkerClient, YybWorkerError router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"]) -def _get_task(db: Session, task_id: int, current: User) -> YybRechargeTask: +def _get_task(db: Session, task_id: int, current: User, write: bool = False) -> YybRechargeTask: + """读取任务。 + + 查看:本人或 yyb:history;写操作:本人或 yyb:manage。 + 历史权限只读,不能通过本函数修改他人任务。 + """ task = db.query(YybRechargeTask).filter(YybRechargeTask.id == task_id).first() if not task: raise HTTPException(404, "充值任务不存在") - if task.created_by != current.id and not user_has_permission(current, "yyb:history"): + if task.created_by == current.id: + return task + if write: + if not user_has_permission(current, "yyb:manage"): + raise HTTPException(403, "无权操作他人的充值任务") + return task + if not user_has_permission(current, "yyb:history"): raise HTTPException(403, "无权查看该充值任务") return task @@ -35,6 +46,11 @@ def _worker_call(call): raise HTTPException(502, str(exc)) from exc +def _creator_username(db: Session, task: YybRechargeTask) -> str: + user = db.query(User).filter(User.id == task.created_by).first() + return user.username if user else "" + + @router.post("/tasks") def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): data = _worker_call(YybWorkerClient().create_job) @@ -44,12 +60,12 @@ def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), cu db.add(task) db.commit() db.refresh(task) - return public_task(task, include_qr=False) + return public_task(task, include_qr=False, creator_username=current.username) @router.post("/tasks/{task_id}/login") def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): - task = _get_task(db, task_id, current) + task = _get_task(db, task_id, current, write=True) data = _worker_call(lambda: YybWorkerClient().login(task.worker_job_id, payload.provider, payload.timeout)) task.provider = payload.provider task.status = str(data.get("status", "waiting_login")) @@ -58,7 +74,7 @@ def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db), if data.get("qr_data"): task.login_qr_data = data["qr_data"] db.commit() - return public_task(task) + return public_task(task, creator_username=_creator_username(db, task)) @router.get("/tasks/{task_id}") @@ -70,18 +86,34 @@ def get_task(task_id: int, db: Session = Depends(get_db), current: User = Depend # Worker 暂时重启时仍返回最近一次持久化状态。 pass return public_task(task, include_qr=user_has_permission(current, "yyb:session"), - include_payment_qr=user_has_permission(current, "yyb:recharge")) + include_payment_qr=user_has_permission(current, "yyb:recharge"), + creator_username=_creator_username(db, task)) @router.get("/tasks") -def list_tasks(limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:history"))): - tasks = db.query(YybRechargeTask).order_by(YybRechargeTask.id.desc()).limit(limit).all() - return [public_task(task, include_qr=False) for task in tasks] +def list_tasks(scope: str = Query("mine", pattern="^(mine|all)$"), + status: str | None = Query(None, max_length=32), + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): + if scope == "all" and not user_has_permission(current, "yyb:history"): + raise HTTPException(403, "无权查看全部充值任务") + query = db.query(YybRechargeTask) + if scope == "mine": + query = query.filter(YybRechargeTask.created_by == current.id) + if status: + query = query.filter(YybRechargeTask.status == status) + rows = query.order_by(YybRechargeTask.id.desc()).limit(limit).all() + usernames = { + user.id: user.username + for user in db.query(User).filter(User.id.in_({row.created_by for row in rows})).all() + } + return [public_task(task, include_qr=False, creator_username=usernames.get(task.created_by, "")) + for task in rows] @router.get("/tasks/{task_id}/selection-options") def selection_options(task_id: int, platform: str = Query("android"), points: int | None = Query(None), zone_id: str | None = Query(None), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): - task = _get_task(db, task_id, current) + task = _get_task(db, task_id, current, write=True) data = _worker_call(lambda: YybWorkerClient().selection_options(task.worker_job_id, platform, points, zone_id)) task.platform = platform db.commit() @@ -90,35 +122,72 @@ def selection_options(task_id: int, platform: str = Query("android"), points: in @router.post("/tasks/{task_id}/selection") def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): - task = _get_task(db, task_id, current) + task = _get_task(db, task_id, current, write=True) data = _worker_call(lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump())) selected = data.get("selection", payload.model_dump()) for field in ("platform", "points", "product_id", "zone_id", "zone_name", "role_id", "role_name"): setattr(task, field, selected[field]) - task.phase, task.status, task.message = "payment", "ready", "选择已保存,可以创建付款码" + task.price_fen = int(selected.get("price_fen") or 0) + task.phase, task.status, task.message = "payment", "ready", "选择已保存,可以生成付款码" db.commit() - return public_task(task) + return public_task(task, creator_username=_creator_username(db, task)) @router.post("/tasks/{task_id}/payment") def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))): - task = _get_task(db, task_id, current) + # 接手他人任务需 yyb:manage;锁行做原子状态迁移,防止并发双击重复下单。 + _get_task(db, task_id, current, write=True) + task = db.query(YybRechargeTask).filter(YybRechargeTask.id == task_id).with_for_update().first() + if not task: + raise HTTPException(404, "充值任务不存在") + if task.status != "ready" or task.phase != "payment": + raise HTTPException(409, "当前状态不能生成付款码(仅待生成付款码状态可操作)") if not task.product_id or not task.role_id: raise HTTPException(400, "请先完成商品、区服和角色选择") - data = _worker_call(lambda: YybWorkerClient().payment(task.worker_job_id)) - task.status = str(data.get("status", "running")) - task.phase = "payment" - task.message = "正在创建订单和付款码" + task.status = "ordering" + task.message = "正在创建商城订单" + task.payment_started_at = _utcnow() db.commit() - return public_task(task) + try: + data = _worker_call(lambda: YybWorkerClient().payment(task.worker_job_id)) + except HTTPException: + # Worker 不可用时回滚原子迁移,避免任务卡死在“下单中”。 + task.status = "ready" + task.phase = "payment" + task.message = "生成付款码失败,请稍后重试" + task.payment_started_at = None + db.commit() + raise + task.status = str(data.get("status", "ordering")) + task.phase = str(data.get("phase", "payment")) + task.message = str(data.get("message", task.message)) + db.commit() + return public_task(task, include_qr=False, creator_username=_creator_username(db, task)) + + +@router.post("/tasks/{task_id}/payment/check") +def payment_check(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))): + task = _get_task(db, task_id, current, write=True) + if task.status not in {"waiting_payment", "payment_timeout"}: + raise HTTPException(409, "当前任务状态不支持检测到账") + data = _worker_call(lambda: YybWorkerClient().payment_check(task.worker_job_id)) + task.status = str(data.get("status", task.status)) + task.phase = str(data.get("phase", task.phase)) + task.message = str(data.get("message", task.message)) + task.payment_last_checked_at = _utcnow() + if task.status == "success": + task.phase = "completed" + db.commit() + return public_task(task, include_qr=False, creator_username=_creator_username(db, task)) @router.post("/tasks/{task_id}/stop") def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))): - task = _get_task(db, task_id, current) + task = _get_task(db, task_id, current, write=True) data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id)) - task.status = "failed" - task.phase = "stopped" - task.message = "任务已停止" + task.status = "stopped" + task.phase = str(data.get("phase", "stopped")) + task.message = str(data.get("message", "任务已停止")) + task.finished_at = _utcnow() db.commit() - return public_task(task) + return public_task(task, creator_username=_creator_username(db, task)) diff --git a/web/backend/services/yyb_service.py b/web/backend/services/yyb_service.py index 2d10691..db046ba 100644 --- a/web/backend/services/yyb_service.py +++ b/web/backend/services/yyb_service.py @@ -15,12 +15,27 @@ def _utcnow(): return datetime.now(timezone.utc) +def _from_ts(value) -> datetime | None: + try: + return datetime.fromtimestamp(int(value), tz=timezone.utc) if value else None + except (TypeError, ValueError, OSError): + return None + + def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int: - rows = db.query(YybRechargeTask).filter(YybRechargeTask.status.in_(["created", "waiting_login", "running", "ready"])).all() + rows = db.query(YybRechargeTask).filter( + YybRechargeTask.status.in_(["created", "waiting_login", "ready", "running", "ordering", "waiting_payment"]) + ).all() for task in rows: - task.status = "failed" - task.message = message - task.finished_at = _utcnow() + if task.status == "ordering": + task.status = "waiting_payment" + task.message = "服务重启,支付流程中断,可重新检测到账" + elif task.status == "waiting_payment": + task.message = "服务重启,到账检测中断,可重新检测到账" + else: + task.status = "failed" + task.message = message + task.finished_at = _utcnow() if rows: db.commit() return len(rows) @@ -44,23 +59,36 @@ def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> Yy "logs": data.get("logs", []), "payment_status": data.get("payment_status"), } + qr_created_at = _from_ts(data.get("payment_qr_created_at")) + if qr_created_at: + task.payment_qr_created_at = qr_created_at + last_checked_at = _from_ts(data.get("payment_last_checked_at")) + if last_checked_at: + task.payment_last_checked_at = last_checked_at if task.status in {"success", "failed"} and task.finished_at is None: task.finished_at = _utcnow() + if task.status not in {"success", "failed", "stopped"} and task.finished_at is not None: + task.finished_at = None db.commit() db.refresh(task) return task def public_task(task: YybRechargeTask, include_qr: bool = True, - include_payment_qr: bool | None = None) -> dict[str, Any]: + include_payment_qr: bool | None = None, + creator_username: str = "") -> dict[str, Any]: result: dict[str, Any] = { "id": task.id, "task_id": task.task_id, "provider": task.provider, "platform": task.platform, "points": task.points, + "price_fen": task.price_fen, "product_id": task.product_id, "zone_id": task.zone_id, "zone_name": task.zone_name, "role_id": task.role_id, "role_name": task.role_name, "status": task.status, "phase": task.phase, "message": task.message, "result": task.result, - "created_by": task.created_by, "created_at": task.created_at, - "finished_at": task.finished_at, + "created_by": task.created_by, "created_by_username": creator_username, + "created_at": task.created_at, "finished_at": task.finished_at, + "payment_started_at": task.payment_started_at, + "payment_qr_created_at": task.payment_qr_created_at, + "payment_last_checked_at": task.payment_last_checked_at, } if task.result: result["login_qr_mime_type"] = task.result.get("login_qr_mime_type", "image/jpeg") diff --git a/web/backend/services/yyb_worker_client.py b/web/backend/services/yyb_worker_client.py index 55a512a..cc9f7fc 100644 --- a/web/backend/services/yyb_worker_client.py +++ b/web/backend/services/yyb_worker_client.py @@ -53,5 +53,8 @@ class YybWorkerClient: def payment(self, worker_job_id: str) -> dict[str, Any]: return self._request("POST", f"/v1/jobs/{worker_job_id}/payment") + def payment_check(self, worker_job_id: str) -> dict[str, Any]: + return self._request("POST", f"/v1/jobs/{worker_job_id}/payment/check") + def stop(self, worker_job_id: str) -> dict[str, Any]: return self._request("POST", f"/v1/jobs/{worker_job_id}/stop") diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 1081849..0f58479 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -152,9 +152,11 @@ export interface YybTask { task_id: string; worker_job_id?: string; created_by: number; + created_by_username?: string; provider: string; platform: 'android' | 'ios'; points: number | null; + price_fen?: number | null; product_id: string; zone_id: string; zone_name: string; @@ -168,6 +170,9 @@ export interface YybTask { login_qr_mime_type?: string; payment_qr_data?: string; payment_qr_mime_type?: string; + payment_started_at?: string | null; + payment_qr_created_at?: string | null; + payment_last_checked_at?: string | null; created_at: string | null; finished_at: string | null; } diff --git a/web/frontend/src/api/yyb.ts b/web/frontend/src/api/yyb.ts index 7e1738d..62f8e65 100644 --- a/web/frontend/src/api/yyb.ts +++ b/web/frontend/src/api/yyb.ts @@ -6,7 +6,8 @@ export const yybApi = { login: (id: number, provider: 'qq' | 'wechat') => api.post(`/yyb/tasks/${id}/login`, { provider, timeout: 600 }), getTask: (id: number) => api.get(`/yyb/tasks/${id}`), - listTasks: () => api.get('/yyb/tasks'), + listTasks: (params?: { scope?: 'mine' | 'all'; status?: string }) => + api.get('/yyb/tasks', { params }), options: (id: number, platform: 'android' | 'ios', points?: number, zone_id?: string) => api.get(`/yyb/tasks/${id}/selection-options`, { params: { platform, ...(points ? { points } : {}), ...(zone_id ? { zone_id } : {}) }, @@ -14,4 +15,6 @@ export const yybApi = { select: (id: number, data: { platform: 'android' | 'ios'; points: number; product_id: string; zone_id: string; zone_name: string; role_id: string; role_name: string }) => api.post(`/yyb/tasks/${id}/selection`, data), payment: (id: number) => api.post(`/yyb/tasks/${id}/payment`), + paymentCheck: (id: number) => api.post(`/yyb/tasks/${id}/payment/check`), + stop: (id: number) => api.post(`/yyb/tasks/${id}/stop`), }; diff --git a/web/frontend/src/pages/YybRechargePage.tsx b/web/frontend/src/pages/YybRechargePage.tsx index e55beac..ed6b0d5 100644 --- a/web/frontend/src/pages/YybRechargePage.tsx +++ b/web/frontend/src/pages/YybRechargePage.tsx @@ -1,6 +1,12 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Alert, Button, Card, Col, Descriptions, Image, Radio, Row, Select, Space, Steps, Tag, Typography, message } from 'antd'; -import { CheckCircleOutlined, QrcodeOutlined, ReloadOutlined, ShoppingCartOutlined, WechatOutlined } from '@ant-design/icons'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, Button, Card, Col, Collapse, Descriptions, Empty, Image, List, Popconfirm, + Radio, Row, Select, Space, Spin, Tag, Typography, message, +} from 'antd'; +import { + CheckCircleOutlined, QqOutlined, ReloadOutlined, ShoppingCartOutlined, WechatOutlined, StopOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; import { yybApi } from '../api/modules'; import type { YybSelectionOptions, YybTask } from '../api/types'; import { getUser } from '../store/auth'; @@ -8,89 +14,471 @@ import { usePermissions } from '../hooks/usePermissions'; const { Title, Text } = Typography; -const phaseIndex: Record = { login: 0, selection: 1, payment: 2, completed: 3 }; +const STATUS_META: Record = { + created: { label: '待登录', color: 'default' }, + waiting_login: { label: '等待扫码', color: 'processing' }, + ready: { label: '待操作', color: 'gold' }, + running: { label: '处理中', color: 'processing' }, + ordering: { label: '下单中', color: 'processing' }, + waiting_payment: { label: '待微信付款', color: 'blue' }, + payment_timeout: { label: '确认超时', color: 'warning' }, + success: { label: '已确认到账', color: 'success' }, + failed: { label: '失败', color: 'error' }, + stopped: { label: '已停止', color: 'default' }, +}; + +const TERMINAL_STATUSES = ['success', 'failed', 'stopped']; + +const fmtTime = (value?: string | null) => (value ? dayjs(value).format('MM-DD HH:mm') : '-'); +const yuan = (fen?: number | null) => `¥${((fen ?? 0) / 100).toFixed(2)}`; + +function OrderSummary({ task }: { task: YybTask }) { + return ( + + {task.platform === 'ios' ? 'iOS 区' : 'Android 区'} + + {task.points ? `${task.points} 点券` : '-'} + {task.price_fen ? ({yuan(task.price_fen)}) : null} + + {task.zone_name || '-'} + {task.role_name ? `${task.role_name}(${task.role_id})` : '-'} + {task.price_fen ? yuan(task.price_fen) : '-'} + + ); +} export default function YybRechargePage() { const [task, setTask] = useState(null); + const [recentTasks, setRecentTasks] = useState([]); const [options, setOptions] = useState(null); const [platform, setPlatform] = useState<'android' | 'ios'>('android'); const [points, setPoints] = useState(); const [zoneId, setZoneId] = useState(); const [roleId, setRoleId] = useState(); const [loading, setLoading] = useState(false); + const [optionsLoading, setOptionsLoading] = useState(false); + const [logsOpen, setLogsOpen] = useState(false); const { can } = usePermissions(getUser()); - const refresh = async () => { - if (!task) return; - try { setTask(await yybApi.getTask(task.id)); } catch { /* 保留当前状态 */ } - }; + const optionsSeq = useRef(0); + const taskIdRef = useRef(null); + useEffect(() => { taskIdRef.current = task?.id ?? null; }, [task?.id]); + + const loadRecent = useCallback(async (scope: 'mine' | 'all') => { + try { setRecentTasks(await yybApi.listTasks({ scope })); } + catch (error) { message.error(error instanceof Error ? error.message : '加载任务列表失败'); } + }, []); + + const refresh = useCallback(async () => { + const id = taskIdRef.current; + if (id == null) return; + try { setTask(await yybApi.getTask(id)); } catch { /* 保留当前状态 */ } + }, []); + + const currentStatus = task?.status; + const currentPhase = task?.phase; useEffect(() => { - if (!task || ['success', 'failed'].includes(task.status)) return; - const timer = window.setInterval(refresh, 2500); + if (currentStatus === undefined || TERMINAL_STATUSES.includes(currentStatus)) return; + const timer = window.setInterval(() => void refresh(), 2500); return () => window.clearInterval(timer); - }, [task?.id, task?.status]); + }, [task?.id, currentStatus, refresh]); useEffect(() => { - if (task?.status === 'ready' && task.phase === 'selection' && !options) { - void loadOptions(platform); - } - }, [task?.status, task?.phase]); + let cancelled = false; + (async () => { + try { + const tasks = await yybApi.listTasks({ scope: 'mine' }); + if (cancelled) return; + setRecentTasks(tasks); + const active = tasks.find(item => !TERMINAL_STATUSES.includes(item.status)); + if (active) { + const synced = await yybApi.getTask(active.id); + if (!cancelled) setTask(synced); + } + } catch { /* 忽略列表加载失败 */ } + })(); + return () => { cancelled = true; }; + }, []); - const loadOptions = async (nextPlatform: 'android' | 'ios', nextPoints?: number, nextZone?: string) => { - if (!task) return; - setLoading(true); + const loadOptions = useCallback(async (nextPlatform: 'android' | 'ios', nextPoints?: number, nextZone?: string) => { + const id = taskIdRef.current; + if (id == null) return; + const seq = ++optionsSeq.current; + setOptionsLoading(true); try { - const data = await yybApi.options(task.id, nextPlatform, nextPoints, nextZone); + const data = await yybApi.options(id, nextPlatform, nextPoints, nextZone); + if (seq !== optionsSeq.current) return; setOptions(data); if (nextPoints === undefined && data.default_product) setPoints(data.default_product.points); if (nextZone === undefined && data.default_zone) setZoneId(data.default_zone.zone_id); - setRoleId(undefined); + const firstRole = data.roles.filter(item => item.ban_status !== '1')[0]; + setRoleId(firstRole?.role_id); } catch (error) { message.error(error instanceof Error ? error.message : '查询商品失败'); } - finally { setLoading(false); } - }; + finally { if (seq === optionsSeq.current) setOptionsLoading(false); } + }, []); + + useEffect(() => { + if (currentStatus === 'ready' && currentPhase === 'selection' && !options) { + void loadOptions(platform); + } + }, [currentStatus, currentPhase, options, platform, loadOptions]); const selectedProduct = useMemo(() => options?.products.find(item => item.points === points), [options, points]); const selectedZone = useMemo(() => options?.zones.find(item => item.zone_id === zoneId), [options, zoneId]); - const selectedRole = useMemo(() => options?.roles.find(item => item.role_id === roleId), [options, roleId]); + const selectedRole = useMemo(() => options?.roles.filter(item => item.ban_status !== '1').find(item => item.role_id === roleId), [options, roleId]); + + const openTask = async (id: number) => { + setLoading(true); + try { + const synced = await yybApi.getTask(id); + setTask(synced); + setOptions(null); + setRoleId(undefined); + setLogsOpen(false); + if (synced.phase === 'selection') setPlatform(synced.platform || 'android'); + } catch (error) { message.error(error instanceof Error ? error.message : '加载任务失败'); } + finally { setLoading(false); } + }; const createTask = async () => { setLoading(true); - try { setTask(await yybApi.createTask()); setOptions(null); } catch (error) { message.error(error instanceof Error ? error.message : '创建任务失败'); } + try { setTask(await yybApi.createTask()); setOptions(null); setRoleId(undefined); } + catch (error) { message.error(error instanceof Error ? error.message : '创建任务失败'); } finally { setLoading(false); } }; + const startLogin = async (provider: 'qq' | 'wechat') => { - if (!task) return; setLoading(true); - try { setTask(await yybApi.login(task.id, provider)); } catch (error) { message.error(error instanceof Error ? error.message : '启动登录失败'); } + try { + let current = task; + if (!current) { + current = await yybApi.createTask(); + setTask(current); + setOptions(null); + setRoleId(undefined); + } + setTask(await yybApi.login(current.id, provider)); + } catch (error) { message.error(error instanceof Error ? error.message : '启动登录失败'); } finally { setLoading(false); } }; + const submitSelection = async () => { if (!task || !selectedProduct || !selectedZone || !selectedRole) return; setLoading(true); try { - setTask(await yybApi.select(task.id, { platform, points: selectedProduct.points, product_id: selectedProduct.product_id, zone_id: selectedZone.zone_id, zone_name: selectedZone.name, role_id: selectedRole.role_id, role_name: selectedRole.name })); + setTask(await yybApi.select(task.id, { + platform, points: selectedProduct.points, product_id: selectedProduct.product_id, + zone_id: selectedZone.zone_id, zone_name: selectedZone.name, + role_id: selectedRole.role_id, role_name: selectedRole.name, + })); message.success('选择已保存'); } catch (error) { message.error(error instanceof Error ? error.message : '保存选择失败'); } finally { setLoading(false); } }; + const createPayment = async () => { if (!task) return; setLoading(true); - try { setTask(await yybApi.payment(task.id)); } catch (error) { message.error(error instanceof Error ? error.message : '创建付款码失败'); } + try { setTask(await yybApi.payment(task.id)); } + catch (error) { message.error(error instanceof Error ? error.message : '创建付款码失败'); } finally { setLoading(false); } }; - return - 应用宝和平精英充值 - - {!task && 每次充值使用独立的 YYB 登录会话。} - {task && <> - {task.status === 'failed' && } - {task.phase === 'login' && void startLogin(event.target.value)} disabled={loading}>QQ 登录 微信登录{task.login_qr_data && }{task.message}} - {task.phase === 'selection' && { const value = event.target.value; setPlatform(value); void loadOptions(value); }}>Android 区iOS 区{options && ({ value: item.zone_id, label: `${item.name}(${item.zone_id})` }))} onChange={value => { setZoneId(value); void loadOptions(platform, points, value); }} /> ({ value: item.zone_id, label: `${item.name}(${item.zone_id})` }))} + onChange={value => { setZoneId(value); void loadOptions(platform, points, value); }} + /> + +
+ 角色 +
+ {selectedRole ? ( + {selectedRole.name}({selectedRole.role_id}) + ) : 当前区服暂无可用角色} +
+
+ + + ) : } +
+
+ ); + }; + + const renderPaymentCard = () => { + if (!task) return null; + const terminal = task.status === 'failed' || task.status === 'stopped'; + return ( + + + + {status === 'ready' && ( + <> + + + + )} + {status === 'ordering' && ( + +
+ {showLogs && ( + setLogsOpen(Array.isArray(keys) && keys.includes('logs'))} + items={[{ + key: 'logs', + label: 执行记录, + children: ( +
+ {logs.map((line, index) =>
{line}
)} +
+ ), + }]} + /> + )} + + )} + {status === 'waiting_payment' && ( + + {task.payment_qr_data ? ( + + + 请使用微信扫码付款 + 生成于 {fmtTime(task.payment_qr_created_at)} · 最近检测 {fmtTime(task.payment_last_checked_at)} + + ) : } + {task.message} + + + + + )} + {status === 'payment_timeout' && ( + + + {task.payment_qr_data && ( + + )} + + + + + + + + + )} + {terminal && } + + + ); + }; + + const renderCompletedCard = () => ( + } + message="充值订单已确认到账" + description={task?.message} /> + ); + + const renderMainCard = () => { + if (!task) return null; + if (task.phase === 'login') return renderLoginCard(); + if (task.phase === 'selection') return renderSelectionCard(); + if (task.phase === 'payment') return renderPaymentCard(); + if (task.phase === 'completed') return renderCompletedCard(); + return {task.message}; + }; + + return ( + + + + + + + + 应用宝和平精英充值 + {task && {statusMeta.label}} + + + + {task && ( + + )} + + + {task && ( +
+ + 创建人:{task.created_by_username || `#${task.created_by}`} + {task.created_at ? ` · 创建于 ${fmtTime(task.created_at)}` : ''} + {task.finished_at ? ` · 结束于 ${fmtTime(task.finished_at)}` : ''} + +
+ )} +
+ {!task && ( + + + 选择登录方式,点击后自动创建充值任务并生成二维码。 + + + + + + + )} + {task && renderMainCard()} +
+ + + + void loadRecent('all')}>全部任务 + ) : undefined + }> + {recentTasks.length === 0 ? : ( + ( + void openTask(item.id)} + > + + + {STATUS_META[item.status]?.label ?? item.status} + {item.points ? {item.points} 点券 : null} + {item.price_fen ? {yuan(item.price_fen)} : null} + {item.role_name ? {item.role_name} : null} + + + {item.created_by_username || `#${item.created_by}`} · {fmtTime(item.created_at)} + + + + )} + /> + )} + + {task && ( + + + + )} + {showLogs && ( + setLogsOpen(Array.isArray(keys) && keys.includes('logs'))} + items={[{ + key: 'logs', + label: 执行记录{task?.status === 'failed' ? '(失败原因)' : ''}, + children: ( +
+ {logs.map((line, index) =>
{line}
)} +
+ ), + }]} + /> + )} +
+ +
+ ); }