优化了部分支付宝界面
This commit is contained in:
@@ -138,6 +138,10 @@ mkdir -p "$YYB_WORKER_DATA_DIR"
|
|||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
start_yyb_worker() {
|
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
|
if [ ! -d "$YYB_WORKER_NODE_MODULES/jsdom" ]; then
|
||||||
echo "正在安装本地 YYB Worker Node 依赖..."
|
echo "正在安装本地 YYB Worker Node 依赖..."
|
||||||
(cd "$ROOT_DIR/services/yyb-worker" && npm ci)
|
(cd "$ROOT_DIR/services/yyb-worker" && npm ci)
|
||||||
|
|||||||
@@ -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")
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="YYB: jsdom DeviceFP + web_save -> 微信付款码")
|
parser = argparse.ArgumentParser(description="YYB: jsdom DeviceFP + web_save -> 微信付款码")
|
||||||
parser.add_argument("--session", default=str(ROOT / "config/mall-session.json"))
|
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,
|
parser.add_argument("--payment-interval", type=float, default=3,
|
||||||
help="订单完成状态检查间隔秒数")
|
help="订单完成状态检查间隔秒数")
|
||||||
parser.add_argument("--skip-payment-check", action="store_true", 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()
|
args = parser.parse_args()
|
||||||
if args.payment_timeout <= 0 or args.payment_interval <= 0:
|
if args.payment_timeout <= 0 or args.payment_interval <= 0:
|
||||||
raise ValueError("--payment-timeout 和 --payment-interval 必须为正数")
|
raise ValueError("--payment-timeout 和 --payment-interval 必须为正数")
|
||||||
|
|
||||||
session_path = Path(args.session).resolve()
|
session_path = Path(args.session).resolve()
|
||||||
response_path = Path(args.mall_response).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)
|
session = load_json(session_path)
|
||||||
cookies = dict(session.get("cookies", {}))
|
cookies = dict(session.get("cookies", {}))
|
||||||
if not cookies:
|
if not cookies:
|
||||||
@@ -312,6 +394,17 @@ def main() -> int:
|
|||||||
order, cookies, web_token, anti_token, encrypt_msg,
|
order, cookies, web_token, anti_token, encrypt_msg,
|
||||||
args.zone_id, payment_pf, args.amount_fen,
|
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...")
|
print("[jsdom-pay] 提交 web_save...")
|
||||||
raw = request_bytes(SAVE_URL, cookies, body.encode())
|
raw = request_bytes(SAVE_URL, cookies, body.encode())
|
||||||
(out_dir / "web-save-response.json").write_text(raw, encoding="utf-8")
|
(out_dir / "web-save-response.json").write_text(raw, encoding="utf-8")
|
||||||
@@ -331,23 +424,36 @@ def main() -> int:
|
|||||||
make_qr(sign, qr_path)
|
make_qr(sign, qr_path)
|
||||||
print("[jsdom-pay] 微信付款码已生成")
|
print("[jsdom-pay] 微信付款码已生成")
|
||||||
print(f"[jsdom-pay] PNG: {qr_path}")
|
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:
|
if args.skip_payment_check:
|
||||||
print("[jsdom-pay] 已跳过付款结果检查。")
|
print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
from pyvm.order_status import find_completed_order, get_official_orders, order_completion_states
|
|
||||||
|
|
||||||
print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...")
|
print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...")
|
||||||
baseline_document = get_official_orders(cookies)
|
|
||||||
baseline_states = order_completion_states(baseline_document)
|
|
||||||
status_path = out_dir / "payment-status.json"
|
status_path = out_dir / "payment-status.json"
|
||||||
save_payment_status(status_path, baseline_document, baseline_states)
|
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
|
deadline = time.monotonic() + args.payment_timeout
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
time.sleep(args.payment_interval)
|
time.sleep(args.payment_interval)
|
||||||
document = get_official_orders(cookies)
|
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)
|
save_payment_status(status_path, document, baseline_states, completed)
|
||||||
if completed:
|
if completed:
|
||||||
print("[jsdom-pay] 微信付款成功,商城订单已完成。")
|
print("[jsdom-pay] 微信付款成功,商城订单已完成。")
|
||||||
|
|||||||
@@ -175,13 +175,16 @@ def main() -> int:
|
|||||||
poll_query["last"] = last
|
poll_query["last"] = last
|
||||||
poll = client.request(f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url)
|
poll = client.request(f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url)
|
||||||
errcode, code = parse_poll(poll.text)
|
errcode, code = parse_poll(poll.text)
|
||||||
if errcode == 405:
|
if errcode == 405 and code:
|
||||||
break
|
break
|
||||||
if errcode == 402:
|
if errcode == 405:
|
||||||
|
# 已扫码、手机待确认:继续轮询,不能立即退出(否则 code 为空导致登录失败)。
|
||||||
|
last = "405"
|
||||||
|
elif errcode == 402:
|
||||||
raise RuntimeError("二维码已过期,请重新执行登录")
|
raise RuntimeError("二维码已过期,请重新执行登录")
|
||||||
if errcode == 403:
|
elif errcode == 403:
|
||||||
raise RuntimeError("用户取消了扫码登录")
|
raise RuntimeError("用户取消了扫码登录")
|
||||||
if errcode == 404:
|
else:
|
||||||
last = "404"
|
last = "404"
|
||||||
time.sleep(args.interval)
|
time.sleep(args.interval)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -55,10 +55,17 @@ def _safe_log(job: dict, line: str) -> None:
|
|||||||
job["logs"] = (job.get("logs", []) + [clean.strip()])[-100:]
|
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]
|
job = _jobs[job_id]
|
||||||
environment = os.environ.copy()
|
environment = os.environ.copy()
|
||||||
environment.pop("NODE_OPTIONS", None)
|
environment.pop("NODE_OPTIONS", None)
|
||||||
|
# 子进程输出经管道实时转存日志;关闭块缓冲避免日志滞后。
|
||||||
|
environment["PYTHONUNBUFFERED"] = "1"
|
||||||
with _lock:
|
with _lock:
|
||||||
job["phase"] = phase
|
job["phase"] = phase
|
||||||
job["status"] = "running"
|
job["status"] = "running"
|
||||||
@@ -75,21 +82,27 @@ def _run_process(job_id: str, command: list[str], phase: str) -> None:
|
|||||||
with _lock:
|
with _lock:
|
||||||
job["process_pid"] = None
|
job["process_pid"] = None
|
||||||
if code != 0:
|
if code != 0:
|
||||||
job["status"] = "failed"
|
if job.get("status") == "stopped":
|
||||||
job["phase"] = phase
|
# 停止请求已 kill 本进程:保留停止语义,不被阶段失败覆盖。
|
||||||
job["message"] = f"{phase}失败(退出码 {code})"
|
job["message"] = "任务已停止"
|
||||||
|
else:
|
||||||
|
job["status"] = "failed"
|
||||||
|
job["phase"] = phase
|
||||||
|
job["message"] = f"{phase}失败(退出码 {code})"
|
||||||
elif phase == "login":
|
elif phase == "login":
|
||||||
job["status"] = "ready"
|
job["status"] = "ready"
|
||||||
job["phase"] = "selection"
|
job["phase"] = "selection"
|
||||||
job["message"] = "登录成功,请选择平台、点券、区服和角色"
|
job["message"] = "登录成功,请选择平台、点券、区服和角色"
|
||||||
else:
|
elif mark_success:
|
||||||
job["status"] = "success"
|
job["status"] = "success"
|
||||||
job["phase"] = "completed"
|
job["phase"] = "completed"
|
||||||
job["message"] = "付款流程已完成"
|
job["message"] = "付款流程已完成"
|
||||||
|
return code
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
with _lock:
|
with _lock:
|
||||||
job["status"] = "failed"
|
job["status"] = "failed"
|
||||||
job["message"] = str(exc)
|
job["message"] = str(exc)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _start_login(job_id: str, provider: str, timeout: int) -> None:
|
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}
|
"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)
|
directory = _job_dir(job_id)
|
||||||
session = directory / "mall-session.json"
|
session = directory / "mall-session.json"
|
||||||
response = directory / "mall-order-response.json"
|
response = directory / "mall-order-response.json"
|
||||||
output = directory / "jsdom-order"
|
output = directory / "jsdom-order"
|
||||||
command = [sys.executable, "main.py", "mall", "auto", "--session", str(session),
|
with _lock:
|
||||||
"--order-template", str(ROOT / "config" / "mall-order-template.json"),
|
job["status"] = "ordering"
|
||||||
"--quantity", "1", "--product-id", str(selection["product_id"]),
|
job["phase"] = "payment"
|
||||||
"--offer-id", str(selection["offer_id"]),
|
job["message"] = "正在创建商城订单"
|
||||||
"--role-id", str(selection["role_id"]), "--role-name", str(selection["role_name"]),
|
order_cmd = [sys.executable, "main.py", "mall", "auto", "--session", str(session),
|
||||||
"--zone-id", str(selection["zone_id"]), "--zone-name", str(selection["zone_name"]),
|
"--order-template", str(ROOT / "config" / "mall-order-template.json"),
|
||||||
"--output", str(response)]
|
"--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"):
|
if selection.get("order_pf"):
|
||||||
command.extend(["--pf", str(selection["order_pf"])])
|
order_cmd.extend(["--pf", str(selection["order_pf"])])
|
||||||
def run() -> None:
|
if not _payment_stage(job_id, order_cmd, "order", "正在创建商城订单", "创建商城订单失败"):
|
||||||
_run_process(job_id, command, "order")
|
return
|
||||||
job = _jobs[job_id]
|
pay_cmd = [sys.executable, "scripts/jsdom-pay.py", "--session", str(session),
|
||||||
if job.get("status") != "success" or not response.exists():
|
"--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
|
return
|
||||||
pay_command = [sys.executable, "scripts/jsdom-pay.py", "--session", str(session),
|
_jobs[job_id]["status"] = "ordering"
|
||||||
"--mall-response", str(response), "--out-dir", str(output),
|
_jobs[job_id]["phase"] = "payment"
|
||||||
"--zone-id", str(selection["zone_id"]),
|
_jobs[job_id]["message"] = "正在创建商城订单"
|
||||||
"--pf", str(selection.get("order_pf", "")),
|
threading.Thread(target=_payment_flow, args=(job_id, selection), daemon=True).start()
|
||||||
"--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:
|
def _stop_job(job_id: str) -> None:
|
||||||
if job_id not in _jobs:
|
if job_id not in _jobs:
|
||||||
raise ValueError("任务不存在")
|
raise ValueError("任务不存在")
|
||||||
pid = _jobs[job_id].get("process_pid")
|
job = _jobs[job_id]
|
||||||
|
pid = job.get("process_pid")
|
||||||
if pid:
|
if pid:
|
||||||
try:
|
try:
|
||||||
os.kill(int(pid), 15)
|
os.kill(int(pid), 15)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
pass
|
||||||
with _lock:
|
with _lock:
|
||||||
_jobs[job_id]["status"] = "failed"
|
if job.get("status") == "waiting_payment":
|
||||||
_jobs[job_id]["phase"] = "stopped"
|
job["status"] = "stopped"
|
||||||
_jobs[job_id]["message"] = "任务已停止"
|
job["phase"] = "payment"
|
||||||
|
job["message"] = "已放弃到账追踪;商城订单仍可能完成,请人工核对"
|
||||||
|
else:
|
||||||
|
job["status"] = "stopped"
|
||||||
|
job["phase"] = "stopped"
|
||||||
|
job["message"] = "任务已停止"
|
||||||
|
|
||||||
|
|
||||||
def _public_job(job_id: str) -> dict:
|
def _public_job(job_id: str) -> dict:
|
||||||
@@ -206,6 +330,9 @@ def _public_job(job_id: str) -> dict:
|
|||||||
}
|
}
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
pass
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -289,13 +416,44 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"order_pf": selector.PLATFORMS[body["platform"]]["order_pf"],
|
"order_pf": selector.PLATFORMS[body["platform"]]["order_pf"],
|
||||||
}
|
}
|
||||||
_jobs[job_id]["phase"] = "payment"
|
_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":
|
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "payment":
|
||||||
job_id = path[2]
|
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": "请先完成角色选择"})
|
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))
|
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":
|
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "stop":
|
||||||
_stop_job(path[2])
|
_stop_job(path[2])
|
||||||
return self._json(200, _public_job(path[2]))
|
return self._json(200, _public_job(path[2]))
|
||||||
@@ -317,6 +475,66 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
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:
|
def main() -> int:
|
||||||
global DEFAULT_DATA, WORKER_KEY
|
global DEFAULT_DATA, WORKER_KEY
|
||||||
parser = argparse.ArgumentParser(description="YYB admin worker")
|
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("--data-dir", type=Path, default=DEFAULT_DATA)
|
||||||
parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY")
|
parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
DEFAULT_DATA = args.data_dir
|
# 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。
|
||||||
|
DEFAULT_DATA = Path(args.data_dir).resolve()
|
||||||
if args.key is not None:
|
if args.key is not None:
|
||||||
WORKER_KEY = args.key
|
WORKER_KEY = args.key
|
||||||
if args.host not in {"127.0.0.1", "localhost", "::1"} and not WORKER_KEY:
|
if args.host not in {"127.0.0.1", "localhost", "::1"} and not WORKER_KEY:
|
||||||
parser.error("监听非本机地址时必须设置 --key 或 YYB_WORKER_KEY")
|
parser.error("监听非本机地址时必须设置 --key 或 YYB_WORKER_KEY")
|
||||||
DEFAULT_DATA.mkdir(parents=True, exist_ok=True)
|
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)
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
print(f"YYB worker listening on {args.host}:{args.port}", flush=True)
|
print(f"YYB worker listening on {args.host}:{args.port}", flush=True)
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -144,12 +144,16 @@ class YybRechargeTask(Base):
|
|||||||
zone_name = Column(String(128), default="")
|
zone_name = Column(String(128), default="")
|
||||||
role_id = Column(String(64), default="")
|
role_id = Column(String(64), default="")
|
||||||
role_name = Column(String(128), default="")
|
role_name = Column(String(128), default="")
|
||||||
|
price_fen = Column(Integer, nullable=True)
|
||||||
status = Column(String(32), default="created", nullable=False, index=True)
|
status = Column(String(32), default="created", nullable=False, index=True)
|
||||||
phase = Column(String(32), default="login", nullable=False)
|
phase = Column(String(32), default="login", nullable=False)
|
||||||
message = Column(String(512), default="")
|
message = Column(String(512), default="")
|
||||||
result = Column(JSON, nullable=True)
|
result = Column(JSON, nullable=True)
|
||||||
login_qr_data = Column(EncryptedText(), default="")
|
login_qr_data = Column(EncryptedText(), default="")
|
||||||
payment_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)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
finished_at = Column(DateTime, nullable=True)
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ PERMISSIONS = {
|
|||||||
"douyu:config": "斗鱼配置管理",
|
"douyu:config": "斗鱼配置管理",
|
||||||
"yyb:session": "应用宝扫码登录与角色查询",
|
"yyb:session": "应用宝扫码登录与角色查询",
|
||||||
"yyb:recharge": "应用宝充值与付款",
|
"yyb:recharge": "应用宝充值与付款",
|
||||||
|
"yyb:manage": "接管并操作他人的应用宝任务",
|
||||||
"yyb:history": "查看应用宝充值历史",
|
"yyb:history": "查看应用宝充值历史",
|
||||||
# 虎牙
|
# 虎牙
|
||||||
"huya:account": "虎牙账号管理(兼容旧权限)",
|
"huya:account": "虎牙账号管理(兼容旧权限)",
|
||||||
@@ -67,6 +68,7 @@ ROLE_PERMISSIONS = {
|
|||||||
"douyu:config",
|
"douyu:config",
|
||||||
"yyb:session",
|
"yyb:session",
|
||||||
"yyb:recharge",
|
"yyb:recharge",
|
||||||
|
"yyb:manage",
|
||||||
"yyb:history",
|
"yyb:history",
|
||||||
"huya:account",
|
"huya:account",
|
||||||
"huya:view_all",
|
"huya:view_all",
|
||||||
|
|||||||
+94
-25
@@ -12,18 +12,29 @@ from ..deps import get_current_user, require_permission
|
|||||||
from ..models import User, YybRechargeTask
|
from ..models import User, YybRechargeTask
|
||||||
from ..permissions import user_has_permission
|
from ..permissions import user_has_permission
|
||||||
from ..schemas import YybLoginRequest, YybSelectionRequest, YybTaskCreateRequest
|
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
|
from ..services.yyb_worker_client import YybWorkerClient, YybWorkerError
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"])
|
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()
|
task = db.query(YybRechargeTask).filter(YybRechargeTask.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(404, "充值任务不存在")
|
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, "无权查看该充值任务")
|
raise HTTPException(403, "无权查看该充值任务")
|
||||||
return task
|
return task
|
||||||
|
|
||||||
@@ -35,6 +46,11 @@ def _worker_call(call):
|
|||||||
raise HTTPException(502, str(exc)) from exc
|
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")
|
@router.post("/tasks")
|
||||||
def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
||||||
data = _worker_call(YybWorkerClient().create_job)
|
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.add(task)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(task)
|
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")
|
@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"))):
|
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))
|
data = _worker_call(lambda: YybWorkerClient().login(task.worker_job_id, payload.provider, payload.timeout))
|
||||||
task.provider = payload.provider
|
task.provider = payload.provider
|
||||||
task.status = str(data.get("status", "waiting_login"))
|
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"):
|
if data.get("qr_data"):
|
||||||
task.login_qr_data = data["qr_data"]
|
task.login_qr_data = data["qr_data"]
|
||||||
db.commit()
|
db.commit()
|
||||||
return public_task(task)
|
return public_task(task, creator_username=_creator_username(db, task))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}")
|
@router.get("/tasks/{task_id}")
|
||||||
@@ -70,18 +86,34 @@ def get_task(task_id: int, db: Session = Depends(get_db), current: User = Depend
|
|||||||
# Worker 暂时重启时仍返回最近一次持久化状态。
|
# Worker 暂时重启时仍返回最近一次持久化状态。
|
||||||
pass
|
pass
|
||||||
return public_task(task, include_qr=user_has_permission(current, "yyb:session"),
|
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")
|
@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"))):
|
def list_tasks(scope: str = Query("mine", pattern="^(mine|all)$"),
|
||||||
tasks = db.query(YybRechargeTask).order_by(YybRechargeTask.id.desc()).limit(limit).all()
|
status: str | None = Query(None, max_length=32),
|
||||||
return [public_task(task, include_qr=False) for task in tasks]
|
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")
|
@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"))):
|
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))
|
data = _worker_call(lambda: YybWorkerClient().selection_options(task.worker_job_id, platform, points, zone_id))
|
||||||
task.platform = platform
|
task.platform = platform
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -90,35 +122,72 @@ def selection_options(task_id: int, platform: str = Query("android"), points: in
|
|||||||
|
|
||||||
@router.post("/tasks/{task_id}/selection")
|
@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"))):
|
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()))
|
data = _worker_call(lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump()))
|
||||||
selected = data.get("selection", 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"):
|
for field in ("platform", "points", "product_id", "zone_id", "zone_name", "role_id", "role_name"):
|
||||||
setattr(task, field, selected[field])
|
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()
|
db.commit()
|
||||||
return public_task(task)
|
return public_task(task, creator_username=_creator_username(db, task))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/payment")
|
@router.post("/tasks/{task_id}/payment")
|
||||||
def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))):
|
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:
|
if not task.product_id or not task.role_id:
|
||||||
raise HTTPException(400, "请先完成商品、区服和角色选择")
|
raise HTTPException(400, "请先完成商品、区服和角色选择")
|
||||||
data = _worker_call(lambda: YybWorkerClient().payment(task.worker_job_id))
|
task.status = "ordering"
|
||||||
task.status = str(data.get("status", "running"))
|
task.message = "正在创建商城订单"
|
||||||
task.phase = "payment"
|
task.payment_started_at = _utcnow()
|
||||||
task.message = "正在创建订单和付款码"
|
|
||||||
db.commit()
|
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")
|
@router.post("/tasks/{task_id}/stop")
|
||||||
def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
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))
|
data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id))
|
||||||
task.status = "failed"
|
task.status = "stopped"
|
||||||
task.phase = "stopped"
|
task.phase = str(data.get("phase", "stopped"))
|
||||||
task.message = "任务已停止"
|
task.message = str(data.get("message", "任务已停止"))
|
||||||
|
task.finished_at = _utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
return public_task(task)
|
return public_task(task, creator_username=_creator_username(db, task))
|
||||||
|
|||||||
@@ -15,12 +15,27 @@ def _utcnow():
|
|||||||
return datetime.now(timezone.utc)
|
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:
|
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:
|
for task in rows:
|
||||||
task.status = "failed"
|
if task.status == "ordering":
|
||||||
task.message = message
|
task.status = "waiting_payment"
|
||||||
task.finished_at = _utcnow()
|
task.message = "服务重启,支付流程中断,可重新检测到账"
|
||||||
|
elif task.status == "waiting_payment":
|
||||||
|
task.message = "服务重启,到账检测中断,可重新检测到账"
|
||||||
|
else:
|
||||||
|
task.status = "failed"
|
||||||
|
task.message = message
|
||||||
|
task.finished_at = _utcnow()
|
||||||
if rows:
|
if rows:
|
||||||
db.commit()
|
db.commit()
|
||||||
return len(rows)
|
return len(rows)
|
||||||
@@ -44,23 +59,36 @@ def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> Yy
|
|||||||
"logs": data.get("logs", []),
|
"logs": data.get("logs", []),
|
||||||
"payment_status": data.get("payment_status"),
|
"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:
|
if task.status in {"success", "failed"} and task.finished_at is None:
|
||||||
task.finished_at = _utcnow()
|
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.commit()
|
||||||
db.refresh(task)
|
db.refresh(task)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
def public_task(task: YybRechargeTask, include_qr: bool = True,
|
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] = {
|
result: dict[str, Any] = {
|
||||||
"id": task.id, "task_id": task.task_id,
|
"id": task.id, "task_id": task.task_id,
|
||||||
"provider": task.provider, "platform": task.platform, "points": task.points,
|
"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,
|
"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,
|
"role_id": task.role_id, "role_name": task.role_name, "status": task.status,
|
||||||
"phase": task.phase, "message": task.message, "result": task.result,
|
"phase": task.phase, "message": task.message, "result": task.result,
|
||||||
"created_by": task.created_by, "created_at": task.created_at,
|
"created_by": task.created_by, "created_by_username": creator_username,
|
||||||
"finished_at": task.finished_at,
|
"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:
|
if task.result:
|
||||||
result["login_qr_mime_type"] = task.result.get("login_qr_mime_type", "image/jpeg")
|
result["login_qr_mime_type"] = task.result.get("login_qr_mime_type", "image/jpeg")
|
||||||
|
|||||||
@@ -53,5 +53,8 @@ class YybWorkerClient:
|
|||||||
def payment(self, worker_job_id: str) -> dict[str, Any]:
|
def payment(self, worker_job_id: str) -> dict[str, Any]:
|
||||||
return self._request("POST", f"/v1/jobs/{worker_job_id}/payment")
|
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]:
|
def stop(self, worker_job_id: str) -> dict[str, Any]:
|
||||||
return self._request("POST", f"/v1/jobs/{worker_job_id}/stop")
|
return self._request("POST", f"/v1/jobs/{worker_job_id}/stop")
|
||||||
|
|||||||
@@ -152,9 +152,11 @@ export interface YybTask {
|
|||||||
task_id: string;
|
task_id: string;
|
||||||
worker_job_id?: string;
|
worker_job_id?: string;
|
||||||
created_by: number;
|
created_by: number;
|
||||||
|
created_by_username?: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
platform: 'android' | 'ios';
|
platform: 'android' | 'ios';
|
||||||
points: number | null;
|
points: number | null;
|
||||||
|
price_fen?: number | null;
|
||||||
product_id: string;
|
product_id: string;
|
||||||
zone_id: string;
|
zone_id: string;
|
||||||
zone_name: string;
|
zone_name: string;
|
||||||
@@ -168,6 +170,9 @@ export interface YybTask {
|
|||||||
login_qr_mime_type?: string;
|
login_qr_mime_type?: string;
|
||||||
payment_qr_data?: string;
|
payment_qr_data?: string;
|
||||||
payment_qr_mime_type?: 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;
|
created_at: string | null;
|
||||||
finished_at: string | null;
|
finished_at: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export const yybApi = {
|
|||||||
login: (id: number, provider: 'qq' | 'wechat') =>
|
login: (id: number, provider: 'qq' | 'wechat') =>
|
||||||
api.post<YybTask, YybTask>(`/yyb/tasks/${id}/login`, { provider, timeout: 600 }),
|
api.post<YybTask, YybTask>(`/yyb/tasks/${id}/login`, { provider, timeout: 600 }),
|
||||||
getTask: (id: number) => api.get<YybTask, YybTask>(`/yyb/tasks/${id}`),
|
getTask: (id: number) => api.get<YybTask, YybTask>(`/yyb/tasks/${id}`),
|
||||||
listTasks: () => api.get<YybTask[], YybTask[]>('/yyb/tasks'),
|
listTasks: (params?: { scope?: 'mine' | 'all'; status?: string }) =>
|
||||||
|
api.get<YybTask[], YybTask[]>('/yyb/tasks', { params }),
|
||||||
options: (id: number, platform: 'android' | 'ios', points?: number, zone_id?: string) =>
|
options: (id: number, platform: 'android' | 'ios', points?: number, zone_id?: string) =>
|
||||||
api.get<YybSelectionOptions, YybSelectionOptions>(`/yyb/tasks/${id}/selection-options`, {
|
api.get<YybSelectionOptions, YybSelectionOptions>(`/yyb/tasks/${id}/selection-options`, {
|
||||||
params: { platform, ...(points ? { points } : {}), ...(zone_id ? { zone_id } : {}) },
|
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 }) =>
|
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<YybTask, YybTask>(`/yyb/tasks/${id}/selection`, data),
|
api.post<YybTask, YybTask>(`/yyb/tasks/${id}/selection`, data),
|
||||||
payment: (id: number) => api.post<YybTask, YybTask>(`/yyb/tasks/${id}/payment`),
|
payment: (id: number) => api.post<YybTask, YybTask>(`/yyb/tasks/${id}/payment`),
|
||||||
|
paymentCheck: (id: number) => api.post<YybTask, YybTask>(`/yyb/tasks/${id}/payment/check`),
|
||||||
|
stop: (id: number) => api.post<YybTask, YybTask>(`/yyb/tasks/${id}/stop`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Alert, Button, Card, Col, Descriptions, Image, Radio, Row, Select, Space, Steps, Tag, Typography, message } from 'antd';
|
import {
|
||||||
import { CheckCircleOutlined, QrcodeOutlined, ReloadOutlined, ShoppingCartOutlined, WechatOutlined } from '@ant-design/icons';
|
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 { yybApi } from '../api/modules';
|
||||||
import type { YybSelectionOptions, YybTask } from '../api/types';
|
import type { YybSelectionOptions, YybTask } from '../api/types';
|
||||||
import { getUser } from '../store/auth';
|
import { getUser } from '../store/auth';
|
||||||
@@ -8,89 +14,471 @@ import { usePermissions } from '../hooks/usePermissions';
|
|||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const phaseIndex: Record<string, number> = { login: 0, selection: 1, payment: 2, completed: 3 };
|
const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||||
|
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 (
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="平台">{task.platform === 'ios' ? 'iOS 区' : 'Android 区'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="商品">
|
||||||
|
{task.points ? `${task.points} 点券` : '-'}
|
||||||
|
{task.price_fen ? <Text type="danger">({yuan(task.price_fen)})</Text> : null}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="区服">{task.zone_name || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="角色">{task.role_name ? `${task.role_name}(${task.role_id})` : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="金额">{task.price_fen ? yuan(task.price_fen) : '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function YybRechargePage() {
|
export default function YybRechargePage() {
|
||||||
const [task, setTask] = useState<YybTask | null>(null);
|
const [task, setTask] = useState<YybTask | null>(null);
|
||||||
|
const [recentTasks, setRecentTasks] = useState<YybTask[]>([]);
|
||||||
const [options, setOptions] = useState<YybSelectionOptions | null>(null);
|
const [options, setOptions] = useState<YybSelectionOptions | null>(null);
|
||||||
const [platform, setPlatform] = useState<'android' | 'ios'>('android');
|
const [platform, setPlatform] = useState<'android' | 'ios'>('android');
|
||||||
const [points, setPoints] = useState<number>();
|
const [points, setPoints] = useState<number>();
|
||||||
const [zoneId, setZoneId] = useState<string>();
|
const [zoneId, setZoneId] = useState<string>();
|
||||||
const [roleId, setRoleId] = useState<string>();
|
const [roleId, setRoleId] = useState<string>();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
|
const [logsOpen, setLogsOpen] = useState(false);
|
||||||
const { can } = usePermissions(getUser());
|
const { can } = usePermissions(getUser());
|
||||||
|
|
||||||
const refresh = async () => {
|
const optionsSeq = useRef(0);
|
||||||
if (!task) return;
|
const taskIdRef = useRef<number | null>(null);
|
||||||
try { setTask(await yybApi.getTask(task.id)); } catch { /* 保留当前状态 */ }
|
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(() => {
|
useEffect(() => {
|
||||||
if (!task || ['success', 'failed'].includes(task.status)) return;
|
if (currentStatus === undefined || TERMINAL_STATUSES.includes(currentStatus)) return;
|
||||||
const timer = window.setInterval(refresh, 2500);
|
const timer = window.setInterval(() => void refresh(), 2500);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [task?.id, task?.status]);
|
}, [task?.id, currentStatus, refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (task?.status === 'ready' && task.phase === 'selection' && !options) {
|
let cancelled = false;
|
||||||
void loadOptions(platform);
|
(async () => {
|
||||||
}
|
try {
|
||||||
}, [task?.status, task?.phase]);
|
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) => {
|
const loadOptions = useCallback(async (nextPlatform: 'android' | 'ios', nextPoints?: number, nextZone?: string) => {
|
||||||
if (!task) return;
|
const id = taskIdRef.current;
|
||||||
setLoading(true);
|
if (id == null) return;
|
||||||
|
const seq = ++optionsSeq.current;
|
||||||
|
setOptionsLoading(true);
|
||||||
try {
|
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);
|
setOptions(data);
|
||||||
if (nextPoints === undefined && data.default_product) setPoints(data.default_product.points);
|
if (nextPoints === undefined && data.default_product) setPoints(data.default_product.points);
|
||||||
if (nextZone === undefined && data.default_zone) setZoneId(data.default_zone.zone_id);
|
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 : '查询商品失败'); }
|
} 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 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 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 () => {
|
const createTask = async () => {
|
||||||
setLoading(true);
|
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); }
|
finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const startLogin = async (provider: 'qq' | 'wechat') => {
|
const startLogin = async (provider: 'qq' | 'wechat') => {
|
||||||
if (!task) return;
|
|
||||||
setLoading(true);
|
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); }
|
finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitSelection = async () => {
|
const submitSelection = async () => {
|
||||||
if (!task || !selectedProduct || !selectedZone || !selectedRole) return;
|
if (!task || !selectedProduct || !selectedZone || !selectedRole) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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('选择已保存');
|
message.success('选择已保存');
|
||||||
} catch (error) { message.error(error instanceof Error ? error.message : '保存选择失败'); }
|
} catch (error) { message.error(error instanceof Error ? error.message : '保存选择失败'); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPayment = async () => {
|
const createPayment = async () => {
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
setLoading(true);
|
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); }
|
finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Space direction="vertical" size={16} style={{ width: '100%' }}>
|
const reCheck = async () => {
|
||||||
<Row justify="space-between" align="middle"><Title level={3} style={{ margin: 0 }}>应用宝和平精英充值</Title><Button icon={<ReloadOutlined />} onClick={task ? refresh : createTask}>{task ? '刷新任务' : '新建充值任务'}</Button></Row>
|
if (!task) return;
|
||||||
<Steps current={phaseIndex[task?.phase || 'login']} items={[{ title: '扫码登录' }, { title: '选择充值信息' }, { title: '微信付款' }, { title: '完成' }]} />
|
setLoading(true);
|
||||||
{!task && <Card><Space direction="vertical"><Text>每次充值使用独立的 YYB 登录会话。</Text><Button type="primary" icon={<ShoppingCartOutlined />} onClick={createTask} loading={loading}>开始充值</Button></Space></Card>}
|
try { setTask(await yybApi.paymentCheck(task.id)); message.success('已重新检测到账'); }
|
||||||
{task && <>
|
catch (error) { message.error(error instanceof Error ? error.message : '检测到账失败'); }
|
||||||
{task.status === 'failed' && <Alert type="error" showIcon message={task.message || '任务失败'} />}
|
finally { setLoading(false); }
|
||||||
{task.phase === 'login' && <Card title="扫码登录"><Space direction="vertical" size={12}><Radio.Group value={task.provider || undefined} onChange={event => void startLogin(event.target.value)} disabled={loading}><Radio.Button value="qq">QQ 登录</Radio.Button><Radio.Button value="wechat"><WechatOutlined /> 微信登录</Radio.Button></Radio.Group>{task.login_qr_data && <Image width={240} preview src={`data:${task.login_qr_mime_type || 'image/jpeg'};base64,${task.login_qr_data}`} />}<Text type="secondary">{task.message}</Text></Space></Card>}
|
};
|
||||||
{task.phase === 'selection' && <Card title="选择充值信息" loading={loading}><Space direction="vertical" style={{ width: '100%' }}><Radio.Group value={platform} onChange={event => { const value = event.target.value; setPlatform(value); void loadOptions(value); }}><Radio.Button value="android">Android 区</Radio.Button><Radio.Button value="ios">iOS 区</Radio.Button></Radio.Group>{options && <Row gutter={[12, 12]}><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="点券档位" value={points} options={options.products.map(item => ({ value: item.points, label: `${item.points} 点券(${(item.price_fen / 100).toFixed(2)} 元)` }))} onChange={value => { setPoints(value); void loadOptions(platform, value, zoneId); }} /></Col><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="区服" value={zoneId} options={options.zones.map(item => ({ value: item.zone_id, label: `${item.name}(${item.zone_id})` }))} onChange={value => { setZoneId(value); void loadOptions(platform, points, value); }} /></Col><Col xs={24} md={8}><Select style={{ width: '100%' }} placeholder="角色" value={roleId} options={options.roles.filter(item => item.ban_status !== '1').map(item => ({ value: item.role_id, label: `${item.name}(${item.role_id})` }))} onChange={setRoleId} /></Col></Row>}<Button type="primary" disabled={!selectedProduct || !selectedZone || !selectedRole} onClick={submitSelection}>确认充值信息</Button></Space></Card>}
|
|
||||||
{task.phase === 'payment' && <Card title="微信付款"><Descriptions column={1} size="small"><Descriptions.Item label="平台">{task.platform === 'ios' ? 'iOS' : 'Android'}</Descriptions.Item><Descriptions.Item label="商品">{task.points} 点券</Descriptions.Item><Descriptions.Item label="区服">{task.zone_name}</Descriptions.Item><Descriptions.Item label="角色">{task.role_name}({task.role_id})</Descriptions.Item></Descriptions>{task.payment_qr_data ? <Space direction="vertical"><Image width={280} preview src={`data:${task.payment_qr_mime_type || 'image/png'};base64,${task.payment_qr_data}`} /><Tag icon={<QrcodeOutlined />} color="blue">请使用微信扫码付款</Tag></Space> : can('yyb:recharge') ? <Button type="primary" icon={<WechatOutlined />} onClick={createPayment} loading={loading || task.status === 'running'} disabled={task.status === 'running'}>{task.status === 'running' ? '正在生成付款码' : '生成微信付款码'}</Button> : <Text type="secondary">当前账号没有创建付款码权限。</Text>}<div><Text type="secondary">{task.message}</Text></div></Card>}
|
const stopTask = async () => {
|
||||||
{task.phase === 'completed' && <Alert type="success" showIcon icon={<CheckCircleOutlined />} message="充值订单已确认完成" description={task.message} />}
|
if (!task) return;
|
||||||
</>}
|
setLoading(true);
|
||||||
</Space>;
|
try { setTask(await yybApi.stop(task.id)); }
|
||||||
|
catch (error) { message.error(error instanceof Error ? error.message : '停止任务失败'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = task?.status ?? '';
|
||||||
|
const statusMeta = STATUS_META[status] ?? { label: status, color: 'default' };
|
||||||
|
const logs = task?.result?.logs ?? [];
|
||||||
|
const showLogs = logs.length > 0;
|
||||||
|
const canStop = task && !['waiting_payment', 'payment_timeout', 'success'].includes(status);
|
||||||
|
|
||||||
|
const renderLoginCard = () => {
|
||||||
|
if (!task) return null;
|
||||||
|
const started = status !== 'created';
|
||||||
|
return (
|
||||||
|
<Card title="扫码登录">
|
||||||
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<Radio.Group value={task.provider || undefined} disabled={started} onChange={event => void startLogin(event.target.value)}>
|
||||||
|
<Radio.Button value="qq">QQ 登录</Radio.Button>
|
||||||
|
<Radio.Button value="wechat"><WechatOutlined /> 微信登录</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
{task.login_qr_data && (
|
||||||
|
<Space direction="vertical" size={4}>
|
||||||
|
<Image width={240} preview src={`data:${task.login_qr_mime_type || 'image/jpeg'};base64,${task.login_qr_data}`} />
|
||||||
|
<Text type="secondary">请使用{task.provider === 'qq' ? '手机 QQ' : '微信'}扫码并确认登录</Text>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
<Text type="secondary">{task.message}</Text>
|
||||||
|
{started && task.status !== 'failed' && (
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void startLogin(task.provider as 'qq' | 'wechat')} loading={loading}>刷新二维码</Button>
|
||||||
|
{canStop && <Popconfirm title="确定停止该任务?" onConfirm={stopTask}><Button danger icon={<StopOutlined />} loading={loading}>停止任务</Button></Popconfirm>}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSelectionCard = () => {
|
||||||
|
if (!task) return null;
|
||||||
|
return (
|
||||||
|
<Card title="选择充值信息" loading={optionsLoading}>
|
||||||
|
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||||
|
<Radio.Group value={platform} onChange={event => { const value = event.target.value as 'android' | 'ios'; setPlatform(value); void loadOptions(value); }}>
|
||||||
|
<Radio.Button value="android">Android 区</Radio.Button>
|
||||||
|
<Radio.Button value="ios">iOS 区</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
{options ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Text strong>点券档位</Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Radio.Group
|
||||||
|
value={points}
|
||||||
|
onChange={event => { const value = event.target.value as number; setPoints(value); void loadOptions(platform, value, zoneId); }}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={6}>
|
||||||
|
{options.products.map(item => (
|
||||||
|
<Radio key={item.product_id} value={item.points}>
|
||||||
|
<span style={{ display: 'inline-block', minWidth: 110 }}>{item.points} 点券</span>
|
||||||
|
<Text type="danger">{yuan(item.price_fen)}</Text>
|
||||||
|
</Radio>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxWidth: 360 }}>
|
||||||
|
<Text strong>区服</Text>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%', marginTop: 8 }}
|
||||||
|
placeholder="选择区服"
|
||||||
|
value={zoneId}
|
||||||
|
options={options.zones.map(item => ({ value: item.zone_id, label: `${item.name}(${item.zone_id})` }))}
|
||||||
|
onChange={value => { setZoneId(value); void loadOptions(platform, points, value); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text strong>角色</Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{selectedRole ? (
|
||||||
|
<Text>{selectedRole.name}({selectedRole.role_id})</Text>
|
||||||
|
) : <Text type="secondary">当前区服暂无可用角色</Text>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" disabled={!selectedProduct || !selectedZone || !selectedRole} onClick={() => void submitSelection()} loading={loading}>
|
||||||
|
确认充值信息
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : <Spin tip="正在查询商品信息..." />}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPaymentCard = () => {
|
||||||
|
if (!task) return null;
|
||||||
|
const terminal = task.status === 'failed' || task.status === 'stopped';
|
||||||
|
return (
|
||||||
|
<Card title="微信付款">
|
||||||
|
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||||
|
<OrderSummary task={task} />
|
||||||
|
{status === 'ready' && (
|
||||||
|
<>
|
||||||
|
<Alert type="info" showIcon message={`确认本次充值 ${task.points} 点券(${yuan(task.price_fen)}),角色与区服见上方摘要`} />
|
||||||
|
<Button type="primary" icon={<WechatOutlined />} onClick={() => void createPayment()} disabled={!task.price_fen} loading={loading}>
|
||||||
|
生成微信付款码
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'ordering' && (
|
||||||
|
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||||
|
<Spin tip={task.message || '正在生成付款码'}><div style={{ height: 40 }} /></Spin>
|
||||||
|
{showLogs && (
|
||||||
|
<Collapse
|
||||||
|
activeKey={logsOpen ? ['logs'] : undefined}
|
||||||
|
onChange={keys => setLogsOpen(Array.isArray(keys) && keys.includes('logs'))}
|
||||||
|
items={[{
|
||||||
|
key: 'logs',
|
||||||
|
label: <Text strong>执行记录</Text>,
|
||||||
|
children: (
|
||||||
|
<div style={{ maxHeight: 200, overflow: 'auto', fontSize: 12, fontFamily: 'monospace' }}>
|
||||||
|
{logs.map((line, index) => <div key={index} style={{ whiteSpace: 'pre-wrap' }}>{line}</div>)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
{status === 'waiting_payment' && (
|
||||||
|
<Space direction="vertical" size={10}>
|
||||||
|
{task.payment_qr_data ? (
|
||||||
|
<Space direction="vertical" size={4}>
|
||||||
|
<Image width={280} preview src={`data:${task.payment_qr_mime_type || 'image/png'};base64,${task.payment_qr_data}`} />
|
||||||
|
<Tag color="blue">请使用微信扫码付款</Tag>
|
||||||
|
<Text type="secondary">生成于 {fmtTime(task.payment_qr_created_at)} · 最近检测 {fmtTime(task.payment_last_checked_at)}</Text>
|
||||||
|
</Space>
|
||||||
|
) : <Spin tip="正在生成付款码..." />}
|
||||||
|
<Text type="secondary">{task.message}</Text>
|
||||||
|
<Popconfirm title="确定放弃到账追踪?商城订单仍可能完成,请自行核对。" onConfirm={stopTask}>
|
||||||
|
<Button danger icon={<StopOutlined />} loading={loading}>放弃追踪</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
{status === 'payment_timeout' && (
|
||||||
|
<Space direction="vertical" size={10}>
|
||||||
|
<Alert type="warning" showIcon
|
||||||
|
message="付款码已生成,但未在时限内确认到账"
|
||||||
|
description="订单可能已支付或尚未支付。可重新检测到账(不会重复下单),或在执行记录中核对订单状态。" />
|
||||||
|
{task.payment_qr_data && (
|
||||||
|
<Image width={280} preview src={`data:${task.payment_qr_mime_type || 'image/png'};base64,${task.payment_qr_data}`} />
|
||||||
|
)}
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={() => void reCheck()} loading={loading}>重新检测到账</Button>
|
||||||
|
<Button onClick={() => setLogsOpen(true)}>查看脱敏执行记录</Button>
|
||||||
|
<Popconfirm title="确定放弃到账追踪?商城订单仍可能完成,请自行核对。" onConfirm={stopTask}>
|
||||||
|
<Button danger icon={<StopOutlined />} loading={loading}>放弃追踪</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
{terminal && <Alert type={status === 'failed' ? 'error' : 'info'} showIcon message={task.message || '任务已结束'} />}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCompletedCard = () => (
|
||||||
|
<Alert type="success" showIcon icon={<CheckCircleOutlined />}
|
||||||
|
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 <Card><Text type="secondary">{task.message}</Text></Card>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} lg={16}>
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Card size="small">
|
||||||
|
<Row justify="space-between" align="middle" gutter={[12, 8]}>
|
||||||
|
<Col>
|
||||||
|
<Space wrap>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>应用宝和平精英充值</Title>
|
||||||
|
{task && <Tag color={statusMeta.color}>{statusMeta.label}</Tag>}
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
{task && (
|
||||||
|
<Button icon={<ShoppingCartOutlined />} onClick={() => void createTask()} loading={loading}>
|
||||||
|
新建充值任务
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{task && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Text type="secondary">
|
||||||
|
创建人:{task.created_by_username || `#${task.created_by}`}
|
||||||
|
{task.created_at ? ` · 创建于 ${fmtTime(task.created_at)}` : ''}
|
||||||
|
{task.finished_at ? ` · 结束于 ${fmtTime(task.finished_at)}` : ''}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
{!task && (
|
||||||
|
<Card title="扫码登录">
|
||||||
|
<Space direction="vertical" size={12}>
|
||||||
|
<Text>选择登录方式,点击后自动创建充值任务并生成二维码。</Text>
|
||||||
|
<Space size={12}>
|
||||||
|
<Button size="large" icon={<QqOutlined />} onClick={() => void startLogin('qq')} loading={loading}>QQ 登录</Button>
|
||||||
|
<Button size="large" icon={<WechatOutlined />} onClick={() => void startLogin('wechat')} loading={loading}>微信登录</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{task && renderMainCard()}
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Card title="最近任务" size="small" extra={
|
||||||
|
can('yyb:history') ? (
|
||||||
|
<Button type="link" size="small" onClick={() => void loadRecent('all')}>全部任务</Button>
|
||||||
|
) : undefined
|
||||||
|
}>
|
||||||
|
{recentTasks.length === 0 ? <Empty description="暂无任务" image={Empty.PRESENTED_IMAGE_SIMPLE} /> : (
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={recentTasks.slice(0, 12)}
|
||||||
|
renderItem={item => (
|
||||||
|
<List.Item
|
||||||
|
style={{ cursor: 'pointer', paddingLeft: 4 }}
|
||||||
|
onClick={() => void openTask(item.id)}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||||
|
<Space wrap size={6}>
|
||||||
|
<Tag color={STATUS_META[item.status]?.color ?? 'default'} style={{ marginInlineEnd: 0 }}>{STATUS_META[item.status]?.label ?? item.status}</Tag>
|
||||||
|
{item.points ? <Text>{item.points} 点券</Text> : null}
|
||||||
|
{item.price_fen ? <Text type="danger">{yuan(item.price_fen)}</Text> : null}
|
||||||
|
{item.role_name ? <Text type="secondary">{item.role_name}</Text> : null}
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{item.created_by_username || `#${item.created_by}`} · {fmtTime(item.created_at)}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
{task && (
|
||||||
|
<Card title="订单摘要" size="small">
|
||||||
|
<OrderSummary task={task} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{showLogs && (
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
activeKey={logsOpen ? ['logs'] : undefined}
|
||||||
|
onChange={keys => setLogsOpen(Array.isArray(keys) && keys.includes('logs'))}
|
||||||
|
items={[{
|
||||||
|
key: 'logs',
|
||||||
|
label: <Text strong>执行记录{task?.status === 'failed' ? '(失败原因)' : ''}</Text>,
|
||||||
|
children: (
|
||||||
|
<div style={{ maxHeight: 280, overflow: 'auto', fontSize: 12, fontFamily: 'monospace' }}>
|
||||||
|
{logs.map((line, index) => <div key={index} style={{ whiteSpace: 'pre-wrap' }}>{line}</div>)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user