""" 店铺商品目录复制服务:复制店铺商品类型、专区、商品(含 OSS 图片独立拷贝)。 """ import logging import time import uuid from django.db import transaction from products.models import Shangpin, ShangpinZhuanqu from shop.models import Dianpu, ShangpinLeixingDianpu from utils.oss_utils import copy_in_oss, delete_from_oss, _url_to_oss_key logger = logging.getLogger('houtai') COPY_MODE_REPLACE = 'replace' COPY_MODE_APPEND = 'append' class ShopCopyError(Exception): def __init__(self, message, code=400): super().__init__(message) self.message = message self.code = code def resolve_dianpu_by_user_uid(user_uid): """通过小程序用户 ID(YonghuPingzheng.yonghu,全局唯一)定位店铺""" uid = str(user_uid or '').strip() if not uid: raise ShopCopyError('用户ID不能为空') try: return Dianpu.objects.select_related('pingzheng').get(pingzheng__yonghu=uid) except Dianpu.DoesNotExist: raise ShopCopyError(f'未找到用户ID {uid} 对应的店铺', 404) def _gen_dest_key(target_dianpu_id, category, src_url): src_key = _url_to_oss_key(src_url) ext = 'png' if src_key and '.' in src_key: ext = src_key.rsplit('.', 1)[-1].lower() or 'png' ts = int(time.time() * 1000) rand = uuid.uuid4().hex[:8] return f"dianpu/shangpin/{category}/{target_dianpu_id}_{ts}_{rand}.{ext}" def _copy_image_url(src_url, target_dianpu_id, category): """复制 OSS 图片到目标店铺独立路径,返回相对 URL""" if not src_url: return None dest_key = _gen_dest_key(target_dianpu_id, category, src_url) if not copy_in_oss(src_url, dest_key): raise ShopCopyError(f'图片复制失败: {src_url}') return dest_key def _clear_target_catalog_db_only(target_dianpu): """全部覆盖模式:仅删除目标店铺商品目录 DB 记录,返回待清理的 OSS URL""" dianpu_id = target_dianpu.id oss_to_delete = [] products = list(Shangpin.query.filter(dianpu_id=dianpu_id)) for p in products: if p.tupian_url: oss_to_delete.append(p.tupian_url) if p.guize_tupian: oss_to_delete.append(p.guize_tupian) if products: Shangpin.query.filter(dianpu_id=dianpu_id).delete() ShangpinZhuanqu.query.filter(dianpu_id=dianpu_id).delete() types = list(ShangpinLeixingDianpu.query.filter(dianpu_id=dianpu_id)) for t in types: if t.tupian_url: oss_to_delete.append(t.tupian_url) if types: ShangpinLeixingDianpu.query.filter(dianpu_id=dianpu_id).delete() return oss_to_delete def _delete_oss_urls(urls): for url in urls: if url: delete_from_oss(url) def _pre_copy_source_images(source_dianpu, target_dianpu): """ 预复制源店铺所有图片到目标店铺 OSS 路径。 返回结构化的预复制数据,供事务内写库使用。 """ src_id = source_dianpu.id tgt_id = target_dianpu.id source_types = list( ShangpinLeixingDianpu.query.filter(dianpu_id=src_id).order_by('paixu', 'id') ) type_payloads = [] for st in source_types: type_payloads.append({ 'source': st, 'tupian_url': _copy_image_url(st.tupian_url, tgt_id, 'shangpinleixing'), }) source_zones = list( ShangpinZhuanqu.query.filter(dianpu_id=src_id).order_by('paixu', 'id') ) zone_payloads = [] for sz in source_zones: zone_payloads.append({'source': sz}) source_products = list( Shangpin.query.filter(dianpu_id=src_id).order_by('paixu', 'id') ) product_payloads = [] for sp in source_products: product_payloads.append({ 'source': sp, 'tupian_url': _copy_image_url(sp.tupian_url, tgt_id, 'shangpin'), 'guize_tupian': _copy_image_url(sp.guize_tupian, tgt_id, 'shangpinguize') if sp.guize_tupian else None, }) return type_payloads, zone_payloads, product_payloads def _insert_copied_catalog(target_dianpu, type_payloads, zone_payloads, product_payloads): """在目标店铺插入复制后的类型、专区、商品,维护 ID 映射""" type_id_map = {} zone_id_map = {} for item in type_payloads: st = item['source'] new_type = ShangpinLeixingDianpu.query.create( dianpu_id=target_dianpu.id, gonggong_leixing_id=st.gonggong_leixing_id, jieshao=st.jieshao, tupian_url=item['tupian_url'], paixu=st.paixu, shangjia_zhuangtai=st.shangjia_zhuangtai, fengjin_zhuangtai=st.fengjin_zhuangtai, shenhe_zhuangtai=st.shenhe_zhuangtai, ) type_id_map[st.id] = new_type.id for item in zone_payloads: sz = item['source'] new_dianpu_leixing_id = type_id_map.get(sz.dianpu_leixing_id) if not new_dianpu_leixing_id: raise ShopCopyError(f'专区「{sz.mingzi}」关联的商品类型在复制映射中缺失') new_zone = ShangpinZhuanqu.query.create( dianpu_id=target_dianpu.id, mingzi=sz.mingzi, dianpu_leixing_id=new_dianpu_leixing_id, leixing_id=sz.leixing_id, paixu=sz.paixu, shangjia_zhuangtai=sz.shangjia_zhuangtai, fengjin_zhuangtai=sz.fengjin_zhuangtai, shenhezhuangtai=sz.shenhezhuangtai, ) zone_id_map[sz.id] = new_zone.id copied_products = 0 for item in product_payloads: sp = item['source'] new_dianpu_leixing_id = type_id_map.get(sp.dianpu_leixing_id) new_zhuanqu_id = zone_id_map.get(sp.zhuanqu_id) if sp.zhuanqu_id else None if sp.dianpu_leixing_id and not new_dianpu_leixing_id: raise ShopCopyError(f'商品「{sp.biaoqian}」关联的商品类型在复制映射中缺失') if sp.zhuanqu_id and not new_zhuanqu_id: raise ShopCopyError(f'商品「{sp.biaoqian}」关联的专区在复制映射中缺失') Shangpin.query.create( biaoqian=sp.biaoqian, jiage=sp.jiage, kucun=sp.kucun, leixing_id=sp.leixing_id, zhuanqu_id=new_zhuanqu_id, zhenshi_xiaoliang=0, duiwai_xiaoliang=0, jieshao=sp.jieshao, xiadan_xuzhi=sp.xiadan_xuzhi, guize_tupian=item['guize_tupian'], yaoqiuleixing=sp.yaoqiuleixing, huiyuan_id=sp.huiyuan_id, yongjin=sp.yongjin, kaioi_ewai_dashou_fencheng=sp.kaioi_ewai_dashou_fencheng, ewai_dashou_fencheng=sp.ewai_dashou_fencheng, tupian_url=item['tupian_url'], paixu=sp.paixu, shenhezhuangtai=sp.shenhezhuangtai, dianpu_id=target_dianpu.id, dianpu_leixing_id=new_dianpu_leixing_id, shangjia_zhuangtai=sp.shangjia_zhuangtai, fengjin_zhuangtai=sp.fengjin_zhuangtai, shenhe_zhuangtai=sp.shenhe_zhuangtai, ) copied_products += 1 return { 'types': len(type_payloads), 'zones': len(zone_payloads), 'products': copied_products, } def copy_shop_catalog(source_user_uid, target_user_uid, copy_mode=COPY_MODE_APPEND): """ 复制店铺商品目录。 :param source_user_uid: 源店铺小程序用户 ID(YonghuPingzheng.yonghu,唯一) :param target_user_uid: 目标店铺小程序用户 ID :param copy_mode: 'replace' 全部覆盖 | 'append' 补充追加 """ if copy_mode not in (COPY_MODE_REPLACE, COPY_MODE_APPEND): raise ShopCopyError('copy_mode 无效,仅支持 replace 或 append') source_dianpu = resolve_dianpu_by_user_uid(source_user_uid) target_dianpu = resolve_dianpu_by_user_uid(target_user_uid) if source_dianpu.id == target_dianpu.id: raise ShopCopyError('源店铺与目标店铺不能相同') # 1. 预复制 OSS 图片(DB 变更前完成,失败则目标店不受影响) type_payloads, zone_payloads, product_payloads = _pre_copy_source_images( source_dianpu, target_dianpu ) oss_cleanup = [] # 2. 数据库事务:锁定目标店 → 可选清空 → 写入 with transaction.atomic(): Dianpu.objects.select_for_update().get(id=target_dianpu.id) if copy_mode == COPY_MODE_REPLACE: oss_cleanup = _clear_target_catalog_db_only(target_dianpu) stats = _insert_copied_catalog(target_dianpu, type_payloads, zone_payloads, product_payloads) if oss_cleanup: _delete_oss_urls(oss_cleanup) logger.info( '店铺商品复制完成: source=%s(%s) -> target=%s(%s) mode=%s stats=%s', source_dianpu.id, source_user_uid, target_dianpu.id, target_user_uid, copy_mode, stats, ) return { 'source_dianpu_id': source_dianpu.id, 'source_dianpu_name': source_dianpu.dianpu_mingcheng, 'target_dianpu_id': target_dianpu.id, 'target_dianpu_name': target_dianpu.dianpu_mingcheng, 'copy_mode': copy_mode, 'copied_types': stats['types'], 'copied_zones': stats['zones'], 'copied_products': stats['products'], }