# -*- coding: utf-8 -*- """第二轮:把「退款」这条腿打通 —— 换渠道补录 + 换寻址键退款。 第一轮的读数(logs/_lsy-lifecycle.txt): * 补录(channel_type=37 建行)→ queryorder 查到 PAY_SUCCESS ✔ * 但 /v3/refundorder → PAY_FAIL「退款失败:没有查询到符合条件的记录。」 * /v3/queryrefund 把那条退款记录查回来了,return_msg = 「查询失败:商户号[105000370940]已注销或待审核」 —— 那个商户号不是我们的(我们的是 Y52211090004),是**渠道商户号**。 ⇒ 假设 R1:退款失败是因为 channel_type=37 那条渠道在建行侧没开通/已注销。 若成立,换一个渠道(50 平安 / 6 官方直连 / 其它)补录后**退款应当能往下走**。 ⇒ 假设 R2:退款失败是**寻址键**的问题(pay_trace_no+pay_trace_time 这条腿在退款接口上不通)。 若成立,用 trade_no 寻址应当能往下走。 两个假设**各改一个变量**,互不干扰。 另测:真实被扫失败单的 /v3/cancelorder(文档:「如果此订单用户支付失败,会将此订单关闭」)。 """ import io import json import os import re import sys import time import uuid import urllib.error import urllib.request from hashlib import md5 YML = "java/src/main/resources/application.yml" VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$") OUTDIR = "logs/_lsy-lc2" OUT = "logs/_lsy-refund-unlock.txt" buf = [] rows = [] def say(s=""): buf.append(s) print(s.encode("ascii", "replace").decode()) def read_block(path, top): lines = io.open(path, encoding="utf-8", errors="replace").read().split("\n") start = None for i, ln in enumerate(lines): if re.match(r"^%s:\s*$" % re.escape(top), ln): start = i break if start is None: raise SystemExit("找不到顶层段 %s:" % top) out = {} for j in range(start + 1, len(lines)): ln = lines[j] if ln.strip() == "" or ln.lstrip().startswith("#"): continue if not ln.startswith((" ", "\t")): break m = re.match(r"^\s+([A-Za-z0-9_.\-]+):\s*(.*)$", ln) if m: out[m.group(1)] = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"") return out def resolve(raw): m = VAR.match(raw) if not m: return raw return os.environ.get(m.group(1)) or m.group(2) cfg = read_block(YML, "leshouying") BASE = resolve(cfg["base-url"]).rstrip("/") INST = resolve(cfg["inst-no"]) KEY = resolve(cfg["key"]) MCH = "Y52211090004" if not os.path.isdir(OUTDIR): os.makedirs(OUTDIR) # 第一轮遗留的真实句柄(读自原始应答字节,不手抄) BARCODE_TRADE_NO = "200001070X3126092816241710003" # create-pay-barcode,PAY_FAIL 的真被扫单 SUP_TRACE = None SUP_TIME = None SUP_NO = None for ln in io.open("logs/_lsy-lifecycle.txt", encoding="utf-8").read().split("\n"): if "data(乐收赢订单号)" in ln: SUP_NO = ln.split("=")[-1].strip() say("── 前置:读第一轮的补录句柄 ──") say(" 补录乐收赢订单号 = %s" % SUP_NO) say(" 真实被扫失败单 = %s" % BARCODE_TRADE_NO) say() def call(path, params, tag, envelope="v3"): p = dict(params) p.setdefault("inst_no", INST) p.setdefault("mch_no", MCH) A = "&".join("%s=%s" % (k, p[k]) for k in sorted(p) if p[k] is not None) p["sign"] = md5((A + "&key=" + KEY).encode("utf-8")).hexdigest().lower() body = json.dumps(p, ensure_ascii=False).encode("utf-8") req = urllib.request.Request(BASE + path, data=body, headers={"Content-Type": "application/json;charset=utf-8"}, method="POST") try: with urllib.request.urlopen(req, timeout=30) as r: code, raw = r.status, r.read() except urllib.error.HTTPError as e: code, raw = e.code, e.read() except Exception as e: # noqa: BLE001 say(" !! 未完成 %s: %r" % (tag, e)) rows.append((tag, path, "TRANSPORT_ERR", repr(e)[:60])) return None io.open("%s/%s.bin" % (OUTDIR, tag), "wb").write(raw) try: resp = json.loads(raw.decode("utf-8")) except Exception: # noqa: BLE001 rows.append((tag, path, "NON_JSON", "%d B" % len(raw))) return None usign = resp.get("sign") vs = "sign=null" if usign: nn = {k: v for k, v in resp.items() if k != "sign" and v is not None} A2 = "&".join("%s=%s" % (k, nn[k]) for k in sorted(nn)) ok = md5((A2 + "&key=" + KEY).encode("utf-8")).hexdigest().lower() == usign vs = "验签OK(%d)" % len(nn) if ok else "验签FAIL(%d)" % len(nn) elif "sign" not in resp: vs = "无sign键" rc = resp.get("return_code") ec = resp.get("result_code") if envelope == "legacy": rc, ec = resp.get("code"), resp.get("data") say(" POST %-20s -> %4d B rc=%s ec=%s %s" % (path, len(raw), rc, ec, vs)) say(" msg = %s" % (resp.get("return_msg") or resp.get("message"))) rows.append((tag, path, "%s/%s" % (rc, ec), "%s; %s" % (str(resp.get("return_msg") or resp.get("message"))[:44], vs))) return resp def stamp(): return time.strftime("%Y%m%d%H%M%S") def uid(p): return "%s%s%s" % (p, time.strftime("%H%M%S"), uuid.uuid4().hex[:6]) # ────────────────────────────────────────────── 0) 验签:FAIL 应答的 sign 覆盖了什么 say("=" * 78) say("【0】先钉一件事:queryorder 的 FAIL 应答 sign 覆盖哪几个字段") say("=" * 78) raw = io.open("logs/_lsy-lc/query-qrpay-unscanned.bin", "rb").read() resp = json.loads(raw.decode("utf-8")) nn = {k: v for k, v in resp.items() if k != "sign" and v is not None} say(" 非 null 字段 = %s" % sorted(nn)) A2 = "&".join("%s=%s" % (k, nn[k]) for k in sorted(nn)) mine = md5((A2 + "&key=" + KEY).encode("utf-8")).hexdigest().lower() say(" 重算 == 上游 : %s" % (mine == resp["sign"])) say(" ⇒ 这两条 FAIL 的 sign 是**常量**(换单号、换 query_trace_no 都不变)") say() # ────────────────────────────────────────────── 1) 换渠道补录 say("=" * 78) say("【1】假设 R1:换 channel_type 补录(各一条),再试退款") say("=" * 78) CHANNELS = [("6", "官方直连"), ("1", "网商"), ("2", "富友"), ("50", "平安"), ("4", "银联")] made = [] for ch, name in CHANNELS: trace = uid("s%d" % len(made)) now = stamp() settle = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 60)) r = call("/v3/createSupply", { "channel_type": ch, "pay_mode": "2", "type": "300", "function_flag": "1", "pay_time": now, "settle_time": settle, "pay_trace_no": trace, "total_fee": "1", "order_body": "二轮补录ch%s" % ch, }, "supply-ch%s" % ch, envelope="legacy") if r and r.get("code") == 200 and r.get("data"): made.append({"ch": ch, "name": name, "trace": trace, "time": now, "no": r["data"]}) say(" ch=%s(%s) 订单号 = %s" % (ch, name, r["data"])) say() # ────────────────────────────────────────────── 2) 每笔补录:查得到吗 say("=" * 78) say("【2】每笔补录用 queryorder 复核(必须 PAY_SUCCESS 才值得往下退)") say("=" * 78) good = [] for m in made: q = call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex, "pay_trace_no": m["trace"], "pay_time": m["time"]}, "q-supply-ch%s" % m["ch"]) if q and q.get("result_code") == "PAY_SUCCESS": good.append(m) say() say(" 查到 PAY_SUCCESS 的:%s" % [m["ch"] for m in good]) say() # ────────────────────────────────────────────── 3) 假设 R1:换渠道退款(键不变) say("=" * 78) say("【3】假设 R1:同一套寻址键(pay_trace_no+pay_trace_time),只换渠道") say("=" * 78) for m in made: say(" ── ch=%s(%s) ──" % (m["ch"], m["name"])) call("/v3/refundorder", { "refund_trace_no": uid("r%d" % len(m["ch"])), "refund_fee": "1", "pay_time": stamp(), "pay_trace_no": m["trace"], "pay_trace_time": m["time"], }, "refund-ch%s-bytrace" % m["ch"]) say() # ────────────────────────────────────────────── 4) 假设 R2:换寻址键(trade_no) say("=" * 78) say("【4】假设 R2:换寻址键为 trade_no(含第一轮那笔建行补录)") say("=" * 78) targets = [{"ch": "37(一轮建行)", "no": SUP_NO}] + [{"ch": m["ch"], "no": m["no"]} for m in good] for t in targets: if not t["no"]: continue say(" ── trade_no = %s ch=%s ──" % (t["no"], t["ch"])) call("/v3/refundorder", { "refund_trace_no": uid("rt"), "refund_fee": "1", "pay_time": stamp(), "trade_no": t["no"], }, "refund-tradeno-%s" % t["no"][-6:]) say() # ────────────────────────────────────────────── 5) 退款查询:把 refund_time 用对 say("=" * 78) say("【5】/v3/queryrefund 两条寻址路(第一轮 bytrace 挂在我把 refund_time 填错)") say("=" * 78) first = json.loads(io.open("logs/_lsy-lc/refund-supply-bytrace.bin", "rb").read().decode("utf-8")) say(" 第一轮那条退款:refund_no=%s refund_trace_no=%s" % (first.get("refund_no"), first.get("refund_trace_no"))) say(" 它回显的 pay_time(=发起退款的时间)= %s ← 这才是 refund_time 该填的值" % first.get("pay_time")) call("/v3/queryrefund", {"query_trace_no": uuid.uuid4().hex, "refund_trace_no": first["refund_trace_no"], "refund_time": first["pay_time"]}, "qrefund-bytrace-fixed") call("/v3/queryrefund", {"query_trace_no": uuid.uuid4().hex, "refund_no": first["refund_no"]}, "qrefund-byno-again") say() # ────────────────────────────────────────────── 6) 撤销:真实被扫失败单 say("=" * 78) say("【6】/v3/cancelorder —— 拿一笔**真实的被扫 PAY_FAIL 单**(文档:支付失败会关单)") say("=" * 78) call("/v3/cancelorder", {"cancel_trace_no": uid("cx"), "cancel_time": stamp(), "trade_no": BARCODE_TRADE_NO}, "cancel-barcode-bytradeno") call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex, "trade_no": BARCODE_TRADE_NO}, "q-barcode-aftercancel") say() # ────────────────────────────────────────────── 7) 退款一笔「已被关单」的 H5 单 say("=" * 78) say("【7】对**已关单**的 H5 单退款(第一轮 closeorder 已把它关了)") say("=" * 78) h5 = json.loads(io.open("logs/_lsy-lc/create-h5pay.bin", "rb").read().decode("utf-8")) say(" H5 单 trade_no = %s(已被 /v3/closeorder 关闭)" % h5.get("trade_no")) call("/v3/refundorder", {"refund_trace_no": uid("rc"), "refund_fee": "1", "pay_time": stamp(), "trade_no": h5.get("trade_no")}, "refund-closed-h5") say() # ────────────────────────────────────────────── 8) 补录退款(function_flag=2) say("=" * 78) say("【8】/v3/createSupply function_flag=2(补录退款订单)—— 直接造一条退款记录") say("=" * 78) if SUP_NO: call("/v3/createSupply", { "channel_type": "37", "pay_mode": "2", "type": "300", "function_flag": "2", "pay_time": stamp(), "settle_time": time.strftime("%Y-%m-%d %H:%M:%S"), "pay_trace_no": uid("sup2"), "total_fee": "1", "out_refund_no": SUP_NO, "order_body": "二轮补录退款", }, "supply-flag2", envelope="legacy") say() say("=" * 78) say("汇总(%d 条)" % len(rows)) say("=" * 78) say("%-30s %-22s %-20s %s" % ("tag", "path", "rc/ec", "note")) for t, p, v, d in rows: say("%-30s %-22s %-20s %s" % (t, p, v, d)) io.open(OUT, "w", encoding="utf-8").write("\n".join(buf) + "\n") print("report -> %s" % OUT)