"""鱼翅直充供应商 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"] = "" 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)