P1 性能优化(避免循环内 N+1 / N 次 DB 查询): - shop_order_views._batch_images: 移除 ThreadPoolExecutor 掩盖的 N 次查询,改单次批量 + Python 侧分组 - product_query.DashouHuiyuanList: 5N 查询 -> 3 次批量预取 + 本地闭包判断 - roles.GetRolePermissionView / 用户列表: 循环内 RolePermission/Permission/UserRole/Role -> 批量 __in 预取 - guanli / zuzhang 杜次分红 4.2/4.3: 循环内 update_or_create + 单条 delete -> bulk_create + bulk_update + 批量 delete - admin_config QQ 群配置: 循环内 exists/first/save/create -> 批量预取 + bulk_create + bulk_update - jituan 服务层多处合并统计查询、批量预取 map - rank 多处循环 N+1 改批量预取 - backend 多处循环内 count/create 改批量 - config/orders/merchant_ops 多处循环 N+1 改批量预取 其他改动: - users/views/kefu.py 拆分为 kefu_base/kefu_dashou/kefu_orders/kefu_punishment/kefu_withdraw 5 个文件 - 删除遗留脚本 check_prod_uid.py / create_rbac_tables.sql
559 lines
22 KiB
Python
559 lines
22 KiB
Python
"""shop.views.shop_order_views - 店铺订单统计与管理视图(含 OSS 图片批量辅助函数)."""
|
||
# ==================== 标准库 ====================
|
||
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 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.money import yuan_to_fen
|
||
|
||
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 users.business_models import User
|
||
|
||
from shop.models import YonghuDianpuBangding, ShangpinLeixingDianpu, DianpuShangpinShenheShezhi
|
||
|
||
from products.models import ShangpinLeixing, ShangpinZhuanqu, Shangpin, Huiyuan
|
||
|
||
from orders.models import (
|
||
Order, PlatformOrderExt, PlayerDeliveryImage, RefundRecord,
|
||
OrderPlayerHistory, Penalty
|
||
)
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
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(
|
||
PlatformOrderExt.query.filter(dianpu_id=shop_id)
|
||
.values_list('Order_id', flat=True) # 这里取的是订单主表的主键id
|
||
)
|
||
|
||
total_orders = 0
|
||
global_counts = defaultdict(int)
|
||
|
||
if order_pks:
|
||
# 4. 用主键列表进行聚合统计,按商品类型和订单状态分组
|
||
aggregation = (
|
||
Order.objects
|
||
.filter(id__in=order_pks) # 修正点:使用id__in,而不是dingdan_id__in
|
||
.values('ProductTypeID', 'Status')
|
||
.annotate(cnt=Count('id'))
|
||
)
|
||
|
||
for row in aggregation:
|
||
lid = row['ProductTypeID'] # 商品类型ID
|
||
st = row['Status'] # 订单状态码
|
||
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.PlayerID:
|
||
# 从打手图片表中查询该订单、该打手的所有图片记录
|
||
image_urls = PlayerDeliveryImage.query.filter(
|
||
OrderID=order.OrderID, # 订单ID匹配
|
||
PlayerID=order.PlayerID # 接单打手ID匹配
|
||
).values_list('ImageURL', 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
|
||
|
||
|
||
# ================================================================
|
||
# 辅助函数:批量获取多个订单的打手图片(单次 DB 查询)
|
||
# ================================================================
|
||
def _batch_images(orders, oss_domain):
|
||
"""
|
||
批量获取订单的打手提交图片,使用单次 DB 查询替代循环/线程池 N 次查询。
|
||
|
||
参数:
|
||
orders: Dingdan 订单对象列表
|
||
oss_domain: OSS 域名
|
||
|
||
返回:
|
||
字典,键为订单ID (OrderID),值为 (images_list, is_punished, punishment_info)
|
||
"""
|
||
result = {}
|
||
# 先为所有订单填充默认空值,保证调用方可拿到键
|
||
for order in orders:
|
||
result[order.OrderID] = ([], False, {})
|
||
|
||
# 收集所有 (OrderID, PlayerID) 组合用于批量查询
|
||
_order_player_pairs = [
|
||
(order.OrderID, order.PlayerID)
|
||
for order in orders
|
||
if order.PlayerID
|
||
]
|
||
if not _order_player_pairs:
|
||
return result
|
||
|
||
# 单次批量查询所有相关图片记录(避免循环 N 次或线程池掩盖 N 次查询)
|
||
# PlayerDeliveryImage 表的主键是 OrderID + PlayerID,使用元组列表过滤
|
||
_order_ids = [pair[0] for pair in _order_player_pairs]
|
||
_pair_set = set(_order_player_pairs)
|
||
|
||
try:
|
||
rows = list(
|
||
PlayerDeliveryImage.query.filter(
|
||
OrderID__in=_order_ids
|
||
).values_list('OrderID', 'PlayerID', 'ImageURL')
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"_batch_images 批量查询图片失败: {e}")
|
||
return result
|
||
|
||
# 按 (OrderID, PlayerID) 分组
|
||
_img_map = {}
|
||
for oid, pid, img_path in rows:
|
||
if not img_path:
|
||
continue
|
||
# 仅保留属于本批 orders 且 PlayerID 匹配的记录(防止跨打手串号)
|
||
if (oid, pid) not in _pair_set:
|
||
continue
|
||
if img_path.startswith('http'):
|
||
url = img_path
|
||
else:
|
||
url = oss_domain + img_path.lstrip('/')
|
||
_img_map.setdefault(oid, []).append(url)
|
||
|
||
# 把分组结果写回 result
|
||
for oid, imgs in _img_map.items():
|
||
if oid in result:
|
||
result[oid] = (imgs, 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(
|
||
PlatformOrderExt.query.filter(
|
||
dianpu_id=shop_id # 店铺ID匹配
|
||
).values_list('Order_id', flat=True) # 只取订单ID字段
|
||
)
|
||
|
||
# 统计变量
|
||
total_orders = 0 # 订单总量
|
||
global_status_counts = defaultdict(int) # 全局各状态计数,键为状态码字符串
|
||
|
||
# ------------------------------------------------------------
|
||
# 步骤3:如果店铺有订单,进行聚合统计
|
||
# ------------------------------------------------------------
|
||
if order_ids:
|
||
# 使用 Django 聚合查询:按 leixing_id(商品类型)和 zhuangtai(订单状态)分组计数
|
||
aggregation = Order.query.filter(
|
||
OrderID__in=order_ids # 从该店铺的订单中查询
|
||
).values(
|
||
'ProductTypeID', # 按类型ID分组
|
||
'Status' # 按状态分组
|
||
).annotate(
|
||
cnt=Count('id') # 统计每个分组的记录数
|
||
)
|
||
|
||
# 遍历每个聚合结果,累加到对应的类型和全局计数中
|
||
for row in aggregation:
|
||
# 提取分组字段
|
||
lid = row['ProductTypeID'] # 商品类型ID
|
||
st = row['Status'] # 订单状态码
|
||
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 = PlatformOrderExt.query.filter(
|
||
dianpu_id=shop_id
|
||
).select_related('Order').order_by('-Order__CreateTime')
|
||
|
||
# 应用筛选条件
|
||
if leixing_id is not None:
|
||
qs = qs.filter(Order__ProductTypeID=leixing_id)
|
||
if zhuangtai is not None:
|
||
qs = qs.filter(Order__Status=zhuangtai)
|
||
if dingdan_id_key:
|
||
qs = qs.filter(Order__OrderID__icontains=dingdan_id_key)
|
||
if jiedan_dashou_id_key:
|
||
qs = qs.filter(Order__PlayerID=jiedan_dashou_id_key)
|
||
if nicheng_key:
|
||
qs = qs.filter(Order__Nickname__icontains=nicheng_key)
|
||
if laoban_id_key:
|
||
qs = qs.filter(BossID=laoban_id_key)
|
||
if jieshao_key:
|
||
qs = qs.filter(Order__Description__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.Order, 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.OrderID, ([], False, {}))
|
||
list_data.append({
|
||
'dingdan_id': order.OrderID,
|
||
'zhuangtai': order.Status,
|
||
'jine': float(order.Amount) if order.Amount else 0.00,
|
||
'dashou_fencheng': float(order.PlayerCommission) if order.PlayerCommission else 0.00,
|
||
'jiedan_dashou_id': order.PlayerID or '',
|
||
'dashou_liuyan': order.PlayerRemark or '',
|
||
'zhiding_id': order.AssignedID or '',
|
||
'shangpin_id': order.ProductID or '',
|
||
'tupian': order.ImageURL or '',
|
||
'jieshao': order.Description or '',
|
||
'beizhu': order.Remark or '',
|
||
'nicheng': order.Nickname or '',
|
||
'leixing_id': order.ProductTypeID,
|
||
'CreateTime': order.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if order.CreateTime else '',
|
||
'UpdateTime': order.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if order.UpdateTime else '',
|
||
'pingtai_kuozhan': {
|
||
'laoban_id': ext.BossID or '',
|
||
'laoban_pingjia': ext.BossReview or '',
|
||
'dianpu_shouyi': float(ext.ShopIncome) if ext.ShopIncome 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
|
||
}
|
||
})
|
||
|