diff --git a/jituan/management/commands/delete_club_by_mch_id.py b/jituan/management/commands/delete_club_by_mch_id.py new file mode 100644 index 0000000..b431c60 --- /dev/null +++ b/jituan/management/commands/delete_club_by_mch_id.py @@ -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删除完成。'))