加入了 GVSDSDK 模块,进行了 QModel 兼容层的尝试,生产环境可用

This commit is contained in:
2026-06-16 00:13:10 +08:00
parent 9d9cfa53a4
commit 63e0f4edfc
68 changed files with 10256 additions and 1143 deletions

View 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}")

View 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}")

View 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': '生产环境需调用支付宝转账接口'},
)

View 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': '生产环境需调用银行代付接口'},
)

View 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} 不支持转账查询')

View 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()

View 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
View 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: 用户 OpenIDJSAPI 必填)
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
View 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')