收款仍用 mch_id/mch_key;提现优先 withdraw_mch_id(空则回落);证书分 pay/withdraw 目录落盘。退款逻辑未改。 Co-authored-by: Cursor <cursoragent@cursor.com>
121 lines
4.3 KiB
Python
121 lines
4.3 KiB
Python
"""俱乐部微信商户证书落盘(收款 / 提现分目录)。"""
|
||
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,
|
||
}
|