Files
TermiNexus f6842ee3c8 "feat(managers): extend commission/payment/position managers
- CommissionEngine: ListRules/DisableRule/ListRateTables/ListEvents
- PaymentManager: ListConfigs/GetConfigByUUID/DisableConfig/SetConfigStatus/DeleteConfig
- PositionManager: HasTemplatePerm"
2026-08-07 02:26:19 +08:00

699 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模块:支付管理器
SAAS 级支付编排器——子服务通过此管理器发起支付操作。
职责:
1. 根据租户UUID + 平台查询 PaymentGatewayConfig
2. 解密密钥构建 PaymentCredentials
3. 从网关注册表获取适配器类,创建网关实例
4. 执行支付操作并记录 PaymentTransaction 流水"""
import uuid
import logging
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.utils import timezone as _timezone
from gvsdsdk.payment.gateways.base import (
PaymentCredentials,
PaymentRequest,
PaymentResponse,
RefundRequest,
RefundResponse,
RefundQueryResponse,
TransferRequest,
TransferResponse,
QueryResponse,
)
from gvsdsdk.payment.gateways.registry import payment_registry
from gvsdsdk.payment.models import (
PaymentGatewayConfig,
PaymentTransaction,
EncryptField,
)
logger = logging.getLogger(__name__)
# 分→元转换除数(WeChat/Bank 回调金额单位为分,需除以 100 转换为元)
_FEN_TO_YUAN_DIVISOR = Decimal('100')
# 回调金额不一致时允许的误差(元),避免浮点精度导致误报
_AMOUNT_TOLERANCE = Decimal('0.01')
class PaymentError(Exception):
"""支付业务异常——业务逻辑错误(如配置缺失、金额非法、交易不存在)"""
def __init__(self, message, transaction=None):
super().__init__(message)
self.Transaction = transaction
class GatewayError(PaymentError):
"""网关通信异常——网关不可达、验签失败、返回非预期响应等
继承 PaymentError,因此 except PaymentError 仍可捕获,
调用方需区分业务错误与网关错误时可单独捕获 GatewayError。
"""
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
# 配置缓存(M10):避免同一 PaymentManager 实例内反复查询数据库
self._ConfigCache: dict = {}
# 凭证缓存(M11):避免同一配置反复解密密钥
self._CredCache: dict = {}
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 实例
"""
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': _timezone.now(),
}
# 密钥类字段:空值时不覆盖已配置的密钥,避免误清空
if not api_key:
defaults.pop('APIKeyEncrypted', None)
if not private_key:
defaults.pop('PrivateKeyEncrypted', None)
if not public_key:
defaults.pop('PublicKeyEncrypted', None)
config, created = PaymentGatewayConfig.objects.update_or_create(
TenantUUID=self.TenantUUID,
Platform=platform,
defaults=defaults,
)
if set_default:
PaymentGatewayConfig.objects.filter(
TenantUUID=self.TenantUUID, ConfigDefault=True,
).update(ConfigDefault=False)
config.ConfigDefault = True
config.save(update_fields=['ConfigDefault'])
# 配置变更后失效缓存,确保后续操作使用新配置
self._ConfigCache.pop(platform, None)
self._CredCache = {k: v for k, v in self._CredCache.items() if k != platform}
logger.info(f'支付网关配置: {platform}@tenant={self.TenantUUID}')
return config
def ListConfigs(self, status=None):
"""列出租户下的支付网关配置(默认按默认渠道优先、平台名排序)
Args:
status: 可选,按配置状态过滤(1=启用,0=禁用)
Returns:
QuerySet[PaymentGatewayConfig]
"""
qs = PaymentGatewayConfig.objects.filter(
TenantUUID=self.TenantUUID,
)
if status is not None:
qs = qs.filter(ConfigStatus=status)
return qs.order_by('-ConfigDefault', 'Platform')
def GetConfigByUUID(self, config_uuid):
"""按 ConfigUUID 查询租户下的配置(不存在返回 None)"""
return PaymentGatewayConfig.objects.filter(
ConfigUUID=config_uuid, TenantUUID=self.TenantUUID,
).first()
def DisableConfig(self, platform):
"""禁用租户指定平台的配置(ConfigStatus=0 并清空更新时间);返回是否命中"""
updated = PaymentGatewayConfig.objects.filter(
TenantUUID=self.TenantUUID, Platform=platform,
).update(ConfigStatus=0, UpdateTime=None)
return updated > 0
def SetConfigStatus(self, config_uuid, status):
"""更新租户指定配置的状态;返回是否命中"""
updated = PaymentGatewayConfig.objects.filter(
ConfigUUID=config_uuid, TenantUUID=self.TenantUUID,
).update(ConfigStatus=status)
return updated > 0
def DeleteConfig(self, config_uuid):
"""删除租户指定配置;返回是否命中"""
deleted, _ = PaymentGatewayConfig.objects.filter(
ConfigUUID=config_uuid, TenantUUID=self.TenantUUID,
).delete()
return deleted > 0
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 = self._GetConfig(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()}'
try:
total_amount = Decimal(str(amount))
except (InvalidOperation, ValueError, TypeError):
raise PaymentError(f'金额格式非法: {amount!r}')
if total_amount <= 0:
raise PaymentError('金额必须大于 0')
req = PaymentRequest(
OutTradeNO=out_trade_no,
TotalAmount=total_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=total_amount,
Subject=subject,
RawRequest={
'method': method,
'open_id': open_id,
'extra': extra or {},
},
)
tx.save()
try:
resp = gateway.Pay(req)
except Exception as e:
tx.MarkFailed(raw_response={'error': str(e)})
raise GatewayError('支付发起失败', transaction=tx)
tx.RawResponse = resp.RawResponse
tx.UpdateTime = _timezone.now()
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 GatewayError('支付发起失败', 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, TenantUUID=self.TenantUUID,
)
except PaymentTransaction.DoesNotExist:
raise PaymentError(f'交易记录不存在: {out_trade_no}')
platform = platform or tx.Platform
config = self._GetConfig(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 tx.TransactionStatus == PaymentTransaction.TX_PENDING:
# 仅允许从中间态(PENDING)回退到成功——已关闭/已失败为终态,拒绝回退
# 注意:PaymentTransaction 仅定义 TX_PENDING/TX_SUCCESS/TX_FAILED/TX_CLOSED
tx.MarkSuccess(
channel_transaction_id=result.TransactionID,
notify_data={'query_result': result.RawResponse},
)
elif result.IsRefunded and tx.TransactionStatus != PaymentTransaction.TX_CLOSED:
tx.MarkClosed(notify_data={'query_result': result.RawResponse})
return result
def Refund(self, out_trade_no, amount, reason='', platform=None, extra=None):
"""发起退款
Args:
out_trade_no: 商户订单号
amount: 退款金额
reason: 退款原因
platform: 支付平台
extra: 额外参数
Returns:
PaymentTransaction 实例
"""
out_refund_no = f'RF-{uuid.uuid4().hex[:16].upper()}'
try:
amount = Decimal(str(amount))
except (InvalidOperation, ValueError, TypeError):
raise PaymentError(f'金额格式非法: {amount!r}')
if amount <= 0:
raise PaymentError('金额必须大于 0')
# 累计退款校验(加行锁防止并发退款竞态)
from django.db.models import Sum as _Sum
with transaction.atomic():
# select_for_update 锁定原始交易行,防止并发退款超额;
# 同时承担原始交易查询职责,避免事务外无锁查询结果过期
# H3: 加入 TenantUUID 过滤,防止跨租户访问
try:
pay_tx = PaymentTransaction.objects.select_for_update().get(
OutTradeNO=out_trade_no,
TenantUUID=self.TenantUUID,
TransactionType=PaymentTransaction.TX_PAY,
)
except PaymentTransaction.DoesNotExist:
raise PaymentError(f'原始交易不存在: {out_trade_no}')
if not pay_tx.IsSuccess:
raise PaymentError(f'原始交易未支付成功,无法退款: {out_trade_no}')
if amount > pay_tx.TotalAmount:
raise PaymentError(f'退款金额超过原始交易金额: {amount} > {pay_tx.TotalAmount}')
platform = platform or pay_tx.Platform
config = self._GetConfig(platform)
if config is None:
raise PaymentError(f'租户未配置支付渠道: {platform}')
creds = self._build_credentials(config)
gateway = payment_registry.create(platform, creds)
# M6: 累计退款校验加入 TenantUUID 防护(跨租户场景下不会累加其他租户退款)
already_refunded = PaymentTransaction.objects.filter(
OutTradeNO=out_trade_no,
TenantUUID=self.TenantUUID,
TransactionType=PaymentTransaction.TX_REFUND,
TransactionStatus__in=[
PaymentTransaction.TX_SUCCESS,
PaymentTransaction.TX_PENDING,
],
).aggregate(total=_Sum('TotalAmount'))['total'] or Decimal('0')
if already_refunded + amount > pay_tx.TotalAmount:
raise PaymentError(f'累计退款金额超过原始交易金额: {already_refunded + 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()
try:
resp = gateway.Refund(req)
except Exception as e:
rx.MarkFailed(raw_response={'error': str(e)})
raise GatewayError('退款发起失败', transaction=rx)
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 = self._GetConfig(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()}'
try:
amount = Decimal(str(amount))
except (InvalidOperation, ValueError, TypeError):
raise PaymentError(f'金额格式非法: {amount!r}')
if amount <= 0:
raise PaymentError('金额必须大于 0')
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()
# H1: 捕获所有异常(不仅是 NotImplementedError),确保网关异常时回写交易状态
try:
resp = gateway.Transfer(req)
except Exception as e:
tx.MarkFailed(raw_response={'error': str(e)})
raise GatewayError(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 = self._GetConfig(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
# H4: notify_id 幂等去重——防止 Alipay 重放攻击(同一 notify_id 24h 内只处理一次)
notify_id = notify_data.get('notify_id', '')
if notify_id:
try:
from django.core.cache import cache
cache_key = f'payment:notify_id:{notify_id}'
if not cache.add(cache_key, '1', timeout=86400):
logger.info(f'回调重复:notify_id={notify_id} 已处理过')
try:
return PaymentTransaction.objects.get(
OutTradeNO=out_trade_no, TenantUUID=self.TenantUUID,
)
except PaymentTransaction.DoesNotExist:
return None
except Exception as exc:
logger.warning(f'缓存不可用,跳过 notify_id 去重: {exc}')
trade_state = notify_data.get('trade_state', '')
transaction_id = notify_data.get('transaction_id', '')
# H2: TOCTOU 修复——使用 select_for_update 锁定交易行,防止并发回调重复结算
# H3: 加入 TenantUUID 过滤,防止跨租户访问
with transaction.atomic():
try:
tx = PaymentTransaction.objects.select_for_update().get(
OutTradeNO=out_trade_no, TenantUUID=self.TenantUUID,
)
except PaymentTransaction.DoesNotExist:
logger.warning(f'回调对应交易不存在: {out_trade_no}')
return None
if trade_state in ('SUCCESS', 'TRADE_SUCCESS', 'TRADE_FINISHED'):
# 幂等性检查:已经是成功状态则直接返回,避免重复回调
if tx.TransactionStatus == PaymentTransaction.TX_SUCCESS:
logger.info(f'回调重复:交易 {out_trade_no} 已是成功状态')
return tx
# 终态保护:已关闭的交易不允许回退到成功
if tx.TransactionStatus == PaymentTransaction.TX_CLOSED:
logger.warning(f'回调异常:交易 {out_trade_no} 已关闭,拒绝回退到成功')
return tx
# 金额校验——防止回调金额与原交易不一致
# 单位差异:WeChat/Bank 回调金额为分(整数),Alipay total_amount 为元(字符串)
notify_amount = notify_data.get('amount', {}).get('total') or notify_data.get('total_amount')
if notify_amount is not None:
try:
notify_decimal = Decimal(str(notify_amount))
# WeChat/Bank 回调金额单位为分,需除以 100 转换为元
if tx.Platform in ('wechat', 'bank'):
notify_decimal = notify_decimal / _FEN_TO_YUAN_DIVISOR
if abs(notify_decimal - tx.TotalAmount) > _AMOUNT_TOLERANCE:
logger.warning(f'回调金额不一致: {notify_decimal} != {tx.TotalAmount}, 交易 {out_trade_no}')
return tx
except (ValueError, TypeError, InvalidOperation):
pass
tx.MarkSuccess(
channel_transaction_id=transaction_id,
notify_data=notify_data,
)
elif trade_state in ('CLOSED', 'REVOKED', 'TRADE_CLOSED'):
tx.MarkClosed(notify_data=notify_data)
elif trade_state in ('REFUND', 'TRADE_REFUND'):
# 幂等性检查:已经是关闭状态则直接返回(退款回调可能重复推送)
if tx.TransactionStatus == PaymentTransaction.TX_CLOSED:
logger.info(f'回调重复:交易 {out_trade_no} 已是关闭/退款状态')
return tx
# 终态保护:已成功的交易退款后回退到关闭,允许该转换
tx.MarkClosed(notify_data=notify_data)
# 更新对应的退款记录状态——按 out_refund_no 查找 TX_REFUND 记录
# 回退兼容:Alipay/Bank 退款异步通知使用 out_request_no 字段,WeChat 使用 out_refund_no
# H3: 加入 TenantUUID 过滤,防止跨租户访问
out_refund_no = notify_data.get('out_refund_no', '') or notify_data.get('out_request_no', '')
if out_refund_no:
try:
refund_tx = PaymentTransaction.objects.select_for_update().get(
OutRefundNO=out_refund_no,
TenantUUID=self.TenantUUID,
TransactionType=PaymentTransaction.TX_REFUND,
)
if refund_tx.TransactionStatus != PaymentTransaction.TX_SUCCESS:
refund_tx.MarkSuccess(
channel_transaction_id=transaction_id,
notify_data=notify_data,
)
except PaymentTransaction.DoesNotExist:
logger.warning(f'退款记录不存在: {out_refund_no}')
return tx
def GetGatewayInstance(self, platform):
"""直接获取网关实例——供子服务自定义操作
Args:
platform: 支付平台
Returns:
AbstractPaymentGateway 实例
Raises:
PaymentError: 租户未配置支付渠道
"""
config = self._GetConfig(platform)
if config is None:
raise PaymentError(f'租户未配置支付渠道: {platform}')
creds = self._build_credentials(config)
return payment_registry.create(platform, creds)
def QueryRefund(self, out_refund_no, platform=None):
"""查询退款状态
Args:
out_refund_no: 商户退款单号
platform: 支付平台(不传则从退款记录推断)
Returns:
RefundQueryResponse
Raises:
PaymentError: 退款记录不存在或租户未配置渠道
GatewayError: 网关查询失败
"""
# H3: 加入 TenantUUID 过滤,防止跨租户访问
try:
tx = PaymentTransaction.objects.get(
OutRefundNO=out_refund_no, TenantUUID=self.TenantUUID,
)
except PaymentTransaction.DoesNotExist:
raise PaymentError(f'退款记录不存在: {out_refund_no}')
platform = platform or tx.Platform
config = self._GetConfig(platform)
if config is None:
raise PaymentError(f'租户未配置支付渠道: {platform}')
creds = self._build_credentials(config)
gateway = payment_registry.create(platform, creds)
try:
return gateway.QueryRefund(out_refund_no)
except NotImplementedError as e:
raise PaymentError(str(e), transaction=tx)
except Exception as e:
raise GatewayError(f'退款查询失败: {e}', transaction=tx)
def _GetConfig(self, platform):
"""获取支付网关配置——带实例级缓存(M10),避免同一实例内反复查询数据库"""
if platform not in self._ConfigCache:
self._ConfigCache[platform] = PaymentGatewayConfig.FromTenantAndPlatform(
self.TenantUUID, platform
)
return self._ConfigCache[platform]
def _build_credentials(self, config):
"""从数据库配置构建 PaymentCredentials——带实例级缓存(M11),避免反复解密"""
cache_key = config.Platform
if cache_key in self._CredCache:
return self._CredCache[cache_key]
creds = 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,
)
self._CredCache[cache_key] = creds
return creds