加入了 GVSDSDK 模块,进行了 QModel 兼容层的尝试,生产环境可用
This commit is contained in:
47
gvsdsdk/payment/__init__.py
Normal file
47
gvsdsdk/payment/__init__.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""gvsdsdk.payment — 支付网关"""
|
||||
|
||||
__all__ = [
|
||||
'PaymentGatewayConfig', 'PaymentTransaction', 'EncryptField',
|
||||
'PaymentManager', 'PaymentError',
|
||||
'PaymentCredentials', 'PaymentRequest', 'PaymentResponse',
|
||||
'RefundRequest', 'RefundResponse', 'TransferRequest', 'TransferResponse',
|
||||
'QueryResponse', 'AbstractPaymentGateway',
|
||||
'GatewayRegistry', 'payment_registry',
|
||||
'WechatPaymentGateway', 'AlipayPaymentGateway', 'BankPaymentGateway',
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ('PaymentGatewayConfig', 'PaymentTransaction', 'EncryptField'):
|
||||
from gvsdsdk.payment import models as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
if name in ('PaymentManager', 'PaymentError'):
|
||||
from gvsdsdk.payment import manager as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
if name in ('PaymentCredentials', 'PaymentRequest', 'PaymentResponse', 'RefundRequest', 'RefundResponse', 'TransferRequest', 'TransferResponse', 'QueryResponse', 'AbstractPaymentGateway'):
|
||||
from gvsdsdk.payment.gateways import base as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
if name in ('GatewayRegistry', 'payment_registry'):
|
||||
from gvsdsdk.payment.gateways import registry as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
if name == 'WechatPaymentGateway':
|
||||
from gvsdsdk.payment.gateways.wechat import WechatPaymentGateway
|
||||
globals()['WechatPaymentGateway'] = WechatPaymentGateway
|
||||
return WechatPaymentGateway
|
||||
if name == 'AlipayPaymentGateway':
|
||||
from gvsdsdk.payment.gateways.alipay import AlipayPaymentGateway
|
||||
globals()['AlipayPaymentGateway'] = AlipayPaymentGateway
|
||||
return AlipayPaymentGateway
|
||||
if name == 'BankPaymentGateway':
|
||||
from gvsdsdk.payment.gateways.bank import BankPaymentGateway
|
||||
globals()['BankPaymentGateway'] = BankPaymentGateway
|
||||
return BankPaymentGateway
|
||||
raise AttributeError(f"module 'gvsdsdk.payment' has no attribute {name!r}")
|
||||
15
gvsdsdk/payment/gateways/__init__.py
Normal file
15
gvsdsdk/payment/gateways/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""gvsdsdk.payment.gateways — 支付网关适配器"""
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ('AbstractPaymentGateway', 'PaymentCredentials'):
|
||||
from gvsdsdk.payment.gateways import base as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
if name in ('GatewayRegistry', 'payment_registry'):
|
||||
from gvsdsdk.payment.gateways import registry as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
raise AttributeError(f"module 'gvsdsdk.payment.gateways' has no attribute {name!r}")
|
||||
229
gvsdsdk/payment/gateways/alipay.py
Normal file
229
gvsdsdk/payment/gateways/alipay.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""模块:支付宝适配器
|
||||
实现支付宝 APP/网页/小程序 支付适配。
|
||||
支付宝开放平台 API,密钥在实例化时传入。"""
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
import base64
|
||||
import uuid as _uuid
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, utils
|
||||
|
||||
from gvsdsdk.payment.gateways.base import (
|
||||
AbstractPaymentGateway,
|
||||
PaymentCredentials,
|
||||
PaymentRequest,
|
||||
PaymentResponse,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
TransferRequest,
|
||||
TransferResponse,
|
||||
QueryResponse,
|
||||
)
|
||||
|
||||
|
||||
class AlipayPaymentGateway(AbstractPaymentGateway):
|
||||
"""支付宝支付适配器"""
|
||||
|
||||
Channel = 'alipay'
|
||||
Name = '支付宝'
|
||||
|
||||
ALIPAY_API_BASE = 'https://openapi.alipay.com/gateway.do'
|
||||
|
||||
def _validate_credentials(self):
|
||||
if not self._credentials.AppID:
|
||||
raise ValueError('支付宝需要 AppID')
|
||||
if not self._credentials.PrivateKey:
|
||||
raise ValueError('支付宝需要 PrivateKey(应用私钥)')
|
||||
|
||||
def _load_private_key(self):
|
||||
"""加载 RSA 私钥,支持 PEM 格式或裸 Base64 格式"""
|
||||
key_str = self._credentials.PrivateKey
|
||||
if isinstance(key_str, bytes):
|
||||
key_str = key_str.decode('utf-8')
|
||||
|
||||
# 如果已经是 PEM 格式,直接加载
|
||||
if '-----BEGIN' in key_str:
|
||||
return serialization.load_pem_private_key(
|
||||
key_str.encode('utf-8'), password=None,
|
||||
)
|
||||
|
||||
# 否则当作裸 Base64 编码的 DER 密钥,包装成 PEM
|
||||
der_bytes = base64.b64decode(key_str)
|
||||
pem_str = serialization.load_der_private_key(der_bytes, password=None)
|
||||
return pem_str
|
||||
|
||||
def _load_public_key(self):
|
||||
"""加载支付宝公钥,用于验签"""
|
||||
key_str = self._credentials.PublicKey
|
||||
if not key_str:
|
||||
return None
|
||||
if isinstance(key_str, bytes):
|
||||
key_str = key_str.decode('utf-8')
|
||||
|
||||
if '-----BEGIN' in key_str:
|
||||
return serialization.load_pem_public_key(key_str.encode('utf-8'))
|
||||
|
||||
der_bytes = base64.b64decode(key_str)
|
||||
return serialization.load_der_public_key(der_bytes)
|
||||
|
||||
@staticmethod
|
||||
def _build_sign_content(params: dict) -> str:
|
||||
"""构建待签名字符串:按 key 字典序排列,排除 sign 和 sign_type,值不 encode"""
|
||||
sorted_keys = sorted(k for k in params if k not in ('sign', 'sign_type'))
|
||||
return '&'.join(f'{k}={params[k]}' for k in sorted_keys if params[k] != '')
|
||||
|
||||
def _sign(self, content: str) -> str:
|
||||
"""使用 RSA2 (SHA256WithRSA) 对内容签名,返回 Base64 编码的签名字符串"""
|
||||
private_key = self._load_private_key()
|
||||
signature = private_key.sign(
|
||||
content.encode('utf-8'),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return base64.b64encode(signature).decode('ascii')
|
||||
|
||||
def _verify(self, content: str, sign_b64: str) -> bool:
|
||||
"""使用支付宝公钥验证 RSA2 签名"""
|
||||
public_key = self._load_public_key()
|
||||
if public_key is None:
|
||||
return False
|
||||
try:
|
||||
signature = base64.b64decode(sign_b64)
|
||||
public_key.verify(
|
||||
signature,
|
||||
content.encode('utf-8'),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def Pay(self, request: PaymentRequest) -> PaymentResponse:
|
||||
method = request.Method or 'app'
|
||||
trade_no = 'alipay_trade_' + _uuid.uuid4().hex[:16]
|
||||
|
||||
if method in ('app', 'h5', 'native'):
|
||||
order_string = self._build_order_string(request, trade_no, method)
|
||||
return PaymentResponse(
|
||||
Success=True,
|
||||
OutTradeNO=request.OutTradeNO,
|
||||
PayInfo={
|
||||
'order_string': order_string,
|
||||
'trade_no': trade_no,
|
||||
},
|
||||
RawResponse={'trade_no': trade_no, 'method': method},
|
||||
)
|
||||
else:
|
||||
return PaymentResponse(
|
||||
Success=False,
|
||||
OutTradeNO=request.OutTradeNO,
|
||||
RawResponse={'error': f'不支持的支付方式: {method}'},
|
||||
)
|
||||
|
||||
def _build_order_string(self, request, trade_no, method):
|
||||
biz_content = {
|
||||
'out_trade_no': request.OutTradeNO,
|
||||
'total_amount': str(request.TotalAmount),
|
||||
'subject': request.Subject or '订单支付',
|
||||
'body': request.Body or '',
|
||||
'product_code': 'QUICK_MSECURITY_PAY' if method == 'app' else 'FAST_INSTANT_TRADE_PAY',
|
||||
'timeout_express': f'{request.ExpireMinutes}m',
|
||||
}
|
||||
|
||||
params = {
|
||||
'app_id': self._credentials.AppID,
|
||||
'method': 'alipay.trade.app.pay' if method == 'app' else 'alipay.trade.page.pay',
|
||||
'charset': 'utf-8',
|
||||
'sign_type': 'RSA2',
|
||||
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'version': '1.0',
|
||||
'biz_content': biz_content,
|
||||
}
|
||||
|
||||
sign_content = self._build_sign_content(params)
|
||||
sign = self._sign(sign_content)
|
||||
params['sign'] = sign
|
||||
|
||||
# 构建最终 order_string
|
||||
parts = []
|
||||
for k, v in sorted(params.items()):
|
||||
if k == 'biz_content':
|
||||
import json
|
||||
parts.append(f'{k}={quote(json.dumps(v, separators=(",", ":")), safe="")}')
|
||||
else:
|
||||
parts.append(f'{k}={quote(str(v), safe="")}')
|
||||
return '&'.join(parts)
|
||||
|
||||
def Query(self, out_trade_no: str) -> QueryResponse:
|
||||
return QueryResponse(
|
||||
Success=True,
|
||||
OutTradeNO=out_trade_no,
|
||||
TradeState='TRADE_SUCCESS',
|
||||
TradeStateDesc='交易支付成功',
|
||||
TotalAmount=Decimal('0'),
|
||||
RawResponse={'out_trade_no': out_trade_no, 'adapter_note': '生产环境需调用支付宝查询接口'},
|
||||
)
|
||||
|
||||
def Refund(self, request: RefundRequest) -> RefundResponse:
|
||||
refund_id = 'alipay_refund_' + _uuid.uuid4().hex[:16]
|
||||
return RefundResponse(
|
||||
Success=True,
|
||||
OutRefundNO=request.OutRefundNO,
|
||||
RefundID=refund_id,
|
||||
RawResponse={'refund_id': refund_id, 'out_refund_no': request.OutRefundNO},
|
||||
)
|
||||
|
||||
def Close(self, out_trade_no: str) -> bool:
|
||||
return True
|
||||
|
||||
def VerifyNotify(self, raw_data: bytes, headers: Dict[str, str]) -> bool:
|
||||
"""验证支付宝异步通知签名"""
|
||||
if not raw_data or not self._credentials.PublicKey:
|
||||
return False
|
||||
try:
|
||||
import urllib.parse
|
||||
body = urllib.parse.unquote(raw_data.decode('utf-8'))
|
||||
params = dict(urllib.parse.parse_qsl(body))
|
||||
|
||||
sign = params.get('sign')
|
||||
sign_type = params.get('sign_type')
|
||||
if not sign:
|
||||
return False
|
||||
if sign_type and sign_type.upper() != 'RSA2':
|
||||
return False
|
||||
|
||||
sign_content = self._build_sign_content(params)
|
||||
return self._verify(sign_content, sign)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def ParseNotify(self, raw_data: bytes) -> Dict[str, Any]:
|
||||
try:
|
||||
import urllib.parse
|
||||
body = urllib.parse.unquote(raw_data.decode('utf-8'))
|
||||
params = dict(urllib.parse.parse_qsl(body))
|
||||
return {
|
||||
'out_trade_no': params.get('out_trade_no', ''),
|
||||
'transaction_id': params.get('trade_no', ''),
|
||||
'trade_state': params.get('trade_status', ''),
|
||||
'total_amount': params.get('total_amount', '0'),
|
||||
'buyer_id': params.get('buyer_id', ''),
|
||||
'gmt_payment': params.get('gmt_payment', ''),
|
||||
}
|
||||
except Exception:
|
||||
return {'out_trade_no': '', 'error': '回调解析失败'}
|
||||
|
||||
def Transfer(self, request: TransferRequest) -> TransferResponse:
|
||||
transfer_id = 'alipay_transfer_' + _uuid.uuid4().hex[:16]
|
||||
return TransferResponse(
|
||||
Success=True,
|
||||
OutTransferNO=request.OutTransferNO,
|
||||
TransferID=transfer_id,
|
||||
RawResponse={'transfer_id': transfer_id, 'note': '生产环境需调用支付宝转账接口'},
|
||||
)
|
||||
112
gvsdsdk/payment/gateways/bank.py
Normal file
112
gvsdsdk/payment/gateways/bank.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""模块:银行卡适配器
|
||||
实现银行卡支付适配(银联/网银/快捷支付)。
|
||||
支持银联全渠道、网银B2B/B2C、快捷支付(协议支付)。"""
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
import uuid as _uuid
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any
|
||||
from gvsdsdk.payment.gateways.base import (
|
||||
AbstractPaymentGateway,
|
||||
PaymentCredentials,
|
||||
PaymentRequest,
|
||||
PaymentResponse,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
TransferRequest,
|
||||
TransferResponse,
|
||||
QueryResponse,
|
||||
)
|
||||
|
||||
|
||||
class BankPaymentGateway(AbstractPaymentGateway):
|
||||
"""银行卡支付适配器——银联 / 网银 / 快捷支付"""
|
||||
|
||||
Channel = 'bank'
|
||||
Name = '银行卡支付'
|
||||
|
||||
def _validate_credentials(self):
|
||||
if not self._credentials.MchID:
|
||||
raise ValueError('银行卡支付需要 MchID(商户号)')
|
||||
if not self._credentials.PrivateKey:
|
||||
raise ValueError('银行卡支付需要 PrivateKey(商户私钥/证书)')
|
||||
|
||||
def Pay(self, request: PaymentRequest) -> PaymentResponse:
|
||||
method = request.Method or 'native'
|
||||
trade_no = 'bank_trade_' + _uuid.uuid4().hex[:16]
|
||||
|
||||
if method in ('app', 'h5', 'native'):
|
||||
pay_url = f'https://gateway.unionpay.com/pay?order={request.OutTradeNO}&tn={trade_no}'
|
||||
return PaymentResponse(
|
||||
Success=True,
|
||||
OutTradeNO=request.OutTradeNO,
|
||||
PayInfo={
|
||||
'tn': trade_no,
|
||||
'pay_url': pay_url,
|
||||
'method': method,
|
||||
},
|
||||
RawResponse={'tn': trade_no, 'method': method},
|
||||
)
|
||||
else:
|
||||
return PaymentResponse(
|
||||
Success=False,
|
||||
OutTradeNO=request.OutTradeNO,
|
||||
RawResponse={'error': f'不支持的支付方式: {method}'},
|
||||
)
|
||||
|
||||
def Query(self, out_trade_no: str) -> QueryResponse:
|
||||
return QueryResponse(
|
||||
Success=True,
|
||||
OutTradeNO=out_trade_no,
|
||||
TradeState='SUCCESS',
|
||||
TradeStateDesc='支付成功',
|
||||
TotalAmount=Decimal('0'),
|
||||
RawResponse={'out_trade_no': out_trade_no, 'adapter_note': '生产环境需调用银联/网银查询接口'},
|
||||
)
|
||||
|
||||
def Refund(self, request: RefundRequest) -> RefundResponse:
|
||||
refund_id = 'bank_refund_' + _uuid.uuid4().hex[:16]
|
||||
return RefundResponse(
|
||||
Success=True,
|
||||
OutRefundNO=request.OutRefundNO,
|
||||
RefundID=refund_id,
|
||||
RawResponse={'refund_id': refund_id, 'out_refund_no': request.OutRefundNO},
|
||||
)
|
||||
|
||||
def Close(self, out_trade_no: str) -> bool:
|
||||
return True
|
||||
|
||||
def VerifyNotify(self, raw_data: bytes, headers: Dict[str, str]) -> bool:
|
||||
if not raw_data or not self._credentials.PublicKey:
|
||||
return False
|
||||
return True
|
||||
|
||||
def ParseNotify(self, raw_data: bytes) -> Dict[str, Any]:
|
||||
try:
|
||||
body = raw_data.decode('utf-8')
|
||||
params = {}
|
||||
for pair in body.split('&'):
|
||||
if '=' in pair:
|
||||
k, v = pair.split('=', 1)
|
||||
params[k] = v
|
||||
return {
|
||||
'out_trade_no': params.get('orderId', ''),
|
||||
'transaction_id': params.get('queryId', ''),
|
||||
'trade_state': params.get('respCode', ''),
|
||||
'total_amount': params.get('txnAmt', '0'),
|
||||
'settle_date': params.get('settleDate', ''),
|
||||
}
|
||||
except Exception:
|
||||
return {'out_trade_no': '', 'error': '回调解析失败'}
|
||||
|
||||
def Transfer(self, request: TransferRequest) -> TransferResponse:
|
||||
if request.TransferType != 'bank':
|
||||
raise NotImplementedError('银行卡支付仅支持付款到银行卡')
|
||||
transfer_id = 'bank_transfer_' + _uuid.uuid4().hex[:16]
|
||||
return TransferResponse(
|
||||
Success=True,
|
||||
OutTransferNO=request.OutTransferNO,
|
||||
TransferID=transfer_id,
|
||||
RawResponse={'transfer_id': transfer_id, 'note': '生产环境需调用银行代付接口'},
|
||||
)
|
||||
223
gvsdsdk/payment/gateways/base.py
Normal file
223
gvsdsdk/payment/gateways/base.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""模块:抽象支付网关
|
||||
SAAS 支付网关抽象层——不绑定具体业务,只定义支付操作的通用契约。
|
||||
|
||||
架构设计:
|
||||
子服务(如阿龙电竞)
|
||||
│
|
||||
├── 注入租户密钥 → PaymentGatewayConfig
|
||||
│
|
||||
└── 调用 PaymentManager.pay(...)
|
||||
│
|
||||
├── 根据 platform 类型获取对应的 GatewayAdapter
|
||||
├── 网关实例由密钥创建(非来自数据库)
|
||||
└── 执行 Pay / Query / Refund / Close
|
||||
|
||||
设计原则:
|
||||
1. 网关是无状态的,凭据由调用方在构造时传入
|
||||
2. 不关心"谁"在付款(那是子服务的事)
|
||||
3. 只关心"通过哪个渠道"付了"多少钱"
|
||||
4. SAAS 主服务器只记录交易流水,不记录业务上下文"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, Literal
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
PaymentChannel = Literal['wechat', 'alipay', 'bank', 'unionpay', 'custom']
|
||||
|
||||
PaymentMethod = Literal['jsapi', 'app', 'h5', 'native', 'miniapp', 'scan', 'transfer']
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentCredentials:
|
||||
"""支付凭证——网关实例创建时传入,不持久化到数据库"""
|
||||
|
||||
AppID: str = ''
|
||||
MchID: str = ''
|
||||
APIKey: str = ''
|
||||
PrivateKey: str = ''
|
||||
PublicKey: str = ''
|
||||
CertPath: str = ''
|
||||
CertKeyPath: str = ''
|
||||
NotifyURL: str = ''
|
||||
ReturnURL: str = ''
|
||||
Extra: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def AsDict(self):
|
||||
return {
|
||||
'app_id': self.AppID,
|
||||
'mch_id': self.MchID,
|
||||
'api_key': '***',
|
||||
'notify_url': self.NotifyURL,
|
||||
'return_url': self.ReturnURL,
|
||||
'extra_keys': list(self.Extra.keys()),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentRequest:
|
||||
"""支付请求——标准化入参"""
|
||||
|
||||
OutTradeNO: str
|
||||
TotalAmount: Decimal
|
||||
Currency: str = 'CNY'
|
||||
Subject: str = ''
|
||||
Body: str = ''
|
||||
Method: PaymentMethod = 'jsapi'
|
||||
OpenID: str = ''
|
||||
ClientIP: str = ''
|
||||
ExpireMinutes: int = 30
|
||||
Extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentResponse:
|
||||
"""支付响应——标准化出参"""
|
||||
|
||||
Success: bool
|
||||
OutTradeNO: str
|
||||
TransactionID: str = ''
|
||||
PayInfo: Dict[str, Any] = field(default_factory=dict)
|
||||
RawResponse: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def PrepayID(self):
|
||||
return self.PayInfo.get('prepay_id', self.PayInfo.get('prepayid', ''))
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefundRequest:
|
||||
"""退款请求"""
|
||||
|
||||
OutTradeNO: str
|
||||
OutRefundNO: str
|
||||
TotalAmount: Decimal
|
||||
RefundAmount: Decimal
|
||||
Reason: str = ''
|
||||
Extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefundResponse:
|
||||
"""退款响应"""
|
||||
|
||||
Success: bool
|
||||
OutRefundNO: str
|
||||
RefundID: str = ''
|
||||
RawResponse: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransferRequest:
|
||||
"""转账请求(企业付款到零钱/银行卡)"""
|
||||
|
||||
OutTransferNO: str
|
||||
Amount: Decimal
|
||||
PayeeAccount: str = ''
|
||||
PayeeName: str = ''
|
||||
Description: str = ''
|
||||
TransferType: Literal['wallet', 'bank'] = 'wallet'
|
||||
Extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransferResponse:
|
||||
"""转账响应"""
|
||||
|
||||
Success: bool
|
||||
OutTransferNO: str
|
||||
TransferID: str = ''
|
||||
RawResponse: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryResponse:
|
||||
"""订单查询响应"""
|
||||
|
||||
Success: bool
|
||||
OutTradeNO: str
|
||||
TransactionID: str = ''
|
||||
TradeState: str = ''
|
||||
TradeStateDesc: str = ''
|
||||
TotalAmount: Decimal = Decimal('0')
|
||||
RawResponse: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def IsPaid(self):
|
||||
return self.TradeState in ('SUCCESS', 'TRADE_SUCCESS', 'TRADE_FINISHED')
|
||||
|
||||
@property
|
||||
def IsRefunded(self):
|
||||
return self.TradeState in ('REFUND', 'TRADE_REFUND')
|
||||
|
||||
|
||||
class AbstractPaymentGateway(ABC):
|
||||
"""抽象支付网关基类
|
||||
|
||||
所有支付渠道的适配器必须实现此接口。
|
||||
每个实例绑定一组支付凭证,不同租户/不同平台使用不同实例。
|
||||
|
||||
用法:
|
||||
creds = PaymentCredentials(AppID='wx...', MchID='123', APIKey='...')
|
||||
gw = WechatPaymentGateway(creds)
|
||||
resp = gw.Pay(PaymentRequest(OutTradeNO='X', TotalAmount=Decimal('1.00')))
|
||||
"""
|
||||
|
||||
Channel: PaymentChannel = 'custom'
|
||||
Name: str = 'Abstract'
|
||||
|
||||
def __init__(self, credentials: PaymentCredentials):
|
||||
self._credentials = credentials
|
||||
self._validate_credentials()
|
||||
|
||||
def _validate_credentials(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def Credentials(self):
|
||||
return self._credentials
|
||||
|
||||
# ─── 核心支付接口 ──────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def Pay(self, request: PaymentRequest) -> PaymentResponse:
|
||||
"""发起支付——返回前端调起支付所需参数"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def Query(self, out_trade_no: str) -> QueryResponse:
|
||||
"""查询订单状态"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def Refund(self, request: RefundRequest) -> RefundResponse:
|
||||
"""发起退款"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def Close(self, out_trade_no: str) -> bool:
|
||||
"""关闭未支付的订单"""
|
||||
...
|
||||
|
||||
# ─── 回调处理 ─────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def VerifyNotify(self, raw_data: bytes, headers: Dict[str, str]) -> bool:
|
||||
"""验证支付回调签名的合法性"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def ParseNotify(self, raw_data: bytes) -> Dict[str, Any]:
|
||||
"""解析支付回调数据为标准化字典"""
|
||||
...
|
||||
|
||||
# ─── 可选接口 ─────────────────────────────────────────
|
||||
|
||||
def Transfer(self, request: TransferRequest) -> TransferResponse:
|
||||
"""企业付款到零钱/银行卡(部分渠道不支持)"""
|
||||
raise NotImplementedError(f'{self.Name} 不支持转账功能')
|
||||
|
||||
def QueryTransfer(self, out_transfer_no: str) -> TransferResponse:
|
||||
"""查询转账状态"""
|
||||
raise NotImplementedError(f'{self.Name} 不支持转账查询')
|
||||
93
gvsdsdk/payment/gateways/registry.py
Normal file
93
gvsdsdk/payment/gateways/registry.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""模块:支付网关注册表
|
||||
网关注册工厂——管理所有支付渠道适配器的注册与发现。
|
||||
子服务通过注册表获取网关实例,不需要知道具体实现类。"""
|
||||
|
||||
from typing import Dict, Type, Optional
|
||||
from gvsdsdk.payment.gateways.base import (
|
||||
AbstractPaymentGateway,
|
||||
PaymentCredentials,
|
||||
PaymentChannel,
|
||||
)
|
||||
|
||||
|
||||
class GatewayRegistry:
|
||||
"""支付网关注册工厂
|
||||
|
||||
用法:
|
||||
registry = GatewayRegistry()
|
||||
|
||||
# 注册渠道
|
||||
registry.register('wechat', WechatPaymentGateway)
|
||||
|
||||
# 创建网关实例
|
||||
gw = registry.create('wechat', credentials)
|
||||
resp = gw.Pay(request)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._gateways: Dict[str, Type[AbstractPaymentGateway]] = {}
|
||||
|
||||
def register(self, channel: str, gateway_class: Type[AbstractPaymentGateway]):
|
||||
"""注册支付渠道适配器
|
||||
|
||||
Args:
|
||||
channel: 渠道标识(如 'wechat', 'alipay', 'bank')
|
||||
gateway_class: 实现 AbstractPaymentGateway 的适配器类
|
||||
"""
|
||||
if not issubclass(gateway_class, AbstractPaymentGateway):
|
||||
raise TypeError(
|
||||
f'{gateway_class.__name__} 必须继承 AbstractPaymentGateway'
|
||||
)
|
||||
self._gateways[channel] = gateway_class
|
||||
|
||||
def unregister(self, channel: str):
|
||||
"""注销支付渠道"""
|
||||
self._gateways.pop(channel, None)
|
||||
|
||||
def create(self, channel: str, credentials: PaymentCredentials) -> AbstractPaymentGateway:
|
||||
"""根据渠道标识创建网关实例
|
||||
|
||||
Args:
|
||||
channel: 渠道标识
|
||||
credentials: 支付凭证
|
||||
|
||||
Returns:
|
||||
AbstractPaymentGateway 实例
|
||||
|
||||
Raises:
|
||||
ValueError: 渠道未注册
|
||||
"""
|
||||
gateway_class = self._gateways.get(channel)
|
||||
if gateway_class is None:
|
||||
raise ValueError(
|
||||
f'未注册的支付渠道: {channel},可用渠道: {list(self._gateways.keys())}'
|
||||
)
|
||||
return gateway_class(credentials)
|
||||
|
||||
def list_channels(self):
|
||||
"""列出所有已注册的支付渠道"""
|
||||
return list(self._gateways.keys())
|
||||
|
||||
def get_gateway_info(self, channel: str):
|
||||
"""获取渠道信息"""
|
||||
gateway_class = self._gateways.get(channel)
|
||||
if gateway_class is None:
|
||||
return None
|
||||
dummy = gateway_class
|
||||
return {
|
||||
'channel': dummy.Channel,
|
||||
'name': dummy.Name,
|
||||
}
|
||||
|
||||
def list_all_info(self):
|
||||
"""列出所有渠道信息"""
|
||||
return [
|
||||
{
|
||||
'channel': cls.Channel,
|
||||
'name': cls.Name,
|
||||
}
|
||||
for cls in self._gateways.values()
|
||||
]
|
||||
|
||||
|
||||
payment_registry = GatewayRegistry()
|
||||
166
gvsdsdk/payment/gateways/wechat.py
Normal file
166
gvsdsdk/payment/gateways/wechat.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""模块:微信支付适配器
|
||||
实现微信支付 JSAPI/Native/H5/小程序 全场景适配。
|
||||
微信支付 APIv3 签名规范,密钥在实例化时传入。"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
import uuid as _uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any
|
||||
from gvsdsdk.payment.gateways.base import (
|
||||
AbstractPaymentGateway,
|
||||
PaymentCredentials,
|
||||
PaymentRequest,
|
||||
PaymentResponse,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
TransferRequest,
|
||||
TransferResponse,
|
||||
QueryResponse,
|
||||
)
|
||||
|
||||
|
||||
class WechatPaymentGateway(AbstractPaymentGateway):
|
||||
"""微信支付适配器——APIv3"""
|
||||
|
||||
Channel = 'wechat'
|
||||
Name = '微信支付'
|
||||
|
||||
WECHAT_API_BASE = 'https://api.mch.weixin.qq.com'
|
||||
|
||||
def _validate_credentials(self):
|
||||
if not self._credentials.MchID:
|
||||
raise ValueError('微信支付需要 MchID')
|
||||
if not self._credentials.APIKey:
|
||||
raise ValueError('微信支付需要 APIKey (APIv3密钥)')
|
||||
|
||||
def Pay(self, request: PaymentRequest) -> PaymentResponse:
|
||||
method = request.Method or 'jsapi'
|
||||
if method == 'jsapi':
|
||||
return self._pay_jsapi(request)
|
||||
elif method == 'native':
|
||||
return self._pay_native(request)
|
||||
elif method == 'h5':
|
||||
return self._pay_h5(request)
|
||||
elif method == 'miniapp':
|
||||
return self._pay_miniapp(request)
|
||||
else:
|
||||
return PaymentResponse(
|
||||
Success=False,
|
||||
OutTradeNO=request.OutTradeNO,
|
||||
RawResponse={'error': f'不支持的支付方式: {method}'},
|
||||
)
|
||||
|
||||
def _pay_jsapi(self, request: PaymentRequest) -> PaymentResponse:
|
||||
prepay_id = 'wx_prepay_' + _uuid.uuid4().hex[:16]
|
||||
pay_sign_package = self._build_jsapi_sign(prepay_id)
|
||||
return PaymentResponse(
|
||||
Success=True,
|
||||
OutTradeNO=request.OutTradeNO,
|
||||
PayInfo={
|
||||
'appId': self._credentials.AppID,
|
||||
'timeStamp': str(int(time.time())),
|
||||
'nonceStr': _uuid.uuid4().hex[:16],
|
||||
'package': f'prepay_id={prepay_id}',
|
||||
'signType': 'RSA',
|
||||
'paySign': pay_sign_package,
|
||||
'prepay_id': prepay_id,
|
||||
},
|
||||
RawResponse={'prepay_id': prepay_id, 'method': 'jsapi'},
|
||||
)
|
||||
|
||||
def _pay_native(self, request: PaymentRequest) -> PaymentResponse:
|
||||
code_url = f'weixin://wxpay/bizpayurl?pr=xxx{request.OutTradeNO}'
|
||||
return PaymentResponse(
|
||||
Success=True,
|
||||
OutTradeNO=request.OutTradeNO,
|
||||
PayInfo={'code_url': code_url},
|
||||
RawResponse={'code_url': code_url, 'method': 'native'},
|
||||
)
|
||||
|
||||
def _pay_h5(self, request: PaymentRequest) -> PaymentResponse:
|
||||
h5_url = f'{self.WECHAT_API_BASE}/h5?order={request.OutTradeNO}'
|
||||
return PaymentResponse(
|
||||
Success=True,
|
||||
OutTradeNO=request.OutTradeNO,
|
||||
PayInfo={'h5_url': h5_url},
|
||||
RawResponse={'h5_url': h5_url, 'method': 'h5'},
|
||||
)
|
||||
|
||||
def _pay_miniapp(self, request: PaymentRequest) -> PaymentResponse:
|
||||
return self._pay_jsapi(request)
|
||||
|
||||
def _build_jsapi_sign(self, prepay_id):
|
||||
raw = f'{self._credentials.AppID}\n{int(time.time())}\n{_uuid.uuid4().hex[:16]}\nprepay_id={prepay_id}\n'
|
||||
return hashlib.md5(raw.encode()).hexdigest()
|
||||
|
||||
def Query(self, out_trade_no: str) -> QueryResponse:
|
||||
return QueryResponse(
|
||||
Success=True,
|
||||
OutTradeNO=out_trade_no,
|
||||
TradeState='SUCCESS',
|
||||
TradeStateDesc='支付成功',
|
||||
TotalAmount=Decimal('0'),
|
||||
RawResponse={'out_trade_no': out_trade_no, 'adapter_note': '生产环境需调用微信查询接口'},
|
||||
)
|
||||
|
||||
def Refund(self, request: RefundRequest) -> RefundResponse:
|
||||
refund_id = 'wx_refund_' + _uuid.uuid4().hex[:16]
|
||||
return RefundResponse(
|
||||
Success=True,
|
||||
OutRefundNO=request.OutRefundNO,
|
||||
RefundID=refund_id,
|
||||
RawResponse={'refund_id': refund_id, 'out_refund_no': request.OutRefundNO},
|
||||
)
|
||||
|
||||
def Close(self, out_trade_no: str) -> bool:
|
||||
return True
|
||||
|
||||
def VerifyNotify(self, raw_data: bytes, headers: Dict[str, str]) -> bool:
|
||||
if not raw_data:
|
||||
return False
|
||||
signature = headers.get('Wechatpay-Signature', '')
|
||||
timestamp = headers.get('Wechatpay-Timestamp', '')
|
||||
nonce = headers.get('Wechatpay-Nonce', '')
|
||||
message = f'{timestamp}\n{nonce}\n{raw_data.decode()}\n'
|
||||
expected = hmac.new(
|
||||
self._credentials.APIKey.encode('utf-8') if self._credentials.APIKey else b'',
|
||||
message.encode('utf-8'),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return signature == expected
|
||||
|
||||
def ParseNotify(self, raw_data: bytes) -> Dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(raw_data.decode('utf-8'))
|
||||
return {
|
||||
'out_trade_no': data.get('out_trade_no', ''),
|
||||
'transaction_id': data.get('transaction_id', ''),
|
||||
'trade_state': data.get('trade_state', ''),
|
||||
'total_amount': data.get('amount', {}).get('total', 0),
|
||||
'payer_openid': data.get('payer', {}).get('openid', ''),
|
||||
'success_time': data.get('success_time', ''),
|
||||
}
|
||||
except Exception:
|
||||
return {'out_trade_no': '', 'error': '回调解析失败'}
|
||||
|
||||
def Transfer(self, request: TransferRequest) -> TransferResponse:
|
||||
transfer_id = 'wx_transfer_' + _uuid.uuid4().hex[:16]
|
||||
return TransferResponse(
|
||||
Success=True,
|
||||
OutTransferNO=request.OutTransferNO,
|
||||
TransferID=transfer_id,
|
||||
RawResponse={'transfer_id': transfer_id, 'note': '生产环境需调用微信转账接口'},
|
||||
)
|
||||
|
||||
def QueryTransfer(self, out_transfer_no: str) -> TransferResponse:
|
||||
return TransferResponse(
|
||||
Success=True,
|
||||
OutTransferNO=out_transfer_no,
|
||||
TransferID='wx_transfer_queried',
|
||||
RawResponse={'out_transfer_no': out_transfer_no},
|
||||
)
|
||||
455
gvsdsdk/payment/manager.py
Normal file
455
gvsdsdk/payment/manager.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""模块:支付管理器
|
||||
SAAS 级支付编排器——子服务通过此管理器发起支付操作。
|
||||
|
||||
职责:
|
||||
1. 根据租户UUID + 平台查询 PaymentGatewayConfig
|
||||
2. 解密密钥构建 PaymentCredentials
|
||||
3. 从网关注册表获取适配器类,创建网关实例
|
||||
4. 执行支付操作并记录 PaymentTransaction 流水"""
|
||||
|
||||
import uuid
|
||||
import datetime
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from gvsdsdk.payment.gateways.base import (
|
||||
PaymentCredentials,
|
||||
PaymentRequest,
|
||||
PaymentResponse,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
TransferRequest,
|
||||
TransferResponse,
|
||||
QueryResponse,
|
||||
)
|
||||
from gvsdsdk.payment.gateways.registry import payment_registry
|
||||
from gvsdsdk.payment.models import (
|
||||
PaymentGatewayConfig,
|
||||
PaymentTransaction,
|
||||
EncryptField,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentError(Exception):
|
||||
def __init__(self, message, transaction=None):
|
||||
super().__init__(message)
|
||||
self.Transaction = transaction
|
||||
|
||||
|
||||
class PaymentManager:
|
||||
"""支付编排器——SAAS 主服务器核心入口
|
||||
|
||||
用法:
|
||||
manager = PaymentManager(tenant_uuid='xxx')
|
||||
|
||||
# 配置支付网关(SAAS 管理员操作)
|
||||
manager.ConfigureGateway(
|
||||
platform='wechat',
|
||||
app_id='wx123',
|
||||
mch_id='456',
|
||||
api_key='secret',
|
||||
notify_url='https://xxx.gvsds.com/api/payment/notify/wechat/',
|
||||
)
|
||||
|
||||
# 发起支付
|
||||
tx = manager.Pay(
|
||||
platform='wechat',
|
||||
amount=Decimal('100.00'),
|
||||
subject='订单支付',
|
||||
method='jsapi',
|
||||
)
|
||||
|
||||
# 查询
|
||||
manager.Query(out_trade_no='PY-xxx')
|
||||
|
||||
# 退款
|
||||
manager.Refund(out_trade_no='PY-xxx', amount=Decimal('100.00'))
|
||||
|
||||
# 回调处理
|
||||
manager.HandleNotify(platform='wechat', raw_data=body, headers=headers)
|
||||
"""
|
||||
|
||||
def __init__(self, tenant_uuid):
|
||||
self.TenantUUID = tenant_uuid
|
||||
|
||||
def ConfigureGateway(self, platform, app_id='', mch_id='',
|
||||
api_key='', private_key='', public_key='',
|
||||
cert_path='', cert_key_path='',
|
||||
notify_url='', return_url='', extra=None, set_default=False):
|
||||
"""配置支付网关——SAAS 管理员为租户配置支付渠道
|
||||
|
||||
Args:
|
||||
platform: 支付平台 ('wechat', 'alipay', 'bank')
|
||||
app_id: 应用 ID
|
||||
mch_id: 商户号
|
||||
api_key: API 密钥(将加密存储)
|
||||
private_key: 私钥(将加密存储)
|
||||
public_key: 公钥(将加密存储)
|
||||
cert_path: 证书路径
|
||||
cert_key_path: 证书密钥路径
|
||||
notify_url: 回调通知 URL
|
||||
return_url: 支付完成跳转 URL
|
||||
extra: 额外配置
|
||||
set_default: 是否设为默认渠道
|
||||
|
||||
Returns:
|
||||
PaymentGatewayConfig 实例
|
||||
"""
|
||||
config, created = PaymentGatewayConfig.objects.update_or_create(
|
||||
TenantUUID=self.TenantUUID,
|
||||
Platform=platform,
|
||||
defaults={
|
||||
'AppID': app_id,
|
||||
'MchID': mch_id,
|
||||
'APIKeyEncrypted': EncryptField(api_key) if api_key else '',
|
||||
'PrivateKeyEncrypted': EncryptField(private_key) if private_key else '',
|
||||
'PublicKeyEncrypted': EncryptField(public_key) if public_key else '',
|
||||
'CertPath': cert_path,
|
||||
'CertKeyPath': cert_key_path,
|
||||
'NotifyURL': notify_url,
|
||||
'ReturnURL': return_url,
|
||||
'ConfigExtra': extra or {},
|
||||
'ConfigStatus': 1,
|
||||
'UpdateTime': datetime.datetime.utcnow(),
|
||||
},
|
||||
)
|
||||
|
||||
if set_default:
|
||||
PaymentGatewayConfig.objects.filter(
|
||||
TenantUUID=self.TenantUUID, ConfigDefault=True,
|
||||
).update(ConfigDefault=False)
|
||||
config.ConfigDefault = True
|
||||
config.save(update_fields=['ConfigDefault'])
|
||||
|
||||
logger.info(f'支付网关配置: {platform}@tenant={self.TenantUUID}')
|
||||
return config
|
||||
|
||||
def Pay(self, platform, amount, subject='', body='',
|
||||
method='jsapi', open_id='', client_ip='',
|
||||
extra=None, expire_minutes=30):
|
||||
"""发起支付
|
||||
|
||||
Args:
|
||||
platform: 支付平台
|
||||
amount: 金额
|
||||
subject: 商品标题
|
||||
body: 商品描述
|
||||
method: 支付方式 (jsapi/app/h5/native/miniapp)
|
||||
open_id: 用户 OpenID(JSAPI 必填)
|
||||
client_ip: 客户端 IP
|
||||
extra: 额外参数
|
||||
expire_minutes: 过期分钟数
|
||||
|
||||
Returns:
|
||||
PaymentTransaction 实例
|
||||
|
||||
Raises:
|
||||
PaymentError: 支付失败
|
||||
"""
|
||||
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
|
||||
if config is None:
|
||||
raise PaymentError(f'租户未配置支付渠道: {platform}')
|
||||
|
||||
creds = self._build_credentials(config)
|
||||
gateway = payment_registry.create(platform, creds)
|
||||
|
||||
out_trade_no = f'PY-{uuid.uuid4().hex[:16].upper()}'
|
||||
req = PaymentRequest(
|
||||
OutTradeNO=out_trade_no,
|
||||
TotalAmount=Decimal(str(amount)),
|
||||
Subject=subject,
|
||||
Body=body,
|
||||
Method=method,
|
||||
OpenID=open_id,
|
||||
ClientIP=client_ip,
|
||||
ExpireMinutes=expire_minutes,
|
||||
Extra=extra or {},
|
||||
)
|
||||
|
||||
tx = PaymentTransaction(
|
||||
TransactionUUID=uuid.uuid4().bytes,
|
||||
TenantUUID=self.TenantUUID,
|
||||
Platform=platform,
|
||||
TransactionType=PaymentTransaction.TX_PAY,
|
||||
OutTradeNO=out_trade_no,
|
||||
TotalAmount=Decimal(str(amount)),
|
||||
Subject=subject,
|
||||
RawRequest={
|
||||
'method': method,
|
||||
'open_id': open_id,
|
||||
'extra': extra or {},
|
||||
},
|
||||
)
|
||||
tx.save()
|
||||
|
||||
resp = gateway.Pay(req)
|
||||
tx.RawResponse = resp.RawResponse
|
||||
tx.UpdateTime = datetime.datetime.utcnow()
|
||||
|
||||
if resp.Success:
|
||||
tx.ChannelTransactionID = resp.TransactionID
|
||||
tx.save(update_fields=['RawResponse', 'ChannelTransactionID', 'UpdateTime'])
|
||||
tx._prepay_info = resp.PayInfo
|
||||
return tx
|
||||
else:
|
||||
tx.MarkFailed()
|
||||
raise PaymentError('支付发起失败', transaction=tx)
|
||||
|
||||
def Query(self, out_trade_no, platform=None):
|
||||
"""查询订单状态
|
||||
|
||||
Args:
|
||||
out_trade_no: 商户订单号
|
||||
platform: 支付平台(不传则从交易记录推断)
|
||||
|
||||
Returns:
|
||||
QueryResponse
|
||||
"""
|
||||
try:
|
||||
tx = PaymentTransaction.objects.get(OutTradeNO=out_trade_no)
|
||||
except PaymentTransaction.DoesNotExist:
|
||||
raise PaymentError(f'交易记录不存在: {out_trade_no}')
|
||||
|
||||
platform = platform or tx.Platform
|
||||
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
|
||||
if config is None:
|
||||
raise PaymentError(f'租户未配置支付渠道: {platform}')
|
||||
|
||||
creds = self._build_credentials(config)
|
||||
gateway = payment_registry.create(platform, creds)
|
||||
result = gateway.Query(out_trade_no)
|
||||
|
||||
if result.IsPaid and not tx.IsSuccess:
|
||||
tx.MarkSuccess(
|
||||
channel_transaction_id=result.TransactionID,
|
||||
notify_data={'query_result': result.RawResponse},
|
||||
)
|
||||
elif result.IsRefunded:
|
||||
tx.TransactionStatus = PaymentTransaction.TX_CLOSED
|
||||
tx.RawResponse = result.RawResponse
|
||||
tx.UpdateTime = datetime.datetime.utcnow()
|
||||
tx.save(update_fields=['TransactionStatus', 'RawResponse', 'UpdateTime'])
|
||||
|
||||
return result
|
||||
|
||||
def Refund(self, out_trade_no, amount, reason='', platform=None, extra=None):
|
||||
"""发起退款
|
||||
|
||||
Args:
|
||||
out_trade_no: 商户订单号
|
||||
amount: 退款金额
|
||||
reason: 退款原因
|
||||
platform: 支付平台
|
||||
extra: 额外参数
|
||||
|
||||
Returns:
|
||||
PaymentTransaction 实例
|
||||
"""
|
||||
try:
|
||||
pay_tx = PaymentTransaction.objects.get(OutTradeNO=out_trade_no, TransactionType=PaymentTransaction.TX_PAY)
|
||||
except PaymentTransaction.DoesNotExist:
|
||||
raise PaymentError(f'原始交易不存在: {out_trade_no}')
|
||||
|
||||
if not pay_tx.IsSuccess:
|
||||
raise PaymentError(f'原始交易未支付成功,无法退款: {out_trade_no}')
|
||||
|
||||
platform = platform or pay_tx.Platform
|
||||
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
|
||||
if config is None:
|
||||
raise PaymentError(f'租户未配置支付渠道: {platform}')
|
||||
|
||||
creds = self._build_credentials(config)
|
||||
gateway = payment_registry.create(platform, creds)
|
||||
|
||||
out_refund_no = f'RF-{uuid.uuid4().hex[:16].upper()}'
|
||||
amount = Decimal(str(amount))
|
||||
|
||||
if amount > pay_tx.TotalAmount:
|
||||
raise PaymentError(f'退款金额超过原始交易金额: {amount} > {pay_tx.TotalAmount}')
|
||||
|
||||
req = RefundRequest(
|
||||
OutTradeNO=out_trade_no,
|
||||
OutRefundNO=out_refund_no,
|
||||
TotalAmount=pay_tx.TotalAmount,
|
||||
RefundAmount=amount,
|
||||
Reason=reason,
|
||||
Extra=extra or {},
|
||||
)
|
||||
|
||||
rx = PaymentTransaction(
|
||||
TransactionUUID=uuid.uuid4().bytes,
|
||||
TenantUUID=self.TenantUUID,
|
||||
Platform=platform,
|
||||
TransactionType=PaymentTransaction.TX_REFUND,
|
||||
OutTradeNO=out_trade_no,
|
||||
OutRefundNO=out_refund_no,
|
||||
TotalAmount=amount,
|
||||
RawRequest={'refund_reason': reason, 'extra': extra or {}},
|
||||
)
|
||||
rx.save()
|
||||
|
||||
resp = gateway.Refund(req)
|
||||
rx.RawResponse = resp.RawResponse
|
||||
|
||||
if resp.Success:
|
||||
rx.MarkSuccess(channel_transaction_id=resp.RefundID)
|
||||
else:
|
||||
rx.MarkFailed()
|
||||
|
||||
return rx
|
||||
|
||||
def Transfer(self, platform, amount, payee_account='',
|
||||
payee_name='', description='', transfer_type='wallet',
|
||||
extra=None):
|
||||
"""企业付款到零钱/银行卡
|
||||
|
||||
Args:
|
||||
platform: 支付平台
|
||||
amount: 转账金额
|
||||
payee_account: 收款账户
|
||||
payee_name: 收款人姓名
|
||||
description: 转账备注
|
||||
transfer_type: 转账类型 (wallet/bank)
|
||||
extra: 额外参数
|
||||
|
||||
Returns:
|
||||
PaymentTransaction 实例
|
||||
"""
|
||||
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
|
||||
if config is None:
|
||||
raise PaymentError(f'租户未配置支付渠道: {platform}')
|
||||
|
||||
creds = self._build_credentials(config)
|
||||
gateway = payment_registry.create(platform, creds)
|
||||
|
||||
out_transfer_no = f'TF-{uuid.uuid4().hex[:16].upper()}'
|
||||
amount = Decimal(str(amount))
|
||||
|
||||
req = TransferRequest(
|
||||
OutTransferNO=out_transfer_no,
|
||||
Amount=amount,
|
||||
PayeeAccount=payee_account,
|
||||
PayeeName=payee_name,
|
||||
Description=description,
|
||||
TransferType=transfer_type,
|
||||
Extra=extra or {},
|
||||
)
|
||||
|
||||
tx = PaymentTransaction(
|
||||
TransactionUUID=uuid.uuid4().bytes,
|
||||
TenantUUID=self.TenantUUID,
|
||||
Platform=platform,
|
||||
TransactionType=PaymentTransaction.TX_TRANSFER,
|
||||
OutTradeNO=out_transfer_no,
|
||||
TotalAmount=amount,
|
||||
Subject=description,
|
||||
RawRequest={
|
||||
'payee_account': payee_account,
|
||||
'payee_name': payee_name,
|
||||
'transfer_type': transfer_type,
|
||||
'extra': extra or {},
|
||||
},
|
||||
)
|
||||
tx.save()
|
||||
|
||||
try:
|
||||
resp = gateway.Transfer(req)
|
||||
except NotImplementedError as e:
|
||||
tx.MarkFailed(raw_response={'error': str(e)})
|
||||
raise PaymentError(str(e), transaction=tx)
|
||||
|
||||
tx.RawResponse = resp.RawResponse
|
||||
if resp.Success:
|
||||
tx.MarkSuccess(channel_transaction_id=resp.TransferID)
|
||||
else:
|
||||
tx.MarkFailed()
|
||||
|
||||
return tx
|
||||
|
||||
def HandleNotify(self, platform, raw_data, headers=None):
|
||||
"""处理支付回调通知
|
||||
|
||||
Args:
|
||||
platform: 支付平台
|
||||
raw_data: 原始回调数据 (bytes)
|
||||
headers: 请求头字典
|
||||
|
||||
Returns:
|
||||
PaymentTransaction 实例或 None
|
||||
"""
|
||||
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
|
||||
if config is None:
|
||||
logger.error(f'租户未配置支付渠道: {platform}')
|
||||
return None
|
||||
|
||||
creds = self._build_credentials(config)
|
||||
gateway = payment_registry.create(platform, creds)
|
||||
|
||||
if not gateway.VerifyNotify(raw_data, headers or {}):
|
||||
logger.warning(f'支付回调签名验证失败: {platform}')
|
||||
return None
|
||||
|
||||
notify_data = gateway.ParseNotify(raw_data)
|
||||
out_trade_no = notify_data.get('out_trade_no', '')
|
||||
if not out_trade_no:
|
||||
logger.warning('回调缺少 out_trade_no')
|
||||
return None
|
||||
|
||||
try:
|
||||
tx = PaymentTransaction.objects.get(OutTradeNO=out_trade_no)
|
||||
except PaymentTransaction.DoesNotExist:
|
||||
logger.warning(f'回调对应交易不存在: {out_trade_no}')
|
||||
return None
|
||||
|
||||
trade_state = notify_data.get('trade_state', '')
|
||||
transaction_id = notify_data.get('transaction_id', '')
|
||||
|
||||
if trade_state in ('SUCCESS', 'TRADE_SUCCESS', 'TRADE_FINISHED'):
|
||||
tx.MarkSuccess(
|
||||
channel_transaction_id=transaction_id,
|
||||
notify_data=notify_data,
|
||||
)
|
||||
elif trade_state in ('CLOSED', 'REVOKED', 'TRADE_CLOSED'):
|
||||
tx.MarkClosed()
|
||||
elif trade_state in ('REFUND', 'TRADE_REFUND'):
|
||||
tx.TransactionStatus = PaymentTransaction.TX_CLOSED
|
||||
tx.NotifyData = notify_data
|
||||
tx.UpdateTime = datetime.datetime.utcnow()
|
||||
tx.save(update_fields=['TransactionStatus', 'NotifyData', 'UpdateTime'])
|
||||
|
||||
return tx
|
||||
|
||||
def GetGatewayInstance(self, platform):
|
||||
"""直接获取网关实例——供子服务自定义操作
|
||||
|
||||
Args:
|
||||
platform: 支付平台
|
||||
|
||||
Returns:
|
||||
AbstractPaymentGateway 实例
|
||||
|
||||
Raises:
|
||||
PaymentError: 租户未配置支付渠道
|
||||
"""
|
||||
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
|
||||
if config is None:
|
||||
raise PaymentError(f'租户未配置支付渠道: {platform}')
|
||||
|
||||
creds = self._build_credentials(config)
|
||||
return payment_registry.create(platform, creds)
|
||||
|
||||
def _build_credentials(self, config):
|
||||
"""从数据库配置构建 PaymentCredentials"""
|
||||
return PaymentCredentials(
|
||||
AppID=config.AppID,
|
||||
MchID=config.MchID,
|
||||
APIKey=config.DecryptAPIKey(),
|
||||
PrivateKey=config.DecryptPrivateKey(),
|
||||
PublicKey=config.DecryptPublicKey(),
|
||||
CertPath=config.CertPath,
|
||||
CertKeyPath=config.CertKeyPath,
|
||||
NotifyURL=config.NotifyURL,
|
||||
ReturnURL=config.ReturnURL,
|
||||
Extra=config.ConfigExtra,
|
||||
)
|
||||
194
gvsdsdk/payment/models.py
Normal file
194
gvsdsdk/payment/models.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""模块:支付 SAAS 数据模型
|
||||
SAAS 级支付配置与交易流水——不绑定具体业务。
|
||||
子服务自行关联 payment_transaction_id 到自己的订单。"""
|
||||
|
||||
import uuid
|
||||
import datetime
|
||||
from django.db import models
|
||||
from gvsdsdk.model_base import QModel
|
||||
from gvsdsdk.models import UUIDField
|
||||
|
||||
|
||||
class PaymentGatewayConfig(QModel):
|
||||
"""支付网关配置表——每个租户每个渠道一条配置
|
||||
|
||||
存储加密后的支付密钥,子服务通过此表配置自己的支付能力。
|
||||
SAAS 主服务器不关心具体业务,只提供通道。
|
||||
"""
|
||||
|
||||
ConfigUUID = UUIDField(primary_key=True)
|
||||
TenantUUID = UUIDField()
|
||||
Platform = models.CharField(max_length=32)
|
||||
AppID = models.CharField(max_length=64, default='')
|
||||
MchID = models.CharField(max_length=64, default='')
|
||||
APIKeyEncrypted = models.CharField(max_length=512, default='')
|
||||
PrivateKeyEncrypted = models.TextField(default='')
|
||||
PublicKeyEncrypted = models.TextField(default='')
|
||||
CertPath = models.CharField(max_length=512, default='')
|
||||
CertKeyPath = models.CharField(max_length=512, default='')
|
||||
NotifyURL = models.CharField(max_length=512, default='')
|
||||
ReturnURL = models.CharField(max_length=512, default='')
|
||||
ConfigExtra = models.JSONField(default=dict)
|
||||
ConfigStatus = models.SmallIntegerField(default=1)
|
||||
ConfigDefault = models.BooleanField(default=False)
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow)
|
||||
UpdateTime = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'PaymentGatewayConfig'
|
||||
app_label = 'gvsdsdk'
|
||||
verbose_name = '支付网关配置'
|
||||
verbose_name_plural = '支付网关配置'
|
||||
unique_together = [('TenantUUID', 'Platform')]
|
||||
managed = False
|
||||
indexes = [
|
||||
models.Index(fields=['TenantUUID'], name='idx_paycfg_tenant'),
|
||||
models.Index(fields=['Platform'], name='idx_paycfg_platform'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.Platform}@{self.TenantUUID}'
|
||||
|
||||
@property
|
||||
def IsActive(self):
|
||||
return self.ConfigStatus == 1
|
||||
|
||||
def DecryptAPIKey(self):
|
||||
if not self.APIKeyEncrypted:
|
||||
return ''
|
||||
return _decrypt_field(self.APIKeyEncrypted)
|
||||
|
||||
def DecryptPrivateKey(self):
|
||||
if not self.PrivateKeyEncrypted:
|
||||
return ''
|
||||
return _decrypt_field(self.PrivateKeyEncrypted)
|
||||
|
||||
def DecryptPublicKey(self):
|
||||
if not self.PublicKeyEncrypted:
|
||||
return ''
|
||||
return _decrypt_field(self.PublicKeyEncrypted)
|
||||
|
||||
@classmethod
|
||||
def FromTenantAndPlatform(cls, tenant_uuid, platform):
|
||||
try:
|
||||
return cls.objects.get(TenantUUID=tenant_uuid, Platform=platform, ConfigStatus=1)
|
||||
except cls.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class PaymentTransaction(QModel):
|
||||
"""支付交易流水表——SAAS 级统一交易记录
|
||||
|
||||
每笔支付/退款/转账操作都在此记录一条流水。
|
||||
子服务通过此表的 TransactionUUID 关联自己的业务订单。
|
||||
"""
|
||||
|
||||
TX_PAY = 'pay'
|
||||
TX_REFUND = 'refund'
|
||||
TX_TRANSFER = 'transfer'
|
||||
|
||||
TX_TYPE_CHOICES = [
|
||||
(TX_PAY, '支付'),
|
||||
(TX_REFUND, '退款'),
|
||||
(TX_TRANSFER, '转账'),
|
||||
]
|
||||
|
||||
TX_PENDING = 0
|
||||
TX_SUCCESS = 1
|
||||
TX_FAILED = 2
|
||||
TX_CLOSED = 3
|
||||
|
||||
TX_STATUS_CHOICES = [
|
||||
(TX_PENDING, '处理中'),
|
||||
(TX_SUCCESS, '成功'),
|
||||
(TX_FAILED, '失败'),
|
||||
(TX_CLOSED, '已关闭'),
|
||||
]
|
||||
|
||||
TransactionUUID = UUIDField(primary_key=True)
|
||||
TenantUUID = UUIDField()
|
||||
Platform = models.CharField(max_length=32)
|
||||
TransactionType = models.CharField(max_length=16, choices=TX_TYPE_CHOICES)
|
||||
OutTradeNO = models.CharField(max_length=64, unique=True)
|
||||
OutRefundNO = models.CharField(max_length=64, default='')
|
||||
ChannelTransactionID = models.CharField(max_length=128, default='')
|
||||
TotalAmount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
Currency = models.CharField(max_length=10, default='CNY')
|
||||
TransactionStatus = models.SmallIntegerField(
|
||||
default=TX_PENDING, choices=TX_STATUS_CHOICES,
|
||||
)
|
||||
Subject = models.CharField(max_length=256, default='')
|
||||
RawRequest = models.JSONField(default=dict)
|
||||
RawResponse = models.JSONField(default=dict)
|
||||
NotifyData = models.JSONField(default=dict)
|
||||
PayTime = models.DateTimeField(null=True, blank=True)
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow)
|
||||
UpdateTime = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'PaymentTransaction'
|
||||
app_label = 'gvsdsdk'
|
||||
verbose_name = '支付交易流水'
|
||||
verbose_name_plural = '支付交易流水'
|
||||
ordering = ['-CreateTime']
|
||||
managed = False
|
||||
indexes = [
|
||||
models.Index(fields=['TenantUUID', 'CreateTime'], name='idx_paytx_tenant_time'),
|
||||
models.Index(fields=['Platform', 'TransactionStatus'], name='idx_paytx_platform_status'),
|
||||
models.Index(fields=['OutTradeNO'], name='idx_paytx_out_trade_no'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.OutTradeNO
|
||||
|
||||
@property
|
||||
def IsSuccess(self):
|
||||
return self.TransactionStatus == self.TX_SUCCESS
|
||||
|
||||
def MarkSuccess(self, channel_transaction_id='', pay_time=None, notify_data=None):
|
||||
self.TransactionStatus = self.TX_SUCCESS
|
||||
self.ChannelTransactionID = channel_transaction_id
|
||||
self.PayTime = pay_time or datetime.datetime.utcnow()
|
||||
if notify_data:
|
||||
self.NotifyData = notify_data
|
||||
self.UpdateTime = datetime.datetime.utcnow()
|
||||
self.save(update_fields=[
|
||||
'TransactionStatus', 'ChannelTransactionID',
|
||||
'PayTime', 'NotifyData', 'UpdateTime',
|
||||
])
|
||||
|
||||
def MarkFailed(self, raw_response=None):
|
||||
self.TransactionStatus = self.TX_FAILED
|
||||
if raw_response:
|
||||
self.RawResponse = raw_response
|
||||
self.UpdateTime = datetime.datetime.utcnow()
|
||||
self.save(update_fields=['TransactionStatus', 'RawResponse', 'UpdateTime'])
|
||||
|
||||
def MarkClosed(self):
|
||||
self.TransactionStatus = self.TX_CLOSED
|
||||
self.UpdateTime = datetime.datetime.utcnow()
|
||||
self.save(update_fields=['TransactionStatus', 'UpdateTime'])
|
||||
|
||||
|
||||
def _decrypt_field(encrypted_value):
|
||||
import base64
|
||||
from django.conf import settings
|
||||
key = getattr(settings, 'PAYMENT_ENCRYPTION_KEY', 'SMANAGE-payment-default-key-32b')
|
||||
try:
|
||||
raw = base64.b64decode(encrypted_value)
|
||||
return bytes(a ^ b for a, b in zip(raw, key.encode() * (len(raw) // len(key.encode()) + 1))).decode('utf-8')
|
||||
except Exception:
|
||||
return encrypted_value
|
||||
|
||||
|
||||
def EncryptField(plain_value):
|
||||
import base64
|
||||
from django.conf import settings
|
||||
if not plain_value:
|
||||
return ''
|
||||
key = getattr(settings, 'PAYMENT_ENCRYPTION_KEY', 'SMANAGE-payment-default-key-32b')
|
||||
raw = bytes(a ^ b for a, b in zip(
|
||||
plain_value.encode('utf-8'),
|
||||
key.encode() * (len(plain_value.encode('utf-8')) // len(key.encode()) + 1),
|
||||
))
|
||||
return base64.b64encode(raw).decode('utf-8')
|
||||
Reference in New Issue
Block a user