push_demo.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. #!/usr/bin/env python3
  2. """API_INBOUND 联调用例。签名按方案 §5.2:METHOD&path&key&ts&nonce&sha256&idem。
  3. 密钥只从 --secret 或环境变量 AIDOP_INBOUND_SECRET 读取,不写死生产 secret。
  4. 联调种子 TESTKPARTNER 的公开占位口令可用 --use-seed-secret(仅开发库)。
  5. """
  6. from __future__ import annotations
  7. import argparse
  8. import hashlib
  9. import hmac
  10. import json
  11. import os
  12. import sys
  13. import time
  14. import urllib.error
  15. import urllib.request
  16. import uuid
  17. from base64 import b64encode
  18. from datetime import datetime, timezone, timedelta
  19. # 公开联调种子,不是生产密钥
  20. _SEED_SECRET = "TESTKPARTNER_SECRET_DO_NOT_USE_IN_PROD"
  21. def sha256_hex(data: bytes) -> str:
  22. return hashlib.sha256(data).hexdigest()
  23. def sign(secret: str, message: str) -> str:
  24. digest = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest()
  25. return b64encode(digest).decode("ascii")
  26. def now_iso() -> str:
  27. return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%dT%H:%M:%S+08:00")
  28. def sales_row(**extra) -> dict:
  29. row = {
  30. "bill_no": extra.pop("bill_no", "SO-INB-DEMO-001"),
  31. "entry_seq": extra.pop("entry_seq", 1),
  32. "seorder_id": extra.pop("seorder_id", 900001),
  33. "qty": extra.pop("qty", 10),
  34. "item_number": "ITEM-INB-001",
  35. "sourceUpdatedAt": extra.pop("sourceUpdatedAt", now_iso()),
  36. }
  37. row.update(extra)
  38. return row
  39. def envelope(rows: list, snapshot_id: str | None = None, seq: int | None = None) -> dict:
  40. data: dict = {"list": rows}
  41. if snapshot_id:
  42. data["snapshotId"] = snapshot_id
  43. if seq is not None:
  44. data["seq"] = seq
  45. return {"data": data}
  46. class InboundClient:
  47. def __init__(self, base_url: str, access_key: str, secret: str):
  48. self.base = base_url.rstrip("/")
  49. self.key = access_key
  50. self.secret = secret
  51. def request(
  52. self,
  53. method: str,
  54. path: str,
  55. body: bytes | None = None,
  56. *,
  57. idem: str | None = None,
  58. ts: int | None = None,
  59. nonce: str | None = None,
  60. skip_idem: bool = False,
  61. tamper_body: bool = False,
  62. extra_headers: dict | None = None,
  63. ) -> tuple[int, dict | str]:
  64. raw = body if body is not None else b""
  65. digest = sha256_hex(raw)
  66. if tamper_body:
  67. raw = raw + b"x"
  68. ts_s = str(ts if ts is not None else int(time.time()))
  69. nonce = nonce or uuid.uuid4().hex
  70. idem = idem or f"IDEM-{uuid.uuid4().hex}"
  71. message = f"{method.upper()}&{path}&{self.key}&{ts_s}&{nonce}&{digest}&{idem}"
  72. headers = {
  73. "Content-Type": "application/json",
  74. "X-Access-Key": self.key,
  75. "X-Timestamp": ts_s,
  76. "X-Nonce": nonce,
  77. "X-Content-SHA256": digest,
  78. "X-Signature": sign(self.secret, message),
  79. }
  80. if not skip_idem:
  81. headers["Idempotency-Key"] = idem
  82. if extra_headers:
  83. headers.update(extra_headers)
  84. req = urllib.request.Request(self.base + path, data=raw if method != "GET" else None, method=method, headers=headers)
  85. try:
  86. with urllib.request.urlopen(req, timeout=30) as resp:
  87. text = resp.read().decode("utf-8")
  88. return resp.status, _parse(text)
  89. except urllib.error.HTTPError as ex:
  90. text = ex.read().decode("utf-8")
  91. return ex.code, _parse(text)
  92. except urllib.error.URLError as ex:
  93. return 0, {"message": str(ex.reason)}
  94. def _parse(text: str):
  95. try:
  96. return json.loads(text) if text else {}
  97. except json.JSONDecodeError:
  98. return text
  99. def report(name: str, ok: bool, detail) -> bool:
  100. mark = "PASS" if ok else "FAIL"
  101. print(f"[{mark}] {name}: {detail}")
  102. return ok
  103. def case_happy(c: InboundClient, entity: str) -> bool:
  104. path = f"/api/mdp/inbound/{entity}"
  105. body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
  106. status, data = c.request("POST", path, body)
  107. return report("happy", status == 202 and isinstance(data, dict) and data.get("code") in (0, 2), (status, data))
  108. def case_missing_idem(c: InboundClient, entity: str) -> bool:
  109. path = f"/api/mdp/inbound/{entity}"
  110. body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
  111. status, data = c.request("POST", path, body, skip_idem=True)
  112. msg = data.get("message") if isinstance(data, dict) else data
  113. return report("missing Idempotency-Key", status == 400 and "Idempotency-Key" in str(msg), (status, data))
  114. def case_tamper_body(c: InboundClient, entity: str) -> bool:
  115. path = f"/api/mdp/inbound/{entity}"
  116. body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
  117. status, data = c.request("POST", path, body, tamper_body=True)
  118. msg = data.get("message") if isinstance(data, dict) else data
  119. return report("body+1 byte", status == 400 and "digest" in str(msg).lower(), (status, data))
  120. def case_expired_ts(c: InboundClient, entity: str) -> bool:
  121. path = f"/api/mdp/inbound/{entity}"
  122. body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
  123. status, data = c.request("POST", path, body, ts=int(time.time()) - 3600)
  124. return report("expired timestamp", status == 401, (status, data))
  125. def case_nonce_replay(c: InboundClient, entity: str) -> bool:
  126. path = f"/api/mdp/inbound/{entity}"
  127. body = json.dumps(envelope([sales_row(bill_no="SO-NONCE-1")]), ensure_ascii=False).encode("utf-8")
  128. nonce = uuid.uuid4().hex
  129. status1, _ = c.request("POST", path, body, nonce=nonce)
  130. status2, data2 = c.request("POST", path, body, nonce=nonce, idem=f"IDEM-{uuid.uuid4().hex}")
  131. return report("nonce replay", status1 == 202 and status2 == 401, (status1, status2, data2))
  132. def case_same_key_replay(c: InboundClient, entity: str) -> bool:
  133. path = f"/api/mdp/inbound/{entity}"
  134. body = json.dumps(envelope([sales_row(bill_no="SO-REPLAY-1")]), ensure_ascii=False).encode("utf-8")
  135. idem = f"IDEM-REPLAY-{uuid.uuid4().hex}"
  136. status1, data1 = c.request("POST", path, body, idem=idem)
  137. status2, data2 = c.request("POST", path, body, idem=idem)
  138. replay = isinstance(data2, dict) and (data2.get("data") or {}).get("idempotentReplay") is True
  139. return report("same-key replay", status1 == 202 and status2 == 202 and replay, (status1, status2, data2))
  140. def case_same_key_diff_body(c: InboundClient, entity: str) -> bool:
  141. path = f"/api/mdp/inbound/{entity}"
  142. idem = f"IDEM-DIFF-{uuid.uuid4().hex}"
  143. b1 = json.dumps(envelope([sales_row(bill_no="SO-DIFF-1")]), ensure_ascii=False).encode("utf-8")
  144. b2 = json.dumps(envelope([sales_row(bill_no="SO-DIFF-2")]), ensure_ascii=False).encode("utf-8")
  145. status1, _ = c.request("POST", path, b1, idem=idem)
  146. status2, data2 = c.request("POST", path, b2, idem=idem)
  147. return report("same-key different body", status1 == 202 and status2 == 409, (status1, status2, data2))
  148. def case_partial(c: InboundClient, entity: str) -> bool:
  149. path = f"/api/mdp/inbound/{entity}"
  150. rows = [sales_row(bill_no="SO-PART-OK"), {"qty": 1, "sourceUpdatedAt": now_iso()}]
  151. body = json.dumps(envelope(rows), ensure_ascii=False).encode("utf-8")
  152. status, data = c.request("POST", path, body)
  153. code = data.get("code") if isinstance(data, dict) else None
  154. return report("partial", status == 202 and code == 2, (status, data))
  155. def case_stale(c: InboundClient, entity: str) -> bool:
  156. path = f"/api/mdp/inbound/{entity}"
  157. newer = "2026-09-11T12:00:00+08:00"
  158. older = "2026-09-01T12:00:00+08:00"
  159. b1 = json.dumps(envelope([sales_row(bill_no="SO-STALE-1", sourceUpdatedAt=newer)]), ensure_ascii=False).encode("utf-8")
  160. b2 = json.dumps(envelope([sales_row(bill_no="SO-STALE-1", sourceUpdatedAt=older)]), ensure_ascii=False).encode("utf-8")
  161. status1, _ = c.request("POST", path, b1)
  162. status2, data2 = c.request("POST", path, b2)
  163. rejected = ((data2.get("data") or {}).get("staleRejected") if isinstance(data2, dict) else 0) or 0
  164. return report("stale", status1 == 202 and status2 == 202 and rejected >= 1, (status1, status2, data2))
  165. def case_snapshot(c: InboundClient, entity: str) -> bool:
  166. open_path = f"/api/mdp/inbound/{entity}/snapshots"
  167. status, data = c.request("POST", open_path, b"")
  168. snap = (data.get("data") or {}).get("snapshotId") if isinstance(data, dict) else None
  169. if status not in (200, 201) or not snap:
  170. return report("snapshot open", False, (status, data))
  171. push = f"/api/mdp/inbound/{entity}"
  172. b1 = json.dumps(envelope([sales_row(bill_no="SO-SNAP-1")], snap, 1), ensure_ascii=False).encode("utf-8")
  173. b2 = json.dumps(envelope([sales_row(bill_no="SO-SNAP-2")], snap, 2), ensure_ascii=False).encode("utf-8")
  174. s1, _ = c.request("POST", push, b1)
  175. s2, _ = c.request("POST", push, b2)
  176. commit_path = f"/api/mdp/inbound/{entity}/snapshots/{snap}/commit"
  177. sc, dc = c.request("POST", commit_path, b"")
  178. committed = isinstance(dc, dict) and ((dc.get("data") or {}).get("status") == "COMMITTED" or dc.get("code") == 0)
  179. return report("snapshot open→2 batches→commit", s1 == 202 and s2 == 202 and sc in (200, 202) and committed, (s1, s2, sc, dc))
  180. CASES = {
  181. "happy": case_happy,
  182. "missing-idem": case_missing_idem,
  183. "tamper-body": case_tamper_body,
  184. "expired-ts": case_expired_ts,
  185. "nonce-replay": case_nonce_replay,
  186. "same-key-replay": case_same_key_replay,
  187. "same-key-diff-body": case_same_key_diff_body,
  188. "partial": case_partial,
  189. "stale": case_stale,
  190. "snapshot": case_snapshot,
  191. }
  192. def main() -> int:
  193. p = argparse.ArgumentParser(description="API_INBOUND mock push")
  194. p.add_argument("--base-url", default="http://127.0.0.1:5005")
  195. p.add_argument("--access-key", default="TESTKPARTNER")
  196. p.add_argument("--secret", default=os.environ.get("AIDOP_INBOUND_SECRET", ""))
  197. p.add_argument("--use-seed-secret", action="store_true", help="使用公开联调种子口令(非生产)")
  198. p.add_argument("--entity", default="S1_SALES_ORDER_ENTRY")
  199. p.add_argument("--case", default="all", choices=["all", *CASES])
  200. args = p.parse_args()
  201. secret = args.secret
  202. if args.use_seed_secret:
  203. secret = _SEED_SECRET
  204. if not secret:
  205. print("missing --secret or AIDOP_INBOUND_SECRET(开发库可加 --use-seed-secret)", file=sys.stderr)
  206. return 2
  207. client = InboundClient(args.base_url, args.access_key, secret)
  208. names = list(CASES) if args.case == "all" else [args.case]
  209. ok = True
  210. for name in names:
  211. ok = CASES[name](client, args.entity) and ok
  212. return 0 if ok else 1
  213. if __name__ == "__main__":
  214. raise SystemExit(main())