_lsy-lifecycle.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. # -*- coding: utf-8 -*-
  2. """乐收赢全链路联调台子:收单 / 查单 / 退款 / 退款查询 / 关单 / 撤销。
  3. 设计要点
  4. * 每一条出站都把**原始应答字节**落盘(logs/_lsy-lc/<tag>.bin),报告里只写结论。
  5. * 每一条出站都做**入站验签**(null 不参与)—— 验签绝不跳过。
  6. * 单条失败不中断:整轮跑完,末尾给汇总表。
  7. * 控制台是 GBK ⇒ stdout 只打 ASCII,正文进 UTF-8 文件。
  8. * 密钥只从 application.yml 读,只打长度。
  9. 用法: python logs/_lsy-lifecycle.py [section ...]
  10. section ∈ create, query, refund, close, cancel, supply, all(默认 all)
  11. """
  12. import io
  13. import json
  14. import os
  15. import re
  16. import sys
  17. import time
  18. import uuid
  19. import urllib.error
  20. import urllib.request
  21. from hashlib import md5
  22. YML = "java/src/main/resources/application.yml"
  23. VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$")
  24. OUTDIR = "logs/_lsy-lc"
  25. OUT = "logs/_lsy-lifecycle.txt"
  26. buf = []
  27. rows = []
  28. def say(s=""):
  29. buf.append(s)
  30. print(s.encode("ascii", "replace").decode())
  31. def row(tag, path, verdict, detail=""):
  32. rows.append((tag, path, verdict, detail))
  33. # ---------------------------------------------------------------- 配置
  34. def read_block(path, top):
  35. lines = io.open(path, encoding="utf-8", errors="replace").read().split("\n")
  36. start = None
  37. for i, ln in enumerate(lines):
  38. if re.match(r"^%s:\s*$" % re.escape(top), ln):
  39. start = i
  40. break
  41. if start is None:
  42. raise SystemExit("找不到顶层段 %s:" % top)
  43. out = {}
  44. for j in range(start + 1, len(lines)):
  45. ln = lines[j]
  46. if ln.strip() == "" or ln.lstrip().startswith("#"):
  47. continue
  48. if not ln.startswith((" ", "\t")):
  49. break
  50. m = re.match(r"^\s+([A-Za-z0-9_.\-]+):\s*(.*)$", ln)
  51. if m:
  52. out[m.group(1)] = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"")
  53. return out
  54. def resolve(raw):
  55. m = VAR.match(raw)
  56. if not m:
  57. return raw
  58. var, default = m.group(1), m.group(2)
  59. return os.environ.get(var) or default
  60. cfg = read_block(YML, "leshouying")
  61. BASE = resolve(cfg["base-url"]).rstrip("/")
  62. INST = resolve(cfg["inst-no"])
  63. KEY = resolve(cfg["key"])
  64. MCH = "Y52211090004"
  65. if not os.path.isdir(OUTDIR):
  66. os.makedirs(OUTDIR)
  67. # ---------------------------------------------------------------- 出站
  68. def call(path, params, tag, expect_sign=True):
  69. """签名 → POST → 落盘 → 入站验签。返回 (http_code, dict|None, raw)。"""
  70. p = dict(params)
  71. p.setdefault("inst_no", INST)
  72. p.setdefault("mch_no", MCH)
  73. A = "&".join("%s=%s" % (k, p[k]) for k in sorted(p) if p[k] is not None)
  74. p["sign"] = md5((A + "&key=" + KEY).encode("utf-8")).hexdigest().lower()
  75. body = json.dumps(p, ensure_ascii=False).encode("utf-8")
  76. req = urllib.request.Request(BASE + path, data=body,
  77. headers={"Content-Type": "application/json;charset=utf-8"},
  78. method="POST")
  79. try:
  80. with urllib.request.urlopen(req, timeout=30) as r:
  81. code, raw = r.status, r.read()
  82. except urllib.error.HTTPError as e:
  83. code, raw = e.code, e.read()
  84. except Exception as e: # noqa: BLE001
  85. say(" !! 请求未完成 %s: %r" % (tag, e))
  86. row(tag, path, "TRANSPORT_ERR", repr(e)[:80])
  87. return None, None, b""
  88. io.open("%s/%s.bin" % (OUTDIR, tag), "wb").write(raw)
  89. try:
  90. resp = json.loads(raw.decode("utf-8"))
  91. except Exception: # noqa: BLE001
  92. say(" !! 应答不是 JSON(%d B)" % len(raw))
  93. row(tag, path, "NON_JSON", "%d B" % len(raw))
  94. return code, None, raw
  95. # 入站验签(null 不参与)
  96. usign = resp.get("sign")
  97. vs = "无 sign"
  98. if usign:
  99. nn = {k: v for k, v in resp.items() if k != "sign" and v is not None}
  100. A2 = "&".join("%s=%s" % (k, nn[k]) for k in sorted(nn))
  101. vs = "验签OK(%d)" % len(nn) if md5((A2 + "&key=" + KEY).encode("utf-8")).hexdigest().lower() == usign else "验签FAIL"
  102. elif expect_sign:
  103. vs = "无sign(异常)"
  104. rcode = resp.get("return_code")
  105. rescode = resp.get("result_code")
  106. say(" POST %-22s -> http %s %4d B return_code=%s result_code=%s %s"
  107. % (path, code, len(raw), rcode, rescode, vs))
  108. say(" return_msg = %s" % resp.get("return_msg"))
  109. row(tag, path, "%s/%s" % (rcode, rescode), "%s; %s" % (str(resp.get("return_msg"))[:40], vs))
  110. return code, resp, raw
  111. def stamp():
  112. return time.strftime("%Y%m%d%H%M%S")
  113. def uid(prefix):
  114. return "%s%s%s" % (prefix, time.strftime("%H%M%S"), uuid.uuid4().hex[:6])
  115. STATE = {} # 跨用例共享的句柄
  116. # ---------------------------------------------------------------- E 补录
  117. def sec_supply():
  118. say("=" * 78)
  119. say("【补录】/v3/createSupply —— 造一笔「已支付成功」的单,解锁退款腿")
  120. say("=" * 78)
  121. trace = uid("sup")
  122. now = stamp()
  123. settle = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 60))
  124. _, resp, _ = call("/v3/createSupply", {
  125. "channel_type": "37", # 37=建行(本商户名就是「建行小店…」)
  126. "pay_mode": "2", # 2=扫码支付
  127. "type": "300", # 300=支付宝
  128. "function_flag": "1", # 1=补单交易成功订单
  129. "pay_time": now,
  130. "settle_time": settle,
  131. "pay_trace_no": trace,
  132. "total_fee": "1",
  133. "order_body": "联调补录",
  134. }, "supply-1")
  135. if resp:
  136. say(" data(乐收赢订单号) = %s" % resp.get("data"))
  137. STATE["sup_trace"] = trace
  138. STATE["sup_time"] = now
  139. STATE["sup_no"] = resp.get("data")
  140. say()
  141. # ---------------------------------------------------------------- A 收单
  142. def sec_create():
  143. say("=" * 78)
  144. say("【收单】三条腿 + 参数变体")
  145. say("=" * 78)
  146. # A1 聚合码:最小参数集(= 生产 createQrOrder 发的集合)
  147. t1, p1 = uid("qr"), stamp()
  148. _, r1, _ = call("/v3/qrpay", {
  149. "pay_trace_no": t1, "pay_time": p1, "total_amount": "1",
  150. "time_expire": "15", "order_body": "联调-主扫-最小",
  151. }, "create-qrpay-min")
  152. if r1:
  153. STATE["qr"] = {"trace": t1, "time": p1, "code_url": r1.get("code_url"), "resp": r1}
  154. say()
  155. # A2 聚合码:全参数
  156. t2, p2 = uid("qf"), stamp()
  157. _, r2, _ = call("/v3/qrpay", {
  158. "pay_trace_no": t2, "pay_time": p2, "total_amount": "2",
  159. "time_expire": "30", "order_body": "联调-主扫-全参",
  160. "attach": "A2", "notify_url": "https://example.com/notify",
  161. "front_url": "https://example.com/front", "auto_payment": "0",
  162. "undiscount_amount": "1",
  163. }, "create-qrpay-full")
  164. if r2:
  165. STATE["qr2"] = {"trace": t2, "time": p2, "code_url": r2.get("code_url")}
  166. say()
  167. # A3 同号重下(幂等/拒绝)
  168. if STATE.get("qr"):
  169. call("/v3/qrpay", {
  170. "pay_trace_no": STATE["qr"]["trace"], "pay_time": STATE["qr"]["time"],
  171. "total_amount": "1", "order_body": "联调-重复单号",
  172. }, "create-qrpay-dup")
  173. say()
  174. # A4 H5
  175. t4, p4 = uid("h5"), stamp()
  176. _, r4, _ = call("/v3/h5pay", {
  177. "pay_trace_no": t4, "pay_time": p4, "total_amount": "1",
  178. "pay_type": "300", "order_body": "联调-H5",
  179. }, "create-h5pay")
  180. if r4:
  181. STATE["h5"] = {"trace": t4, "time": p4, "trade_no": r4.get("trade_no"),
  182. "code_url": r4.get("code_url")}
  183. say()
  184. # A5 被扫(无真付款码,预期失败;仍记一条腿的读数)
  185. t5, p5 = uid("bc"), stamp()
  186. call("/v3/pay", {
  187. "pay_trace_no": t5, "pay_time": p5, "total_amount": "1",
  188. "auth_code": "284812345678901234", "pay_type": "300", "order_body": "联调-被扫",
  189. }, "create-pay-barcode")
  190. say()
  191. # A6 被扫:未知条码类型(错误路径)
  192. t6, p6 = uid("bx"), stamp()
  193. call("/v3/pay", {
  194. "pay_trace_no": t6, "pay_time": p6, "total_amount": "1",
  195. "auth_code": "0" * 18, "pay_type": "000",
  196. }, "create-pay-badcode")
  197. say()
  198. # ---------------------------------------------------------------- B 查单
  199. def sec_query():
  200. say("=" * 78)
  201. say("【查单】/v3/queryorder —— 五条键路")
  202. say("=" * 78)
  203. h5 = STATE.get("h5")
  204. if h5 and h5.get("trade_no"):
  205. call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex,
  206. "trade_no": h5["trade_no"]}, "query-h5-bytradeno")
  207. call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex,
  208. "pay_trace_no": h5["trace"], "pay_time": h5["time"]},
  209. "query-h5-bytrace")
  210. call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex,
  211. "pay_trace_no": h5["trace"]}, "query-h5-traceONLY")
  212. call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex,
  213. "trade_no": h5["trade_no"], "pay_trace_no": h5["trace"],
  214. "pay_time": h5["time"]}, "query-h5-both-keys")
  215. say()
  216. qr = STATE.get("qr")
  217. if qr:
  218. call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex,
  219. "pay_trace_no": qr["trace"], "pay_time": qr["time"]},
  220. "query-qrpay-unscanned")
  221. say()
  222. sup = STATE.get("sup_trace")
  223. if sup:
  224. call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex,
  225. "pay_trace_no": sup, "pay_time": STATE["sup_time"]},
  226. "query-supply-bytrace")
  227. if STATE.get("sup_no"):
  228. call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex,
  229. "trade_no": STATE["sup_no"]}, "query-supply-bytradeno")
  230. say()
  231. call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex,
  232. "pay_trace_no": "nosuchorder" + uuid.uuid4().hex[:8],
  233. "pay_time": stamp()}, "query-nonexistent")
  234. say()
  235. # 键缺失:两个键都不发(文档说二选一必传)
  236. call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex}, "query-nokey")
  237. say()
  238. # ---------------------------------------------------------------- D 退款
  239. def sec_refund():
  240. say("=" * 78)
  241. say("【退款】/v3/refundorder + /v3/queryrefund")
  242. say("=" * 78)
  243. # D1 对**未支付**的聚合码单退款(错误路径,看上游怎么拒)
  244. qr = STATE.get("qr")
  245. if qr:
  246. call("/v3/refundorder", {
  247. "refund_trace_no": uid("rf"), "refund_fee": "1",
  248. "pay_time": stamp(), "pay_trace_no": qr["trace"],
  249. "pay_trace_time": qr["time"],
  250. }, "refund-unpaid-qrpay")
  251. say()
  252. # D2 对**补录成功**的单退款(正路)
  253. sup = STATE.get("sup_trace")
  254. if sup:
  255. rt = uid("rf")
  256. _, r, _ = call("/v3/refundorder", {
  257. "refund_trace_no": rt, "refund_fee": "1", "pay_time": stamp(),
  258. "pay_trace_no": sup, "pay_trace_time": STATE["sup_time"],
  259. }, "refund-supply-bytrace")
  260. STATE["refund"] = {"trace": rt, "resp": r}
  261. if r and not r.get("refund_no") and r.get("refund_trace_no"):
  262. STATE["refund"]["trace"] = r.get("refund_trace_no")
  263. say()
  264. # D3 退款查询
  265. if STATE.get("refund"):
  266. rf = STATE["refund"]
  267. if rf["resp"] and rf["resp"].get("refund_no"):
  268. call("/v3/queryrefund", {"query_trace_no": uuid.uuid4().hex,
  269. "refund_no": rf["resp"]["refund_no"]}, "queryrefund-byno")
  270. call("/v3/queryrefund", {"query_trace_no": uuid.uuid4().hex,
  271. "refund_trace_no": rf["trace"],
  272. "refund_time": stamp()}, "queryrefund-bytrace")
  273. say()
  274. # D4 超额退款
  275. if sup:
  276. call("/v3/refundorder", {
  277. "refund_trace_no": uid("rx"), "refund_fee": "999999",
  278. "pay_time": stamp(), "pay_trace_no": sup,
  279. "pay_trace_time": STATE["sup_time"],
  280. }, "refund-overamount")
  281. say()
  282. # D5 退款不存在的单
  283. call("/v3/refundorder", {
  284. "refund_trace_no": uid("rn"), "refund_fee": "1", "pay_time": stamp(),
  285. "pay_trace_no": "nosuch" + uuid.uuid4().hex[:8], "pay_trace_time": stamp(),
  286. }, "refund-nonexistent")
  287. say()
  288. # ---------------------------------------------------------------- C 关单
  289. def sec_close():
  290. say("=" * 78)
  291. say("【关单】/v3/qrclose(聚合码,按 code_url)+ 幂等 + 错误路径")
  292. say("=" * 78)
  293. qr = STATE.get("qr")
  294. if qr and qr.get("code_url"):
  295. call("/v3/qrclose", {"code_url": qr["code_url"]}, "close-qr-1st")
  296. call("/v3/qrclose", {"code_url": qr["code_url"]}, "close-qr-2nd")
  297. call("/v3/queryorder", {"query_trace_no": uuid.uuid4().hex,
  298. "pay_trace_no": qr["trace"], "pay_time": qr["time"]},
  299. "query-qrpay-afterclose")
  300. say()
  301. call("/v3/qrclose", {"code_url": "https://test.gdyfsk.com/yfpay/s/s-99999"},
  302. "close-qr-nonexistent")
  303. call("/v3/qrclose", {}, "close-qr-nocodeurl")
  304. say()
  305. # H5 / 聚合码的通用关单腿(我方**没接** /v3/closeorder,这里顺手量一下它认不认)
  306. h5 = STATE.get("h5")
  307. if h5 and h5.get("trade_no"):
  308. call("/v3/closeorder", {
  309. "close_trace_no": uid("cl"), "close_time": stamp(),
  310. "trade_no": h5["trade_no"],
  311. }, "closeorder-h5-bytradeno")
  312. if STATE.get("qr"):
  313. call("/v3/closeorder", {
  314. "close_trace_no": uid("cl"), "close_time": stamp(),
  315. "pay_trace_no": STATE["qr"]["trace"], "pay_time": STATE["qr"]["time"],
  316. }, "closeorder-qr-bytrace")
  317. say()
  318. # ---------------------------------------------------------------- F 撤销
  319. def sec_cancel():
  320. say("=" * 78)
  321. say("【撤销】/v3/cancelorder(被扫)")
  322. say("=" * 78)
  323. call("/v3/cancelorder", {
  324. "cancel_trace_no": uid("cx"), "cancel_time": stamp(),
  325. "pay_trace_no": "nosuch" + uuid.uuid4().hex[:8], "pay_time": stamp(),
  326. }, "cancel-nonexistent")
  327. say()
  328. SECTIONS = {
  329. "supply": sec_supply, "create": sec_create, "query": sec_query,
  330. "refund": sec_refund, "close": sec_close, "cancel": sec_cancel,
  331. }
  332. ORDER = ["supply", "create", "query", "close", "refund", "cancel"]
  333. def main():
  334. want = sys.argv[1:] or ["all"]
  335. todo = ORDER if "all" in want else [s for s in ORDER if s in want]
  336. say("乐收赢全链路联调 —— 测试环境 %s / inst_no=%s / mch_no=%s / key=<len=%d>"
  337. % (BASE, INST, MCH, len(KEY)))
  338. say("时间 %s" % time.strftime("%Y-%m-%d %H:%M:%S"))
  339. say()
  340. for s in todo:
  341. try:
  342. SECTIONS[s]()
  343. except Exception as e: # noqa: BLE001
  344. say("!! 段落 %s 抛异常: %r" % (s, e))
  345. say()
  346. say("=" * 78)
  347. say("汇总(%d 条)" % len(rows))
  348. say("=" * 78)
  349. say("%-26s %-22s %-22s %s" % ("tag", "path", "return/result", "note"))
  350. for t, p, v, d in rows:
  351. say("%-26s %-22s %-22s %s" % (t, p, v, d))
  352. io.open(OUT, "w", encoding="utf-8").write("\n".join(buf) + "\n")
  353. print("report -> %s" % OUT)
  354. main()