From 8e82a8457db256fe3d732036af147ccfb401f1bc Mon Sep 17 00:00:00 2001 From: XingQue Date: Tue, 21 Jul 2026 03:52:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E6=AC=BE=E4=B8=8E=E6=8F=90?= =?UTF-8?q?=E7=8E=B0=E5=95=86=E6=88=B7=E6=8B=86=E5=88=86=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=90=8E=E5=8F=B0=E4=B8=8A=E4=BC=A0=E8=AF=81=E4=B9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收款仍用 mch_id/mch_key;提现优先 withdraw_mch_id(空则回落);证书分 pay/withdraw 目录落盘。退款逻辑未改。 Co-authored-by: Cursor --- .../0013_club_pay_withdraw_split.py | 70 ++++++++++ jituan/models.py | 35 ++++- jituan/services/club_cert_upload.py | 120 ++++++++++++++++++ jituan/services/wechat_pay.py | 28 +++- jituan/urls.py | 2 + jituan/views.py | 89 ++++++++++++- 6 files changed, 328 insertions(+), 16 deletions(-) create mode 100644 jituan/migrations/0013_club_pay_withdraw_split.py create mode 100644 jituan/services/club_cert_upload.py diff --git a/jituan/migrations/0013_club_pay_withdraw_split.py b/jituan/migrations/0013_club_pay_withdraw_split.py new file mode 100644 index 0000000..deb0f61 --- /dev/null +++ b/jituan/migrations/0013_club_pay_withdraw_split.py @@ -0,0 +1,70 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('jituan', '0012_club_mch_key'), + ] + + operations = [ + migrations.AddField( + model_name='club', + name='pay_cert_path', + field=models.CharField( + blank=True, default='', help_text='apiclient_cert.pem,退款/V2 证书用', + max_length=512, verbose_name='收款商户证书路径', + ), + ), + migrations.AddField( + model_name='club', + name='pay_key_path', + field=models.CharField( + blank=True, default='', help_text='apiclient_key.pem,退款/V2 证书用', + max_length=512, verbose_name='收款商户私钥路径', + ), + ), + migrations.AddField( + model_name='club', + name='withdraw_mch_id', + field=models.CharField( + blank=True, default='', help_text='空则回落收款商户号 mch_id', + max_length=32, verbose_name='提现商户号', + ), + ), + migrations.AlterField( + model_name='club', + name='mch_id', + field=models.CharField(blank=True, default='', max_length=32, verbose_name='收款商户号'), + ), + migrations.AlterField( + model_name='club', + name='pay_app_id', + field=models.CharField(blank=True, default='', max_length=32, verbose_name='收款AppID'), + ), + migrations.AlterField( + model_name='club', + name='mch_key', + field=models.CharField(blank=True, default='', max_length=128, verbose_name='收款APIv2密钥'), + ), + migrations.AlterField( + model_name='club', + name='api_v3_key', + field=models.CharField(blank=True, default='', max_length=128, verbose_name='提现APIv3密钥'), + ), + migrations.AlterField( + model_name='club', + name='cert_serial_no', + field=models.CharField(blank=True, default='', max_length=64, verbose_name='提现证书序列号'), + ), + migrations.AlterField( + model_name='club', + name='private_key_path', + field=models.CharField(blank=True, default='', max_length=512, verbose_name='提现商户私钥路径'), + ), + migrations.AlterField( + model_name='club', + name='platform_cert_dir', + field=models.CharField(blank=True, default='', max_length=512, verbose_name='提现平台证书目录'), + ), + ] diff --git a/jituan/models.py b/jituan/models.py index a116cdd..50c2601 100644 --- a/jituan/models.py +++ b/jituan/models.py @@ -13,13 +13,34 @@ class Club(QModel): status = models.IntegerField(default=1, verbose_name='1启用0停用') wx_appid = models.CharField(max_length=32, blank=True, default='') wx_secret = models.CharField(max_length=128, blank=True, default='') - mch_id = models.CharField(max_length=32, blank=True, default='') - pay_app_id = models.CharField(max_length=32, blank=True, default='') - mch_key = models.CharField(max_length=128, blank=True, default='', verbose_name='APIv2商户密钥') - api_v3_key = models.CharField(max_length=128, blank=True, default='', verbose_name='APIv3密钥') - cert_serial_no = models.CharField(max_length=64, blank=True, default='') - private_key_path = models.CharField(max_length=255, blank=True, default='') - platform_cert_dir = models.CharField(max_length=255, blank=True, default='') + # —— 点单/会员等收款商户(V2 JSAPI;退款也应对齐这套)—— + mch_id = models.CharField(max_length=32, blank=True, default='', verbose_name='收款商户号') + pay_app_id = models.CharField(max_length=32, blank=True, default='', verbose_name='收款AppID') + mch_key = models.CharField(max_length=128, blank=True, default='', verbose_name='收款APIv2密钥') + pay_cert_path = models.CharField( + max_length=512, blank=True, default='', + verbose_name='收款商户证书路径', + help_text='apiclient_cert.pem,退款/V2 证书用', + ) + pay_key_path = models.CharField( + max_length=512, blank=True, default='', + verbose_name='收款商户私钥路径', + help_text='apiclient_key.pem,退款/V2 证书用', + ) + # —— 提现/转账商户(V3;可与收款商户号不同)—— + withdraw_mch_id = models.CharField( + max_length=32, blank=True, default='', + verbose_name='提现商户号', + help_text='空则回落收款商户号 mch_id', + ) + api_v3_key = models.CharField(max_length=128, blank=True, default='', verbose_name='提现APIv3密钥') + cert_serial_no = models.CharField(max_length=64, blank=True, default='', verbose_name='提现证书序列号') + private_key_path = models.CharField( + max_length=512, blank=True, default='', verbose_name='提现商户私钥路径', + ) + platform_cert_dir = models.CharField( + max_length=512, blank=True, default='', verbose_name='提现平台证书目录', + ) official_appid = models.CharField(max_length=32, blank=True, default='') official_secret = models.CharField(max_length=128, blank=True, default='') official_token = models.CharField(max_length=64, blank=True, default='') diff --git a/jituan/services/club_cert_upload.py b/jituan/services/club_cert_upload.py new file mode 100644 index 0000000..8133e23 --- /dev/null +++ b/jituan/services/club_cert_upload.py @@ -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, + } diff --git a/jituan/services/wechat_pay.py b/jituan/services/wechat_pay.py index ceff180..e278a5c 100644 --- a/jituan/services/wechat_pay.py +++ b/jituan/services/wechat_pay.py @@ -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 diff --git a/jituan/urls.py b/jituan/urls.py index 5314347..6dfb14f 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -41,6 +41,7 @@ from jituan.views import ( ClubAdminAssignmentListView, ClubAuditLogListView, ClubManageView, + ClubCertUploadView, ClubCaiwuView, ClubChongzhiFinanceView, ClubDashouRegisterView, @@ -102,6 +103,7 @@ urlpatterns = [ path('auth/perm-debug', ClubPermDebugView.as_view(), name='jituan_perm_debug'), path('auth/dashou-register', ClubDashouRegisterView.as_view(), name='jituan_dashou_register'), path('houtai/club-manage', ClubManageView.as_view(), name='jituan_club_manage'), + path('houtai/club-cert-upload', ClubCertUploadView.as_view(), name='jituan_club_cert_upload'), path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'), path('houtai/caiwu', ClubCaiwuView.as_view(), name='jituan_caiwu'), path('houtai/szxx', ClubSzxxView.as_view(), name='jituan_szxx'), diff --git a/jituan/views.py b/jituan/views.py index 848ba5c..24754ce 100644 --- a/jituan/views.py +++ b/jituan/views.py @@ -3,7 +3,7 @@ import logging from django.utils import timezone from rest_framework import status -from rest_framework.parsers import JSONParser +from rest_framework.parsers import JSONParser, MultiPartParser, FormParser from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -450,8 +450,11 @@ class ClubManageView(APIView): parser_classes = [JSONParser] _EDITABLE_FIELDS = ( - 'name', 'status', 'wx_appid', 'wx_secret', 'mch_id', 'pay_app_id', - 'mch_key', 'api_v3_key', + 'name', 'status', 'wx_appid', 'wx_secret', + # 收款商户 + 'mch_id', 'pay_app_id', 'mch_key', 'pay_cert_path', 'pay_key_path', + # 提现商户 + 'withdraw_mch_id', 'api_v3_key', 'cert_serial_no', 'private_key_path', 'platform_cert_dir', 'official_appid', 'official_secret', 'official_token', 'encoding_aes_key', 'template_id', 'template_max_per_minute', 'h5_domain', 'oss_prefix', @@ -485,12 +488,20 @@ class ClubManageView(APIView): warnings = [] if not mch_key: warnings.append( - '未配置 APIv2 密钥 mch_key:小程序/会员/押金/点单等微信支付不会使用 api_v3_key,' - '未填时将回落服务器 app_secrets 旧密钥,易与当前商户号不匹配导致签名错误。' + '未配置收款 APIv2 密钥 mch_key:点单/会员等收款不会使用提现 api_v3_key。' ) if mch_key and api_v3 and mch_key == api_v3: warnings.append( - 'mch_key 与 api_v3_key 内容相同:通常不正确,V2(小程序支付)与 V3(提现转账)是两套不同密钥。' + '收款 mch_key 与提现 api_v3_key 相同:通常不正确,收款与提现是两套密钥。' + ) + withdraw_mch = (getattr(club, 'withdraw_mch_id', None) or '').strip() + pay_mch = (club.mch_id or '').strip() + if withdraw_mch and pay_mch and withdraw_mch != pay_mch: + # 这是预期场景,给信息而非警告 + pass + elif not withdraw_mch and pay_mch: + warnings.append( + '提现商户号未单独填写:提现将回落收款商户号 mch_id。若两套商户不同请填写 withdraw_mch_id。' ) wx_a = (club.wx_appid or '').strip() pay_a = (club.pay_app_id or '').strip() @@ -537,6 +548,7 @@ class ClubManageView(APIView): 'oss_prefix': club.oss_prefix, 'mch_id': club.mch_id, 'pay_app_id': club.pay_app_id, + 'withdraw_mch_id': getattr(club, 'withdraw_mch_id', '') or '', 'goeasy_appkey': club.goeasy_appkey, 'template_id': club.template_id, 'sort_order': club.sort_order, @@ -546,6 +558,8 @@ class ClubManageView(APIView): base.update({ 'wx_secret': club.wx_secret, 'mch_key': mch_key, + 'pay_cert_path': getattr(club, 'pay_cert_path', '') or '', + 'pay_key_path': getattr(club, 'pay_key_path', '') or '', 'api_v3_key': club.api_v3_key, 'pay_config': self._pay_config_meta(club), 'cert_serial_no': club.cert_serial_no, @@ -638,6 +652,69 @@ class ClubManageView(APIView): return Response({'code': 99, 'msg': '俱乐部配置操作失败'}, status=500) +class ClubCertUploadView(APIView): + """ + POST /jituan/houtai/club-cert-upload + multipart: phone, club_id, kind, file + kind: pay_key | pay_cert | withdraw_key | withdraw_cert | withdraw_platform + """ + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + def post(self, request): + try: + username = (request.data.get('phone') or request.data.get('username') or '').strip() + if not username: + return Response({'code': 401, 'msg': '缺少账号'}, status=401) + kefu_obj, permissions = verify_kefu_permission(request, username) + if kefu_obj is None: + return permissions + if not can_manage_admin_assignments(request.user): + return Response({'code': 403, 'msg': '仅集团超管可上传商户证书'}, status=403) + + club_id = (request.data.get('club_id') or '').strip() + kind = (request.data.get('kind') or '').strip() + uploaded = request.FILES.get('file') + if not club_id or not kind or not uploaded: + return Response({'code': 400, 'msg': '需要 club_id、kind、file'}) + + club = Club.query.filter(club_id=club_id).first() + if not club: + return Response({'code': 404, 'msg': f'俱乐部 {club_id} 不存在'}) + + from jituan.services.club_cert_upload import save_club_cert_file, CERT_KINDS + if kind not in CERT_KINDS: + return Response({ + 'code': 400, + 'msg': f'kind 无效,支持: {", ".join(CERT_KINDS.keys())}', + }) + try: + result = save_club_cert_file(club, kind, uploaded) + except ValueError as exc: + return Response({'code': 400, 'msg': str(exc)}) + except OSError as exc: + logger.exception('证书落盘失败 club=%s kind=%s', club_id, kind) + return Response({'code': 500, 'msg': f'写入失败: {exc}'}) + + club = Club.query.filter(club_id=club_id).first() + return Response({ + 'code': 0, + 'msg': '上传成功', + 'data': { + **result, + 'pay_cert_path': getattr(club, 'pay_cert_path', '') or '', + 'pay_key_path': getattr(club, 'pay_key_path', '') or '', + 'private_key_path': club.private_key_path or '', + 'platform_cert_dir': club.platform_cert_dir or '', + 'withdraw_mch_id': getattr(club, 'withdraw_mch_id', '') or '', + 'mch_id': club.mch_id or '', + }, + }) + except Exception: + logger.exception('ClubCertUploadView 异常') + return Response({'code': 99, 'msg': '证书上传失败'}, status=500) + + class ClubCaiwuView(APIView): """ 俱乐部维度财务(新接口)。