diff --git a/a_long_dianjing/settings.py b/a_long_dianjing/settings.py index a8ace3c..8488a12 100644 --- a/a_long_dianjing/settings.py +++ b/a_long_dianjing/settings.py @@ -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' \ No newline at end of file diff --git a/peizhi/management/__init__.py b/peizhi/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/peizhi/management/commands/__init__.py b/peizhi/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/peizhi/management/commands/seed_wechat_mch_from_settings.py b/peizhi/management/commands/seed_wechat_mch_from_settings.py new file mode 100644 index 0000000..398d234 --- /dev/null +++ b/peizhi/management/commands/seed_wechat_mch_from_settings.py @@ -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')}" + )) diff --git a/peizhi/migrations/0022_wechat_pay_mch_config.py b/peizhi/migrations/0022_wechat_pay_mch_config.py new file mode 100644 index 0000000..760e79b --- /dev/null +++ b/peizhi/migrations/0022_wechat_pay_mch_config.py @@ -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), + ] diff --git a/peizhi/models.py b/peizhi/models.py index ccc91bf..c2a7a30 100644 --- a/peizhi/models.py +++ b/peizhi/models.py @@ -807,4 +807,35 @@ class XcxXieyiQianshu(models.Model): verbose_name_plural = verbose_name indexes = [ models.Index(fields=['yonghuid', 'xieyi']), - ] \ No newline at end of file + ] + + +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 "-"})' \ No newline at end of file diff --git a/peizhi/urls.py b/peizhi/urls.py index 32bcde8..8af2b58 100644 --- a/peizhi/urls.py +++ b/peizhi/urls.py @@ -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='小程序检查协议签署'), diff --git a/peizhi/wx_mch_views.py b/peizhi/wx_mch_views.py new file mode 100644 index 0000000..30e89c1 --- /dev/null +++ b/peizhi/wx_mch_views.py @@ -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, + }, + }) diff --git a/utils/wechat_mch_service.py b/utils/wechat_mch_service.py new file mode 100644 index 0000000..423762b --- /dev/null +++ b/utils/wechat_mch_service.py @@ -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) diff --git a/utils/wechat_v3.py b/utils/wechat_v3.py index b7c7df4..a8e1fe9 100644 --- a/utils/wechat_v3.py +++ b/utils/wechat_v3.py @@ -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') \ No newline at end of file + 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}') diff --git a/yonghu/migrations/0024_tixianautorecord_mch_id.py b/yonghu/migrations/0024_tixianautorecord_mch_id.py new file mode 100644 index 0000000..b95f5ed --- /dev/null +++ b/yonghu/migrations/0024_tixianautorecord_mch_id.py @@ -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='发起转账商户号(新单必填)', + ), + ), + ] diff --git a/yonghu/models.py b/yonghu/models.py index 091afef..39b3114 100644 --- a/yonghu/models.py +++ b/yonghu/models.py @@ -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( diff --git a/yonghu/tixian_shenhe_services.py b/yonghu/tixian_shenhe_services.py index 839efe8..8dee5e0 100644 --- a/yonghu/tixian_shenhe_services.py +++ b/yonghu/tixian_shenhe_services.py @@ -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 '', } diff --git a/yonghu/tixian_shenhe_views.py b/yonghu/tixian_shenhe_views.py index e22ca9e..94288d8 100644 --- a/yonghu/tixian_shenhe_views.py +++ b/yonghu/tixian_shenhe_views.py @@ -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) diff --git a/yonghu/views.py b/yonghu/views.py index ea3308e..45d76bb 100644 --- a/yonghu/views.py +++ b/yonghu/views.py @@ -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(