Files
along_django/utils/wechat_mch_service.py
XingQue 537e538528 修复多商户轮换死锁,并加固回调解密与证书校验。
余额不足等明确错误在查单宽限 PENDING 时会卡死不换号;现确认无单后切换,停用商户仍可解回调、请求带 notify_url。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 03:28:42 +08:00

189 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
微信商家转账商户配置服务。
配置来源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], *, require_key_file: bool = True) -> bool:
if not cfg:
return False
required = ('MCHID', 'APPID', 'API_V3_KEY', 'PRIVATE_KEY_PATH', 'CERT_SERIAL_NO', 'TRANSFER_SCENE_ID')
if not all(str(cfg.get(k) or '').strip() for k in required):
return False
if require_key_file:
pk = str(cfg.get('PRIVATE_KEY_PATH') or '').strip()
if not os.path.isfile(pk):
logger.warning('商户私钥文件不存在已跳过 mch_id=%s path=%s', cfg.get('MCHID'), pk)
return False
return True
def _rows_to_complete_cfgs(rows) -> List[Dict]:
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)
return result
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 = _rows_to_complete_cfgs(rows)
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 list_wx_cfgs_for_callback() -> List[Dict]:
"""
回调验签/解密:包含已停用商户。
解密只需 API_V3_KEY验签缺平台公钥时会自动跳过该户。
"""
from peizhi.models import WechatPayMchConfig
rows = list(WechatPayMchConfig.objects.all().order_by('priority', 'id'))
result = []
for row in rows:
cfg = row_to_wx_cfg(row)
if str(cfg.get('MCHID') or '').strip() and str(cfg.get('API_V3_KEY') or '').strip():
result.append(cfg)
if result:
return result
fb = _settings_fallback_cfg()
if str(fb.get('MCHID') or '').strip() and str(fb.get('API_V3_KEY') or '').strip():
return [fb]
return []
def get_wx_cfg_by_mch_id(mch_id: str) -> Optional[Dict]:
"""按 mch_id 取配置(含停用);查单需可签名,故要求私钥文件存在。"""
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)