fdff54a6cf
与已入库的 scripts/hjc_test_push.py(入向:一站式 → 官网 /api/hjc/push/project)互为反向, 一条命令回答「出向对接通没通、卡在哪一层」: - 按文档约定的签名方案(sign = MD5(appKey + password + timestamp))发起真实推送; - 额外遍历若干鉴权头变体,用于区分「签名不对」与「根本没校验签名」; - 用对照探测把失败定位到具体一层:登录过滤器 / 路由不存在 / 验签失败 / 网络不可达。 默认参数直接从仓库的 application.yml 读取,避免脚本与配置脱节。
658 lines
29 KiB
Python
658 lines
29 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
汇吉采 → 一站式:**出向**推送联调探测脚本(POST {base-url}{create-purchase-details-path})
|
||
|
||
与同目录 hjc_test_push.py(入向:一站式 → 官方网 /api/hjc/push/project)互为反向。
|
||
|
||
用途:一条命令回答「出向对接到底通没通、卡在哪一层」。
|
||
脚本会自动做三件事:
|
||
1. 按文档约定的签名方案(sign = MD5(appKey + password + timestamp))发起真实推送;
|
||
2. 额外遍历若干鉴权头变体(含 password 明文头等历史解读),用于区分「签名不对」与「根本没校验签名」;
|
||
3. 用对照探测把失败定位到具体一层:登录过滤器 / 路由不存在 / 验签失败 / 网络不可达。
|
||
|
||
默认参数自动从仓库读取,避免与代码脱节:
|
||
- application.yml → hjc.one-stop.base-url、create-purchase-details-path
|
||
- HjcOneStopAuthUtil.java → APP_KEY、PASSWORD
|
||
|
||
用法::
|
||
|
||
python hjc_test_push_out.py # 全量:约定方案 + 变体矩阵 + 对照探测
|
||
python hjc_test_push_out.py --quick # 只打约定方案(日常回归用)
|
||
python hjc_test_push_out.py --repeat 3 # 约定方案连打 3 次(同一订单号,验幂等)
|
||
python hjc_test_push_out.py --base http://x:8810 --path /api/biz/createPurchaseDetails
|
||
python hjc_test_push_out.py --order-no HJC2026080101ABCD --json result.json
|
||
python hjc_test_push_out.py --dry-run # 只打印将要发出的请求,不真的发
|
||
|
||
订单号默认是固定值 ``TEST-HJC-ORDER-20260801``(不随时间变化):它同时是一站式的幂等键,
|
||
固定住才能重复验证幂等/状态流转,也不会每跑一次就在对方库里堆一条新订单;要换单号显式传 ``--order-no``。
|
||
|
||
退款报文(status=REFUNDED,必带 refundTime + refundReason,见 docs/一站式平台对接-接口文档.md §3.4)::
|
||
|
||
python hjc_test_push_out.py --refund --dry-run # 看退款报文长什么样
|
||
python hjc_test_push_out.py --refund --quick # 打退款报文
|
||
python hjc_test_push_out.py --paid-then-refund # 同一订单号先 PAID 再 REFUNDED(推荐的联调姿势)
|
||
python hjc_test_push_out.py --refund --order-no HJC2026080101ABCD \\
|
||
--refund-reason "项目终止,客户申请退款" --refund-time "2026-08-03 09:15:00"
|
||
|
||
无第三方依赖,仅标准库。时间统一按北京时间(UTC+8)。
|
||
|
||
退出码:0=出向推送成功(收到 2xx);1=失败(并打印定位结论)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import datetime as dt
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import ssl
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
try: # Windows 控制台默认 GBK,强制 UTF-8 避免中文乱码
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 默认值 / 配置读取
|
||
# --------------------------------------------------------------------------- #
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
MP_JAVA = os.path.dirname(HERE)
|
||
APPLICATION_YML = os.path.join(MP_JAVA, "src", "main", "resources", "application.yml")
|
||
AUTH_UTIL_JAVA = os.path.join(
|
||
MP_JAVA, "src", "main", "java", "com", "gxwebsoft", "hjc", "util", "HjcOneStopAuthUtil.java"
|
||
)
|
||
|
||
FALLBACK_BASE = "http://180.141.88.21:8810"
|
||
FALLBACK_PATH = "/api/biz/createPurchaseDetails"
|
||
FALLBACK_APP_KEY = "HJC_Official_Website"
|
||
FALLBACK_PASSWORD = "vQ8$kR3#mW6@xP2!nF"
|
||
|
||
TS_FMT = "%Y%m%d%H%M"
|
||
DT_FMT = "%Y-%m-%d %H:%M:%S"
|
||
# argparse 的 help 会对 % 做格式化,展示用的时间格式需转义
|
||
DT_FMT_HELP = DT_FMT.replace("%", "%%")
|
||
|
||
STATUS_PAID = "PAID"
|
||
STATUS_REFUNDED = "REFUNDED"
|
||
# 固定探测订单号:不随时间变化,重复运行始终命中一站式同一条记录。
|
||
# 订单号同时是一站式幂等键(HJC_ORD_<orderNo>),固定值才能稳定验证幂等与状态流转,
|
||
# 也不会每跑一次就在对方库里堆一条新订单。(与 hjc_test_push.py 固定 projectNo 同一思路)
|
||
DEFAULT_ORDER_NO = "PROBE_202609111127"
|
||
# 与 HjcBizServiceImpl.DEFAULT_REFUND_REASON 对齐:退款原因留空时官方网会兜底这个值
|
||
DEFAULT_REFUND_REASON = "联调探测:客户申请退款"
|
||
# 与 hjc_order.refund_reason 字段长度对齐(varchar(255))
|
||
REFUND_REASON_MAX = 255
|
||
|
||
|
||
def _read_text(path: str) -> str:
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
return f.read()
|
||
except OSError:
|
||
return ""
|
||
|
||
|
||
def load_config() -> tuple[str, str]:
|
||
"""从仓库读取 base-url / path,读不到则回退到内置值。"""
|
||
base, path = FALLBACK_BASE, FALLBACK_PATH
|
||
yml = _read_text(APPLICATION_YML)
|
||
if yml:
|
||
m = re.search(r'base-url:\s*"([^"]*)"', yml)
|
||
if m and m.group(1).strip():
|
||
base = m.group(1).strip()
|
||
m = re.search(r"create-purchase-details-path:\s*(\S+)", yml)
|
||
if m and m.group(1).strip():
|
||
path = m.group(1).strip().strip('"')
|
||
return base, path
|
||
|
||
|
||
def load_credentials() -> tuple[str, str]:
|
||
"""从 HjcOneStopAuthUtil.java 读取 APP_KEY / PASSWORD,读不到则回退。"""
|
||
app_key, password = FALLBACK_APP_KEY, FALLBACK_PASSWORD
|
||
java = _read_text(AUTH_UTIL_JAVA)
|
||
if java:
|
||
m = re.search(r'APP_KEY\s*=\s*"([^"]*)"', java)
|
||
if m:
|
||
app_key = m.group(1)
|
||
m = re.search(r'PASSWORD\s*=\s*"([^"]*)"', java)
|
||
if m:
|
||
password = m.group(1)
|
||
return app_key, password
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 签名 / 时间
|
||
# --------------------------------------------------------------------------- #
|
||
def bj_now() -> dt.datetime:
|
||
return dt.datetime.now(dt.timezone.utc).astimezone(dt.timezone(dt.timedelta(hours=8)))
|
||
|
||
|
||
def timestamp_str(now: dt.datetime) -> str:
|
||
return now.strftime(TS_FMT)
|
||
|
||
|
||
def sign_of(app_key: str, password: str, ts: str) -> str:
|
||
return hashlib.md5((app_key + password + ts).encode("utf-8")).hexdigest()
|
||
|
||
|
||
def md5(text: str) -> str:
|
||
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# HTTP
|
||
# --------------------------------------------------------------------------- #
|
||
def post(url: str, body: bytes, headers: dict, timeout: float, insecure: bool = False):
|
||
"""返回 (http_code, response_text, elapsed_ms, error)。http_code=0 表示网络层失败。"""
|
||
req = urllib.request.Request(url, data=body, method="POST", headers=headers)
|
||
ctx = ssl._create_unverified_context() if insecure else None
|
||
t0 = time.monotonic()
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||
text = resp.read().decode("utf-8", errors="replace")
|
||
return resp.status, text, (time.monotonic() - t0) * 1000, None
|
||
except urllib.error.HTTPError as e:
|
||
text = e.read().decode("utf-8", errors="replace")
|
||
return e.code, text, (time.monotonic() - t0) * 1000, None
|
||
except Exception as e: # noqa: BLE001 - 网络层任何异常都归为不可达
|
||
return 0, "", (time.monotonic() - t0) * 1000, f"{type(e).__name__}: {e}"
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 响应判定
|
||
# --------------------------------------------------------------------------- #
|
||
LOGIN_HINTS = ("token失效", "请重新登录", "未登录", "not login", "unauthorized")
|
||
SIGN_HINTS = ("签名", "验签", "sign校验", "invalid signature", "signature")
|
||
TS_HINTS = ("时间戳", "timestamp失效", "timestamp expired")
|
||
|
||
VERDICT_LABEL = {
|
||
"OK": "✅ 通过",
|
||
"LOGIN_FILTER": "🔒 登录过滤器拦截",
|
||
"SIGN_REJECT": "❌ 验签被拒",
|
||
"TS_REJECT": "❌ 时间戳被拒",
|
||
"NOT_FOUND": "🚫 路由不存在(404)",
|
||
"UNREACHABLE": "🌐 网络不可达",
|
||
"OTHER": "❓ 其他",
|
||
}
|
||
|
||
|
||
def classify(status: int, body: str) -> str:
|
||
if status == 0:
|
||
return "UNREACHABLE"
|
||
if 200 <= status < 300:
|
||
return "OK"
|
||
low = (body or "").lower()
|
||
if any(h in low for h in TS_HINTS):
|
||
return "TS_REJECT"
|
||
if any(h in low for h in SIGN_HINTS):
|
||
return "SIGN_REJECT"
|
||
if any(h in low for h in LOGIN_HINTS):
|
||
return "LOGIN_FILTER"
|
||
if status == 404:
|
||
return "NOT_FOUND"
|
||
return "OTHER"
|
||
|
||
|
||
def brief(body: str, limit: int = 160) -> str:
|
||
one = " ".join((body or "").split())
|
||
return one if len(one) <= limit else one[:limit] + "…"
|
||
|
||
|
||
def fingerprint(status: int, body: str) -> str:
|
||
"""响应的「错误语义指纹」:忽略每次都变的 timestamp/path 字段,只保留结论性字段。
|
||
|
||
用于判断两次响应是否表达同一个错误(例如真实接口与不存在路径都被同一个过滤器拒绝)。
|
||
"""
|
||
try:
|
||
obj = json.loads(body)
|
||
if isinstance(obj, dict):
|
||
keep = {k: obj[k] for k in ("status", "error", "message") if k in obj}
|
||
if keep:
|
||
return json.dumps(keep, ensure_ascii=False, sort_keys=True)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
return " ".join((body or "").split())[:200] or f"HTTP {status}"
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 报文
|
||
# --------------------------------------------------------------------------- #
|
||
def default_payload(
|
||
order_no: str,
|
||
now: dt.datetime,
|
||
status: str = STATUS_PAID,
|
||
refund_reason: str | None = None,
|
||
refund_time: dt.datetime | None = None,
|
||
) -> dict:
|
||
"""构造 createPurchaseDetails 请求体。
|
||
|
||
status=REFUNDED 时追加 refundTime / refundReason(官方网侧同样只在退款单推这两个字段,
|
||
见 HjcBizServiceImpl#buildCreatePurchaseDetails)。
|
||
"""
|
||
payload = {
|
||
"idempotencyKey": "HJC_ORD_" + order_no,
|
||
"orderNo": order_no,
|
||
"projectNo": "HJC-GXZCY2-2026002",
|
||
"projectName": "出向推送联调探测",
|
||
"tenderPrice": 0.01,
|
||
"quantity": 1,
|
||
"totalAmount": 0.01,
|
||
"buyer": {
|
||
"enterpriseName": "联调探测企业",
|
||
"creditCode": "91450000PROBE01",
|
||
"contactName": "联调",
|
||
"contactPhone": "13800000000",
|
||
"contactEmail": "probe@example.com",
|
||
},
|
||
"paidAt": now.strftime(DT_FMT),
|
||
"payMethod": "WECHAT_NATIVE",
|
||
"status": status,
|
||
"invoiceStatus": "NONE",
|
||
}
|
||
if status == STATUS_REFUNDED:
|
||
reason = (refund_reason or "").strip() or DEFAULT_REFUND_REASON
|
||
payload["refundTime"] = (refund_time or now).strftime(DT_FMT)
|
||
payload["refundReason"] = reason[:REFUND_REASON_MAX]
|
||
return payload
|
||
|
||
|
||
def refund_payload_issues(payload: dict) -> list[str]:
|
||
"""退款报文的本地自检:官方网/一站式都要求这两个字段非空,先在这里拦住低级错误。"""
|
||
issues: list[str] = []
|
||
if payload.get("status") != STATUS_REFUNDED:
|
||
return issues
|
||
for field in ("refundTime", "refundReason"):
|
||
value = payload.get(field)
|
||
if not isinstance(value, str) or not value.strip():
|
||
issues.append(f"{field} 缺失或为空(退款推送必带)")
|
||
rt = payload.get("refundTime")
|
||
if isinstance(rt, str) and rt.strip():
|
||
try:
|
||
dt.datetime.strptime(rt.strip(), DT_FMT)
|
||
except ValueError:
|
||
issues.append(f"refundTime 格式应为 {DT_FMT}(当前: {rt})")
|
||
reason = payload.get("refundReason")
|
||
if isinstance(reason, str) and len(reason) > REFUND_REASON_MAX:
|
||
issues.append(f"refundReason 超过 {REFUND_REASON_MAX} 字符(当前: {len(reason)})")
|
||
return issues
|
||
|
||
|
||
def case_matrix(app_key: str, password: str, ts: str, sign: str) -> list[tuple[str, dict, str]]:
|
||
"""返回 [(方案名, 请求头, 说明)],方案 1 为文档约定方案。"""
|
||
return [
|
||
(
|
||
"1 文档约定方案",
|
||
{"appKey": app_key, "timestamp": ts, "sign": sign},
|
||
"appKey+timestamp+sign(sign=MD5(appKey+password+timestamp))",
|
||
),
|
||
(
|
||
"2 约定 + password 明文头",
|
||
{"appKey": app_key, "timestamp": ts, "sign": sign, "password": password},
|
||
"在原需求「明文 password 头」与现规范之间的折中",
|
||
),
|
||
(
|
||
"3 仅 password 明文头",
|
||
{"password": password},
|
||
"只带密钥,不带 appKey/sign",
|
||
),
|
||
(
|
||
"4 appKey + password 明文",
|
||
{"appKey": app_key, "password": password, "timestamp": ts},
|
||
"按原文需求字面实现(无 sign)",
|
||
),
|
||
(
|
||
"5 双 MD5 解读",
|
||
{"appKey": md5(app_key + ts), "password": md5(password + ts), "timestamp": ts},
|
||
"原文「appKey+年月日时分,password+年月日时分,MD5加密」的另一种读法",
|
||
),
|
||
(
|
||
"6 Authorization 头",
|
||
{"appKey": app_key, "timestamp": ts, "Authorization": sign},
|
||
"把签名放 Authorization,适配标准网关",
|
||
),
|
||
(
|
||
"7 无任何鉴权头",
|
||
{},
|
||
"基准:对比有无鉴权头的响应差异",
|
||
),
|
||
]
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 主流程
|
||
# --------------------------------------------------------------------------- #
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(
|
||
description="汇吉采 → 一站式 出向推送联调探测",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
)
|
||
cfg_base, cfg_path = load_config()
|
||
cfg_key, cfg_pwd = load_credentials()
|
||
|
||
ap.add_argument("--base", default=cfg_base, help=f"一站式 base-url(默认读配置: {cfg_base})")
|
||
ap.add_argument("--path", default=cfg_path, help=f"推送路径(默认读配置: {cfg_path})")
|
||
ap.add_argument("--appkey", default=cfg_key, help="appKey")
|
||
ap.add_argument("--password", default=cfg_pwd, help="签名密钥 password")
|
||
ap.add_argument("--order-no", default=DEFAULT_ORDER_NO, help=f"订单号(默认固定值 {DEFAULT_ORDER_NO})")
|
||
ap.add_argument("--payload", default=None, help="自定义请求体 JSON 文件")
|
||
ap.add_argument(
|
||
"--refund",
|
||
action="store_true",
|
||
help="推退款报文(status=REFUNDED,带 refundTime/refundReason)",
|
||
)
|
||
ap.add_argument(
|
||
"--paid-then-refund",
|
||
action="store_true",
|
||
help="同一订单号先推 PAID 基线、再推 REFUNDED(模拟真实退款状态流转)",
|
||
)
|
||
ap.add_argument(
|
||
"--refund-reason",
|
||
default=None,
|
||
help=f"退款原因(默认「{DEFAULT_REFUND_REASON}」,最长 {REFUND_REASON_MAX} 字符)",
|
||
)
|
||
ap.add_argument(
|
||
"--refund-time",
|
||
default=None,
|
||
help=f"退款时间,格式 {DT_FMT_HELP}(默认当前北京时间)",
|
||
)
|
||
ap.add_argument("--repeat", type=int, default=1, help="约定方案重复次数,用于验幂等(默认 1)")
|
||
ap.add_argument("--timeout", type=float, default=15.0, help="单请求超时秒数(默认 15)")
|
||
ap.add_argument("--quick", action="store_true", help="只跑约定方案,跳过变体矩阵与对照探测")
|
||
ap.add_argument("--no-control", action="store_true", help="跳过对照探测(不额外打扰对方服务)")
|
||
ap.add_argument("--dry-run", action="store_true", help="只打印请求,不发送")
|
||
ap.add_argument("--insecure", action="store_true", help="跳过 HTTPS 证书校验")
|
||
ap.add_argument("--json", dest="json_out", default=None, help="把完整结果写入 JSON 文件")
|
||
args = ap.parse_args()
|
||
|
||
now = bj_now()
|
||
ts = timestamp_str(now)
|
||
sign = sign_of(args.appkey, args.password, ts)
|
||
order_no = args.order_no
|
||
url = args.base.rstrip("/") + args.path
|
||
|
||
refund_time = None
|
||
if args.refund_time:
|
||
try:
|
||
refund_time = dt.datetime.strptime(args.refund_time.strip(), DT_FMT)
|
||
except ValueError:
|
||
print(f"❌ --refund-time 格式应为 {DT_FMT},收到:{args.refund_time}", file=sys.stderr)
|
||
return 2
|
||
|
||
want_refund = args.refund or args.paid_then_refund
|
||
status = STATUS_REFUNDED if want_refund else STATUS_PAID
|
||
if not want_refund and not args.payload and (args.refund_reason or args.refund_time):
|
||
print(
|
||
"⚠️ --refund-reason/--refund-time 仅在 --refund 或 --paid-then-refund 下生效"
|
||
"(当前为 PAID 报文)",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
if args.payload:
|
||
if want_refund:
|
||
print("⚠️ --payload 已指定,忽略 --refund/--paid-then-refund/--refund-reason/--refund-time", file=sys.stderr)
|
||
with open(args.payload, encoding="utf-8") as f:
|
||
payload = json.load(f)
|
||
else:
|
||
payload = default_payload(order_no, now, status, args.refund_reason, refund_time)
|
||
|
||
# 退款报文自检:本地先拦掉「必带字段缺失/时间格式错/超长」,避免白跑一轮联调
|
||
issues = refund_payload_issues(payload)
|
||
if issues:
|
||
print("❌ 退款报文自检未通过:", file=sys.stderr)
|
||
for it in issues:
|
||
print(f" · {it}", file=sys.stderr)
|
||
return 2
|
||
|
||
# --paid-then-refund:同一订单号先落一条 PAID,再发 REFUNDED
|
||
paid_body = None
|
||
if args.paid_then_refund and not args.payload:
|
||
paid_body = json.dumps(
|
||
default_payload(order_no, now, STATUS_PAID), ensure_ascii=False
|
||
).encode("utf-8")
|
||
|
||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
|
||
print("=" * 78)
|
||
print("汇吉采 → 一站式 出向推送探测" + ("(退款报文)" if payload.get("status") == STATUS_REFUNDED else ""))
|
||
print("=" * 78)
|
||
print(f"目标 URL : {url}")
|
||
print(f"北京时间 : {now.strftime(DT_FMT)} → timestamp={ts}")
|
||
print(f"appKey : {args.appkey}")
|
||
print(f"sign : {sign}")
|
||
print(f"订单号 : {order_no}")
|
||
print(f"订单状态 : {payload.get('status')}")
|
||
if payload.get("status") == STATUS_REFUNDED:
|
||
print(f"退款时间 : {payload.get('refundTime')}")
|
||
print(f"退款原因 : {payload.get('refundReason')}")
|
||
print(f"请求体 : {body.decode('utf-8')}")
|
||
if paid_body is not None:
|
||
print()
|
||
print("(--paid-then-refund:先发 PAID 基线)")
|
||
print(f"请求体 : {paid_body.decode('utf-8')}")
|
||
print()
|
||
|
||
if args.dry_run:
|
||
print("[dry-run] 未发送任何请求。等价 curl:")
|
||
if paid_body is not None:
|
||
print(
|
||
f" # 1) 同一订单号先落 PAID\n"
|
||
f" curl -i -X POST '{url}' \\\n"
|
||
f" -H 'Content-Type: application/json' \\\n"
|
||
f" -H 'appKey: {args.appkey}' -H 'timestamp: {ts}' -H 'sign: {sign}' \\\n"
|
||
f" -d '{paid_body.decode('utf-8')}'\n"
|
||
f" # 2) 再推 REFUNDED(带退款时间/原因)"
|
||
)
|
||
print(
|
||
f" curl -i -X POST '{url}' \\\n"
|
||
f" -H 'Content-Type: application/json' \\\n"
|
||
f" -H 'appKey: {args.appkey}' -H 'timestamp: {ts}' -H 'sign: {sign}' \\\n"
|
||
f" -d '{body.decode('utf-8')}'"
|
||
)
|
||
return 0
|
||
|
||
results: list[dict] = []
|
||
|
||
def run_case(name: str, headers: dict, note: str, target_url: str = url, use_body: bytes = body):
|
||
h = {"Content-Type": "application/json", **headers}
|
||
status_code, text, ms, err = post(target_url, use_body, h, args.timeout, args.insecure)
|
||
verdict = classify(status_code, text)
|
||
results.append(
|
||
{
|
||
"name": name,
|
||
"note": note,
|
||
"url": target_url,
|
||
"status": status_code,
|
||
"verdict": verdict,
|
||
"fingerprint": fingerprint(status_code, text),
|
||
"elapsed_ms": round(ms, 1),
|
||
"body": text,
|
||
"error": err,
|
||
}
|
||
)
|
||
shown = f"HTTP {status_code}" if status_code else "无响应"
|
||
print(f" {name:<22} {shown:<9} {VERDICT_LABEL[verdict]:<18} {brief(err or text)}")
|
||
return results[-1]
|
||
|
||
matrix = case_matrix(args.appkey, args.password, ts, sign)
|
||
|
||
# ---------------- 阶段 0:(可选)PAID 基线 ----------------
|
||
# 退款推送要针对「已在一站式存在的订单」,故先用同一订单号落一条 PAID 记录。
|
||
if paid_body is not None:
|
||
print("── 阶段 0:PAID 基线(同一订单号,先建立已支付记录)──")
|
||
run_case("0 PAID 基线", matrix[0][1], "退款前先落一条已支付记录", use_body=paid_body)
|
||
print()
|
||
|
||
# ---------------- 阶段 1:约定方案(含 repeat 幂等验证) ----------------
|
||
print(f"── 阶段 1:文档约定方案{'(连打 %d 次验幂等)' % args.repeat if args.repeat > 1 else ''} ──")
|
||
primary = run_case(matrix[0][0], matrix[0][1], matrix[0][2])
|
||
for i in range(2, args.repeat + 1):
|
||
run_case(f"1 文档约定方案 #{i}", matrix[0][1], "幂等复打")
|
||
|
||
if not args.quick:
|
||
# ---------------- 阶段 2:鉴权头变体 ----------------
|
||
print()
|
||
print("── 阶段 2:鉴权头变体矩阵(用于区分「签名不对」与「未校验签名」)──")
|
||
for name, headers, note in matrix[1:]:
|
||
run_case(name, headers, note)
|
||
|
||
# query 参数形式单独处理(不带请求头)
|
||
q = urllib.parse.urlencode(
|
||
{"appKey": args.appkey, "password": args.password, "timestamp": ts, "sign": sign}
|
||
)
|
||
run_case("8 query 参数", {}, "凭据放 URL query", target_url=f"{url}?{q}")
|
||
|
||
# ---------------- 阶段 3:对照探测 ----------------
|
||
# 仅在失败时才有诊断价值;已成功就不再给对方发多余请求。
|
||
controls: list[dict] = []
|
||
if not args.no_control and primary["verdict"] != "OK":
|
||
print()
|
||
print("── 阶段 3:对照探测(定位失败发生在哪一层)──")
|
||
parts = [p for p in args.path.strip("/").split("/") if p]
|
||
if len(parts) >= 2:
|
||
protected_nope = "/" + "/".join(parts[:-1] + ["__probe_nope__"])
|
||
reference_nope = "/" + "/".join(parts[:1] + ["auth", "__probe_nope__"])
|
||
ctl_headers = matrix[0][1]
|
||
for label, p, note in (
|
||
("C1 同前缀不存在路径", protected_nope, "若与目标接口响应一致 → 拦截发生在路由解析之前"),
|
||
("C2 另一前缀不存在路径", reference_nope, "未受保护的参照组,应为干净的 404"),
|
||
):
|
||
controls.append(
|
||
run_case(label, ctl_headers, note, target_url=args.base.rstrip("/") + p)
|
||
)
|
||
else:
|
||
print(" (路径层级不足,跳过对照探测)")
|
||
|
||
# ---------------- 结论 ----------------
|
||
print()
|
||
print("=" * 78)
|
||
print("结论")
|
||
print("=" * 78)
|
||
|
||
primary_verdict = primary["verdict"]
|
||
success = primary_verdict == "OK"
|
||
|
||
# 与「同前缀不存在路径」对照:错误语义一致说明根本没走到路由
|
||
same_as_control = bool(
|
||
controls
|
||
and controls[0]["status"] == primary["status"]
|
||
and controls[0]["fingerprint"] == primary["fingerprint"]
|
||
)
|
||
|
||
if success:
|
||
print("✅ 出向对接成功:目标接口返回 2xx,对方已接受并(应已)落盘。")
|
||
if args.repeat > 1:
|
||
ok_all = all(r["verdict"] == "OK" for r in results if r["name"].startswith("1 "))
|
||
print(
|
||
f" 幂等复打 {args.repeat} 次:{'全部 2xx ✅' if ok_all else '存在非 2xx,请检查幂等处理 ❌'}"
|
||
)
|
||
else:
|
||
print(f"❌ 出向对接失败:约定方案判定为「{VERDICT_LABEL[primary_verdict]}」。")
|
||
print()
|
||
if primary_verdict == "LOGIN_FILTER":
|
||
print("定位:请求被对方的**登录态过滤器**拒绝,与签名无关。依据:")
|
||
if same_as_control:
|
||
print(" · 同一套凭据打「同前缀下不存在的路径」,响应与真实接口**完全一致**")
|
||
print(" → 拒绝发生在路由解析之前,接口是否存在、签名是否正确都影响不到它。")
|
||
print(" · 响应体是登录错误而非「签名校验失败」,说明对方的验签逻辑没有被执行。")
|
||
print()
|
||
print("需对方处理:把该接口加入免登录白名单并实现约定验签,或提供获取调用 Token 的方式。")
|
||
elif primary_verdict == "SIGN_REJECT":
|
||
print("定位:对方已放行到验签逻辑,但签名不通过 → 属于**算法/密钥不一致**。")
|
||
print("建议:让对方用下面这组固定值离线自证,无需部署:")
|
||
print(f" appKey={args.appkey} timestamp={ts} sign={sign}")
|
||
print(" 若对方算不出同一 sign,核对 password 取值与拼接顺序(是否含分隔符、大小写)。")
|
||
elif primary_verdict == "TS_REJECT":
|
||
print("定位:时间戳被拒 → 核对对方服务器时间与北京时区,以及允许窗口(约定 ±10 分钟)。")
|
||
elif primary_verdict == "NOT_FOUND":
|
||
print("定位:路由不存在 → 路径或 base-url 不对,需对方确认接口实际地址与端口。")
|
||
elif primary_verdict == "UNREACHABLE":
|
||
print(f"定位:网络不可达 → {primary['error']}")
|
||
print("建议:确认对方服务已启动、端口开放、本机到该地址无防火墙/白名单限制。")
|
||
else:
|
||
print("未能自动归类,请把上面的响应原文提供给对方排查。")
|
||
|
||
# 变体是否出现「与基准不同」的响应——若全一致,进一步佐证签名未被校验
|
||
if not args.quick:
|
||
variants = [
|
||
r
|
||
for r in results
|
||
if r["name"][0].isdigit() and not r["name"].startswith(("0 ", "1 "))
|
||
]
|
||
distinct = {r["fingerprint"] for r in variants}
|
||
if len(distinct) <= 1 and variants:
|
||
print()
|
||
print("补充:全部鉴权头变体(含不带任何头的基准)响应**完全相同**")
|
||
print(" → 对方当前没有对请求头做任何区分,签名校验尚未生效。")
|
||
|
||
# 退款模式补充结论:PAID 基线 + REFUNDED 是否都通过(退款字段是否被对方接受)
|
||
if payload.get("status") == STATUS_REFUNDED:
|
||
print()
|
||
paid_base = next((r for r in results if r["name"].startswith("0 ")), None)
|
||
if paid_base is not None:
|
||
print(
|
||
f"PAID 基线 : {VERDICT_LABEL[paid_base['verdict']]}(同一订单号 {order_no})"
|
||
+ ("" if paid_base["verdict"] == "OK" else " ← 基线未通过,退款结果可能不可信")
|
||
)
|
||
print(
|
||
f"REFUNDED 报文: {VERDICT_LABEL[primary_verdict]},"
|
||
f"refundTime={payload.get('refundTime')} / refundReason={payload.get('refundReason')}"
|
||
)
|
||
|
||
print()
|
||
print(f"复现命令(可直接发给对方):")
|
||
if paid_body is not None:
|
||
print(
|
||
f" # 1) 同一订单号先落 PAID\n"
|
||
f" curl -i -X POST '{url}' -H 'Content-Type: application/json' \\\n"
|
||
f" -H 'appKey: {args.appkey}' -H 'timestamp: {ts}' -H 'sign: {sign}' \\\n"
|
||
f" -d '{paid_body.decode('utf-8')}'\n"
|
||
f" # 2) 再推 REFUNDED(带退款时间/原因)"
|
||
)
|
||
print(
|
||
f" curl -i -X POST '{url}' \\\n"
|
||
f" -H 'Content-Type: application/json' \\\n"
|
||
f" -H 'appKey: {args.appkey}' -H 'timestamp: {ts}' -H 'sign: {sign}' \\\n"
|
||
f" -d '{body.decode('utf-8')}'"
|
||
)
|
||
|
||
if args.json_out:
|
||
with open(args.json_out, "w", encoding="utf-8") as f:
|
||
json.dump(
|
||
{
|
||
"base": args.base,
|
||
"path": args.path,
|
||
"url": url,
|
||
"timestamp": ts,
|
||
"sign": sign,
|
||
"appKey": args.appkey,
|
||
"orderNo": order_no,
|
||
"orderStatus": payload.get("status"),
|
||
"refundTime": payload.get("refundTime"),
|
||
"refundReason": payload.get("refundReason"),
|
||
"paidThenRefund": args.paid_then_refund,
|
||
"payload": payload,
|
||
"results": results,
|
||
"controls": controls,
|
||
"verdict": primary_verdict,
|
||
"success": success,
|
||
},
|
||
f,
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
)
|
||
print(f"\n完整结果已写入:{args.json_out}")
|
||
|
||
return 0 if success else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|