diff --git a/jituan/management/commands/delete_ufo_public_catalog.py b/jituan/management/commands/delete_ufo_public_catalog.py new file mode 100644 index 0000000..15c4355 --- /dev/null +++ b/jituan/management/commands/delete_ufo_public_catalog.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +""" +删除 UFO 俱乐部「正常/公共」商品目录(专区 + 商品)及对应 OSS 图片。 + +范围(必须全部满足,缺一不删): + - club_id 固定为 ufo(不可改目标) + - dianpu IS NULL(公共目录;店铺商品一律不碰) + - 表:shangpin、shangpin_zhuanqu + - OSS:商品 tupian_url / guize_tupian、专区 tupian_url + +不碰: + - 其它俱乐部(xq / lxs / xzj 等) + - 店铺商品(dianpu 非空) + - 全局 ShangpinLeixing + - ClubShangpinLeixingConfig、订单、轮播、转盘、统计等 + +用法: + + python manage.py delete_ufo_public_catalog + python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo +""" +from __future__ import annotations + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +ALLOWED_CLUB_ID = 'ufo' +NAME_HINT = 'UFO' # Club.name 须含此字样(大小写不敏感) + + +class Command(BaseCommand): + help = '删除 UFO 公共商品+专区及 OSS 图(默认 dry-run;仅 ufo)' + + def add_arguments(self, parser): + parser.add_argument( + '--apply', + action='store_true', + help='真正删库 + 删 OSS(默认仅打印清单)', + ) + parser.add_argument( + '--i-confirm-club', + type=str, + default='', + help='必须等于 ufo,才允许 --apply', + ) + parser.add_argument( + '--club-id', + type=str, + default=ALLOWED_CLUB_ID, + help='仅允许 ufo;传其它值会直接拒绝', + ) + + def handle(self, *args, **options): + from jituan.models import Club + from products.models import Shangpin, ShangpinZhuanqu + from utils.oss_utils import delete_from_oss + + club_id = (options.get('club_id') or '').strip() + apply = bool(options.get('apply')) + confirm = (options.get('i_confirm_club') or '').strip() + + if club_id != ALLOWED_CLUB_ID: + raise CommandError( + f'安全拦截:本命令只允许 club_id={ALLOWED_CLUB_ID!r},你传的是 {club_id!r}' + ) + + club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first() + if not club: + raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在,无需删除') + if NAME_HINT.lower() not in (club.name or '').lower(): + raise CommandError( + f'安全拦截:club_id={ALLOWED_CLUB_ID!r} 的 name={club.name!r} ' + f'不含「{NAME_HINT}」,拒绝执行。' + ) + + if apply and confirm != ALLOWED_CLUB_ID: + raise CommandError( + '安全拦截:--apply 必须同时传 --i-confirm-club=ufo\n' + ' 例:python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo' + ) + + zones = list( + ShangpinZhuanqu.query.filter( + club_id=ALLOWED_CLUB_ID, + dianpu__isnull=True, + ).order_by('id') + ) + products = list( + Shangpin.query.filter( + club_id=ALLOWED_CLUB_ID, + dianpu__isnull=True, + ).order_by('id') + ) + + oss_urls = [] + for z in zones: + if z.tupian_url: + oss_urls.append(('zone', z.id, z.tupian_url)) + for p in products: + if p.tupian_url: + oss_urls.append(('product', p.id, p.tupian_url)) + if p.guize_tupian: + oss_urls.append(('product_guize', p.id, p.guize_tupian)) + + mode = 'APPLY' if apply else 'DRY-RUN' + self.stdout.write(self.style.NOTICE( + f'\n===== {mode} 删除 UFO 公共商品目录 =====\n' + f'club_id={ALLOWED_CLUB_ID} name={club.name!r}\n' + f'公共专区={len(zones)} 公共商品={len(products)} ' + f'待删 OSS 路径={len(oss_urls)}\n' + )) + + self.stdout.write('—— 专区 ——') + for z in zones[:50]: + self.stdout.write( + f' id={z.id} mingzi={z.mingzi!r} leixing_id={z.leixing_id} ' + f'tupian={z.tupian_url or "-"}' + ) + if len(zones) > 50: + self.stdout.write(f' ... 其余 {len(zones) - 50} 个专区省略') + + self.stdout.write('—— 商品 ——') + for p in products[:80]: + self.stdout.write( + f' id={p.id} {p.biaoqian!r} ¥{p.jiage} ' + f'zhuanqu_id={p.zhuanqu_id} tupian={p.tupian_url or "-"}' + ) + if len(products) > 80: + self.stdout.write(f' ... 其余 {len(products) - 80} 个商品省略') + + self.stdout.write(f'—— OSS 将删 {len(oss_urls)} 个对象(相对路径)——') + for kind, oid, url in oss_urls[:30]: + self.stdout.write(f' [{kind}#{oid}] {url}') + if len(oss_urls) > 30: + self.stdout.write(f' ... 其余 {len(oss_urls) - 30} 个省略') + + if not apply: + self.stdout.write(self.style.WARNING( + '\n以上仅计划,未删库、未删 OSS。\n' + '确认无误后:\n' + ' python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo\n' + )) + return + + if not zones and not products: + self.stdout.write(self.style.SUCCESS('无公共专区/商品,无需删除。')) + return + + with transaction.atomic(): + # 再按条件删一遍,避免误伤其它 club / 店铺商品 + n_prod, _ = Shangpin.query.filter( + club_id=ALLOWED_CLUB_ID, + dianpu__isnull=True, + ).delete() + n_zone, _ = ShangpinZhuanqu.query.filter( + club_id=ALLOWED_CLUB_ID, + dianpu__isnull=True, + ).delete() + + oss_ok = 0 + oss_fail = 0 + seen = set() + for _, _, url in oss_urls: + key = (url or '').strip() + if not key or key in seen: + continue + seen.add(key) + if delete_from_oss(key): + oss_ok += 1 + else: + oss_fail += 1 + self.stderr.write(f'OSS 删除失败(已记日志,继续): {key}') + + self.stdout.write(self.style.SUCCESS( + f'\n删除完成: products={n_prod} zones={n_zone} ' + f'oss_ok={oss_ok} oss_fail={oss_fail}\n' + f'未动:其它俱乐部、店铺商品、全局类型、订单/转盘等。' + ))