| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- #!/usr/bin/env python3
- """API_INBOUND 联调用例。签名按方案 §5.2:METHOD&path&key&ts&nonce&sha256&idem。
- 密钥只从 --secret 或环境变量 AIDOP_INBOUND_SECRET 读取,不写死生产 secret。
- 联调种子 TESTKPARTNER 的公开占位口令可用 --use-seed-secret(仅开发库)。
- """
- from __future__ import annotations
- import argparse
- import hashlib
- import hmac
- import json
- import os
- import sys
- import time
- import urllib.error
- import urllib.request
- import uuid
- from base64 import b64encode
- from datetime import datetime, timezone, timedelta
- # 公开联调种子,不是生产密钥
- _SEED_SECRET = "TESTKPARTNER_SECRET_DO_NOT_USE_IN_PROD"
- def sha256_hex(data: bytes) -> str:
- return hashlib.sha256(data).hexdigest()
- def sign(secret: str, message: str) -> str:
- digest = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest()
- return b64encode(digest).decode("ascii")
- def now_iso() -> str:
- return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%dT%H:%M:%S+08:00")
- def sales_row(**extra) -> dict:
- row = {
- "bill_no": extra.pop("bill_no", "SO-INB-DEMO-001"),
- "entry_seq": extra.pop("entry_seq", 1),
- "seorder_id": extra.pop("seorder_id", 900001),
- "qty": extra.pop("qty", 10),
- "item_number": "ITEM-INB-001",
- "sourceUpdatedAt": extra.pop("sourceUpdatedAt", now_iso()),
- }
- row.update(extra)
- return row
- def envelope(rows: list, snapshot_id: str | None = None, seq: int | None = None) -> dict:
- data: dict = {"list": rows}
- if snapshot_id:
- data["snapshotId"] = snapshot_id
- if seq is not None:
- data["seq"] = seq
- return {"data": data}
- class InboundClient:
- def __init__(self, base_url: str, access_key: str, secret: str):
- self.base = base_url.rstrip("/")
- self.key = access_key
- self.secret = secret
- def request(
- self,
- method: str,
- path: str,
- body: bytes | None = None,
- *,
- idem: str | None = None,
- ts: int | None = None,
- nonce: str | None = None,
- skip_idem: bool = False,
- tamper_body: bool = False,
- extra_headers: dict | None = None,
- ) -> tuple[int, dict | str]:
- raw = body if body is not None else b""
- digest = sha256_hex(raw)
- if tamper_body:
- raw = raw + b"x"
- ts_s = str(ts if ts is not None else int(time.time()))
- nonce = nonce or uuid.uuid4().hex
- idem = idem or f"IDEM-{uuid.uuid4().hex}"
- message = f"{method.upper()}&{path}&{self.key}&{ts_s}&{nonce}&{digest}&{idem}"
- headers = {
- "Content-Type": "application/json",
- "X-Access-Key": self.key,
- "X-Timestamp": ts_s,
- "X-Nonce": nonce,
- "X-Content-SHA256": digest,
- "X-Signature": sign(self.secret, message),
- }
- if not skip_idem:
- headers["Idempotency-Key"] = idem
- if extra_headers:
- headers.update(extra_headers)
- req = urllib.request.Request(self.base + path, data=raw if method != "GET" else None, method=method, headers=headers)
- try:
- with urllib.request.urlopen(req, timeout=30) as resp:
- text = resp.read().decode("utf-8")
- return resp.status, _parse(text)
- except urllib.error.HTTPError as ex:
- text = ex.read().decode("utf-8")
- return ex.code, _parse(text)
- except urllib.error.URLError as ex:
- return 0, {"message": str(ex.reason)}
- def _parse(text: str):
- try:
- return json.loads(text) if text else {}
- except json.JSONDecodeError:
- return text
- def report(name: str, ok: bool, detail) -> bool:
- mark = "PASS" if ok else "FAIL"
- print(f"[{mark}] {name}: {detail}")
- return ok
- def case_happy(c: InboundClient, entity: str) -> bool:
- path = f"/api/mdp/inbound/{entity}"
- body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
- status, data = c.request("POST", path, body)
- return report("happy", status == 202 and isinstance(data, dict) and data.get("code") in (0, 2), (status, data))
- def case_missing_idem(c: InboundClient, entity: str) -> bool:
- path = f"/api/mdp/inbound/{entity}"
- body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
- status, data = c.request("POST", path, body, skip_idem=True)
- msg = data.get("message") if isinstance(data, dict) else data
- return report("missing Idempotency-Key", status == 400 and "Idempotency-Key" in str(msg), (status, data))
- def case_tamper_body(c: InboundClient, entity: str) -> bool:
- path = f"/api/mdp/inbound/{entity}"
- body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
- status, data = c.request("POST", path, body, tamper_body=True)
- msg = data.get("message") if isinstance(data, dict) else data
- return report("body+1 byte", status == 400 and "digest" in str(msg).lower(), (status, data))
- def case_expired_ts(c: InboundClient, entity: str) -> bool:
- path = f"/api/mdp/inbound/{entity}"
- body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
- status, data = c.request("POST", path, body, ts=int(time.time()) - 3600)
- return report("expired timestamp", status == 401, (status, data))
- def case_nonce_replay(c: InboundClient, entity: str) -> bool:
- path = f"/api/mdp/inbound/{entity}"
- body = json.dumps(envelope([sales_row(bill_no="SO-NONCE-1")]), ensure_ascii=False).encode("utf-8")
- nonce = uuid.uuid4().hex
- status1, _ = c.request("POST", path, body, nonce=nonce)
- status2, data2 = c.request("POST", path, body, nonce=nonce, idem=f"IDEM-{uuid.uuid4().hex}")
- return report("nonce replay", status1 == 202 and status2 == 401, (status1, status2, data2))
- def case_same_key_replay(c: InboundClient, entity: str) -> bool:
- path = f"/api/mdp/inbound/{entity}"
- body = json.dumps(envelope([sales_row(bill_no="SO-REPLAY-1")]), ensure_ascii=False).encode("utf-8")
- idem = f"IDEM-REPLAY-{uuid.uuid4().hex}"
- status1, data1 = c.request("POST", path, body, idem=idem)
- status2, data2 = c.request("POST", path, body, idem=idem)
- replay = isinstance(data2, dict) and (data2.get("data") or {}).get("idempotentReplay") is True
- return report("same-key replay", status1 == 202 and status2 == 202 and replay, (status1, status2, data2))
- def case_same_key_diff_body(c: InboundClient, entity: str) -> bool:
- path = f"/api/mdp/inbound/{entity}"
- idem = f"IDEM-DIFF-{uuid.uuid4().hex}"
- b1 = json.dumps(envelope([sales_row(bill_no="SO-DIFF-1")]), ensure_ascii=False).encode("utf-8")
- b2 = json.dumps(envelope([sales_row(bill_no="SO-DIFF-2")]), ensure_ascii=False).encode("utf-8")
- status1, _ = c.request("POST", path, b1, idem=idem)
- status2, data2 = c.request("POST", path, b2, idem=idem)
- return report("same-key different body", status1 == 202 and status2 == 409, (status1, status2, data2))
- def case_partial(c: InboundClient, entity: str) -> bool:
- path = f"/api/mdp/inbound/{entity}"
- rows = [sales_row(bill_no="SO-PART-OK"), {"qty": 1, "sourceUpdatedAt": now_iso()}]
- body = json.dumps(envelope(rows), ensure_ascii=False).encode("utf-8")
- status, data = c.request("POST", path, body)
- code = data.get("code") if isinstance(data, dict) else None
- return report("partial", status == 202 and code == 2, (status, data))
- def case_stale(c: InboundClient, entity: str) -> bool:
- path = f"/api/mdp/inbound/{entity}"
- newer = "2026-09-11T12:00:00+08:00"
- older = "2026-09-01T12:00:00+08:00"
- b1 = json.dumps(envelope([sales_row(bill_no="SO-STALE-1", sourceUpdatedAt=newer)]), ensure_ascii=False).encode("utf-8")
- b2 = json.dumps(envelope([sales_row(bill_no="SO-STALE-1", sourceUpdatedAt=older)]), ensure_ascii=False).encode("utf-8")
- status1, _ = c.request("POST", path, b1)
- status2, data2 = c.request("POST", path, b2)
- rejected = ((data2.get("data") or {}).get("staleRejected") if isinstance(data2, dict) else 0) or 0
- return report("stale", status1 == 202 and status2 == 202 and rejected >= 1, (status1, status2, data2))
- def case_snapshot(c: InboundClient, entity: str) -> bool:
- open_path = f"/api/mdp/inbound/{entity}/snapshots"
- status, data = c.request("POST", open_path, b"")
- snap = (data.get("data") or {}).get("snapshotId") if isinstance(data, dict) else None
- if status not in (200, 201) or not snap:
- return report("snapshot open", False, (status, data))
- push = f"/api/mdp/inbound/{entity}"
- b1 = json.dumps(envelope([sales_row(bill_no="SO-SNAP-1")], snap, 1), ensure_ascii=False).encode("utf-8")
- b2 = json.dumps(envelope([sales_row(bill_no="SO-SNAP-2")], snap, 2), ensure_ascii=False).encode("utf-8")
- s1, _ = c.request("POST", push, b1)
- s2, _ = c.request("POST", push, b2)
- commit_path = f"/api/mdp/inbound/{entity}/snapshots/{snap}/commit"
- sc, dc = c.request("POST", commit_path, b"")
- committed = isinstance(dc, dict) and ((dc.get("data") or {}).get("status") == "COMMITTED" or dc.get("code") == 0)
- return report("snapshot open→2 batches→commit", s1 == 202 and s2 == 202 and sc in (200, 202) and committed, (s1, s2, sc, dc))
- CASES = {
- "happy": case_happy,
- "missing-idem": case_missing_idem,
- "tamper-body": case_tamper_body,
- "expired-ts": case_expired_ts,
- "nonce-replay": case_nonce_replay,
- "same-key-replay": case_same_key_replay,
- "same-key-diff-body": case_same_key_diff_body,
- "partial": case_partial,
- "stale": case_stale,
- "snapshot": case_snapshot,
- }
- def main() -> int:
- p = argparse.ArgumentParser(description="API_INBOUND mock push")
- p.add_argument("--base-url", default="http://127.0.0.1:5005")
- p.add_argument("--access-key", default="TESTKPARTNER")
- p.add_argument("--secret", default=os.environ.get("AIDOP_INBOUND_SECRET", ""))
- p.add_argument("--use-seed-secret", action="store_true", help="使用公开联调种子口令(非生产)")
- p.add_argument("--entity", default="S1_SALES_ORDER_ENTRY")
- p.add_argument("--case", default="all", choices=["all", *CASES])
- args = p.parse_args()
- secret = args.secret
- if args.use_seed_secret:
- secret = _SEED_SECRET
- if not secret:
- print("missing --secret or AIDOP_INBOUND_SECRET(开发库可加 --use-seed-secret)", file=sys.stderr)
- return 2
- client = InboundClient(args.base_url, args.access_key, secret)
- names = list(CASES) if args.case == "all" else [args.case]
- ok = True
- for name in names:
- ok = CASES[name](client, args.entity) and ok
- return 0 if ok else 1
- if __name__ == "__main__":
- raise SystemExit(main())
|