余额不足等明确错误在查单宽限 PENDING 时会卡死不换号;现确认无单后切换,停用商户仍可解回调、请求带 notify_url。 Co-authored-by: Cursor <cursoragent@cursor.com>
189 lines
6.3 KiB
Python
189 lines
6.3 KiB
Python
"""
|
||
微信商家转账商户配置服务。
|
||
配置来源: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)
|