Files
Django/shop/views.py
2026-06-17 23:02:35 +08:00

3114 lines
130 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ==================== 标准库 ====================
import io
import json
import time
import random
import string
import hmac
import hashlib
import requests
import logging
import traceback
import calendar
import threading
import xmltodict
from decimal import Decimal
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from django.conf import settings
from django.db import models, transaction, IntegrityError
from django.db.models import F, Sum, Count, Q, Prefetch
from gvsdsdk.fluent import db, func, FQ
from django.core.cache import cache
from django.core.paginator import Paginator, EmptyPage
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from django.http import HttpResponse
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework_simplejwt.tokens import RefreshToken
from utils.oss_utils import upload_to_oss, delete_from_oss, get_oss_client
from .utils import verify_shop_permission
from shop.utils import verify_shop_permission, update_dianpu_daily_stat
from products.utils import update_shangpin_daily_stat
from orders.utils import update_daily_payout
from backend.utils import update_dashou_daily_by_action
from .models import YonghuPingzheng, Dianpu, DianpuShouzhiMeiriTongji
from users.models import UserDashou, UserShangjia, UserGuanshi, UserZuzhang
from gvsdsdk.models import User
from shop.models import YonghuDianpuBangding, ShangpinLeixingDianpu, DianpuShangpinShenheShezhi
from products.models import ShangpinLeixing, ShangpinZhuanqu, Shangpin, Huiyuan
from orders.models import (
Dingdan, DingdanPingtai, Dashoutupian, Tuikuanjilu,
OrderDashouHistory, Fadan
)
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': '用户身份信息异常'})
# 先获取 User 对象(不加锁,仅用于校验存在性)
try:
user_main = User.query.get(UserUID=user.yonghuid)
except User.DoesNotExist:
return Response({'code': 404, 'msg': '用户不存在'})
# 校验店铺是否存在且状态正常(不加锁)
try:
dianpu = Dianpu.query.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 = User.objects.select_for_update().get(UserUID=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.query.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 = User.query.get(UserUUID=user.UserUUID)
except User.DoesNotExist:
return Response({'code': 404, 'msg': '用户不存在'})
# 1. 查询用户店铺绑定关系
binding = YonghuDianpuBangding.query.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.query.filter(user=user_main).exists() or
UserShangjia.query.filter(user=user_main).exists() or
UserGuanshi.query.filter(user=user_main).exists() or
UserZuzhang.query.filter(user=user_main).exists())
def _get_shop_goods(self, dianpu):
"""
获取店铺专属商品
类型、专区、商品均从店铺相关表中获取且需通过平台审核shenhe_zhuangtai=True
"""
# 1. 店铺商品类型:未封禁、已上架、平台审核通过
leixing_queryset = ShangpinLeixingDianpu.query.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.query.filter(
dianpu=dianpu,
dianpu_leixing_id__in=leixing_ids,
fengjin_zhuangtai=False,
shangjia_zhuangtai=True
).order_by('-paixu', 'id')
else:
zhuanqu_queryset = ShangpinZhuanqu.query.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.query.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.query.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.query.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.query.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.query.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 : 用户ID7位
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 = User.query.get(UserUID=yonghuid)
except User.DoesNotExist:
logger.warning(f"登录失败用户ID不存在 - {yonghuid}")
return Response({'code': 404, 'msg': '账号或密码错误'})
# 4. 通过用户ID + 账号查询凭证表
try:
pingzheng = YonghuPingzheng.query.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.UserLastLoginDate = timezone.now()
user_main.save(update_fields=['UserLastLoginDate'])
# 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
})
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 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.query.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
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
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.query.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.query.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.query.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.query.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.query.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,
}
})
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.query.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.query.filter(id=1).first()
shenhe_status = not (audit_config and audit_config.kaiqi_shenhe)
ShangpinLeixingDianpu.query.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.query.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.query.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.query.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.query.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.query.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.query.filter(id=leixing_id, shenhezhuangtai=1).exists():
return Response({'code': 400, 'msg': '关联的公共商品类型无效'})
ShangpinZhuanqu.query.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.query.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.query.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.query.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': '删除失败,请稍后重试'})
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.query.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.query.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.query.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.query.get(
id=product.dianpu_leixing_id, dianpu_id=dianpu.id
)
public_type = ShangpinLeixing.query.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.query.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.query.get(id=dianpu_leixing_id, dianpu_id=dianpu.id)
except ShangpinLeixingDianpu.DoesNotExist:
return Response({'code': 400, 'msg': '无效的店铺商品类型'})
try:
public_type = ShangpinLeixing.query.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.query.filter(id=1).first()
if audit_config and audit_config.kaiqi_shenhe:
shenhe_status = False
product = Shangpin.query.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': '商品已删除'})
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.query.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
})
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.query.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 User.query.filter(UserUID=user_uid).exists():
return Response({'code': 404, 'msg': '对应的小程序用户不存在'})
# 检查账号是否已占用
if YonghuPingzheng.query.filter(zhanghao=zhanghao).exists():
return Response({'code': 400, 'msg': '登录账号已存在'})
if YonghuPingzheng.query.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.query.create(
yonghu=user_uid,
zhanghao=zhanghao,
mima=mima,
is_active=True
)
# 创建店铺,关联上级
dianpu = Dianpu.query.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. 订单列表:支持筛选、分页、返回订单详情及打手图片(含跨平台图片拉取)
"""
# ---------- 图片域名 ----------
"""
店铺订单管理接口
=================
功能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.query.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.query.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():
"""
获取对象存储的域名,末尾包含 '/'
返回:字符串,如 'https://example.com/'
"""
return getattr(settings, 'COS_DOMAIN', 'https://xingque999qygwuyq-1404472910.cos.ap-shanghai.myqcloud.com/').rstrip('/') + '/'
# ================================================================
# 辅助函数获取单个订单的打手提交图片绝对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.query.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.query.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.query.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.query.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.query.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
# 店铺退款接口 —— 基于跨平台退款接口二改,只处理平台订单,我方派单
# ================================================================
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.query.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 = User.query.get(UserUID=dashou_id)
dashou_profile = dashou_user.dashou_profile
dashou_profile.tuikuanliang += 1
dashou_profile.zhuangtai = 1
dashou_profile.save()
except (User.DoesNotExist, ObjectDoesNotExist):
logger.warning(f"退款:打手 {dashou_id} 不存在,跳过状态更新")
# 更新退款记录表
self._update_refund_record(order, dashou_id, str(shop_id), reason)
# 打手每日统计(退款动作)
try:
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:
update_daily_payout(order.jine)
except Exception as e:
logger.error(f"更新每日支出失败: {e}")
# ========== 新增:商品和店铺每日统计 ==========
try:
# 商品每日统计只要有商品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': '退款处理失败,请稍后重试'})
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.query.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 _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 = ['<xml>']
for k, v in data.items():
xml.append(f'<{k}>{v}</{k}>')
xml.append('</xml>')
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.query.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}'})
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)
if not dashou_id:
return Response({'code': 400, 'msg': '订单无接单打手,无法结算'})
if dashou_fencheng < 0:
return Response({'code': 400, 'msg': '打手分成金额无效'})
# ---------- 更新打手余额和统计 ----------
try:
dashou_user = User.query.get(UserUID=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 (User.DoesNotExist, ObjectDoesNotExist):
logger.warning(f"本地订单结算:打手 {dashou_id} 不存在,跳过打手更新")
# ---------- 更新订单状态 ----------
order.zhuangtai = 3
order.clkf = str(shop_id)
order.save()
# ---------- 打手每日统计(成交) ----------
try:
update_dashou_daily_by_action(
yonghuid=dashou_id,
amount=dashou_fencheng,
action=2 # 2 表示成交(结算)
)
except Exception as e:
logger.error(f"打手每日统计更新失败: {e}")
# ---------- 店铺收益统计(结算) ----------
if pingtai_ext.dianpu_shouyi:
try:
# 商品每日统计只要有商品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}")
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}")
# ========== 5. 记录操作日志 ==========
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': '服务器内部错误'})
# ================================================================
# shangdian/views/transfer_hall_view.py
# 店铺转移大厅接口 —— 严格二改,不添加任何原接口没有的逻辑
# ================================================================
# ================================================================
# shangdian/views/transfer_hall_view.py
# 店铺转移大厅接口 —— 添加本地打手退款统计
# ================================================================
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.query.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
dashou_fencheng = order.dashou_fencheng or 0 # 用于退款统计
try:
with transaction.atomic():
# 锁定订单行
order = Dingdan.objects.select_for_update().get(dingdan_id=dingdan_id)
# ---------- 6. 记录打手历史(原接口保留逻辑) ----------
if current_dashou_id:
last_history = OrderDashouHistory.query.filter(
dingdan_id=dingdan_id
).order_by('-times').first()
next_times = (last_history.times + 1) if last_history else 1
OrderDashouHistory.query.create(
dingdan_id=dingdan_id,
dashou_id=current_dashou_id,
times=next_times,
operator=str(shop_id)
)
# ---------- 7. 释放本地打手 + 退款统计 ----------
if current_dashou_id:
try:
dashou_profile = UserDashou.query.get(user__UserUID=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.jiedan_dashou_id = None
order.clkf = str(shop_id)
# 根据是否有指定打手决定状态
order.zhuangtai = 7 if zhiding_id else 1
order.save()
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客服罚款
# ================================================================
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.query.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.query.filter(beichufa_id=dashou_id, guanliandingdan_id=dingdan_id).exists():
return Response({'code': 400, 'msg': '该打手已被罚款过'})
# ---------- 6. 创建罚单 ----------
with transaction.atomic():
Fadan.query.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 店铺管理员
)
logger.info(f"本地罚款成功: 订单 {dingdan_id}, 打手 {dashou_id}, 金额 {fakuanjine}")
return Response({'code': 200, 'msg': '罚款已生成'})
except Exception as e:
logger.error(f"店铺罚款申请异常: {traceback.format_exc()}")
return Response({'code': 500, 'msg': '服务器内部错误'})