"""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: from shop.services.shop_binding import set_user_shop_binding changed, msg = set_user_shop_binding(user_main, dianpu) logger.info(f"用户 {user.UserUID} 绑定店铺 {dianpu_id}: changed={changed} msg={msg}") return Response({'code': 200, 'msg': msg if changed else '已绑定该店铺'}) 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. 查询用户店铺绑定关系(一人多条历史脏数据时取最新) from shop.services.shop_binding import get_user_shop_binding binding = get_user_shop_binding(user_main) # 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: # 未绑店:受本俱乐部「是否只看审核」开关控制 # 开启 → 有特殊身份看正常(=1),否则看审核专用(=2) # 关闭 → 一律看正常公共商品(与身份无关) use_normal = True if self._is_zhi_kan_shenhe_enabled(club_id): use_normal = has_special data = self._get_public_goods(use_normal) return Response({'code': 200, 'data': data}) def _is_zhi_kan_shenhe_enabled(self, club_id): """本俱乐部开关:未绑店用户是否按身份只看审核专用商品。默认开启。""" from shop.services.shenhe_config import is_zhi_kan_shenhe_enabled return is_zhi_kan_shenhe_enabled(club_id) 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, 'tupian_url': getattr(zq, 'tupian_url', None) or '', }) # 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', 'jieshao', '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 sp['jieshao'] = sp.get('jieshao') or '' 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, 'tupian_url': getattr(zq, 'tupian_url', None) or '', }) # 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', 'jieshao', 'jiage', 'tupian_url', 'duiwai_xiaoliang', 'paixu', 'leixing_id', 'zhuanqu_id', 'shi_zhuanpan' ).order_by('-paixu', 'id') shangpin_data = list(shangpin_queryset) for sp in shangpin_data: sp['xiaoliang'] = sp.get('duiwai_xiaoliang') or 0 sp['jieshao'] = sp.get('jieshao') or '' sp['shi_zhuanpan'] = bool(sp.get('shi_zhuanpan')) 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} })