320 lines
13 KiB
Python
320 lines
13 KiB
Python
"""鱼翅直充供应商 API 客户端。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import time
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import Any, Callable, Mapping
|
|
|
|
import requests
|
|
|
|
|
|
class FishFinRechargeError(RuntimeError):
|
|
"""鱼翅直充供应商接口调用失败。"""
|
|
|
|
|
|
class FishFinRechargeConfigError(FishFinRechargeError):
|
|
"""鱼翅直充供应商配置不完整。"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FishFinRechargeConfig:
|
|
"""供应商接入配置;敏感值仅从运行环境读取。"""
|
|
|
|
base_url: str
|
|
app_id: str
|
|
app_secret: str
|
|
app_key: str = ""
|
|
notify_url: str = ""
|
|
timeout: tuple[float, float] = (8, 20)
|
|
debug: bool = False
|
|
|
|
@classmethod
|
|
def from_env(cls) -> "FishFinRechargeConfig":
|
|
"""从环境变量读取配置,不在代码或数据库中保存商户密钥。"""
|
|
timeout = float(os.getenv("FISH_FIN_RECHARGE_TIMEOUT", "20"))
|
|
return cls(
|
|
base_url=os.getenv("FISH_FIN_RECHARGE_BASE_URL", "").strip(),
|
|
app_id=os.getenv("FISH_FIN_RECHARGE_APP_ID", "").strip(),
|
|
app_secret=os.getenv("FISH_FIN_RECHARGE_APP_SECRET", "").strip(),
|
|
app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(),
|
|
notify_url=os.getenv("FISH_FIN_RECHARGE_NOTIFY_URL", "").strip(),
|
|
timeout=(8, timeout),
|
|
debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower()
|
|
in {"1", "true", "yes"},
|
|
)
|
|
|
|
def validate(self) -> None:
|
|
"""在实际发起请求前校验必要配置。"""
|
|
missing = [
|
|
name
|
|
for name, value in (
|
|
("FISH_FIN_RECHARGE_BASE_URL", self.base_url),
|
|
("FISH_FIN_RECHARGE_APP_ID", self.app_id),
|
|
("FISH_FIN_RECHARGE_APP_SECRET", self.app_secret),
|
|
)
|
|
if not value
|
|
]
|
|
if missing:
|
|
raise FishFinRechargeConfigError(f"缺少鱼翅直充配置: {', '.join(missing)}")
|
|
|
|
|
|
class FishFinRechargeClient:
|
|
"""实现供应商 createOrderV2、queryOrderV2 与 userInfoV2 协议。"""
|
|
|
|
CREATE_ORDER_PATH = "/adapter-apiaccess/open/api/createOrderV2"
|
|
QUERY_ORDER_PATH = "/adapter-apiaccess/open/api/queryOrderV2"
|
|
USER_INFO_PATH = "/adapter-apiaccess/open/api/userInfoV2"
|
|
|
|
def __init__(
|
|
self,
|
|
config: FishFinRechargeConfig,
|
|
*,
|
|
session: requests.Session | None = None,
|
|
trace: Callable[[dict[str, Any]], None] | None = None,
|
|
):
|
|
config.validate()
|
|
self.config = config
|
|
self.session = session or requests.Session()
|
|
self.session.trust_env = False
|
|
self.trace = trace or (lambda _event: None)
|
|
|
|
@staticmethod
|
|
def _sign_value(value: Any) -> str:
|
|
"""将参数转为待签名文本;对象按稳定紧凑 JSON 表示。"""
|
|
if isinstance(value, (dict, list, tuple)):
|
|
return json.dumps(
|
|
value, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
)
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
return str(value)
|
|
|
|
@classmethod
|
|
def normalized_params(cls, params: Mapping[str, Any]) -> dict[str, str]:
|
|
"""按供应商规则去除首尾空白与空值,保留可签名参数。"""
|
|
normalized: dict[str, str] = {}
|
|
for raw_key, raw_value in params.items():
|
|
key = str(raw_key).strip()
|
|
if not key or key == "sign" or raw_value is None:
|
|
continue
|
|
value = cls._sign_value(raw_value).strip()
|
|
if value:
|
|
normalized[key] = value
|
|
return normalized
|
|
|
|
def sign(self, params: Mapping[str, Any], method: str) -> str:
|
|
"""生成小写 MD5 签名。"""
|
|
normalized = self.normalized_params(params)
|
|
query = "&".join(f"{key}={normalized[key]}" for key in sorted(normalized))
|
|
raw = f"{query}{method.upper()}{self.config.app_secret}"
|
|
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
|
|
|
@staticmethod
|
|
def _json_text(value: Any) -> str:
|
|
"""生成单行 JSON 调试文本,避免日志被控制字符截断。"""
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
|
|
@classmethod
|
|
def _debug_payload(cls, payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
"""调试记录只保留协议字段,签名值和密钥相关内容统一脱敏。"""
|
|
safe = dict(payload)
|
|
if "sign" in safe:
|
|
safe["sign"] = "<redacted>"
|
|
return safe
|
|
|
|
@staticmethod
|
|
def _response_value(payload: Mapping[str, Any], *keys: str) -> Any:
|
|
"""兼容供应商将订单字段放在根节点、data 或 result 节点。"""
|
|
sources = [payload]
|
|
for key in ("data", "result"):
|
|
value = payload.get(key)
|
|
if isinstance(value, Mapping):
|
|
sources.append(value)
|
|
for source in sources:
|
|
for key in keys:
|
|
if source.get(key) is not None:
|
|
return source[key]
|
|
return None
|
|
|
|
def verify_response_sign(self, payload: Mapping[str, Any], method: str) -> bool:
|
|
"""校验带 sign 的响应;供应商未返回 sign 时由调用方决定是否接受。"""
|
|
received = str(payload.get("sign") or "").strip().lower()
|
|
return bool(received) and received == self.sign(payload, method)
|
|
|
|
def _request(
|
|
self, method: str, path: str, params: Mapping[str, Any] | None = None
|
|
) -> dict[str, Any]:
|
|
"""补齐公共参数、签名并执行一次 JSON 请求。"""
|
|
request_params: dict[str, Any] = {
|
|
"app_id": self.config.app_id,
|
|
"timestamp": int(time.time()),
|
|
**(params or {}),
|
|
}
|
|
request_params["sign"] = self.sign(request_params, method)
|
|
url = f"{self.config.base_url.rstrip('/')}{path}"
|
|
trace_event = {
|
|
"stage": "request",
|
|
"method": method.upper(),
|
|
"path": path,
|
|
"params": {
|
|
key: value
|
|
for key, value in request_params.items()
|
|
if key not in {"sign", "recharge_arg", "ext_arg"}
|
|
},
|
|
"parameter_types": {
|
|
key: type(value).__name__
|
|
for key, value in request_params.items()
|
|
if key != "sign"
|
|
},
|
|
# 只记录摘要,便于关联排查而不暴露可重放的签名。
|
|
"sign_digest": hashlib.sha256(
|
|
request_params["sign"].encode("utf-8")
|
|
).hexdigest()[:12],
|
|
}
|
|
if self.config.debug:
|
|
sign_params = self.normalized_params(request_params)
|
|
sign_query = "&".join(
|
|
f"{key}={sign_params[key]}" for key in sorted(sign_params)
|
|
)
|
|
trace_event.update(
|
|
{
|
|
"url": url,
|
|
"content_type": "application/json",
|
|
"json_body": self._debug_payload(request_params),
|
|
"sign_params": sign_params,
|
|
# 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。
|
|
"sign_source_digest": hashlib.sha256(
|
|
f"{sign_query}{method.upper()}".encode("utf-8")
|
|
).hexdigest()[:12],
|
|
}
|
|
)
|
|
self.trace(trace_event)
|
|
try:
|
|
if method.upper() == "GET":
|
|
response = self.session.get(
|
|
url, params=request_params, timeout=self.config.timeout
|
|
)
|
|
else:
|
|
response = self.session.post(
|
|
url, json=request_params, timeout=self.config.timeout
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
except requests.RequestException as exc:
|
|
raise FishFinRechargeError(f"供应商请求失败: {exc}") from exc
|
|
except ValueError as exc:
|
|
raise FishFinRechargeError("供应商响应不是 JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise FishFinRechargeError("供应商响应格式无效")
|
|
trace_event = {
|
|
"stage": "response",
|
|
"method": method.upper(),
|
|
"path": path,
|
|
"http_status": response.status_code,
|
|
"code": payload.get("code"),
|
|
"message": payload.get("msg") or payload.get("message") or "",
|
|
"out_order_id": self._response_value(payload, "out_order_id", "outOrderId"),
|
|
"order_id": self._response_value(payload, "order_id", "orderId"),
|
|
"order_status": self._response_value(
|
|
payload, "order_status", "orderStatus"
|
|
),
|
|
"fail_reason": self._response_value(payload, "fail_reason", "failReason"),
|
|
}
|
|
if self.config.debug:
|
|
trace_event.update(
|
|
{
|
|
"response_headers": {
|
|
key: value
|
|
for key, value in response.headers.items()
|
|
if key.lower() in {"content-type", "x-request-id", "request-id"}
|
|
},
|
|
"response_body": self._debug_payload(payload),
|
|
}
|
|
)
|
|
self.trace(trace_event)
|
|
return payload
|
|
|
|
@staticmethod
|
|
def _amount(value: Decimal | int | float | str) -> str:
|
|
"""规范化金额,避免浮点数表达式进入签名或订单请求。"""
|
|
try:
|
|
price = Decimal(str(value))
|
|
except (InvalidOperation, ValueError) as exc:
|
|
raise ValueError("pay_amount 必须是有效金额") from exc
|
|
if not price.is_finite() or price <= 0:
|
|
raise ValueError("pay_amount 必须大于 0")
|
|
return format(price.normalize(), "f")
|
|
|
|
@classmethod
|
|
def _json_amount(cls, value: Decimal | int | float | str) -> int | float:
|
|
"""按文档以 JSON 数字发送金额,整数不附带无意义的小数位。"""
|
|
amount_text = cls._amount(value)
|
|
return int(amount_text) if "." not in amount_text else float(amount_text)
|
|
|
|
@staticmethod
|
|
def _json_argument(value: list[dict[str, Any]] | dict[str, Any], field: str) -> str:
|
|
"""将供应商规定的扩展参数编码为紧凑 JSON 字符串。"""
|
|
if not value:
|
|
raise ValueError(f"{field} 不能为空")
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
def create_order(
|
|
self,
|
|
*,
|
|
buy_num: int,
|
|
pay_amount: Decimal | int | float | str,
|
|
out_order_id: str,
|
|
product_id: str,
|
|
recharge_arg: list[dict[str, Any]],
|
|
order_type: int = 0,
|
|
notify_url: str = "",
|
|
ext_arg: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""创建直充订单;成功后应使用 query_order 或供应商回调确认最终状态。"""
|
|
if not str(out_order_id).strip():
|
|
raise ValueError("out_order_id 不能为空")
|
|
if not str(product_id).strip():
|
|
raise ValueError("product_id 不能为空")
|
|
if not isinstance(buy_num, int) or isinstance(buy_num, bool) or buy_num < 1:
|
|
raise ValueError("buy_num 必须是不小于 1 的整数")
|
|
if (
|
|
not isinstance(order_type, int)
|
|
or isinstance(order_type, bool)
|
|
or order_type not in {0, 1, 2, 3}
|
|
):
|
|
raise ValueError("order_type 必须是 0 至 3 的整数")
|
|
if not isinstance(recharge_arg, list) or not recharge_arg:
|
|
raise ValueError("recharge_arg 必须是非空数组")
|
|
params: dict[str, Any] = {
|
|
"buy_num": buy_num,
|
|
"pay_amount": self._json_amount(pay_amount),
|
|
"out_order_id": str(out_order_id).strip(),
|
|
"product_id": str(product_id).strip(),
|
|
"order_type": order_type,
|
|
"recharge_arg": self._json_argument(recharge_arg, "recharge_arg"),
|
|
}
|
|
# 文档规定可选参数未特别约定时默认不传,不能以空值或内部字段占位。
|
|
if str(notify_url).strip():
|
|
params["notify_url"] = str(notify_url).strip()
|
|
if ext_arg:
|
|
params["ext_arg"] = self._json_argument(ext_arg, "ext_arg")
|
|
return self._request("POST", self.CREATE_ORDER_PATH, params)
|
|
|
|
def query_order(self, out_order_id: str) -> dict[str, Any]:
|
|
"""按新版文档使用来源/外部订单号查询订单。"""
|
|
out_order_id = str(out_order_id).strip()
|
|
if not out_order_id:
|
|
raise ValueError("out_order_id 不能为空")
|
|
return self._request(
|
|
"GET", self.QUERY_ORDER_PATH, {"out_order_id": out_order_id}
|
|
)
|
|
|
|
def account_info(self) -> dict[str, Any]:
|
|
"""查询商户账户信息。"""
|
|
return self._request("GET", self.USER_INFO_PATH)
|