inbound_client.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  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. sign_path: str | None = None,
  55. ) -> tuple[int, dict | str, dict]:
  56. """返回 (http_status, parsed_body, sent_headers)。sent_headers 中签名已遮蔽。
  57. sign_path:签名串路径与请求 URL 路径不同时使用(查询串不得进入签名,
  58. 对齐 Ai-DOP InboundSignatureHandler 用 Request.Path.Value 验签)。"""
  59. raw = body if body is not None else b""
  60. digest = sha256_hex(raw)
  61. if tamper_body:
  62. raw = raw + b"x"
  63. ts_s = str(ts if ts is not None else int(time.time()))
  64. nonce = nonce or uuid.uuid4().hex
  65. idem = idem or f"IDEM-{uuid.uuid4().hex}"
  66. message = build_message(method, sign_path or path, self.key, ts_s, nonce, digest, idem)
  67. signature = sign(self._secret, message)
  68. if bad_signature:
  69. signature = sign(self._secret + "-wrong", message)
  70. headers = {
  71. "Content-Type": "application/json",
  72. "X-Access-Key": self.key,
  73. "X-Timestamp": ts_s,
  74. "X-Nonce": nonce,
  75. "X-Content-SHA256": digest,
  76. "X-Signature": signature,
  77. }
  78. if not skip_idem:
  79. headers["Idempotency-Key"] = idem
  80. if contract_version:
  81. headers[CONTRACT_VERSION_HEADER] = contract_version
  82. if extra_headers:
  83. headers.update(extra_headers)
  84. data = raw if method.upper() != "GET" else None
  85. req = urllib.request.Request(self.base + path, data=data, method=method.upper(), headers=headers)
  86. redacted = dict(headers)
  87. redacted["X-Signature"] = "***REDACTED***"
  88. try:
  89. with urllib.request.urlopen(req, timeout=self.timeout) as resp:
  90. return resp.status, _parse(resp.read().decode("utf-8")), redacted
  91. except urllib.error.HTTPError as ex:
  92. return ex.code, _parse(ex.read().decode("utf-8")), redacted
  93. except urllib.error.URLError as ex:
  94. return 0, {"message": f"SOURCE_UNREACHABLE: {ex.reason}"}, redacted
  95. except TimeoutError:
  96. return 0, {"message": "SOURCE_UNREACHABLE: timeout"}, redacted
  97. # -- 七类操作 -------------------------------------------------------------
  98. def op_schema(self, entity: str, contract_version: str | None = None):
  99. path = f"/api/mdp/inbound/{entity}/schema"
  100. return self.request("GET", path, b"", contract_version=contract_version)
  101. def op_push(self, entity: str, envelope: dict, **kwargs):
  102. path = f"/api/mdp/inbound/{entity}"
  103. body = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
  104. return self.request("POST", path, body, **kwargs)
  105. def op_receipt(self, sync_batch_id: str):
  106. path = f"/api/mdp/inbound/receipts/{urllib.parse.quote(sync_batch_id)}"
  107. return self.request("GET", path, b"")
  108. def op_bulk(self, entity: str, ndjson_lines: list[dict], **kwargs):
  109. path = f"/api/mdp/inbound/{entity}/bulk"
  110. body = ("\n".join(json.dumps(line, ensure_ascii=False) for line in ndjson_lines)).encode("utf-8")
  111. headers = {"Content-Type": "application/x-ndjson"}
  112. extra = dict(kwargs.pop("extra_headers", None) or {})
  113. extra.update(headers)
  114. return self.request("POST", path, body, extra_headers=extra, **kwargs)
  115. def op_snapshot_open(self, entity: str):
  116. path = f"/api/mdp/inbound/{entity}/snapshots"
  117. return self.request("POST", path, b"")
  118. def op_snapshot_commit(self, entity: str, snapshot_id: str, force: bool = False):
  119. sign_path = f"/api/mdp/inbound/{entity}/snapshots/{urllib.parse.quote(snapshot_id)}/commit"
  120. path = sign_path + ("?force=true" if force else "")
  121. return self.request("POST", path, b"", sign_path=sign_path)
  122. def op_digest(self, entity: str, biz_date: str):
  123. sign_path = f"/api/mdp/inbound/{entity}/digest"
  124. path = sign_path + f"?date={urllib.parse.quote(biz_date)}"
  125. return self.request("GET", path, b"", sign_path=sign_path)
  126. # -- 必测用例(P1-E)-------------------------------------------------------
  127. def case_expired_ts(self, entity: str, envelope: dict):
  128. return self.op_push(entity, envelope, ts=int(time.time()) - 3600)
  129. def case_nonce_replay(self, entity: str, envelope: dict):
  130. body = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
  131. path = f"/api/mdp/inbound/{entity}"
  132. nonce = uuid.uuid4().hex
  133. s1, d1, _ = self.request("POST", path, body, nonce=nonce)
  134. s2, d2, _ = self.request("POST", path, body, nonce=nonce, idem=f"IDEM-{uuid.uuid4().hex}")
  135. return (s1, d1), (s2, d2)
  136. def case_same_key_replay(self, entity: str, envelope: dict):
  137. body = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
  138. path = f"/api/mdp/inbound/{entity}"
  139. idem = f"IDEM-REPLAY-{uuid.uuid4().hex}"
  140. s1, d1, _ = self.request("POST", path, body, idem=idem)
  141. s2, d2, _ = self.request("POST", path, body, idem=idem)
  142. return (s1, d1), (s2, d2)
  143. def case_same_key_diff_body(self, entity: str, env1: dict, env2: dict):
  144. path = f"/api/mdp/inbound/{entity}"
  145. idem = f"IDEM-DIFF-{uuid.uuid4().hex}"
  146. b1 = json.dumps(env1, ensure_ascii=False).encode("utf-8")
  147. b2 = json.dumps(env2, ensure_ascii=False).encode("utf-8")
  148. s1, d1, _ = self.request("POST", path, b1, idem=idem)
  149. s2, d2, _ = self.request("POST", path, b2, idem=idem)
  150. return (s1, d1), (s2, d2)
  151. def case_missing_idem(self, entity: str, envelope: dict):
  152. return self.op_push(entity, envelope, skip_idem=True)
  153. def case_tamper_body(self, entity: str, envelope: dict):
  154. return self.op_push(entity, envelope, tamper_body=True)
  155. def case_bad_signature(self, entity: str, envelope: dict):
  156. return self.op_push(entity, envelope, bad_signature=True)