@alipay/alipay-aipay 1.2.2 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +5 -0
  2. package/dist/commands/install.d.ts.map +1 -1
  3. package/dist/commands/install.js +13 -12
  4. package/dist/commands/install.js.map +1 -1
  5. package/dist/commands/uninstall.d.ts.map +1 -1
  6. package/dist/commands/uninstall.js +17 -9
  7. package/dist/commands/uninstall.js.map +1 -1
  8. package/dist/commands/update.d.ts.map +1 -1
  9. package/dist/commands/update.js +5 -5
  10. package/dist/commands/update.js.map +1 -1
  11. package/dist/index.js +4 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/utils/config.d.ts +5 -3
  14. package/dist/utils/config.d.ts.map +1 -1
  15. package/dist/utils/config.js +34 -12
  16. package/dist/utils/config.js.map +1 -1
  17. package/dist/utils/file.d.ts +8 -0
  18. package/dist/utils/file.d.ts.map +1 -1
  19. package/dist/utils/file.js +161 -9
  20. package/dist/utils/file.js.map +1 -1
  21. package/dist/utils/jq.d.ts.map +1 -1
  22. package/dist/utils/jq.js +8 -0
  23. package/dist/utils/jq.js.map +1 -1
  24. package/package.json +6 -5
  25. package/skill/SKILL.md +15 -169
  26. package/skill/VERSION +1 -1
  27. package/skill/references/integration/flow.md +321 -292
  28. package/skill/references/integration/modules/alipay-sdk-reminder.md +5 -3
  29. package/skill/references/integration/modules/checklist.md +41 -27
  30. package/skill/references/integration/modules/code-examples/csharp/1-/351/200/232/347/224/250/346/216/245/345/217/243//345/274/202/346/255/245/351/200/232/347/237/245/345/244/204/347/220/206/344/273/243/347/240/201/347/244/272/344/276/213.md +199 -0
  31. package/skill/references/integration/modules/code-examples/csharp/2-/347/275/221/347/253/231/346/224/257/344/273/230//347/273/237/344/270/200/346/224/266/345/215/225/344/270/213/345/215/225/345/271/266/346/224/257/344/273/230/351/241/265/351/235/242/346/216/245/345/217/243/344/273/243/347/240/201/347/244/272/344/276/213.md +8 -4
  32. package/skill/references/integration/modules/code-examples/csharp/3-APP/346/224/257/344/273/230/APP/346/224/257/344/273/230/346/216/245/345/217/243/344/273/243/347/240/201/347/244/272/344/276/213.md +6 -2
  33. package/skill/references/integration/modules/code-examples/csharp/4-/346/214/211/351/207/217/344/273/230/350/264/271/A2MPaymentDemo.cs +3 -1
  34. package/skill/references/integration/modules/code-examples/java/1-/351/200/232/347/224/250/346/216/245/345/217/243//345/274/202/346/255/245/351/200/232/347/237/245/345/244/204/347/220/206/344/273/243/347/240/201/347/244/272/344/276/213.md +184 -0
  35. package/skill/references/integration/modules/code-examples/java/2-/347/275/221/347/253/231/346/224/257/344/273/230//347/273/237/344/270/200/346/224/266/345/215/225/344/270/213/345/215/225/345/271/266/346/224/257/344/273/230/351/241/265/351/235/242/346/216/245/345/217/243/344/273/243/347/240/201/347/244/272/344/276/213.md +4 -1
  36. package/skill/references/integration/modules/code-examples/java/3-APP/346/224/257/344/273/230/APP/346/224/257/344/273/230/346/216/245/345/217/243/344/273/243/347/240/201/347/244/272/344/276/213.md +5 -2
  37. package/skill/references/integration/modules/code-examples/java/4-/346/214/211/351/207/217/344/273/230/350/264/271/A2MPaymentDemoController.java +3 -1
  38. package/skill/references/integration/modules/code-examples/nodejs/1-/351/200/232/347/224/250/346/216/245/345/217/243//345/274/202/346/255/245/351/200/232/347/237/245/345/244/204/347/220/206/344/273/243/347/240/201/347/244/272/344/276/213.md +135 -0
  39. package/skill/references/integration/modules/code-examples/nodejs/2-/347/275/221/347/253/231/346/224/257/344/273/230//347/273/237/344/270/200/346/224/266/345/215/225/344/270/213/345/215/225/345/271/266/346/224/257/344/273/230/351/241/265/351/235/242/346/216/245/345/217/243/344/273/243/347/240/201/347/244/272/344/276/213.md +16 -10
  40. package/skill/references/integration/modules/code-examples/nodejs/3-APP/346/224/257/344/273/230/APP/346/224/257/344/273/230/346/216/245/345/217/243/344/273/243/347/240/201/347/244/272/344/276/213.md +10 -4
  41. package/skill/references/integration/modules/code-examples/nodejs/4-/346/214/211/351/207/217/344/273/230/350/264/271/A2MPaymentDemo.js +2 -1
  42. package/skill/references/integration/modules/code-examples/php/1-/351/200/232/347/224/250/346/216/245/345/217/243//345/274/202/346/255/245/351/200/232/347/237/245/345/244/204/347/220/206/344/273/243/347/240/201/347/244/272/344/276/213.md +157 -0
  43. package/skill/references/integration/modules/code-examples/php/2-/347/275/221/347/253/231/346/224/257/344/273/230//347/273/237/344/270/200/346/224/266/345/215/225/344/270/213/345/215/225/345/271/266/346/224/257/344/273/230/351/241/265/351/235/242/346/216/245/345/217/243/344/273/243/347/240/201/347/244/272/344/276/213.md +4 -1
  44. package/skill/references/integration/modules/code-examples/php/3-APP/346/224/257/344/273/230/APP/346/224/257/344/273/230/346/216/245/345/217/243/344/273/243/347/240/201/347/244/272/344/276/213.md +5 -2
  45. package/skill/references/integration/modules/code-examples/php/4-/346/214/211/351/207/217/344/273/230/350/264/271/A2MPaymentDemo.php +2 -1
  46. package/skill/references/integration/modules/code-examples/python/1-/351/200/232/347/224/250/346/216/245/345/217/243//345/274/202/346/255/245/351/200/232/347/237/245/345/244/204/347/220/206/344/273/243/347/240/201/347/244/272/344/276/213.md +128 -0
  47. package/skill/references/integration/modules/code-examples/python/2-/347/275/221/347/253/231/346/224/257/344/273/230//347/273/237/344/270/200/346/224/266/345/215/225/344/270/213/345/215/225/345/271/266/346/224/257/344/273/230/351/241/265/351/235/242/346/216/245/345/217/243/344/273/243/347/240/201/347/244/272/344/276/213.md +4 -1
  48. package/skill/references/integration/modules/code-examples/python/3-APP/346/224/257/344/273/230/APP/346/224/257/344/273/230/346/216/245/345/217/243/344/273/243/347/240/201/347/244/272/344/276/213.md +5 -1
  49. package/skill/references/integration/modules/code-examples/python/4-/346/214/211/351/207/217/344/273/230/350/264/271/A2MPaymentDemo.py +4 -2
  50. package/skill/references/integration/modules/interface-contract/aipay-interface-contract.md +53 -0
  51. package/skill/references/integration/modules/interface-contract/apppay-interface-contract.md +45 -0
  52. package/skill/references/integration/modules/interface-contract/common-trade-contract.md +42 -0
  53. package/skill/references/integration/modules/interface-contract/notify-url-contract.md +68 -0
  54. package/skill/references/integration/modules/interface-contract/webpay-interface-contract.md +44 -0
  55. package/skill/references/integration/modules/interface-guide.md +37 -72
  56. package/skill/references/integration/modules/product-decision.md +10 -27
  57. package/skill/references/integration/modules/sandbox/a2m-sandbox-test.md +62 -94
  58. package/skill/references/integration/modules/sandbox/alipay-sandbox-tool.md +43 -40
  59. package/skill/references/integration/modules/sandbox/sandbox-setup-guide.md +93 -114
  60. package/skill/references/integration/modules/scripts/local_402_sandbox_pay.py +357 -147
  61. package/skill/references/integration/modules/scripts/sandbox_config.sh +299 -0
  62. package/skill/references/integration/modules/troubleshooting-flow.md +28 -0
  63. package/skill/references/normal/alipay-cli-env.md +15 -5
  64. package/skill/references/normal/full-process-routing.md +80 -0
  65. package/skill/references/normal/json/customer-messages.json +937 -0
  66. package/skill/references/normal/json/policy-rules.json +179 -0
  67. package/skill/references/normal/rejection-guide.md +11 -6
  68. package/skill/references/normal/scripts/alipay_cli_env.mjs +29 -0
  69. package/skill/references/normal/scripts/alipay_cli_env.sh +21 -0
  70. package/skill/references/normal/scripts/common.sh +87 -0
  71. package/skill/references/normal/scripts/detect_dev_tool.sh +5 -1
  72. package/skill/references/normal/scripts/integration_message_runner.mjs +801 -0
  73. package/skill/references/normal/scripts/onboarding_message_runner.mjs +436 -0
  74. package/skill/references/normal/scripts/open_official_url.sh +82 -0
  75. package/skill/references/normal/scripts/platform_compat.mjs +39 -0
  76. package/skill/references/normal/scripts/project_route_inspector.sh +132 -0
  77. package/skill/references/normal/scripts/python_script_runner.mjs +56 -0
  78. package/skill/references/normal/scripts/render_customer_message.mjs +1263 -0
  79. package/skill/references/normal/scripts/self_update.mjs +160 -0
  80. package/skill/references/normal/scripts/telemetry.mjs +1278 -0
  81. package/skill/references/normal/self-update.md +18 -51
  82. package/skill/references/onboarding/flow.md +381 -283
  83. package/skill/references/onboarding/modules/app-release.md +132 -179
  84. package/skill/references/onboarding/modules/authorization.md +53 -64
  85. package/skill/references/onboarding/modules/error-handling.md +31 -34
  86. package/skill/references/onboarding/modules/product-sign.md +62 -66
  87. package/skill/references/onboarding/modules/scripts/app.sh +397 -158
  88. package/skill/references/onboarding/modules/scripts/ar_sign_apply.sh +38 -22
  89. package/skill/references/onboarding/modules/scripts/auth.sh +479 -167
  90. package/skill/references/onboarding/modules/scripts/download_key_tool.sh +157 -0
  91. package/skill/references/onboarding/modules/scripts/error_handler.sh +100 -130
  92. package/skill/references/onboarding/modules/scripts/network_retry.sh +96 -0
  93. package/skill/references/onboarding/modules/scripts/query_sign_status.sh +17 -70
  94. package/skill/references/onboarding/modules/scripts/service.sh +153 -89
  95. package/skill/references/onboarding/modules/scripts/sign_status_parser.sh +58 -0
  96. package/skill/references/onboarding/modules/scripts/upload_screenshots.sh +16 -14
  97. package/skill/references/onboarding/modules/service-registration.md +67 -72
  98. package/skill/references/runtime-entry.md +62 -0
@@ -7,9 +7,10 @@ import argparse
7
7
  import base64
8
8
  import hashlib
9
9
  import json
10
+ import os
10
11
  import re
11
- import shlex
12
12
  import shutil
13
+ import stat
13
14
  import subprocess
14
15
  import sys
15
16
  import tempfile
@@ -22,10 +23,53 @@ from urllib.parse import quote
22
23
  PAY_ENDPOINT = "http://aicashier.dl.alipaydev.com/openclawpay/agent/v1/pay"
23
24
  PAY_URL_PREFIX = "https://render.alipay.com/p/yuyan/180020010001290755/pay.html?schema="
24
25
  A2M_SANDBOX_SERVICE_ID = "api_mock_service_id"
25
- DEFAULT_PAYMENT_NEEDED_FILE = "/tmp/402_needed_file.txt"
26
+ PAYMENT_NEEDED_REQUIRED_PATHS = (
27
+ ("protocol", "out_trade_no"),
28
+ ("protocol", "amount"),
29
+ ("protocol", "currency"),
30
+ ("protocol", "resource_id"),
31
+ ("protocol", "pay_before"),
32
+ ("protocol", "seller_signature"),
33
+ ("protocol", "seller_sign_type"),
34
+ ("protocol", "seller_unique_id"),
35
+ ("method", "seller_name"),
36
+ ("method", "seller_id"),
37
+ ("method", "seller_app_id"),
38
+ ("method", "goods_name"),
39
+ ("method", "seller_unique_id_key"),
40
+ ("method", "service_id"),
41
+ )
26
42
  DEFAULT_PAY_RETRIES = 3
27
43
  DEFAULT_PAY_RETRY_DELAY_SECONDS = 2.0
28
44
  RETRYABLE_PAY_ERROR_CODES = {"PAY_SUBMIT_FAILED"}
45
+ ARTIFACT_PREFIX = "alipay_local_402_sandbox_pay_"
46
+ ARTIFACT_FILES = {
47
+ "payment_needed.txt",
48
+ "decoded_bill.json",
49
+ "cashier_payload.json",
50
+ "state.json",
51
+ "pay_headers.txt",
52
+ "pay_status.txt",
53
+ "pay_response.json",
54
+ "payment_proof_body.json",
55
+ "payment_proof_header.txt",
56
+ "final_response.txt",
57
+ "final_headers.txt",
58
+ "final_status.txt",
59
+ }
60
+ KNOWN_DELIVERY_FAILURE_CODES = {
61
+ "CREATE_ORDER_ERROR",
62
+ "FULFILLMENT_CONFIRM_FAILED",
63
+ "FULFILLMENT_ERROR",
64
+ "INVALID_PAYMENT_PROOF",
65
+ "INVALID_PAYMENT_PROOF_FORMAT",
66
+ "MISSING_OUT_TRADE_NO",
67
+ "MISSING_RESOURCE_ID",
68
+ "ORDER_NOT_FOUND",
69
+ "RESOURCE_ID_MISMATCH",
70
+ "SIGN_ERROR",
71
+ "VERIFY_FAILED",
72
+ }
29
73
  REDACTED = "<redacted>"
30
74
  SENSITIVE_KEY_PATTERNS = (
31
75
  "signature",
@@ -34,12 +78,82 @@ SENSITIVE_KEY_PATTERNS = (
34
78
  "proof_header",
35
79
  "proofheader",
36
80
  )
81
+ ACTIVE_ARTIFACT_DIRS: set[Path] = set()
37
82
 
38
83
 
39
84
  def now_ms() -> str:
40
85
  return str(int(time.time() * 1000))
41
86
 
42
87
 
88
+ def secure_write_text(path: Path, value: str) -> None:
89
+ if path.name not in ARTIFACT_FILES and path.parent.name.startswith(ARTIFACT_PREFIX):
90
+ raise RuntimeError(f"拒绝写入未登记的过程产物文件:{path.name}")
91
+ if path.exists() and path.is_symlink():
92
+ raise RuntimeError(f"过程产物文件禁止使用符号链接:{path}")
93
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
94
+ if hasattr(os, "O_NOFOLLOW"):
95
+ flags |= os.O_NOFOLLOW
96
+ descriptor = os.open(path, flags, 0o600)
97
+ try:
98
+ if hasattr(os, "fchmod"):
99
+ os.fchmod(descriptor, 0o600)
100
+ with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
101
+ stream.write(value)
102
+ stream.flush()
103
+ os.fsync(stream.fileno())
104
+ finally:
105
+ os.close(descriptor)
106
+
107
+
108
+ def secure_read_text(path: Path) -> str:
109
+ info = path.lstat()
110
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
111
+ raise RuntimeError(f"过程产物必须是普通文件且不能是符号链接:{path}")
112
+ if os.name != "nt" and info.st_mode & 0o077:
113
+ raise RuntimeError(f"过程产物文件权限过宽,拒绝读取:{path}")
114
+ return path.read_text(encoding="utf-8")
115
+
116
+
117
+ def validate_artifact_dir(artifact_dir: Path) -> None:
118
+ if artifact_dir not in ACTIVE_ARTIFACT_DIRS:
119
+ raise RuntimeError("过程产物不属于本次命令,拒绝读取或清理")
120
+ info = artifact_dir.lstat()
121
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
122
+ raise RuntimeError(f"过程产物路径必须是普通目录且不能是符号链接:{artifact_dir}")
123
+ if hasattr(os, "getuid") and info.st_uid != os.getuid():
124
+ raise RuntimeError(f"过程产物目录不属于当前用户:{artifact_dir}")
125
+ if os.name != "nt" and info.st_mode & 0o077:
126
+ raise RuntimeError(f"过程产物目录权限过宽,拒绝使用:{artifact_dir}")
127
+
128
+
129
+ def create_artifact_dir() -> Path:
130
+ artifact_dir = Path(tempfile.mkdtemp(prefix=ARTIFACT_PREFIX))
131
+ os.chmod(artifact_dir, 0o700)
132
+ ACTIVE_ARTIFACT_DIRS.add(artifact_dir)
133
+ return artifact_dir
134
+
135
+
136
+ def cleanup_artifact_dir(path: str | Path) -> None:
137
+ artifact_dir = Path(path)
138
+ validate_artifact_dir(artifact_dir)
139
+ children = list(artifact_dir.iterdir())
140
+ unsafe = [child.name for child in children if child.name not in ARTIFACT_FILES or child.is_symlink() or child.is_dir()]
141
+ if unsafe:
142
+ raise RuntimeError(f"过程产物目录包含未登记内容,拒绝自动删除:{', '.join(sorted(unsafe))}")
143
+ for child in children:
144
+ child.unlink()
145
+ artifact_dir.rmdir()
146
+ ACTIVE_ARTIFACT_DIRS.discard(artifact_dir)
147
+
148
+
149
+ def cleanup_active_artifacts() -> None:
150
+ for artifact_dir in list(ACTIVE_ARTIFACT_DIRS):
151
+ try:
152
+ cleanup_artifact_dir(artifact_dir)
153
+ except (OSError, RuntimeError, ValueError):
154
+ continue
155
+
156
+
43
157
  def ensure_curl() -> None:
44
158
  if not shutil.which("curl"):
45
159
  raise SystemExit("需要 curl,但当前 PATH 中未找到 curl")
@@ -110,15 +224,20 @@ def fetch_payment_needed(
110
224
  method: str,
111
225
  body: str | None,
112
226
  content_type: str,
113
- output_file: str,
227
+ output_file: str | None,
228
+ require_status_402: bool = False,
114
229
  ) -> str:
115
230
  method = method.upper()
116
231
  if method == "GET":
117
- headers = run_curl(["-s", "-I", url])
118
- payment_needed = parse_payment_needed(headers)
119
- if not payment_needed:
232
+ if require_status_402:
120
233
  headers = run_curl(["-s", "-D", "-", "-o", "/dev/null", "-X", "GET", url])
121
234
  payment_needed = parse_payment_needed(headers)
235
+ else:
236
+ headers = run_curl(["-s", "-I", url])
237
+ payment_needed = parse_payment_needed(headers)
238
+ if not payment_needed:
239
+ headers = run_curl(["-s", "-D", "-", "-o", "/dev/null", "-X", "GET", url])
240
+ payment_needed = parse_payment_needed(headers)
122
241
  else:
123
242
  curl_args = ["-s", "-D", "-", "-o", "/dev/null", "-X", method, url]
124
243
  if body is not None:
@@ -126,10 +245,23 @@ def fetch_payment_needed(
126
245
  headers = run_curl(curl_args)
127
246
  payment_needed = parse_payment_needed(headers)
128
247
 
248
+ if require_status_402:
249
+ statuses = [
250
+ parts[1]
251
+ for line in headers.splitlines()
252
+ if line.startswith("HTTP/") and len(parts := line.split()) > 1
253
+ ]
254
+ status = statuses[-1] if statuses else ""
255
+ if status != "402":
256
+ raise RuntimeError(f"HTTP 状态不是 402: {status or '未知'}")
257
+
129
258
  if not payment_needed:
259
+ if require_status_402:
260
+ raise RuntimeError("未找到 Payment-Needed 响应头")
130
261
  raise RuntimeError(f"未找到 Payment-Needed 响应头。原始响应头:\n{headers}")
131
262
 
132
- Path(output_file).write_text(payment_needed, encoding="utf-8")
263
+ if output_file:
264
+ secure_write_text(Path(output_file).expanduser().resolve(strict=False), payment_needed)
133
265
  return payment_needed
134
266
 
135
267
 
@@ -147,6 +279,22 @@ def b64decode_json(value: str) -> dict[str, Any]:
147
279
  return data
148
280
 
149
281
 
282
+ def validate_payment_needed_fields(data: dict[str, Any]) -> None:
283
+ missing = []
284
+ for parent, child in PAYMENT_NEEDED_REQUIRED_PATHS:
285
+ nested = data.get(parent)
286
+ if not isinstance(nested, dict) or nested.get(child) in (None, ""):
287
+ missing.append(f"{parent}.{child}")
288
+ if missing:
289
+ raise RuntimeError("Payment-Needed 缺少字段: " + ", ".join(missing))
290
+ method = data["method"]
291
+ if method.get("service_id") != A2M_SANDBOX_SERVICE_ID:
292
+ raise RuntimeError(
293
+ f"沙箱联调 method.service_id 必须为 {A2M_SANDBOX_SERVICE_ID},"
294
+ "请修正用户服务的沙箱运行配置后重试"
295
+ )
296
+
297
+
150
298
  def snake_to_camel(key: str) -> str:
151
299
  if "_" not in key:
152
300
  return key
@@ -214,10 +362,6 @@ def redact_for_display(value: Any) -> Any:
214
362
  return value
215
363
 
216
364
 
217
- def shell_quote(value: str | Path) -> str:
218
- return shlex.quote(str(value))
219
-
220
-
221
365
  def post_json(url: str, payload: dict[str, Any]) -> tuple[int, str, dict[str, Any]]:
222
366
  with tempfile.TemporaryDirectory() as tmp_dir:
223
367
  headers_path = Path(tmp_dir) / "headers.txt"
@@ -393,85 +537,156 @@ def retry_original_service(
393
537
  return status_code, headers, response_body
394
538
 
395
539
 
396
- def write_json(path: Path, value: Any) -> None:
397
- path.write_text(pretty_json(value) + "\n", encoding="utf-8")
540
+ def b64url_decode_json(value: str) -> dict[str, Any]:
541
+ compact = value.strip().strip('"')
542
+ padded = compact + ("=" * ((4 - len(compact) % 4) % 4))
543
+ try:
544
+ decoded = base64.urlsafe_b64decode(padded).decode("utf-8")
545
+ result = json.loads(decoded)
546
+ except Exception as exc: # noqa: BLE001 - 对无效响应头统一返回确定错误。
547
+ raise ValueError(f"Payment-Validation 不是合法的 Base64URL JSON:{exc}") from exc
548
+ if not isinstance(result, dict):
549
+ raise ValueError("Payment-Validation 解码结果必须是 JSON 对象")
550
+ return result
398
551
 
399
552
 
400
- def create_artifact_dir(path: str | None) -> Path:
401
- artifact_dir = Path(path or f"/tmp/alipay_local_402_sandbox_pay_{now_ms()}")
402
- if artifact_dir.exists() and not artifact_dir.is_dir():
403
- raise RuntimeError(f"过程产物路径已存在但不是目录:{artifact_dir}")
404
- artifact_dir.mkdir(parents=True, exist_ok=True)
405
- return artifact_dir
553
+ def find_delivery_failure(value: Any) -> str | None:
554
+ if isinstance(value, list):
555
+ for item in value:
556
+ failure = find_delivery_failure(item)
557
+ if failure:
558
+ return failure
559
+ return None
560
+ if not isinstance(value, dict):
561
+ return None
562
+
563
+ if value.get("success") is False:
564
+ return "success=false"
565
+ if value.get("validated") is False:
566
+ return "validated=false"
567
+ if value.get("fulfillment_confirmed") is False or value.get("fulfillmentConfirmed") is False:
568
+ return "fulfillment_confirmed=false"
569
+
570
+ for key in ("code", "errorCode", "sub_code", "subCode"):
571
+ code = str(value.get(key) or "")
572
+ if code in KNOWN_DELIVERY_FAILURE_CODES:
573
+ return code
574
+ for item in value.values():
575
+ failure = find_delivery_failure(item)
576
+ if failure:
577
+ return failure
578
+ return None
406
579
 
407
580
 
408
- def load_reuse_artifact(path: str | None) -> tuple[dict[str, Any], str | None]:
409
- if not path:
410
- return {}, None
411
- artifact_dir = Path(path)
412
- if not artifact_dir.is_dir():
413
- raise RuntimeError(f"--reuse-artifact 指定的产物目录不存在:{artifact_dir}")
414
- state_path = artifact_dir / "state.json"
415
- payment_needed_path = artifact_dir / "payment_needed.txt"
416
- if not state_path.exists():
417
- raise RuntimeError(f"--reuse-artifact 缺少 state.json:{state_path}")
418
- if not payment_needed_path.exists():
419
- raise RuntimeError(f"--reuse-artifact 缺少 payment_needed.txt:{payment_needed_path}")
420
-
421
- loaded = json.loads(state_path.read_text(encoding="utf-8"))
422
- if not isinstance(loaded, dict):
423
- raise RuntimeError(f"--reuse-artifact 的 state.json 必须是 JSON 对象:{state_path}")
424
- state = loaded
425
- required_keys = ["url", "method", "buyerId"]
426
- missing_keys = [key for key in required_keys if not state.get(key)]
427
- if missing_keys:
428
- raise RuntimeError(
429
- f"--reuse-artifact 的 state.json 缺少必要字段:{', '.join(missing_keys)}。"
430
- "请使用同一脚本成功执行过 run 后再复用产物。"
581
+ def nonempty_resource(value: Any) -> bool:
582
+ return value not in (None, "", [], {})
583
+
584
+
585
+ def validate_delivery_response(
586
+ status_code: int,
587
+ response_headers: str,
588
+ final_response: str,
589
+ require_payment_validation: bool,
590
+ ) -> tuple[bool, str]:
591
+ if status_code != 200:
592
+ return False, f"最终服务必须返回 HTTP 200,实际为 {status_code}"
593
+ if not final_response.strip():
594
+ return False, "最终服务返回空响应,未证明资源交付"
595
+
596
+ response_json: Any = None
597
+ try:
598
+ response_json = json.loads(final_response)
599
+ except json.JSONDecodeError:
600
+ response_json = None
601
+
602
+ response_resource_id = ""
603
+ if response_json is not None:
604
+ failure = find_delivery_failure(response_json)
605
+ if failure:
606
+ return False, f"最终服务返回明确业务失败:{failure}"
607
+ if not isinstance(response_json, dict):
608
+ return False, "最终服务 JSON 响应必须是对象,无法确认资源归属"
609
+ response_resource_id = str(response_json.get("resource_id") or response_json.get("resourceId") or "")
610
+ content = response_json.get("content")
611
+ if not response_resource_id or not nonempty_resource(content):
612
+ return False, "最终服务响应缺少非空 resource_id/resourceId 或 content"
613
+
614
+ payment_validation_raw = parse_header_value(response_headers, "Payment-Validation")
615
+ if require_payment_validation and not payment_validation_raw:
616
+ return False, "目标服务要求 Payment-Validation,但最终响应未返回该响应头"
617
+ if payment_validation_raw:
618
+ try:
619
+ payment_validation = b64url_decode_json(payment_validation_raw)
620
+ except ValueError as exc:
621
+ return False, str(exc)
622
+ validation_failure = find_delivery_failure(payment_validation)
623
+ if validation_failure:
624
+ return False, f"Payment-Validation 返回明确失败:{validation_failure}"
625
+ if payment_validation.get("validated") is not True:
626
+ return False, "Payment-Validation 未明确返回 validated=true"
627
+ validation_trade_no = str(payment_validation.get("trade_no") or payment_validation.get("tradeNo") or "")
628
+ validation_resource_id = str(
629
+ payment_validation.get("resource_id") or payment_validation.get("resourceId") or ""
431
630
  )
631
+ if not validation_trade_no or not validation_resource_id:
632
+ return False, "Payment-Validation 缺少非空 trade_no/tradeNo 或 resource_id/resourceId"
633
+ if response_resource_id and validation_resource_id != response_resource_id:
634
+ return False, "Payment-Validation 与响应体的 resource_id 不一致"
432
635
 
433
- payment_needed = payment_needed_path.read_text(encoding="utf-8").strip()
434
- if not payment_needed:
435
- raise RuntimeError(f"--reuse-artifact 的 payment_needed.txt 为空:{payment_needed_path}")
436
- return state, payment_needed
636
+ if response_json is None and not payment_validation_raw:
637
+ return False, "非 JSON 资源响应缺少可用于确认归属的 Payment-Validation"
638
+ return True, "HTTP 200、非空可归属资源且无明确业务失败"
639
+
640
+
641
+ def write_json(path: Path, value: Any) -> None:
642
+ secure_write_text(path, pretty_json(value) + "\n")
643
+
644
+
645
+ def command_precheck(args: argparse.Namespace) -> int:
646
+ url = prompt_missing(args.url, "本地服务请求地址")
647
+ method = (args.method or "GET").upper()
648
+ if method not in {"GET", "POST"}:
649
+ raise SystemExit("当前技能只支持 GET 和 POST")
650
+ content_type = args.content_type or "application/json"
651
+ body = load_body(args, method, None)
652
+ decoded_bill = b64decode_json(
653
+ fetch_payment_needed(url, method, body, content_type, None, require_status_402=True)
654
+ )
655
+ validate_payment_needed_fields(decoded_bill)
656
+ fields = ", ".join(f"{parent}.{child}" for parent, child in PAYMENT_NEEDED_REQUIRED_PATHS)
657
+ print("Payment-Needed 预检通过,已确认 HTTP 402 和关键字段存在。")
658
+ print("已校验字段: " + fields)
659
+ return 0
437
660
 
438
661
 
439
662
  def command_run(args: argparse.Namespace) -> int:
440
- reuse_state, reuse_payment_needed = load_reuse_artifact(args.reuse_artifact)
441
- url = prompt_missing(args.url or reuse_state.get("url"), "本地服务请求地址")
442
- method = (args.method or reuse_state.get("method") or "GET").upper()
663
+ if not args.auto_complete:
664
+ raise RuntimeError("当前流程只支持一次连续执行 run --auto-complete;不保存跨轮付款或恢复状态")
665
+ url = prompt_missing(args.url, "本地服务请求地址")
666
+ method = (args.method or "GET").upper()
443
667
  if method not in {"GET", "POST"}:
444
668
  raise SystemExit("当前技能只支持 GET 和 POST")
445
- buyer_id = prompt_missing(args.buyer_id or reuse_state.get("buyerId"), "沙箱买家 2088 账号")
446
- content_type = args.content_type or str(reuse_state.get("contentType") or "application/json")
447
- body = load_body(args, method, reuse_state.get("body"))
448
- artifact_dir = create_artifact_dir(args.artifact_dir)
449
-
450
- if args.payment_needed:
451
- payment_needed = args.payment_needed.strip()
452
- Path(args.payment_needed_file).write_text(payment_needed, encoding="utf-8")
453
- elif reuse_payment_needed:
454
- payment_needed = reuse_payment_needed
455
- Path(args.payment_needed_file).write_text(payment_needed, encoding="utf-8")
456
- else:
457
- payment_needed = fetch_payment_needed(
458
- url,
459
- method,
460
- body,
461
- content_type,
462
- args.payment_needed_file,
463
- )
669
+ buyer_id = prompt_missing(args.buyer_id, "沙箱买家 2088 账号")
670
+ content_type = args.content_type or "application/json"
671
+ body = load_body(args, method, None)
672
+ artifact_dir = create_artifact_dir()
464
673
 
465
- decoded_bill = b64decode_json(payment_needed)
466
- method_data = decoded_bill.get("method") if isinstance(decoded_bill.get("method"), dict) else {}
467
- if method_data.get("service_id") != A2M_SANDBOX_SERVICE_ID:
468
- raise RuntimeError(
469
- f"沙箱联调 method.service_id 必须为 {A2M_SANDBOX_SERVICE_ID},"
470
- "请修正用户服务的沙箱运行配置后重试"
471
- )
472
- cashier_payload = build_cashier_payload(decoded_bill, buyer_id, args.buyer_signature)
674
+ try:
675
+ payment_needed = fetch_payment_needed(url, method, body, content_type, None)
676
+
677
+ decoded_bill = b64decode_json(payment_needed)
678
+ method_data = decoded_bill.get("method") if isinstance(decoded_bill.get("method"), dict) else {}
679
+ if method_data.get("service_id") != A2M_SANDBOX_SERVICE_ID:
680
+ raise RuntimeError(
681
+ f"沙箱联调 method.service_id 必须为 {A2M_SANDBOX_SERVICE_ID},"
682
+ "请修正用户服务的沙箱运行配置后重试"
683
+ )
684
+ cashier_payload = build_cashier_payload(decoded_bill, buyer_id, args.buyer_signature)
685
+ except (OSError, RuntimeError, ValueError):
686
+ cleanup_artifact_dir(artifact_dir)
687
+ raise
473
688
 
474
- (artifact_dir / "payment_needed.txt").write_text(payment_needed, encoding="utf-8")
689
+ secure_write_text(artifact_dir / "payment_needed.txt", payment_needed)
475
690
  write_json(artifact_dir / "decoded_bill.json", decoded_bill)
476
691
  write_json(artifact_dir / "cashier_payload.json", cashier_payload)
477
692
 
@@ -482,10 +697,10 @@ def command_run(args: argparse.Namespace) -> int:
482
697
  "contentType": content_type,
483
698
  "buyerId": buyer_id,
484
699
  "buyerSignature": args.buyer_signature,
485
- "paymentNeededFile": args.payment_needed_file,
486
700
  "payEndpoint": args.pay_endpoint,
487
- "reusedArtifact": args.reuse_artifact,
701
+ "requirePaymentValidation": bool(args.require_payment_validation),
488
702
  }
703
+ write_json(artifact_dir / "state.json", state)
489
704
 
490
705
  print("Payment-Needed 已获取并保存到本地过程产物,不在终端输出原始值。")
491
706
  print("\n解码后的 Payment-Needed JSON(已脱敏):")
@@ -493,12 +708,6 @@ def command_run(args: argparse.Namespace) -> int:
493
708
  print("\n收银接口请求体(已脱敏):")
494
709
  print(pretty_json(redact_for_display(cashier_payload)))
495
710
 
496
- if args.dry_run:
497
- state["dryRun"] = True
498
- write_json(artifact_dir / "state.json", state)
499
- print(f"\ndry-run 完成。过程产物目录:{artifact_dir}")
500
- return 0
501
-
502
711
  pay_status, pay_headers, pay_response = request_cashier_with_retry(
503
712
  args.pay_endpoint,
504
713
  cashier_payload,
@@ -507,8 +716,8 @@ def command_run(args: argparse.Namespace) -> int:
507
716
  )
508
717
  state["payStatus"] = pay_status
509
718
  state["payResponse"] = pay_response
510
- (artifact_dir / "pay_headers.txt").write_text(pay_headers, encoding="utf-8")
511
- (artifact_dir / "pay_status.txt").write_text(str(pay_status), encoding="utf-8")
719
+ secure_write_text(artifact_dir / "pay_headers.txt", pay_headers)
720
+ secure_write_text(artifact_dir / "pay_status.txt", str(pay_status))
512
721
  write_json(artifact_dir / "pay_response.json", pay_response)
513
722
  print("\n收银接口 HTTP 状态:")
514
723
  print(pay_status)
@@ -521,29 +730,31 @@ def command_run(args: argparse.Namespace) -> int:
521
730
  "errorMessage": pay_response.get("errorMessage"),
522
731
  }
523
732
  write_json(artifact_dir / "state.json", state)
524
- retry_mode = " --auto-complete" if args.auto_complete else ""
733
+ if not is_retryable_pay_response(pay_response):
734
+ cleanup_artifact_dir(artifact_dir)
735
+ raise RuntimeError("收银接口未返回 payScheme,且不是已确认的临时错误;敏感过程产物已清理")
525
736
  raise RuntimeError(
526
737
  "收银接口未返回 payScheme,无法生成付款链接。"
527
- f"过程产物已保存:{artifact_dir}。"
528
- "如果是 PAY_SUBMIT_FAILED/系统繁忙,可稍后复用同一个 Payment-Needed 重试:\n"
529
- f"python3 {shell_quote(Path(__file__).resolve())} run "
530
- f"--reuse-artifact {shell_quote(artifact_dir)}{retry_mode}"
738
+ "本轮敏感过程产物将立即清理;请稍后重新执行完整联调。"
531
739
  )
532
740
 
533
741
  pay_url = pay_url_from_response(pay_response)
534
- trade_no = trade_no_from_response(pay_response)
742
+ try:
743
+ trade_no = trade_no_from_response(pay_response)
744
+ except RuntimeError:
745
+ cleanup_artifact_dir(artifact_dir)
746
+ raise
535
747
  state["payUrl"] = pay_url
536
748
  state["tradeNo"] = trade_no
537
749
  write_json(artifact_dir / "state.json", state)
538
750
 
539
- print(f"\n过程产物目录:{artifact_dir}")
540
-
541
751
  if args.auto_complete:
542
752
  print("\n继续执行 Payment-Proof 服务端联调...")
543
753
  complete_args = argparse.Namespace(
544
754
  artifact_dir=str(artifact_dir),
545
755
  payment_proof=args.payment_proof,
546
756
  buyer_signature=args.buyer_signature,
757
+ require_payment_validation=args.require_payment_validation,
547
758
  )
548
759
  result = command_complete(complete_args)
549
760
  if result == 0:
@@ -552,35 +763,28 @@ def command_run(args: argparse.Namespace) -> int:
552
763
  print(pay_url)
553
764
  return result
554
765
 
555
- print("\n请在浏览器打开以下链接,并使用沙箱买家账号完成付款:")
556
- print(pay_url)
557
- print("付款成功后运行:")
558
- print(f"python3 {Path(__file__).resolve()} complete --artifact-dir {artifact_dir}")
559
-
560
- if args.wait:
561
- input("\n沙箱付款成功后按回车,继续重试原始服务...")
562
- complete_args = argparse.Namespace(
563
- artifact_dir=str(artifact_dir),
564
- payment_proof=args.payment_proof,
565
- buyer_signature=args.buyer_signature,
566
- )
567
- return command_complete(complete_args)
568
-
569
- return 0
766
+ raise RuntimeError("未进入自动完成分支")
570
767
 
571
768
 
572
769
  def command_complete(args: argparse.Namespace) -> int:
573
770
  artifact_dir = Path(prompt_missing(args.artifact_dir, "过程产物目录"))
771
+ validate_artifact_dir(artifact_dir)
574
772
  state_path = artifact_dir / "state.json"
575
773
  if not state_path.exists():
576
- raise SystemExit(f"缺少状态文件:{state_path}")
577
- state = json.loads(state_path.read_text(encoding="utf-8"))
774
+ cleanup_artifact_dir(artifact_dir)
775
+ raise RuntimeError(f"缺少状态文件,无法恢复且已清理过程产物:{state_path}")
776
+ try:
777
+ state = json.loads(secure_read_text(state_path))
778
+ except json.JSONDecodeError as exc:
779
+ cleanup_artifact_dir(artifact_dir)
780
+ raise RuntimeError("状态文件不是合法 JSON,无法恢复且已清理过程产物") from exc
578
781
  if not isinstance(state, dict):
579
- raise SystemExit(f"状态文件必须是 JSON 对象:{state_path}")
782
+ cleanup_artifact_dir(artifact_dir)
783
+ raise RuntimeError(f"状态文件必须是 JSON 对象,无法恢复且已清理过程产物:{state_path}")
580
784
  missing_keys = [key for key in ("url", "method", "buyerId", "tradeNo") if not state.get(key)]
581
785
  if missing_keys:
582
- raise SystemExit(f"状态文件缺少必要字段:{', '.join(missing_keys)}")
583
-
786
+ cleanup_artifact_dir(artifact_dir)
787
+ raise RuntimeError(f"状态文件缺少必要字段,无法恢复且已清理过程产物:{', '.join(missing_keys)}")
584
788
  buyer_signature = args.buyer_signature if args.buyer_signature is not None else state.get("buyerSignature", "-")
585
789
  proof_body, proof_header = build_payment_proof_header(
586
790
  buyer_id=str(state["buyerId"]),
@@ -590,7 +794,7 @@ def command_complete(args: argparse.Namespace) -> int:
590
794
  )
591
795
 
592
796
  write_json(artifact_dir / "payment_proof_body.json", proof_body)
593
- (artifact_dir / "payment_proof_header.txt").write_text(proof_header, encoding="utf-8")
797
+ secure_write_text(artifact_dir / "payment_proof_header.txt", proof_header)
594
798
 
595
799
  print("Payment-Proof 请求体(已脱敏):")
596
800
  print(pretty_json(redact_for_display(proof_body)))
@@ -603,25 +807,33 @@ def command_complete(args: argparse.Namespace) -> int:
603
807
  content_type=str(state.get("contentType") or "application/json"),
604
808
  proof_header=proof_header,
605
809
  )
606
- (artifact_dir / "final_response.txt").write_text(final_response, encoding="utf-8")
607
- (artifact_dir / "final_headers.txt").write_text(response_headers, encoding="utf-8")
608
- (artifact_dir / "final_status.txt").write_text(str(status_code), encoding="utf-8")
810
+ secure_write_text(artifact_dir / "final_response.txt", final_response)
811
+ secure_write_text(artifact_dir / "final_headers.txt", response_headers)
812
+ secure_write_text(artifact_dir / "final_status.txt", str(status_code))
609
813
 
610
814
  print("\n最终服务 HTTP 状态:")
611
815
  print(status_code)
612
816
  payment_validation = parse_header_value(response_headers, "Payment-Validation")
613
817
  if payment_validation:
614
- print("\nPayment-Validation 响应头:")
615
- print(payment_validation)
818
+ print("\nPayment-Validation 响应头已收到,原始值不在终端输出。")
616
819
  print("\n最终服务响应体:")
617
820
  print(final_response)
618
- print(f"\n过程产物目录:{artifact_dir}")
619
- if status_code < 200 or status_code >= 300:
620
- print(
621
- f"\nPayment-Proof 重试未通过:最终服务返回 HTTP {status_code}。"
622
- "请优先检查服务端订单映射、Payment-Proof 验证、资源防串和履约确认逻辑。"
623
- )
821
+ require_payment_validation = bool(
822
+ getattr(args, "require_payment_validation", False) or state.get("requirePaymentValidation")
823
+ )
824
+ passed, reason = validate_delivery_response(
825
+ status_code,
826
+ response_headers,
827
+ final_response,
828
+ require_payment_validation,
829
+ )
830
+ if not passed:
831
+ print(f"\nPayment-Proof 重试未通过:{reason}。")
832
+ print("请优先检查服务端订单映射、Payment-Proof 验证、资源防串和履约确认逻辑。")
624
833
  return 1
834
+ print(f"\n资源交付证据校验通过:{reason}。")
835
+ cleanup_artifact_dir(artifact_dir)
836
+ print("敏感过程产物已安全清理。")
625
837
  return 0
626
838
 
627
839
 
@@ -629,7 +841,15 @@ def build_parser() -> argparse.ArgumentParser:
629
841
  parser = argparse.ArgumentParser(description=__doc__)
630
842
  subparsers = parser.add_subparsers(dest="command")
631
843
 
632
- run_parser = subparsers.add_parser("run", help="获取 Payment-Needed 并生成沙箱付款链接")
844
+ precheck_parser = subparsers.add_parser("precheck", help="只读检查 Payment-Needed 的 HTTP 402 和关键字段")
845
+ precheck_parser.add_argument("--url", help="本地服务请求地址,GET 参数需要直接拼在 URL 中")
846
+ precheck_parser.add_argument("--method", help="HTTP 方法:GET 或 POST")
847
+ precheck_parser.add_argument("--body", help="POST 请求体,或用 @file 从文件读取请求体")
848
+ precheck_parser.add_argument("--body-file", help="保存 POST 请求体的文件")
849
+ precheck_parser.add_argument("--content-type", help="POST 请求的 Content-Type")
850
+ precheck_parser.set_defaults(func=command_precheck)
851
+
852
+ run_parser = subparsers.add_parser("run", help="一笔完成 Payment-Needed、沙箱收银和资源交付联调")
633
853
  run_parser.add_argument("--url", help="本地服务请求地址,GET 参数需要直接拼在 URL 中")
634
854
  run_parser.add_argument("--method", help="HTTP 方法:GET 或 POST")
635
855
  run_parser.add_argument("--body", help="POST 请求体,或用 @file 从文件读取请求体")
@@ -637,15 +857,6 @@ def build_parser() -> argparse.ArgumentParser:
637
857
  run_parser.add_argument("--content-type", help="POST 请求的 Content-Type")
638
858
  run_parser.add_argument("--buyer-id", help="沙箱买家 2088 账号")
639
859
  run_parser.add_argument("--buyer-signature", default="-", help="买家签名占位值")
640
- run_parser.add_argument("--payment-needed", help="已有的 Payment-Needed 响应头值")
641
- run_parser.add_argument("--payment-needed-file", default=DEFAULT_PAYMENT_NEEDED_FILE)
642
- run_parser.add_argument("--reuse-artifact", help="复用上一轮 run 产物中的 url/method/body/content-type/payment_needed")
643
- run_parser.add_argument("--pay-endpoint", default=PAY_ENDPOINT)
644
- run_parser.add_argument("--artifact-dir", help="生成过程产物的目录")
645
- run_parser.add_argument(
646
- "--payment-proof",
647
- help="使用 --auto-complete 或 --wait 时可选的最终 payment_proof 值",
648
- )
649
860
  run_parser.add_argument("--pay-retries", type=int, default=DEFAULT_PAY_RETRIES, help="沙箱收银接口临时失败时的重试次数")
650
861
  run_parser.add_argument(
651
862
  "--pay-retry-delay",
@@ -653,26 +864,23 @@ def build_parser() -> argparse.ArgumentParser:
653
864
  default=DEFAULT_PAY_RETRY_DELAY_SECONDS,
654
865
  help="沙箱收银接口重试间隔秒数",
655
866
  )
656
- run_parser.add_argument("--dry-run", action="store_true", help="调用收银接口前停止")
657
867
  run_parser.add_argument(
658
868
  "--auto-complete",
659
869
  action="store_true",
660
870
  help="生成付款链接后连续携带 Payment-Proof 重试原始服务",
661
871
  )
662
- run_parser.add_argument("--wait", action="store_true", help="等待付款后立刻重试原始服务")
663
- run_parser.set_defaults(func=command_run)
664
-
665
- complete_parser = subparsers.add_parser("complete", help="携带 Payment-Proof 重试原始服务")
666
- complete_parser.add_argument("--artifact-dir", required=True, help="run 命令生成的过程产物目录")
667
- complete_parser.add_argument("--payment-proof", help="可选 payment_proof;未传时由脚本按沙箱联调约定处理")
668
- complete_parser.add_argument("--buyer-signature", help="买家签名占位值")
669
- complete_parser.set_defaults(func=command_complete)
872
+ run_parser.add_argument(
873
+ "--require-payment-validation",
874
+ action="store_true",
875
+ help="目标服务实现 Payment-Validation 时要求并校验该响应头",
876
+ )
877
+ run_parser.set_defaults(func=command_run, pay_endpoint=PAY_ENDPOINT, payment_proof=None)
670
878
 
671
879
  return parser
672
880
 
673
881
 
674
882
  def main(argv: list[str]) -> int:
675
- if argv and argv[0] not in {"run", "complete", "-h", "--help"}:
883
+ if argv and argv[0] not in {"precheck", "run", "-h", "--help"}:
676
884
  argv = ["run", *argv]
677
885
  parser = build_parser()
678
886
  args = parser.parse_args(argv)
@@ -684,6 +892,8 @@ def main(argv: list[str]) -> int:
684
892
  except (RuntimeError, ValueError) as exc:
685
893
  print(f"执行失败:{exc}", file=sys.stderr)
686
894
  return 1
895
+ finally:
896
+ cleanup_active_artifacts()
687
897
 
688
898
 
689
899
  if __name__ == "__main__":