# -*- coding: utf-8 -*- """ 将 UFO 俱乐部已上架的「三角洲」公共商品目录复制到龙先生俱乐部(只增不减)。 涉及表(只写目标俱乐部,不改源、不删任何行): - club_shangpin_leixing_config ClubShangpinLeixingConfig 上架类型 - shangpin_zhuanqu ShangpinZhuanqu 公共专区 - shangpin Shangpin 公共商品 - OSS copy_in_oss 图物理复制到新相对路径 不碰: - shangpin_leixing(全局类型只复用,不新建) - 店铺商品(dianpu 非空) - 订单/统计/轮播/转盘活动表 - UFO 及其它俱乐部任何行 用法(服务器): cd /path/to/django ./venv/bin/python manage.py copy_ufo_sanjiaozhou_catalog --to-club=lxs # 确认清单后: ./venv/bin/python manage.py copy_ufo_sanjiaozhou_catalog --to-club=lxs --apply --i-confirm-to-club=lxs 注意:龙先生 club_id 可能是 lxs 或 lsx,脚本不猜测,必须显式传 --to-club, 且 Club.name 必须包含「龙先生」。 """ from __future__ import annotations import time import uuid from django.core.management.base import BaseCommand, CommandError from django.db import transaction DEFAULT_FROM_CLUB = 'ufo' DEFAULT_LEIXING_NAME = '三角洲' NAME_HINT = '龙先生' class Command(BaseCommand): help = ( '复制 UFO「三角洲」公共上架商品目录到龙先生(默认 dry-run;' '只 insert / 启用类型,不删不改源)' ) def add_arguments(self, parser): parser.add_argument( '--from-club', type=str, default=DEFAULT_FROM_CLUB, help=f'源俱乐部,默认 {DEFAULT_FROM_CLUB}', ) parser.add_argument( '--to-club', type=str, default='', help='目标俱乐部 club_id(必须显式指定,如 lxs;--list-clubs 时可省略)', ) parser.add_argument( '--leixing-name', type=str, default=DEFAULT_LEIXING_NAME, help=f'全局类型名全名精确匹配,默认「{DEFAULT_LEIXING_NAME}」', ) parser.add_argument( '--leixing-id', type=int, default=0, help='可选:直接指定全局 ShangpinLeixing.id(仍校验名称)', ) parser.add_argument( '--apply', action='store_true', help='真正写库 + OSS 复制(默认仅 dry-run)', ) parser.add_argument( '--i-confirm-to-club', type=str, default='', help='与 --to-club 完全一致时才允许 --apply', ) parser.add_argument( '--list-clubs', action='store_true', help='只列出 Club 表(含名称含「龙先生」的候选),不复制', ) def handle(self, *args, **options): from jituan.models import Club, ClubShangpinLeixingConfig from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu from utils.oss_utils import copy_in_oss if options.get('list_clubs'): self._list_clubs(Club) return from_club = (options.get('from_club') or DEFAULT_FROM_CLUB).strip() to_club = (options.get('to_club') or '').strip() leixing_name = (options.get('leixing_name') or DEFAULT_LEIXING_NAME).strip() apply = bool(options.get('apply')) confirm = (options.get('i_confirm_to_club') or '').strip() if not to_club: raise CommandError( '必须传 --to-club=...(龙先生当前库为 lxs)。' '仅查看俱乐部列表时用:python manage.py copy_ufo_sanjiaozhou_catalog --list-clubs' ) self.stdout.write(self.style.NOTICE('—— Club 表候选(名称含「龙先生」)——')) self._list_clubs(Club, only_hint=True) src_club = Club.query.filter(club_id=from_club).first() if not src_club: raise CommandError(f'源俱乐部不存在: club_id={from_club!r}') dst_club = Club.query.filter(club_id=to_club).first() if not dst_club: raise CommandError( f'目标俱乐部不存在: club_id={to_club!r}。' f'请先 --list-clubs 核对(常见:lxs 与 lsx 写反)。' ) if NAME_HINT not in (dst_club.name or ''): raise CommandError( f'安全拦截:目标 club_id={to_club!r} 的 name={dst_club.name!r} ' f'不含「{NAME_HINT}」,拒绝执行。' ) if from_club == to_club: raise CommandError('源与目标 club_id 不能相同') if apply: if confirm != to_club: raise CommandError( '安全拦截:--apply 必须同时传 --i-confirm-to-club,且与 --to-club 完全一致。\n' f' 例:--to-club={to_club} --apply --i-confirm-to-club={to_club}' ) leixing = self._resolve_leixing( ShangpinLeixing, leixing_id=int(options.get('leixing_id') or 0), leixing_name=leixing_name, ) if leixing.shenhezhuangtai == 2: raise CommandError( f'类型 id={leixing.id}「{leixing.jieshao}」是审核专用,拒绝挂载' ) # 源:公共专区 + 上架商品(dianpu 为空 = 俱乐部公共目录) src_zones = list( ShangpinZhuanqu.query.filter( club_id=from_club, leixing_id=leixing.id, shenhezhuangtai=1, dianpu__isnull=True, ).order_by('paixu', 'id') ) zone_ids = [z.id for z in src_zones] src_products = [] if zone_ids: src_products = list( Shangpin.query.filter( club_id=from_club, leixing_id=leixing.id, zhuanqu_id__in=zone_ids, shenhezhuangtai=1, shangjia_zhuangtai=True, dianpu__isnull=True, ).order_by('paixu', 'id') ) # 计划:Config cfg_existing = ClubShangpinLeixingConfig.query.filter( club_id=to_club, leixing_id=leixing.id, ).first() if cfg_existing is None: cfg_action = 'CREATE_ENABLE' elif not cfg_existing.is_enabled: cfg_action = 'ENABLE_EXISTING' else: cfg_action = 'SKIP_ALREADY_ENABLED' # 计划:专区 zone_plans = [] for z in src_zones: exist = ShangpinZhuanqu.query.filter( club_id=to_club, leixing_id=leixing.id, mingzi=z.mingzi, dianpu__isnull=True, ).first() if exist: zone_plans.append({ 'source': z, 'action': 'SKIP', 'target_id': exist.id, 'new_tupian_url': exist.tupian_url or '', }) else: zone_plans.append({ 'source': z, 'action': 'CREATE', 'target_id': None, 'new_tupian_url': None, }) zone_by_src_id = {p['source'].id: p for p in zone_plans} product_plans = [] for p in src_products: zp = zone_by_src_id.get(p.zhuanqu_id) if not zp: product_plans.append({ 'source': p, 'action': 'SKIP_NO_ZONE', 'reason': f'zhuanqu_id={p.zhuanqu_id} 不在本批三角洲专区', }) continue planned_zq_id = zp.get('target_id') if planned_zq_id: exist_p = Shangpin.query.filter( club_id=to_club, zhuanqu_id=planned_zq_id, biaoqian=p.biaoqian, dianpu__isnull=True, ).first() if exist_p: product_plans.append({ 'source': p, 'action': 'SKIP', 'zone_plan': zp, 'target_zhuanqu_id': planned_zq_id, }) continue product_plans.append({ 'source': p, 'action': 'CREATE', 'zone_plan': zp, 'target_zhuanqu_id': planned_zq_id, }) # —— 打印确认清单 —— mode = 'APPLY' if apply else 'DRY-RUN' self.stdout.write(self.style.NOTICE( f'\n===== {mode} =====\n' f'源: {from_club} ({src_club.name}) → 目标: {to_club} ({dst_club.name})\n' f'类型: jieshao={leixing.jieshao!r} id={leixing.id}(全局复用,不新建)\n' f'ClubShangpinLeixingConfig: {cfg_action}\n' f'源专区={len(src_zones)} 源上架商品={len(src_products)}\n' )) self.stdout.write('—— 专区计划 ——') for i, zp in enumerate(zone_plans, 1): z = zp['source'] self.stdout.write( f' [{i}] {zp["action"]} src_id={z.id} mingzi={z.mingzi!r} ' f'paixu={z.paixu} tupian={z.tupian_url or "-"}' + (f' → existing_id={zp["target_id"]}' if zp['target_id'] else '') ) self.stdout.write('—— 商品计划 ——') for i, pp in enumerate(product_plans, 1): p = pp['source'] zname = '' zp = pp.get('zone_plan') if zp: zname = zp['source'].mingzi self.stdout.write( f' [{i}] {pp["action"]} src_id={p.id} {p.biaoqian!r} ' f'¥{p.jiage} zone={zname!r} ' f'tupian={p.tupian_url or "-"} guize={p.guize_tupian or "-"}' + (f' reason={pp.get("reason")}' if pp.get('reason') else '') ) stats_preview = { 'cfg': cfg_action, 'zones_create': sum(1 for x in zone_plans if x['action'] == 'CREATE'), 'zones_skip': sum(1 for x in zone_plans if x['action'] == 'SKIP'), 'products_create': sum(1 for x in product_plans if x['action'] == 'CREATE'), 'products_skip': sum(1 for x in product_plans if x['action'] == 'SKIP'), 'products_skip_no_zone': sum(1 for x in product_plans if x['action'] == 'SKIP_NO_ZONE'), } self.stdout.write(self.style.NOTICE(f'\n统计预览: {stats_preview}')) if not apply: self.stdout.write(self.style.WARNING( '\n以上仅计划,未写库、未拷 OSS。\n' '确认无误后执行:\n' f' python manage.py copy_ufo_sanjiaozhou_catalog ' f'--to-club={to_club} --apply --i-confirm-to-club={to_club}\n' )) return # —— APPLY:先 OSS,再 DB 事务 —— def dest_key(category: str, src_url: str) -> str: ext = 'png' raw = (src_url or '').split('?', 1)[0] if '.' in raw.rsplit('/', 1)[-1]: ext = raw.rsplit('.', 1)[-1].lower()[:8] or 'png' ts = int(time.time() * 1000) rand = uuid.uuid4().hex[:8] return f'club/{to_club}/catalog/{category}/{ts}_{rand}.{ext}' def copy_img(src_url: str, category: str) -> str: if not src_url: return '' # 已是相对路径或 URL:物理复制到新 key key = dest_key(category, src_url) if not copy_in_oss(src_url, key): raise CommandError(f'OSS 复制失败,已中止(未写库): {src_url} → {key}') return key # 预复制专区/商品图(仅 CREATE) oss_ok = 0 for zp in zone_plans: if zp['action'] != 'CREATE': continue z = zp['source'] if z.tupian_url: zp['new_tupian_url'] = copy_img(z.tupian_url, 'zhuanqu') oss_ok += 1 else: zp['new_tupian_url'] = '' for pp in product_plans: if pp['action'] != 'CREATE': continue p = pp['source'] pp['new_tupian_url'] = copy_img(p.tupian_url, 'shangpin') if p.tupian_url else '' if p.tupian_url: oss_ok += 1 if p.guize_tupian: pp['new_guize_tupian'] = copy_img(p.guize_tupian, 'guize') oss_ok += 1 else: pp['new_guize_tupian'] = '' stats = { 'cfg_created': 0, 'cfg_enabled': 0, 'zones_created': 0, 'zones_skipped': 0, 'products_created': 0, 'products_skipped': 0, 'products_skipped_no_zone': 0, 'oss_copied': oss_ok, } with transaction.atomic(): cfg, created = ClubShangpinLeixingConfig.query.get_or_create( club_id=to_club, leixing_id=leixing.id, defaults={ 'is_enabled': True, 'paixu': int(leixing.paixu or 0), 'tupian_url': '', }, ) if created: stats['cfg_created'] = 1 elif not cfg.is_enabled: cfg.is_enabled = True cfg.save(update_fields=['is_enabled', 'UpdateTime']) stats['cfg_enabled'] = 1 # 专区 insert + 映射 zone_id_map = {} # src_id -> dst_id for zp in zone_plans: z = zp['source'] if zp['action'] == 'SKIP': zone_id_map[z.id] = zp['target_id'] stats['zones_skipped'] += 1 continue # 事务内再查一次,防并发重复 exist = ShangpinZhuanqu.query.filter( club_id=to_club, leixing_id=leixing.id, mingzi=z.mingzi, dianpu__isnull=True, ).first() if exist: zone_id_map[z.id] = exist.id stats['zones_skipped'] += 1 continue row = ShangpinZhuanqu.query.create( mingzi=z.mingzi, leixing_id=leixing.id, club_id=to_club, tupian_url=zp.get('new_tupian_url') or '', shenhezhuangtai=1, paixu=int(z.paixu or 0), shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)), fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)), dianpu=None, dianpu_leixing=None, ) zone_id_map[z.id] = row.id zp['target_id'] = row.id stats['zones_created'] += 1 for pp in product_plans: p = pp['source'] if pp['action'] == 'SKIP_NO_ZONE': stats['products_skipped_no_zone'] += 1 continue if pp['action'] == 'SKIP': stats['products_skipped'] += 1 continue dst_zq = zone_id_map.get(p.zhuanqu_id) if not dst_zq: raise CommandError( f'商品「{p.biaoqian}」src_id={p.id} 无法映射 zhuanqu_id={p.zhuanqu_id}' ) exist_p = Shangpin.query.filter( club_id=to_club, zhuanqu_id=dst_zq, biaoqian=p.biaoqian, dianpu__isnull=True, ).first() if exist_p: stats['products_skipped'] += 1 continue Shangpin.query.create( biaoqian=p.biaoqian, jiage=p.jiage, kucun=p.kucun, leixing_id=leixing.id, zhuanqu_id=dst_zq, zhenshi_xiaoliang=0, duiwai_xiaoliang=0, jieshao=p.jieshao, xiadan_xuzhi=p.xiadan_xuzhi, guize_tupian=pp.get('new_guize_tupian') or '', yaoqiuleixing=p.yaoqiuleixing, huiyuan_id=p.huiyuan_id, yongjin=p.yongjin, kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng), ewai_dashou_fencheng=p.ewai_dashou_fencheng, tupian_url=pp.get('new_tupian_url') or '', paixu=int(p.paixu or 0), shenhezhuangtai=1, shangjia_zhuangtai=True, fengjin_zhuangtai=bool(p.fengjin_zhuangtai), shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', False)), club_id=to_club, shi_zhuanpan=bool(getattr(p, 'shi_zhuanpan', False)), dianpu=None, dianpu_leixing=None, ) stats['products_created'] += 1 self.stdout.write(self.style.SUCCESS(f'\n写入完成: {stats}')) self.stdout.write( '说明:未复制 Zhuanpan*;全局 ShangpinLeixing 未改;源 UFO 数据未动。' ) def _list_clubs(self, Club, only_hint=False): qs = Club.query.order_by('club_id') for c in qs: name = c.name or '' if only_hint and NAME_HINT not in name: continue mark = ' <<-- 龙先生候选' if NAME_HINT in name else '' self.stdout.write( f' club_id={c.club_id!r} name={name!r} status={c.status}{mark}' ) if only_hint: hits = [c for c in Club.query.order_by('club_id') if NAME_HINT in (c.name or '')] if not hits: self.stdout.write(self.style.WARNING(' (无名称含「龙先生」的俱乐部)')) def _resolve_leixing(self, ShangpinLeixing, leixing_id: int, leixing_name: str): if leixing_id: row = ShangpinLeixing.query.filter(id=leixing_id).first() if not row: raise CommandError(f'找不到 ShangpinLeixing id={leixing_id}') if (row.jieshao or '') != leixing_name: raise CommandError( f'安全拦截:--leixing-id={leixing_id} 的名称是「{row.jieshao}」,' f'与要求的精确名「{leixing_name}」不一致。' ) return row matches = list(ShangpinLeixing.query.filter(jieshao=leixing_name)) if not matches: # 列出相近提示 self.stdout.write('全局商品类型清单:') for lx in ShangpinLeixing.query.order_by('id'): self.stdout.write(f' id={lx.id} name={lx.jieshao!r}') raise CommandError( f'未找到全局类型全名精确匹配「{leixing_name}」。' f'请改后台类型名或 --leixing-id=... 指定。' ) if len(matches) > 1: ids = [m.id for m in matches] raise CommandError( f'全局类型「{leixing_name}」匹配到多条 id={ids},拒绝猜测。' f'请用 --leixing-id=... 指定唯一一条。' ) return matches[0]