feat: 增加 import_ufo_catalog,仅向 club=ufo 灌入商品专区

默认 dry-run;--apply 写库并上传 OSS;可选 --with-banners。内置 ufo_catalog.json。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-22 23:20:31 +08:00
parent a483236d45
commit 9d49c96e64
3 changed files with 4406 additions and 0 deletions

1
jituan/data/README.md Normal file
View File

@@ -0,0 +1 @@
# 内置数据目录UFO 商品导入 catalog 等

4036
jituan/data/ufo_catalog.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,369 @@
# -*- coding: utf-8 -*-
"""
将原 UFO 站点商品目录灌入星阙,且只写 club_id=ufo。
默认 dry-run只打印计划不写库。真正写入必须加 --apply。
服务器上(复制整段即可):
cd /opt/1panel/apps/openresty/nginx/www/sites/43.142.166.152.443/django
./venv/bin/python manage.py import_ufo_catalog
./venv/bin/python manage.py import_ufo_catalog --apply
可选:
--leixing-name 三角洲
--with-banners 同时写入 accept_order / accept_order_banner2 轮播
--catalog /path/to.json 自定义目录文件(默认用内置 jituan/data/ufo_catalog.json
--skip-oss 不上传图,直接用原 CDN 完整 URL 写入(仅应急)
"""
from __future__ import annotations
import json
import mimetypes
import os
import re
import ssl
import uuid
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from urllib.request import Request, urlopen
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
TARGET_CLUB = 'ufo'
DEFAULT_LEIXING = '三角洲'
DEFAULT_CATALOG = Path(__file__).resolve().parents[2] / 'data' / 'ufo_catalog.json'
class Command(BaseCommand):
help = '导入 UFO 商品/专区到 club_id=ufo默认 dry-run加 --apply 才写库)'
def add_arguments(self, parser):
parser.add_argument(
'--apply',
action='store_true',
help='真正写库(仅 ufo。不加则只预览。',
)
parser.add_argument(
'--catalog',
type=str,
default='',
help='catalog.json 路径,默认 jituan/data/ufo_catalog.json',
)
parser.add_argument(
'--leixing-name',
type=str,
default=DEFAULT_LEIXING,
help='商品类型名称(全局 ShangpinLeixing.jieshao默认三角洲',
)
parser.add_argument(
'--with-banners',
action='store_true',
help='同时导入主轮播/第二横幅到 Lunbopage_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',
)
def handle(self, *args, **options):
club_id = (options.get('club_id') or '').strip()
if club_id != TARGET_CLUB:
raise CommandError(
f'拒绝执行:本命令只允许 club_id={TARGET_CLUB},你传的是 {club_id!r}'
'禁止影响 xq/xzj/其它俱乐部。'
)
catalog_path = Path(options['catalog']).expanduser() if options['catalog'] else DEFAULT_CATALOG
if not catalog_path.is_file():
raise CommandError(f'找不到目录文件: {catalog_path}')
data = json.loads(catalog_path.read_text(encoding='utf-8'))
products = data.get('products') or []
zones = data.get('zones') or []
banners = data.get('banners') or []
banners2 = data.get('banners2') or []
leixing_name = (options['leixing_name'] or DEFAULT_LEIXING).strip()
apply = bool(options['apply'])
with_banners = bool(options['with_banners'])
skip_oss = bool(options['skip_oss'])
from jituan.models import Club, ClubShangpinLeixingConfig
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
if not Club.query.filter(club_id=TARGET_CLUB).exists():
raise CommandError(f'俱乐部 {TARGET_CLUB} 不存在,请先 create_club ufo')
leixing = (
ShangpinLeixing.query.filter(jieshao__icontains=leixing_name)
.exclude(shenhezhuangtai=2)
.order_by('id')
.first()
)
if not leixing:
raise CommandError(
f'找不到全局商品类型「{leixing_name}」。请先在后台建好该类型(集团共享),再跑本命令。'
)
self.stdout.write(self.style.NOTICE(
f'目标俱乐部={TARGET_CLUB} 类型={leixing.jieshao}(id={leixing.id}) '
f'专区={len(zones)} 商品={len(products)} banners={len(banners)}+{len(banners2)} '
f'mode={"APPLY" if apply else "DRY-RUN"}'
))
# 计划摘要
for z in zones:
self.stdout.write(f' 专区: {z.get("name")} goods={len(z.get("goods_ids") or [])}')
for p in products[:5]:
self.stdout.write(
f' 商品样例: [{p.get("source_id")}] {p.get("title")} ¥{p.get("price")}{p.get("zone_name")}'
)
if len(products) > 5:
self.stdout.write(f' ... 其余 {len(products) - 5} 个商品省略')
if not apply:
self.stdout.write(self.style.WARNING(
'当前为 dry-run未写库。确认无误后执行\n'
' ./venv/bin/python manage.py import_ufo_catalog --apply\n'
'需要轮播再加: --with-banners'
))
return
stats = {
'leixing_enabled': 0,
'zones_created': 0,
'zones_updated': 0,
'products_created': 0,
'products_updated': 0,
'banners': 0,
'oss_ok': 0,
'oss_fail': 0,
}
def to_oss(url: str, prefix: str) -> str:
"""下载原图并上传 OSS返回相对路径失败返回空串。"""
if not url:
return ''
if skip_oss:
return url
# 已是相对路径
if not str(url).startswith('http'):
return url
try:
path = self._upload_remote(url, prefix)
if path:
stats['oss_ok'] += 1
return path
stats['oss_fail'] += 1
self.stderr.write(f'OSS 失败,保留原 URL: {url}')
return url
except Exception as exc: # noqa: BLE001
stats['oss_fail'] += 1
self.stderr.write(f'OSS 异常 {url}: {exc}')
return url
with transaction.atomic():
# 1) 上架三角洲到 ufo
cfg, created = ClubShangpinLeixingConfig.query.get_or_create(
club_id=TARGET_CLUB,
leixing_id=leixing.id,
defaults={
'is_enabled': True,
'paixu': int(leixing.paixu or 0),
'tupian_url': '',
},
)
if not cfg.is_enabled:
cfg.is_enabled = True
cfg.save(update_fields=['is_enabled', 'UpdateTime'])
stats['leixing_enabled'] += 1
elif created:
stats['leixing_enabled'] += 1
# 2) 专区
zone_id_map = {} # source_cat2_id -> ShangpinZhuanqu.id
for idx, z in enumerate(zones):
name = (z.get('name') or '').strip()
if not name:
continue
src_id = int(z.get('source_id') or 0)
thumb = to_oss(z.get('thumb_url') or '', f'ufo/zones/{src_id or idx}')
row = ShangpinZhuanqu.query.filter(
club_id=TARGET_CLUB,
mingzi=name,
leixing_id=leixing.id,
dianpu__isnull=True,
).first()
if row:
row.tupian_url = thumb or row.tupian_url
row.shenhezhuangtai = 1
row.paixu = max(len(zones) - idx, 0)
row.save(update_fields=['tupian_url', 'shenhezhuangtai', 'paixu', 'UpdateTime'])
stats['zones_updated'] += 1
else:
row = ShangpinZhuanqu.query.create(
mingzi=name,
leixing_id=leixing.id,
club_id=TARGET_CLUB,
tupian_url=thumb or '',
shenhezhuangtai=1,
paixu=max(len(zones) - idx, 0),
dianpu=None,
dianpu_leixing=None,
)
stats['zones_created'] += 1
if src_id:
zone_id_map[src_id] = row.id
zone_id_map[name] = row.id
# 3) 商品
for p in products:
title = (p.get('title') or '').strip()
if not title:
continue
zone_name = (p.get('zone_name') or p.get('target_zhuanqu_name') or '').strip()
cat2 = int(p.get('cat2_id') or 0)
zhuanqu_id = zone_id_map.get(cat2) or zone_id_map.get(zone_name)
if not zhuanqu_id:
self.stderr.write(f'跳过无专区商品: {title} zone={zone_name}')
continue
price = self._decimal(p.get('price'))
intro = (p.get('intro') or '').strip()
xuzhi = (p.get('buy_tips_text') or '').strip()
if p.get('rise_reason'):
xuzhi = ((xuzhi + '\n\n') if xuzhi else '') + f"加价说明:{p.get('rise_reason')}"
pics = list(p.get('pics_url') or [])
thumb = p.get('thumb_url') or (pics[0] if pics else '')
tupian = to_oss(thumb, f'ufo/products/{p.get("source_id")}')
rule_imgs = list(p.get('rule_images_url') or [])
guize = to_oss(rule_imgs[0], f'ufo/rules/{p.get("source_id")}') if rule_imgs else ''
weigh = int(p.get('weigh') or p.get('home_weigh') or 0)
on_shelf = int(p.get('status') or 1) == 1
is_raffle = int(p.get('raffle_id') or 0) > 0
row = Shangpin.query.filter(
club_id=TARGET_CLUB,
biaoqian=title,
zhuanqu_id=zhuanqu_id,
dianpu__isnull=True,
).first()
fields = dict(
biaoqian=title,
jiage=price,
kucun=99999,
leixing_id=leixing.id,
zhuanqu_id=zhuanqu_id,
jieshao=intro,
xiadan_xuzhi=xuzhi[:5000] if xuzhi else '',
guize_tupian=guize or '',
tupian_url=tupian or '',
yaoqiuleixing=leixing.yaoqiuleixing,
huiyuan_id=leixing.huiyuan_id,
yongjin=leixing.yongjin,
paixu=weigh,
shenhezhuangtai=1,
shangjia_zhuangtai=on_shelf,
club_id=TARGET_CLUB,
shi_zhuanpan=bool(is_raffle),
dianpu=None,
dianpu_leixing=None,
)
if row:
for k, v in fields.items():
setattr(row, k, v)
row.save()
stats['products_updated'] += 1
else:
Shangpin.query.create(
zhenshi_xiaoliang=0,
duiwai_xiaoliang=0,
**fields,
)
stats['products_created'] += 1
# 4) 轮播(可选)
if with_banners:
from config.models import Lunbo
from jituan.services.display_config import (
LUNBO_PAGE_ACCEPT_BANNER2,
LUNBO_PAGE_ACCEPT_ORDER,
lunbo_image_type_for_page,
)
def upsert_lunbo(page_key, items):
image_type = lunbo_image_type_for_page(page_key)
# 只清本俱乐部本 page_key不动其它
Lunbo.query.filter(
club_id=TARGET_CLUB,
page_key=page_key,
ImageType=image_type,
).delete()
for b in items:
url = to_oss(b.get('url') or '', f'ufo/lunbo/{page_key}')
if not url:
continue
Lunbo.query.create(
club_id=TARGET_CLUB,
page_key=page_key,
ImageURL=url,
ImageType=image_type,
)
stats['banners'] += 1
upsert_lunbo(LUNBO_PAGE_ACCEPT_ORDER, banners)
upsert_lunbo(LUNBO_PAGE_ACCEPT_BANNER2, banners2)
self.stdout.write(self.style.SUCCESS(
'完成(仅 club=ufo: '
f"类型上架={stats['leixing_enabled']} "
f"专区+={stats['zones_created']}/改={stats['zones_updated']} "
f"商品+={stats['products_created']}/改={stats['products_updated']} "
f"轮播={stats['banners']} "
f"OSS成功={stats['oss_ok']} 失败={stats['oss_fail']}"
))
@staticmethod
def _decimal(v) -> Decimal:
try:
return Decimal(str(v if v is not None else '0'))
except (InvalidOperation, TypeError, ValueError):
return Decimal('0')
@staticmethod
def _upload_remote(url: str, prefix: str) -> str:
from utils.oss_utils import upload_to_oss
ctx = ssl.create_default_context()
req = Request(url, headers={'User-Agent': 'UFO-Import/1.0'})
with urlopen(req, timeout=60, context=ctx) as resp:
raw = resp.read()
ctype = resp.headers.get('Content-Type') or ''
ext = mimetypes.guess_extension((ctype or '').split(';')[0].strip()) or ''
if not ext or ext == '.jpe':
m = re.search(r'\.(jpg|jpeg|png|gif|webp|bmp)(?:\?|$)', url, re.I)
ext = ('.' + m.group(1).lower()) if m else '.jpg'
if ext == '.jpeg':
ext = '.jpg'
rel = f"{prefix.strip('/')}/{uuid.uuid4().hex[:10]}{ext}"
bio = BytesIO(raw)
bio.name = os.path.basename(rel)
ok = upload_to_oss(bio, rel)
if not ok:
return ''
return rel