"""俱乐部微信商户证书落盘(收款 / 提现分目录)。""" 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, }