fix: UFO 导入必须显式 --leixing-id,避免三角洲类型挂错
新增 --list-leixing;名称不含「三角洲」直接拦截;禁止模糊匹配。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,18 +3,20 @@
|
|||||||
将原 UFO 站点商品目录灌入星阙,且只写 club_id=ufo。
|
将原 UFO 站点商品目录灌入星阙,且只写 club_id=ufo。
|
||||||
|
|
||||||
默认 dry-run(只打印计划,不写库)。真正写入必须加 --apply。
|
默认 dry-run(只打印计划,不写库)。真正写入必须加 --apply。
|
||||||
|
【必须】用 --leixing-id 指定三角洲类型主键,禁止靠模糊名字瞎挂。
|
||||||
|
|
||||||
服务器上(复制整段即可):
|
服务器上(复制整段):
|
||||||
|
|
||||||
cd /opt/1panel/apps/openresty/nginx/www/sites/43.142.166.152.443/django
|
cd /opt/1panel/apps/openresty/nginx/www/sites/43.142.166.152.443/django
|
||||||
./venv/bin/python manage.py import_ufo_catalog
|
git pull origin main
|
||||||
./venv/bin/python manage.py import_ufo_catalog --apply
|
|
||||||
|
|
||||||
可选:
|
# 先看全部类型 id(核对哪一行是三角洲)
|
||||||
--leixing-name 三角洲
|
./venv/bin/python manage.py import_ufo_catalog --list-leixing
|
||||||
--with-banners 同时写入 accept_order / accept_order_banner2 轮播
|
|
||||||
--catalog /path/to.json 自定义目录文件(默认用内置 jituan/data/ufo_catalog.json)
|
# 假设核对后 id=3(示例!以你库里为准)
|
||||||
--skip-oss 不上传图,直接用原 CDN 完整 URL 写入(仅应急)
|
./venv/bin/python manage.py import_ufo_catalog --leixing-id=3
|
||||||
|
./venv/bin/python manage.py import_ufo_catalog --leixing-id=3 --apply
|
||||||
|
./venv/bin/python manage.py import_ufo_catalog --leixing-id=3 --apply --with-banners
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -33,54 +35,59 @@ from django.core.management.base import BaseCommand, CommandError
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
TARGET_CLUB = 'ufo'
|
TARGET_CLUB = 'ufo'
|
||||||
DEFAULT_LEIXING = '三角洲'
|
EXPECTED_NAME_HINT = '三角洲'
|
||||||
DEFAULT_CATALOG = Path(__file__).resolve().parents[2] / 'data' / 'ufo_catalog.json'
|
DEFAULT_CATALOG = Path(__file__).resolve().parents[2] / 'data' / 'ufo_catalog.json'
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = '导入 UFO 商品/专区到 club_id=ufo(默认 dry-run,加 --apply 才写库)'
|
help = '导入 UFO 商品/专区到 club_id=ufo(默认 dry-run;必须 --leixing-id)'
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--apply', action='store_true', help='真正写库(仅 ufo)')
|
||||||
|
parser.add_argument('--catalog', type=str, default='', help='catalog.json 路径')
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--apply',
|
'--list-leixing',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
help='真正写库(仅 ufo)。不加则只预览。',
|
help='只列出全局商品类型 id/名称,用来核对三角洲真实 id',
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--catalog',
|
'--leixing-id',
|
||||||
type=str,
|
type=int,
|
||||||
default='',
|
default=0,
|
||||||
help='catalog.json 路径,默认 jituan/data/ufo_catalog.json',
|
help='【必填,推荐】全局商品类型主键。先 --list-leixing 核对',
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--leixing-name',
|
'--leixing-name',
|
||||||
type=str,
|
type=str,
|
||||||
default=DEFAULT_LEIXING,
|
default='',
|
||||||
help='商品类型名称(全局 ShangpinLeixing.jieshao),默认:三角洲',
|
help='未传 id 时:按 jieshao「全名精确匹配」且必须唯一;禁止模糊匹配',
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--with-banners',
|
|
||||||
action='store_true',
|
|
||||||
help='同时导入主轮播/第二横幅到 Lunbo(page_key=accept_order / accept_order_banner2)',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--skip-oss',
|
|
||||||
action='store_true',
|
|
||||||
help='不上传 OSS,直接把原站图片 URL 写入(应急;正式建议走 OSS)',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--club-id',
|
|
||||||
type=str,
|
|
||||||
default=TARGET_CLUB,
|
|
||||||
help='禁止改成其它俱乐部;仅允许 ufo',
|
|
||||||
)
|
)
|
||||||
|
parser.add_argument('--with-banners', action='store_true', help='同时导入点单主轮播+第二横幅')
|
||||||
|
parser.add_argument('--skip-oss', action='store_true', help='不上传 OSS,保留原 URL(应急)')
|
||||||
|
parser.add_argument('--club-id', type=str, default=TARGET_CLUB, help='仅允许 ufo')
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
|
from products.models import ShangpinLeixing
|
||||||
|
|
||||||
|
if options.get('list_leixing'):
|
||||||
|
self.stdout.write('全局商品类型清单:')
|
||||||
|
for lx in ShangpinLeixing.query.order_by('id'):
|
||||||
|
mark = ' <<-- 名称含三角洲' if EXPECTED_NAME_HINT in (lx.jieshao or '') else ''
|
||||||
|
self.stdout.write(
|
||||||
|
f' id={lx.id} name={lx.jieshao!r} '
|
||||||
|
f'shenhezhuangtai={lx.shenhezhuangtai} paixu={lx.paixu}{mark}'
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
'请人工确认「三角洲」那一行的 id,再执行:\n'
|
||||||
|
' ./venv/bin/python manage.py import_ufo_catalog --leixing-id=<id>\n'
|
||||||
|
' ./venv/bin/python manage.py import_ufo_catalog --leixing-id=<id> --apply'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
club_id = (options.get('club_id') or '').strip()
|
club_id = (options.get('club_id') or '').strip()
|
||||||
if club_id != TARGET_CLUB:
|
if club_id != TARGET_CLUB:
|
||||||
raise CommandError(
|
raise CommandError(
|
||||||
f'拒绝执行:本命令只允许 club_id={TARGET_CLUB},你传的是 {club_id!r}。'
|
f'拒绝执行:只允许 club_id={TARGET_CLUB},你传的是 {club_id!r}'
|
||||||
'禁止影响 xq/xzj/其它俱乐部。'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
catalog_path = Path(options['catalog']).expanduser() if options['catalog'] else DEFAULT_CATALOG
|
catalog_path = Path(options['catalog']).expanduser() if options['catalog'] else DEFAULT_CATALOG
|
||||||
@@ -92,35 +99,38 @@ class Command(BaseCommand):
|
|||||||
zones = data.get('zones') or []
|
zones = data.get('zones') or []
|
||||||
banners = data.get('banners') or []
|
banners = data.get('banners') or []
|
||||||
banners2 = data.get('banners2') or []
|
banners2 = data.get('banners2') or []
|
||||||
leixing_name = (options['leixing_name'] or DEFAULT_LEIXING).strip()
|
|
||||||
apply = bool(options['apply'])
|
apply = bool(options['apply'])
|
||||||
with_banners = bool(options['with_banners'])
|
with_banners = bool(options['with_banners'])
|
||||||
skip_oss = bool(options['skip_oss'])
|
skip_oss = bool(options['skip_oss'])
|
||||||
|
|
||||||
from jituan.models import Club, ClubShangpinLeixingConfig
|
from jituan.models import Club, ClubShangpinLeixingConfig
|
||||||
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
from products.models import Shangpin, ShangpinZhuanqu
|
||||||
|
|
||||||
if not Club.query.filter(club_id=TARGET_CLUB).exists():
|
if not Club.query.filter(club_id=TARGET_CLUB).exists():
|
||||||
raise CommandError(f'俱乐部 {TARGET_CLUB} 不存在,请先 create_club ufo')
|
raise CommandError(f'俱乐部 {TARGET_CLUB} 不存在,请先 create_club ufo')
|
||||||
|
|
||||||
leixing = (
|
leixing = self._resolve_leixing(
|
||||||
ShangpinLeixing.query.filter(jieshao__icontains=leixing_name)
|
ShangpinLeixing,
|
||||||
.exclude(shenhezhuangtai=2)
|
leixing_id=int(options.get('leixing_id') or 0),
|
||||||
.order_by('id')
|
leixing_name=(options.get('leixing_name') or '').strip(),
|
||||||
.first()
|
|
||||||
)
|
)
|
||||||
if not leixing:
|
|
||||||
raise CommandError(
|
|
||||||
f'找不到全局商品类型「{leixing_name}」。请先在后台建好该类型(集团共享),再跑本命令。'
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write(self.style.NOTICE(
|
self.stdout.write(self.style.NOTICE(
|
||||||
f'目标俱乐部={TARGET_CLUB} 类型={leixing.jieshao}(id={leixing.id}) '
|
f'目标俱乐部={TARGET_CLUB} 类型={leixing.jieshao!r}(id={leixing.id}) '
|
||||||
|
f'shenhezhuangtai={leixing.shenhezhuangtai} '
|
||||||
f'专区={len(zones)} 商品={len(products)} banners={len(banners)}+{len(banners2)} '
|
f'专区={len(zones)} 商品={len(products)} banners={len(banners)}+{len(banners2)} '
|
||||||
f'mode={"APPLY" if apply else "DRY-RUN"}'
|
f'mode={"APPLY" if apply else "DRY-RUN"}'
|
||||||
))
|
))
|
||||||
|
if leixing.shenhezhuangtai == 2:
|
||||||
|
raise CommandError(
|
||||||
|
f'类型 id={leixing.id}「{leixing.jieshao}」是审核专用,拒绝挂载'
|
||||||
|
)
|
||||||
|
if EXPECTED_NAME_HINT not in (leixing.jieshao or ''):
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:类型 id={leixing.id} 名称是「{leixing.jieshao}」,'
|
||||||
|
f'不含「{EXPECTED_NAME_HINT}」。请重新 --list-leixing 核对后再传正确 --leixing-id。'
|
||||||
|
)
|
||||||
|
|
||||||
# 计划摘要
|
|
||||||
for z in zones:
|
for z in zones:
|
||||||
self.stdout.write(f' 专区: {z.get("name")} goods={len(z.get("goods_ids") or [])}')
|
self.stdout.write(f' 专区: {z.get("name")} goods={len(z.get("goods_ids") or [])}')
|
||||||
for p in products[:5]:
|
for p in products[:5]:
|
||||||
@@ -132,9 +142,9 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
if not apply:
|
if not apply:
|
||||||
self.stdout.write(self.style.WARNING(
|
self.stdout.write(self.style.WARNING(
|
||||||
'当前为 dry-run,未写库。确认无误后执行:\n'
|
'当前 dry-run,未写库。确认类型 id 无误后:\n'
|
||||||
' ./venv/bin/python manage.py import_ufo_catalog --apply\n'
|
f' ./venv/bin/python manage.py import_ufo_catalog --leixing-id={leixing.id} --apply\n'
|
||||||
'需要轮播再加: --with-banners'
|
f' ./venv/bin/python manage.py import_ufo_catalog --leixing-id={leixing.id} --apply --with-banners'
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -150,12 +160,10 @@ class Command(BaseCommand):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def to_oss(url: str, prefix: str) -> str:
|
def to_oss(url: str, prefix: str) -> str:
|
||||||
"""下载原图并上传 OSS,返回相对路径;失败返回空串。"""
|
|
||||||
if not url:
|
if not url:
|
||||||
return ''
|
return ''
|
||||||
if skip_oss:
|
if skip_oss:
|
||||||
return url
|
return url
|
||||||
# 已是相对路径
|
|
||||||
if not str(url).startswith('http'):
|
if not str(url).startswith('http'):
|
||||||
return url
|
return url
|
||||||
try:
|
try:
|
||||||
@@ -172,7 +180,6 @@ class Command(BaseCommand):
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# 1) 上架三角洲到 ufo
|
|
||||||
cfg, created = ClubShangpinLeixingConfig.query.get_or_create(
|
cfg, created = ClubShangpinLeixingConfig.query.get_or_create(
|
||||||
club_id=TARGET_CLUB,
|
club_id=TARGET_CLUB,
|
||||||
leixing_id=leixing.id,
|
leixing_id=leixing.id,
|
||||||
@@ -189,8 +196,7 @@ class Command(BaseCommand):
|
|||||||
elif created:
|
elif created:
|
||||||
stats['leixing_enabled'] += 1
|
stats['leixing_enabled'] += 1
|
||||||
|
|
||||||
# 2) 专区
|
zone_id_map = {}
|
||||||
zone_id_map = {} # source_cat2_id -> ShangpinZhuanqu.id
|
|
||||||
for idx, z in enumerate(zones):
|
for idx, z in enumerate(zones):
|
||||||
name = (z.get('name') or '').strip()
|
name = (z.get('name') or '').strip()
|
||||||
if not name:
|
if not name:
|
||||||
@@ -225,7 +231,6 @@ class Command(BaseCommand):
|
|||||||
zone_id_map[src_id] = row.id
|
zone_id_map[src_id] = row.id
|
||||||
zone_id_map[name] = row.id
|
zone_id_map[name] = row.id
|
||||||
|
|
||||||
# 3) 商品
|
|
||||||
for p in products:
|
for p in products:
|
||||||
title = (p.get('title') or '').strip()
|
title = (p.get('title') or '').strip()
|
||||||
if not title:
|
if not title:
|
||||||
@@ -295,7 +300,6 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
stats['products_created'] += 1
|
stats['products_created'] += 1
|
||||||
|
|
||||||
# 4) 轮播(可选)
|
|
||||||
if with_banners:
|
if with_banners:
|
||||||
from config.models import Lunbo
|
from config.models import Lunbo
|
||||||
from jituan.services.display_config import (
|
from jituan.services.display_config import (
|
||||||
@@ -306,7 +310,6 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def upsert_lunbo(page_key, items):
|
def upsert_lunbo(page_key, items):
|
||||||
image_type = lunbo_image_type_for_page(page_key)
|
image_type = lunbo_image_type_for_page(page_key)
|
||||||
# 只清本俱乐部本 page_key,不动其它
|
|
||||||
Lunbo.query.filter(
|
Lunbo.query.filter(
|
||||||
club_id=TARGET_CLUB,
|
club_id=TARGET_CLUB,
|
||||||
page_key=page_key,
|
page_key=page_key,
|
||||||
@@ -336,6 +339,48 @@ class Command(BaseCommand):
|
|||||||
f"OSS成功={stats['oss_ok']} 失败={stats['oss_fail']}"
|
f"OSS成功={stats['oss_ok']} 失败={stats['oss_fail']}"
|
||||||
))
|
))
|
||||||
|
|
||||||
|
def _resolve_leixing(self, ShangpinLeixing, leixing_id: int, leixing_name: str):
|
||||||
|
"""必须用 id;名字仅作精确匹配兜底,且必须唯一。"""
|
||||||
|
if leixing_id:
|
||||||
|
try:
|
||||||
|
return ShangpinLeixing.query.get(id=leixing_id)
|
||||||
|
except ShangpinLeixing.DoesNotExist as exc:
|
||||||
|
raise CommandError(f'类型 id={leixing_id} 不存在') from exc
|
||||||
|
|
||||||
|
name = (leixing_name or '').strip()
|
||||||
|
if not name:
|
||||||
|
raise CommandError(
|
||||||
|
'必须指定类型,先执行:\n'
|
||||||
|
' ./venv/bin/python manage.py import_ufo_catalog --list-leixing\n'
|
||||||
|
'再:\n'
|
||||||
|
' ./venv/bin/python manage.py import_ufo_catalog --leixing-id=<三角洲的id>'
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = list(ShangpinLeixing.query.filter(jieshao=name).order_by('id'))
|
||||||
|
if not rows:
|
||||||
|
# 列出接近的,方便人工选
|
||||||
|
near = list(
|
||||||
|
ShangpinLeixing.query.filter(jieshao__icontains=name).order_by('id')[:20]
|
||||||
|
)
|
||||||
|
hint = '\n'.join(
|
||||||
|
f' id={x.id} name={x.jieshao!r} shenhezhuangtai={x.shenhezhuangtai}'
|
||||||
|
for x in near
|
||||||
|
) or ' (无近似匹配)'
|
||||||
|
raise CommandError(
|
||||||
|
f'找不到全名精确等于「{name}」的类型。近似:\n{hint}\n'
|
||||||
|
'请用 --leixing-id=... 明确指定。'
|
||||||
|
)
|
||||||
|
if len(rows) > 1:
|
||||||
|
detail = '\n'.join(
|
||||||
|
f' id={x.id} name={x.jieshao!r} shenhezhuangtai={x.shenhezhuangtai}'
|
||||||
|
for x in rows
|
||||||
|
)
|
||||||
|
raise CommandError(
|
||||||
|
f'名称「{name}」匹配到多条,拒绝自动选择,避免挂错:\n{detail}\n'
|
||||||
|
'请用 --leixing-id=... 指定其中一条。'
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _decimal(v) -> Decimal:
|
def _decimal(v) -> Decimal:
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user