diff --git a/dev.sh b/dev.sh index 2d83b71..73edaf9 100755 --- a/dev.sh +++ b/dev.sh @@ -138,9 +138,16 @@ mkdir -p "$YYB_WORKER_DATA_DIR" trap cleanup EXIT INT TERM start_yyb_worker() { + # Worker 跟随 dev.sh 生命周期:发现遗留进程(外部启动)时先停止,再自启, + # 确保运行的是当前代码且 Ctrl+C 时一并退出。 if curl -fsS "${DEV_YYB_WORKER_URL}/health" >/dev/null 2>&1; then - echo "本地 YYB Worker 已运行: ${DEV_YYB_WORKER_URL}(复用现有进程,如需重启请先停掉该进程)" - return + local leaked + leaked="$(lsof -ti tcp:"$YYB_WORKER_PORT" -sTCP:LISTEN 2>/dev/null | head -1 || true)" + if [ -n "$leaked" ]; then + echo "发现遗留 YYB Worker(pid $leaked),正在停止并重新启动..." + kill "$leaked" 2>/dev/null || true + sleep 1 + fi fi if [ ! -d "$YYB_WORKER_NODE_MODULES/jsdom" ]; then echo "正在安装本地 YYB Worker Node 依赖..." diff --git a/services/yyb-worker/runtime/main.py b/services/yyb-worker/runtime/main.py index f014c87..637f1f7 100644 --- a/services/yyb-worker/runtime/main.py +++ b/services/yyb-worker/runtime/main.py @@ -480,6 +480,8 @@ def main() -> int: p.add_argument("--role-name", default=None, help="游戏角色名称;默认使用模板当前角色") p.add_argument("--zone-id", default=None, help="游戏区服 ID;默认使用模板当前区服") p.add_argument("--zone-name", default=None, help="游戏区服名称") + p.add_argument("--area", default=None, help="游戏大区 ID(QQ 平台与 zoneid 不同,来自角色查询的 partition_info)") + p.add_argument("--partition", default=None, help="游戏分区 ID(QQ 平台可为空)") p.add_argument("--pf", default=None, help="支付平台标识;由角色选择器按 Android/iOS 写入") p.add_argument("--output", default=None) p = msub.add_parser("pay", help="mall 下单 -> goods web_save -> 微信支付二维码(纯 Python)") @@ -624,8 +626,17 @@ def _apply_card_selection(payload: dict, args) -> None: zone_id = getattr(args, "zone_id", None) if zone_id: product["zoneid"] = zone_id - product["area"] = zone_id payload["zoneid"] = zone_id + # QQ 平台的大区(area)与 zoneid 不同,来自角色查询的 partition_info; + # 必须用真实 area,否则 PlaceOrder 校验角色失败(gamerole err)。 + area = getattr(args, "area", None) + if area: + product["area"] = area + elif zone_id: + product["area"] = zone_id + partition = getattr(args, "partition", None) + if partition is not None: + product["partition"] = partition zone_name = getattr(args, "zone_name", None) if zone_name: product["zonename"] = zone_name @@ -696,12 +707,22 @@ def cmd_mall_auto(args) -> int: out.write_text(raw, encoding="utf-8") js = json.loads(raw) ret = js.get("result_code") - if ret == "0": - cr = json.loads(js["data"]["call_reply"]) - print(f"✅ 仅CK全自动下单成功! token={cr['data']['token'][:24]}...") + try: + call_reply = json.loads(js["data"]["call_reply"]) + except (KeyError, TypeError, json.JSONDecodeError): + call_reply = {} + # PlaceOrder 业务失败(如 10014 gamerole err)时外层 result_code 仍可能为 0, + # 必须检查 call_reply 的 result_code,避免把失败当成功后再崩溃。 + inner_ret = call_reply.get("result_code") + if ret == "0" and str(inner_ret) in ("0", "None", ""): + data = call_reply.get("data") or {} + token = str(data.get("token") or "")[:24] + print(f"✅ 仅CK全自动下单成功! token={token or '(无 token)'}...") print(f" 响应已保存 → {out}") return 0 - print(f"❌ 下单失败 ret={ret} ({js.get('result_info', '')})") + detail = js.get("result_info", "") or call_reply.get("result_info", "") or "" + print(f"❌ 下单失败 ret={ret} inner={inner_ret} ({detail})") + print(f" 响应已保存 → {out}") return 1 diff --git a/services/yyb-worker/runtime/scripts/jsdom-pay.py b/services/yyb-worker/runtime/scripts/jsdom-pay.py index b0375c7..53d2aa0 100644 --- a/services/yyb-worker/runtime/scripts/jsdom-pay.py +++ b/services/yyb-worker/runtime/scripts/jsdom-pay.py @@ -212,11 +212,16 @@ 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: +def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, bool], + document: dict, portal_serial_no: str = "") -> 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. + + The official order list (GetPrivateDomainOrderList) exposes its own ``order_id`` + which equals web_save's ``portal_serial_no``; save it under ``order_id`` so the + completion check can match precisely instead of guessing by "newly completed". """ from pyvm.order_status import order_ids @@ -225,6 +230,7 @@ def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, boo "token_id": order.get("token_id", ""), "transaction_id": order.get("transaction_id", ""), "out_trade_no": order.get("out_trade_no", ""), + "order_id": portal_serial_no, }, "baseline_order_states": baseline, "baseline_order_ids": sorted(order_ids(document)), @@ -295,7 +301,7 @@ def main() -> int: parser.add_argument("--wait", type=int, default=12, help="jsdom 等待 DeviceFP 的秒数") parser.add_argument("--zone-id", default="1", help="所选游戏区服 ID") parser.add_argument("--pf", default="", help="所选 Android/iOS 支付平台标识") - parser.add_argument("--amount-fen", type=int, required=True, help="所选点券的价格,单位分") + parser.add_argument("--amount-fen", type=int, default=0, help="所选点券的价格,单位分(check-only 模式不需要)") parser.add_argument("--dry-run", action="store_true", help="仅拉取页面并生成 DeviceFP,不上报或创建付款码") parser.add_argument("--payment-timeout", type=float, default=300, help="付款码生成后等待订单完成的最长秒数") @@ -315,6 +321,8 @@ def main() -> int: if args.check_only: out_dir.mkdir(parents=True, exist_ok=True) return cmd_check_only(session_path, out_dir) + if args.amount_fen <= 0: + raise ValueError("充值金额必须为正数") session = load_json(session_path) cookies = dict(session.get("cookies", {})) @@ -420,6 +428,8 @@ def main() -> int: if not sign.startswith("weixin://"): print("[jsdom-pay] web_save 成功,但响应没有微信付款链接") return 1 + info = response_json.get("info", {}) or {} + portal_serial_no = str(info.get("portal_serial_no", "") or "") qr_path = Path(args.qr) if args.qr else out_dir / "wechat-pay.png" make_qr(sign, qr_path) print("[jsdom-pay] 微信付款码已生成") @@ -435,7 +445,7 @@ def main() -> int: baseline_document = {"list": []} baseline_states = {} print("[jsdom-pay] 记录付款前订单基线与本次订单标识...") - save_payment_meta(out_dir / "payment-meta.json", order, baseline_states, baseline_document) + save_payment_meta(out_dir / "payment-meta.json", order, baseline_states, baseline_document, portal_serial_no) if args.skip_payment_check: print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测") return 0 diff --git a/services/yyb-worker/runtime/scripts/select-peace-elite.py b/services/yyb-worker/runtime/scripts/select-peace-elite.py index 0bd5a8c..bcc0e60 100644 --- a/services/yyb-worker/runtime/scripts/select-peace-elite.py +++ b/services/yyb-worker/runtime/scripts/select-peace-elite.py @@ -123,10 +123,17 @@ class Cmall: def roles(self, zone_id: str) -> list[dict[str, str]]: response = self.query("15", use_currency_offerid="1", zoneid=zone_id) roles = response.get("role_info") or response.get("role_list") or [] - return [{"role_id": str(item.get("role_id", "")), - "name": unquote(str(item.get("role_name", ""))), - "ban_status": str(item.get("ban_status", ""))} - for item in roles if isinstance(item, dict) and item.get("role_id")] + # QQ 平台和平精英的 area 与 zoneid 不同(如 area=2, zoneid=1); + # PlaceOrder 校验角色时需要该分区信息,随角色一并返回。 + partition = response.get("partition_info") or {} + return [{ + "role_id": str(item.get("role_id", "")), + "name": unquote(str(item.get("role_name", ""))), + "ban_status": str(item.get("ban_status", "")), + "area": str(partition.get("area", "")), + "partition": str(partition.get("partition", "")), + "platid": str(partition.get("platid", "")), + } for item in roles if isinstance(item, dict) and item.get("role_id")] def choose(label: str, options: list[dict], display) -> dict: diff --git a/services/yyb-worker/runtime/scripts/yyb-worker.py b/services/yyb-worker/runtime/scripts/yyb-worker.py index a44bb66..b003a44 100644 --- a/services/yyb-worker/runtime/scripts/yyb-worker.py +++ b/services/yyb-worker/runtime/scripts/yyb-worker.py @@ -242,6 +242,10 @@ def _payment_flow(job_id: str, selection: dict) -> None: "--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", "正在创建商城订单", "创建商城订单失败"): @@ -413,6 +417,7 @@ class Handler(BaseHTTPRequestHandler): "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" diff --git a/web/backend/services/yyb_service.py b/web/backend/services/yyb_service.py index db046ba..a53d23a 100644 --- a/web/backend/services/yyb_service.py +++ b/web/backend/services/yyb_service.py @@ -22,6 +22,15 @@ def _from_ts(value) -> datetime | None: return None +def _as_utc(value: datetime | None) -> datetime | None: + """DB 时间列为 naive UTC;输出时补 UTC 时区标记,前端才能按本地时区正确显示。""" + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int: rows = db.query(YybRechargeTask).filter( YybRechargeTask.status.in_(["created", "waiting_login", "ready", "running", "ordering", "waiting_payment"]) @@ -85,10 +94,10 @@ def public_task(task: YybRechargeTask, include_qr: bool = True, "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_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, + "created_at": _as_utc(task.created_at), "finished_at": _as_utc(task.finished_at), + "payment_started_at": _as_utc(task.payment_started_at), + "payment_qr_created_at": _as_utc(task.payment_qr_created_at), + "payment_last_checked_at": _as_utc(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/frontend/src/pages/YybRechargePage.tsx b/web/frontend/src/pages/YybRechargePage.tsx index ed6b0d5..6afeea9 100644 --- a/web/frontend/src/pages/YybRechargePage.tsx +++ b/web/frontend/src/pages/YybRechargePage.tsx @@ -309,7 +309,17 @@ export default function YybRechargePage() { )} {status === 'ordering' && ( -
+ +
+ + 付款码生成中... +
{showLogs && (