feat: 收款与提现商户拆分,支持后台上传证书

收款仍用 mch_id/mch_key;提现优先 withdraw_mch_id(空则回落);证书分 pay/withdraw 目录落盘。退款逻辑未改。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-21 03:52:55 +08:00
parent 98b58f8058
commit 8e82a8457d
6 changed files with 328 additions and 16 deletions

View File

@@ -0,0 +1,120 @@
"""俱乐部微信商户证书落盘(收款 / 提现分目录)。"""
import logging
import os
import re
from django.conf import settings
logger = logging.getLogger(__name__)
# pay = 点单收款商户withdraw = 提现转账商户
CERT_KINDS = {
'pay_key': ('pay', 'apiclient_key.pem', 'pay_key_path'),
'pay_cert': ('pay', 'apiclient_cert.pem', 'pay_cert_path'),
'withdraw_key': ('withdraw', 'apiclient_key.pem', 'private_key_path'),
'withdraw_cert': ('withdraw', 'apiclient_cert.pem', None), # 与私钥同目录,不单独存字段
'withdraw_platform': ('withdraw', 'pub_key.pem', 'platform_cert_dir'), # 目录字段指向所在目录
}
_SAFE_CLUB = re.compile(r'^[a-zA-Z0-9_-]{1,16}$')
def club_wx_cert_root():
"""
证书根目录。优先 settings.CLUB_WX_CERT_ROOT
否则尝试沿用现网 secrets 父目录下的 wx_certs最后 BASE_DIR/wx_certs。
"""
configured = (getattr(settings, 'CLUB_WX_CERT_ROOT', None) or '').strip()
if configured:
return configured
# 兼容现网:.../sites/xxx/tuikuanzhengshu → .../sites/xxx/wx_certs
for attr in ('WEIXIN_CERT_PATH', 'WEIXIN_KEY_PATH'):
p = (getattr(settings, attr, None) or '').strip()
if p:
parent = os.path.dirname(os.path.dirname(os.path.abspath(p)))
if parent and parent not in ('.', os.sep):
return os.path.join(parent, 'wx_certs')
v3 = getattr(settings, 'WECHAT_PAY_V3_CONFIG', None) or {}
pk = (v3.get('PRIVATE_KEY_PATH') or '').strip()
if pk:
parent = os.path.dirname(os.path.dirname(os.path.abspath(pk)))
if parent and parent not in ('.', os.sep):
return os.path.join(parent, 'wx_certs')
return os.path.join(str(settings.BASE_DIR), 'wx_certs')
def club_cert_dir(club_id, bucket):
cid = (club_id or '').strip()
if not _SAFE_CLUB.match(cid):
raise ValueError('非法 club_id')
if bucket not in ('pay', 'withdraw'):
raise ValueError('非法证书分类')
path = os.path.join(club_wx_cert_root(), cid, bucket)
os.makedirs(path, mode=0o750, exist_ok=True)
return path
def _validate_pem(raw: bytes, expect_private: bool):
if not raw or len(raw) > 64 * 1024:
raise ValueError('文件过大或为空')
text = raw.decode('utf-8', errors='ignore')
if 'BEGIN' not in text or 'END' not in text:
raise ValueError('不是有效的 PEM 文件')
has_private = 'PRIVATE KEY' in text
if expect_private and not has_private:
raise ValueError('需要私钥 PEM含 PRIVATE KEY')
if not expect_private and has_private:
raise ValueError('请上传证书/公钥 PEM不要上传私钥')
return raw
def save_club_cert_file(club, kind, uploaded_file):
"""
保存上传的证书文件并回写 Club 字段。
kind: pay_key | pay_cert | withdraw_key | withdraw_cert | withdraw_platform
返回 {'path': ..., 'field': ..., 'kind': ...}
"""
if kind not in CERT_KINDS:
raise ValueError(f'不支持的文件类型: {kind}')
bucket, filename, field_name = CERT_KINDS[kind]
expect_private = kind in ('pay_key', 'withdraw_key')
raw = uploaded_file.read()
raw = _validate_pem(raw, expect_private=expect_private)
directory = club_cert_dir(club.club_id, bucket)
dest = os.path.join(directory, filename)
# 原子写入
tmp = dest + '.tmp'
with open(tmp, 'wb') as f:
f.write(raw)
os.chmod(tmp, 0o640)
os.replace(tmp, dest)
update_fields = []
if field_name == 'platform_cert_dir':
# 平台证书:字段存目录
setattr(club, field_name, directory)
update_fields.append(field_name)
saved_path = directory
elif field_name:
setattr(club, field_name, dest)
update_fields.append(field_name)
saved_path = dest
else:
# withdraw_cert只落盘序列号由私钥配对自动解析
saved_path = dest
if update_fields:
club.save(update_fields=update_fields)
logger.warning(
'[CLUB_CERT_UPLOAD] club=%s kind=%s path=%s field=%s',
club.club_id, kind, saved_path, field_name or '(none)',
)
return {
'kind': kind,
'path': saved_path,
'field': field_name,
'bucket': bucket,
'filename': filename,
}

View File

@@ -226,12 +226,20 @@ def get_wechat_v2_config(club_id=None):
cid, mch_id,
)
# 收款商户证书(供退款等对齐收款商户;未配时留空,调用方可回落 settings
pay_cert = _pick_club_field(club, cfg_json, 'pay_cert_path')
pay_key = _pick_club_field(club, cfg_json, 'pay_key_path')
return {
'appid': appid,
'mch_id': mch_id,
'key': mch_key,
'club_id': cid,
'_key_source': key_source,
# 明确标记:这是点单/会员收款商户
'merchant_role': 'pay',
'cert_path': pay_cert,
'key_path': pay_key,
}
@@ -378,16 +386,26 @@ def get_wechat_v3_config(club_id=None):
xq = XQ_WX_PAY if cid in SHARED_WX_MERCHANT_CLUBS else {}
mch_id = _pick_club_field(club, cfg_json, 'mch_id') or xq.get('mch_id', '')
# 提现商户号:优先 withdraw_mch_id空则回落收款 mch_id兼容旧数据
mch_id = (
_pick_club_field(club, cfg_json, 'withdraw_mch_id')
or _pick_club_field(club, cfg_json, 'mch_id')
or xq.get('mch_id', '')
)
api_v3_key = _pick_club_field(club, cfg_json, 'api_v3_key') or xq.get('api_v3_key', '')
# 共用商户的俱乐部:私钥/证书目录与星阙相同settings 路径 + 同目录 apiclient_cert.pem
# 共用商户的俱乐部:默认用 settings 私钥;仅当库中路径存在且文件在盘上才覆盖
# (避免旧库里曾填过、但原先被忽略的无效路径突然接管提现)
private_key_path = path_defaults['PRIVATE_KEY_PATH']
if cid not in SHARED_WX_MERCHANT_CLUBS:
private_key_path = (
_pick_club_field(club, cfg_json, 'private_key_path')
or private_key_path
)
elif club:
uploaded_key = (getattr(club, 'private_key_path', None) or '').strip()
if uploaded_key and os.path.isfile(uploaded_key):
private_key_path = uploaded_key
platform_cert_dir = (
_pick_club_field(club, cfg_json, 'platform_cert_dir')
@@ -405,7 +423,10 @@ def get_wechat_v3_config(club_id=None):
# 序列号必须从与私钥配对的 apiclient_cert.pem 读取,禁止盲用数据库/写死值
cert_serial = resolve_merchant_cert_serial(private_key_path, '')
if not cert_serial:
cert_serial = xq.get('cert_serial_no', '')
cert_serial = (
_pick_club_field(club, cfg_json, 'cert_serial_no')
or xq.get('cert_serial_no', '')
)
cfg = {
'APPID': appid,
@@ -416,6 +437,7 @@ def get_wechat_v3_config(club_id=None):
'PLATFORM_CERT_DIR': platform_cert_dir,
'TRANSFER_SCENE_ID': transfer_scene_id,
'club_id': cid,
'merchant_role': 'withdraw',
}
_log_v3_config(cfg, source='club_db' if club else 'xq_fallback')
return cfg