支持自动提现多商户轮换:配置入库、打款单强制记 mch_id、收款失败自动换号。

- 新增 wechat_pay_mch_config 表,迁移时从 settings 种子默认商户
- TixianAutoRecord 增加 mch_id;查单/回调解密后按记录对齐
- 后台接口 wxmchlb/wxmchbj/wxmchscwj(权限 8080a)与证书上传

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-14 02:44:38 +08:00
parent c3bab3564a
commit 685c65691f
15 changed files with 760 additions and 222 deletions

View File

@@ -626,5 +626,8 @@ WECHAT_PAY_V3_CONFIG = {
'TRANSFER_SCENE_ID': '1005', # 替换为你的实际场景ID
}
# 多商户证书上传根目录(每个商户一个子目录:{BASE}/{mch_id}/
WECHAT_MCH_CERT_BASE_DIR = '/opt/1panel/apps/openresty/openresty/www/sites/119.28.221.247.1199/huizhifuzhengshu'
CROSS_PLATFORM_SYNC_TOKEN = 'xK9mP2vL8qW5rT7y'

View File

View File

View File

@@ -0,0 +1,29 @@
"""
将 settings.WECHAT_PAY_V3_CONFIG 迁移进 wechat_pay_mch_config 表。
用法:
python manage.py seed_wechat_mch_from_settings
python manage.py seed_wechat_mch_from_settings --force
"""
from django.core.management.base import BaseCommand
from utils.wechat_mch_service import seed_from_settings
class Command(BaseCommand):
help = 'Seed WechatPayMchConfig from settings.WECHAT_PAY_V3_CONFIG'
def add_arguments(self, parser):
parser.add_argument(
'--force',
action='store_true',
help='覆盖同商户号已有记录',
)
def handle(self, *args, **options):
cfg = seed_from_settings(force=bool(options.get('force')))
if not cfg:
self.stderr.write(self.style.ERROR('settings.WECHAT_PAY_V3_CONFIG 不完整,未写入'))
return
self.stdout.write(self.style.SUCCESS(
f"OK mch_id={cfg.get('MCHID')} source={cfg.get('_source')} path={cfg.get('PRIVATE_KEY_PATH')}"
))

View File

@@ -0,0 +1,70 @@
# Generated manually for multi WeChat merchant transfer configs
from django.db import migrations, models
def seed_default_mch(apps, schema_editor):
WechatPayMchConfig = apps.get_model('peizhi', 'WechatPayMchConfig')
if WechatPayMchConfig.objects.exists():
return
# 运行时从 settings 导入,避免迁移环境无配置时炸掉
try:
from django.conf import settings
raw = getattr(settings, 'WECHAT_PAY_V3_CONFIG', {}) or {}
except Exception:
return
mch_id = str(raw.get('MCHID') or '').strip()
appid = str(raw.get('APPID') or '').strip()
api_v3_key = str(raw.get('API_V3_KEY') or '').strip()
private_key_path = str(raw.get('PRIVATE_KEY_PATH') or '').strip()
cert_serial_no = str(raw.get('CERT_SERIAL_NO') or '').strip()
if not all([mch_id, appid, api_v3_key, private_key_path, cert_serial_no]):
return
WechatPayMchConfig.objects.create(
mch_id=mch_id,
name='默认商户(自settings迁移)',
appid=appid,
api_v3_key=api_v3_key,
private_key_path=private_key_path,
cert_serial_no=cert_serial_no,
platform_pub_key_path=str(raw.get('PLATFORM_CERT_DIR') or '').strip(),
notify_url=str(raw.get('NOTIFY_URL') or '').strip(),
transfer_scene_id=str(raw.get('TRANSFER_SCENE_ID') or '1005').strip() or '1005',
priority=1,
enabled=True,
)
class Migration(migrations.Migration):
dependencies = [
('peizhi', '0021_withdrawconfig_dongjie_quanju_enabled'),
]
operations = [
migrations.CreateModel(
name='WechatPayMchConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mch_id', models.CharField(db_index=True, max_length=32, unique=True, verbose_name='商户号')),
('name', models.CharField(blank=True, default='', max_length=100, verbose_name='备注名称')),
('appid', models.CharField(max_length=64, verbose_name='小程序AppID')),
('api_v3_key', models.CharField(max_length=64, verbose_name='APIv3密钥')),
('private_key_path', models.CharField(max_length=500, verbose_name='商户私钥pem路径')),
('cert_serial_no', models.CharField(max_length=128, verbose_name='商户证书序列号')),
('platform_pub_key_path', models.CharField(blank=True, default='', max_length=500, verbose_name='微信平台公钥pem路径')),
('notify_url', models.CharField(blank=True, default='', max_length=500, verbose_name='转账回调URL')),
('transfer_scene_id', models.CharField(default='1005', max_length=32, verbose_name='转账场景ID')),
('priority', models.IntegerField(db_index=True, default=100, verbose_name='优先级(越小越优先)')),
('enabled', models.BooleanField(db_index=True, default=True, verbose_name='是否启用')),
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '微信转账商户配置',
'verbose_name_plural': '微信转账商户配置',
'db_table': 'wechat_pay_mch_config',
'ordering': ['priority', 'id'],
},
),
migrations.RunPython(seed_default_mch, migrations.RunPython.noop),
]

View File

@@ -807,4 +807,35 @@ class XcxXieyiQianshu(models.Model):
verbose_name_plural = verbose_name
indexes = [
models.Index(fields=['yonghuid', 'xieyi']),
]
]
class WechatPayMchConfig(models.Model):
"""
微信商家转账(自动提现收款)商户号配置。
收款发起按 priority 升序轮换;打款记录必须写入 mch_id回调/查单按记录反查。
"""
mch_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='商户号')
name = models.CharField(max_length=100, blank=True, default='', verbose_name='备注名称')
appid = models.CharField(max_length=64, verbose_name='小程序AppID')
api_v3_key = models.CharField(max_length=64, verbose_name='APIv3密钥')
private_key_path = models.CharField(max_length=500, verbose_name='商户私钥pem路径')
cert_serial_no = models.CharField(max_length=128, verbose_name='商户证书序列号')
platform_pub_key_path = models.CharField(
max_length=500, blank=True, default='', verbose_name='微信平台公钥pem路径'
)
notify_url = models.CharField(max_length=500, blank=True, default='', verbose_name='转账回调URL')
transfer_scene_id = models.CharField(max_length=32, default='1005', verbose_name='转账场景ID')
priority = models.IntegerField(default=100, db_index=True, verbose_name='优先级(越小越优先)')
enabled = models.BooleanField(default=True, db_index=True, verbose_name='是否启用')
create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
db_table = 'wechat_pay_mch_config'
verbose_name = '微信转账商户配置'
verbose_name_plural = verbose_name
ordering = ['priority', 'id']
def __str__(self):
return f'{self.mch_id}({self.name or "-"})'

View File

@@ -11,6 +11,7 @@ from .xcx_config_views import (
XcxSysPeizhiQueryView, XcxSysPeizhiUpdateView,
XcxPageZiyuanQueryView, XcxPageZiyuanUpdateView, XcxPageZiyuanUploadView,
)
from .wx_mch_views import WechatMchListView, WechatMchSaveView, WechatMchUploadView
from .xieyi_views import XieyiListView, XieyiDetailView, XieyiSignStatusView, XieyiSignView, XieyiCheckView
urlpatterns = [
@@ -53,6 +54,9 @@ urlpatterns = [
path('xcxyzycx', XcxPageZiyuanQueryView.as_view(), name='管理员查询页面资源'),
path('xcxyzgx', XcxPageZiyuanUpdateView.as_view(), name='管理员更新页面资源'),
path('xcxyzsc', XcxPageZiyuanUploadView.as_view(), name='管理员上传页面资源'),
path('wxmchlb', WechatMchListView.as_view(), name='获取转账商户列表'),
path('wxmchbj', WechatMchSaveView.as_view(), name='保存转账商户配置'),
path('wxmchscwj', WechatMchUploadView.as_view(), name='上传转账商户证书'),
path('xieyihq', XieyiListView.as_view(), name='小程序获取协议列表'),
path('xieyixq', XieyiDetailView.as_view(), name='小程序获取协议详情'),
path('xieyijc', XieyiCheckView.as_view(), name='小程序检查协议签署'),

204
peizhi/wx_mch_views.py Normal file
View File

@@ -0,0 +1,204 @@
"""后台 — 微信转账多商户配置(小程序配置权限 8080a"""
import logging
import os
import re
from django.db import transaction
from rest_framework import permissions
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from peizhi.models import WechatPayMchConfig
from peizhi.xcx_config_views import _authorize_miniapp_config, _mask_secret
from utils.wechat_mch_service import cert_base_dir, seed_from_settings, wx_cfg_is_complete, row_to_wx_cfg
logger = logging.getLogger(__name__)
_SAFE_NAME = re.compile(r'[^A-Za-z0-9._-]+')
def _serialize_row(row, hide_secret=True):
return {
'id': row.id,
'mch_id': row.mch_id,
'name': row.name or '',
'appid': row.appid or '',
'api_v3_key': _mask_secret(row.api_v3_key) if hide_secret else (row.api_v3_key or ''),
'api_v3_key_set': bool(row.api_v3_key),
'private_key_path': row.private_key_path or '',
'cert_serial_no': row.cert_serial_no or '',
'platform_pub_key_path': row.platform_pub_key_path or '',
'notify_url': row.notify_url or '',
'transfer_scene_id': row.transfer_scene_id or '1005',
'priority': row.priority,
'enabled': bool(row.enabled),
'private_key_exists': bool(row.private_key_path and os.path.isfile(row.private_key_path)),
'platform_pub_key_exists': bool(
row.platform_pub_key_path and os.path.isfile(row.platform_pub_key_path)
),
'create_time': row.create_time.strftime('%Y-%m-%d %H:%M:%S') if row.create_time else '',
'update_time': row.update_time.strftime('%Y-%m-%d %H:%M:%S') if row.update_time else '',
}
class WechatMchListView(APIView):
"""POST /peizhi/wxmchlb — 获取全部转账商户配置"""
permission_classes = [permissions.IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
ok, err = _authorize_miniapp_config(request)
if not ok:
return err
# 若表为空,尝试把 settings 默认商户写进表(仅一次)
if not WechatPayMchConfig.objects.exists():
try:
seed_from_settings(force=False)
except Exception as e:
logger.warning('seed wechat mch from settings failed: %s', e)
rows = WechatPayMchConfig.objects.all().order_by('priority', 'id')
return Response({
'code': 0,
'data': {
'list': [_serialize_row(r) for r in rows],
'cert_base_dir': cert_base_dir(),
},
})
class WechatMchSaveView(APIView):
"""POST /peizhi/wxmchbj — 新增或更新商户配置"""
permission_classes = [permissions.IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
ok, err = _authorize_miniapp_config(request)
if not ok:
return err
data = request.data
row_id = data.get('id')
mch_id = str(data.get('mch_id') or '').strip()
if not mch_id:
return Response({'code': 400, 'msg': '商户号不能为空'})
fields = {
'name': str(data.get('name') or '').strip()[:100],
'appid': str(data.get('appid') or '').strip(),
'private_key_path': str(data.get('private_key_path') or '').strip(),
'cert_serial_no': str(data.get('cert_serial_no') or '').strip(),
'platform_pub_key_path': str(data.get('platform_pub_key_path') or '').strip(),
'notify_url': str(data.get('notify_url') or '').strip(),
'transfer_scene_id': str(data.get('transfer_scene_id') or '1005').strip() or '1005',
'priority': int(data.get('priority') or 100),
'enabled': bool(data.get('enabled', True)),
}
api_v3_key = data.get('api_v3_key')
if api_v3_key is not None and str(api_v3_key).strip() and '****' not in str(api_v3_key):
fields['api_v3_key'] = str(api_v3_key).strip()
try:
with transaction.atomic():
if row_id:
row = WechatPayMchConfig.objects.select_for_update().get(id=int(row_id))
if row.mch_id != mch_id:
if WechatPayMchConfig.objects.filter(mch_id=mch_id).exclude(id=row.id).exists():
return Response({'code': 400, 'msg': '商户号已存在'})
row.mch_id = mch_id
for k, v in fields.items():
setattr(row, k, v)
if 'api_v3_key' in fields:
row.api_v3_key = fields['api_v3_key']
row.save()
else:
if WechatPayMchConfig.objects.filter(mch_id=mch_id).exists():
return Response({'code': 400, 'msg': '商户号已存在'})
if 'api_v3_key' not in fields or not fields.get('api_v3_key'):
return Response({'code': 400, 'msg': '新建必须填写 APIv3 密钥'})
create_data = {'mch_id': mch_id, **fields}
row = WechatPayMchConfig.objects.create(**create_data)
except WechatPayMchConfig.DoesNotExist:
return Response({'code': 404, 'msg': '记录不存在'})
except (TypeError, ValueError) as e:
return Response({'code': 400, 'msg': f'参数错误: {e}'})
cfg = row_to_wx_cfg(row)
warn = '' if wx_cfg_is_complete(cfg) else '配置尚未完整,收款时该户会被跳过'
return Response({
'code': 0,
'msg': '保存成功' + (f'(提示:{warn}' if warn else ''),
'data': _serialize_row(row),
})
class WechatMchUploadView(APIView):
"""
POST /peizhi/wxmchscwj — 上传商户证书/密钥文件
form-data: username, mch_id, file_type(private_key|platform_pub_key), file
"""
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
ALLOWED_TYPES = {
'private_key': 'apiclient_key.pem',
'platform_pub_key': 'pub_key.pem',
}
def post(self, request):
ok, err = _authorize_miniapp_config(request)
if not ok:
return err
mch_id = str(request.data.get('mch_id') or '').strip()
file_type = str(request.data.get('file_type') or '').strip()
upload = request.FILES.get('file')
if not mch_id:
return Response({'code': 400, 'msg': 'mch_id 不能为空'})
if file_type not in self.ALLOWED_TYPES:
return Response({'code': 400, 'msg': 'file_type 仅支持 private_key / platform_pub_key'})
if not upload:
return Response({'code': 400, 'msg': '请上传文件'})
# 安全文件名:优先固定名,避免多商户同名冲突(已按 mch_id 分目录)
raw_name = os.path.basename(upload.name or self.ALLOWED_TYPES[file_type])
safe = _SAFE_NAME.sub('_', raw_name).strip('._') or self.ALLOWED_TYPES[file_type]
if not safe.lower().endswith('.pem'):
safe = f'{safe}.pem'
folder = os.path.join(cert_base_dir(), mch_id)
os.makedirs(folder, exist_ok=True)
# 固定标准文件名,更稳
dest_name = self.ALLOWED_TYPES[file_type]
dest_path = os.path.join(folder, dest_name)
try:
with open(dest_path, 'wb') as f:
for chunk in upload.chunks():
f.write(chunk)
except OSError as e:
logger.error('上传商户证书失败 mch=%s err=%s', mch_id, e, exc_info=True)
return Response({'code': 500, 'msg': f'写入失败: {e}'})
# 若该商户已有配置行,自动回写路径
row = WechatPayMchConfig.objects.filter(mch_id=mch_id).first()
if row:
if file_type == 'private_key':
row.private_key_path = dest_path
row.save(update_fields=['private_key_path', 'update_time'])
else:
row.platform_pub_key_path = dest_path
row.save(update_fields=['platform_pub_key_path', 'update_time'])
return Response({
'code': 0,
'msg': '上传成功',
'data': {
'mch_id': mch_id,
'file_type': file_type,
'saved_path': dest_path,
'original_name': safe,
},
})

154
utils/wechat_mch_service.py Normal file
View File

@@ -0,0 +1,154 @@
"""
微信商家转账商户配置服务。
配置来源peizhi.WechatPayMchConfig无启用记录时回退 settings.WECHAT_PAY_V3_CONFIG。
"""
from __future__ import annotations
import logging
import os
from typing import Dict, List, Optional
from django.conf import settings
logger = logging.getLogger('utils.wechat_mch')
def _settings_fallback_cfg() -> Dict:
raw = getattr(settings, 'WECHAT_PAY_V3_CONFIG', {}) or {}
return {
'MCHID': str(raw.get('MCHID') or '').strip(),
'APPID': str(raw.get('APPID') or '').strip(),
'API_V3_KEY': str(raw.get('API_V3_KEY') or '').strip(),
'PRIVATE_KEY_PATH': str(raw.get('PRIVATE_KEY_PATH') or '').strip(),
'CERT_SERIAL_NO': str(raw.get('CERT_SERIAL_NO') or '').strip(),
'PLATFORM_PUB_KEY_PATH': str(raw.get('PLATFORM_CERT_DIR') or '').strip(),
'NOTIFY_URL': str(raw.get('NOTIFY_URL') or '').strip(),
'TRANSFER_SCENE_ID': str(raw.get('TRANSFER_SCENE_ID') or '1005').strip(),
'_source': 'settings',
}
def row_to_wx_cfg(row) -> Dict:
return {
'MCHID': str(row.mch_id or '').strip(),
'APPID': str(row.appid or '').strip(),
'API_V3_KEY': str(row.api_v3_key or '').strip(),
'PRIVATE_KEY_PATH': str(row.private_key_path or '').strip(),
'CERT_SERIAL_NO': str(row.cert_serial_no or '').strip(),
'PLATFORM_PUB_KEY_PATH': str(row.platform_pub_key_path or '').strip(),
'NOTIFY_URL': str(row.notify_url or '').strip(),
'TRANSFER_SCENE_ID': str(row.transfer_scene_id or '1005').strip(),
'_source': 'db',
'_db_id': row.id,
'_name': row.name or '',
'_priority': row.priority,
}
def wx_cfg_is_complete(cfg: Optional[Dict]) -> bool:
if not cfg:
return False
required = ('MCHID', 'APPID', 'API_V3_KEY', 'PRIVATE_KEY_PATH', 'CERT_SERIAL_NO', 'TRANSFER_SCENE_ID')
return all(str(cfg.get(k) or '').strip() for k in required)
def list_enabled_wx_cfgs() -> List[Dict]:
"""启用商户按 priority 升序;库空时回退 settings 单户。"""
from peizhi.models import WechatPayMchConfig
rows = list(
WechatPayMchConfig.objects.filter(enabled=True).order_by('priority', 'id')
)
result = []
for row in rows:
cfg = row_to_wx_cfg(row)
if wx_cfg_is_complete(cfg):
result.append(cfg)
else:
logger.warning('商户配置不完整已跳过 mch_id=%s id=%s', row.mch_id, row.id)
if result:
return result
fb = _settings_fallback_cfg()
if wx_cfg_is_complete(fb):
logger.warning('无启用商户配置,回退 settings.WECHAT_PAY_V3_CONFIG mch=%s', fb.get('MCHID'))
return [fb]
return []
def get_wx_cfg_by_mch_id(mch_id: str) -> Optional[Dict]:
mch_id = (mch_id or '').strip()
if not mch_id:
return None
from peizhi.models import WechatPayMchConfig
row = WechatPayMchConfig.objects.filter(mch_id=mch_id).first()
if row:
cfg = row_to_wx_cfg(row)
return cfg if wx_cfg_is_complete(cfg) else None
fb = _settings_fallback_cfg()
if fb.get('MCHID') == mch_id and wx_cfg_is_complete(fb):
return fb
return None
def get_default_wx_cfg() -> Optional[Dict]:
cfgs = list_enabled_wx_cfgs()
return cfgs[0] if cfgs else None
def resolve_wx_cfg_for_bill(out_bill_no: str) -> Optional[Dict]:
"""查单/对账:必须优先用打款记录上的 mch_id。"""
from yonghu.models import TixianAutoRecord
rec = (
TixianAutoRecord.objects.filter(tixian_id=out_bill_no)
.only('mch_id')
.first()
)
if rec and rec.mch_id:
cfg = get_wx_cfg_by_mch_id(rec.mch_id)
if cfg:
return cfg
logger.error('打款单 mch_id 无对应配置 bill=%s mch_id=%s', out_bill_no, rec.mch_id)
return get_default_wx_cfg()
def cert_base_dir() -> str:
raw = getattr(settings, 'WECHAT_MCH_CERT_BASE_DIR', '') or ''
if raw:
return raw
fb = _settings_fallback_cfg().get('PRIVATE_KEY_PATH') or ''
if fb:
return os.path.dirname(fb)
return os.path.join(settings.BASE_DIR, 'wechat_mch_certs')
def seed_from_settings(force: bool = False) -> Optional[Dict]:
"""将 settings.WECHAT_PAY_V3_CONFIG 写入表;已存在同 mch_id 时默认跳过。"""
from peizhi.models import WechatPayMchConfig
fb = _settings_fallback_cfg()
if not wx_cfg_is_complete(fb):
return None
exists = WechatPayMchConfig.objects.filter(mch_id=fb['MCHID']).first()
if exists and not force:
return row_to_wx_cfg(exists)
row, _ = WechatPayMchConfig.objects.update_or_create(
mch_id=fb['MCHID'],
defaults={
'name': '默认商户(自settings迁移)',
'appid': fb['APPID'],
'api_v3_key': fb['API_V3_KEY'],
'private_key_path': fb['PRIVATE_KEY_PATH'],
'cert_serial_no': fb['CERT_SERIAL_NO'],
'platform_pub_key_path': fb['PLATFORM_PUB_KEY_PATH'],
'notify_url': fb['NOTIFY_URL'],
'transfer_scene_id': fb['TRANSFER_SCENE_ID'] or '1005',
'priority': 1,
'enabled': True,
},
)
return row_to_wx_cfg(row)

View File

@@ -1,150 +1,52 @@
# utils/wechat_v3.py
'''import time
import hashlib
"""
微信支付 V3 签名 / 验签 / 回调解密。
支持传入商户配置 dict未传时使用 wechat_mch_service 默认配置表优先settings 兜底)。
"""
import base64
import json
from urllib.parse import urlparse
import rsa
from Crypto.Cipher import AES
from django.conf import settings
def load_private_key():
"""加载商户私钥PEM格式"""
with open(settings.WECHAT_PAY_V3_CONFIG['PRIVATE_KEY_PATH'], 'rb') as f:
key_data = f.read()
return rsa.PrivateKey.load_pkcs1(key_data)
def build_authorization(method, url, body):
"""
生成V3接口的Authorization头
:param method: GET/POST
:param url: 完整URL含域名和路径
:param body: 请求体JSON字符串
:return: Authorization字符串
"""
private_key = load_private_key()
mchid = settings.WECHAT_PAY_V3_CONFIG['MCHID']
serial_no = settings.WECHAT_PAY_V3_CONFIG['CERT_SERIAL_NO']
# 提取URL路径不含query
parsed = urlparse(url)
path = parsed.path
if parsed.query:
path += '?' + parsed.query
timestamp = str(int(time.time()))
nonce = hashlib.md5((timestamp + 'wechat').encode()).hexdigest()[:32]
# 构造签名串
message = '\n'.join([method, path, timestamp, nonce, body]) + '\n'
# 使用私钥签名SHA256
signature = rsa.sign(message.encode('utf-8'), private_key, 'SHA-256')
signature_b64 = base64.b64encode(signature).decode('utf-8')
# 组装Authorization
auth = f'WECHATPAY2-SHA256-RSA2048 mchid="{mchid}",nonce_str="{nonce}",timestamp="{timestamp}",serial_no="{serial_no}",signature="{signature_b64}"'
return auth
def verify_wechat_sign(headers, body):
"""
验证微信回调的签名
需要提前从微信平台下载证书公钥,存放在 PLATFORM_CERT_DIR 下,文件名 = 序列号.pem
:param headers: 请求头
:param body: 原始请求体字符串
:return: bool
"""
serial = headers.get('Wechatpay-Serial')
signature = headers.get('Wechatpay-Signature')
timestamp = headers.get('Wechatpay-Timestamp')
nonce = headers.get('Wechatpay-Nonce')
if not all([serial, signature, timestamp, nonce]):
return False
# 构造验签名串
message = '\n'.join([timestamp, nonce, body]) + '\n'
# 读取对应序列号的平台证书公钥
cert_path = f"{settings.WECHAT_PAY_V3_CONFIG['PLATFORM_CERT_DIR']}/{serial}.pem"
try:
with open(cert_path, 'rb') as f:
pub_key = rsa.PublicKey.load_pkcs1_openssl_pem(f.read())
except FileNotFoundError:
# 如果证书不存在,可尝试下载(生产环境建议定时下载)
return False
signature_bytes = base64.b64decode(signature)
try:
rsa.verify(message.encode('utf-8'), signature_bytes, pub_key)
return True
except rsa.VerificationError:
return False
def decrypt_callback_ciphertext(associated_data, nonce, ciphertext):
"""
使用APIv3密钥解密回调中的加密数据
:param associated_data: 附加数据
:param nonce: 随机串
:param ciphertext: 密文Base64
:return: 解密后的JSON字符串
"""
api_v3_key = settings.WECHAT_PAY_V3_CONFIG['API_V3_KEY'].encode('utf-8')
nonce_bytes = nonce.encode('utf-8')
ciphertext_bytes = base64.b64decode(ciphertext)
# AES-GCM解密
cipher = AES.new(api_v3_key, AES.MODE_GCM, nonce=nonce_bytes)
if associated_data:
cipher.update(associated_data.encode('utf-8'))
plaintext = cipher.decrypt_and_verify(ciphertext_bytes[:-16], ciphertext_bytes[-16:])
return plaintext.decode('utf-8')'''
# utils/wechat_v3.py
import time
import hashlib
import base64
import json
from urllib.parse import urlparse
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
from Crypto.Cipher import AES
from django.conf import settings
import logging
import os
import time
from urllib.parse import urlparse
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from Crypto.Cipher import AES
logger = logging.getLogger('utils.wechat_v3')
def load_private_key():
def _resolve_cfg(wx_cfg=None):
if wx_cfg:
return wx_cfg
from utils.wechat_mch_service import get_default_wx_cfg
cfg = get_default_wx_cfg()
if not cfg:
raise RuntimeError('无可用微信商户配置')
return cfg
def load_private_key(wx_cfg=None):
"""加载私钥(支持 PKCS#1 和 PKCS#8"""
key_path = settings.WECHAT_PAY_V3_CONFIG['PRIVATE_KEY_PATH']
cfg = _resolve_cfg(wx_cfg)
key_path = cfg['PRIVATE_KEY_PATH']
with open(key_path, 'rb') as f:
key_data = f.read()
# cryptography 自动识别格式
private_key = serialization.load_pem_private_key(
return serialization.load_pem_private_key(
key_data,
password=None,
backend=default_backend()
backend=default_backend(),
)
return private_key
def build_authorization(method, url, body):
"""生成V3接口的Authorization头"""
private_key = load_private_key()
mchid = settings.WECHAT_PAY_V3_CONFIG['MCHID']
serial_no = settings.WECHAT_PAY_V3_CONFIG['CERT_SERIAL_NO']
def build_authorization(method, url, body, wx_cfg=None):
"""生成V3接口的Authorization头wx_cfg 指定商户签名配置。"""
cfg = _resolve_cfg(wx_cfg)
private_key = load_private_key(cfg)
mchid = cfg['MCHID']
serial_no = cfg['CERT_SERIAL_NO']
parsed = urlparse(url)
path = parsed.path
@@ -153,53 +55,74 @@ def build_authorization(method, url, body):
timestamp = str(int(time.time()))
nonce = hashlib.md5((timestamp + 'wechat').encode()).hexdigest()[:32]
message = '\n'.join([method, path, timestamp, nonce, body or '']) + '\n'
# 构造签名串
message = '\n'.join([method, path, timestamp, nonce, body]) + '\n'
# 使用 cryptography 签名
signature = private_key.sign(
message.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
hashes.SHA256(),
)
signature_b64 = base64.b64encode(signature).decode('utf-8')
auth = f'WECHATPAY2-SHA256-RSA2048 mchid="{mchid}",nonce_str="{nonce}",timestamp="{timestamp}",serial_no="{serial_no}",signature="{signature_b64}"'
return auth
return (
f'WECHATPAY2-SHA256-RSA2048 mchid="{mchid}",nonce_str="{nonce}",'
f'timestamp="{timestamp}",serial_no="{serial_no}",signature="{signature_b64}"'
)
def verify_wechat_sign(headers, body):
"""验证微信回调签名(需提前下载平台证书)"""
# 这部分使用 rsa 库验签,也可以改用 cryptography但 rsa 已足够
def _resolve_platform_pub_path(wx_cfg, serial=None):
"""兼容:既可是目录/{serial}.pem也可是直接指向公钥文件路径。"""
base = (wx_cfg or {}).get('PLATFORM_PUB_KEY_PATH') or (wx_cfg or {}).get('PLATFORM_CERT_DIR') or ''
base = str(base).strip()
if not base:
return None
if os.path.isfile(base):
return base
if serial:
candidate = os.path.join(base, f'{serial}.pem')
if os.path.isfile(candidate):
return candidate
return None
def verify_wechat_sign(headers, body, wx_cfg=None):
"""验证微信回调签名。可指定商户;未指定则尝试启用商户列表。"""
import rsa
serial = headers.get('Wechatpay-Serial')
signature = headers.get('Wechatpay-Signature')
timestamp = headers.get('Wechatpay-Timestamp')
nonce = headers.get('Wechatpay-Nonce')
if not all([serial, signature, timestamp, nonce]):
return False
message = '\n'.join([timestamp, nonce, body]) + '\n'
cert_path = f"{settings.WECHAT_PAY_V3_CONFIG['PLATFORM_CERT_DIR']}/{serial}.pem"
if not os.path.exists(cert_path):
return False
with open(cert_path, 'rb') as f:
pub_key = rsa.PublicKey.load_pkcs1_openssl_pem(f.read())
signature_bytes = base64.b64decode(signature)
try:
rsa.verify(message.encode('utf-8'), signature_bytes, pub_key)
return True
except rsa.VerificationError:
return False
cfgs = []
if wx_cfg:
cfgs = [wx_cfg]
else:
from utils.wechat_mch_service import list_enabled_wx_cfgs
cfgs = list_enabled_wx_cfgs()
for cfg in cfgs:
cert_path = _resolve_platform_pub_path(cfg, serial=serial)
if not cert_path or not os.path.exists(cert_path):
continue
try:
with open(cert_path, 'rb') as f:
pub_key = rsa.PublicKey.load_pkcs1_openssl_pem(f.read())
rsa.verify(message.encode('utf-8'), signature_bytes, pub_key)
return True
except Exception:
continue
return False
def decrypt_callback_ciphertext(associated_data, nonce, ciphertext):
"""解密回调数据(使用 AES-GCM"""
api_v3_key = settings.WECHAT_PAY_V3_CONFIG['API_V3_KEY'].encode('utf-8')
def decrypt_callback_ciphertext(associated_data, nonce, ciphertext, wx_cfg=None):
"""用指定商户 APIv3 密钥解密回调。"""
cfg = _resolve_cfg(wx_cfg)
api_v3_key = cfg['API_V3_KEY'].encode('utf-8')
nonce_bytes = nonce.encode('utf-8')
ciphertext_bytes = base64.b64decode(ciphertext)
@@ -207,4 +130,23 @@ def decrypt_callback_ciphertext(associated_data, nonce, ciphertext):
if associated_data:
cipher.update(associated_data.encode('utf-8'))
plaintext = cipher.decrypt_and_verify(ciphertext_bytes[:-16], ciphertext_bytes[-16:])
return plaintext.decode('utf-8')
return plaintext.decode('utf-8')
def decrypt_callback_with_merchants(associated_data, nonce, ciphertext):
"""
回调解密:因密文未解出前无法知道 out_bill_no
仅对启用商户密钥做 AES 尝试(通常很少),成功后返回 (plaintext, wx_cfg)。
业务侧再用 out_bill_no 反查记录上的 mch_id对齐一致性。
"""
from utils.wechat_mch_service import list_enabled_wx_cfgs
last_err = None
for cfg in list_enabled_wx_cfgs():
try:
text = decrypt_callback_ciphertext(associated_data, nonce, ciphertext, wx_cfg=cfg)
return text, cfg
except Exception as e:
last_err = e
continue
raise ValueError(f'所有商户密钥均无法解密回调: {last_err}')

View File

@@ -0,0 +1,23 @@
# Generated manually: TixianAutoRecord.mch_id
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yonghu', '0023_freeze_balance_feature'),
]
operations = [
migrations.AddField(
model_name='tixianautorecord',
name='mch_id',
field=models.CharField(
blank=True,
db_index=True,
default='',
max_length=32,
verbose_name='发起转账商户号(新单必填)',
),
),
]

View File

@@ -509,6 +509,13 @@ class TixianAutoRecord(models.Model):
blank=True,
verbose_name='微信返回的package_info(JSON)'
)
mch_id = models.CharField(
max_length=32,
blank=True,
default='',
db_index=True,
verbose_name='发起转账商户号(新单必填)'
)
fail_reason = models.TextField(null=True, blank=True, verbose_name='失败原因')
# 关联提现审核单号(新流程必填,旧数据为空)
shenhe_danhao = models.CharField(

View File

@@ -21,7 +21,6 @@ import time
from datetime import date, timedelta
import requests
from django.conf import settings
from django.db import transaction
from django.utils import timezone
@@ -1072,20 +1071,27 @@ def sync_audit_and_jilu_status(audit, new_status, **extra_fields):
Tixianjilu.objects.filter(id=audit.tixianjilu_id).update(**update_fields)
def query_wechat_transfer_bill(out_bill_no):
def query_wechat_transfer_bill(out_bill_no, wx_cfg=None):
"""
向微信查转账单状态
GET /v3/fund-app/mch-transfer/transfer-bills/out-bill-no/{out_bill_no}
返回 dict网络/系统异常返回 None微信无此单返回 {'_not_found': True}
必须用发起该单的商户号配置签名(优先记录 mch_id
"""
from utils.wechat_mch_service import resolve_wx_cfg_for_bill
from utils.wechat_v3 import build_authorization
cfg = wx_cfg or resolve_wx_cfg_for_bill(out_bill_no)
if not cfg:
logger.error('查单无商户配置 bill=%s', out_bill_no)
return None
url = (
'https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/'
f'transfer-bills/out-bill-no/{out_bill_no}'
)
try:
auth = build_authorization('GET', url, '')
auth = build_authorization('GET', url, '', wx_cfg=cfg)
headers = {
'Authorization': auth,
'Accept': 'application/json',
@@ -1097,8 +1103,8 @@ def query_wechat_transfer_bill(out_bill_no):
if resp.status_code == 404:
return {'_not_found': True}
logger.error(
'微信查单失败 bill=%s HTTP%s body=%s',
out_bill_no, resp.status_code, resp.text[:300],
'微信查单失败 bill=%s mch=%s HTTP%s body=%s',
out_bill_no, cfg.get('MCHID'), resp.status_code, resp.text[:300],
)
return None
except (requests.RequestException, OSError, ValueError) as e:
@@ -1290,14 +1296,18 @@ def _sync_record_from_wx_query(auto_record, wx_data):
if auto_record.zhuangtai == 0:
auto_record.wechat_package = json.dumps(package, ensure_ascii=False)
auto_record.save(update_fields=['wechat_package', 'update_time'])
wx_cfg = settings.WECHAT_PAY_V3_CONFIG
mch_id = (auto_record.mch_id or '').strip()
if not mch_id:
from utils.wechat_mch_service import get_default_wx_cfg
fb = get_default_wx_cfg() or {}
mch_id = fb.get('MCHID', '')
return RECONCILE_WAIT_CONFIRM, {
'tixian_id': auto_record.tixian_id,
'package_info': package,
'shouxufei': str(auto_record.shouxufei),
'shijidaozhang': str(auto_record.shijidaozhang),
'current_rate': str(auto_record.feilv),
'mch_id': wx_cfg.get('MCHID', ''),
'mch_id': mch_id,
'shenhe_danhao': auto_record.shenhe_danhao or '',
}

View File

@@ -12,7 +12,6 @@ import string
import time
import requests
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from rest_framework import permissions
@@ -63,6 +62,22 @@ def _is_wx_balance_insufficient(err_code='', err_msg=''):
return any(k in blob for k in ('NOT_ENOUGH', 'INSUFFICIENT', '余额不足', '资金不足'))
def _is_wx_switchable_error(err_code='', err_msg=''):
"""
可立刻换下一商户重试的错误(本笔尚未进入 WAIT_USER_CONFIRM
仅白名单,避免误伤导致重复开单。
"""
if _is_wx_balance_insufficient(err_code, err_msg):
return True
blob = f'{err_code} {err_msg}'.upper()
keywords = (
'FREQUENCY_LIMITED', 'RATE_LIMIT', 'QUOTA', 'LIMIT',
'日限额', '超出限额', '超过限额', '限额', '次数超限',
'ACCOUNT_ERROR', 'NO_AUTH', 'PERM', '商户号异常', '账户异常',
)
return any(k in blob for k in keywords)
def _verify_collect_quota_or_response(user_main, audit, shenhe_danhao):
"""收款限额校验;不通过时直接返回 Response"""
ok, msg, limit_kind = check_collect_quota_limits(
@@ -121,7 +136,7 @@ def _call_wechat_transfer(body, wx_cfg):
url = 'https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/transfer-bills'
body_json = json.dumps(body, separators=(',', ':'))
auth = build_authorization('POST', url, body_json)
auth = build_authorization('POST', url, body_json, wx_cfg=wx_cfg)
headers = {
'Authorization': auth,
'Content-Type': 'application/json',
@@ -208,12 +223,14 @@ def process_audit_collect(request):
)
try:
from utils.wechat_mch_service import list_enabled_wx_cfgs, wx_cfg_is_complete
if not user_main.openid:
return Response({'code': 10, 'msg': '用户未绑定微信,无法收款'})
wx_cfg = settings.WECHAT_PAY_V3_CONFIG
if not wx_cfg.get('APPID') or not wx_cfg.get('MCHID') or not wx_cfg.get('TRANSFER_SCENE_ID'):
logger.error('微信打款配置不完整 APPID=%s MCHID=%s', wx_cfg.get('APPID'), wx_cfg.get('MCHID'))
mch_cfgs = list_enabled_wx_cfgs()
if not mch_cfgs or not wx_cfg_is_complete(mch_cfgs[0]):
logger.error('微信打款配置不完整 enabled_count=%s', len(mch_cfgs))
return Response({'code': 11, 'msg': '系统配置异常,请联系客服'})
ctx, err = resolve_collect_context(
@@ -304,33 +321,55 @@ def process_audit_collect(request):
quota_reserved = True
# 多商户轮换:仅在发起阶段未进入待确认时切换;成功后 mch_id 必须落库
last_user_msg = '微信收款发起失败,请稍后重试'
last_was_balance = False
tixian_id = None
for mch_idx, wx_cfg in enumerate(mch_cfgs):
tixian_id = generate_tixian_id()
TixianAutoRecord.objects.create(
tixian_id=tixian_id,
yonghuid=yonghuid,
leixing=leixing,
jine=jine,
shouxufei=shouxufei,
shijidaozhang=shijidaozhang,
feilv=feilv,
zhuangtai=0,
shenhe_danhao=shenhe_danhao,
mch_id = wx_cfg['MCHID']
with transaction.atomic():
TixianAutoRecord.objects.create(
tixian_id=tixian_id,
yonghuid=yonghuid,
leixing=leixing,
jine=jine,
shouxufei=shouxufei,
shijidaozhang=shijidaozhang,
feilv=feilv,
zhuangtai=0,
shenhe_danhao=shenhe_danhao,
mch_id=mch_id,
)
TixianShenheJilu.objects.filter(id=audit.id).update(
tixian_auto_id=tixian_id,
update_time=timezone.now(),
)
body = _build_wx_transfer_body(
wx_cfg, tixian_id, user_main.openid, yonghuid, leixing, shijidaozhang,
)
audit.tixian_auto_id = tixian_id
audit.save(update_fields=['tixian_auto_id', 'update_time'])
body = _build_wx_transfer_body(
wx_cfg, tixian_id, user_main.openid, yonghuid, leixing, shijidaozhang,
)
try:
resp, wx_result = _call_wechat_transfer(body, wx_cfg)
try:
resp, wx_result = _call_wechat_transfer(body, wx_cfg)
except requests.RequestException as e:
# 网络不确定是否已建单:禁止换号,保持待查单防双笔
logger.error(
'微信打款网络异常(保持待查单): bill=%s mch=%s err=%s',
tixian_id, mch_id, e, exc_info=True,
)
return Response({
'code': 12,
'msg': '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击',
})
if resp.status_code == 200 and wx_result.get('state') == 'WAIT_USER_CONFIRM':
package_info = wx_result.get('package_info')
auto_record = TixianAutoRecord.objects.get(tixian_id=tixian_id)
auto_record.wechat_package = json.dumps(package_info, ensure_ascii=False)
auto_record.save(update_fields=['wechat_package'])
auto_record.mch_id = mch_id
auto_record.save(update_fields=['wechat_package', 'mch_id'])
return _build_collect_success_response({
'tixian_id': tixian_id,
@@ -338,7 +377,7 @@ def process_audit_collect(request):
'shouxufei': str(shouxufei),
'shijidaozhang': str(shijidaozhang),
'current_rate': str(feilv),
'mch_id': wx_cfg['MCHID'],
'mch_id': mch_id,
'shenhe_danhao': shenhe_danhao,
'tixianjilu_id': tixianjilu_id_val,
})
@@ -346,27 +385,17 @@ def process_audit_collect(request):
err_code = wx_result.get('code', '')
err_msg = wx_result.get('message', wx_result.get('detail', '微信接口调用失败'))
logger.error(
'微信发起转账未进入待确认: bill=%s code=%s msg=%s HTTP%s',
tixian_id, err_code, err_msg, resp.status_code,
'微信发起转账未进入待确认: bill=%s mch=%s code=%s msg=%s HTTP%s',
tixian_id, mch_id, err_code, err_msg, resp.status_code,
)
is_balance_issue = _is_wx_balance_insufficient(err_code, err_msg)
if is_balance_issue:
_mark_auto_record_failed(tixian_id, err_msg)
if quota_reserved:
release_collect_quota_for_record(
TixianAutoRecord.objects.get(tixian_id=tixian_id),
)
quota_reserved = False
return Response({
'code': 99,
'msg': '运营账户资金不足,需等管理员充值后才能提现',
})
post_action, post_payload = handle_post_transfer_failure(
tixian_id, shenhe_danhao, err_code, err_msg,
)
if post_action == RECONCILE_WAIT_CONFIRM:
if isinstance(post_payload, dict):
post_payload.setdefault('mch_id', mch_id)
post_payload.setdefault('tixianjilu_id', tixianjilu_id_val)
quota_resp = _verify_collect_quota_or_response(user_main, audit, shenhe_danhao)
if quota_resp:
return quota_resp
@@ -385,24 +414,42 @@ def process_audit_collect(request):
)
return Response({'code': 12, 'msg': pending_msg or '有收款处理中,请稍后再试'})
# 明确失败:标失败;可切换则试下一商户(不释放限额,直到全部失败)
user_msg = err_msg or '微信收款发起失败,请稍后重试'
_mark_auto_record_failed(tixian_id, user_msg)
last_user_msg = user_msg
last_was_balance = _is_wx_balance_insufficient(err_code, err_msg)
_mark_auto_record_failed(tixian_id, f'[{mch_id}] {user_msg}')
if _is_wx_switchable_error(err_code, err_msg) and mch_idx < len(mch_cfgs) - 1:
logger.warning(
'商户转账失败,切换下一商户 shenhe=%s from=%s idx=%s/%s',
shenhe_danhao, mch_id, mch_idx + 1, len(mch_cfgs),
)
continue
if quota_reserved:
release_collect_quota_for_record(
TixianAutoRecord.objects.get(tixian_id=tixian_id),
)
quota_reserved = False
if last_was_balance and mch_idx >= len(mch_cfgs) - 1:
return Response({
'code': 99,
'msg': '运营账户资金不足,需等管理员充值后才能提现',
})
return Response({'code': 99, 'msg': user_msg})
except requests.RequestException as e:
logger.error(
'微信打款网络异常(保持待查单): bill=%s err=%s',
tixian_id, e, exc_info=True,
if quota_reserved and tixian_id:
release_collect_quota_for_record(
TixianAutoRecord.objects.get(tixian_id=tixian_id),
)
quota_reserved = False
if last_was_balance:
return Response({
'code': 12,
'msg': '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击',
'code': 99,
'msg': '运营账户资金不足,需等管理员充值后才能提现',
})
return Response({'code': 99, 'msg': last_user_msg})
finally:
release_lock(lock_key)

View File

@@ -13071,12 +13071,13 @@ class TixianShenqingV3View(APIView):
from utils.wechat_v3 import verify_wechat_sign, decrypt_callback_ciphertext
from utils.wechat_v3 import verify_wechat_sign, decrypt_callback_with_merchants
from utils.wechat_mch_service import get_wx_cfg_by_mch_id
class TixianCallbackV3View(APIView):
"""
微信转账结果回调接口(最终状态通知)
处理微信异步发送的转账成功/失败结果
解密后按 out_bill_no 反查打款记录上的 mch_id不按业务逻辑轮询商户。
"""
permission_classes = [] # 无需认证,签名验证保证安全
@@ -13084,11 +13085,11 @@ class TixianCallbackV3View(APIView):
headers = request.headers
body = request.body.decode('utf-8')
# 1. 验证签名
# 1. 验证签名(启用商户公钥尝试)
if not verify_wechat_sign(headers, body):
return Response({'code': 'FAIL', 'message': '签名验证失败'}, status=status.HTTP_401_UNAUTHORIZED)
# 2. 解密数据
# 2. 解密数据(密文解出前只能试密钥;解出后用记录 mch_id 对齐)
try:
data = json.loads(body)
resource = data.get('resource')
@@ -13101,7 +13102,9 @@ class TixianCallbackV3View(APIView):
if not nonce or not ciphertext:
return Response({'code': 'FAIL', 'message': '解密字段缺失'}, status=status.HTTP_400_BAD_REQUEST)
plaintext = decrypt_callback_ciphertext(associated_data, nonce, ciphertext)
plaintext, decrypt_cfg = decrypt_callback_with_merchants(
associated_data, nonce, ciphertext,
)
callback_data = json.loads(plaintext)
except Exception as e:
logger.error(f"回调解密失败: {str(e)}", exc_info=True)
@@ -13126,13 +13129,24 @@ class TixianCallbackV3View(APIView):
)
from .models import TixianShenheJilu
# 4. 幂等处理
# 4. 幂等处理:以打款记录 mch_id 为准
with transaction.atomic():
try:
auto_record = TixianAutoRecord.objects.select_for_update().get(tixian_id=out_bill_no)
except TixianAutoRecord.DoesNotExist:
return Response({'code': 'SUCCESS', 'message': 'ok'})
if auto_record.mch_id:
record_cfg = get_wx_cfg_by_mch_id(auto_record.mch_id)
if record_cfg and decrypt_cfg and record_cfg.get('MCHID') != decrypt_cfg.get('MCHID'):
logger.warning(
'回调商户与记录不一致 bill=%s record_mch=%s decrypt_mch=%s',
out_bill_no, auto_record.mch_id, decrypt_cfg.get('MCHID'),
)
elif decrypt_cfg and decrypt_cfg.get('MCHID'):
auto_record.mch_id = decrypt_cfg['MCHID']
auto_record.save(update_fields=['mch_id', 'update_time'])
if transfer_status == 'SUCCESS':
if auto_record.zhuangtai == 0:
mark_transfer_success(