from django.db import models from utils.oss_utils import upload_to_oss, delete_from_oss from yonghu.models import UserMain, UserDashou, UserShangjia, UserGuanshi, UserZuzhang from shangdian.models import Dianpu, YonghuDianpuBangding, ShangpinLeixingDianpu, DianpuShangpinShenheShezhi from shangpin.models import ShangpinLeixing, ShangpinZhuanqu, Shangpin from rest_framework.permissions import AllowAny from django.utils import timezone from rest_framework_simplejwt.tokens import RefreshToken from yonghu.models import UserMain from .models import YonghuPingzheng, Dianpu import logging # shangdian/views.py 继续添加 import io import time import requests from django.conf import settings from django.core.cache import cache from django.db import transaction from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status from .utils import verify_shop_permission from .models import Dianpu from django.db.models import F logger = logging.getLogger(__name__) class BindDianpuView(APIView): """ 店铺绑定接口 接收前端传入的店铺ID,更新用户与店铺的绑定关系 """ permission_classes = [IsAuthenticated] def post(self, request): dianpu_id = request.data.get('dianpu_id') if not dianpu_id: return Response({'code': 400, 'msg': '店铺ID不能为空'}) user = request.user if not hasattr(user, 'yonghuid'): return Response({'code': 500, 'msg': '用户身份信息异常'}) # 先获取 UserMain 对象(不加锁,仅用于校验存在性) try: user_main = UserMain.objects.get(yonghuid=user.yonghuid) except UserMain.DoesNotExist: return Response({'code': 404, 'msg': '用户不存在'}) # 校验店铺是否存在且状态正常(不加锁) try: dianpu = Dianpu.objects.get(id=dianpu_id) except Dianpu.DoesNotExist: return Response({'code': 404, 'msg': '店铺不存在'}) if dianpu.zhuangtai != 1: return Response({'code': 200, 'msg': '店铺已被封禁,无法绑定'}) # 在事务内部进行加锁操作 try: with transaction.atomic(): # 重新获取加锁的用户对象(确保并发安全) user_main_locked = UserMain.objects.select_for_update().get(yonghuid=user.yonghuid) dianpu_locked = Dianpu.objects.select_for_update().get(id=dianpu_id) # 再次校验店铺状态(防止事务期间被修改) if dianpu_locked.zhuangtai != 1: return Response({'code': 200, 'msg': '店铺已被封禁,无法绑定'}) # 处理绑定关系 try: existing_binding = YonghuDianpuBangding.objects.select_for_update().get(yonghu=user_main_locked) old_dianpu = existing_binding.dianpu if old_dianpu.id == dianpu_locked.id: return Response({'code': 200, 'msg': '已绑定该店铺'}) # 旧店铺绑定人数减1 old_dianpu.bangding_yonghushu = F('bangding_yonghushu') - 1 old_dianpu.save(update_fields=['bangding_yonghushu']) # 更新绑定关系 existing_binding.dianpu = dianpu_locked existing_binding.save() except YonghuDianpuBangding.DoesNotExist: YonghuDianpuBangding.objects.create(yonghu=user_main_locked, dianpu=dianpu_locked) # 新店铺绑定人数加1 dianpu_locked.bangding_yonghushu = F('bangding_yonghushu') + 1 dianpu_locked.save(update_fields=['bangding_yonghushu']) logger.info(f"用户 {user.yonghuid} 绑定店铺 {dianpu_id} 成功") return Response({'code': 200, 'msg': '绑定成功'}) except Exception as e: logger.exception(f"绑定店铺失败: {e}") return Response({'code': 500, 'msg': '绑定失败,服务器内部错误'}) class ShopGoodsView(APIView): """ 获取店铺商品数据(类型、专区、商品列表) 根据用户绑定店铺状态及身份,返回店铺专属或公共商品 """ permission_classes = [IsAuthenticated] def post(self, request): user = request.user try: user_main = UserMain.objects.get(yonghuid=user.yonghuid) except UserMain.DoesNotExist: return Response({'code': 404, 'msg': '用户不存在'}) # 1. 查询用户店铺绑定关系 binding = YonghuDianpuBangding.objects.filter( yonghu=user_main ).select_related('dianpu').first() # 2. 判断用户是否拥有特殊身份 has_special = self._has_special_identity(user_main) # 3. 根据绑定关系及店铺状态决定数据获取策略 if binding: dianpu = binding.dianpu if dianpu.zhuangtai == 1: # 店铺正常 -> 返回店铺专属商品(需通过平台审核) data = self._get_shop_goods(dianpu) else: # 店铺被封 -> 返回公共商品,有特殊身份则返回审核状态为1的,否则返回2的 data = self._get_public_goods(True) else: # 无绑定店铺,根据身份决定返回公共商品的审核状态 data = self._get_public_goods(has_special) return Response({'code': 200, 'data': data}) def _has_special_identity(self, user_main): """判断用户是否拥有打手/商家/管事/组长任一身份""" return (UserDashou.objects.filter(user=user_main).exists() or UserShangjia.objects.filter(user=user_main).exists() or UserGuanshi.objects.filter(user=user_main).exists() or UserZuzhang.objects.filter(user=user_main).exists()) def _get_shop_goods(self, dianpu): """ 获取店铺专属商品 类型、专区、商品均从店铺相关表中获取,且需通过平台审核(shenhe_zhuangtai=True) """ # 1. 店铺商品类型:未封禁、已上架、平台审核通过 leixing_queryset = ShangpinLeixingDianpu.objects.filter( dianpu=dianpu, fengjin_zhuangtai=False, shangjia_zhuangtai=True, shenhe_zhuangtai=True # 🔥 必须通过平台审核 ).select_related('gonggong_leixing').order_by('-paixu', 'id') leixing_data = [] for lx in leixing_queryset: leixing_data.append({ 'id': lx.id, # 店铺类型ID,前端归类用 'jieshao': lx.jieshao, 'tupian_url': lx.tupian_url, 'paixu': lx.paixu }) leixing_ids = [lx['id'] for lx in leixing_data] # 2. 专区:根据店铺和店铺类型ID,未封禁、已上架 if leixing_ids: zhuanqu_queryset = ShangpinZhuanqu.objects.filter( dianpu=dianpu, dianpu_leixing_id__in=leixing_ids, fengjin_zhuangtai=False, shangjia_zhuangtai=True ).order_by('-paixu', 'id') else: zhuanqu_queryset = ShangpinZhuanqu.objects.none() zhuanqu_data = [] for zq in zhuanqu_queryset: zhuanqu_data.append({ 'id': zq.id, 'mingzi': zq.mingzi, 'leixing_id': zq.dianpu_leixing_id, # 对应店铺类型ID 'paixu': zq.paixu }) # 3. 商品:根据店铺、店铺类型、专区,未封禁、已上架、平台审核通过、原有审核状态为正常 zhuanqu_ids = [zq['id'] for zq in zhuanqu_data] if zhuanqu_ids: shangpin_queryset = Shangpin.objects.filter( dianpu=dianpu, dianpu_leixing_id__in=leixing_ids, zhuanqu_id__in=zhuanqu_ids, fengjin_zhuangtai=False, shangjia_zhuangtai=True, shenhe_zhuangtai=True, # 🔥 平台审核通过 shenhezhuangtai=1 # 原有审核状态:正常 ).values( 'id', 'biaoqian', 'jiage', 'tupian_url', 'duiwai_xiaoliang', 'paixu', 'dianpu_leixing_id', 'zhuanqu_id' ).order_by('-paixu', 'id') shangpin_data = list(shangpin_queryset) # 字段映射:前端统一使用 leixing_id for sp in shangpin_data: sp['leixing_id'] = sp.pop('dianpu_leixing_id') else: shangpin_data = [] return { 'shangpinleixing': leixing_data, 'shangpinzhuanqu': zhuanqu_data, 'shangpinliebiao': shangpin_data } def _get_public_goods(self, use_audit_status_one=True): """ 获取公共商品 类型、专区、商品均从公共表获取。 - 必须满足 dianpu 为空且 dianpu_leixing 为空(即纯公共数据)。 - 审核状态:若 use_audit_status_one=True,则返回 shenhezhuangtai=1 的数据; 否则返回 shenhezhuangtai=2 的数据(审核专用)。 """ target_shenhe = 1 if use_audit_status_one else 2 # 1. 公共商品类型:原有审核状态为 target_shenhe leixing_queryset = ShangpinLeixing.objects.filter( shenhezhuangtai=target_shenhe ).order_by('-paixu', 'id') leixing_data = [] for lx in leixing_queryset: leixing_data.append({ 'id': lx.id, 'jieshao': lx.jieshao, 'tupian_url': lx.tupian_url, 'paixu': lx.paixu }) visible_leixing_ids = [lx['id'] for lx in leixing_data] # 2. 专区:dianpu 为空,且 dianpu_leixing 为空,leixing_id 在可见范围内,原有审核状态为 target_shenhe if visible_leixing_ids: zhuanqu_queryset = ShangpinZhuanqu.objects.filter( dianpu__isnull=True, dianpu_leixing__isnull=True, # 🔥 必须为空 leixing_id__in=visible_leixing_ids, shenhezhuangtai=target_shenhe ).order_by('-paixu', 'id') else: zhuanqu_queryset = ShangpinZhuanqu.objects.none() zhuanqu_data = [] for zq in zhuanqu_queryset: zhuanqu_data.append({ 'id': zq.id, 'mingzi': zq.mingzi, 'leixing_id': zq.leixing_id, 'paixu': zq.paixu }) # 3. 商品:dianpu 为空,dianpu_leixing 为空,专区在可见范围内,原有审核状态为 target_shenhe zhuanqu_ids = [zq['id'] for zq in zhuanqu_data] if zhuanqu_ids: shangpin_queryset = Shangpin.objects.filter( dianpu__isnull=True, dianpu_leixing__isnull=True, # 🔥 必须为空 leixing_id__in=visible_leixing_ids, zhuanqu_id__in=zhuanqu_ids, shenhezhuangtai=target_shenhe ).values( 'id', 'biaoqian', 'jiage', 'tupian_url', 'duiwai_xiaoliang', 'paixu', 'leixing_id', 'zhuanqu_id' ).order_by('-paixu', 'id') shangpin_data = list(shangpin_queryset) else: shangpin_data = [] return { 'shangpinleixing': leixing_data, 'shangpinzhuanqu': zhuanqu_data, 'shangpinliebiao': shangpin_data } class ShangdianLoginView(APIView): """ 商家后台登录接口(通过凭证直接获取店铺) POST /shangdian/login 请求参数: yonghuid : 用户ID(7位) zhanghao : 登录账号 mima : 密码 返回数据: code: 0, data: { token: "jwt_token", yonghuid: "1234567", dianpu_mingcheng: "店铺名称", dianpu_touxiang: "头像相对URL", erweima_url: "二维码相对URL" } """ permission_classes = [AllowAny] def post(self, request): # 1. 获取请求参数 yonghuid = request.data.get('yonghuid', '').strip() zhanghao = request.data.get('zhanghao', '').strip() mima = request.data.get('mima', '').strip() # 2. 参数完整性校验 if not yonghuid or not zhanghao or not mima: return Response({'code': 400, 'msg': '用户ID、账号、密码均不能为空'}) # 3. 验证用户主表是否存在 try: user_main = UserMain.objects.get(yonghuid=yonghuid) except UserMain.DoesNotExist: logger.warning(f"登录失败:用户ID不存在 - {yonghuid}") return Response({'code': 404, 'msg': '账号或密码错误'}) # 4. 通过用户ID + 账号查询凭证表 try: pingzheng = YonghuPingzheng.objects.get( yonghu=yonghuid, zhanghao=zhanghao, is_active=True ) except YonghuPingzheng.DoesNotExist: logger.warning(f"登录失败:账号或密码错误 - 用户ID:{yonghuid}, 账号:{zhanghao}") return Response({'code': 401, 'msg': '账号或密码错误'}) # 5. 校验密码 if pingzheng.mima != mima: logger.warning(f"登录失败:密码错误 - 用户ID:{yonghuid}, 账号:{zhanghao}") return Response({'code': 401, 'msg': '账号或密码错误'}) # 6. 【修改点】通过凭证直接获取关联的店铺(利用 OneToOneField 反向关系) try: dianpu = pingzheng.dianpu except Dianpu.DoesNotExist: logger.error(f"登录异常:凭证 {zhanghao} 未关联任何店铺") return Response({'code': 403, 'msg': '您尚未关联店铺,请联系管理员'}) # 7. 检查店铺状态 if dianpu.zhuangtai != 1: logger.warning(f"登录失败:店铺已被封禁 - 店铺ID:{dianpu.id}, 用户ID:{yonghuid}") return Response({'code': 403, 'msg': '店铺已被封禁,无法登录'}) # 8. 生成 JWT Token refresh = RefreshToken.for_user(user_main) token = str(refresh.access_token) # 9. 更新最后登录时间 user_main.last_login_time = timezone.now() user_main.save(update_fields=['last_login_time']) # 10. 构造返回数据 data = { 'token': token, 'yonghuid': user_main.yonghuid, 'dianpu_mingcheng': dianpu.dianpu_mingcheng, 'dianpu_touxiang': dianpu.dianpu_touxiang or '', 'erweima_url': dianpu.erweima_url or '' } logger.info(f"商家登录成功:用户ID {yonghuid} - 店铺 {dianpu.dianpu_mingcheng}") return Response({'code': 0, 'msg': '登录成功', 'data': data}) '''class ShangdianLoginView(APIView): """ 商家后台登录接口(安全修正版) POST /shangdian/login 请求参数: yonghuid : 用户ID(7位) zhanghao : 登录账号 mima : 密码 返回数据: code: 0, data: { token: "jwt_token", yonghuid: "1234567", dianpu_mingcheng: "店铺名称", dianpu_touxiang: "头像相对URL", erweima_url: "二维码相对URL" } """ permission_classes = [AllowAny] def post(self, request): # 1. 获取请求参数 yonghuid = request.data.get('yonghuid', '').strip() zhanghao = request.data.get('zhanghao', '').strip() mima = request.data.get('mima', '').strip() # 2. 参数完整性校验 if not yonghuid or not zhanghao or not mima: return Response({'code': 400, 'msg': '用户ID、账号、密码均不能为空'}) # 3. 【第一步】验证用户主表是否存在 try: user_main = UserMain.objects.get(yonghuid=yonghuid) except UserMain.DoesNotExist: logger.warning(f"登录失败:用户ID不存在 - {yonghuid}") return Response({'code': 404, 'msg': '用户不存在'}) # 4. 【第二步】通过用户ID + 账号查询凭证表(防止用他人账号登录) try: pingzheng = YonghuPingzheng.objects.get( yonghu=yonghuid, # 关键:必须匹配当前用户ID zhanghao=zhanghao, # 必须匹配账号 is_active=True # 必须启用 ) except YonghuPingzheng.DoesNotExist: logger.warning(f"登录失败:账号或密码错误 - 用户ID:{yonghuid}, 账号:{zhanghao}") return Response({'code': 401, 'msg': '账号或密码错误'}) # 5. 校验密码(明文对比,后续可改为哈希) if pingzheng.mima != mima: logger.warning(f"登录失败:密码错误 - 用户ID:{yonghuid}, 账号:{zhanghao}") return Response({'code': 401, 'msg': '账号或密码错误'}) # 6. 【第三步】通过用户ID查询绑定关系表,获取店铺ID try: binding = YonghuDianpuBangding.objects.select_related('dianpu').get(yonghu=user_main) dianpu = binding.dianpu except YonghuDianpuBangding.DoesNotExist: logger.error(f"登录异常:用户 {yonghuid} 未绑定任何店铺") return Response({'code': 403, 'msg': '您尚未绑定任何店铺,请联系管理员'}) # 7. 【第四步】检查店铺状态 if dianpu.zhuangtai != 1: logger.warning(f"登录失败:店铺已被封禁 - 店铺ID:{dianpu.id}, 用户ID:{yonghuid}") return Response({'code': 403, 'msg': '店铺已被封禁,无法登录'}) # 8. 生成 JWT Token(关联 UserMain) refresh = RefreshToken.for_user(user_main) token = str(refresh.access_token) # 9. 更新最后登录时间 user_main.last_login_time = timezone.now() user_main.save(update_fields=['last_login_time']) # 10. 构造返回数据 data = { 'token': token, 'yonghuid': user_main.yonghuid, 'dianpu_mingcheng': dianpu.dianpu_mingcheng, 'dianpu_touxiang': dianpu.dianpu_touxiang or '', 'erweima_url': dianpu.erweima_url or '' } logger.info(f"商家登录成功:用户ID {yonghuid} - 店铺 {dianpu.dianpu_mingcheng}") return Response({'code': 0, 'msg': '登录成功', 'data': data})''' # shangdian/views.py 追加 from django.db.models import Sum from shangdian.models import DianpuShouzhiMeiriTongji class DianpuShouzhiTongjiView(APIView): """ 店铺每日收支统计接口(支持按日、月、年聚合) URL: /dianpu/dpsztj """ permission_classes = [IsAuthenticated] def post(self, request): # 身份验证 frontend_user_id = request.data.get('userId') if not frontend_user_id: return Response({'code': 400, 'msg': '缺少 userId'}) dianpu, error = verify_shop_permission(request, frontend_user_id) if error: return error granularity = request.data.get('granularity', 'day') # day / month / year year = request.data.get('year') month = request.data.get('month') # 查询当前店铺的统计记录 qs = DianpuShouzhiMeiriTongji.objects.filter(dianpu_id=dianpu.id) if granularity == 'day': if not year or not month: return Response({'code': 400, 'msg': '日颗粒度需要提供年份和月份'}) try: year = int(year) month = int(month) except (TypeError, ValueError): return Response({'code': 400, 'msg': '年份或月份格式错误'}) # 取出该月每一天的数据,并按日排序 daily_data = qs.filter(nian=year, yue=month).values('ri').annotate( xiadan_zongliang=Sum('xiadan_zongliang'), xiadan_zonge=Sum('xiadan_zonge'), chengjiao_zongliang=Sum('chengjiao_zongliang'), chengjiao_zonge=Sum('chengjiao_zonge'), tuikuan_zongliang=Sum('tuikuan_zongliang'), tuikuan_zonge=Sum('tuikuan_zonge'), shouyi_zonge=Sum('shouyi_zonge'), ).order_by('ri') # 补全缺失的日期(默认值为0) import calendar days_in_month = calendar.monthrange(year, month)[1] result = [] for day in range(1, days_in_month + 1): found = next((item for item in daily_data if item['ri'] == day), None) if found: result.append(found) else: result.append({ 'ri': day, 'xiadan_zongliang': 0, 'xiadan_zonge': 0, 'chengjiao_zongliang': 0, 'chengjiao_zonge': 0, 'tuikuan_zongliang': 0, 'tuikuan_zonge': 0, 'shouyi_zonge': 0, }) return Response({'code': 0, 'data': result, 'type': 'day'}) elif granularity == 'month': if not year: return Response({'code': 400, 'msg': '月颗粒度需要提供年份'}) try: year = int(year) except (TypeError, ValueError): return Response({'code': 400, 'msg': '年份格式错误'}) # 按月份聚合 monthly_data = qs.filter(nian=year).values('yue').annotate( xiadan_zongliang=Sum('xiadan_zongliang'), xiadan_zonge=Sum('xiadan_zonge'), chengjiao_zongliang=Sum('chengjiao_zongliang'), chengjiao_zonge=Sum('chengjiao_zonge'), tuikuan_zongliang=Sum('tuikuan_zongliang'), tuikuan_zonge=Sum('tuikuan_zonge'), shouyi_zonge=Sum('shouyi_zonge'), ).order_by('yue') # 补全 1~12 月 result = [] for m in range(1, 13): found = next((item for item in monthly_data if item['yue'] == m), None) if found: result.append(found) else: result.append({ 'yue': m, 'xiadan_zongliang': 0, 'xiadan_zonge': 0, 'chengjiao_zongliang': 0, 'chengjiao_zonge': 0, 'tuikuan_zongliang': 0, 'tuikuan_zonge': 0, 'shouyi_zonge': 0, }) return Response({'code': 0, 'data': result, 'type': 'month'}) elif granularity == 'year': # 按年份聚合 yearly_data = qs.values('nian').annotate( xiadan_zongliang=Sum('xiadan_zongliang'), xiadan_zonge=Sum('xiadan_zonge'), chengjiao_zongliang=Sum('chengjiao_zongliang'), chengjiao_zonge=Sum('chengjiao_zonge'), tuikuan_zongliang=Sum('tuikuan_zongliang'), tuikuan_zonge=Sum('tuikuan_zonge'), shouyi_zonge=Sum('shouyi_zonge'), ).order_by('nian') return Response({'code': 0, 'data': list(yearly_data), 'type': 'year'}) else: return Response({'code': 400, 'msg': '无效的颗粒度'}) class ShopOverviewView(APIView): """ 店铺概览数据接口 URL: /shangdian/sjdpgl Method: POST 权限: JWT认证 """ permission_classes = [IsAuthenticated] def post(self, request): # 从请求体中获取前端传递的 userId(用于防越权) frontend_user_id = request.data.get('userId') if not frontend_user_id: return Response( {'code': 400, 'msg': '缺少必要参数 userId'} ) # 调用公共验证方法 dianpu, error_response = verify_shop_permission(request, frontend_user_id) if error_response: return error_response # 验证通过,构造返回数据(新增字段,其余保持不变) data = { 'id': dianpu.id, 'dianpu_mingcheng': dianpu.dianpu_mingcheng, 'dianpu_touxiang': dianpu.dianpu_touxiang or '', 'lianxi_dianhua': dianpu.lianxi_dianhua or '', 'weixinhao': dianpu.weixinhao or '', 'zhuangtai': dianpu.zhuangtai, 'bangding_yonghushu': dianpu.bangding_yonghushu, 'erweima_url': dianpu.erweima_url or '', 'create_time': dianpu.create_time.isoformat() if dianpu.create_time else '', 'update_time': dianpu.update_time.isoformat() if dianpu.update_time else '', # 新增字段 'kaiqi_shouyi_choucheng': dianpu.kaiqi_shouyi_choucheng, 'shouyi_choucheng_feilv': float(dianpu.shouyi_choucheng_feilv) if dianpu.shouyi_choucheng_feilv else 0.0, 'ketixian_yue': float(dianpu.ketixian_yue), 'zhengqu_zonge': float(dianpu.zhengqu_zonge), 'meiri_tixian_xiane': float(dianpu.meiri_tixian_xiane) if dianpu.meiri_tixian_xiane else 0.0, 'kaiqi_meiri_xiane': dianpu.kaiqi_meiri_xiane, } return Response({ 'code': 0, 'msg': 'success', 'data': data }) # 新增导入 from django.http import HttpResponse from utils.oss_utils import get_oss_client # 使用您已有的 COS 客户端获取方法 class QrcodeDownloadView(APIView): """ 店铺二维码下载接口(通过后端代理下载,解决跨域限制) URL: /shangdian/xzewm?dianpu_id=xx 权限: JWT认证 + 店铺归属验证 """ permission_classes = [IsAuthenticated] def get(self, request): # 1. 身份验证与店铺校验 frontend_user_id = request.GET.get('userId') # 前端通过查询参数传递 userId if not frontend_user_id: return Response({'code': 400, 'msg': '缺少必要参数 userId'}, status=400) dianpu, error_response = verify_shop_permission(request, frontend_user_id) if error_response: return error_response # 2. 获取店铺的二维码相对路径 erweima_relative_url = dianpu.erweima_url if not erweima_relative_url: return HttpResponse('店铺暂无二维码', status=404) # 3. 拼接 OSS 完整访问地址(使用 settings 中的域名) oss_domain = getattr(settings, 'COS_DOMAIN', '') if not oss_domain: logger.error("COS_DOMAIN 未配置") return HttpResponse('OSS域名未配置', status=500) image_url = oss_domain.rstrip('/') + '/' + erweima_relative_url.lstrip('/') # 4. 从腾讯云 COS 获取图片内容 try: client = get_oss_client() bucket = getattr(settings, 'COS_BUCKET', '') response = client.get_object(Bucket=bucket, Key=erweima_relative_url) image_data = response['Body'].get_raw_stream().read() except Exception as e: logger.exception("从COS获取二维码失败") return HttpResponse('下载失败,请稍后重试', status=502) # 5. 构造下载响应,强制浏览器保存为文件 filename = f"店铺二维码_{dianpu.dianpu_mingcheng}.png" response = HttpResponse(image_data, content_type='image/png') response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Content-Length'] = str(len(image_data)) return response class ShopQrcodeView(APIView): """ 店铺专属二维码生成/更新接口 URL: /shangdian/sdscdpm Method: POST 权限: JWT认证 """ permission_classes = [IsAuthenticated] def post(self, request): # 获取前端传递的 userId frontend_user_id = request.data.get('userId') if not frontend_user_id: return Response( {'code': 400, 'msg': '缺少必要参数 userId'}, status=status.HTTP_400_BAD_REQUEST ) # 公共验证 dianpu, error_response = verify_shop_permission(request, frontend_user_id) if error_response: return error_response # 获取缓存的微信 access_token access_token = self._get_wx_access_token() if not access_token: return Response( {'code': 500, 'msg': '获取微信 access_token 失败'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) # 构造小程序码请求参数 # 注意:scene 最大32个字符,我们使用店铺ID scene = str(dianpu.id) page_path = "pages/index/index" # 前端小程序首页路径 wx_url = f'https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={access_token}' post_data = { 'scene': scene, 'page': page_path, 'width': 430, 'auto_color': False, 'line_color': {'r': 0, 'g': 0, 'b': 0}, } # 调用微信接口生成小程序码 try: wx_resp = requests.post(wx_url, json=post_data, timeout=10) except Exception as e: logger.error(f"请求微信生成二维码接口异常: {e}") return Response( {'code': 500, 'msg': '调用微信服务失败'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) # 判断返回是否为图片 if wx_resp.status_code != 200 or 'image' not in wx_resp.headers.get('content-type', ''): error_info = wx_resp.json() if wx_resp.headers.get('content-type') == 'application/json' else {} errmsg = error_info.get('errmsg', '未知错误') logger.error(f"微信生成二维码失败: {errmsg}") return Response( {'code': 500, 'msg': f'生成二维码失败: {errmsg}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) # 构造文件名和 COS 路径 timestamp = int(time.time()) # 三层文件夹结构: dianpu/shangjiadianpu/dianpuerweima/ filename = f"dianpu_{dianpu.id}_{timestamp}.png" oss_path = f"dianpu/shangjiadianpu/dianpuerweima/{filename}" file_obj = io.BytesIO(wx_resp.content) # 上传到 COS try: full_url = upload_to_oss(file_obj, oss_path) except Exception as e: logger.error(f"上传二维码到 COS 失败: {e}") return Response( {'code': 500, 'msg': '图片上传失败'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) if not full_url: return Response( {'code': 500, 'msg': '图片上传失败'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) # 提取相对路径(根据您的 upload_to_oss 返回值格式调整) cos_domain = getattr(settings, 'COS_DOMAIN', '') if cos_domain and full_url.startswith(cos_domain): relative_path = full_url[len(cos_domain):].lstrip('/') else: relative_path = oss_path # 删除旧二维码文件(如果存在) old_relative_path = dianpu.erweima_url if old_relative_path: try: delete_from_oss(old_relative_path) except Exception as e: logger.warning(f"删除旧二维码文件失败: {old_relative_path}, 错误: {e}") # 更新数据库 with transaction.atomic(): dianpu.erweima_url = relative_path dianpu.save(update_fields=['erweima_url', 'update_time']) return Response({ 'code': 0, 'msg': 'success', 'data': {'erweima_url': relative_path} }) def _get_wx_access_token(self): """ 获取微信小程序 access_token,带缓存 缓存键: wx_mini_access_token """ cache_key = 'wx_mini_access_token' token = cache.get(cache_key) if token: return token appid = getattr(settings, 'WEIXIN_APPID', '') secret = getattr(settings, 'WEIXIN_SECRET', '') if not appid or not secret: logger.error("微信小程序 AppID 或 Secret 未配置") return None url = f'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}' try: resp = requests.get(url, timeout=5) data = resp.json() token = data.get('access_token') expires_in = data.get('expires_in', 7200) if token: # 提前200秒过期,防止边界情况 cache.set(cache_key, token, expires_in - 200) return token except Exception as e: logger.error(f"获取微信 access_token 异常: {e}") return None from django.db.models import Count from rest_framework.views import APIView from shangpin.models import ShangpinLeixing class ShopProductConfigView(APIView): """ 获取商品发布所需配置:公共类型、店铺商品类型、专区及各自商品数量 URL: /shangdian/dphqspxx 权限: JWT认证 + 店铺归属验证 """ permission_classes = [IsAuthenticated] def post(self, request): # 1. 身份验证,获取店铺对象 frontend_user_id = request.data.get('userId') if not frontend_user_id: return Response({'code': 400, 'msg': '缺少必要参数 userId'}) dianpu, error = verify_shop_permission(request, frontend_user_id) if error: return error # ---------- 2. 公共商品类型(需审核正常) ---------- try: public_types_qs = ShangpinLeixing.objects.filter(shenhezhuangtai=1).values( 'id', 'jieshao', 'tupian_url' ) public_types = [{ 'id': pt['id'], 'jieshao': pt['jieshao'] or '', 'tupian_url': pt['tupian_url'] or '', } for pt in public_types_qs] except Exception as e: logger.exception("查询公共商品类型失败") return Response({'code': 500, 'msg': '服务器错误'}) # ---------- 3. 店铺商品类型 ---------- try: shop_types = ShangpinLeixingDianpu.objects.filter( dianpu_id=dianpu.id ).select_related('gonggong_leixing').order_by('-paixu') except Exception as e: logger.exception("查询店铺商品类型失败") return Response({'code': 500, 'msg': '服务器错误'}) type_ids = [] shop_product_types = [] for t in shop_types: shop_product_types.append({ 'id': t.id, 'jieshao': t.jieshao or '', 'tupian_url': t.tupian_url or '', 'paixu': t.paixu, 'shangjia_zhuangtai': t.shangjia_zhuangtai, 'fengjin_zhuangtai': t.fengjin_zhuangtai, 'shenhe_zhuangtai': t.shenhe_zhuangtai, 'gonggong_leixing_id': t.gonggong_leixing_id, }) type_ids.append(t.id) # ---------- 4. 各类型商品数量统计 ---------- type_product_counts = {} if type_ids: counts = Shangpin.objects.filter( dianpu_id=dianpu.id, dianpu_leixing_id__in=type_ids ).values('dianpu_leixing_id').annotate(cnt=Count('id')) type_product_counts = {item['dianpu_leixing_id']: item['cnt'] for item in counts} # ---------- 5. 专区列表 ---------- try: zhuanqu_qs = ShangpinZhuanqu.objects.filter( dianpu_id=dianpu.id ).order_by('-paixu').values( 'id', 'mingzi', 'paixu', 'shangjia_zhuangtai', 'fengjin_zhuangtai', 'dianpu_leixing_id' ) zhuanqu_list = [] zhuanqu_ids = [] for zq in zhuanqu_qs: zhuanqu_list.append({ 'id': zq['id'], 'mingzi': zq['mingzi'] or '', 'paixu': zq['paixu'], 'shangjia_zhuangtai': zq['shangjia_zhuangtai'], 'fengjin_zhuangtai': zq['fengjin_zhuangtai'], 'dianpu_leixing_id': zq['dianpu_leixing_id'], }) zhuanqu_ids.append(zq['id']) except Exception as e: logger.exception("查询专区失败") return Response({'code': 500, 'msg': '服务器错误'}) # ---------- 6. 各专区商品数量统计 ---------- zhuanqu_product_counts = {} if zhuanqu_ids: counts = Shangpin.objects.filter( dianpu_id=dianpu.id, zhuanqu_id__in=zhuanqu_ids ).values('zhuanqu_id').annotate(cnt=Count('id')) zhuanqu_product_counts = {item['zhuanqu_id']: item['cnt'] for item in counts} return Response({ 'code': 0, 'msg': 'success', 'data': { 'public_types': public_types, 'shop_product_types': shop_product_types, 'zhuanqu_list': zhuanqu_list, 'type_product_counts': type_product_counts, 'zhuanqu_product_counts': zhuanqu_product_counts, } }) from django.db import transaction from utils.oss_utils import delete_from_oss # 引入文件删除工具 # shangdian/views.py import logging from django.db import models, transaction class ShopProductModifyView(APIView): """店铺商品类型与专区管理(增、改、删)""" permission_classes = [IsAuthenticated] def post(self, request): try: frontend_user_id = request.data.get('userId') or request.POST.get('userId') if not frontend_user_id: return Response({'code': 400, 'msg': '缺少必要参数 userId'}) dianpu, error = verify_shop_permission(request, frontend_user_id) if error: return error action = request.data.get('action') or request.POST.get('action') if not action: return Response({'code': 400, 'msg': '缺少操作类型 action'}) handlers = { 'add_type': self._add_type, 'update_type': self._update_type, 'delete_type': self._delete_type, 'add_zhuanqu': self._add_zhuanqu, 'update_zhuanqu': self._update_zhuanqu, 'delete_zhuanqu': self._delete_zhuanqu, } handler = handlers.get(action) if not handler: return Response({'code': 400, 'msg': '不支持的操作类型'}) return handler(dianpu, request) except Exception as e: logger.exception("ShopProductModifyView 全局异常") return Response({'code': 500, 'msg': '服务器内部错误'}) # ---------- 商品类型操作 ---------- @transaction.atomic def _add_type(self, dianpu, request): """添加店铺商品类型""" try: jieshao = request.data.get('jieshao') or request.POST.get('jieshao') gonggong_leixing_id = request.data.get('gonggong_leixing_id') or request.POST.get('gonggong_leixing_id') paixu = request.data.get('paixu', 0) or request.POST.get('paixu', 0) if not jieshao or not gonggong_leixing_id: return Response({'code': 400, 'msg': '缺少介绍或公共类型ID'}) # 验证公共类型存在且正常 if not ShangpinLeixing.objects.filter(id=gonggong_leixing_id, shenhezhuangtai=1).exists(): return Response({'code': 400, 'msg': '所选公共商品类型无效'}) image_file = request.FILES.get('image') if not image_file: return Response({'code': 400, 'msg': '必须上传类型图片'}) # 上传图片 file_path = f"dianpu/shangpin/shangpinleixing/{dianpu.id}_{int(time.time())}.png" full_url = upload_to_oss(image_file, file_path) if not full_url: return Response({'code': 500, 'msg': '图片上传失败'}) # 提取相对路径 tupian_url = full_url.replace(settings.COS_DOMAIN.rstrip('/') + '/', '', 1) # 审核状态 audit_config = DianpuShangpinShenheShezhi.objects.filter(id=1).first() shenhe_status = not (audit_config and audit_config.kaiqi_shenhe) ShangpinLeixingDianpu.objects.create( dianpu_id=dianpu.id, gonggong_leixing_id=gonggong_leixing_id, jieshao=jieshao, tupian_url=tupian_url, paixu=paixu, shangjia_zhuangtai=True, fengjin_zhuangtai=False, shenhe_zhuangtai=shenhe_status, ) return Response({'code': 0, 'msg': '商品类型添加成功'}) except Exception as e: logger.exception("添加商品类型失败") return Response({'code': 500, 'msg': '添加失败,请稍后重试'}) @transaction.atomic def _update_type(self, dianpu, request): """修改店铺商品类型,支持更换图片(先删旧图)""" type_id = request.data.get('type_id') or request.POST.get('type_id') if not type_id: return Response({'code': 400, 'msg': '缺少 type_id'}) try: obj = ShangpinLeixingDianpu.objects.select_for_update().get( id=type_id, dianpu_id=dianpu.id ) except ShangpinLeixingDianpu.DoesNotExist: return Response({'code': 404, 'msg': '商品类型不存在'}) except Exception as e: logger.exception("查询商品类型异常") return Response({'code': 500, 'msg': '服务器错误'}) try: update_fields = [] content_changed = False # 简介 if 'jieshao' in request.data or 'jieshao' in request.POST: obj.jieshao = request.data.get('jieshao') or request.POST.get('jieshao') update_fields.append('jieshao') content_changed = True # 映射公共类型 if 'gonggong_leixing_id' in request.data or 'gonggong_leixing_id' in request.POST: new_id = request.data.get('gonggong_leixing_id') or request.POST.get('gonggong_leixing_id') if not ShangpinLeixing.objects.filter(id=new_id, shenhezhuangtai=1).exists(): return Response({'code': 400, 'msg': '公共商品类型无效'}) obj.gonggong_leixing_id = new_id update_fields.append('gonggong_leixing_id') content_changed = True # 排序 if 'paixu' in request.data or 'paixu' in request.POST: obj.paixu = request.data.get('paixu') or request.POST.get('paixu') update_fields.append('paixu') # 上架状态 if 'shangjia_zhuangtai' in request.data or 'shangjia_zhuangtai' in request.POST: val = request.data.get('shangjia_zhuangtai') or request.POST.get('shangjia_zhuangtai') if isinstance(val, str): val = val.lower() in ('true', '1', 'yes') obj.shangjia_zhuangtai = bool(val) update_fields.append('shangjia_zhuangtai') # 图片更换:先删除旧图,再上传新图 image_file = request.FILES.get('image') if image_file: if obj.tupian_url: delete_from_oss(obj.tupian_url) # 删除旧图片 file_path = f"dianpu/shangpin/shangpinleixing/{dianpu.id}_{int(time.time())}.png" full_url = upload_to_oss(image_file, file_path) if not full_url: return Response({'code': 500, 'msg': '图片上传失败'}) obj.tupian_url = full_url.replace(settings.COS_DOMAIN.rstrip('/') + '/', '', 1) update_fields.append('tupian_url') content_changed = True # 审核重置 if content_changed: audit_config = DianpuShangpinShenheShezhi.objects.filter(id=1).first() if audit_config and audit_config.kaiqi_shenhe: obj.shenhe_zhuangtai = False update_fields.append('shenhe_zhuangtai') if update_fields: obj.save(update_fields=update_fields) return Response({'code': 0, 'msg': '商品类型更新成功'}) except Exception as e: logger.exception("修改商品类型失败") return Response({'code': 500, 'msg': '修改失败,请稍后重试'}) @transaction.atomic def _delete_type(self, dianpu, request): """删除店铺商品类型,可选是否同时删除关联商品""" type_id = request.data.get('id') or request.POST.get('id') if not type_id: return Response({'code': 400, 'msg': '缺少类型ID'}) try: obj = ShangpinLeixingDianpu.objects.select_for_update().get( id=type_id, dianpu_id=dianpu.id ) except ShangpinLeixingDianpu.DoesNotExist: return Response({'code': 404, 'msg': '商品类型不存在'}) except Exception as e: logger.exception("查询商品类型异常") return Response({'code': 500, 'msg': '服务器错误'}) try: delete_products = request.data.get('delete_products') or request.POST.get('delete_products') delete_products = str(delete_products).lower() in ('true', '1', 'yes') if delete_products: # 删除该类型下所有关联商品(同时清理图片) products = Shangpin.objects.filter( dianpu_leixing_id=obj.id, dianpu_id=dianpu.id ) for p in products: if p.tupian_url: delete_from_oss(p.tupian_url) if p.guize_tupian: delete_from_oss(p.guize_tupian) p.delete() else: # 仅解除关联,不删商品 Shangpin.objects.filter( dianpu_leixing_id=obj.id, dianpu_id=dianpu.id ).update(dianpu_leixing=None) # 删除类型自身图片 if obj.tupian_url: delete_from_oss(obj.tupian_url) obj.delete() return Response({'code': 0, 'msg': '类型删除成功'}) except Exception as e: logger.exception("删除商品类型失败") return Response({'code': 500, 'msg': '删除失败,请稍后重试'}) # ---------- 专区操作 ---------- @transaction.atomic def _add_zhuanqu(self, dianpu, request): """添加专区,自动从店铺商品类型获取 leixing_id(公共商品类型ID)""" try: mingzi = request.data.get('mingzi') dianpu_leixing_id = request.data.get('dianpu_leixing_id') paixu = request.data.get('paixu', 0) if not mingzi or not dianpu_leixing_id: return Response({'code': 400, 'msg': '缺少名称或所属商品类型'}) # 获取店铺商品类型,并拿到映射的公共类型ID try: shop_type = ShangpinLeixingDianpu.objects.get( id=dianpu_leixing_id, dianpu_id=dianpu.id ) leixing_id = shop_type.gonggong_leixing_id # 这就是专区需要的 leixing_id except ShangpinLeixingDianpu.DoesNotExist: return Response({'code': 400, 'msg': '所选商品类型无效'}) # 验证公共类型有效 if not ShangpinLeixing.objects.filter(id=leixing_id, shenhezhuangtai=1).exists(): return Response({'code': 400, 'msg': '关联的公共商品类型无效'}) ShangpinZhuanqu.objects.create( dianpu_id=dianpu.id, mingzi=mingzi, dianpu_leixing_id=dianpu_leixing_id, leixing_id=leixing_id, # 自动填充 paixu=paixu, shangjia_zhuangtai=True, fengjin_zhuangtai=False, ) return Response({'code': 0, 'msg': '专区添加成功'}) except Exception as e: logger.exception("添加专区失败") return Response({'code': 500, 'msg': '添加失败,请稍后重试'}) @transaction.atomic def _update_zhuanqu(self, dianpu, request): """修改专区,若更换所属商品类型则同步更新 leixing_id""" zhuanqu_id = request.data.get('zhuanqu_id') if not zhuanqu_id: return Response({'code': 400, 'msg': '缺少 zhuanqu_id'}) try: obj = ShangpinZhuanqu.objects.select_for_update().get( id=zhuanqu_id, dianpu_id=dianpu.id ) except ShangpinZhuanqu.DoesNotExist: return Response({'code': 404, 'msg': '专区不存在'}) except Exception as e: logger.exception("查询专区异常") return Response({'code': 500, 'msg': '服务器错误'}) try: update_fields = [] if 'mingzi' in request.data: obj.mingzi = request.data.get('mingzi') update_fields.append('mingzi') if 'dianpu_leixing_id' in request.data: new_id = request.data.get('dianpu_leixing_id') try: shop_type = ShangpinLeixingDianpu.objects.get( id=new_id, dianpu_id=dianpu.id ) obj.dianpu_leixing_id = new_id obj.leixing_id = shop_type.gonggong_leixing_id # 同步更新公共类型ID update_fields.extend(['dianpu_leixing_id', 'leixing_id']) except ShangpinLeixingDianpu.DoesNotExist: return Response({'code': 400, 'msg': '所选商品类型无效'}) if 'paixu' in request.data: obj.paixu = request.data.get('paixu') update_fields.append('paixu') if 'shangjia_zhuangtai' in request.data: val = request.data.get('shangjia_zhuangtai') if isinstance(val, str): val = val.lower() in ('true', '1', 'yes') obj.shangjia_zhuangtai = bool(val) update_fields.append('shangjia_zhuangtai') if update_fields: obj.save(update_fields=update_fields) return Response({'code': 0, 'msg': '专区更新成功'}) except Exception as e: logger.exception("修改专区失败") return Response({'code': 500, 'msg': '修改失败,请稍后重试'}) @transaction.atomic def _delete_zhuanqu(self, dianpu, request): """删除专区,可选是否同时删除关联商品""" zhuanqu_id = request.data.get('id') if not zhuanqu_id: return Response({'code': 400, 'msg': '缺少专区ID'}) try: obj = ShangpinZhuanqu.objects.select_for_update().get( id=zhuanqu_id, dianpu_id=dianpu.id ) except ShangpinZhuanqu.DoesNotExist: return Response({'code': 404, 'msg': '专区不存在'}) except Exception as e: logger.exception("查询专区异常") return Response({'code': 500, 'msg': '服务器错误'}) try: delete_products = request.data.get('delete_products') delete_products = str(delete_products).lower() in ('true', '1', 'yes') if delete_products: products = Shangpin.objects.filter( zhuanqu_id=obj.id, dianpu_id=dianpu.id ) for p in products: if p.tupian_url: delete_from_oss(p.tupian_url) if p.guize_tupian: delete_from_oss(p.guize_tupian) p.delete() else: Shangpin.objects.filter( zhuanqu_id=obj.id, dianpu_id=dianpu.id ).update(zhuanqu_id=None) obj.delete() return Response({'code': 0, 'msg': '专区删除成功'}) except Exception as e: logger.exception("删除专区失败") return Response({'code': 500, 'msg': '删除失败,请稍后重试'}) from django.core.paginator import Paginator class ShopProductListView(APIView): """ 商品列表接口,支持筛选、分页。 URL: /shangdian/sdhqspsj 权限: JWT认证 + 店铺验证 """ permission_classes = [IsAuthenticated] def post(self, request): # 1. 身份验证,获取店铺对象 frontend_user_id = request.data.get('userId') if not frontend_user_id: return Response({'code': 400, 'msg': '缺少必要参数 userId'}) dianpu, error = verify_shop_permission(request, frontend_user_id) if error: return error # 2. 安全解析分页与筛选参数 try: page = int(request.data.get('page', 1)) page_size = int(request.data.get('page_size', 20)) except (TypeError, ValueError): return Response({'code': 400, 'msg': '分页参数格式错误'}) keyword = str(request.data.get('keyword', '')).strip() shop_product_type_id = request.data.get('shop_product_type_id') # 店铺商品类型ID zhuanqu_id = request.data.get('zhuanqu_id') # 专区ID # 布尔型筛选字段 def parse_bool(val): if val is None or val == '': return None if isinstance(val, bool): return val if isinstance(val, str): return val.lower() in ('true', '1', 'yes') return bool(val) shenhe = parse_bool(request.data.get('shenhe_zhuangtai')) shangjia = parse_bool(request.data.get('shangjia_zhuangtai')) fengjin = parse_bool(request.data.get('fengjin_zhuangtai')) # 3. 构建查询(只查当前店铺的商品) products = Shangpin.objects.filter(dianpu_id=dianpu.id).select_related( 'dianpu_leixing' ).only( 'id', 'biaoqian', 'jiage', 'kucun', 'leixing_id', 'zhuanqu_id', 'zhenshi_xiaoliang', 'duiwai_xiaoliang', 'jieshao', 'xiadan_xuzhi', 'guize_tupian', 'yaoqiuleixing', 'huiyuan_id', 'yongjin', 'tupian_url', 'ewai_dashou_fencheng', 'paixu', 'dianpu_leixing_id', 'shangjia_zhuangtai', 'fengjin_zhuangtai', 'shenhe_zhuangtai', 'create_time', 'update_time' ) # 筛选 if shop_product_type_id is not None: try: products = products.filter(dianpu_leixing_id=int(shop_product_type_id)) except (ValueError, TypeError): return Response({'code': 400, 'msg': 'shop_product_type_id 格式错误'}) if zhuanqu_id is not None: try: products = products.filter(zhuanqu_id=int(zhuanqu_id)) except (ValueError, TypeError): return Response({'code': 400, 'msg': 'zhuanqu_id 格式错误'}) if shenhe is not None: products = products.filter(shenhe_zhuangtai=shenhe) if shangjia is not None: products = products.filter(shangjia_zhuangtai=shangjia) if fengjin is not None: products = products.filter(fengjin_zhuangtai=fengjin) # 关键词搜索(商品ID、标题、介绍) if keyword: products = products.filter( models.Q(id__icontains=keyword) | models.Q(biaoqian__icontains=keyword) | models.Q(jieshao__icontains=keyword) ) # 4. 分页 total_count = products.count() paginator = Paginator(products, page_size) page_obj = paginator.get_page(page) # 5. 构造返回数据 product_list = [] for p in page_obj: product_list.append({ 'id': p.id, 'biaoqian': p.biaoqian or '', 'jiage': p.jiage, 'kucun': p.kucun, 'leixing_id': p.leixing_id, 'zhuanqu_id': p.zhuanqu_id, 'zhenshi_xiaoliang': p.zhenshi_xiaoliang, 'duiwai_xiaoliang': p.duiwai_xiaoliang, 'jieshao': p.jieshao or '', 'xiadan_xuzhi': p.xiadan_xuzhi or '', 'guize_tupian': p.guize_tupian or '', 'yaoqiuleixing': p.yaoqiuleixing, 'huiyuan_id': p.huiyuan_id or '', 'yongjin': p.yongjin, 'tupian_url': p.tupian_url or '', 'ewai_dashou_fencheng': p.ewai_dashou_fencheng, 'paixu': p.paixu, 'dianpu_leixing_id': p.dianpu_leixing_id, 'shangjia_zhuangtai': p.shangjia_zhuangtai, 'fengjin_zhuangtai': p.fengjin_zhuangtai, 'shenhe_zhuangtai': p.shenhe_zhuangtai, 'create_time': p.create_time.isoformat() if p.create_time else '', 'update_time': p.update_time.isoformat() if p.update_time else '', }) return Response({ 'code': 0, 'data': { 'list': product_list, 'total': total_count, } }) class ShopProductModifyView1(APIView): """ 商品统一操作:修改、添加、删除 URL: /shangdian/xgdpsplx 权限: JWT认证 + 店铺验证 """ permission_classes = [IsAuthenticated] def post(self, request): frontend_user_id = request.data.get('userId') if not frontend_user_id: return Response({'code': 400, 'msg': '缺少必要参数 userId'}) dianpu, error = verify_shop_permission(request, frontend_user_id) if error: return error action = request.data.get('action') if not action: return Response({'code': 400, 'msg': '缺少操作类型 action'}) # 分发 if action == 'update_product': return self._update_product(dianpu, request.data) elif action == 'add_product': return self._add_product(dianpu, request.data) elif action == 'delete_product': return self._delete_product(dianpu, request.data) else: return Response({'code': 400, 'msg': '不支持的操作类型'}) # ---------- 修改商品 ---------- @transaction.atomic def _update_product(self, dianpu, data): product_id = data.get('product_id') if not product_id: return Response({'code': 400, 'msg': '缺少 product_id'}) try: product = Shangpin.objects.select_for_update().get(id=product_id, dianpu_id=dianpu.id) except Shangpin.DoesNotExist: return Response({'code': 404, 'msg': '商品不存在'}) update_fields = [] # 标记是否有核心内容修改(会触发审核重置) content_changed = False # 标题 if 'biaoqian' in data: product.biaoqian = data['biaoqian'] update_fields.append('biaoqian') content_changed = True # 价格 if 'jiage' in data: product.jiage = data['jiage'] update_fields.append('jiage') content_changed = True # 库存 if 'kucun' in data: product.kucun = data['kucun'] update_fields.append('kucun') content_changed = True # 店铺商品类型 if 'dianpu_leixing_id' in data: # 验证类型属于当前店铺 new_type_id = data['dianpu_leixing_id'] if not ShangpinLeixingDianpu.objects.filter(id=new_type_id, dianpu_id=dianpu.id).exists(): return Response({'code': 400, 'msg': '无效的店铺商品类型'}) product.dianpu_leixing_id = new_type_id update_fields.append('dianpu_leixing_id') content_changed = True # 专区 if 'zhuanqu_id' in data: new_zq_id = data['zhuanqu_id'] if new_zq_id and not ShangpinZhuanqu.objects.filter(id=new_zq_id, dianpu_id=dianpu.id).exists(): return Response({'code': 400, 'msg': '无效的专区'}) product.zhuanqu_id = new_zq_id or None update_fields.append('zhuanqu_id') content_changed = True # 介绍 if 'jieshao' in data: product.jieshao = data['jieshao'] update_fields.append('jieshao') content_changed = True # 下单须知 if 'xiadan_xuzhi' in data: product.xiadan_xuzhi = data['xiadan_xuzhi'] update_fields.append('xiadan_xuzhi') # 抢单要求类型 if 'yaoqiuleixing' in data: yqlx = int(data['yaoqiuleixing']) if yqlx not in [1, 2]: return Response({'code': 400, 'msg': '抢单要求类型无效'}) product.yaoqiuleixing = yqlx update_fields.append('yaoqiuleixing') content_changed = True # 会员ID if 'huiyuan_id' in data: # 当抢单要求是会员时可能传 product.huiyuan_id = data['huiyuan_id'] or None update_fields.append('huiyuan_id') content_changed = True # 佣金 if 'yongjin' in data: product.yongjin = data['yongjin'] or 0 update_fields.append('yongjin') content_changed = True # 打手额外分成金额(不能超过商品价格) if 'ewai_dashou_fencheng' in data: fencheng = data['ewai_dashou_fencheng'] or 0 current_price = product.jiage if fencheng > current_price: return Response({'code': 400, 'msg': '分成金额不能超过商品价格'}) product.ewai_dashou_fencheng = fencheng update_fields.append('ewai_dashou_fencheng') content_changed = True # 排序 if 'paixu' in data: product.paixu = data['paixu'] update_fields.append('paixu') # 上架状态 if 'shangjia_zhuangtai' in data: product.shangjia_zhuangtai = data['shangjia_zhuangtai'] update_fields.append('shangjia_zhuangtai') # 商品图片(上传新图则替换旧图) if 'tupian_url' in data and data['tupian_url'] != product.tupian_url: # 删除旧图 if product.tupian_url: delete_from_oss(product.tupian_url) product.tupian_url = data['tupian_url'] update_fields.append('tupian_url') content_changed = True # 规则图片 if 'guize_tupian' in data: new_rule = data['guize_tupian'] or '' if new_rule != (product.guize_tupian or ''): if product.guize_tupian: delete_from_oss(product.guize_tupian) product.guize_tupian = new_rule if new_rule else None update_fields.append('guize_tupian') content_changed = True # 检查全局审核模式:若开启且内容有修改,则标记为未审核 if content_changed: audit_config = DianpuShangpinShenheShezhi.objects.filter(id=1).first() if audit_config and audit_config.kaiqi_shenhe: product.shenhe_zhuangtai = False update_fields.append('shenhe_zhuangtai') if update_fields: product.save(update_fields=update_fields + ['update_time']) return Response({'code': 0, 'msg': '商品修改成功'}) # ---------- 添加商品 ---------- @transaction.atomic def _add_product(self, dianpu, data): biaoqian = data.get('biaoqian') jiage = data.get('jiage') kucun = data.get('kucun') dianpu_leixing_id = data.get('dianpu_leixing_id') zhuanqu_id = data.get('zhuanqu_id') jieshao = data.get('jieshao') # 必填校验 if not all([biaoqian, jiage is not None, kucun is not None, dianpu_leixing_id, zhuanqu_id, jieshao]): return Response({'code': 400, 'msg': '必填项缺失'}) # 验证店铺商品类型 try: shop_type = ShangpinLeixingDianpu.objects.get(id=dianpu_leixing_id, dianpu_id=dianpu.id) except ShangpinLeixingDianpu.DoesNotExist: return Response({'code': 400, 'msg': '无效的店铺商品类型'}) # 获取映射的公共商品类型 try: public_type = ShangpinLeixing.objects.get(id=shop_type.gonggong_leixing_id, shenhezhuangtai=1) except ShangpinLeixing.DoesNotExist: return Response({'code': 400, 'msg': '关联的公共商品类型无效'}) # 抢单要求:默认会员,若用户选择了会员但未指定,则用公共类型的会员ID yaoqiuleixing = data.get('yaoqiuleixing', 1) if yaoqiuleixing == 1: huiyuan_id = data.get('huiyuan_id') or public_type.huiyuan_id or None yongjin = None else: huiyuan_id = None yongjin = data.get('yongjin') or public_type.yongjin or 0 # 打手额外分成:金额不能大于价格,没传则0 ewai_fencheng = data.get('ewai_dashou_fencheng') or 0 try: ewai_val = float(ewai_fencheng) jiage_val = float(jiage) if ewai_val > jiage_val: return Response({'code': 400, 'msg': '分成金额不能超过商品价格'}) except ValueError: # 处理转换失败的情况,比如记录日志或设为默认值 print("无法转换为数字") '''if ewai_fencheng > jiage: return Response({'code': 400, 'msg': '分成金额不能超过商品价格'})''' # 审核状态:根据全局配置决定 shenhe_status = True # 默认通过 audit_config = DianpuShangpinShenheShezhi.objects.filter(id=1).first() if audit_config and audit_config.kaiqi_shenhe: shenhe_status = False # 创建商品 product = Shangpin.objects.create( biaoqian=biaoqian, jiage=jiage, kucun=kucun, dianpu_id=dianpu.id, dianpu_leixing_id=dianpu_leixing_id, zhuanqu_id=zhuanqu_id, leixing_id=public_type.id, # 公共商品类型ID jieshao=jieshao, xiadan_xuzhi=data.get('xiadan_xuzhi', ''), guize_tupian=data.get('guize_tupian') or None, tupian_url=data.get('tupian_url') or '', # 商品图片必须由前端上传后传入 yaoqiuleixing=yaoqiuleixing, huiyuan_id=huiyuan_id, yongjin=yongjin, ewai_dashou_fencheng=ewai_fencheng, kaioi_ewai_dashou_fencheng=True, # 强制开启 paixu=data.get('paixu', 0), shangjia_zhuangtai=data.get('shangjia_zhuangtai', True), # 默认上架 fengjin_zhuangtai=False, # 默认未封禁 shenhe_zhuangtai=shenhe_status, shenhezhuangtai=1, # 另一个审核字段永远正常 ) return Response({'code': 0, 'msg': '商品添加成功', 'product_id': product.id}) # ---------- 删除商品 ---------- @transaction.atomic def _delete_product(self, dianpu, data): product_id = data.get('product_id') if not product_id: return Response({'code': 400, 'msg': '缺少 product_id'}) try: product = Shangpin.objects.select_for_update().get(id=product_id, dianpu_id=dianpu.id) except Shangpin.DoesNotExist: return Response({'code': 404, 'msg': '商品不存在'}) # 删除关联图片(OSS) if product.tupian_url: delete_from_oss(product.tupian_url) if product.guize_tupian: delete_from_oss(product.guize_tupian) product.delete() return Response({'code': 0, 'msg': '商品已删除'}) from shangpin.models import Huiyuan # 会员模型,根据实际路径调整 class MemberListView(APIView): """ 获取所有会员列表(供商品发布时选择) URL: /shangdian/hqhy 权限:JWT认证 + 店铺归属验证 说明:前端需传递 userId,接口会进行身份及店铺状态校验 """ permission_classes = [IsAuthenticated] def post(self, request): # ---------- 1. 获取前端传递的用户ID(必须) ---------- frontend_user_id = request.data.get('userId') if not frontend_user_id: return Response({'code': 400, 'msg': '缺少必要参数 userId'}) # ---------- 2. 调用公共验证方法(会校验用户身份、店铺状态) ---------- dianpu, error_response = verify_shop_permission(request, frontend_user_id) if error_response: return error_response # ---------- 3. 查询所有会员(只返回前端需要的字段) ---------- try: members = Huiyuan.objects.all().values('huiyuan_id', 'jieshao') member_list = [ { 'huiyuan_id': m['huiyuan_id'], 'jieshao': m['jieshao'] or '' } for m in members ] except Exception as e: logger.exception("获取会员列表失败") return Response({'code': 500, 'msg': '服务器错误'}) # ---------- 4. 返回成功响应 ---------- return Response({ 'code': 0, 'msg': 'success', 'data': member_list }) from django.db import transaction, IntegrityError from django.core.paginator import Paginator, EmptyPage class SubShopListView(APIView): """ 获取下级店铺列表,同时返回上级分红汇总 URL: /shangdian/wdxjdp 方法: POST 权限: JWT认证 + 上级店铺身份验证 """ permission_classes = [IsAuthenticated] def post(self, request): # 1. 身份验证 - 获取上级店铺对象 user_id = request.data.get('userId') if not user_id: return Response({'code': 400, 'msg': '缺少 userId'}) parent_dianpu, error = verify_shop_permission(request, user_id) if error: return error # 此时 parent_dianpu 即为当前上级店铺 # 2. 读取筛选参数 dianpu_mingcheng = request.data.get('dianpu_mingcheng', '').strip() zhuangtai = request.data.get('zhuangtai') bind_min = request.data.get('bind_min') bind_max = request.data.get('bind_max') page = request.data.get('page', 1) page_size = request.data.get('page_size', 10) # 3. 查询下级店铺:yaoqing_dianpu_id 等于当前上级店铺ID queryset = Dianpu.objects.filter(yaoqing_dianpu_id=parent_dianpu.id) # 4. 筛选条件 if dianpu_mingcheng: queryset = queryset.filter(dianpu_mingcheng__icontains=dianpu_mingcheng) if zhuangtai is not None: try: zhuangtai = int(zhuangtai) queryset = queryset.filter(zhuangtai=zhuangtai) except (ValueError, TypeError): pass if bind_min is not None: try: bind_min = int(bind_min) queryset = queryset.filter(bangding_yonghushu__gte=bind_min) except (ValueError, TypeError): pass if bind_max is not None: try: bind_max = int(bind_max) queryset = queryset.filter(bangding_yonghushu__lte=bind_max) except (ValueError, TypeError): pass # 5. 排序:按创建时间倒序 queryset = queryset.order_by('-create_time') # 6. 分页 try: paginator = Paginator(queryset, page_size) page_obj = paginator.page(page) except EmptyPage: page_obj = paginator.page(1) # 7. 构建列表数据 shop_list = [] for shop in page_obj: shop_list.append({ 'id': shop.id, 'dianpu_mingcheng': shop.dianpu_mingcheng, 'dianpu_touxiang': shop.dianpu_touxiang or '', 'lianxi_dianhua': shop.lianxi_dianhua or '', 'zhuangtai': shop.zhuangtai, 'bangding_yonghushu': shop.bangding_yonghushu, 'zhengqu_zonge': str(shop.zhengqu_zonge), 'ketixian_yue': str(shop.ketixian_yue), 'create_time': shop.create_time.strftime('%Y-%m-%d %H:%M:%S') if shop.create_time else '', 'update_time': shop.update_time.strftime('%Y-%m-%d %H:%M:%S') if shop.update_time else '', 'erweima_url': shop.erweima_url or '', 'fenhong_choucheng_feilv': str(shop.fenhong_choucheng_feilv) if shop.fenhong_choucheng_feilv else '0.0000', }) # 8. 上级分红汇总(从当前店铺主表读取) shangji_leiji = str(parent_dianpu.shangji_fenhong_leiji) if parent_dianpu.shangji_fenhong_leiji else '0.00' shangji_ketixian = str(parent_dianpu.shangji_ketixian_fenhong) if parent_dianpu.shangji_ketixian_fenhong else '0.00' return Response({ 'code': 200, 'msg': '获取成功', 'data': { 'list': shop_list, 'total': paginator.count, 'page': page_obj.number, 'page_size': page_size, 'shangji_fenhong_leiji': shangji_leiji, 'shangji_ketixian_fenhong': shangji_ketixian, } }) class SubShopModifyView(APIView): """ 添加/修改下级店铺 URL: /shangdian/xjdpbj 方法: POST 权限: JWT认证 + 上级店铺身份验证 action字段: - 'add' : 添加新的子店铺 - 'update': 修改已有子店铺的状态/分红费率 """ permission_classes = [IsAuthenticated] def post(self, request): # 1. 身份验证 user_id = request.data.get('userId') if not user_id: return Response({'code': 400, 'msg': '缺少 userId'}) parent_dianpu, error = verify_shop_permission(request, user_id) if error: return error action = request.data.get('action', '').strip().lower() if action == 'add': return self._add_subshop(request, parent_dianpu) elif action == 'update': return self._update_subshop(request, parent_dianpu) else: return Response({'code': 400, 'msg': '未知操作类型,action 应为 add 或 update'}) def _add_subshop(self, request, parent_dianpu): """添加子店铺,需提供用户UID、账号、密码、店铺名称等""" user_uid = request.data.get('user_uid', '').strip() zhanghao = request.data.get('zhanghao', '').strip() mima = request.data.get('mima', '') dianpu_mingcheng = request.data.get('dianpu_mingcheng', '').strip() lianxi_dianhua = request.data.get('lianxi_dianhua', '').strip() or None fenhong_feilv = request.data.get('fenhong_choucheng_feilv', None) # 必填校验 if not all([user_uid, zhanghao, mima, dianpu_mingcheng]): return Response({'code': 400, 'msg': '必填字段缺失(user_uid, zhanghao, mima, dianpu_mingcheng)'}) # 检查用户是否存在 if not UserMain.objects.filter(yonghuid=user_uid).exists(): return Response({'code': 404, 'msg': '对应的小程序用户不存在'}) # 检查账号是否已占用 if YonghuPingzheng.objects.filter(zhanghao=zhanghao).exists(): return Response({'code': 400, 'msg': '登录账号已存在'}) if YonghuPingzheng.objects.filter(yonghu=user_uid).exists(): return Response({'code': 400, 'msg': '该用户 ID 已有店铺凭证'}) # 费率处理:前端可选,后端默认 0.0500 if fenhong_feilv is not None: try: fenhong_feilv = float(fenhong_feilv) if fenhong_feilv < 0 or fenhong_feilv > 1: return Response({'code': 400, 'msg': '分红费率需在 0~1 之间'}) except (ValueError, TypeError): return Response({'code': 400, 'msg': '分红费率格式错误'}) else: fenhong_feilv = 0.0500 try: with transaction.atomic(): # 创建凭证 pingzheng = YonghuPingzheng.objects.create( yonghu=user_uid, zhanghao=zhanghao, mima=mima, is_active=True ) # 创建店铺,关联上级 dianpu = Dianpu.objects.create( pingzheng=pingzheng, dianpu_mingcheng=dianpu_mingcheng, lianxi_dianhua=lianxi_dianhua or '', yaoqing_dianpu_id=parent_dianpu.id, # 关键:绑定上级 fenhong_choucheng_feilv=fenhong_feilv, # 其他字段使用默认值 ) return Response({ 'code': 200, 'msg': '子店铺添加成功', 'dianpu_id': dianpu.id }) except IntegrityError as e: logger.exception("创建子店铺违反唯一约束") return Response({'code': 400, 'msg': '数据冲突,请检查账号或用户ID'}) except Exception as e: logger.exception("创建子店铺失败") return Response({'code': 500, 'msg': '创建失败,请稍后重试'}) def _update_subshop(self, request, parent_dianpu): """修改子店铺状态和上级分红费率""" dianpu_id = request.data.get('dianpu_id') if not dianpu_id: return Response({'code': 400, 'msg': '缺少 dianpu_id'}) try: dianpu_id = int(dianpu_id) except (ValueError, TypeError): return Response({'code': 400, 'msg': 'dianpu_id 需为数字'}) try: with transaction.atomic(): sub_dianpu = Dianpu.objects.select_for_update().get(id=dianpu_id) # 权限校验:确保该店铺的上级就是当前店铺 if sub_dianpu.yaoqing_dianpu_id != parent_dianpu.id: return Response({'code': 403, 'msg': '无权修改该店铺,非您的下级'}) # 更新状态(如果有传) if 'zhuangtai' in request.data: zhuangtai = request.data['zhuangtai'] if zhuangtai not in (0, 1, '0', '1'): return Response({'code': 400, 'msg': '店铺状态只能为 0(封禁) 或 1(正常)'}) sub_dianpu.zhuangtai = int(zhuangtai) # 更新上级分红费率(如果有传) if 'fenhong_choucheng_feilv' in request.data: feilv = request.data['fenhong_choucheng_feilv'] try: feilv = float(feilv) if feilv < 0 or feilv > 1: return Response({'code': 400, 'msg': '分红费率需在 0~1 之间'}) sub_dianpu.fenhong_choucheng_feilv = feilv except (ValueError, TypeError): return Response({'code': 400, 'msg': '分红费率格式错误'}) sub_dianpu.save(update_fields=['zhuangtai', 'fenhong_choucheng_feilv', 'update_time']) return Response({'code': 200, 'msg': '修改成功'}) except Dianpu.DoesNotExist: return Response({'code': 404, 'msg': '店铺不存在'}) except Exception as e: logger.exception("修改下级店铺失败") return Response({'code': 500, 'msg': '修改失败,请稍后重试'}) # shangdian/views/order_manage_views.py """ 店铺订单管理接口 包含: 1. 统计数据:商品类型及订单总数、各状态订单数 2. 订单列表:支持筛选、分页、返回订单详情及打手图片(含跨平台图片拉取) """ from concurrent.futures import ThreadPoolExecutor, as_completed from django.db.models import Count, Q, Prefetch from django.core.paginator import Paginator, EmptyPage # 已存在的验证函数 from dingdan.models import Dingdan, DingdanPingtai, Dashoutupian from peizhi.models import ClubConfig, Club # 跨平台相关模型,按实际路径调整 from collections import defaultdict # 如果有 ClubConfig 模型用于获取存储桶域名,确保导入(如没有可忽略,使用 settings 中的 OSS 域名) try: from kuaping.models import ClubConfig except ImportError: ClubConfig = None # ================================================================ # 视图1:订单统计接口 # ================================================================ class ShopOrderStatisticsView(APIView): """ 店铺订单统计接口 URL: POST /shangdian/ddltj 权限: JWT认证 + 店铺身份验证 请求参数: userId (必填): 店铺所属用户的ID 返回结构: { "code": 200, "msg": "统计成功", "data": { "types": [ # 所有商品类型 { "id": 类型ID, "jieshao": "类型介绍", "tupian_url": "类型图片相对路径", "order_count": 该类型订单总数, "status_counts": { # 该类型下各状态订单数(键为字符串) "1": 数量, "7": 数量, "2": 数量, "8": 数量, "3": 数量, "4": 数量, "5": 数量, "6": 数量 } }, ... ], "status_counts": { # 全局各状态订单总数 "1": 总数, "7": 总数, "2": 总数, "8": 总数, "3": 总数, "4": 总数, "5": 总数, "6": 总数 }, "total_orders": 订单总量 } } """ permission_classes = [IsAuthenticated] def post(self, request): # 1. 获取身份标识 user_id = request.data.get('userId', '').strip() if not user_id: return Response({'code': 400, 'msg': '缺少 userId'}) # 2. 验证店铺身份,获取店铺对象 dianpu, error = verify_shop_permission(request, user_id) if error: return error shop_id = dianpu.id # 3. 获取所有正常的商品类型(审核状态=1) types_qs = ShangpinLeixing.objects.filter( shenhezhuangtai=1 ).order_by('-paixu', 'id') # 构建类型列表,初始化各状态计数为 0 type_list = [] for t in types_qs: type_list.append({ 'id': t.id, 'jieshao': t.jieshao or '', 'tupian_url': t.tupian_url or '', 'order_count': 0, 'status_counts': { '1': 0, '7': 0, '2': 0, '8': 0, '3': 0, '4': 0, '5': 0, '6': 0 } }) # 4. 获取本店铺所有订单 ID(通过平台扩展表) order_ids = list( DingdanPingtai.objects.filter(dianpu_id=shop_id) .values_list('dingdan_id', flat=True) ) total_orders = 0 # 订单总量 global_counts = defaultdict(int) # 全局各状态计数 if order_ids: # 5. 按商品类型和订单状态聚合统计 agg = ( Dingdan.objects .filter(dingdan_id__in=order_ids) .values('leixing_id', 'zhuangtai') .annotate(cnt=Count('id')) ) for row in agg: lid = row['leixing_id'] # 商品类型ID st = str(row['zhuangtai']) # 状态码 -> 字符串 cnt = row['cnt'] # 累加总量与全局状态 total_orders += cnt global_counts[st] += cnt # 填充到对应商品类型 for tp in type_list: if tp['id'] == lid: tp['order_count'] += cnt if st in tp['status_counts']: tp['status_counts'][st] += cnt break # 6. 保证全局统计返回所有8个状态 all_status_keys = ['1', '7', '2', '8', '3', '4', '5', '6'] final_global = {k: global_counts.get(k, 0) for k in all_status_keys} return Response({ 'code': 200, 'msg': '统计成功', 'data': { 'types': type_list, 'status_counts': final_global, 'total_orders': total_orders } }) def get_oss_domain(): """ 获取对象存储的域名(用于拼接本地打手图片完整 URL) 优先从 ClubConfig 获取,其次从 settings.OSS_URL 获取 """ if ClubConfig: my_config = ClubConfig.objects.filter(is_self=1).first() if my_config and my_config.storage_bucket_domain: return my_config.storage_bucket_domain.rstrip('/') + '/' # 降级使用 settings 中的 OSS URL return getattr(settings, 'OSS_URL', '').rstrip('/') + '/' def _fetch_partner_images_for_order(order, oss_domain): """ 根据单个订单获取打手提交的图片列表(完整绝对 URL)。 处理本地图片 + 跨平台图片(重试拉取对方平台图片) 返回: (images_list, is_punished, punishment_info) """ images = [] is_punished = False punishment_info = {} # 1. 本地打手图片(我方平台的打手提交) if order.jiedan_dashou_id: local_imgs = Dashoutupian.objects.filter( dingdan_id=order.dingdan_id, dashou=order.jiedan_dashou_id ).values_list('tupian', flat=True) for rel_path in local_imgs: if rel_path: if rel_path.startswith('http'): images.append(rel_path) else: images.append(oss_domain + rel_path.lstrip('/')) # 2. 如果订单是跨平台且需要获取对方打手图片 if order.is_cross and order.partner_club_id and order.partner_order_id: try: my_club = ClubConfig.objects.filter(is_self=1).first() if not my_club: logger.error("无法获取本俱乐部配置") return images, is_punished, punishment_info self_club_id = my_club.club_id # 获取合作关系 club_rel = Club.objects.get(club_id=self_club_id, partner_club_id=order.partner_club_id) partner_domain = club_rel.partner_domain.rstrip('/') # 构建对方接口URL url = partner_domain + '/houtai/kpthqtp' payload = {'order_id': order.partner_order_id, 'club_id': self_club_id} retries = 2 for attempt in range(retries): try: resp = requests.post(url, json=payload, timeout=5) if resp.status_code == 200: data = resp.json() if data.get('code') == 0: partner_data = data.get('data', {}) partner_imgs = partner_data.get('images', []) # 对方返回的图片可能是相对路径,拼接对方域名 for img in partner_imgs: if img.startswith('http'): images.append(img) else: images.append(partner_domain + '/' + img.lstrip('/')) punishment_info = partner_data.get('punishment', {}) is_punished = bool(punishment_info.get('cfliyou')) break else: logger.warning(f"对方接口HTTP错误: {resp.status_code}") except Exception as e: logger.warning(f"请求对方图片失败(重试{attempt+1}): {e}") except Exception as e: logger.error(f"获取跨平台图片异常: {e}") return images, is_punished, punishment_info def _get_images_for_orders(orders, oss_domain, max_workers=10): """ 并发获取多个订单的打手图片,返回字典 {dingdan_id: (images, is_punished, punishment_info)} """ result = {} with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_order = { executor.submit(_fetch_partner_images_for_order, order, oss_domain): order.dingdan_id for order in orders } for future in as_completed(future_to_order): dingdan_id = future_to_order[future] try: images, is_punished, pun_info = future.result() result[dingdan_id] = (images, is_punished, pun_info) except Exception as e: logger.error(f"获取订单 {dingdan_id} 图片失败: {e}") result[dingdan_id] = ([], False, {}) return result class ShopOrderManageView(APIView): """ 店铺订单管理与统计接口 URL: /shangdian/ddlbhq 方法: POST 权限: JWT认证 + 店铺身份验证 (verify_shop_permission) 请求参数: - userId (必填): 当前店铺对应的小程序用户ID - action (可选): 值为 'statistics' 时返回统计数据;其他任意值或省略均返回订单列表 统计模式 (action='statistics') 返回: types: 商品类型列表,每个类型包含: id, jieshao, tupian_url, order_count, status_counts status_counts: 各状态订单总数 (全局) total_orders: 订单总量 列表模式 (默认) 支持筛选、分页、搜索,返回: list: 订单列表,每个订单包含: dingdan_id, zhuangtai, jine, dashou_fencheng, jiedan_dashou_id, dashou_liuyan, zhiding_id, shangpin_id, tupian, jieshao, beizhu, nicheng, leixing_id, create_time, update_time, pingtai_kuozhan (laoban_id, laoban_pingjia, dianpu_shouyi), dashou_images (绝对URL数组), is_punished, is_fadaned total: 总条数 page: 当前页码 page_size: 每页条数 """ permission_classes = [IsAuthenticated] def post(self, request): user_id = request.data.get('userId', '').strip() if not user_id: return Response({'code': 400, 'msg': '缺少 userId'}) # 验证店铺身份 dianpu, error_response = verify_shop_permission(request, user_id) if error_response: return error_response action = request.data.get('action', '').strip().lower() if action == 'statistics': return self._handle_statistics(dianpu) else: return self._handle_order_list(request, dianpu) def _handle_statistics(self, dianpu): """统计逻辑:商品类型计数 + 各状态订单数 + 总量""" shop_id = dianpu.id # 1. 获取正常商品类型(审核状态=1) types_qs = ShangpinLeixing.objects.filter(shenhezhuangtai=1).order_by('-paixu', 'id') types_data = [] type_ids = [] for t in types_qs: types_data.append({ 'id': t.id, 'jieshao': t.jieshao or '', 'tupian_url': t.tupian_url or '', 'order_count': 0, # 之后填充 'status_counts': {} # 该类型下各状态计数 }) type_ids.append(t.id) # 2. 获取本店铺所有订单 ID order_ids = DingdanPingtai.objects.filter( dianpu_id=shop_id ).values_list('dingdan_id', flat=True) # 统计结构 type_status_map = defaultdict(lambda: defaultdict(int)) global_status_counts = defaultdict(int) total_orders = 0 if order_ids: # 批量聚合统计 aggregation = Dingdan.objects.filter( dingdan_id__in=order_ids ).values('leixing_id', 'zhuangtai').annotate(cnt=Count('id')) for row in aggregation: lx_id = row['leixing_id'] zt = row['zhuangtai'] cnt = row['cnt'] if lx_id in type_ids: type_status_map[lx_id][zt] += cnt global_status_counts[zt] += cnt total_orders += cnt # 填充统计数据回类型列表 for t in types_data: tid = t['id'] t['order_count'] = sum(type_status_map[tid].values()) t['status_counts'] = dict(type_status_map[tid]) return Response({ 'code': 200, 'msg': '获取成功', 'data': { 'types': types_data, 'status_counts': dict(global_status_counts), 'total_orders': total_orders } }) def _handle_order_list(self, request, dianpu): """列表逻辑:筛选 + 分页 + 搜索 + 图片并发获取""" shop_id = dianpu.id # ---------- 解析筛选参数 ---------- leixing_id = request.data.get('leixing_id') zhuangtai = request.data.get('zhuangtai') dingdan_id = request.data.get('dingdan_id', '').strip() jiedan_dashou_id = request.data.get('jiedan_dashou_id', '').strip() nicheng = request.data.get('nicheng', '').strip() laoban_id = request.data.get('laoban_id', '').strip() jieshao = request.data.get('jieshao', '').strip() page = request.data.get('page', 1) page_size = request.data.get('page_size', 20) # ---------- 基础查询:店铺的订单(通过扩展表关联) ---------- queryset = DingdanPingtai.objects.filter( dianpu_id=shop_id ).select_related('dingdan').order_by('-dingdan__create_time') # 应用筛选 if leixing_id is not None: queryset = queryset.filter(dingdan__leixing_id=leixing_id) if zhuangtai is not None: queryset = queryset.filter(dingdan__zhuangtai=zhuangtai) if dingdan_id: queryset = queryset.filter(dingdan__dingdan_id__icontains=dingdan_id) if jiedan_dashou_id: queryset = queryset.filter(dingdan__jiedan_dashou_id=jiedan_dashou_id) if nicheng: queryset = queryset.filter(dingdan__nicheng__icontains=nicheng) if laoban_id: queryset = queryset.filter(laoban_id=laoban_id) # 扩展表字段 if jieshao: queryset = queryset.filter(dingdan__jieshao__icontains=jieshao) # ---------- 分页 ---------- paginator = Paginator(queryset, page_size) try: page_obj = paginator.page(page) except EmptyPage: page_obj = paginator.page(1) # 收集当前页订单列表(主表 + 扩展表) orders_with_ext = [] for pt_kuozhan in page_obj: orders_with_ext.append((pt_kuozhan.dingdan, pt_kuozhan)) # 提取所有订单主表对象,用于并发获取打手图片 dingdan_objs = [order for order, _ in orders_with_ext] oss_domain = get_oss_domain() images_map = _get_images_for_orders(dingdan_objs, oss_domain) # ---------- 组装返回数据 ---------- result_list = [] for order, pt_kuozhan in orders_with_ext: order_data = { 'dingdan_id': order.dingdan_id, 'zhuangtai': order.zhuangtai, 'jine': float(order.jine) if order.jine else 0.00, 'dashou_fencheng': float(order.dashou_fencheng) if order.dashou_fencheng else 0.00, 'jiedan_dashou_id': order.jiedan_dashou_id or '', 'dashou_liuyan': order.dashou_liuyan or '', # 打手留言 'zhiding_id': order.zhiding_id or '', 'shangpin_id': order.shangpin_id or '', 'tupian': order.tupian or '', 'jieshao': order.jieshao or '', 'beizhu': order.beizhu or '', 'nicheng': order.nicheng or '', 'leixing_id': order.leixing_id, 'create_time': order.create_time.strftime('%Y-%m-%d %H:%M:%S') if order.create_time else '', 'update_time': order.update_time.strftime('%Y-%m-%d %H:%M:%S') if order.update_time else '', 'pingtai_kuozhan': { 'laoban_id': pt_kuozhan.laoban_id or '', 'laoban_pingjia': pt_kuozhan.laoban_pingjia or '', 'dianpu_shouyi': float(pt_kuozhan.dianpu_shouyi) if pt_kuozhan.dianpu_shouyi else 0.00, }, # 打手图片(已在后端拼接绝对 URL) 'dashou_images': images_map.get(order.dingdan_id, ([], False, {}))[0], 'is_punished': images_map.get(order.dingdan_id, ([], False, {}))[1], 'is_fadaned': False, # 暂不支持罚款状态,可根据需要扩展 } result_list.append(order_data) return Response({ 'code': 200, 'msg': '获取成功', 'data': { 'list': result_list, 'total': paginator.count, 'page': page_obj.number, 'page_size': page_size } }) # ================================================================ # shangdian/views/refund_view.py # 店铺退款接口 —— 基于跨平台退款接口二改,只处理平台订单,我方派单 # ================================================================ import json import time import random import string import hashlib import hmac import logging import traceback from decimal import Decimal import xmltodict # 解析微信退款回执 import requests from django.db import transaction from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from shangdian.utils import verify_shop_permission # 店铺身份验证 from dingdan.models import Dingdan, DingdanPingtai, Dashoutupian, Tuikuanjilu from yonghu.models import UserMain from shangdian.models import Dianpu logger = logging.getLogger(__name__) class ShopRefundView(APIView): """ 店铺订单退款接口 URL: POST /shangdian/tk 权限: JWT认证 + 店铺身份验证(只处理平台订单,我方派单) 请求参数: userId (必填) : 店铺所属用户的ID dingdan_id (必填): 订单ID reason (可选) : 退款理由 """ permission_classes = [IsAuthenticated] def post(self, request): # 1. 获取参数 user_id = request.data.get('userId', '').strip() dingdan_id = request.data.get('dingdan_id', '').strip() reason = request.data.get('reason', '').strip() # 退款理由 if not user_id or not dingdan_id: return Response({'code': 400, 'msg': '参数不完整'}) # 2. 店铺身份验证 dianpu, error = verify_shop_permission(request, user_id) if error: return error shop_id = dianpu.id # 3. 查询订单,必须属于本店铺(通过扩展表关联) try: # 通过扩展表确认归属,并 select_related 获取订单主表 pingtai_ext = DingdanPingtai.objects.select_related('dingdan').get( dianpu_id=shop_id, dingdan__dingdan_id=dingdan_id ) order = pingtai_ext.dingdan except DingdanPingtai.DoesNotExist: logger.warning(f"订单 {dingdan_id} 不属于店铺 {shop_id} 或不存在") return Response({'code': 404, 'msg': '订单不存在或不属于本店铺'}) # 4. 订单状态校验(只有这些状态可退款) allowed_statuses = [1, 7, 2, 8, 4] if order.zhuangtai not in allowed_statuses: logger.warning(f"订单状态不允许退款: {order.zhuangtai}") return Response({'code': 400, 'msg': '订单状态不允许退款'}) # 5. 全部视为平台订单(我方派单),调用微信退款 refund_result = self._call_wechat_refund(order) if refund_result['code'] != 0: return Response({'code': 400, 'msg': refund_result['msg']}) # 6. 事务内更新订单、打手、记录、统计 dashou_id = order.jiedan_dashou_id try: with transaction.atomic(): # 更新订单状态为已退款 order.zhuangtai = 5 order.clkf = str(shop_id) # 处理人记录为店铺ID if reason: order.tkly = reason order.save() # 更新打手统计(退款量+1,状态置为空闲) if dashou_id: try: dashou_user = UserMain.objects.get(yonghuid=dashou_id) dashou_profile = dashou_user.dashou_profile dashou_profile.tuikuanliang += 1 dashou_profile.zhuangtai = 1 dashou_profile.save() except (UserMain.DoesNotExist, ObjectDoesNotExist): logger.warning(f"退款:打手 {dashou_id} 不存在,跳过状态更新") # 更新退款记录表 self._update_refund_record(order, dashou_id, str(shop_id), reason) # 打手每日统计(退款动作) try: from houtai.utils import update_dashou_daily_by_action update_dashou_daily_by_action( yonghuid=dashou_id, amount=order.dashou_fencheng or Decimal('0.00'), action=3 # 3 表示退款 ) except Exception as e: logger.error(f"打手每日统计更新失败: {e}") # 更新平台支出记录 try: from dingdan.utils import update_daily_payout update_daily_payout(order.jine) except Exception as e: logger.error(f"更新每日支出失败: {e}") # ========== 新增:商品和店铺每日统计 ========== try: from shangdian.utils import update_dianpu_daily_stat from shangpin.utils import update_shangpin_daily_stat # 商品每日统计(只要有商品ID就执行) if order.shangpin_id: # 从扩展表获取已计算好的收益值(下单时已存储) pingtai_shouyi = Decimal('0.00') dianpu_shouyi = Decimal('0.00') if hasattr(order, 'pingtai_kuozhan'): pingtai_shouyi = order.pingtai_kuozhan.pingtai_shouyi or Decimal('0.00') dianpu_shouyi = order.pingtai_kuozhan.dianpu_shouyi or Decimal('0.00') update_shangpin_daily_stat( shangpin_id=order.shangpin_id, caozuo_leixing=3, # 3 = 退款 dingdan_jiage=order.jine, pingtai_shouyi=pingtai_shouyi, dianpu_zongshouyi=dianpu_shouyi ) logger.info(f"商品统计更新完成,商品ID: {order.shangpin_id}") # 店铺每日统计(仅当存在店铺ID时执行) dianpu_id = None if hasattr(order, 'pingtai_kuozhan'): dianpu_id = order.pingtai_kuozhan.dianpu_id if dianpu_id: dianpu_shouyi = order.pingtai_kuozhan.dianpu_shouyi or Decimal('0.00') update_dianpu_daily_stat( dianpu_id=dianpu_id, caozuo_leixing=3, # 3 = 退款 dingdan_jiage=order.jine, dianpu_shouyi=dianpu_shouyi ) logger.info(f"店铺统计更新完成,店铺ID: {dianpu_id}") except Exception as e: logger.error(f"商品/店铺统计更新失败: {e}") logger.error(traceback.format_exc()) # 统计失败不影响主流程 # ============================================= except Exception as e: logger.exception("退款事务执行失败") return Response({'code': 500, 'msg': '退款处理失败,请稍后重试'}) # 7. 跨平台通知(如果存在对方平台订单ID,异步通知,不影响主流程) partner_order_id = order.partner_order_id partner_club_id = order.partner_club_id if partner_order_id and partner_club_id: try: self._notify_partner_refund(dingdan_id, partner_club_id, partner_order_id, reason, order.jine) except Exception as e: logger.error(f"跨平台退款通知异常: {e}") return Response({'code': 200, 'msg': '退款成功'}) # ========== 微信退款 ========== def _call_wechat_refund(self, order): """调用微信支付退款接口""" appid = getattr(settings, 'WEIXIN_APPID', '') mch_id = getattr(settings, 'WEIXIN_MCHID', '') key = getattr(settings, 'WEIXIN_SHANGHUMIYAO', '') cert_path = getattr(settings, 'WEIXIN_CERT_PATH', '') key_path = getattr(settings, 'WEIXIN_KEY_PATH', '') if not all([appid, mch_id, key, cert_path, key_path]): return {'code': 500, 'msg': '微信支付配置不完整'} out_refund_no = self._generate_refund_no(order.dingdan_id) total_fee = int(float(order.jine or 0) * 100) refund_fee = total_fee params = { 'appid': appid, 'mch_id': mch_id, 'nonce_str': self._generate_nonce_str(), 'out_refund_no': out_refund_no, 'total_fee': total_fee, 'refund_fee': refund_fee, 'refund_desc': '店铺退款', } if order.wechat_transaction_id: params['transaction_id'] = order.wechat_transaction_id else: params['out_trade_no'] = order.dingdan_id # 签名 sorted_keys = sorted(params.keys()) stringA = '&'.join([f"{k}={params[k]}" for k in sorted_keys]) stringSignTemp = f"{stringA}&key={key}" sign = hashlib.md5(stringSignTemp.encode('utf-8')).hexdigest().upper() params['sign'] = sign xml_data = self._dict_to_xml(params) url = 'https://api.mch.weixin.qq.com/secapi/pay/refund' try: response = requests.post(url, data=xml_data, cert=(cert_path, key_path), timeout=10) result = xmltodict.parse(response.content)['xml'] logger.info(f"微信退款响应: {result}") except Exception as e: logger.error(f"微信退款请求异常: {e}", exc_info=True) return {'code': 500, 'msg': '微信退款请求异常'} if result.get('return_code') == 'SUCCESS' and result.get('result_code') == 'SUCCESS': return {'code': 0, 'msg': '退款成功'} else: err_msg = result.get('err_code_des', result.get('return_msg', '未知错误')) return {'code': 400, 'msg': err_msg} # ========== 退款记录 ========== def _update_refund_record(self, order, dashou_id, chuli_id, reason): """更新退款记录表""" try: refund_record, created = Tuikuanjilu.objects.get_or_create( dingdan_id=order.dingdan_id, defaults={ 'dashouid': dashou_id or '', 'qingqiuid': '', 'chuliid': chuli_id, 'liyou': reason or '店铺退款', 'sqzhuangtai': 1, 'jine': order.jine or 0, 'dashou_fencheng': order.dashou_fencheng or 0, 'jieshao': order.jieshao or '', 'beizhu': '店铺退款', 'nicheng': order.nicheng or '', } ) if not created: refund_record.sqzhuangtai = 1 refund_record.chuliid = chuli_id if reason: refund_record.liyou = reason refund_record.save() except Exception as e: logger.error(f"更新退款记录异常: {e}") # ========== 跨平台通知 ========== def _notify_partner_refund(self, dingdan_id, partner_club_id, partner_order_id, reason, amount): """ 通知对方平台我方已退款。 此方法执行不影响主流程,任何异常仅记录日志。 """ try: from dingdan.models import ClubConfig, Club, CrossPlatformOrderData my_config = ClubConfig.objects.filter(is_self=1).first() if not my_config: logger.error("未配置我方俱乐部信息,无法通知对方") return self_club_id = my_config.club_id club_rel = Club.objects.get(club_id=self_club_id, partner_club_id=partner_club_id) partner_domain = club_rel.partner_domain.rstrip('/') notify_url = f"{partner_domain}/houtai/dfddtk" payload = { 'order_id': partner_order_id, 'club_id': self_club_id, 'status': 5, 'reason': reason or '店铺退款' } resp = requests.post(notify_url, json=payload, timeout=3) if resp.status_code == 200: resp_json = resp.json() if resp_json.get('code') == 0: # 更新跨平台扩展表状态 cross_data, _ = CrossPlatformOrderData.objects.get_or_create( dingdan_id=dingdan_id, defaults={ 'partner_order_id': partner_order_id, 'partner_club_id': partner_club_id, 'partner_order_status': resp_json.get('data', {}).get('partner_status', 0), 'partner_dashou_id': resp_json.get('data', {}).get('partner_dashou_id', ''), 'partner_amount': amount or 0, } ) if not _: cross_data.partner_order_status = resp_json.get('data', {}).get('partner_status', 0) cross_data.save() else: logger.warning(f"对方平台返回错误: {resp_json.get('msg')}") else: logger.warning(f"通知对方HTTP错误: {resp.status_code}") except Exception as e: logger.error(f"通知对方平台异常: {e}") # ========== 工具方法 ========== def _generate_nonce_str(self): return ''.join(random.choices(string.ascii_letters + string.digits, k=32)) def _generate_refund_no(self, dingdan_id): timestamp = str(int(time.time())) rand = ''.join(random.choices('0123456789', k=4)) return f"{dingdan_id}REF{timestamp}{rand}" def _dict_to_xml(self, data): xml = [''] for k, v in data.items(): xml.append(f'<{k}>{v}') xml.append('') return ''.join(xml) class ShopForceCompleteView(APIView): """ 店铺强制结单接口 URL: POST /shangdian/qzjd 权限: JWT认证 + 店铺身份验证 请求参数: userId (必填) : 店铺所属用户的ID dingdan_id(必填) : 订单ID 说明: - 只处理平台订单(fadan_pingtai == 1) - 订单必须属于当前店铺 - 订单状态必须为 8(结算中)才能强制结单 - 默认视为我方派单,根据是否有对方订单ID判断是否跨平台 - 本地订单:给打手结算、更新打手余额和统计、更新店铺收益统计 - 跨平台订单:仅通知对方平台,不处理打手 """ permission_classes = [IsAuthenticated] def post(self, request): # ========== 1. 获取参数 ========== user_id = request.data.get('userId', '').strip() dingdan_id = request.data.get('dingdan_id', '').strip() if not user_id or not dingdan_id: return Response({'code': 400, 'msg': '参数不完整'}) # ========== 2. 店铺身份验证 ========== dianpu, error = verify_shop_permission(request, user_id) if error: return error shop_id = dianpu.id # ========== 3. 查询订单并验证归属 ========== try: # 通过平台扩展表确认订单属于本店铺 pingtai_ext = DingdanPingtai.objects.select_related('dingdan').get( dianpu_id=shop_id, dingdan__dingdan_id=dingdan_id ) order = pingtai_ext.dingdan except DingdanPingtai.DoesNotExist: logger.warning(f"订单 {dingdan_id} 不属于店铺 {shop_id} 或不存在") return Response({'code': 404, 'msg': '订单不存在或不属于本店铺'}) # ========== 4. 校验订单状态必须为 8(结算中) ========== if order.zhuangtai != 8: logger.warning(f"订单 {dingdan_id} 当前状态为 {order.zhuangtai},不是结算中") return Response({'code': 400, 'msg': f'订单状态不是结算中,当前状态: {order.zhuangtai}'}) # ========== 5. 判断是否为跨平台订单(有对方订单ID就认为是跨平台) ========== is_cross = order.is_cross and (order.partner_order_id or order.partner_club_id) dashou_id = order.jiedan_dashou_id dashou_fencheng = order.dashou_fencheng or Decimal('0.00') jine = order.jine or Decimal('0.00') try: with transaction.atomic(): # 锁定订单行,防止并发 order = Dingdan.objects.select_for_update().get(dingdan_id=dingdan_id) # ============================ # 情况1:跨平台订单(我方派单给对方平台打手) # ============================ if is_cross: # 只需更新我方订单状态,打手在对方平台,不处理我方打手余额 order.zhuangtai = 3 # 已完成 order.clkf = str(shop_id) # 处理人记录为店铺ID order.save() # 店铺结算时,若涉及跨平台收益,这里是否要更新店铺统计? # 根据业务需求,可在此处调用 update_dianpu_daily_stat 等 # 但通常跨平台订单的资金已在对方平台流转,店铺仅作为发单方, # 收益可能已在其他环节计算,此处可根据需要添加统计。 # 通知对方平台(异步,不影响主流程) if order.partner_club_id and order.partner_order_id: transaction.on_commit( lambda: self._notify_partner_order_complete( order.partner_club_id, order.partner_order_id, shop_id ) ) # ============================ # 情况2:本地订单(我方打手接单) # ============================ else: if not dashou_id: return Response({'code': 400, 'msg': '订单无接单打手,无法结算'}) if dashou_fencheng < 0: return Response({'code': 400, 'msg': '打手分成金额无效'}) # ---------- 更新打手余额和统计 ---------- try: dashou_user = UserMain.objects.get(yonghuid=dashou_id) dashou_profile = dashou_user.dashou_profile dashou_profile.chengjiaozongliang += 1 dashou_profile.yue += dashou_fencheng dashou_profile.zonge += dashou_fencheng dashou_profile.jinrishouyi += dashou_fencheng dashou_profile.jinyueshouyi += dashou_fencheng dashou_profile.zhuangtai = 1 # 空闲 dashou_profile.save() except (UserMain.DoesNotExist, ObjectDoesNotExist): logger.warning(f"本地订单结算:打手 {dashou_id} 不存在,跳过打手更新") # ---------- 更新订单状态 ---------- order.zhuangtai = 3 order.clkf = str(shop_id) order.save() # ---------- 打手每日统计(成交) ---------- try: from houtai.utils import update_dashou_daily_by_action update_dashou_daily_by_action( yonghuid=dashou_id, amount=dashou_fencheng, action=2 # 2 表示成交(结算) ) except Exception as e: logger.error(f"打手每日统计更新失败: {e}") # ---------- 店铺收益统计(结算) ---------- # 根据订单上的店铺收益 (从扩展表取) 更新店铺每日统计及余额 # 前提:订单创建时已存储了 dianpu_shouyi 到扩展表 if pingtai_ext.dianpu_shouyi: try: from shangpin.utils import update_shangpin_daily_stat # 商品每日统计(只要有商品ID就执行) if order.shangpin_id: # 从扩展表获取已计算好的收益值(下单时已存储) pingtai_shouyi = Decimal('0.00') dianpu_shouyi = Decimal('0.00') if hasattr(order, 'pingtai_kuozhan'): pingtai_shouyi = order.pingtai_kuozhan.pingtai_shouyi or Decimal('0.00') dianpu_shouyi = order.pingtai_kuozhan.dianpu_shouyi or Decimal('0.00') update_shangpin_daily_stat( shangpin_id=order.shangpin_id, caozuo_leixing=2, # 2 = 结算 dingdan_jiage=order.jine, pingtai_shouyi=pingtai_shouyi, dianpu_zongshouyi=dianpu_shouyi ) logger.info(f"商品统计更新完成,商品ID: {order.shangpin_id}") from shangdian.utils import update_dianpu_daily_stat update_dianpu_daily_stat( dianpu_id=shop_id, caozuo_leixing=2, # 结算操作 dingdan_jiage=jine, dianpu_shouyi=pingtai_ext.dianpu_shouyi, # 如果涉及上级分红,可从扩展表取相应字段传入,此处略 ) except Exception as e: logger.error(f"店铺每日统计更新失败: {e}") # ========== 6. 记录操作日志 ========== logger.info(f"强制结单成功,订单 {dingdan_id},店铺 {shop_id}") return Response({'code': 200, 'msg': '强制结单成功'}) except Dingdan.DoesNotExist: logger.warning(f"订单不存在: {dingdan_id}") return Response({'code': 404, 'msg': '订单不存在'}) except Exception as e: logger.error(f"强制结单接口异常: {str(e)}", exc_info=True) return Response({'code': 500, 'msg': '服务器内部错误'}) # ==================== 通知对方平台(异步) ==================== def _notify_partner_order_complete(self, partner_club_id, partner_order_id, shop_id): """通知对方平台我方订单已完成(状态3)""" try: # 尝试获取对方域名等配置(与退款接口类似) my_config = ClubConfig.objects.filter(is_self=1).first() if not my_config: logger.error("未配置我方俱乐部信息,无法通知对方") return self_club_id = my_config.club_id club_rel = Club.objects.get(club_id=self_club_id, partner_club_id=partner_club_id) partner_domain = club_rel.partner_domain.rstrip('/') notify_url = f"{partner_domain}/houtai/dfddtk" import requests payload = { 'order_id': partner_order_id, 'club_id': self_club_id, 'status': 3 } resp = requests.post(notify_url, json=payload, timeout=3) if resp.status_code == 200: resp_data = resp.json() if resp_data.get('code') == 0: logger.info(f"通知对方订单已完成: {partner_order_id}") else: logger.warning(f"对方平台返回错误: {resp_data.get('msg')}") else: logger.warning(f"通知对方HTTP错误: {resp.status_code}") except Exception as e: logger.error(f"通知对方平台异常: {e}") # ================================================================ # shangdian/views/transfer_hall_view.py # 店铺转移大厅接口 —— 严格二改,不添加任何原接口没有的逻辑 # ================================================================ # ================================================================ # shangdian/views/transfer_hall_view.py # 店铺转移大厅接口 —— 添加本地打手退款统计 # ================================================================ import logging import threading from django.core.exceptions import ObjectDoesNotExist from dingdan.models import Dingdan, DingdanPingtai, OrderDashouHistory from houtai.utils import update_dashou_daily_by_action # 打手每日统计(退款用) class ShopTransferHallView(APIView): """ 店铺转移大厅接口 URL: POST /shangdian/zydt 权限: JWT认证 + 店铺身份验证 请求参数: userId (必填) : 店铺所属用户的ID dingdan_id(必填) : 订单ID 说明: - 只处理平台订单,我方派单 - 允许转移的状态:2,8,4 - 决定是否处理本地打手:有对方订单ID或俱乐部ID(跨平台)则不处理本地打手,只通知对方; 没有对方信息则处理本地打手(释放+退款统计) - 转移后清空对方平台信息,订单重回可接单状态 """ permission_classes = [IsAuthenticated] def post(self, request): # ---------- 1. 获取参数 ---------- user_id = request.data.get('userId', '').strip() dingdan_id = request.data.get('dingdan_id', '').strip() if not user_id or not dingdan_id: return Response({'code': 400, 'msg': '参数不完整'}) # ---------- 2. 店铺身份验证 ---------- dianpu, error = verify_shop_permission(request, user_id) if error: return error shop_id = dianpu.id # ---------- 3. 验证订单归属 ---------- try: pingtai_ext = DingdanPingtai.objects.select_related('dingdan').get( dianpu_id=shop_id, dingdan__dingdan_id=dingdan_id ) order = pingtai_ext.dingdan except DingdanPingtai.DoesNotExist: logger.warning(f"订单 {dingdan_id} 不属于店铺 {shop_id} 或不存在") return Response({'code': 404, 'msg': '订单不存在或不属于本店铺'}) # ---------- 4. 状态校验 ---------- if order.zhuangtai not in [2, 8, 4]: return Response({'code': 400, 'msg': f'订单状态不允许转移大厅,当前状态: {order.zhuangtai}'}) # ---------- 5. 提取关键字段 ---------- current_dashou_id = order.jiedan_dashou_id zhiding_id = order.zhiding_id partner_club_id = order.partner_club_id partner_order_id = order.partner_order_id dashou_fencheng = order.dashou_fencheng or 0 # 用于退款统计 # 是否跨平台:只要有对方信息即认为是跨平台订单 is_cross = bool(partner_club_id or partner_order_id) try: with transaction.atomic(): # 锁定订单行 order = Dingdan.objects.select_for_update().get(dingdan_id=dingdan_id) # ---------- 6. 记录打手历史(原接口保留逻辑) ---------- if current_dashou_id: last_history = OrderDashouHistory.objects.filter( dingdan_id=dingdan_id ).order_by('-times').first() next_times = (last_history.times + 1) if last_history else 1 OrderDashouHistory.objects.create( dingdan_id=dingdan_id, dashou_id=current_dashou_id, times=next_times, operator=str(shop_id) ) # ---------- 7. 释放本地打手 + 退款统计(仅当非跨平台时处理) ---------- if not is_cross and current_dashou_id: try: dashou_profile = UserDashou.objects.get(user__yonghuid=current_dashou_id) dashou_profile.zhuangtai = 1 # 设为空闲 dashou_profile.save() except ObjectDoesNotExist: logger.warning(f"打手 {current_dashou_id} 不存在,跳过状态更新") else: # 打手释放成功后,记录每日退款统计(转移视为退款) try: update_dashou_daily_by_action( yonghuid=current_dashou_id, amount=dashou_fencheng, action=3 # 3 表示退款 ) logger.info(f"打手 {current_dashou_id} 转移大厅退款统计成功") except Exception as e: logger.error(f"打手每日统计失败: {e}") # ---------- 8. 清空对方平台信息并重置订单 ---------- order.partner_club_id = None order.partner_order_id = None order.jiedan_dashou_id = None order.clkf = str(shop_id) # 根据是否有指定打手决定状态 order.zhuangtai = 7 if zhiding_id else 1 order.save() # ---------- 9. 异步通知对方平台(如果有对方信息) ---------- if is_cross and partner_club_id and partner_order_id: # 获取我方俱乐部ID my_club_config = None try: from peizhi.models import ClubConfig my_club_config = ClubConfig.objects.filter(is_self=1).first() except Exception as e: logger.error(f"获取俱乐部配置失败: {e}") if my_club_config: self_club_id = my_club_config.club_id def notify(): try: from dingdan.models import CrossPlatformOrderData from peizhi.models import Club # 确保导入 club_rel = Club.objects.get(club_id=self_club_id, partner_club_id=partner_club_id) partner_domain = club_rel.partner_domain.rstrip('/') notify_url = f"{partner_domain}/houtai/kpttzzydt" payload = { 'order_id': partner_order_id, 'club_id': self_club_id } resp = requests.post(notify_url, json=payload, timeout=3) if resp.status_code == 200: resp_json = resp.json() if resp_json.get('code') == 0: # 更新跨平台订单数据(原接口逻辑) try: cross_data, _ = CrossPlatformOrderData.objects.get_or_create( dingdan_id=dingdan_id, defaults={ 'partner_order_id': partner_order_id, 'partner_club_id': partner_club_id, 'partner_order_status': resp_json.get('data', {}).get('partner_status', 0), 'partner_dashou_id': '', 'partner_amount': order.jine or 0, } ) if not _: cross_data.partner_order_status = resp_json.get('data', {}).get('partner_status', 0) cross_data.save(update_fields=['partner_order_status']) logger.info(f"转移大厅通知对方成功: {dingdan_id}") except Exception as e: logger.error(f"更新跨平台订单数据失败: {e}") else: logger.warning(f"对方平台返回错误: {resp_json.get('msg')}") else: logger.warning(f"对方接口HTTP错误: {resp.status_code}") except Club.DoesNotExist: logger.error(f"未找到对方俱乐部配置: partner_club_id={partner_club_id}") except Exception as e: logger.error(f"转移大厅通知对方异常: {e}") threading.Thread(target=notify).start() logger.info(f"转移大厅成功,订单 {dingdan_id}") return Response({'code': 200, 'msg': '转移大厅成功'}) except Dingdan.DoesNotExist: return Response({'code': 404, 'msg': '订单不存在'}) except Exception as e: logger.error(f"转移大厅接口异常: {str(e)}", exc_info=True) return Response({'code': 500, 'msg': '服务器内部错误'}) # ================================================================ # shangdian/views/fine_apply_view.py # 店铺罚款申请接口 —— 二改自 FineApplyView(客服罚款) # ================================================================ from dingdan.models import Dingdan, DingdanPingtai, Fadan # 订单、罚款记录 class ShopFineApplyView(APIView): """ 店铺罚款申请接口 URL: POST /shangdian/fksq 权限: JWT认证 + 店铺身份验证 请求参数: userId (必填) : 店铺所属用户的ID(用于身份验证) dingdan_id (必填) : 订单ID reason (必填) : 罚款原因 amount (必填) : 罚款金额(元) yingxiang_qiangdan (可选) : 是否影响抢单,1=是,0=否,默认1 说明: - 仅处理平台订单,需属于当前店铺 - 本地/对方派单处理逻辑与客服接口一致: 无对方订单ID 或 对方派单 → 本地生成罚单 有对方订单ID且我方派单 → 跨平台通知对方,本地也记录(标记对方俱乐部ID) - 申请人:店铺ID,身份码固定为4(店铺管理员) - 被处罚人:接单打手,身份码固定为1 """ permission_classes = [IsAuthenticated] def post(self, request): # ---------- 1. 提取参数 ---------- user_id = request.data.get('userId', '').strip() # 店铺用户ID dingdan_id = request.data.get('dingdan_id', '').strip() chufaliyou = request.data.get('reason', '').strip() # 罚款原因(对应前端的reason) fakuanjine = request.data.get('amount', 0) # 罚款金额(对应前端的amount) yingxiang_qiangdan = request.data.get('yingxiang_qiangdan', 1) # 影响抢单 if not all([user_id, dingdan_id, chufaliyou, fakuanjine]): return Response({'code': 400, 'msg': '参数不完整'}) try: # ---------- 2. 店铺身份验证 ---------- dianpu, error = verify_shop_permission(request, user_id) if error: return error shop_id = dianpu.id # 店铺ID,作为罚款申请人 # ---------- 3. 验证订单归属 ---------- try: pingtai_ext = DingdanPingtai.objects.select_related('dingdan').get( dianpu_id=shop_id, dingdan__dingdan_id=dingdan_id ) order = pingtai_ext.dingdan except DingdanPingtai.DoesNotExist: logger.warning(f"罚款申请:订单 {dingdan_id} 不属于店铺 {shop_id}") return Response({'code': 404, 'msg': '订单不存在或不属于本店铺'}) # ---------- 4. 获取打手ID ---------- dashou_id = order.jiedan_dashou_id if not dashou_id: return Response({'code': 400, 'msg': '该订单无接单打手,无法罚款'}) # ---------- 5. 重复罚款检查 ---------- if Fadan.objects.filter(beichufa_id=dashou_id, guanliandingdan_id=dingdan_id).exists(): return Response({'code': 400, 'msg': '该打手已被罚款过'}) # ---------- 6. 跨平台相关字段 ---------- partner_order_id = order.partner_order_id or '' partner_club_id = order.partner_club_id or '' dispatch_type = order.dispatch_type or 0 # 1=我方派单,2=对方派单 # 本地创建罚单的通用函数 def create_local_fadan(partner_club=''): Fadan.objects.create( beichufa_id=dashou_id, shenqing_chufa=str(shop_id), # 申请人记为店铺ID shenfen=1, # 被处罚人身份:1 打手 chufaliyou=chufaliyou, fakuanjine=fakuanjine, guanliandingdan_id=dingdan_id, zhuangtai=1, # 待缴纳 yingxiang_qiangdan=yingxiang_qiangdan, shenqingren_shenfen=4, # 申请人身份:4 店铺管理员 partner_club_id=partner_club, ) # ---------- 7. 判断处罚流向 ---------- # 情况A:没有对方订单ID,或对方派单 → 本地处罚 if not partner_order_id or dispatch_type == 2: with transaction.atomic(): create_local_fadan() logger.info(f"本地罚款成功: 订单 {dingdan_id}, 打手 {dashou_id}, 金额 {fakuanjine}") return Response({'code': 200, 'msg': '罚款已生成'}) # ---------- 8. 情况B:有对方订单ID且我方派单 → 跨平台通知对方 ---------- # 获取我方俱乐部ID try: my_club = ClubConfig.objects.filter(is_self=1).first() if not my_club: return Response({'code': 500, 'msg': '系统配置错误,缺少本俱乐部信息'}) self_club_id = my_club.club_id club_rel = Club.objects.get(club_id=self_club_id, partner_club_id=partner_club_id) partner_domain = club_rel.partner_domain except Club.DoesNotExist: return Response({'code': 500, 'msg': '未找到对方平台配置,无法通知'}) except Exception as e: logger.error(f"获取对方平台信息失败: {e}") return Response({'code': 500, 'msg': '获取对方平台信息失败'}) # 构造通知 payload url = partner_domain.rstrip('/') + '/houtai/kffkdssq_notify' payload = { 'order_id': partner_order_id, # 对方平台的订单ID 'club_id': self_club_id, # 我方俱乐部ID 'chufaliyou': chufaliyou, 'fakuanjine': fakuanjine, 'yingxiang_qiangdan': yingxiang_qiangdan, 'shenqingren_shenfen': 4, # 店铺管理员身份 'operator': str(shop_id), } # 带重试的通知 success = False for _ in range(2): try: resp = requests.post(url, json=payload, timeout=3) if resp.status_code == 200 and resp.json().get('code') == 0: success = True break except Exception: continue if not success: return Response({'code': 500, 'msg': '通知对方平台失败,请稍后重试'}) # 通知成功后,本地也记录一笔(标记为跨平台) with transaction.atomic(): create_local_fadan(partner_club=partner_club_id) logger.info(f"跨平台罚款通知成功: 订单 {dingdan_id}") return Response({'code': 200, 'msg': '罚款已提交至对方平台'}) except Exception as e: logger.error(f"店铺罚款申请异常: {traceback.format_exc()}") return Response({'code': 500, 'msg': '服务器内部错误'})