Files
Django/jituan/management/commands/import_ufo_catalog.py
XingQue bc29118a6e fix: UFO 导入图片路径对齐后台目录,失败不写外站 URL
商品/专区/规则/轮播分别进 shangpin/shangpintupian、zhuanqu、guize、a_long/lunbo;库只存相对路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:34:09 +08:00

416 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
将原 UFO 站点商品目录灌入星阙,且只写 club_id=ufo。
默认 dry-run只打印计划不写库。真正写入必须加 --apply。
默认按全局类型名「三角洲」**全名精确匹配**找 id不含这三字的一律不要
找到唯一一条后:
1) ClubShangpinLeixingConfig 给 club=ufo 上架该类型
2) ShangpinZhuanqu / Shangpin 的 leixing_id 挂这个全局类型 id且 club_id=ufo
服务器上(复制整段):
cd /opt/1panel/apps/openresty/nginx/www/sites/43.142.166.152.443/django
git pull origin main
./venv/bin/python manage.py import_ufo_catalog
./venv/bin/python manage.py import_ufo_catalog --apply
./venv/bin/python manage.py import_ufo_catalog --apply --with-banners
"""
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_NAME = '三角洲' # 全名精确匹配,不模糊
DEFAULT_CATALOG = Path(__file__).resolve().parents[2] / 'data' / 'ufo_catalog.json'
class Command(BaseCommand):
help = '导入 UFO 商品/专区到 club_id=ufo默认 dry-run类型名默认精确匹配「三角洲」'
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(
'--list-leixing',
action='store_true',
help='只列出全局商品类型 id/名称',
)
parser.add_argument(
'--leixing-id',
type=int,
default=0,
help='可选:直接指定全局类型主键;不传则按 --leixing-name 精确匹配',
)
parser.add_argument(
'--leixing-name',
type=str,
default=DEFAULT_LEIXING_NAME,
help='全局 ShangpinLeixing.jieshao 全名精确匹配,默认:三角洲',
)
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):
from products.models import ShangpinLeixing
if options.get('list_leixing'):
self.stdout.write('全局商品类型清单ShangpinLeixing')
for lx in ShangpinLeixing.query.order_by('id'):
mark = ' <<-- 精确名=三角洲' if (lx.jieshao or '') == DEFAULT_LEIXING_NAME 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(
'默认会精确匹配 name==「三角洲」;也可 --leixing-id=... 覆盖。\n'
'俱乐部上架表 ClubShangpinLeixingConfig 会在 --apply 时给 ufo 自动开启该类型。'
))
return
club_id = (options.get('club_id') or '').strip()
if club_id != TARGET_CLUB:
raise CommandError(
f'拒绝执行:只允许 club_id={TARGET_CLUB},你传的是 {club_id!r}'
)
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 []
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, ShangpinZhuanqu
if not Club.query.filter(club_id=TARGET_CLUB).exists():
raise CommandError(f'俱乐部 {TARGET_CLUB} 不存在,请先 create_club ufo')
leixing = self._resolve_leixing(
ShangpinLeixing,
leixing_id=int(options.get('leixing_id') or 0),
leixing_name=(options.get('leixing_name') or '').strip(),
)
self.stdout.write(self.style.NOTICE(
f'目标俱乐部={TARGET_CLUB} 类型={leixing.jieshao!r}(id={leixing.id}) '
f'shenhezhuangtai={leixing.shenhezhuangtai} '
f'专区={len(zones)} 商品={len(products)} banners={len(banners)}+{len(banners2)} '
f'mode={"APPLY" if apply else "DRY-RUN"}'
))
if leixing.shenhezhuangtai == 2:
raise CommandError(
f'类型 id={leixing.id}{leixing.jieshao}」是审核专用,拒绝挂载'
)
expect_name = (options.get('leixing_name') or DEFAULT_LEIXING_NAME).strip()
if (leixing.jieshao or '') != expect_name and not int(options.get('leixing_id') or 0):
raise CommandError(
f'安全拦截:解析到的类型名是「{leixing.jieshao}」,'
f'与要求的精确名「{expect_name}」不一致。'
)
self.stdout.write(
f'将写入ClubShangpinLeixingConfig(club=ufo, leixing_id={leixing.id}, is_enabled=True)'
f'专区/商品 leixing_id={leixing.id}, club_id=ufo'
)
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'
' ./venv/bin/python manage.py import_ufo_catalog --apply --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 → 返回相对路径(与后台 AddProduct 一致)。"""
if not url:
return ''
if skip_oss:
# 应急:允许写完整 URL正式导入不要加 --skip-oss
return url
if not str(url).startswith('http'):
return url
path = self._upload_remote(url, prefix)
if not path:
stats['oss_fail'] += 1
raise CommandError(f'OSS 上传失败,已中止以免库里写入外站链接: {url}')
stats['oss_ok'] += 1
return path
with transaction.atomic():
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
zone_id_map = {}
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 '', 'shangpin/zhuanqu')
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
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, 'shangpin/shangpintupian')
rule_imgs = list(p.get('rule_images_url') or [])
guize = to_oss(rule_imgs[0], 'shangpin/guize') 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
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)
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 '', 'a_long/lunbo')
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']}"
))
def _resolve_leixing(self, ShangpinLeixing, leixing_id: int, leixing_name: str):
"""优先 id否则 jieshao 全名精确匹配且必须唯一。不做 icontains。"""
if leixing_id:
try:
lx = ShangpinLeixing.query.get(id=leixing_id)
except ShangpinLeixing.DoesNotExist as exc:
raise CommandError(f'类型 id={leixing_id} 不存在') from exc
return lx
name = (leixing_name or DEFAULT_LEIXING_NAME).strip() or DEFAULT_LEIXING_NAME
rows = list(
ShangpinLeixing.query.filter(jieshao=name)
.exclude(shenhezhuangtai=2)
.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'找不到 jieshao 精确等于「{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}'
)
return rows[0]
@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:
"""返回 OSS 相对 key失败返回空串。与后台一致DB 只存相对路径。"""
from datetime import datetime
from utils.oss_utils import upload_to_oss
try:
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 ''
except Exception: # noqa: BLE001
return ''
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'
ts = datetime.now().strftime('%Y%m%d%H%M%S')
rel = f"{prefix.strip('/')}/{ts}_{uuid.uuid4().hex[:8]}{ext}"
bio = BytesIO(raw)
bio.name = os.path.basename(rel)
ok = upload_to_oss(bio, rel)
if not ok:
return ''
return rel # 关键:只返回相对路径,不返回完整 CDN URL