Quellcode durchsuchen

test(乐收赢): 补上 674b814 引用的证据文件,并把密钥体检的覆盖面改成递归

【前一件:修悬空引用】
674b814 的提交信息里写了「原始字节 logs/_lsy-lc4/qrefund-bogus-trace.bin」,
但那批文件**根本没进版本库** —— 提交信息引用了一个不存在的东西。
ed310fc 那条提交信息专门写过「放在这里是为了让 javadoc 的引用有东西可指」,结果下一批
(logs/_lsy-lc4/ 与三个 query 探针)又漏了同一件事。这里补上 11 个文件:

  _lsy-lc4/qrclose-bogus-url.bin      /v3/qrclose 打一个上游不认的 code_url 的原始应答
  _lsy-lc4/qrefund-bogus-trace.bin    /v3/queryrefund 打一个上游不认的 refund_trace_no 的原始应答
  _lsy-query-probe.{py,txt}           + -resp.bin  主扫单走「pay_trace_no+pay_time」分支查一次
  _lsy-query-by-tradeno-probe.{py,txt} + -resp.bin 同一笔 H5 单走「trade_no」分支(正对照)
  _lsy-query-by-traceno-probe.{py,txt} + -resp.bin 同一笔 H5 单改走 trace 分支(判决用)

判决读数(_lsy-query-by-traceno-probe.txt):**H1** —— 同一笔 H5 单,用 trade_no 查得到
(正对照:13 字段 / PAY_IN_PROCESS),改用 pay_trace_no+pay_time **也查得到**
(17 字段参与验签,重算 == 上游 sign)。⇒ 查询腿的两条分支**都通**,
「查不到」只发生在「未扫的聚合码单」这一个状态上,不是键用错了。
没有这条正对照,「未扫聚合码单查不到」这个结论就分不清是上游语义还是我方发错键。

【后一件:体检的覆盖面】
_lsy-key-scan.py 原来扫的是**硬编码文件表**。表里没有 logs/_lsy-lc4/、也没有三个 query 探针
⇒ 它对这批文件是**瞎的**,而它上一轮报的「扫描 79 个文件 / 含密钥 0 个」只覆盖了表里那些。
表和新增产物各改各的,属同一类失真:量具自己的覆盖面没人量。
改成 os.walk 递归扫全部 logs/_lsy*,新增产物自动在射程内。

改完立刻见效 —— 扫描文件数 78,含密钥 **3** 个:
  logs/_lsy-doc-196236523.md、logs/_lsy-page-196236523.{json,u8.json}
不是泄漏:这 3 个是**上游自己公开的文档页**(就是授权联调那一页「联调准备 / 获取测试参数」)
的抓取件,密钥出现在它自己的表格行「机构秘钥key | … | 测试密钥」里 ——
是**厂商公布的测试环境凭证**,我们的 application.yml 用的就是它(这正是联调能解锁的原因)。
⇒ 这 3 个文件**不入库**:入库等于把 application.yml 里那个值搬进 git,
而 application.yml(mode MM,私钥明文)被刻意排除在版本库外,签这个文件等于绕开那个决定。
其余 74 个文件干净,本提交的 12 个文件全部在其中。

体检自己的正对照:扫描器先要求密钥在 application.yml 里命中 ≥1 次,否则自判「失效,体检作废」;
本轮读数 命中 = 1 / OK。密钥只打长度(32)与 sha256 前 16 位指纹,不打印任何子串。
alphaH vor 2 Wochen
Ursprung
Commit
7d4d3511a3

+ 11 - 10
logs/_lsy-key-scan.py

@@ -48,16 +48,17 @@ print("正对照 application.yml 命中 = %d  %s" % (pos, "OK" if pos >= 1 else
 if pos < 1:
     raise SystemExit(1)
 
-dirs = ["logs/_lsy-lc", "logs/_lsy-lc2", "logs/_lsy-lc3", "logs/_lsy-ctl"]
-loose = ["logs/_lsy-lifecycle.py", "logs/_lsy-lifecycle.txt",
-         "logs/_lsy-refund-unlock.py", "logs/_lsy-refund-unlock.txt",
-         "logs/_lsy-round3.py", "logs/_lsy-round3.txt",
-         "logs/_lsy-close-control.py", "logs/_lsy-close-control.txt"]
-files = list(loose)
-for d in dirs:
-    if os.path.isdir(d):
-        for n in sorted(os.listdir(d)):
-            files.append(os.path.join(d, n))
+# ★ 2026-09-28 改成**递归扫全部 logs/_lsy\***,不再用硬编码文件表。
+#   原表漏了后来新增的 logs/_lsy-lc4/ 与几个 query 探针 —— 于是「体检通过」只覆盖了表里那些,
+#   而表和新增产物**各改各的**(同一类失真:量具自己的覆盖面没人量)。
+#   实测过它漏的后果:ed310fc 之后新增的 11 个产物不在表里,体检说「干净」而它们根本没被读过。
+#   递归之后新增产物自动在射程内,这个失效模式整类消失。
+files = []
+for root, _dirs, names in os.walk("logs"):
+    for n in sorted(names):
+        if n.startswith("_lsy"):
+            files.append(os.path.join(root, n))
+files.sort()
 
 bad, n = [], 0
 for f in files:

+ 1 - 0
logs/_lsy-lc4/qrclose-bogus-url.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"该聚合码链接不存在或已过期!","result_code":"PAY_FAIL","mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"sign":null}

+ 1 - 0
logs/_lsy-lc4/qrefund-bogus-trace.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"query_trace_no":null,"refund_trace_no":null,"refund_time":null,"total_amount":null,"refund_amount":null,"refund_settle":null,"trade_no":null,"refund_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"undiscount_amount":null,"sign":null}

+ 168 - 0
logs/_lsy-query-by-traceno-probe.py

@@ -0,0 +1,168 @@
+# -*- coding: utf-8 -*-
+"""判决探针:拿一笔**已知可查**的单,改用 `pay_trace_no + pay_time` 分支再查一次。
+
+要分的两个假设(此前无法分辨):
+  H1 `queryorder` 按 `pay_trace_no+pay_time` 能查到单,只是**未扫的聚合码单**查不到
+     ⇒ 「聚合码未扫 = 不在查询域」成立,属上游语义问题。
+  H2 `pay_trace_no+pay_time` 这条分支**根本不通**(至少在本测试环境/本商户上)
+     ⇒ 原结论是用错键造出来的,对账腿对**所有**走这条分支的单都瞎,性质完全不同。
+
+量具:用 H5 那笔单(`logs/_lsy-h5pay-resp.bin`)—— 它的 trade_no 分支**实测查得到**
+(`logs/_lsy-query-by-tradeno-resp.bin`,614 B / PAY_IN_PROCESS)。同一笔单换个键:
+查得到 ⇒ H1;还是「订单信息不存在」⇒ H2。
+
+★ 只读查询。键值一律从原始应答字节里读,不手抄。
+★ 密钥只从 application.yml 读、只打长度。
+"""
+import json
+import os
+import re
+import sys
+import uuid
+import urllib.request
+import urllib.error
+from hashlib import md5
+
+YML = "java/src/main/resources/application.yml"
+VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$")
+SRC_BIN = "logs/_lsy-h5pay-resp.bin"
+CTRL_BIN = "logs/_lsy-query-by-tradeno-resp.bin"
+OUT = "logs/_lsy-query-by-traceno-probe.txt"
+
+buf = []
+
+
+def say(s=""):
+    buf.append(s)
+    print(s.encode("ascii", "replace").decode())
+
+
+def read_block(path, top):
+    lines = 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:
+            v = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"")
+            out[m.group(1)] = v
+    return out
+
+
+def resolve(raw):
+    m = VAR.match(raw)
+    if not m:
+        return raw, "application.yml 字面量"
+    var, default = m.group(1), m.group(2)
+    if os.environ.get(var):
+        return os.environ[var], "环境变量 %s" % var
+    return default, "application.yml 默认值(键 %s)" % var
+
+
+cfg = read_block(YML, "leshouying")
+base_url = resolve(cfg["base-url"])[0]
+inst_no = resolve(cfg["inst-no"])[0]
+key = resolve(cfg["key"])[0]
+
+prev = json.loads(open(SRC_BIN, "rb").read().decode("utf-8"))
+ctrl = json.loads(open(CTRL_BIN, "rb").read().decode("utf-8"))
+
+trace = prev.get("pay_trace_no")
+ptime = prev.get("pay_time")
+if not (trace and ptime):
+    raise SystemExit("H5 应答里缺 pay_trace_no/pay_time ⇒ 前提不成立: %s" % sorted(prev))
+
+say("── 先摆正对照:同一笔单,trade_no 分支的既有读数(读自原始应答字节)──")
+say("  trade_no     = %s" % prev.get("trade_no"))
+say("  pay_trace_no = %s" % trace)
+say("  pay_time     = %s" % ptime)
+say("  正对照读数   = return_code=%s / return_msg=%s / result_code=%s"
+    % (ctrl.get("return_code"), ctrl.get("return_msg"), ctrl.get("result_code")))
+say("  正对照必须是「查得到」—— 否则本探针的对照组不成立")
+if ctrl.get("result_code") is None:
+    raise SystemExit("正对照本身就没查到 ⇒ 换个对照组,别用这笔")
+say()
+
+payload = {
+    "inst_no": inst_no,
+    "mch_no": prev.get("mch_no"),
+    "query_trace_no": uuid.uuid4().hex,
+    # ↓ 走 LsyQueryService 的**无 trade_no 分支**(故意不发 trade_no)
+    "pay_trace_no": trace,
+    "pay_time": ptime,
+}
+
+A = "&".join("%s=%s" % (k, payload[k]) for k in sorted(payload))
+sign = md5((A + "&key=" + key).encode("utf-8")).hexdigest().lower()
+payload["sign"] = sign
+
+say("── 将发送(不含密钥;sign 打码)──")
+shown = dict(payload)
+shown["sign"] = "<len=%d>" % len(sign)
+say(json.dumps(shown, ensure_ascii=False, indent=2))
+say("  注意:**没有** trade_no 这个键 ⇒ 走的正是主扫单走的那条分支")
+say()
+
+url = base_url.rstrip("/") + "/v3/queryorder"
+body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+say("── 出站 ──")
+say("  POST %s" % url)
+say("  bytes = %d" % len(body))
+say()
+
+req = urllib.request.Request(
+    url, 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:
+    say("!! 请求未完成: %r" % (e,))
+    open(OUT, "w", encoding="utf-8").write("\n".join(buf))
+    sys.exit(1)
+
+open("logs/_lsy-query-by-traceno-resp.bin", "wb").write(raw)
+resp = json.loads(raw.decode("utf-8"))
+
+say("── 上游应答 ──")
+say("  http_code = %s, bytes = %d" % (code, len(raw)))
+for k in sorted(resp):
+    v = resp[k]
+    say("  %-16s = %s" % (k, "null" if v is None else repr(v)))
+say()
+
+say("── 判决 ──")
+if resp.get("result_code") is not None:
+    say("  查到了 ⇒ H1:pay_trace_no+pay_time 分支**通**,")
+    say("          问题只出在「未扫的聚合码单」这个状态上。")
+else:
+    say("  没查到 ⇒ H2:这条分支**不通**(本测试环境/本商户),")
+    say("          原结论「聚合码未扫查不到」是用错键造出来的,对账腿对走这条分支的单**全瞎**。")
+say()
+
+usign = resp.get("sign")
+if usign:
+    nonnull = {k: v for k, v in resp.items() if k != "sign" and v is not None}
+    A2 = "&".join("%s=%s" % (k, nonnull[k]) for k in sorted(nonnull))
+    mine = md5((A2 + "&key=" + key).encode("utf-8")).hexdigest().lower()
+    say("── 入站验签(null 不参与)──")
+    say("  参与签名字段数 = %d" % len(nonnull))
+    say("  重算 == 上游 sign : %s" % (mine == usign))
+say()
+
+open(OUT, "w", encoding="utf-8").write("\n".join(buf))

+ 56 - 0
logs/_lsy-query-by-traceno-probe.txt

@@ -0,0 +1,56 @@
+── 先摆正对照:同一笔单,trade_no 分支的既有读数(读自原始应答字节)──
+  trade_no     = 200001070X4326092816045010002
+  pay_trace_no = probe20260928160450h5
+  pay_time     = 20260928160450
+  正对照读数   = return_code=SUCCESS / return_msg=用户支付中 / result_code=PAY_IN_PROCESS
+  正对照必须是「查得到」—— 否则本探针的对照组不成立
+
+── 将发送(不含密钥;sign 打码)──
+{
+  "inst_no": "900112",
+  "mch_no": "Y52211090004",
+  "query_trace_no": "04f6029a3a5d4ce382a5a16e4d064b12",
+  "pay_trace_no": "probe20260928160450h5",
+  "pay_time": "20260928160450",
+  "sign": "<len=32>"
+}
+  注意:**没有** trade_no 这个键 ⇒ 走的正是主扫单走的那条分支
+
+── 出站 ──
+  POST https://test.gdyfsk.com/yfpay/v3/queryorder
+  bytes = 216
+
+── 上游应答 ──
+  http_code = 200, bytes = 614
+  attach           = null
+  coupon_amount    = null
+  discount_amount  = '0'
+  end_time         = null
+  mch_name         = '建行小店基本休息修改'
+  mch_no           = 'Y52211090004'
+  pay_amount       = '1'
+  pay_time         = '20260928160450'
+  pay_trace_no     = 'probe20260928160450h5'
+  pay_type         = '300'
+  qd_trade_no      = null
+  query_trace_no   = '04f6029a3a5d4ce382a5a16e4d064b12'
+  receipt_amount   = '0'
+  result_code      = 'PAY_IN_PROCESS'
+  return_code      = 'SUCCESS'
+  return_msg       = '用户支付中'
+  shop_name        = ''
+  shop_no          = ''
+  sign             = '54052b33db75e2c6d06bae0e5457728b'
+  td_trade_no      = null
+  total_amount     = '1'
+  trade_no         = '200001070X4326092816045010002'
+  trade_state      = 'USERPAYING'
+  user_id          = null
+
+── 判决 ──
+  查到了 ⇒ H1:pay_trace_no+pay_time 分支**通**,
+          问题只出在「未扫的聚合码单」这个状态上。
+
+── 入站验签(null 不参与)──
+  参与签名字段数 = 17
+  重算 == 上游 sign : True

+ 1 - 0
logs/_lsy-query-by-traceno-resp.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"用户支付中","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","query_trace_no":"04f6029a3a5d4ce382a5a16e4d064b12","pay_trace_no":"probe20260928160450h5","pay_time":"20260928160450","total_amount":"1","end_time":null,"trade_no":"200001070X4326092816045010002","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":"USERPAYING","receipt_amount":"0","pay_amount":"1","coupon_amount":null,"discount_amount":"0","sign":"54052b33db75e2c6d06bae0e5457728b"}

+ 152 - 0
logs/_lsy-query-by-tradeno-probe.py

@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+"""按 trade_no 查一次 /v3/queryorder —— 判 H5 对账走哪条分支。
+
+背景(实测,非推断):
+  `LsyQueryService.queryOrder` 的逻辑是「order 上有 trade_no 就用 trade_no,
+  否则用 pay_trace_no + pay_time」。
+  主扫(/v3/qrpay)的应答**没有 trade_no** ⇒ 主扫单走第二条分支(已实证)。
+  但 H5(/v3/h5pay)的 PAY_SUCCESS 应答**带 trade_no**(logs/_lsy-h5pay-resp.bin 里实测有)
+  ⇒ H5 单落库时 trade_no 非空 ⇒ 对账时走的是**第一条分支**。
+  而代码注释里写的是「qrpay 与 h5pay 那两个路径传的 patch 本来就没有 trade_no」——
+  与实测不符(h5 有)。本发就是要判:**按 trade_no 到底查不查得到这笔单**。
+
+★ 只读请求。trade_no 从上一发的原始应答字节里读,不手抄。
+★ 凭据同前:密钥只从 application.yml 读、只打长度。
+"""
+import json
+import os
+import re
+import sys
+import uuid
+import urllib.request
+import urllib.error
+from hashlib import md5
+
+YML = "java/src/main/resources/application.yml"
+VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$")
+SRC_BIN = "logs/_lsy-h5pay-resp.bin"
+OUT = "logs/_lsy-query-by-tradeno-probe.txt"
+
+buf = []
+
+
+def say(s=""):
+    buf.append(s)
+    print(s.encode("ascii", "replace").decode())
+
+
+def read_block(path, top):
+    lines = 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:
+            v = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"")
+            out[m.group(1)] = v
+    return out
+
+
+def resolve(raw):
+    m = VAR.match(raw)
+    if not m:
+        return raw, "application.yml 字面量"
+    var, default = m.group(1), m.group(2)
+    if os.environ.get(var):
+        return os.environ[var], "环境变量 %s" % var
+    return default, "application.yml 默认值(键 %s)" % var
+
+
+cfg = read_block(YML, "leshouying")
+base_url = resolve(cfg["base-url"])[0]
+inst_no = resolve(cfg["inst-no"])[0]
+key = resolve(cfg["key"])[0]
+
+if not os.path.exists(SRC_BIN):
+    raise SystemExit("缺少 %s —— 先跑 _lsy-h5pay-probe.py" % SRC_BIN)
+prev = json.loads(open(SRC_BIN, "rb").read().decode("utf-8"))
+
+trade_no = prev.get("trade_no")
+if not trade_no:
+    raise SystemExit("H5 那一发的应答里没有 trade_no ⇒ 本探针要判的前提不成立:%s" % sorted(prev))
+
+say("── 被查的那笔单(读自上一发的原始应答,非手抄)──")
+say("  trade_no     = %s" % trade_no)
+say("  pay_trace_no = %s" % prev.get("pay_trace_no"))
+say("  result_code  = %s  (下单受理,尚未被扫/未支付)" % prev.get("result_code"))
+say()
+
+payload = {
+    "inst_no": inst_no,
+    "mch_no": prev.get("mch_no"),
+    "query_trace_no": uuid.uuid4().hex,     # 上游要求每次查询唯一
+    # ↓ 走 LsyQueryService 的**有 trade_no 分支**
+    "trade_no": trade_no,
+}
+
+A = "&".join("%s=%s" % (k, payload[k]) for k in sorted(payload))
+sign = md5((A + "&key=" + key).encode("utf-8")).hexdigest().lower()
+payload["sign"] = sign
+
+say("── 将发送(不含密钥;sign 打码)──")
+shown = dict(payload)
+shown["sign"] = "<len=%d>" % len(sign)
+say(json.dumps(shown, ensure_ascii=False, indent=2))
+say()
+
+url = base_url.rstrip("/") + "/v3/queryorder"
+body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+say("── 出站 ──")
+say("  POST %s" % url)
+say("  bytes = %d" % len(body))
+say()
+
+req = urllib.request.Request(
+    url, 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:
+    say("!! 请求未完成: %r" % (e,))
+    open(OUT, "w", encoding="utf-8").write("\n".join(buf))
+    sys.exit(1)
+
+open("logs/_lsy-query-by-tradeno-resp.bin", "wb").write(raw)
+resp = json.loads(raw.decode("utf-8"))
+
+say("── 上游应答 ──")
+say("  http_code = %s, bytes = %d" % (code, len(raw)))
+for k in sorted(resp):
+    v = resp[k]
+    say("  %-16s = %s" % (k, "null" if v is None else repr(v)))
+say()
+
+usign = resp.get("sign")
+if usign:
+    nonnull = {k: v for k, v in resp.items() if k != "sign" and v is not None}
+    A2 = "&".join("%s=%s" % (k, nonnull[k]) for k in sorted(nonnull))
+    mine = md5((A2 + "&key=" + key).encode("utf-8")).hexdigest().lower()
+    say("── 入站验签(null 不参与)──")
+    say("  参与签名字段数 = %d" % len(nonnull))
+    say("  重算 == 上游 sign : %s" % (mine == usign))
+else:
+    say("── 应答里没有 sign 字段 ──")
+say()
+
+open(OUT, "w", encoding="utf-8").write("\n".join(buf))

+ 48 - 0
logs/_lsy-query-by-tradeno-probe.txt

@@ -0,0 +1,48 @@
+── 被查的那笔单(读自上一发的原始应答,非手抄)──
+  trade_no     = 200001070X4326092816045010002
+  pay_trace_no = probe20260928160450h5
+  result_code  = PAY_SUCCESS  (下单受理,尚未被扫/未支付)
+
+── 将发送(不含密钥;sign 打码)──
+{
+  "inst_no": "900112",
+  "mch_no": "Y52211090004",
+  "query_trace_no": "483a97c660a14921942751ba3ad4af79",
+  "trade_no": "200001070X4326092816045010002",
+  "sign": "<len=32>"
+}
+
+── 出站 ──
+  POST https://test.gdyfsk.com/yfpay/v3/queryorder
+  bytes = 190
+
+── 上游应答 ──
+  http_code = 200, bytes = 614
+  attach           = null
+  coupon_amount    = null
+  discount_amount  = '0'
+  end_time         = null
+  mch_name         = '建行小店基本休息修改'
+  mch_no           = 'Y52211090004'
+  pay_amount       = '1'
+  pay_time         = '20260928160450'
+  pay_trace_no     = 'probe20260928160450h5'
+  pay_type         = '300'
+  qd_trade_no      = null
+  query_trace_no   = '483a97c660a14921942751ba3ad4af79'
+  receipt_amount   = '0'
+  result_code      = 'PAY_IN_PROCESS'
+  return_code      = 'SUCCESS'
+  return_msg       = '用户支付中'
+  shop_name        = ''
+  shop_no          = ''
+  sign             = '18146ea94b94f4264992243d90d199be'
+  td_trade_no      = null
+  total_amount     = '1'
+  trade_no         = '200001070X4326092816045010002'
+  trade_state      = 'USERPAYING'
+  user_id          = null
+
+── 入站验签(null 不参与)──
+  参与签名字段数 = 17
+  重算 == 上游 sign : True

+ 1 - 0
logs/_lsy-query-by-tradeno-resp.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"用户支付中","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","query_trace_no":"483a97c660a14921942751ba3ad4af79","pay_trace_no":"probe20260928160450h5","pay_time":"20260928160450","total_amount":"1","end_time":null,"trade_no":"200001070X4326092816045010002","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":"USERPAYING","receipt_amount":"0","pay_amount":"1","coupon_amount":null,"discount_amount":"0","sign":"18146ea94b94f4264992243d90d199be"}

+ 153 - 0
logs/_lsy-query-probe.py

@@ -0,0 +1,153 @@
+# -*- coding: utf-8 -*-
+"""用刚下的那笔主扫单,走一遍**对账那条腿**的查询。
+
+为什么单列一发:
+  `LsyQueryService.queryOrder` 有两条腿 —— 有 trade_no 用 trade_no,没有则用
+  pay_trace_no + pay_time。而 /v3/qrpay 的应答**根本不含 trade_no**(文档的返回表没有这个字段,
+  实测也没有),所以「主扫单被对账捞起时」走的一定是 **pay_trace_no + pay_time 那条分支**。
+  这条分支此前从没被真上游读过一次。
+
+  pay_trace_no / pay_time **直接从上一发的原始应答字节里读**(logs/_lsy-qrpay-resp.bin),
+  不手抄 —— 手抄的常数是失真源。
+
+预期:一笔记账中(没人扫过码)的聚合码单 ⇒ result_code 应为 PAY_IN_PROCESS,
+      而不是「订单不存在」。若回「订单不存在」⇒ 下单与查询两条腿对同一笔单的键不一致。
+
+★ 凭据同前:密钥只从 application.yml 读、只打长度。★ 只读请求,不创建不修改。
+"""
+import json
+import os
+import re
+import sys
+import uuid
+import urllib.request
+import urllib.error
+from hashlib import md5
+
+YML = "java/src/main/resources/application.yml"
+VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$")
+SRC_BIN = "logs/_lsy-qrpay-resp.bin"
+OUT = "logs/_lsy-query-probe.txt"
+
+buf = []
+
+
+def say(s=""):
+    buf.append(s)
+    print(s.encode("ascii", "replace").decode())
+
+
+def read_block(path, top):
+    lines = 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:
+            v = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"")
+            out[m.group(1)] = v
+    return out
+
+
+def resolve(raw):
+    m = VAR.match(raw)
+    if not m:
+        return raw, "application.yml 字面量"
+    var, default = m.group(1), m.group(2)
+    if os.environ.get(var):
+        return os.environ[var], "环境变量 %s" % var
+    return default, "application.yml 默认值(键 %s)" % var
+
+
+cfg = read_block(YML, "leshouying")
+base_url = resolve(cfg["base-url"])[0]
+inst_no = resolve(cfg["inst-no"])[0]
+key = resolve(cfg["key"])[0]
+
+if not os.path.exists(SRC_BIN):
+    raise SystemExit("缺少 %s —— 先跑 _lsy-qrpay-probe.py" % SRC_BIN)
+prev = json.loads(open(SRC_BIN, "rb").read().decode("utf-8"))
+
+trace = prev.get("pay_trace_no")
+ptime = prev.get("pay_time")
+if not trace or not ptime:
+    raise SystemExit("上一发应答里没有 pay_trace_no/pay_time,无法查:%s" % sorted(prev))
+
+say("── 被查的那笔单(读自上一发的原始应答,非手抄)──")
+say("  pay_trace_no = %s" % trace)
+say("  pay_time     = %s" % ptime)
+say("  上一发是否给了 trade_no : %s  (qrpay 返回表里本就没有这个字段)" % ("trade_no" in prev))
+say()
+
+payload = {
+    "inst_no": inst_no,
+    "mch_no": prev.get("mch_no") or "Y52211090004",
+    "query_trace_no": uuid.uuid4().hex,     # 上游要求每次查询唯一
+    # ↓ 走 LsyQueryService 的**无 trade_no 分支**
+    "pay_trace_no": trace,
+    "pay_time": ptime,
+}
+
+A = "&".join("%s=%s" % (k, payload[k]) for k in sorted(payload))
+sign = md5((A + "&key=" + key).encode("utf-8")).hexdigest().lower()
+payload["sign"] = sign
+
+say("── 将发送(不含密钥;sign 打码)──")
+shown = dict(payload)
+shown["sign"] = "<len=%d>" % len(sign)
+say(json.dumps(shown, ensure_ascii=False, indent=2))
+say()
+
+url = base_url.rstrip("/") + "/v3/queryorder"
+body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+say("── 出站 ──")
+say("  POST %s" % url)
+say("  bytes = %d" % len(body))
+say()
+
+req = urllib.request.Request(
+    url, 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:
+    say("!! 请求未完成: %r" % (e,))
+    open(OUT, "w", encoding="utf-8").write("\n".join(buf))
+    sys.exit(1)
+
+open("logs/_lsy-query-resp.bin", "wb").write(raw)
+resp = json.loads(raw.decode("utf-8"))
+
+say("── 上游应答 ──")
+say("  http_code = %s, bytes = %d" % (code, len(raw)))
+say(json.dumps(resp, ensure_ascii=False, indent=2))
+say()
+
+# 入站验签:null 不参与(已两次实证的读法)
+usign = resp.get("sign")
+if usign:
+    nonnull = {k: v for k, v in resp.items() if k != "sign" and v is not None}
+    A2 = "&".join("%s=%s" % (k, nonnull[k]) for k in sorted(nonnull))
+    mine = md5((A2 + "&key=" + key).encode("utf-8")).hexdigest().lower()
+    say("── 入站验签(null 不参与)──")
+    say("  参与签名字段数 = %d" % len(nonnull))
+    say("  重算 == 上游 sign : %s" % (mine == usign))
+    say()
+
+open(OUT, "w", encoding="utf-8").write("\n".join(buf))

+ 51 - 0
logs/_lsy-query-probe.txt

@@ -0,0 +1,51 @@
+── 被查的那笔单(读自上一发的原始应答,非手抄)──
+  pay_trace_no = probe20260928152114qr
+  pay_time     = 20260928152114
+  上一发是否给了 trade_no : False  (qrpay 返回表里本就没有这个字段)
+
+── 将发送(不含密钥;sign 打码)──
+{
+  "inst_no": "900112",
+  "mch_no": "Y52211090004",
+  "query_trace_no": "ea811eb1179d442e928aae84a0423aaf",
+  "pay_trace_no": "probe20260928152114qr",
+  "pay_time": "20260928152114",
+  "sign": "<len=32>"
+}
+
+── 出站 ──
+  POST https://test.gdyfsk.com/yfpay/v3/queryorder
+  bytes = 216
+
+── 上游应答 ──
+  http_code = 200, bytes = 481
+{
+  "return_code": "FAIL",
+  "return_msg": "订单信息不存在!",
+  "result_code": null,
+  "pay_type": null,
+  "mch_name": null,
+  "shop_name": null,
+  "mch_no": null,
+  "shop_no": null,
+  "query_trace_no": null,
+  "pay_trace_no": null,
+  "pay_time": null,
+  "total_amount": null,
+  "end_time": null,
+  "trade_no": null,
+  "td_trade_no": null,
+  "qd_trade_no": null,
+  "user_id": null,
+  "attach": null,
+  "trade_state": null,
+  "receipt_amount": null,
+  "pay_amount": null,
+  "coupon_amount": null,
+  "discount_amount": null,
+  "sign": "a3cb8169d863e438486a6ea0ae72fea7"
+}
+
+── 入站验签(null 不参与)──
+  参与签名字段数 = 2
+  重算 == 上游 sign : True

+ 1 - 0
logs/_lsy-query-resp.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"query_trace_no":null,"pay_trace_no":null,"pay_time":null,"total_amount":null,"end_time":null,"trade_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"a3cb8169d863e438486a6ea0ae72fea7"}