"""付呗开放平台客户端:签名、小程序下单、退款、回调验签。""" from __future__ import annotations import hashlib import json import logging import random import string import time from decimal import Decimal, ROUND_HALF_UP from typing import Any, Dict, Optional, Tuple import requests logger = logging.getLogger(__name__) METHOD_MINA = 'openapi.payment.order.mina' METHOD_REFUND = 'openapi.payment.order.refund' METHOD_QUERY = 'openapi.payment.order.query' def _yuan_str(amount) -> str: d = Decimal(str(amount)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) return format(d, 'f') def build_sign(params: Dict[str, Any], app_secret: str) -> str: """付呗签名:参数 ASCII 排序后 key=value&... 再无缝拼接 secret,MD5 大写。""" items = [] for k in sorted(params.keys()): if k == 'sign': continue v = params[k] if v is None or v == '': continue items.append(f'{k}={v}') raw = '&'.join(items) + (app_secret or '') return hashlib.md5(raw.encode('utf-8')).hexdigest().upper() def verify_callback_sign(params: Dict[str, Any], app_secret: str) -> bool: got = (params.get('sign') or '').strip().upper() if not got: return False expect = build_sign(params, app_secret) return got == expect def _nonce(n: int = 24) -> str: return ''.join(random.choices(string.ascii_letters + string.digits, k=n)) def call_fubei_api( *, gateway_url: str, app_id: str, app_secret: str, method: str, biz: Dict[str, Any], timeout: int = 15, ) -> Dict[str, Any]: """调用付呗 gateway,成功返回 data 字典;失败抛 Exception。""" biz_content = json.dumps(biz, ensure_ascii=False, separators=(',', ':')) payload = { 'app_id': app_id, 'method': method, 'format': 'json', 'sign_method': 'md5', 'nonce': _nonce(), 'version': '1.0', 'biz_content': biz_content, } payload['sign'] = build_sign(payload, app_secret) url = (gateway_url or '').strip() or 'https://shq-api.51fubei.com/gateway' logger.info('[FUBEI] method=%s app_id=%s biz_keys=%s', method, app_id, list(biz.keys())) resp = requests.post(url, json=payload, timeout=timeout, headers={'Content-Type': 'application/json; charset=utf-8'}) try: body = resp.json() except Exception: logger.error('[FUBEI] 非JSON响应 status=%s body=%s', resp.status_code, resp.text[:500]) raise Exception(f'付呗接口异常: HTTP {resp.status_code}') result_code = body.get('result_code') if str(result_code) not in ('200', '200.0'): msg = body.get('result_message') or body.get('sub_code') or '付呗下单失败' logger.warning('[FUBEI] 业务失败 method=%s code=%s msg=%s body=%s', method, result_code, msg, body) raise Exception(str(msg)) data = body.get('data') if isinstance(data, str): try: data = json.loads(data) except Exception: pass if not isinstance(data, dict): raise Exception('付呗返回 data 格式异常') return data def mina_prepay( *, gateway_url: str, app_id: str, app_secret: str, merchant_order_sn: str, sub_openid: str, total_fee, store_id: int, call_back_url: str, body: str = '', attach: str = '', ) -> Dict[str, Any]: """小程序支付下单,返回含 sign_params / order_sn 等。""" biz = { 'merchant_order_sn': merchant_order_sn, 'sub_openid': sub_openid, 'total_fee': float(_yuan_str(total_fee)), 'store_id': int(store_id), } if call_back_url: biz['call_back_url'] = call_back_url if body: biz['body'] = (body or '')[:128] if attach: biz['attach'] = (attach or '')[:127] return call_fubei_api( gateway_url=gateway_url, app_id=app_id, app_secret=app_secret, method=METHOD_MINA, biz=biz, ) def refund_order( *, gateway_url: str, app_id: str, app_secret: str, merchant_order_sn: str, merchant_refund_sn: str, refund_money=None, order_sn: str = '', ) -> Dict[str, Any]: biz: Dict[str, Any] = { 'merchant_refund_sn': merchant_refund_sn, } if merchant_order_sn: biz['merchant_order_sn'] = merchant_order_sn if order_sn: biz['order_sn'] = order_sn if refund_money is not None: biz['refund_money'] = float(_yuan_str(refund_money)) return call_fubei_api( gateway_url=gateway_url, app_id=app_id, app_secret=app_secret, method=METHOD_REFUND, biz=biz, ) def query_order( *, gateway_url: str, app_id: str, app_secret: str, merchant_order_sn: str = '', order_sn: str = '', ) -> Dict[str, Any]: """订单查询。trade_state=SUCCESS 表示已支付。""" biz: Dict[str, Any] = {} if merchant_order_sn: biz['merchant_order_sn'] = merchant_order_sn if order_sn: biz['order_sn'] = order_sn if not biz: raise Exception('付呗查单缺少订单号') return call_fubei_api( gateway_url=gateway_url, app_id=app_id, app_secret=app_secret, method=METHOD_QUERY, biz=biz, ) def parse_callback_form(post_dict: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]: """解析付呗 form 回调,返回 (顶层参数含 sign, data 业务字典)。""" params = {k: (v if not isinstance(v, list) else (v[0] if v else '')) for k, v in post_dict.items()} # 兼容 QueryDict.dict() data_raw = params.get('data') or '' data: Dict[str, Any] = {} if isinstance(data_raw, dict): data = data_raw elif isinstance(data_raw, str) and data_raw.strip(): try: data = json.loads(data_raw) except Exception: # 偶发双重编码 try: data = json.loads(data_raw.replace("'", '"')) except Exception: logger.error('[FUBEI_NOTIFY] data 解析失败: %s', data_raw[:300]) data = {} return params, data def extract_jsapi_pay_params(sign_params: Dict[str, Any]) -> Dict[str, str]: """从付呗 sign_params 抽出小程序 wx.requestPayment 所需字段。""" if not sign_params: raise Exception('付呗未返回支付签名包') # 部分字段可能混有 store_id 等,只取调起支付字段 out = { 'appId': str(sign_params.get('appId') or sign_params.get('appid') or ''), 'timeStamp': str(sign_params.get('timeStamp') or sign_params.get('timestamp') or ''), 'nonceStr': str(sign_params.get('nonceStr') or sign_params.get('noncestr') or ''), 'package': str(sign_params.get('package') or ''), 'signType': str(sign_params.get('signType') or sign_params.get('sign_type') or 'MD5'), 'paySign': str(sign_params.get('paySign') or sign_params.get('pay_sign') or ''), } if not out['timeStamp'] or not out['package'] or not out['paySign']: raise Exception('付呗签名包字段不完整') return out