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,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='提现平台证书目录'),
),
]

View File

@@ -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='')

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

View File

@@ -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'),

View File

@@ -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):
"""
俱乐部维度财务(新接口)。