功能: 接入鱼翅供应商直充渠道
This commit is contained in:
+87
-10
@@ -8,7 +8,7 @@ import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
import requests
|
||||
|
||||
@@ -30,6 +30,7 @@ class FishFinRechargeConfig:
|
||||
app_secret: str
|
||||
app_key: str = ""
|
||||
timeout: tuple[float, float] = (8, 20)
|
||||
debug: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "FishFinRechargeConfig":
|
||||
@@ -41,6 +42,7 @@ class FishFinRechargeConfig:
|
||||
app_secret=os.getenv("FISH_FIN_RECHARGE_APP_SECRET", "").strip(),
|
||||
app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(),
|
||||
timeout=(8, timeout),
|
||||
debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower() in {"1", "true", "yes"},
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
@@ -65,11 +67,18 @@ class FishFinRechargeClient:
|
||||
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):
|
||||
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:
|
||||
@@ -100,6 +109,11 @@ class FishFinRechargeClient:
|
||||
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)
|
||||
|
||||
def verify_response_sign(self, payload: Mapping[str, Any], method: str) -> bool:
|
||||
"""校验带 sign 的响应;供应商未返回 sign 时由调用方决定是否接受。"""
|
||||
received = str(payload.get("sign") or "").strip().lower()
|
||||
@@ -114,6 +128,35 @@ class FishFinRechargeClient:
|
||||
}
|
||||
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",
|
||||
# 调试模式按用户要求保留完整协议内容,包含 sign 和 AppSecret。
|
||||
"json_body": request_params,
|
||||
"sign_params": sign_params,
|
||||
"sign_source": f"{sign_query}{method.upper()}{self.config.app_secret}",
|
||||
})
|
||||
self.trace(trace_event)
|
||||
try:
|
||||
if method.upper() == "GET":
|
||||
response = self.session.get(url, params=request_params, timeout=self.config.timeout)
|
||||
@@ -127,6 +170,28 @@ class FishFinRechargeClient:
|
||||
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 "",
|
||||
"keys": sorted(payload.keys()),
|
||||
"data_keys": sorted((payload.get("data") or {}).keys()) if isinstance(payload.get("data"), dict) else [],
|
||||
"result_keys": sorted((payload.get("result") or {}).keys()) if isinstance(payload.get("result"), dict) else [],
|
||||
}
|
||||
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": payload,
|
||||
"response_text": response.text,
|
||||
})
|
||||
self.trace(trace_event)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
@@ -140,6 +205,12 @@ class FishFinRechargeClient:
|
||||
raise ValueError("customer_price 必须大于 0")
|
||||
return format(price.normalize(), "f")
|
||||
|
||||
@classmethod
|
||||
def _json_price(cls, value: Decimal | int | float | str) -> int | float:
|
||||
"""按文档以 JSON 数字发送金额,整数不附带无意义的小数位。"""
|
||||
price_text = cls._price(value)
|
||||
return int(price_text) if "." not in price_text else float(price_text)
|
||||
|
||||
def create_order(
|
||||
self,
|
||||
*,
|
||||
@@ -148,7 +219,7 @@ class FishFinRechargeClient:
|
||||
customer_price: Decimal | int | float | str,
|
||||
customer_order_no: str,
|
||||
product_id: str,
|
||||
recharge_arg: list[dict[str, Any]],
|
||||
recharge_arg: list[dict[str, Any]] | None = None,
|
||||
notify_url: str = "",
|
||||
ext_arg: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
@@ -161,18 +232,24 @@ class FishFinRechargeClient:
|
||||
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(recharge_arg, list) or not recharge_arg:
|
||||
if recharge_arg is not None and (not isinstance(recharge_arg, list) or not recharge_arg):
|
||||
raise ValueError("recharge_arg 必须是非空数组")
|
||||
return self._request("POST", self.CREATE_ORDER_PATH, {
|
||||
params: dict[str, Any] = {
|
||||
"charge_account": str(charge_account).strip(),
|
||||
"buy_num": buy_num,
|
||||
"customer_price": self._price(customer_price),
|
||||
# 文档示例使用 JSON 数字;签名使用实际 JSON 数字对应的文本。
|
||||
"customer_price": self._json_price(customer_price),
|
||||
"customer_order_no": str(customer_order_no).strip(),
|
||||
"product_id": str(product_id).strip(),
|
||||
"recharge_arg": recharge_arg,
|
||||
"notify_url": str(notify_url).strip(),
|
||||
"ext_arg": ext_arg or {},
|
||||
})
|
||||
}
|
||||
# 文档规定可选参数未特别约定时默认不传,不能以空值或内部字段占位。
|
||||
if recharge_arg:
|
||||
params["recharge_arg"] = recharge_arg
|
||||
if str(notify_url).strip():
|
||||
params["notify_url"] = str(notify_url).strip()
|
||||
if ext_arg:
|
||||
params["ext_arg"] = ext_arg
|
||||
return self._request("POST", self.CREATE_ORDER_PATH, params)
|
||||
|
||||
def query_order(self, **query: Any) -> dict[str, Any]:
|
||||
"""查询订单。
|
||||
|
||||
Reference in New Issue
Block a user