支持自动提现多商户轮换:配置入库、打款单强制记 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:
@@ -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'
|
||||
0
peizhi/management/__init__.py
Normal file
0
peizhi/management/__init__.py
Normal file
0
peizhi/management/commands/__init__.py
Normal file
0
peizhi/management/commands/__init__.py
Normal file
29
peizhi/management/commands/seed_wechat_mch_from_settings.py
Normal file
29
peizhi/management/commands/seed_wechat_mch_from_settings.py
Normal 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')}"
|
||||
))
|
||||
70
peizhi/migrations/0022_wechat_pay_mch_config.py
Normal file
70
peizhi/migrations/0022_wechat_pay_mch_config.py
Normal 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),
|
||||
]
|
||||
@@ -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 "-"})'
|
||||
@@ -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
204
peizhi/wx_mch_views.py
Normal 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
154
utils/wechat_mch_service.py
Normal 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)
|
||||
@@ -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}')
|
||||
|
||||
23
yonghu/migrations/0024_tixianautorecord_mch_id.py
Normal file
23
yonghu/migrations/0024_tixianautorecord_mch_id.py
Normal 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='发起转账商户号(新单必填)',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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 '',
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user