| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279 |
- # -*- 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)
|