428 lines
16 KiB
Python
428 lines
16 KiB
Python
"""仅清理「龙先生电竞」(club_id=lsx) 的配置;绝对不碰其他俱乐部。
|
||
|
||
安全规则(硬约束):
|
||
1. 只允许操作 club_id=lsx(或显式传入且必须等于 lsx)
|
||
2. 若商户号命中了非 lsx 俱乐部:只打印警告,绝不删除
|
||
3. 默认 dry-run / --precheck:只列出将删内容与条数,不写库
|
||
4. --apply 必须再带 --i-understand-lsx-only 才真正删除
|
||
|
||
用法:
|
||
# 预检(推荐先跑这个,把输出发给你确认)
|
||
python manage.py delete_club_by_mch_id 17480975501 --precheck
|
||
|
||
# 确认无误后再删(双确认)
|
||
python manage.py delete_club_by_mch_id 17480975501 --apply --i-understand-lsx-only
|
||
"""
|
||
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
|
||
|
||
try:
|
||
from config.models import (
|
||
ClubMiniappIcon,
|
||
ClubPindao,
|
||
ClubPindaoImage,
|
||
Gonggao,
|
||
Lunbo,
|
||
MiniappScriptAutoReply,
|
||
MiniappScriptScene,
|
||
PopupPage,
|
||
Szjilu,
|
||
Tupianpeizhi,
|
||
)
|
||
except Exception:
|
||
Lunbo = Gonggao = Tupianpeizhi = PopupPage = Szjilu = None
|
||
ClubMiniappIcon = ClubPindao = ClubPindaoImage = None
|
||
MiniappScriptScene = MiniappScriptAutoReply = None
|
||
|
||
# 本命令唯一允许删除的俱乐部
|
||
ALLOWED_CLUB_ID = 'lsx'
|
||
ALLOWED_CLUB_NAME_HINT = '龙先生'
|
||
|
||
# ORM 配置模型(均按 club_id 过滤)
|
||
ORM_MODELS = [
|
||
('支付通道 ClubPaymentChannel', ClubPaymentChannel),
|
||
('会员价 ClubHuiyuanPrice', ClubHuiyuanPrice),
|
||
('会员套餐 ClubHuiyuanBundleInclude', ClubHuiyuanBundleInclude),
|
||
('利率 ClubLilubiao', ClubLilubiao),
|
||
('提现配置 ClubWithdrawConfig', ClubWithdrawConfig),
|
||
('提现配额 ClubTixianQuota', ClubTixianQuota),
|
||
('商品类型上架 ClubShangpinLeixingConfig', ClubShangpinLeixingConfig),
|
||
('考核称号 ClubKaoheChenghaoConfig', ClubKaoheChenghaoConfig),
|
||
('发单分红利率 ClubFadanFenhongLilv', ClubFadanFenhongLilv),
|
||
('接单考试配置 ClubDashouExamConfig', ClubDashouExamConfig),
|
||
('接单考试题图 ClubDashouExamQuestionImage', ClubDashouExamQuestionImage),
|
||
('接单考试题 ClubDashouExamQuestion', ClubDashouExamQuestion),
|
||
('接单考试通过 ClubDashouExamPass', ClubDashouExamPass),
|
||
('身份标签绑定 IdentityTagBind', IdentityTagBind),
|
||
('身份标签 IdentityTag', IdentityTag),
|
||
('支付通道流水 PayChannelLedger', PayChannelLedger),
|
||
('OpenID绑定 UserWxOpenid', UserWxOpenid),
|
||
('客服任职 AdminAssignment', AdminAssignment),
|
||
('审计日志 AdminAuditLog', AdminAuditLog),
|
||
('轮播 Lunbo', Lunbo),
|
||
('公告 Gonggao', Gonggao),
|
||
('图片配置 Tupianpeizhi', Tupianpeizhi),
|
||
('弹窗页 PopupPage', PopupPage),
|
||
('收支记录配置 Szjilu', Szjilu),
|
||
('小程序图标 ClubMiniappIcon', ClubMiniappIcon),
|
||
('频道图 ClubPindaoImage', ClubPindaoImage),
|
||
('频道 ClubPindao', ClubPindao),
|
||
('话术场景 MiniappScriptScene', MiniappScriptScene),
|
||
('话术自动回复 MiniappScriptAutoReply', MiniappScriptAutoReply),
|
||
]
|
||
|
||
# SQL 兜底表(仅 DELETE WHERE club_id=%s,绝不会无 club_id 条件)
|
||
SQL_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',
|
||
]
|
||
|
||
# 明确不删(业务数据)
|
||
NEVER_DELETE_NOTE = [
|
||
'User 用户主表 / 各身份扩展表(打手商家等)',
|
||
'订单 Order / 退款 / 处罚等业务流水',
|
||
'商品 Shangpin(若 club_id=lsx 有商品也不会在本命令删;如需另开命令)',
|
||
'其他俱乐部 xq / xzj / ufo 等任何数据',
|
||
]
|
||
|
||
|
||
def _count_model(model, club_id):
|
||
if model is None:
|
||
return None
|
||
try:
|
||
return model.query.filter(club_id=club_id).count()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _assert_lsx_only(club_id: str):
|
||
cid = (club_id or '').strip()
|
||
if cid != ALLOWED_CLUB_ID:
|
||
raise CommandError(
|
||
f'安全拦截:本命令只允许操作 club_id={ALLOWED_CLUB_ID}(龙先生电竞),'
|
||
f'拒绝 club_id={cid!r}'
|
||
)
|
||
|
||
|
||
def _club_snapshot(club: Club):
|
||
return {
|
||
'club_id': club.club_id,
|
||
'name': club.name,
|
||
'status': club.status,
|
||
'wx_appid': club.wx_appid or '',
|
||
'official_appid': club.official_appid or '',
|
||
'official_secret_set': bool((club.official_secret or '').strip()),
|
||
'official_token_set': bool((club.official_token or '').strip()),
|
||
'encoding_aes_key_set': bool((club.encoding_aes_key or '').strip()),
|
||
'template_id': club.template_id or '',
|
||
'mch_id': club.mch_id or '',
|
||
'withdraw_mch_id': getattr(club, 'withdraw_mch_id', '') or '',
|
||
'pay_app_id': club.pay_app_id or '',
|
||
'pay_key_path': club.pay_key_path or '',
|
||
'pay_cert_path': club.pay_cert_path or '',
|
||
'private_key_path': club.private_key_path or '',
|
||
'platform_cert_dir': club.platform_cert_dir or '',
|
||
'goeasy_appkey_set': bool((club.goeasy_appkey or '').strip()),
|
||
'h5_domain': club.h5_domain or '',
|
||
'oss_prefix': club.oss_prefix or '',
|
||
'mini_pay_channel': getattr(club, 'mini_pay_channel', '') or '',
|
||
}
|
||
|
||
|
||
def _find_other_clubs_with_mch(mch_id: str, exclude_club_id: str):
|
||
"""商户号撞到其他俱乐部时列出,绝不删除它们。"""
|
||
others = list(
|
||
Club.query.filter(
|
||
Q(mch_id=mch_id) | Q(withdraw_mch_id=mch_id)
|
||
).exclude(club_id=exclude_club_id)
|
||
)
|
||
ch_club_ids = (
|
||
ClubPaymentChannel.query.filter(mch_id=mch_id)
|
||
.exclude(club_id=exclude_club_id)
|
||
.values_list('club_id', flat=True)
|
||
.distinct()
|
||
)
|
||
for cid in ch_club_ids:
|
||
if not any(c.club_id == cid for c in others):
|
||
c = Club.query.filter(club_id=cid).first()
|
||
if c:
|
||
others.append(c)
|
||
return others
|
||
|
||
|
||
def _precheck_report(stdout, style, club: Club, mch_id: str):
|
||
snap = _club_snapshot(club)
|
||
stdout.write(style.SUCCESS('=' * 64))
|
||
stdout.write(style.SUCCESS('【预检】仅针对龙先生电竞 club_id=lsx'))
|
||
stdout.write(style.SUCCESS('=' * 64))
|
||
stdout.write(f'请求商户号: {mch_id or "(未指定,仅按 lsx 清理)"}')
|
||
stdout.write('')
|
||
stdout.write('—— 俱乐部主表字段 ——')
|
||
for k, v in snap.items():
|
||
stdout.write(f' {k}: {v if v != "" else "(空)"}')
|
||
|
||
if mch_id:
|
||
club_mch = (club.mch_id or '').strip()
|
||
club_wm = (getattr(club, 'withdraw_mch_id', '') or '').strip()
|
||
ch_hit = ClubPaymentChannel.query.filter(club_id=ALLOWED_CLUB_ID, mch_id=mch_id).count()
|
||
matched = club_mch == mch_id or club_wm == mch_id or ch_hit > 0
|
||
if not matched:
|
||
stdout.write(style.WARNING(
|
||
f'\n注意:lsx 当前 mch_id={club_mch or "(空)"} / withdraw={club_wm or "(空)"} / '
|
||
f'通道命中={ch_hit},与请求商户号 {mch_id} 不完全一致。'
|
||
f'本命令仍只清理 lsx,不会动其他俱乐部。'
|
||
))
|
||
else:
|
||
stdout.write(style.SUCCESS(f'\n商户号匹配确认:lsx 绑定了 {mch_id}'))
|
||
|
||
others = _find_other_clubs_with_mch(mch_id, ALLOWED_CLUB_ID)
|
||
if others:
|
||
stdout.write(style.ERROR(
|
||
f'\n!! 其他俱乐部也配置了商户号 {mch_id}(只警告,绝对不删):'
|
||
))
|
||
for o in others:
|
||
stdout.write(
|
||
f' - KEEP {o.club_id} ({o.name}) mch={o.mch_id} withdraw={getattr(o, "withdraw_mch_id", "")}'
|
||
)
|
||
else:
|
||
stdout.write('其他俱乐部未发现同商户号(安全)。')
|
||
|
||
stdout.write('\n—— 将删除的配置表(仅 club_id=lsx)——')
|
||
total = 0
|
||
for label, model in ORM_MODELS:
|
||
n = _count_model(model, ALLOWED_CLUB_ID)
|
||
if n is None:
|
||
stdout.write(f' [跳过不可用] {label}')
|
||
continue
|
||
mark = '将删' if n else '空'
|
||
stdout.write(f' [{mark}] {label}: {n} 条')
|
||
total += n or 0
|
||
|
||
stdout.write('\n—— SQL 兜底表计数(仅 WHERE club_id=lsx)——')
|
||
with connection.cursor() as cursor:
|
||
for table in SQL_TABLES:
|
||
try:
|
||
cursor.execute(
|
||
f'SELECT COUNT(*) FROM `{table}` WHERE club_id=%s',
|
||
[ALLOWED_CLUB_ID],
|
||
)
|
||
n = cursor.fetchone()[0]
|
||
stdout.write(f' [{("将删" if n else "空")}] {table}: {n}')
|
||
total += n
|
||
except Exception as exc:
|
||
stdout.write(f' [无此表/跳过] {table}: {exc}')
|
||
|
||
cert_dir = os.path.join(club_wx_cert_root(), ALLOWED_CLUB_ID)
|
||
stdout.write('\n—— 证书目录 ——')
|
||
if os.path.isdir(cert_dir):
|
||
file_n = 0
|
||
for _root, _dirs, files in os.walk(cert_dir):
|
||
file_n += len(files)
|
||
stdout.write(f' [将删目录] {cert_dir} (约 {file_n} 个文件)')
|
||
else:
|
||
stdout.write(f' [无目录] {cert_dir}')
|
||
|
||
stdout.write('\n—— 明确不删 ——')
|
||
for tip in NEVER_DELETE_NOTE:
|
||
stdout.write(f' · {tip}')
|
||
|
||
stdout.write('')
|
||
stdout.write(style.SUCCESS(f'预检合计(配置行粗略合计,含重复计数可能):约 {total}'))
|
||
stdout.write(style.WARNING(
|
||
'确认无误后再执行:\n'
|
||
f' python manage.py delete_club_by_mch_id {mch_id or "17480975501"} '
|
||
'--apply --i-understand-lsx-only'
|
||
))
|
||
stdout.write(style.SUCCESS('=' * 64))
|
||
|
||
|
||
def _delete_lsx(stdout, style, remove_cert_dir: bool):
|
||
_assert_lsx_only(ALLOWED_CLUB_ID)
|
||
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||
if not club:
|
||
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在,无需删除')
|
||
|
||
# 名称再确认一层(非强制阻断,只警告)
|
||
if ALLOWED_CLUB_NAME_HINT not in (club.name or ''):
|
||
stdout.write(style.WARNING(
|
||
f'警告:{ALLOWED_CLUB_ID} 的 name={club.name!r} 不含「{ALLOWED_CLUB_NAME_HINT}」,'
|
||
f'但仍按 club_id=lsx 删除(主键约束)。'
|
||
))
|
||
|
||
with transaction.atomic():
|
||
for label, model in ORM_MODELS:
|
||
if model is None:
|
||
continue
|
||
n = model.query.filter(club_id=ALLOWED_CLUB_ID).count()
|
||
if n:
|
||
model.query.filter(club_id=ALLOWED_CLUB_ID).delete()
|
||
stdout.write(f' - 已删 {label}: {n}')
|
||
|
||
with connection.cursor() as cursor:
|
||
for table in SQL_TABLES:
|
||
try:
|
||
cursor.execute(
|
||
f'SELECT COUNT(*) FROM `{table}` WHERE club_id=%s',
|
||
[ALLOWED_CLUB_ID],
|
||
)
|
||
n = cursor.fetchone()[0]
|
||
if n:
|
||
cursor.execute(
|
||
f'DELETE FROM `{table}` WHERE club_id=%s',
|
||
[ALLOWED_CLUB_ID],
|
||
)
|
||
stdout.write(f' - SQL 已删 {table}: {n}')
|
||
except Exception:
|
||
pass
|
||
|
||
# 最后删主表(再次校验主键)
|
||
deleted, _ = Club.query.filter(club_id=ALLOWED_CLUB_ID).delete()
|
||
stdout.write(style.SUCCESS(f' - 已删 Club 主表 lsx (affected={deleted})'))
|
||
|
||
if remove_cert_dir:
|
||
cert_dir = os.path.join(club_wx_cert_root(), ALLOWED_CLUB_ID)
|
||
if os.path.isdir(cert_dir):
|
||
shutil.rmtree(cert_dir, ignore_errors=True)
|
||
stdout.write(style.SUCCESS(f' - 已删证书目录 {cert_dir}'))
|
||
|
||
|
||
class Command(BaseCommand):
|
||
help = '【仅 lsx/龙先生】按商户号预检/删除俱乐部配置;绝不碰其他俱乐部'
|
||
|
||
def add_arguments(self, parser):
|
||
parser.add_argument(
|
||
'mch_id',
|
||
nargs='?',
|
||
default='17480975501',
|
||
help='用于核对的商户号(默认 17480975501);删除范围仍固定为 lsx',
|
||
)
|
||
parser.add_argument(
|
||
'--precheck',
|
||
action='store_true',
|
||
help='只预检列出将删内容(默认行为也是预检)',
|
||
)
|
||
parser.add_argument('--apply', action='store_true', help='真正删除(需配合确认开关)')
|
||
parser.add_argument(
|
||
'--i-understand-lsx-only',
|
||
action='store_true',
|
||
help='确认仅删除龙先生 lsx,不涉及其他俱乐部',
|
||
)
|
||
parser.add_argument(
|
||
'--keep-cert-dir',
|
||
action='store_true',
|
||
help='保留磁盘 wx_certs/lsx 目录',
|
||
)
|
||
parser.add_argument(
|
||
'--club-id',
|
||
type=str,
|
||
default=ALLOWED_CLUB_ID,
|
||
help='必须为 lsx;传其他值会直接拒绝',
|
||
)
|
||
|
||
def handle(self, *args, **options):
|
||
club_id = (options.get('club_id') or ALLOWED_CLUB_ID).strip()
|
||
_assert_lsx_only(club_id)
|
||
|
||
mch_id = str(options.get('mch_id') or '').strip()
|
||
apply = bool(options.get('apply'))
|
||
confirm = bool(options.get('i_understand_lsx_only'))
|
||
# 默认/显式 precheck 都走预检;只有 apply+确认才删除
|
||
do_delete = apply and confirm
|
||
|
||
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||
if not club:
|
||
raise CommandError(
|
||
f'未找到 club_id={ALLOWED_CLUB_ID}。不会删除任何其他俱乐部。'
|
||
)
|
||
|
||
_precheck_report(self.stdout, self.style, club, mch_id)
|
||
|
||
if not do_delete:
|
||
if apply and not confirm:
|
||
self.stdout.write(self.style.ERROR(
|
||
'\n已拒绝删除:缺少 --i-understand-lsx-only(防止误删)。'
|
||
))
|
||
self.stdout.write(self.style.WARNING('\n当前仅预检,数据库未改动。'))
|
||
return
|
||
|
||
# 删除前再拦一道:其他俱乐部同商户号只警告
|
||
others = _find_other_clubs_with_mch(mch_id, ALLOWED_CLUB_ID) if mch_id else []
|
||
if others:
|
||
self.stdout.write(self.style.ERROR(
|
||
'其他俱乐部存在同商户号配置,本命令仍只删 lsx,下列俱乐部保持不动:'
|
||
))
|
||
for o in others:
|
||
self.stdout.write(f' KEEP {o.club_id}')
|
||
|
||
self.stdout.write(self.style.WARNING('\n开始删除(仅 lsx)...'))
|
||
_delete_lsx(
|
||
self.stdout,
|
||
self.style,
|
||
remove_cert_dir=not bool(options.get('keep_cert_dir')),
|
||
)
|
||
self.stdout.write(self.style.SUCCESS('\n完成:仅龙先生 lsx 配置已清理。'))
|