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:
1
jituan/data/README.md
Normal file
1
jituan/data/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# 内置数据目录:UFO 商品导入 catalog 等
|
||||
4036
jituan/data/ufo_catalog.json
Normal file
4036
jituan/data/ufo_catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
369
jituan/management/commands/import_ufo_catalog.py
Normal file
369
jituan/management/commands/import_ufo_catalog.py
Normal 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='同时导入主轮播/第二横幅到 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',
|
||||
)
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user