feat: 按商户号彻底删除俱乐部及绑定配置 delete_club_by_mch_id

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-23 21:59:17 +08:00
parent b41f2b3095
commit 34344fa5b2

View File

@@ -0,0 +1,286 @@
"""按微信商户号彻底删除俱乐部及其绑定配置(不删业务订单/真实用户)。
用法:
python manage.py delete_club_by_mch_id 17480975501
python manage.py delete_club_by_mch_id 17480975501 --apply
"""
from __future__ import annotations
import os
import shutil
from django.core.management.base import BaseCommand, CommandError
from django.db import connection, transaction
from django.db.models import Q
from jituan.models import (
AdminAssignment,
Club,
ClubDashouExamConfig,
ClubDashouExamPass,
ClubDashouExamQuestion,
ClubDashouExamQuestionImage,
ClubFadanFenhongLilv,
ClubHuiyuanBundleInclude,
ClubHuiyuanPrice,
ClubKaoheChenghaoConfig,
ClubLilubiao,
ClubPaymentChannel,
ClubShangpinLeixingConfig,
ClubTixianQuota,
ClubWithdrawConfig,
IdentityTag,
IdentityTagBind,
PayChannelLedger,
UserWxOpenid,
)
from jituan.services.club_cert_upload import club_wx_cert_root
try:
from jituan.models import AdminAuditLog
except Exception:
AdminAuditLog = None
# 展示/运营配置config app
try:
from config.models import (
ClubMiniappIcon,
ClubPindao,
ClubPindaoImage,
Gonggao,
Lunbo,
MiniappScriptAutoReply,
MiniappScriptScene,
PopupPage,
Szjilu,
Tupianpeizhi,
)
except Exception: # pragma: no cover
Lunbo = Gonggao = Tupianpeizhi = PopupPage = Szjilu = None
ClubMiniappIcon = ClubPindao = ClubPindaoImage = None
MiniappScriptScene = MiniappScriptAutoReply = None
def _delete_qs(model, club_id, stdout, label=None):
if model is None:
return 0
name = label or model.__name__
try:
qs = model.query.filter(club_id=club_id)
n = qs.count()
if n:
qs.delete()
stdout.write(f' - {name}: 删除 {n}')
else:
stdout.write(f' - {name}: 0')
return n
except Exception as exc:
stdout.write(f' - {name}: 跳过 ({exc})')
return 0
def _find_clubs_by_mch(mch_id: str):
"""club.mch_id / withdraw_mch_id / payment_channel.mch_id / extra_json 命中。"""
clubs = list(
Club.query.filter(
Q(mch_id=mch_id) | Q(withdraw_mch_id=mch_id)
)
)
found_ids = {c.club_id for c in clubs}
for ch in ClubPaymentChannel.query.filter(mch_id=mch_id):
if ch.club_id not in found_ids:
c = Club.query.filter(club_id=ch.club_id).first()
if c:
clubs.append(c)
found_ids.add(c.club_id)
# extra_json 里可能写 merchant_id
for ch in ClubPaymentChannel.query.all().only('id', 'club_id', 'mch_id', 'extra_json'):
extra = ch.extra_json or {}
mid = str(extra.get('merchant_id') or extra.get('mch_id') or '').strip()
if mid == mch_id and ch.club_id not in found_ids:
c = Club.query.filter(club_id=ch.club_id).first()
if c:
clubs.append(c)
found_ids.add(c.club_id)
return clubs
def _purge_club(club: Club, stdout, style, apply: bool, remove_cert_dir: bool):
cid = club.club_id
stdout.write(style.WARNING(f'\n>>> 俱乐部 {cid} ({club.name})'))
stdout.write(f' mch_id={club.mch_id or "(空)"} withdraw_mch_id={getattr(club, "withdraw_mch_id", "") or "(空)"}')
stdout.write(f' wx_appid={club.wx_appid or "(空)"}')
stdout.write(f' pay_key_path={club.pay_key_path or "(空)"}')
stdout.write(f' pay_cert_path={club.pay_cert_path or "(空)"}')
stdout.write(f' private_key_path={club.private_key_path or "(空)"}')
channel_n = ClubPaymentChannel.query.filter(club_id=cid).count()
stdout.write(f' payment_channel 条数={channel_n}')
if not apply:
stdout.write(' (dry-run未删除)')
return
with transaction.atomic():
# jituan 配置表
for model in (
ClubPaymentChannel,
ClubHuiyuanPrice,
ClubHuiyuanBundleInclude,
ClubLilubiao,
ClubWithdrawConfig,
ClubTixianQuota,
ClubShangpinLeixingConfig,
ClubKaoheChenghaoConfig,
ClubFadanFenhongLilv,
ClubDashouExamConfig,
ClubDashouExamQuestionImage,
ClubDashouExamQuestion,
ClubDashouExamPass,
IdentityTagBind,
IdentityTag,
PayChannelLedger,
UserWxOpenid,
AdminAssignment,
AdminAuditLog,
):
_delete_qs(model, cid, stdout)
# 展示配置
for model in (
Lunbo, Gonggao, Tupianpeizhi, PopupPage, Szjilu,
ClubMiniappIcon, ClubPindaoImage, ClubPindao,
MiniappScriptScene, MiniappScriptAutoReply,
):
_delete_qs(model, cid, stdout)
# 兜底:扫一遍已知配置表(防 ORM 漏删)
with connection.cursor() as cursor:
tables = [
'club_payment_channel',
'club_huiyuan_price',
'club_huiyuan_bundle_include',
'club_lilubiao',
'club_withdraw_config',
'club_tixian_quota',
'club_shangpin_leixing_config',
'club_kaohe_chenghao_config',
'club_fadan_fenhong_lilv',
'club_dashou_exam_config',
'club_dashou_exam_question',
'club_dashou_exam_question_image',
'club_dashou_exam_pass',
'identity_tag_bind',
'identity_tag',
'pay_channel_ledger',
'user_wx_openid',
'admin_assignment',
'admin_audit_log',
'lunbo',
'gonggao',
'tupianpeizhi',
'popup_page',
'szjilu',
'club_miniapp_icon',
'club_pindao',
'club_pindao_image',
'miniapp_script_scene',
'miniapp_script_auto_reply',
'daily_income_stat',
'daily_payout_stat',
'platform_income_log',
]
for table in tables:
try:
cursor.execute(f'SELECT COUNT(*) FROM `{table}` WHERE club_id=%s', [cid])
n = cursor.fetchone()[0]
if n:
cursor.execute(f'DELETE FROM `{table}` WHERE club_id=%s', [cid])
stdout.write(f' - SQL {table}: 删除 {n}')
except Exception:
pass
Club.query.filter(club_id=cid).delete()
stdout.write(style.SUCCESS(f' - Club 主表已删除: {cid}'))
if remove_cert_dir:
root = club_wx_cert_root()
cert_dir = os.path.join(root, cid)
if os.path.isdir(cert_dir):
if apply:
shutil.rmtree(cert_dir, ignore_errors=True)
stdout.write(style.SUCCESS(f' - 已删除证书目录: {cert_dir}'))
else:
stdout.write(f' - 将删除证书目录: {cert_dir}')
else:
stdout.write(f' - 证书目录不存在: {cert_dir}')
class Command(BaseCommand):
help = '按商户号删除俱乐部及其全部绑定配置(默认 dry-run'
def add_arguments(self, parser):
parser.add_argument(
'mch_id',
nargs='?',
default='17480975501',
help='微信商户号(默认 17480975501',
)
parser.add_argument('--apply', action='store_true', help='真正删除')
parser.add_argument(
'--keep-cert-dir',
action='store_true',
help='不删磁盘上 wx_certs/{club_id} 目录',
)
def handle(self, *args, **options):
mch_id = str(options.get('mch_id') or '').strip()
if not mch_id:
raise CommandError('请提供商户号')
apply = bool(options.get('apply'))
remove_cert = not bool(options.get('keep_cert_dir'))
clubs = _find_clubs_by_mch(mch_id)
# 也列出仅有通道命中、主表已无的 club_id
orphan_channels = list(
ClubPaymentChannel.query.filter(mch_id=mch_id).exclude(
club_id__in=[c.club_id for c in clubs]
)
)
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write(f'目标商户号 mch_id = {mch_id}')
self.stdout.write(f'命中俱乐部: {len(clubs)}')
if not clubs and not orphan_channels:
self.stdout.write(self.style.WARNING('未找到绑定该商户号的俱乐部/支付通道,无需删除。'))
return
for club in clubs:
_purge_club(club, self.stdout, self.style, apply, remove_cert)
if orphan_channels:
self.stdout.write(self.style.WARNING('\n仅有支付通道、无 Club 主表的残留:'))
for ch in orphan_channels:
self.stdout.write(
f' channel id={ch.id} club_id={ch.club_id} name={ch.name} mch_id={ch.mch_id}'
)
if apply:
cid = ch.club_id
with transaction.atomic():
ClubPaymentChannel.query.filter(id=ch.id).delete()
# 顺带清同 club_id 残留配置
for model in (
ClubPaymentChannel, PayChannelLedger, AdminAssignment, UserWxOpenid,
):
_delete_qs(model, cid, self.stdout)
self.stdout.write(self.style.SUCCESS(f' 已删通道 id={ch.id}'))
if not apply:
self.stdout.write(self.style.WARNING(
f'\n当前为预览。确认后执行:\n'
f' python manage.py delete_club_by_mch_id {mch_id} --apply'
))
else:
self.stdout.write(self.style.SUCCESS('\n删除完成。'))