feat: 新增删除 UFO 公共商品与专区(含 OSS)管理命令

仅 club_id=ufo 且 dianpu 为空;默认 dry-run,--apply 需 --i-confirm-club=ufo。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-24 16:40:46 +08:00
parent 2a2ed25f0c
commit d771add6e8

View File

@@ -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'未动:其它俱乐部、店铺商品、全局类型、订单/转盘等。'
))