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 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 # 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 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 # 引入文件删除工具 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/xgdpsplx1 """ 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, request.FILES) elif action == 'add_product': return self._add_product(dianpu, request.data, request.FILES) 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, files): 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 # 辅助函数:安全转换为整数,失败则返回原值(让其报错) def to_int(val): try: return int(val) except (TypeError, ValueError): return val def to_float(val): try: return float(val) except (TypeError, ValueError): return val # 标题 if 'biaoqian' in data: product.biaoqian = data['biaoqian'] update_fields.append('biaoqian') content_changed = True # 价格 if 'jiage' in data: product.jiage = to_float(data['jiage']) update_fields.append('jiage') content_changed = True # 库存 if 'kucun' in data: product.kucun = to_int(data['kucun']) update_fields.append('kucun') content_changed = True # 店铺商品类型 if 'dianpu_leixing_id' in data: new_type_id = to_int(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 is not None and new_zq_id != '': new_zq_id = to_int(new_zq_id) if not ShangpinZhuanqu.objects.filter(id=new_zq_id, dianpu_id=dianpu.id).exists(): return Response({'code': 400, 'msg': '无效的专区'}) product.zhuanqu_id = new_zq_id else: product.zhuanqu_id = 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 = to_int(data['yaoqiuleixing']) if yqlx not in [1, 2]: return Response({'code': 400, 'msg': '抢单要求类型无效'}) product.yaoqiuleixing = yqlx update_fields.append('yaoqiuleixing') content_changed = True if yqlx == 1: # 会员抢单:优先使用前端显式传递的非空会员ID if 'huiyuan_id' in data and data.get('huiyuan_id'): product.huiyuan_id = data['huiyuan_id'] update_fields.append('huiyuan_id') product.yongjin = None if 'yongjin' not in update_fields: update_fields.append('yongjin') else: # 自动从公共类型获取会员ID,或转为佣金 try: shop_type = ShangpinLeixingDianpu.objects.get( id=product.dianpu_leixing_id, dianpu_id=dianpu.id ) public_type = ShangpinLeixing.objects.get(id=shop_type.gonggong_leixing_id) auto_huiyuan_id = public_type.huiyuan_id if auto_huiyuan_id: product.huiyuan_id = auto_huiyuan_id update_fields.append('huiyuan_id') product.yongjin = None if 'yongjin' not in update_fields: update_fields.append('yongjin') elif public_type.yaoqiuleixing == 2 and public_type.yongjin: # 无会员,强制转为佣金 product.yaoqiuleixing = 2 product.yongjin = public_type.yongjin product.huiyuan_id = None # yaoqiuleixing 已在 update_fields 中,需要添加 huiyuan_id 和 yongjin update_fields.append('huiyuan_id') update_fields.append('yongjin') else: return Response({'code': 400, 'msg': '会员抢单缺少会员ID,公共类型也无会员ID或佣金'}) except (ShangpinLeixingDianpu.DoesNotExist, ShangpinLeixing.DoesNotExist): return Response({'code': 400, 'msg': '店铺商品类型无效'}) elif yqlx == 2: # 佣金抢单,清除会员ID,设置佣金 product.huiyuan_id = None update_fields.append('huiyuan_id') if 'yongjin' in data: product.yongjin = to_float(data['yongjin']) or 0 else: product.yongjin = 0 update_fields.append('yongjin') else: # 未修改抢单类型,保留原有独立修改逻辑 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 = to_float(data['yongjin']) or 0 update_fields.append('yongjin') content_changed = True # 打手额外分成金额 if 'ewai_dashou_fencheng' in data: fencheng = to_float(data['ewai_dashou_fencheng']) or 0 if fencheng > (product.jiage or 0): 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 = to_int(data['paixu']) update_fields.append('paixu') # 上架状态(保持原逻辑,兼容字符串) if 'shangjia_zhuangtai' in data: val = data['shangjia_zhuangtai'] if isinstance(val, str): val = val.lower() in ('true', '1', 'yes') product.shangjia_zhuangtai = bool(val) update_fields.append('shangjia_zhuangtai') # ★ 商品图片真上传 image_file = files.get('image') if image_file: if product.tupian_url: delete_from_oss(product.tupian_url) file_path = f"dianpu/shangpin/shangpin/{dianpu.id}_{int(time.time())}.png" full_url = upload_to_oss(image_file, file_path) if not full_url: return Response({'code': 500, 'msg': '商品图片上传失败'}) product.tupian_url = full_url.replace(settings.COS_DOMAIN.rstrip('/') + '/', '', 1) update_fields.append('tupian_url') content_changed = True # ★ 规则图片真上传 rule_file = files.get('rule_image') remove_rule = data.get('remove_rule_image', '0') if rule_file: if product.guize_tupian: delete_from_oss(product.guize_tupian) file_path = f"dianpu/shangpin/shangpinguize/{dianpu.id}_{int(time.time())}.png" full_url = upload_to_oss(rule_file, file_path) if full_url: product.guize_tupian = full_url.replace(settings.COS_DOMAIN.rstrip('/') + '/', '', 1) update_fields.append('guize_tupian') content_changed = True elif remove_rule == '1' and product.guize_tupian: delete_from_oss(product.guize_tupian) product.guize_tupian = 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, files): 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': '关联的公共商品类型无效'}) # 商品图片上传 image_file = files.get('image') if not image_file: return Response({'code': 400, 'msg': '必须上传商品图片'}) file_path = f"dianpu/shangpin/shangpin/{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) # 规则图片 guize_tupian = None rule_file = files.get('rule_image') if rule_file: file_path = f"dianpu/shangpin/shangpinguize/{dianpu.id}_{int(time.time())}.png" full_url = upload_to_oss(rule_file, file_path) if full_url: guize_tupian = full_url.replace(settings.COS_DOMAIN.rstrip('/') + '/', '', 1) yaoqiuleixing = int(data.get('yaoqiuleixing', 1)) if yaoqiuleixing == 1: huiyuan_id = data.get('huiyuan_id') # 如果前端没传有效的会员ID,尝试从公共类型获取 if not huiyuan_id: huiyuan_id = public_type.huiyuan_id # 仍然没有会员ID,检查公共类型是否可转为佣金 if not huiyuan_id: if public_type.yaoqiuleixing == 2 and public_type.yongjin: # 自动转为佣金抢单 yaoqiuleixing = 2 yongjin = public_type.yongjin huiyuan_id = None else: return Response({'code': 400, 'msg': '会员抢单缺少会员ID,且无法自动获取'}) else: yongjin = None else: huiyuan_id = None yongjin = data.get('yongjin') or public_type.yongjin or 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, jieshao=jieshao, xiadan_xuzhi=data.get('xiadan_xuzhi', ''), guize_tupian=guize_tupian, tupian_url=tupian_url, 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': '商品不存在'}) 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 域名) # ---------- 图片域名 ---------- """ 店铺订单管理接口 ================= 功能1:获取统计数据(action='statistics') - 每个商品类型的订单总数(order_count) - 每个商品类型下各状态的订单数量(status_counts) - 全局各状态的订单总数(status_counts) - 店铺订单总量(total_orders) 功能2:获取订单列表(默认,不带action参数时执行) - 支持商品类型、订单状态、订单ID、打手ID、游戏昵称、老板ID、订单介绍筛选 - 支持分页、自定义每页大小 - 返回打手提交图片(绝对URL)、老板信息、店铺收益等 接口地址:POST /shangdian/ddlbhq 权限:JWT认证 + verify_shop_permission 验证店铺身份 """ 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'}) dianpu, error = verify_shop_permission(request, user_id) if error: return error shop_id = dianpu.id try: # 2. 获取所有正常状态(审核通过)的商品类型 types_qs = ShangpinLeixing.objects.filter( shenhezhuangtai=1 ).order_by('-paixu', 'id') type_list = [] type_id_map = {} for idx, t in enumerate(types_qs): item = { '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 } } type_list.append(item) type_id_map[t.id] = idx # 3. 获取本店铺所有订单的主键ID列表(通过平台扩展表) order_pks = list( DingdanPingtai.objects.filter(dianpu_id=shop_id) .values_list('dingdan_id', flat=True) # 这里取的是订单主表的主键id ) total_orders = 0 global_counts = defaultdict(int) if order_pks: # 4. 用主键列表进行聚合统计,按商品类型和订单状态分组 aggregation = ( Dingdan.objects .filter(id__in=order_pks) # 修正点:使用id__in,而不是dingdan_id__in .values('leixing_id', 'zhuangtai') .annotate(cnt=Count('id')) ) for row in aggregation: lid = row['leixing_id'] # 商品类型ID st = row['zhuangtai'] # 订单状态码 cnt = row['cnt'] # 转为字符串键,方便前端展示 st_key = str(st) # 累加总量 total_orders += cnt global_counts[st_key] += cnt # 填充到对应商品类型 if lid in type_id_map: idx = type_id_map[lid] type_list[idx]['order_count'] += cnt if st_key in type_list[idx]['status_counts']: type_list[idx]['status_counts'][st_key] += cnt # 5. 确保全局状态返回所有8个键,即使值为0 all_status_keys = ['1', '7', '2', '8', '3', '4', '5', '6'] final_global_counts = { k: global_counts.get(k, 0) for k in all_status_keys } # 6. 返回统计结果 return Response({ 'code': 200, 'msg': '统计成功', 'data': { 'types': type_list, 'status_counts': final_global_counts, 'total_orders': total_orders } }) except Exception as e: logger.exception("订单统计接口异常") return Response({ 'code': 500, 'msg': '统计失败,服务器内部错误' }) # ================================================================ # 辅助函数:获取OSS域名(用于拼接本地图片绝对URL) # ================================================================ def get_oss_domain(): """ 获取对象存储的域名,末尾包含 '/'。 优先从跨平台配置表 ClubConfig 中获取本俱乐部的 storage_bucket_domain, 如果获取失败或表不存在,则使用预设的 OSS 域名作为降级方案。 返回:字符串,如 'https://example.com/' """ try: # 尝试从跨平台配置中读取 from kuaping.models import ClubConfig config = ClubConfig.objects.filter(is_self=1).first() if config and config.storage_bucket_domain: # 确保域名末尾有 '/' return config.storage_bucket_domain.rstrip('/') + '/' except Exception: # 表不存在或其它异常,忽略,使用下方默认域名 pass # 默认 OSS 域名,请替换为你的实际域名 return 'https://xingque999qygwuyq-1404472910.cos.ap-shanghai.myqcloud.com/' # ================================================================ # 辅助函数:获取单个订单的打手提交图片(绝对URL) # ================================================================ def _fetch_images(order, oss_domain): """ 查询指定订单的打手提交图片记录(Dashoutupian 表), 将相对路径拼接为完整的绝对 URL 并返回。 参数: order: Dingdan 订单模型实例 oss_domain: OSS 域名,末尾含 '/' 返回: (images_list, is_punished, punishment_info) 三元组 - images_list: 图片绝对 URL 列表 - is_punished: 暂未使用,默认 False - punishment_info: 暂未使用,默认空字典 """ images = [] is_punished = False punishment_info = {} # 仅当订单有接单打手ID时才查询打手图片 if order.jiedan_dashou_id: # 从打手图片表中查询该订单、该打手的所有图片记录 image_urls = Dashoutupian.objects.filter( dingdan_id=order.dingdan_id, # 订单ID匹配 dashou=order.jiedan_dashou_id # 接单打手ID匹配 ).values_list('tupian', flat=True) # 只取图片URL字段 # 遍历所有图片路径 for img_path in image_urls: if img_path: if img_path.startswith('http'): # 已经是完整URL,直接添加 images.append(img_path) else: # 相对路径,拼接 OSS 域名 images.append(oss_domain + img_path.lstrip('/')) # 跨平台图片拉取逻辑(根据实际业务需求补充,此处保留结构) # 如果有跨平台订单,需要请求对方平台接口获取打手图片 return images, is_punished, punishment_info # ================================================================ # 辅助函数:并发获取多个订单的打手图片 # ================================================================ def _batch_images(orders, oss_domain): """ 并发调用 _fetch_images,提升批量获取图片的速度。 参数: orders: Dingdan 订单对象列表 oss_domain: OSS 域名 返回: 字典,键为订单ID (dingdan_id),值为 (images_list, is_punished, punishment_info) """ result = {} # 存储结果 # 使用线程池并发执行,max_workers 可根据服务器性能调整 with ThreadPoolExecutor(max_workers=10) as executor: # 建立 future 到订单ID的映射 future_to_oid = { executor.submit(_fetch_images, order, oss_domain): order.dingdan_id for order in orders } # 等待所有任务完成 for future in as_completed(future_to_oid): oid = future_to_oid[future] # 对应的订单ID try: # 获取任务返回值 imgs, pun, pinfo = future.result() result[oid] = (imgs, pun, pinfo) except Exception as e: # 某个订单获取失败,记录日志并赋予空值,保证整体流程不中断 logger.error(f"获取订单 {oid} 图片失败: {e}") result[oid] = ([], False, {}) return result # ================================================================ # 核心视图类 # ================================================================ class ShopOrderManageView(APIView): permission_classes = [IsAuthenticated] def post(self, request): """ 统一入口,根据 action 参数分发到统计或列表模块。 """ # 1. 从请求中提取店铺用户ID user_id = request.data.get('userId', '').strip() if not user_id: return Response({'code': 400, 'msg': '缺少 userId'}) # 2. 验证店铺身份,获取店铺对象 dianpu, error_response = verify_shop_permission(request, user_id) if error_response: return error_response # 3. 根据 action 决定调用哪个方法 action = request.data.get('action', '').strip().lower() if action == 'statistics': return self._statistics(dianpu) # 统计 else: return self._order_list(request, dianpu) # 列表 # ----------------------------------------------------------------- # 统计实现 (这就是你要求的100个统计字段的详细实现) # ----------------------------------------------------------------- def _statistics(self, dianpu): """ 统计本店铺的订单数据,输出以下信息: - types: 商品类型数组,每个元素包含: id : 类型ID jieshao : 类型介绍 tupian_url : 类型图片 order_count : 【该类型的订单总数】 status_counts: 该类型下各个状态的订单数量 - status_counts: 全局各状态订单总数(键为状态码字符串) - total_orders : 店铺订单总量 所有字段名均与前端保持一致。 """ # 获取店铺ID shop_id = dianpu.id # ------------------------------------------------------------ # 步骤1:获取所有正常的商品类型(审核状态为1) # ------------------------------------------------------------ types_query = ShangpinLeixing.objects.filter( shenhezhuangtai=1 # 审核状态为1(正常) ).order_by('-paixu', 'id') # 按排序权重和ID排序 # 构建返回的类型列表,并预初始化各状态计数 type_list = [] # 最终要返回的类型数组 type_ids = [] # 所有类型的ID列表,用于后续快速索引 # 定义可能出现的所有状态码(1/7已付款,2进行中,8待结算,3已完成,4退款审核,5已退款,6退款失败) status_keys = ['1', '7', '2', '8', '3', '4', '5', '6'] for t in types_query: # 构造每个类型的基础数据 type_item = { 'id': t.id, 'jieshao': t.jieshao or '', 'tupian_url': t.tupian_url or '', 'order_count': 0, # 该类型订单总数,稍后填充 'status_counts': {k: 0 for k in status_keys} # 各状态计数初始为0 } type_list.append(type_item) type_ids.append(t.id) # 记录ID # ------------------------------------------------------------ # 步骤2:获取本店铺的所有订单ID(通过平台扩展表) # ------------------------------------------------------------ # 从 DingdanPingtai 中过滤出属于当前店铺的订单扩展记录,并提取订单主表ID order_ids = list( DingdanPingtai.objects.filter( dianpu_id=shop_id # 店铺ID匹配 ).values_list('dingdan_id', flat=True) # 只取订单ID字段 ) # 统计变量 total_orders = 0 # 订单总量 global_status_counts = defaultdict(int) # 全局各状态计数,键为状态码字符串 # ------------------------------------------------------------ # 步骤3:如果店铺有订单,进行聚合统计 # ------------------------------------------------------------ if order_ids: # 使用 Django 聚合查询:按 leixing_id(商品类型)和 zhuangtai(订单状态)分组计数 aggregation = Dingdan.objects.filter( dingdan_id__in=order_ids # 从该店铺的订单中查询 ).values( 'leixing_id', # 按类型ID分组 'zhuangtai' # 按状态分组 ).annotate( cnt=Count('id') # 统计每个分组的记录数 ) # 遍历每个聚合结果,累加到对应的类型和全局计数中 for row in aggregation: # 提取分组字段 lid = row['leixing_id'] # 商品类型ID st = row['zhuangtai'] # 订单状态码 cnt = row['cnt'] # 该分组下的订单数量 # 累加到全局总数 total_orders += cnt global_status_counts[str(st)] += cnt # 累加到对应商品类型的状态计数中 if lid in type_ids: # 在 type_list 中找到对应的类型条目 for tp in type_list: if tp['id'] == lid: # 更新该类型的订单总数 tp['order_count'] += cnt # 更新该类型下对应状态的计数(键为字符串) tp['status_counts'][str(st)] += cnt break # 找到后跳出循环,因为ID唯一 # 将全局状态计数转换为按固定顺序的字典,确保前端展示顺序稳定 final_global_counts = {k: global_status_counts.get(k, 0) for k in status_keys} # ------------------------------------------------------------ # 步骤4:返回统计结果 # ------------------------------------------------------------ return Response({ 'code': 200, 'msg': '统计成功', 'data': { 'types': type_list, # 商品类型列表(含各类型统计) 'status_counts': final_global_counts, # 全局各状态总数 'total_orders': total_orders # 订单总量 } }) # ----------------------------------------------------------------- # 订单列表实现 # ----------------------------------------------------------------- def _order_list(self, request, dianpu): """处理订单列表请求,支持筛选、分页、并发获取图片。""" shop_id = dianpu.id # 解析筛选参数 leixing_id = request.data.get('leixing_id') zhuangtai = request.data.get('zhuangtai') dingdan_id_key = request.data.get('dingdan_id', '').strip() jiedan_dashou_id_key = request.data.get('jiedan_dashou_id', '').strip() nicheng_key = request.data.get('nicheng', '').strip() laoban_id_key = request.data.get('laoban_id', '').strip() jieshao_key = request.data.get('jieshao', '').strip() page = request.data.get('page', 1) page_size = request.data.get('page_size', 20) # 基础查询:从平台扩展表关联订单主表,并按创建时间倒序 qs = DingdanPingtai.objects.filter( dianpu_id=shop_id ).select_related('dingdan').order_by('-dingdan__create_time') # 应用筛选条件 if leixing_id is not None: qs = qs.filter(dingdan__leixing_id=leixing_id) if zhuangtai is not None: qs = qs.filter(dingdan__zhuangtai=zhuangtai) if dingdan_id_key: qs = qs.filter(dingdan__dingdan_id__icontains=dingdan_id_key) if jiedan_dashou_id_key: qs = qs.filter(dingdan__jiedan_dashou_id=jiedan_dashou_id_key) if nicheng_key: qs = qs.filter(dingdan__nicheng__icontains=nicheng_key) if laoban_id_key: qs = qs.filter(laoban_id=laoban_id_key) if jieshao_key: qs = qs.filter(dingdan__jieshao__icontains=jieshao_key) # 分页 paginator = Paginator(qs, page_size) try: page_obj = paginator.page(page) except EmptyPage: page_obj = paginator.page(1) # 获取当前页的所有订单和扩展对象 orders_with_ext = [(item.dingdan, item) for item in page_obj] # 提取订单对象列表,用于并发获取图片 order_objs = [order for order, _ in orders_with_ext] oss_domain = get_oss_domain() img_map = _batch_images(order_objs, oss_domain) # 构建返回数据 list_data = [] for order, ext in orders_with_ext: imgs, is_pun, _ = img_map.get(order.dingdan_id, ([], False, {})) list_data.append({ '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': ext.laoban_id or '', 'laoban_pingjia': ext.laoban_pingjia or '', 'dianpu_shouyi': float(ext.dianpu_shouyi) if ext.dianpu_shouyi else 0.00, }, 'dashou_images': imgs, 'is_punished': is_pun, 'is_fadaned': False, }) return Response({ 'code': 200, 'msg': '列表成功', 'data': { 'list': list_data, '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, # 3 = 退款 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': '服务器内部错误'})