inbound_client.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. """API_INBOUND 签名客户端:与 MdpInboundController / InboundSignatureHandler 契约一致。
  2. 签名串(方案 §5.2):
  3. message = METHOD&path&accessKey&timestamp&nonce&bodySha256&idempotencyKey
  4. signature = Base64(HMAC-SHA256(secret, message))
  5. Secret 只驻留本进程内存,不出现在返回值、日志与页面(脱敏由 redaction 模块负责)。
  6. 七类操作:schema / push / receipt / bulk / snapshot open / snapshot commit / digest。
  7. 测试用例语义与 tools/mock/inbound/push_demo.py 对齐(复用协议、不迁移文件)。
  8. """
  9. from __future__ import annotations
  10. import hashlib
  11. import hmac
  12. import json
  13. import time
  14. import urllib.error
  15. import urllib.parse
  16. import urllib.request
  17. import uuid
  18. from base64 import b64encode
  19. CONTRACT_VERSION_HEADER = "X-Mdp-Contract-Version"
  20. def sha256_hex(data: bytes) -> str:
  21. return hashlib.sha256(data).hexdigest()
  22. def sign(secret: str, message: str) -> str:
  23. digest = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest()
  24. return b64encode(digest).decode("ascii")
  25. def build_message(method: str, path: str, access_key: str, timestamp: str,
  26. nonce: str, body_sha256: str, idempotency_key: str) -> str:
  27. return f"{method.upper()}&{path}&{access_key}&{timestamp}&{nonce}&{body_sha256}&{idempotency_key}"
  28. def _parse(text: str):
  29. try:
  30. return json.loads(text) if text else {}
  31. except json.JSONDecodeError:
  32. return text
  33. class InboundClient:
  34. def __init__(self, base_url: str, access_key: str, secret: str, timeout: int = 30):
  35. self.base = base_url.rstrip("/")
  36. self.key = access_key
  37. self._secret = secret
  38. self.timeout = timeout
  39. # -- 底层签名请求 ---------------------------------------------------------
  40. def request(
  41. self,
  42. method: str,
  43. path: str,
  44. body: bytes | None = None,
  45. *,
  46. idem: str | None = None,
  47. ts: int | None = None,
  48. nonce: str | None = None,
  49. skip_idem: bool = False,
  50. tamper_body: bool = False,
  51. bad_signature: bool = False,
  52. contract_version: str | None = None,
  53. extra_headers: dict | None = None,
  54. ) -> tuple[int, dict | str, dict]:
  55. """返回 (http_status, parsed_body, sent_headers)。sent_headers 中签名已遮蔽。"""
  56. raw = body if body is not None else b""
  57. digest = sha256_hex(raw)
  58. if tamper_body:
  59. raw = raw + b"x"
  60. ts_s = str(ts if ts is not None else int(time.time()))
  61. nonce = nonce or uuid.uuid4().hex
  62. idem = idem or f"IDEM-{uuid.uuid4().hex}"
  63. message = build_message(method, path, self.key, ts_s, nonce, digest, idem)
  64. signature = sign(self._secret, message)
  65. if bad_signature:
  66. signature = sign(self._secret + "-wrong", message)
  67. headers = {
  68. "Content-Type": "application/json",
  69. "X-Access-Key": self.key,
  70. "X-Timestamp": ts_s,
  71. "X-Nonce": nonce,
  72. "X-Content-SHA256": digest,
  73. "X-Signature": signature,
  74. }
  75. if not skip_idem:
  76. headers["Idempotency-Key"] = idem
  77. if contract_version:
  78. headers[CONTRACT_VERSION_HEADER] = contract_version
  79. if extra_headers:
  80. headers.update(extra_headers)
  81. data = raw if method.upper() != "GET" else None
  82. req = urllib.request.Request(self.base + path, data=data, method=method.upper(), headers=headers)
  83. redacted = dict(headers)
  84. redacted["X-Signature"] = "***REDACTED***"
  85. try:
  86. with urllib.request.urlopen(req, timeout=self.timeout) as resp:
  87. return resp.status, _parse(resp.read().decode("utf-8")), redacted
  88. except urllib.error.HTTPError as ex:
  89. return ex.code, _parse(ex.read().decode("utf-8")), redacted
  90. except urllib.error.URLError as ex:
  91. return 0, {"message": f"SOURCE_UNREACHABLE: {ex.reason}"}, redacted
  92. except TimeoutError:
  93. return 0, {"message": "SOURCE_UNREACHABLE: timeout"}, redacted
  94. # -- 七类操作 -------------------------------------------------------------
  95. def op_schema(self, entity: str, contract_version: str | None = None):
  96. path = f"/api/mdp/inbound/{entity}/schema"
  97. return self.request("GET", path, b"", contract_version=contract_version)
  98. def op_push(self, entity: str, envelope: dict, **kwargs):
  99. path = f"/api/mdp/inbound/{entity}"
  100. body = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
  101. return self.request("POST", path, body, **kwargs)
  102. def op_receipt(self, sync_batch_id: str):
  103. path = f"/api/mdp/inbound/receipts/{urllib.parse.quote(sync_batch_id)}"
  104. return self.request("GET", path, b"")
  105. def op_bulk(self, entity: str, ndjson_lines: list[dict], **kwargs):
  106. path = f"/api/mdp/inbound/{entity}/bulk"
  107. body = ("\n".join(json.dumps(line, ensure_ascii=False) for line in ndjson_lines)).encode("utf-8")
  108. headers = {"Content-Type": "application/x-ndjson"}
  109. extra = dict(kwargs.pop("extra_headers", None) or {})
  110. extra.update(headers)
  111. return self.request("POST", path, body, extra_headers=extra, **kwargs)
  112. def op_snapshot_open(self, entity: str):
  113. path = f"/api/mdp/inbound/{entity}/snapshots"
  114. return self.request("POST", path, b"")
  115. def op_snapshot_commit(self, entity: str, snapshot_id: str, force: bool = False):
  116. path = f"/api/mdp/inbound/{entity}/snapshots/{urllib.parse.quote(snapshot_id)}/commit"
  117. if force:
  118. path += "?force=true"
  119. return self.request("POST", path, b"")
  120. def op_digest(self, entity: str, biz_date: str):
  121. path = f"/api/mdp/inbound/{entity}/digest?date={urllib.parse.quote(biz_date)}"
  122. return self.request("GET", path, b"")
  123. # -- 必测用例(P1-E)-------------------------------------------------------
  124. def case_expired_ts(self, entity: str, envelope: dict):
  125. return self.op_push(entity, envelope, ts=int(time.time()) - 3600)
  126. def case_nonce_replay(self, entity: str, envelope: dict):
  127. body = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
  128. path = f"/api/mdp/inbound/{entity}"
  129. nonce = uuid.uuid4().hex
  130. s1, d1, _ = self.request("POST", path, body, nonce=nonce)
  131. s2, d2, _ = self.request("POST", path, body, nonce=nonce, idem=f"IDEM-{uuid.uuid4().hex}")
  132. return (s1, d1), (s2, d2)
  133. def case_same_key_replay(self, entity: str, envelope: dict):
  134. body = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
  135. path = f"/api/mdp/inbound/{entity}"
  136. idem = f"IDEM-REPLAY-{uuid.uuid4().hex}"
  137. s1, d1, _ = self.request("POST", path, body, idem=idem)
  138. s2, d2, _ = self.request("POST", path, body, idem=idem)
  139. return (s1, d1), (s2, d2)
  140. def case_same_key_diff_body(self, entity: str, env1: dict, env2: dict):
  141. path = f"/api/mdp/inbound/{entity}"
  142. idem = f"IDEM-DIFF-{uuid.uuid4().hex}"
  143. b1 = json.dumps(env1, ensure_ascii=False).encode("utf-8")
  144. b2 = json.dumps(env2, ensure_ascii=False).encode("utf-8")
  145. s1, d1, _ = self.request("POST", path, b1, idem=idem)
  146. s2, d2, _ = self.request("POST", path, b2, idem=idem)
  147. return (s1, d1), (s2, d2)
  148. def case_missing_idem(self, entity: str, envelope: dict):
  149. return self.op_push(entity, envelope, skip_idem=True)
  150. def case_tamper_body(self, entity: str, envelope: dict):
  151. return self.op_push(entity, envelope, tamper_body=True)
  152. def case_bad_signature(self, entity: str, envelope: dict):
  153. return self.op_push(entity, envelope, bad_signature=True)