商品与专区、店铺增加 club_id(历史默认 xq);列表按俱乐部过滤;工单列表返回待办统计;新增 hqshouji/rzwlsjh 手机号查询与认证接口。 Co-authored-by: Cursor <cursoragent@cursor.com>
788 lines
32 KiB
Python
788 lines
32 KiB
Python
"""shop.views.shop_base_views - 店铺基础视图:绑定/登录/概览/二维码/收支统计."""
|
||
# ==================== 标准库 ====================
|
||
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.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, _url_to_oss_key
|
||
from utils.weixin_token import get_weixin_mini_access_token, is_weixin_token_invalid
|
||
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 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, 'UserUID'):
|
||
return Response({'code': 500, 'msg': '用户身份信息异常'})
|
||
|
||
# 先获取 User 对象(不加锁,仅用于校验存在性)
|
||
try:
|
||
user_main = User.query.get(UserUID=user.UserUID)
|
||
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.UserUID)
|
||
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.UserUID} 绑定店铺 {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. 根据绑定关系及店铺状态决定数据获取策略(店铺须属当前俱乐部)
|
||
from jituan.constants import CLUB_ID_DEFAULT
|
||
from jituan.services.club_context import resolve_effective_club_id
|
||
club_id = resolve_effective_club_id(request, user_main) or CLUB_ID_DEFAULT
|
||
|
||
if binding:
|
||
dianpu = binding.dianpu
|
||
shop_club = (getattr(dianpu, 'club_id', None) or CLUB_ID_DEFAULT)
|
||
if dianpu.zhuangtai == 1 and shop_club == club_id:
|
||
data = self._get_shop_goods(dianpu)
|
||
else:
|
||
# 店铺封禁或不属于当前俱乐部 → 公共商品
|
||
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;已拼展示用 xiaoliang 别名
|
||
for sp in shangpin_data:
|
||
sp['leixing_id'] = sp.pop('dianpu_leixing_id')
|
||
sp['xiaoliang'] = sp.get('duiwai_xiaoliang') or 0
|
||
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 的数据(审核专用)。
|
||
- 按俱乐部隔离:类型走俱乐部可见配置;专区/商品按 club_id 过滤(历史默认 xq)。
|
||
"""
|
||
from jituan.constants import CLUB_ID_DEFAULT
|
||
from jituan.services.club_context import resolve_effective_club_id
|
||
from jituan.services.club_shangpin_leixing import build_club_leixing_list_for_api
|
||
|
||
target_shenhe = 1 if use_audit_status_one else 2
|
||
club_id = resolve_effective_club_id(self.request, getattr(self.request, 'user', None)) or CLUB_ID_DEFAULT
|
||
|
||
# 1. 公共商品类型:按俱乐部可见类型
|
||
if use_audit_status_one:
|
||
leixing_rows = build_club_leixing_list_for_api(club_id)
|
||
leixing_data = [
|
||
{
|
||
'id': row['id'],
|
||
'jieshao': row['jieshao'],
|
||
'tupian_url': row['tupian_url'],
|
||
'paixu': row['paixu'],
|
||
}
|
||
for row in leixing_rows
|
||
]
|
||
else:
|
||
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. 专区:当前俱乐部 + 公共
|
||
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,
|
||
club_id=club_id,
|
||
).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. 商品:当前俱乐部 + 公共
|
||
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,
|
||
club_id=club_id,
|
||
).values(
|
||
'id', 'biaoqian', 'jiage', 'tupian_url', 'duiwai_xiaoliang', 'paixu',
|
||
'leixing_id', 'zhuanqu_id'
|
||
).order_by('-paixu', 'id')
|
||
shangpin_data = list(shangpin_queryset)
|
||
for sp in shangpin_data:
|
||
sp['xiaoliang'] = sp.get('duiwai_xiaoliang') or 0
|
||
else:
|
||
shangpin_data = []
|
||
|
||
return {
|
||
'shangpinleixing': leixing_data,
|
||
'shangpinzhuanqu': zhuanqu_data,
|
||
'shangpinliebiao': shangpin_data
|
||
}
|
||
|
||
|
||
class ShangdianLoginView(APIView):
|
||
"""
|
||
商家后台登录接口(通过凭证直接获取店铺)
|
||
POST /shangdian/login
|
||
请求参数:
|
||
yonghuid : 用户ID(7位)
|
||
zhanghao : 登录账号
|
||
mima : 密码
|
||
返回数据:
|
||
code: 0, data: {
|
||
token: "jwt_token",
|
||
yonghuid: "1234567",
|
||
dianpu_mingcheng: "店铺名称",
|
||
dianpu_touxiang: "头像相对URL",
|
||
erweima_url: "二维码相对URL"
|
||
}
|
||
"""
|
||
permission_classes = [AllowAny]
|
||
|
||
def post(self, request):
|
||
# 1. 获取请求参数
|
||
yonghuid = request.data.get('yonghuid', '').strip()
|
||
zhanghao = request.data.get('zhanghao', '').strip()
|
||
mima = request.data.get('mima', '').strip()
|
||
|
||
# 2. 参数完整性校验
|
||
if not yonghuid or not zhanghao or not mima:
|
||
return Response({'code': 400, 'msg': '用户ID、账号、密码均不能为空'})
|
||
|
||
# 3. 验证用户主表是否存在
|
||
try:
|
||
user_main = 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.UserUID,
|
||
'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 '',
|
||
'CreateTime': dianpu.CreateTime.isoformat() if dianpu.CreateTime else '',
|
||
'UpdateTime': dianpu.UpdateTime.isoformat() if dianpu.UpdateTime 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 Response({'code': 404, 'msg': '店铺暂无二维码'}, status=404)
|
||
|
||
oss_key = _url_to_oss_key(erweima_relative_url) or str(erweima_relative_url).lstrip('/')
|
||
|
||
# 3. 从腾讯云 COS 获取图片内容
|
||
try:
|
||
client = get_oss_client()
|
||
bucket = getattr(settings, 'COS_BUCKET', '')
|
||
cos_resp = client.get_object(Bucket=bucket, Key=oss_key)
|
||
image_data = cos_resp['Body'].get_raw_stream().read()
|
||
except Exception:
|
||
logger.exception("从COS获取二维码失败 key=%s", oss_key)
|
||
return Response({'code': 502, 'msg': '下载失败,请稍后重试'}, status=502)
|
||
|
||
if not image_data:
|
||
return Response({'code': 404, 'msg': '二维码文件不存在'}, status=404)
|
||
|
||
# 店铺二维码正常仅数十 KB,超过 2MB 视为 OSS 路径异常
|
||
if len(image_data) > 2 * 1024 * 1024:
|
||
logger.error(
|
||
'店铺二维码文件体积异常 dianpu=%s key=%s size=%s',
|
||
dianpu.id, oss_key, len(image_data),
|
||
)
|
||
return Response({'code': 500, 'msg': '二维码文件异常,请重新生成'}, status=500)
|
||
|
||
# 4. 返回图片(inline 供页面展示,attachment 供下载)
|
||
filename = f"shop_qrcode_{dianpu.id}.png"
|
||
disposition_type = 'inline' if request.GET.get('disposition') == 'inline' else 'attachment'
|
||
response = HttpResponse(image_data, content_type='image/png')
|
||
response['Content-Disposition'] = f'{disposition_type}; filename="{filename}"'
|
||
response['Content-Length'] = str(len(image_data))
|
||
response['Cache-Control'] = 'private, max-age=300'
|
||
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(stable_token + 缓存)
|
||
access_token = get_weixin_mini_access_token()
|
||
if not access_token:
|
||
return Response(
|
||
{'code': 500, 'msg': '获取微信 access_token 失败,请检查小程序配置'},
|
||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||
)
|
||
|
||
# 构造小程序码请求参数(scene 传店铺ID,首页解析 scene 进入店铺)
|
||
scene = str(dianpu.id)
|
||
page_path = getattr(settings, 'SHOP_QRCODE_PAGE', 'pages/index/index')
|
||
post_data = {
|
||
'scene': scene,
|
||
'page': page_path,
|
||
'width': 430,
|
||
'auto_color': False,
|
||
'line_color': {'r': 0, 'g': 0, 'b': 0},
|
||
'check_path': False,
|
||
}
|
||
|
||
def _request_wx_qrcode(token):
|
||
wx_url = f'https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={token}'
|
||
return requests.post(wx_url, json=post_data, timeout=15)
|
||
|
||
wx_resp = _request_wx_qrcode(access_token)
|
||
|
||
# token 失效时强制刷新重试一次
|
||
if wx_resp.headers.get('content-type', '').startswith('application/json'):
|
||
try:
|
||
err = wx_resp.json()
|
||
if is_weixin_token_invalid(err.get('errcode'), err.get('errmsg', '')):
|
||
access_token = get_weixin_mini_access_token(force_refresh=True)
|
||
if access_token:
|
||
wx_resp = _request_wx_qrcode(access_token)
|
||
except Exception:
|
||
pass
|
||
|
||
# 判断返回是否为图片(微信错误时可能返回 JSON)
|
||
content_type = wx_resp.headers.get('content-type', '')
|
||
if wx_resp.status_code != 200 or 'image' not in content_type:
|
||
errmsg = '未知错误'
|
||
try:
|
||
error_info = wx_resp.json()
|
||
errmsg = error_info.get('errmsg', errmsg)
|
||
logger.error(
|
||
'微信生成店铺二维码失败 dianpu=%s errcode=%s errmsg=%s',
|
||
dianpu.id, error_info.get('errcode'), errmsg,
|
||
)
|
||
except Exception:
|
||
logger.error('微信生成店铺二维码失败 dianpu=%s status=%s', dianpu.id, wx_resp.status_code)
|
||
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
|
||
)
|
||
|
||
# 提取相对路径(与商品图片上传逻辑一致)
|
||
cos_domain = getattr(settings, 'COS_DOMAIN', '').rstrip('/')
|
||
if cos_domain and full_url.startswith(cos_domain):
|
||
relative_path = full_url.replace(f'{cos_domain}/', '', 1)
|
||
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', 'UpdateTime'])
|
||
|
||
return Response({
|
||
'code': 0,
|
||
'msg': 'success',
|
||
'data': {'erweima_url': relative_path}
|
||
})
|
||
|