Files

321 lines
13 KiB
Python

"""鱼翅直充供应商 API 客户端。"""
from __future__ import annotations
import hashlib
import json
import os
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any
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()
).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 | 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 | 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 | 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)