下单已走付呗但复查仍查微信直连,导致付成功不入账;现按流水查付呗并履约,同时加强回调日志。 Co-authored-by: Cursor <cursoragent@cursor.com>
228 lines
7.0 KiB
Python
228 lines
7.0 KiB
Python
"""付呗开放平台客户端:签名、小程序下单、退款、回调验签。"""
|
||
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
|