feat: 新增后台复制店铺商品目录接口

支持按用户ID复制商品类型、专区与商品,OSS图片独立拷贝,并提供覆盖/补充两种模式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-09 22:58:56 +08:00
parent 581295443a
commit 214b52badd
6 changed files with 388 additions and 1 deletions

View File

View File

@@ -0,0 +1,259 @@
"""
店铺商品目录复制服务:复制店铺商品类型、专区、商品(含 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):
"""通过小程序用户 IDYonghuPingzheng.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: 源店铺小程序用户 IDYonghuPingzheng.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'],
}

View File

@@ -10,7 +10,7 @@ from .view import (GetRolePermissionView,
UpdateWithdrawSettingsView, GetZuzhangListView, GetZuzhangDetailView, UpdateZuzhangView, GetOperationLogListView, GetProductTypeZoneView,
ModifyProductTypeZoneView, GetRateView, ModifyRateView, PopupNoticeListAPIView, PopupNoticeModifyAPIView,ShopListView,
ShopModifyView, ShopPublicTypeAndAuditView, ShopListForProductView, ShopProductModifyView,
ShopProductTypeMappingView, ProductListView, ProductModifyView, KefuChatPermissionsView,
ShopProductTypeMappingView, ProductListView, ProductModifyView, ShopCopyCatalogView, KefuChatPermissionsView,
FineApplyView, PunishDashouView,
FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanChuangJianView, FaKuanPingTaiShenHeView,
HqbkxxView,BkxgView,CaiwuView,CwhybkhqView, HybkjtsjView, SzxxView, CwddhqlxView, CwhqjtddsjView,
@@ -82,6 +82,7 @@ urlpatterns = [
path('hqhtdpsplx', ProductListView.as_view(), name='后台获取店铺商品数据'),
path('htxgdpspsj', ProductModifyView.as_view(), name='后台修改店铺商品数据'),
path('htfzdpspsj', ShopCopyCatalogView.as_view(), name='后台复制店铺商品目录'),
path('kfhqltqx', KefuChatPermissionsView.as_view(), name='后台获取客服聊天配置'),

View File

@@ -63,6 +63,7 @@ from .views.system import (
from .views.shops import (
ShopListView, ShopModifyView, ShopPublicTypeAndAuditView, ShopListForProductView,
ShopProductTypeMappingView, ShopProductModifyView, ProductListView, ProductModifyView,
ShopCopyCatalogView,
)
from .views.penalties import (
FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanPingTaiShenHeView,
@@ -117,6 +118,7 @@ __all__ = [
# shops
'ShopListView', 'ShopModifyView', 'ShopPublicTypeAndAuditView', 'ShopListForProductView',
'ShopProductTypeMappingView', 'ShopProductModifyView', 'ProductListView', 'ProductModifyView',
'ShopCopyCatalogView',
# penalties
'FaKuanTongJiView', 'FaKuanLieBiaoView', 'FaKuanChuLiView', 'FaKuanPingTaiShenHeView',
'FaKuanChuangJianView', 'FineApplyView', 'PunishDashouView',

View File

@@ -931,3 +931,46 @@ class ProductModifyView(APIView):
product.save(update_fields=update_fields)
return Response({'code': 0, 'msg': '商品修改成功'})
class ShopCopyCatalogView(APIView):
"""
复制店铺商品目录(类型 + 专区 + 商品,含 OSS 图片独立拷贝)。
请求路径: /houtai/htfzdpspsj
权限: 店铺管理权限 999a~999e 任一
"""
permission_classes = [IsAuthenticated]
def post(self, request):
username = request.data.get('username')
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'})
required_perms = {'999a', '999b', '999c', '999d', '999e'}
if not required_perms.intersection(set(permissions)):
return Response({'code': 403, 'msg': '您没有权限执行店铺商品复制操作'})
source_user_uid = str(request.data.get('source_user_uid', '')).strip()
target_user_uid = str(request.data.get('target_user_uid', '')).strip()
copy_mode = str(request.data.get('copy_mode', 'append')).strip().lower()
if not source_user_uid or not target_user_uid:
return Response({'code': 400, 'msg': '请填写源店铺与目标店铺的用户ID'})
from backend.services.shop_copy_service import copy_shop_catalog, ShopCopyError
try:
result = copy_shop_catalog(source_user_uid, target_user_uid, copy_mode)
return Response({
'code': 0,
'msg': (
f'复制成功:商品类型 {result["copied_types"]} 个,'
f'专区 {result["copied_zones"]} 个,商品 {result["copied_products"]}'
),
'data': result,
})
except ShopCopyError as e:
return Response({'code': e.code, 'msg': e.message})
except Exception:
logger.exception('复制店铺商品失败')
return Response({'code': 500, 'msg': '复制失败,请稍后重试'})