"""鱼翅直充供应商 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) 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", # 调试模式按用户要求保留完整协议内容,包含 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) 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 "", "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 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)