import io import os import re import time import random import secrets import hashlib import threading import urllib.parse from decimal import Decimal, InvalidOperation from django.conf import settings from django.db import transaction, connection from django.db.models import Q, F, Max, Prefetch from gvsdsdk.fluent import db, func, FQ from django.core.cache import cache from django.core.paginator import Paginator from django.utils import timezone from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status, permissions from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.parsers import JSONParser, MultiPartParser, FormParser from rest_framework.throttling import AnonRateThrottle, SimpleRateThrottle from utils.oss_utils import upload_to_oss, delete_from_oss, validate_image from utils.weixin_broadcast import WeixinBroadcastSender from utils.weixin_token import get_weixin_mini_access_token, is_weixin_token_invalid # from utils.ip_security import * from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode from backend.utils import update_shangjia_daily from utils.chat_utils import subscribe_merchant_link_chat from utils.pdd_order_validator import validate_pdd_order_id, validate_cn_mobile from ..models import ( Gonggao, Lunbo, Tupianpeizhi, Qunpeizhi, ShangjiaMoban, ShangjiaLianjie, PopupPage, PopupConfig, WithdrawConfig, MiniappScriptScene, MiniappScriptAutoReply, ) from users.models import ( AdminProfile, UserDashou, UserBoss, UserShangjia, UserGuanshi, UserZuzhang ) from users.business_models import User from orders.models import CommissionRate, Order, MerchantOrderExt from orders.notice_tasks import dingdan_guangbo from rank.models import Chenghao, DingdanBiaoqian from products.models import ShangpinLeixing, Huiyuangoumai from ..serializers import PopupConfigSerializer import traceback import requests import logging logger = logging.getLogger(__name__) class GuanZhuAListView(APIView): """ 获取关注阿龙页面图片列表 POST /peizhi/gzal 需要JWT认证 返回: { "code": 0, "msg": "success", "data": { "images": ["相对URL1", "相对URL2", ...], "last_update": "2025-03-16 12:34:56", "visit_count": 123 } } """ permission_classes = [IsAuthenticated] def post(self, request): try: from jituan.services.club_context import resolve_effective_club_id club_id = resolve_effective_club_id(request, request.user) qs = Tupianpeizhi.query.filter(ImageType=3, club_id=club_id) if not qs.exists(): return Response({ 'code': 0, 'msg': 'success', 'data': { 'images': [], 'last_update': None, 'visit_count': 0 } }, status=status.HTTP_200_OK) # 获取所有图片的相对URL images = list(qs.values_list('ImageURL', flat=True)) # 获取最新更新时间(取所有记录的UpdateTime最大值) last_update = qs.aggregate(UpdateTime__max=Max('UpdateTime'))['UpdateTime__max'] last_update_str = last_update.strftime('%Y-%m-%d %H:%M:%S') if last_update else None # 原子性增加访问次数 # 先更新所有记录,再获取更新后的最大次数 qs.update(AccessCount=F('AccessCount') + 1) visit_count = qs.aggregate(access_count__max=Max('AccessCount'))['access_count__max'] or 0 return Response({ 'code': 0, 'msg': 'success', 'data': { 'images': images, 'last_update': last_update_str, 'visit_count': visit_count } }, status=status.HTTP_200_OK) except Exception as e: logger.error(f"获取关注阿龙图片列表失败: {e}", exc_info=True) return Response({ 'code': 1, 'msg': '服务器内部错误', 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) #不用缓存,一天2000次 class GuanshiQRCodeView(APIView): permission_classes = [permissions.IsAuthenticated] def post(self, request): logger.info("===== 开始生成管事二维码 =====") current_user = request.user logger.info(f"当前用户ID: {current_user.UserUID}") try: from jituan.services.club_context import resolve_effective_club_id from utils.weixin_token import get_club_mini_access_token, is_weixin_token_invalid club_id = resolve_effective_club_id(request, current_user) try: guanshi = current_user.GuanshiProfile logger.info(f"管事扩展表查询成功,状态: {guanshi.zhuangtai}") except UserGuanshi.DoesNotExist: logger.warning(f"用户 {current_user.UserUID} 不是管事身份") return Response( {'code': 403, 'message': '当前用户不是管事身份', 'data': None}, status=status.HTTP_403_FORBIDDEN ) if guanshi.zhuangtai != 1: logger.warning(f"用户 {current_user.UserUID} 管事状态异常: {guanshi.zhuangtai}") return Response( {'code': 403, 'message': '管事账号状态异常', 'data': None}, status=status.HTTP_403_FORBIDDEN ) invite_code = guanshi.yaoqingma if not invite_code: logger.info(f"用户 {current_user.UserUID} 无邀请码,开始生成") invite_code = CreateInvitationCode(str(current_user.UserUID)) if not VerifyInvitationCode(invite_code): logger.error(f"邀请码唯一性校验失败,用户: {current_user.UserUID}") return Response( {'code': 500, 'message': '邀请码生成失败', 'data': None}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) guanshi.yaoqingma = invite_code guanshi.save(update_fields=['yaoqingma']) logger.info(f"邀请码生成成功: {invite_code}") else: logger.info(f"使用已有邀请码: {invite_code}") # 按俱乐部使用对应小程序 appid 的 access_token access_token = get_club_mini_access_token(club_id) if not access_token: logger.error("获取 access_token 失败 club_id=%s", club_id) return Response( {'code': 500, 'message': '获取微信access_token失败', 'data': None}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) logger.info(f"获取到 access_token (前10位): {access_token[:10]}... club={club_id}") encoded_invite = urllib.parse.quote(invite_code, safe='') # 新小程序:打手端 Tab「我的」;旧码 dashouduan 由小程序兼容页转发 page_path = f'pages/fighter/fighter?inviteCode={encoded_invite}' wx_url = f'https://api.weixin.qq.com/wxa/getwxacode?access_token={access_token}' post_data = { 'path': page_path, 'width': 430, 'auto_color': False, 'line_color': {'r': 0, 'g': 0, 'b': 0} } logger.info(f"调用微信接口生成小程序码,path: {page_path}") def _request_qr(token): try: return requests.post( f'https://api.weixin.qq.com/wxa/getwxacode?access_token={token}', json=post_data, timeout=10, ) except Exception as e: logger.error(f"请求微信接口异常: {str(e)}", exc_info=True) return None wx_resp = _request_qr(access_token) if wx_resp is None: return Response( {'code': 500, 'message': '调用微信服务失败', 'data': None}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) 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_club_mini_access_token(club_id, force_refresh=True) if access_token: wx_resp = _request_qr(access_token) except Exception: pass logger.info(f"微信接口响应状态码: {wx_resp.status_code}") if wx_resp.status_code != 200: error_info = {} if wx_resp.headers.get('content-type') == 'application/json': try: error_info = wx_resp.json() except: pass errmsg = error_info.get('errmsg', '未知错误') logger.error(f"微信接口返回错误 (HTTP {wx_resp.status_code}): {errmsg}") if 'errcode' in error_info: logger.error(f"微信错误码: {error_info.get('errcode')}") return Response( {'code': 500, 'message': f'生成二维码失败: {errmsg}', 'data': None}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) content_type = wx_resp.headers.get('content-type', '') if 'image' not in content_type: try: error_info = wx_resp.json() errmsg = error_info.get('errmsg', '未知错误') logger.error(f"微信接口返回非图片内容: {errmsg}") return Response( {'code': 500, 'message': f'生成二维码失败: {errmsg}', 'data': None}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) except: logger.error("微信接口返回未知内容,无法解析为JSON") return Response( {'code': 500, 'message': '生成二维码失败', 'data': None}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) timestamp = int(time.time()) filename = f"{current_user.UserUID}_{timestamp}.png" oss_path = f"erweima/guanshi/{club_id}/{filename}" file_obj = io.BytesIO(wx_resp.content) logger.info(f"开始上传图片到COS: {oss_path}") try: full_url = upload_to_oss(file_obj, oss_path) logger.info(f"上传成功,完整URL: {full_url}") except Exception as e: logger.error(f"上传到COS异常: {str(e)}", exc_info=True) return Response( {'code': 500, 'message': '图片上传失败', 'data': None}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) if not full_url: logger.error("upload_to_oss 返回了空 URL") return Response( {'code': 500, 'message': '图片上传失败', 'data': None}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) 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 logger.info(f"相对路径: {relative_path}") old_url = guanshi.invite_qrcode_url if old_url: logger.info(f"删除旧二维码: {old_url}") try: delete_from_oss(old_url) except Exception as e: logger.warning(f"删除旧二维码失败(不影响主流程): {str(e)}") with transaction.atomic(): guanshi.invite_qrcode_url = relative_path guanshi.save(update_fields=['invite_qrcode_url']) logger.info("数据库更新成功") return Response({ 'code': 0, 'message': 'success', 'data': {'url': relative_path} }) except Exception as e: logger.error(f"生成二维码过程中发生未捕获异常: {str(e)}", exc_info=True) return Response( {'code': 500, 'message': '服务器内部错误', 'data': None}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) def get_fresh_access_token(self): """ 直接请求微信接口获取新的 access_token,不缓存,保证最新。 """ appid = getattr(settings, 'WEIXIN_APPID', '') secret = getattr(settings, 'WEIXIN_SECRET', '') if not appid or not secret: logger.error("微信小程序配置缺失: WEIXIN_APPID 或 WEIXIN_SECRET 未设置") return None url = 'https://api.weixin.qq.com/cgi-bin/token' params = { 'grant_type': 'client_credential', 'appid': appid, 'secret': secret } try: logger.info("正在请求微信接口获取最新 access_token...") resp = requests.get(url, params=params, timeout=5) data = resp.json() new_token = data.get('access_token') if new_token: logger.info("成功获取最新 access_token") return new_token else: errcode = data.get('errcode', '未知') errmsg = data.get('errmsg', '未知错误') logger.error(f"获取 access_token 失败,errcode={errcode}, errmsg={errmsg}") return None except Exception as e: logger.error(f"请求微信 access_token 接口异常: {str(e)}", exc_info=True) return None class ZuzhangHaibaoView(APIView): """ 组长专属海报二维码生成接口 路径:/peizhi/zuzhanghb 方法:POST 权限:JWT认证,仅限组长身份 返回: { "code": 0, "message": "success", "data": { "url": "erweima/zuzhang/1000001_1234567890.png", "yaoqingma": "ABC123XYZ" } } """ permission_classes = [permissions.IsAuthenticated] def post(self, request): current_user = request.user from jituan.services.club_context import resolve_effective_club_id from utils.weixin_token import get_club_mini_access_token club_id = resolve_effective_club_id(request, current_user) # 1. 验证组长身份 try: zuzhang = current_user.ZuzhangProfile except UserZuzhang.DoesNotExist: return Response({ 'code': 403, 'message': '当前用户不是组长身份', 'data': None }, status=status.HTTP_403_FORBIDDEN) # 2. 验证组长账号状态 if zuzhang.zhuangtai != 1: return Response({ 'code': 403, 'message': '组长账号状态异常,无法生成海报', 'data': None }, status=status.HTTP_403_FORBIDDEN) # 3. 获取或生成邀请码 invite_code = zuzhang.yaoqingma if not invite_code: invite_code = CreateInvitationCode(str(current_user.UserUID)) if not VerifyInvitationCode(invite_code): logger.error(f"组长邀请码生成失败,用户: {current_user.UserUID}") return Response({ 'code': 500, 'message': '邀请码生成失败', 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) zuzhang.yaoqingma = invite_code zuzhang.save(update_fields=['yaoqingma']) # 4. 获取微信 access_token(按俱乐部对应小程序) access_token = get_club_mini_access_token(club_id, force_refresh=False) if not access_token: return Response({ 'code': 500, 'message': '获取微信access_token失败', 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # ===== 修改点:使用 A 接口(getwxacode) ===== encoded_invite = urllib.parse.quote(invite_code, safe='') # 新小程序:打手端 Tab「我的」管事注册;旧码 guanshiduan 由小程序兼容页转发 page_path = f'pages/fighter/fighter?registerType=guanshi&inviteCode={encoded_invite}' wx_resp, wx_error = self._request_wx_qrcode(page_path, access_token) if wx_error and is_weixin_token_invalid(wx_error.get('errcode'), wx_error.get('errmsg', '')): logger.warning('组长海报二维码 token 失效,强制刷新后重试 club=%s', club_id) access_token = get_club_mini_access_token(club_id, force_refresh=True) if not access_token: return Response({ 'code': 500, 'message': '获取微信access_token失败', 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) wx_resp, wx_error = self._request_wx_qrcode(page_path, access_token) if wx_error: errmsg = wx_error.get('errmsg', '未知错误') logger.error(f"微信接口返回错误: {errmsg}") return Response({ 'code': 500, 'message': f'生成二维码失败: {errmsg}', 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) if wx_resp is None: return Response({ 'code': 500, 'message': '调用微信服务失败', 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # 5. 上传到腾讯云 COS timestamp = int(time.time()) filename = f"{current_user.UserUID}_{timestamp}.png" oss_path = f"erweima/zuzhang/{club_id}/{filename}" file_obj = io.BytesIO(wx_resp.content) try: full_url = upload_to_oss(file_obj, oss_path) except Exception as e: logger.error(f"上传到COS失败: {e}") return Response({ 'code': 500, 'message': '图片上传失败', 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) if not full_url: return Response({ 'code': 500, 'message': '图片上传失败', 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # 6. 获取相对路径 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 # 7. 删除旧二维码文件 old_url = zuzhang.haibao_url if old_url: delete_from_oss(old_url) # 8. 更新数据库中的 haibao_url 字段 with transaction.atomic(): zuzhang.haibao_url = relative_path zuzhang.save(update_fields=['haibao_url']) # 9. 返回邀请码和二维码相对路径 return Response({ 'code': 0, 'message': 'success', 'data': { 'url': relative_path, 'yaoqingma': invite_code } }) def _request_wx_qrcode(self, page_path, access_token): """ 调用 getwxacode 生成小程序码。 返回 (response, error_dict);成功时 error_dict 为 None。 """ wx_url = f'https://api.weixin.qq.com/wxa/getwxacode?access_token={access_token}' post_data = { 'path': 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 None, {'errmsg': '调用微信服务失败'} if wx_resp.status_code != 200: error_info = {} if wx_resp.headers.get('content-type', '').startswith('application/json'): try: error_info = wx_resp.json() except Exception: pass return wx_resp, { 'errcode': error_info.get('errcode'), 'errmsg': error_info.get('errmsg', f'HTTP {wx_resp.status_code}'), } content_type = wx_resp.headers.get('content-type', '') if 'image' not in content_type: try: error_info = wx_resp.json() return wx_resp, { 'errcode': error_info.get('errcode'), 'errmsg': error_info.get('errmsg', '未知错误'), } except Exception: return wx_resp, {'errmsg': '微信接口返回未知内容'} return wx_resp, None