diff --git a/config/views.py b/config/views.py deleted file mode 100644 index bb52353..0000000 --- a/config/views.py +++ /dev/null @@ -1,3718 +0,0 @@ -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 GetDynamicConfigView(APIView): - permission_classes = [AllowAny] - parser_classes = [JSONParser] - - def post(self, request): - try: - from jituan.services.display_config import get_tupianpeizhi_url - - from jituan.services.miniapp_assets import build_miniapp_assets_payload - - cos_oss_url = getattr(settings, 'COS_DOMAIN', '') - if cos_oss_url and not cos_oss_url.endswith('/'): - cos_oss_url += '/' - cos_bucket = getattr(settings, 'COS_BUCKET', '') - cos_region = getattr(settings, 'COS_REGION', 'ap-shanghai') - goeasy_host = 'hangzhou.goeasy.io' - goeasy_appkey = getattr(settings, 'GOEASY_APPKEY', '') - kefu_link = getattr(settings, 'KEFU_LINK', '') - kefu_enterprise_id = getattr(settings, 'KEFU_ENTERPRISE_ID', '') - rule_img = get_tupianpeizhi_url(request, 1) - avatar_img = get_tupianpeizhi_url(request, 2) - morentouxiang = avatar_img or 'beijing/morentouxiang.jpg' - dashouguize = rule_img or 'a_long/dashouguize.jpg' - - # 构造返回数据 - data = { - "code": 0, - "data": { - "cos": { - "bucket": cos_bucket, - "region": cos_region, - "ossImageUrl": cos_oss_url, - "uploadPathPrefix": "order/" - }, - "goEasy": { - "host": goeasy_host, - "appkey": goeasy_appkey - }, - "otherConfig": { - "morentouxiang": morentouxiang, - "dashouguize": dashouguize - }, - "kefu": { - "link": kefu_link, - "enterpriseId": kefu_enterprise_id - }, - "miniappAssets": build_miniapp_assets_payload(request), - } - } - return Response(data) - - except Exception as e: - logger.error(f"获取动态配置失败: {str(e)}", exc_info=True) - return Response({"code": 500, "msg": "服务器内部错误"}, status=500) - - -class HaibaoPeizhiView(APIView): - """小程序推广海报背景配置(按俱乐部)。""" - permission_classes = [AllowAny] - parser_classes = [JSONParser] - - def post(self, request): - try: - from jituan.services.display_config import _display_club_id - from jituan.services.miniapp_assets import get_poster_payload - - club_id = _display_club_id(request) - poster = get_poster_payload(club_id) - return Response({ - 'code': 0, - 'msg': 'success', - 'data': { - 'club_id': club_id, - 'poster': poster, - }, - }) - except Exception as e: - logger.error('获取海报配置失败: %s', e, exc_info=True) - return Response({'code': 500, 'msg': '服务器内部错误'}, status=500) - - - - -class ShangpinGonggaoView(APIView): - """ - 商品公告和轮播图接口 - 获取类型为1的公告和轮播图 - """ - throttle_classes = [AnonRateThrottle] - permission_classes = [AllowAny] - authentication_classes = [] - - def post(self, request): - """ - 处理POST请求,返回公告和轮播图数据 - - 请求: POST /api/peizhi/shangpingonggao/ - 返回: { - "shangpingonggao": "公告内容字符串", - "shangpinlunbo": ["轮播图URL1", "轮播图URL2"] - } - """ - try: - logger.info("收到商品公告和轮播图请求") - from jituan.services.display_config import get_gonggao_content, get_lunbo_urls, normalize_page_key - - page_key = normalize_page_key(request.data.get('page_key'), image_type=1) - gonggao_content = get_gonggao_content(request, notice_type=1, page_key=page_key) - lunbo_urls = get_lunbo_urls(request, page_key=page_key, image_type=1) - response_data = { - "shangpingonggao": gonggao_content, - "shangpinlunbo": lunbo_urls, - "page_key": page_key, - } - logger.info(f"返回数据:公告长度{len(gonggao_content)},轮播图数量{len(lunbo_urls)} page_key={page_key}") - # 4. 返回响应 - return Response(response_data, status=status.HTTP_200_OK) - except Exception as e: - # 记录异常 - logger.error(f"获取商品公告和轮播图时发生错误:{str(e)}") - # 返回错误响应 - error_data = { - "error": "服务器内部错误", - "detail": "获取数据失败,请稍后重试" - } - return Response(error_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - - - -class AdminConfigQueryView(APIView): - """ - 管理员获取系统配置接口 - URL: /peizhi/adpzhq - 方法: POST - 权限: JWT Token认证 + 管理员权限 - 参数: zhanghao (管理员账号) - """ - permission_classes = [permissions.IsAuthenticated] - - def post(self, request): - try: - # 获取前端传递的账号 - zhanghao = request.data.get('zhanghao') - if not zhanghao: - return Response({ - 'code': 1, - 'msg': '账号不能为空', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 获取当前用户(User实例) - user_main = request.user - - # 验证账号:比较user_main的phone与前端传递的zhanghao - if user_main.Phone != zhanghao: - return Response({ - 'code': 1, - 'msg': '账号不匹配', - 'data': None - }, status=status.HTTP_401_UNAUTHORIZED) - - # 验证用户类型是否为管理员 - if user_main.UserType != 'admin': - return Response({ - 'code': 1, - 'msg': '无权限', - 'data': None - }, status=status.HTTP_401_UNAUTHORIZED) - - # 验证管理员扩展表是否存在 - try: - admin_profile = user_main.AdminProfile - except AdminProfile.DoesNotExist: - return Response({ - 'code': 1, - 'msg': '无权限', - 'data': None - }, status=status.HTTP_401_UNAUTHORIZED) - - # 查询图片配置 - rule_image = Tupianpeizhi.query.filter(ImageType=1).first() - default_avatar = Tupianpeizhi.query.filter(ImageType=2).first() - - # 查询群配置 - qun_configs = Qunpeizhi.query.all() - qq_groups = [] - for config in qun_configs: - qq_groups.append({ - 'peizhiid': config.GroupType, - 'neirong': config.GroupContent if config.GroupContent else '', - 'qunid': config.GroupID if config.GroupID else '', - 'jieshao': config.Description if config.Description else '' - }) - - # 查询利率配置 - platform_rate_obj = CommissionRate.query.filter(Platform='1').first() - shop_rate_obj = CommissionRate.query.filter(Platform='3').first() - # 🔴【新增】打手提现抽成利率(字符5) - dashou_withdraw_obj = CommissionRate.query.filter(Platform='5').first() - # 🔴【新增】管事提现抽成利率(字符6) - guanshi_withdraw_obj = CommissionRate.query.filter(Platform='6').first() - - # 构建返回数据 - data = { - 'images': { - 'rule_image': rule_image.ImageURL if rule_image and rule_image.ImageURL else '', - 'default_avatar': default_avatar.ImageURL if default_avatar and default_avatar.ImageURL else '' - }, - 'qq_groups': qq_groups, - 'rates': { - 'platform_rate': float(platform_rate_obj.Rate) if platform_rate_obj and platform_rate_obj.Rate else 0.0, - 'shop_rate': float(shop_rate_obj.Rate) if shop_rate_obj and shop_rate_obj.Rate else 0.0, - # 🔴【新增】打手提现抽成利率 - 'dashou_withdraw_rate': float( - dashou_withdraw_obj.Rate) if dashou_withdraw_obj and dashou_withdraw_obj.Rate else 0.0, - # 🔴【新增】管事提现抽成利率 - 'guanshi_withdraw_rate': float( - guanshi_withdraw_obj.Rate) if guanshi_withdraw_obj and guanshi_withdraw_obj.Rate else 0.0 - - } - } - - return Response({ - 'code': 0, - 'msg': 'success', - 'data': data - }) - - except Exception as e: - return Response({ - 'code': 99, - 'msg': f'系统错误: {str(e)}', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -class AdminUploadImageView(APIView): - """ - 管理员上传配置图片接口 - URL: /peizhi/adxgtp - 方法: POST - 权限: JWT Token认证 + 管理员权限 - 参数: zhanghao, leixing (1:打手规则图片, 2:默认头像图片), image (文件) - """ - permission_classes = [permissions.IsAuthenticated] - parser_classes = (MultiPartParser, FormParser) - - def post(self, request): - try: - zhanghao = request.data.get('zhanghao') - leixing = request.data.get('leixing') - image_file = request.FILES.get('image') - - if not zhanghao: - return Response({ - 'code': 1, - 'msg': '账号不能为空', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - if not leixing: - return Response({ - 'code': 2, - 'msg': '图片类型不能为空', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - if not image_file: - return Response({ - 'code': 3, - 'msg': '请选择图片文件', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 验证用户权限 - user_main = request.user - if user_main.Phone != zhanghao: - return Response({ - 'code': 1, - 'msg': '账号不匹配', - 'data': None - }, status=status.HTTP_401_UNAUTHORIZED) - - if user_main.UserType != 'admin': - return Response({ - 'code': 1, - 'msg': '无权限', - 'data': None - }, status=status.HTTP_401_UNAUTHORIZED) - - try: - admin_profile = user_main.AdminProfile - except AdminProfile.DoesNotExist: - return Response({ - 'code': 1, - 'msg': '无权限', - 'data': None - }, status=status.HTTP_401_UNAUTHORIZED) - - # 验证图片类型 - if leixing not in ['1', '2']: - return Response({ - 'code': 4, - 'msg': '图片类型错误', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 验证图片文件 - is_valid, error_msg = validate_image(image_file) - if not is_valid: - return Response({ - 'code': 5, - 'msg': f'图片验证失败: {error_msg}', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 根据类型确定固定相对URL和文件名 - if leixing == '1': - # 打手规则图片 - fixed_relative_url = 'a_long/dashouguize.jpg' - oss_file_name = 'dashouguize.jpg' - else: - # 默认头像图片 - fixed_relative_url = 'a_long/morentouxiang.jpg' - oss_file_name = 'morentouxiang.jpg' - - # 构建OSS文件路径 - oss_file_path = fixed_relative_url # a_long/dashouguize.jpg 或 a_long/morentouxiang.jpg - - # 上传到OSS(覆盖原有文件) - image_file.seek(0) # 重置文件指针 - full_url = upload_to_oss(image_file, oss_file_path) - - if not full_url: - return Response({ - 'code': 6, - 'msg': '图片上传到存储桶失败', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - # 更新数据库 - with transaction.atomic(): - # 获取或创建图片配置记录 - tupian_peizhi, created = Tupianpeizhi.query.get_or_create(ImageType=int(leixing)) - tupian_peizhi.ImageURL = fixed_relative_url - tupian_peizhi.save() - - return Response({ - 'code': 0, - 'msg': '图片上传成功', - 'data': { - 'image_url': fixed_relative_url - } - }) - - except Exception as e: - return Response({ - 'code': 99, - 'msg': f'系统错误: {str(e)}', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -class AdminUpdateConfigView(APIView): - """ - 管理员更新系统配置接口(QQ群、利率)- 已修复 - URL: /peizhi/adupdate - 方法: POST - 权限: JWT Token认证 + 管理员权限 - 参数: - - zhanghao: 管理员账号 - - qq_groups: [{"peizhiid": 1, "neirong": "", "qunid": "", "jieshao": ""}, ...] (可选) - - rates: {"platform_rate": 0.1, "shop_rate": 0.2} (可选) - """ - permission_classes = [permissions.IsAuthenticated] - - def post(self, request): - try: - zhanghao = request.data.get('zhanghao') - if not zhanghao: - return Response({ - 'code': 1, - 'msg': '账号不能为空', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 验证用户权限 - user_main = request.user - if user_main.Phone != zhanghao: - return Response({ - 'code': 1, - 'msg': '账号不匹配', - 'data': None - }, status=status.HTTP_401_UNAUTHORIZED) - - if user_main.UserType != 'admin': - return Response({ - 'code': 1, - 'msg': '无权限', - 'data': None - }, status=status.HTTP_401_UNAUTHORIZED) - - try: - admin_profile = user_main.AdminProfile - except AdminProfile.DoesNotExist: - return Response({ - 'code': 1, - 'msg': '无权限', - 'data': None - }, status=status.HTTP_401_UNAUTHORIZED) - - response_data = {} - - # 🔴 修复:更新QQ群配置 - 确保根据peizhiid正确更新对应的记录 - qq_groups = request.data.get('qq_groups') - if qq_groups and isinstance(qq_groups, list): - updated_groups = [] - for group_data in qq_groups: - peizhiid = group_data.get('peizhiid') - if not peizhiid: - continue - - # 🔴 修复:使用filter()确保查询到正确的记录 - qun_configs = Qunpeizhi.query.filter(GroupType=peizhiid) - - if qun_configs.exists(): - # 如果存在多个相同peizhiid的记录,取第一个(应该只有一个) - qun_config = qun_configs.first() - - # 更新字段 - neirong = group_data.get('neirong') - qunid = group_data.get('qunid') - jieshao = group_data.get('jieshao') - - if neirong is not None: - qun_config.GroupContent = neirong or '' - if qunid is not None: - qun_config.GroupID = qunid or '' - if jieshao is not None: - qun_config.Description = jieshao or '' - - qun_config.save() - else: - # 如果不存在,创建新的(使用提供的peizhiid) - qun_config = Qunpeizhi.query.create( - GroupType=peizhiid, - GroupContent=group_data.get('neirong', '') or '', - GroupID=group_data.get('qunid', '') or '', - Description=group_data.get('jieshao', '') or '' - ) - - updated_groups.append({ - 'peizhiid': qun_config.GroupType, - 'neirong': qun_config.GroupContent, - 'qunid': qun_config.GroupID, - 'jieshao': qun_config.Description - }) - - if updated_groups: - response_data['qq_groups'] = updated_groups - - # 🔴 修复:更新利率配置 - 确保更新现有记录而不是创建新记录 - rates = request.data.get('rates') - if rates and isinstance(rates, dict): - updated_rates = {} - - # 平台发单利率(类型1,fadanpingtai='1') - platform_rate = rates.get('platform_rate') - if platform_rate is not None: - try: - platform_rate_float = float(platform_rate) - if 0 <= platform_rate_float <= 1: - # 🔴 修复:查询现有记录,如果不存在则创建 - platform_rate_obj = CommissionRate.query.filter(Platform='1').first() - - if platform_rate_obj: - # 更新现有记录 - platform_rate_obj.Rate = platform_rate_float - platform_rate_obj.save() - else: - # 创建新记录 - platform_rate_obj = CommissionRate.query.create( - Rate=platform_rate_float, - Platform='1' - ) - - updated_rates['platform_rate'] = platform_rate_float - else: - return Response({ - 'code': 3, - 'msg': '平台发单利率必须在0-1之间', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - except ValueError: - return Response({ - 'code': 4, - 'msg': '平台发单利率格式错误', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 商家发单利率(类型2,fadanpingtai='2') - shop_rate = rates.get('shop_rate') - if shop_rate is not None: - try: - shop_rate_float = float(shop_rate) - if 0 <= shop_rate_float <= 1: - # 🔴 修复:查询现有记录,如果不存在则创建 - shop_rate_obj = CommissionRate.query.filter(Platform='3').first() - - if shop_rate_obj: - # 更新现有记录 - shop_rate_obj.Rate = shop_rate_float - shop_rate_obj.save() - else: - # 创建新记录 - shop_rate_obj = CommissionRate.query.create( - Rate=shop_rate_float, - Platform='3' - ) - - updated_rates['shop_rate'] = shop_rate_float - else: - return Response({ - 'code': 5, - 'msg': '商家发单利率必须在0-1之间', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - except ValueError: - return Response({ - 'code': 6, - 'msg': '商家发单利率格式错误', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - - - - # 🔴【新增】打手提现抽成利率(类型5,fadanpingtai='5') - dashou_withdraw_rate = rates.get('dashou_withdraw_rate') - if dashou_withdraw_rate is not None: - try: - dashou_withdraw_rate_float = float(dashou_withdraw_rate) - if 0 <= dashou_withdraw_rate_float <= 1: - # 查询现有记录,如果不存在则创建 - dashou_withdraw_obj = CommissionRate.query.filter(Platform='5').first() - - if dashou_withdraw_obj: - # 更新现有记录 - dashou_withdraw_obj.Rate = dashou_withdraw_rate_float - dashou_withdraw_obj.save() - else: - # 创建新记录 - dashou_withdraw_obj = CommissionRate.query.create( - Rate=dashou_withdraw_rate_float, - Platform='5' - ) - - updated_rates['dashou_withdraw_rate'] = dashou_withdraw_rate_float - else: - return Response({ - 'code': 7, # 注意:这里需要调整错误码,不要重复 - 'msg': '打手提现抽成利率必须在0-1之间', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - except ValueError: - return Response({ - 'code': 8, - 'msg': '打手提现抽成利率格式错误', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 🔴【新增】管事提现抽成利率(类型6,fadanpingtai='6') - guanshi_withdraw_rate = rates.get('guanshi_withdraw_rate') - if guanshi_withdraw_rate is not None: - try: - guanshi_withdraw_rate_float = float(guanshi_withdraw_rate) - if 0 <= guanshi_withdraw_rate_float <= 1: - # 查询现有记录,如果不存在则创建 - guanshi_withdraw_obj = CommissionRate.query.filter(Platform='6').first() - - if guanshi_withdraw_obj: - # 更新现有记录 - guanshi_withdraw_obj.Rate = guanshi_withdraw_rate_float - guanshi_withdraw_obj.save() - else: - # 创建新记录 - guanshi_withdraw_obj = CommissionRate.query.create( - Rate=guanshi_withdraw_rate_float, - Platform='6' - ) - - updated_rates['guanshi_withdraw_rate'] = guanshi_withdraw_rate_float - else: - return Response({ - 'code': 9, - 'msg': '管事提现抽成利率必须在0-1之间', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - except ValueError: - return Response({ - 'code': 10, - 'msg': '管事提现抽成利率格式错误', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - if updated_rates: - response_data['rates'] = updated_rates - - # 如果没有任何更新 - if not response_data: - return Response({ - 'code': 7, - 'msg': '没有需要更新的数据', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - return Response({ - 'code': 0, - 'msg': '更新成功', - 'data': response_data - }) - - except Exception as e: - return Response({ - 'code': 99, - 'msg': f'系统错误: {str(e)}', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - - - -class DashouPaihangView(APIView): - """ - 排行榜接口 - 路径:/peizhi/dsph - 方法:POST - 认证:JWT Token认证 - - 请求参数: - - leixing: 1=今日收入,2=今月收入 - - yonghu_leixing: 1=打手,2=管事,3=商家 - - 返回数据: - - code: 0=成功,其他=失败 - - msg: 提示信息 - - data: { - xianshi: True/False, # 是否展示真实数据 - paihang_list: [ # 排行榜列表 - { - uid: '用户ID', - touxiang: '头像相对URL', - nicheng: '用户昵称', - jine: 收入金额, - mingci: 排名 - }, - ... - ] - } - """ - - # =============== 可配置参数 =============== - # 每次查询返回的最大记录数(可修改) - ZUI_DAO_CHA_XUN_SHU_LIANG = 50 - - # 最少需要多少人才能展示(可修改) - ZUI_SHAO_REN_SHU_YAO_QIU = 1 - - # 第一名最低金额要求(单位:元)(可修改) - DI_YI_MING_ZUI_DI_JIN_E = Decimal('10.00') - - # 管事第一名最低数量要求(可修改) - DI_YI_MING_ZUI_DI_SHU_LIANG = 1 - # =============== 可配置参数结束 =============== - - permission_classes = [IsAuthenticated] - - def post(self, request): - try: - # 获取请求参数 - 修复参数类型问题 - leixing_str = request.data.get('leixing', '1') - yonghu_leixing_str = request.data.get('yonghu_leixing', '1') - - # 转换为整数 - try: - leixing = int(leixing_str) - except (ValueError, TypeError): - leixing = 1 - - try: - yonghu_leixing = int(yonghu_leixing_str) - except (ValueError, TypeError): - yonghu_leixing = 1 - - # 参数验证 - if leixing not in [1, 2]: - return Response({ - 'code': 400, - 'msg': '参数错误:leixing必须为1(今日)或2(今月)', - 'data': {'xianshi': False} - }) - - if yonghu_leixing not in [1, 2, 3]: - return Response({ - 'code': 400, - 'msg': '参数错误:yonghu_leixing必须为1(打手)、2(管事)或3(商家)', - 'data': {'xianshi': False} - }) - - # 根据用户类型调用不同的查询方法 - if yonghu_leixing == 1: - # 打手排行榜 - paihang_list, xianshi_flag = self.chaxun_dashou_paihang(leixing) - elif yonghu_leixing == 2: - # 管事排行榜 - paihang_list, xianshi_flag = self.chaxun_guanshi_paihang(leixing) - else: - # 商家排行榜 - paihang_list, xianshi_flag = self.chaxun_shangjia_paihang(leixing) - - # 返回结果 - return Response({ - 'code': 0, - 'msg': '获取成功', - 'data': { - 'xianshi': xianshi_flag, - 'paihang_list': paihang_list - } - }) - - except Exception as e: - logger.error(f"排行榜查询失败: {str(e)}", exc_info=True) - return Response({ - 'code': 500, - 'msg': '服务器内部错误', - 'data': {'xianshi': False} - }) - - def chaxun_dashou_paihang(self, leixing): - """ - 查询打手排行榜 - :param leixing: 1=今日收入,2=今月收入 - :return: (排行榜列表, 是否展示标志) - """ - # 确定查询的字段 - if leixing == 1: - # 今日收入 - shouyi_ziduan = 'jinrishouyi' - else: - # 今月收入 - shouyi_ziduan = 'jinyueshouyi' - - # 查询条件:收入大于0,打手账号状态正常(1=正常) - # 注意:打手表中的zhanghaozhuangtai字段 - chaxun_tiaojian = Q(**{f'{shouyi_ziduan}__gt': 0}) & Q(zhanghaozhuangtai=1) - - # 查询打手扩展表,并关联用户主表 - dashou_list = UserDashou.query.select_related('user').filter( - chaxun_tiaojian - ).order_by(f'-{shouyi_ziduan}')[:self.ZUI_DAO_CHA_XUN_SHU_LIANG] - - # 检查是否满足展示条件 - if len(dashou_list) < self.ZUI_SHAO_REN_SHU_YAO_QIU: - # 人数不足,不展示 - return [], False - - # 检查第一名金额是否达到最低要求 - if leixing == 1: - di_yi_ming_jine = dashou_list[0].jinrishouyi - else: - di_yi_ming_jine = dashou_list[0].jinyueshouyi - - if di_yi_ming_jine < self.DI_YI_MING_ZUI_DI_JIN_E: - # 第一名金额不足,不展示 - return [], False - - # 构建返回数据 - paihang_list = [] - for index, dashou in enumerate(dashou_list): - # 获取用户主表信息 - user_main = dashou.user - - # 打手的昵称直接使用打手扩展表的nicheng字段 - dashou_nicheng = dashou.nicheng or '未设置昵称' - - # 获取头像(相对URL) - touxiang_url = user_main.Avatar or '' - - # 获取收入金额 - if leixing == 1: - shouyi_jine = dashou.jinrishouyi - else: - shouyi_jine = dashou.jinyueshouyi - - paihang_list.append({ - 'uid': user_main.UserUID, - 'touxiang': touxiang_url, - 'nicheng': dashou_nicheng, - 'jine': float(shouyi_jine), # 转换为float方便前端处理 - 'mingci': index + 1 - }) - - return paihang_list, True - - def chaxun_guanshi_paihang(self, leixing): - """ - 查询管事排行榜 - :param leixing: 1=今日,2=今月 - :return: (排行榜列表, 是否展示标志) - """ - # 确定查询的字段 - if leixing == 1: - # 今日充值打手数量 - chaxun_ziduan = 'jinrichongzhi' - else: - # 今月充值打手数量 - chaxun_ziduan = 'jinyuechongzhi' - - # 查询条件:数量大于0,管事状态正常(状态1为正常) - # 注意:管手表中的zhuangtai字段 - chaxun_tiaojian = Q(**{f'{chaxun_ziduan}__gt': 0}) & Q(zhuangtai=1) - - # 查询管事扩展表,并关联用户主表 - guanshi_list = UserGuanshi.query.select_related('user').filter( - chaxun_tiaojian - ).order_by(f'-{chaxun_ziduan}')[:self.ZUI_DAO_CHA_XUN_SHU_LIANG] - - # 检查是否满足展示条件 - if len(guanshi_list) < self.ZUI_SHAO_REN_SHU_YAO_QIU: - # 人数不足,不展示 - return [], False - - # 检查第一名数量是否达到最低要求 - if leixing == 1: - di_yi_ming_shuliang = guanshi_list[0].jinrichongzhi - else: - di_yi_ming_shuliang = guanshi_list[0].jinyuechongzhi - - if di_yi_ming_shuliang < self.DI_YI_MING_ZUI_DI_SHU_LIANG: - # 第一名数量不足,不展示 - return [], False - - # 构建返回数据 - paihang_list = [] - for index, guanshi in enumerate(guanshi_list): - # 获取用户主表信息 - user_main = guanshi.user - - # 管事的昵称需要查询老板扩展表 - boss_profile = UserBoss.query.filter(user=user_main).first() - guanshi_nicheng = boss_profile.nickname if boss_profile else '未设置昵称' - - # 获取头像(相对URL) - touxiang_url = user_main.Avatar or '' - - # 获取数量 - if leixing == 1: - chongzhi_shuliang = guanshi.jinrichongzhi - else: - chongzhi_shuliang = guanshi.jinyuechongzhi - - paihang_list.append({ - 'uid': user_main.UserUID, - 'touxiang': touxiang_url, - 'nicheng': guanshi_nicheng, - 'jine': float(chongzhi_shuliang), # 这里jine字段实际存储的是数量 - 'mingci': index + 1 - }) - - return paihang_list, True - - def chaxun_shangjia_paihang(self, leixing): - """ - 查询商家排行榜 - :param leixing: 1=今日流水,2=今月流水 - :return: (排行榜列表, 是否展示标志) - """ - # 确定查询的字段 - if leixing == 1: - # 今日流水 - liushui_ziduan = 'jinriliushui' - else: - # 今月流水 - liushui_ziduan = 'jinyueliushui' - - # 查询条件:流水大于0,商家状态正常(状态1为正常) - # 注意:商家表中的zhuangtai字段 - chaxun_tiaojian = Q(**{f'{liushui_ziduan}__gt': 0}) & Q(zhuangtai=1) - - # 查询商家扩展表,并关联用户主表 - shangjia_list = UserShangjia.query.select_related('user').filter( - chaxun_tiaojian - ).order_by(f'-{liushui_ziduan}')[:self.ZUI_DAO_CHA_XUN_SHU_LIANG] - - # 检查是否满足展示条件 - if len(shangjia_list) < self.ZUI_SHAO_REN_SHU_YAO_QIU: - # 人数不足,不展示 - return [], False - - # 检查第一名金额是否达到最低要求 - if leixing == 1: - di_yi_ming_liushui = shangjia_list[0].jinriliushui - else: - di_yi_ming_liushui = shangjia_list[0].jinyueliushui - - if di_yi_ming_liushui < self.DI_YI_MING_ZUI_DI_JIN_E: - # 第一名流水不足,不展示 - return [], False - - # 构建返回数据 - paihang_list = [] - for index, shangjia in enumerate(shangjia_list): - # 获取用户主表信息 - user_main = shangjia.user - - # 商家的昵称需要查询老板扩展表 - boss_profile = UserBoss.query.filter(user=user_main).first() - shangjia_nicheng = boss_profile.nickname if boss_profile else '未设置昵称' - - # 获取头像(相对URL) - touxiang_url = user_main.Avatar or '' - - # 获取流水金额 - if leixing == 1: - liushui_jine = shangjia.jinriliushui - else: - liushui_jine = shangjia.jinyueliushui - - paihang_list.append({ - 'uid': user_main.UserUID, - 'touxiang': touxiang_url, - 'nicheng': shangjia_nicheng, - 'jine': float(liushui_jine), - 'mingci': index + 1 - }) - - return paihang_list, True - - - - - - - - - - -class GetAdminZhanghaoView(APIView): - """ - 获取管理员账号列表接口 - 路径:/peizhi/adzhhq - 方法:POST - 权限:JWT Token + 管理员验证 - 参数: - - zhanghao: 当前登录的管理员账号(字符串) - """ - permission_classes = [IsAuthenticated] - - def post(self, request): - try: - # 🔥 1. 身份验证(100%按表模型) - user = request.user - - # 获取当前用户的手机号(CharField,字符串类型) - current_phone = getattr(user, 'Phone', '') - if not current_phone: - return Response({ - 'code': 401, - 'message': '用户手机号信息不完整', - 'data': None - }, status=status.HTTP_401_UNAUTHORIZED) - - # 检查前端传递的账号是否匹配(都是字符串) - frontend_zhanghao = request.data.get('zhanghao', '').strip() - if frontend_zhanghao != current_phone: - return Response({ - 'code': 403, - 'message': '账号不匹配,无权限访问', - 'data': None - }, status=status.HTTP_403_FORBIDDEN) - - # 🔥 2. 验证是否为管理员(user_type是CharField,值是'admin'字符串) - user_type = getattr(user, 'user_type', '') - if user_type != 'admin': - # 检查是否有管理员扩展表 - if not hasattr(user, 'AdminProfile'): - return Response({ - 'code': 403, - 'message': '无管理员权限', - 'data': None - }, status=status.HTTP_403_FORBIDDEN) - - # 🔥 3. 查询管理员账号列表(全部按字符串查询) - with connection.cursor() as cursor: - # 🔥 查询所有管理员账号(user_type='admin' 字符串) - # 🔥 phone和yonghuid都是CharField,xiugaiid也是CharField - sql = """ - SELECT - um.id, - um.phone, - um.yonghuid, - um.UserType, - COALESCE(COUNT(xjl.id), 0) as jilushuliang - FROM user_main um - LEFT JOIN xiugaijilu xjl ON um.phone = xjl.xiugaiid - WHERE um.UserType = 'admin' - GROUP BY um.id, um.phone, um.yonghuid, um.UserType - ORDER BY um.CreateTime DESC - """ - - cursor.execute(sql) - results = cursor.fetchall() - - # 🔥 4. 处理查询结果(全部保持字符串类型) - zhanghao_list = [] - for row in results: - zhanghao_list.append({ - #'id': row[0], - 'id': str(row[1]) if row[1] else str(row[2]), # 🔥 保持字符串 - 'zhanghao':str(row[1]) if row[1] else str(row[2]), # 🔥 保持字符串 - 'yonghuid': str(row[2]) if row[2] else '', # 🔥 保持字符串 - 'user_type': str(row[3]) if row[3] else '', # 🔥 保持字符串 - 'jilushuliang': int(row[4]) # 只有这个是数字 - }) - - return Response({ - 'code': 0, - 'message': '获取成功', - 'data': zhanghao_list - }, status=status.HTTP_200_OK) - - except Exception as e: - print(f"获取管理员账号列表失败: {str(e)}") - traceback.print_exc() - return Response({ - 'code': 500, - 'message': '系统错误', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -class GetXiugaiJiluView(APIView): - """ - 获取修改记录列表接口 - 路径:/peizhi/adhqxg - 方法:POST - 权限:JWT Token + 管理员验证 - 参数: - - zhanghao: 当前登录的管理员账号(字符串) - - xiugaizhe_zhanghaoid: 选中的管理员账号ID(字符串,对应phone或yonghuid) - - page: 页码(整数) - - pagesize: 每页条数(整数) - """ - permission_classes = [IsAuthenticated] - - def post(self, request): - try: - # 🔥 1. 身份验证(100%按表模型) - user = request.user - - # 获取当前用户的手机号(CharField,字符串) - current_phone = getattr(user, 'Phone', '') - if not current_phone: - return Response({ - 'code': 401, - 'message': '用户手机号信息不完整', - 'data': None - }, status=status.HTTP_401_UNAUTHORIZED) - - # 检查前端传递的账号是否匹配(字符串比较) - frontend_zhanghao = request.data.get('zhanghao', '') - if not isinstance(frontend_zhanghao, str): - frontend_zhanghao = str(frontend_zhanghao) - frontend_zhanghao = frontend_zhanghao.strip() - - if frontend_zhanghao != current_phone: - return Response({ - 'code': 403, - 'message': '账号不匹配,无权限访问', - 'data': None - }, status=status.HTTP_403_FORBIDDEN) - - # 🔥 2. 验证是否为管理员(user_type='admin'字符串) - user_type = getattr(user, 'user_type', '') - if user_type != 'admin': - if not hasattr(user, 'AdminProfile'): - return Response({ - 'code': 403, - 'message': '无管理员权限', - 'data': None - }, status=status.HTTP_403_FORBIDDEN) - - # 🔥 3. 获取参数(100%按实际类型) - # 🔥 xiugaizhe_zhanghaoid 是字符串(对应xiugaiid字段,CharField) - xiugaizhe_zhanghaoid_param = request.data.get('xiugaizhe_zhanghaoid', '') - - - # 🔥 强制转为字符串(因为数据库是CharField) - if xiugaizhe_zhanghaoid_param is None: - xiugaizhe_zhanghaoid = '' - else: - # 无论如何都转为字符串 - xiugaizhe_zhanghaoid = str(xiugaizhe_zhanghaoid_param).strip() - - - # 🔥 分页参数转为整数 - try: - page = int(request.data.get('page', 1)) - except: - page = 1 - - try: - pagesize = int(request.data.get('pagesize', 10)) - except: - pagesize = 10 - - # 🔥 4. 参数验证 - if not xiugaizhe_zhanghaoid: - return Response({ - 'code': 400, - 'message': '修改者账号ID不能为空', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - if page < 1: - page = 1 - if pagesize < 1 or pagesize > 50: - pagesize = 10 - - # 🔥 5. 高效查询(100%按表字段类型) - with connection.cursor() as cursor: - # 🔥 5.1 查询总记录数(xiugaiid是CharField,用字符串查询) - count_sql = """ - SELECT COUNT(*) - FROM xiugaijilu - WHERE xiugaiid = %s - """ - cursor.execute(count_sql, [xiugaizhe_zhanghaoid]) - total_count = cursor.fetchone()[0] - - # 🔥 5.2 计算分页 - offset = (page - 1) * pagesize - - # 🔥 5.3 查询当前页数据(使用参数化查询,防止SQL注入) - data_sql = """ - SELECT - xjl.id, - xjl.yonghuid, -- CharField - xjl.xiugaiid, -- CharField - xjl.leixing, -- IntegerField - xjl.xiugaitijiao, -- DecimalField - xjl.xiugaitijiaoq, -- DecimalField - xjl.jifen, -- IntegerField - xjl.yjifen, -- IntegerField - xjl.yajin, -- DecimalField - xjl.yyajin, -- DecimalField - xjl.shangjiayue, -- DecimalField - xjl.shangjiayueq, -- DecimalField - xjl.guanshiyue, -- DecimalField - xjl.guanshiyueq, -- DecimalField - xjl.huiyuants, -- IntegerField - xjl.huiyuan_id, -- CharField - xjl.qitashuoming, -- TextField - xjl.CreateTime, -- DateTimeField - um.avatar -- CharField - FROM xiugaijilu xjl - LEFT JOIN user_main um ON xjl.yonghuid = um.yonghuid - WHERE xjl.xiugaiid = %s - ORDER BY xjl.CreateTime DESC - LIMIT %s OFFSET %s - """ - - cursor.execute(data_sql, [xiugaizhe_zhanghaoid, pagesize, offset]) - records = cursor.fetchall() - - # 🔥 6. 处理查询结果(严格按照字段类型处理) - jilu_list = [] - for record in records: - # 获取修改者账号信息(都是字符串) - xiugaizhe_zhanghao = '' - with connection.cursor() as cursor2: - # 🔥 phone和yonghuid都是CharField,用字符串查询 - cursor2.execute( - "SELECT phone, yonghuid FROM user_main WHERE phone = %s OR yonghuid = %s LIMIT 1", - [xiugaizhe_zhanghaoid, xiugaizhe_zhanghaoid] - ) - xiugaizhe_info = cursor2.fetchone() - if xiugaizhe_info: - # 🔥 都是字符串 - xiugaizhe_zhanghao = str(xiugaizhe_info[0]) if xiugaizhe_info[0] else str(xiugaizhe_info[1]) if \ - xiugaizhe_info[1] else xiugaizhe_zhanghaoid - - # 获取会员名称(如果有) - huiyuan_mingzi = '' - if record[15]: # huiyuan_id是CharField - with connection.cursor() as cursor3: - cursor3.execute( - "SELECT jieshao FROM huiyuan WHERE huiyuan_id = %s LIMIT 1", - [str(record[15])] # 🔥 转为字符串查询 - ) - huiyuan_info = cursor3.fetchone() - if huiyuan_info: - huiyuan_mingzi = str(huiyuan_info[0]) if huiyuan_info[0] else '' - - # 🔥 严格按照前端字段名映射,保持正确类型 - jilu_data = { - # 基础字段(字符串) - 'id': record[0], - 'beixiugai_yonghuid': str(record[1]) if record[1] else '', # yonghuid - 'xiugaizhe_id': str(record[2]) if record[2] else '', # xiugaiid - 'xiugaizhe_zhanghao': xiugaizhe_zhanghao, # 字符串 - 'beixiugai_yonghuleixing': record[3], # leixing是整数 - - # 打手字段(Decimal转float,Integer转int) - 'xiugaihou_dashoutixianjine': float(record[4]) if record[4] is not None else 0.00, - 'xiugaiqian_dashoutixianjine': float(record[5]) if record[5] is not None else 0.00, - 'xiugaihou_dashoujifen': int(record[6]) if record[6] is not None else 0, - 'xiugaiqian_dashoujifen': int(record[7]) if record[7] is not None else 0, - 'xiugaihou_dashouyajin': float(record[8]) if record[8] is not None else 0.00, - 'xiugaiqian_dashouyajin': float(record[9]) if record[9] is not None else 0.00, - - # 商家字段 - 'xiugaihou_shangjiayue': float(record[10]) if record[10] is not None else 0.00, - 'xiugaiqian_shangjiayue': float(record[11]) if record[11] is not None else 0.00, - - # 管事字段 - 'xiugaihou_guanshiyue': float(record[12]) if record[12] is not None else 0.00, - 'xiugaiqian_guanshiyue': float(record[13]) if record[13] is not None else 0.00, - - # 会员字段 - 'tianjia_huiyuantianshu': int(record[14]) if record[14] is not None else 0, - 'huiyuan_id': str(record[15]) if record[15] else '', # 字符串 - 'huiyuan_mingzi': huiyuan_mingzi, # 字符串 - - # 其他字段 - 'qita_shuoming': str(record[16]) if record[16] else '', # 字符串 - 'xiugai_time': record[17].strftime('%Y-%m-%d %H:%M:%S') if record[17] else None, - 'touxiang': str(record[18]) if record[18] else '' # avatar是字符串 - } - - jilu_list.append(jilu_data) - - # 🔥 7. 分页控制 - queried_records = len(jilu_list) - has_more = False - - if queried_records == pagesize: - total_queried = (page - 1) * pagesize + queried_records - has_more = total_queried < total_count - - return Response({ - 'code': 0, - 'message': '获取成功', - 'data': { - 'list': jilu_list, - 'zongshuliang': total_count, - 'dangqian_page': page, - 'page_size': pagesize, - 'has_more': has_more, - 'shijichaxundediaoshuliang': queried_records - } - }, status=status.HTTP_200_OK) - - except ValueError as e: - print(f"参数错误: {str(e)}") - return Response({ - 'code': 400, - 'message': '参数错误', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - except Exception as e: - print(f"获取修改记录失败: {str(e)}") - traceback.print_exc() - return Response({ - 'code': 500, - 'message': '系统错误', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - - - - - - - - -class ShangjiaMobanListView(APIView): - """ - 商家订单模板列表接口 - POST /peizhi/sjddmblb - 权限: JWT认证 - """ - permission_classes = [IsAuthenticated] - - def post(self, request): - try: - user = request.user - yonghu_id = user.UserUID - - leixing_id = request.data.get('shangpinTypeId') - if not leixing_id: - return Response({'code': 400, 'msg': '商品类型ID不能为空', 'data': {}}, status=400) - - get_type = int(request.data.get('getType', 2)) - page = int(request.data.get('page', 1)) - page_size = int(request.data.get('pageSize', 50)) - - keyword = request.data.get('keyword', '') - min_price = request.data.get('minPrice', '') - label_id = request.data.get('labelId', None) - if label_id is not None: - try: - label_id = int(label_id) - except (ValueError, TypeError): - label_id = None - - query = Q(UserID=yonghu_id, ProductTypeID=leixing_id) - - if get_type == 1: # 搜索模式 - if keyword: - keyword = str(keyword).strip() - try: - mid = int(keyword) - query &= Q(id=mid) | Q(TemplateDesc__icontains=keyword) - except ValueError: - query &= Q(TemplateDesc__icontains=keyword) - if min_price: - try: - query &= Q(Price__gte=Decimal(str(min_price))) - except: - pass - if label_id: - query &= Q(TitleID=label_id) - - mobans = ShangjiaMoban.query.filter(query).order_by('-id') - formatted = [] - for moban in mobans: - texiao_json = '' - label_name = '' - if moban.TitleID: - try: - ch = Chenghao.query.get(id=moban.TitleID) - label_name = ch.mingcheng - texiao_json = ch.texiao_miaoshu or '' - except Chenghao.DoesNotExist: - pass - formatted.append({ - 'mobanId': moban.id, - 'shangpinTypeId': moban.ProductTypeID, - 'jieshao': moban.TemplateDesc, - 'jiage': str(moban.Price), - 'fabushuliang': moban.PublishedCount, - 'commissionEnabled': moban.CommissionGrabEnabled, - 'commissionValue': str(moban.CommissionPrice) if moban.CommissionPrice is not None else None, - 'labelId': moban.TitleID, - 'labelName': label_name, - 'texiaoJson': texiao_json, - 'createTime': moban.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if moban.CreateTime else '' - }) - return Response({'code': 200, 'msg': '搜索成功', 'data': {'list': formatted, 'total': len(formatted), 'hasMore': False}}) - - # 分页模式 - queryset = ShangjiaMoban.query.filter(query).order_by('-id') - paginator = Paginator(queryset, page_size) - if page < 1 or page > paginator.num_pages: - return Response({'code': 400, 'msg': '页码超出范围', 'data': {}}, status=400) - - current_page = paginator.page(page) - formatted = [] - for moban in current_page.object_list: - texiao_json = '' - label_name = '' - if moban.TitleID: - try: - ch = Chenghao.query.get(id=moban.TitleID) - label_name = ch.mingcheng - texiao_json = ch.texiao_miaoshu or '' - except Chenghao.DoesNotExist: - pass - formatted.append({ - 'mobanId': moban.id, - 'shangpinTypeId': moban.ProductTypeID, - 'jieshao': moban.TemplateDesc, - 'jiage': str(moban.Price), - 'fabushuliang': moban.PublishedCount, - 'commissionEnabled': moban.CommissionGrabEnabled, - 'commissionValue': str(moban.CommissionPrice) if moban.CommissionPrice is not None else None, - 'labelId': moban.TitleID, - 'labelName': label_name, - 'texiaoJson': texiao_json, - 'createTime': moban.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if moban.CreateTime else '' - }) - - has_more = current_page.has_next() - if len(formatted) < page_size: - has_more = False - - return Response({ - 'code': 200, - 'msg': '获取成功', - 'data': { - 'list': formatted, - 'total': paginator.count, - 'hasMore': has_more, - 'currentPage': page, - 'pageSize': page_size, - 'totalPages': paginator.num_pages - } - }) - - except Exception as e: - logger.error(f"获取模板列表失败: {str(e)}", exc_info=True) - return Response({'code': 500, 'msg': '服务器内部错误', 'data': {}}, status=500) - - - - - -class ShangjiaTianjiaMobanView(APIView): - """ - 商家添加订单模板接口 - POST /peizhi/sjtjddmb - 权限: JWT认证 - """ - permission_classes = [IsAuthenticated] - - def post(self, request): - try: - # 1. 获取当前商家用户ID - user = request.user - yonghu_id = user.UserUID - - # 2. 接收并清洗参数,避免类型错误 - leixing_id = request.data.get('shangpinTypeId') - jieshao = request.data.get('jieshao', '').strip() if request.data.get('jieshao') else '' - jiage = request.data.get('jiage', '') - # 佣金相关 - commission_enabled = request.data.get('commissionEnabled', False) - commission_value = request.data.get('commissionValue', None) - # 标签ID(称号ID) - label_id = request.data.get('labelId', None) - - # 3. 必填验证 - if not leixing_id or not jieshao or not jiage: - logger.warning(f"参数缺失 - leixing_id:{leixing_id}, jieshao:{jieshao}, jiage:{jiage}") - return Response({ - 'code': 400, - 'msg': '缺少必填字段(订单类型、介绍、价格)', - 'data': {} - }, status=400) - - # 4. 价格格式验证 - try: - jiage_decimal = Decimal(str(jiage)) - if jiage_decimal <= 0: - return Response({'code': 400, 'msg': '价格必须大于0', 'data': {}}, status=400) - if jiage_decimal > 10000: - return Response({'code': 400, 'msg': '价格不能超过10000元', 'data': {}}, status=400) - except (InvalidOperation, ValueError, TypeError): - return Response({'code': 400, 'msg': '价格格式错误', 'data': {}}, status=400) - - # 5. 介绍长度验证 - if len(jieshao) > 100: - return Response({'code': 400, 'msg': '订单介绍不能超过100字', 'data': {}}, status=400) - - # 6. 佣金验证(若启用) - CommissionGrabEnabled = False - CommissionPrice = None - if commission_enabled: - CommissionGrabEnabled = True - if not commission_value: - return Response({'code': 400, 'msg': '开启佣金时,佣金金额不能为空', 'data': {}}, status=400) - try: - comm_decimal = Decimal(str(commission_value)) - if comm_decimal <= 0: - return Response({'code': 400, 'msg': '佣金必须大于0', 'data': {}}, status=400) - CommissionPrice = comm_decimal - except (InvalidOperation, ValueError, TypeError): - return Response({'code': 400, 'msg': '佣金格式错误', 'data': {}}, status=400) - - # 7. 标签ID清洗(前端可能传数字或字符串) - cleaned_label_id = None - if label_id is not None and str(label_id).strip(): - try: - cleaned_label_id = int(label_id) - except (ValueError, TypeError): - return Response({'code': 400, 'msg': '标签ID格式错误', 'data': {}}, status=400) - - # 8. 验证商品类型是否存在且可用 - try: - shangpin_obj = ShangpinLeixing.query.get( - id=leixing_id, - shenhezhuangtai=1 # 审核通过 - ) - except ShangpinLeixing.DoesNotExist: - return Response({'code': 404, 'msg': '商品类型不存在或已禁用', 'data': {}}, status=404) - - # 9. 检查同一用户、同一类型下是否已存在相同介绍的模板 - if ShangjiaMoban.query.filter( - UserID=yonghu_id, - ProductTypeID=leixing_id, - TemplateDesc=jieshao - ).exists(): - return Response({'code': 400, 'msg': '已存在相同介绍的模板', 'data': {}}, status=400) - - # 10. 执行数据库创建(事务) - with transaction.atomic(): - moban = ShangjiaMoban.query.create( - UserID=yonghu_id, - ProductTypeID=leixing_id, - TemplateDesc=jieshao, - Price=jiage_decimal, - PublishedCount=0, - CommissionGrabEnabled=CommissionGrabEnabled, - CommissionPrice=CommissionPrice, - TitleID=cleaned_label_id - ) - - # 11. 返回成功数据(字段与前端对齐) - return Response({ - 'code': 200, - 'msg': '模板创建成功', - 'data': { - 'mobanId': moban.id, - 'shangpinTypeId': leixing_id, - 'jieshao': moban.TemplateDesc, - 'jiage': str(moban.Price), - 'fabushuliang': moban.PublishedCount, - 'createTime': moban.CreateTime.strftime('%Y-%m-%d %H:%M:%S'), - 'commissionEnabled': moban.CommissionGrabEnabled, - 'commissionValue': str(moban.CommissionPrice) if moban.CommissionPrice is not None else None, - 'labelId': moban.TitleID - } - }) - - except Exception as e: - # 打印完整错误堆栈到日志 - logger.error(f"添加模板失败: {str(e)}", exc_info=True) - return Response({ - 'code': 500, - 'msg': '服务器内部错误,添加模板失败,请联系管理员', - 'data': {} - }, status=500) - - - - -class ShangjiaShanchuMobanView(APIView): - """ - 删除模板 - POST /peizhi/sjddmbsc - """ - permission_classes = [IsAuthenticated] - - def post(self, request): - try: - user = request.user - yonghu_id = user.UserUID - - moban_id = request.data.get('mobanId') - leixing_id = request.data.get('shangpinTypeId') - - if not all([moban_id, leixing_id]): - return Response({'code': 400, 'msg': '参数缺失', 'data': {}}, status=400) - - try: - moban = ShangjiaMoban.query.get(id=moban_id, UserID=yonghu_id, ProductTypeID=leixing_id) - except ShangjiaMoban.DoesNotExist: - return Response({'code': 404, 'msg': '模板不存在', 'data': {}}, status=404) - - # 强制硬删除 - moban.delete() - - return Response({'code': 200, 'msg': '删除成功', 'data': {'mobanId': moban_id, 'shangpinTypeId': leixing_id}}) - except Exception as e: - logger.error(f"删除模板失败: {str(e)}", exc_info=True) - return Response({'code': 500, 'msg': '服务器内部错误', 'data': {}}, status=500) - - - - - - -class ShangjiaGengxinMobanView(APIView): - """ - 商家更新订单模板接口 - POST /peizhi/sjddmbgx - 权限: JWT认证 - """ - permission_classes = [IsAuthenticated] - - def post(self, request): - try: - user = request.user - yonghu_id = user.UserUID - - moban_id = request.data.get('mobanId') - leixing_id = request.data.get('shangpinTypeId') - new_jieshao = request.data.get('jieshao', '').strip() - new_jiage = request.data.get('jiage', '').strip() - # 新增字段 - commission_enabled = request.data.get('commissionEnabled') - commission_value = request.data.get('commissionValue') - label_id = request.data.get('labelId') - - if not all([moban_id, leixing_id]): - return Response({'code': 400, 'msg': '模板ID和商品类型ID不能为空', 'data': {}}, status=400) - - # 查询模板 - try: - moban = ShangjiaMoban.query.get( - id=moban_id, - UserID=yonghu_id, - ProductTypeID=leixing_id - ) - except ShangjiaMoban.DoesNotExist: - return Response({'code': 404, 'msg': '模板不存在或无权修改', 'data': {}}, status=404) - - update_fields = [] - - # 修改介绍 - if new_jieshao: - if len(new_jieshao) > 100: - return Response({'code': 400, 'msg': '订单介绍不能超过100字', 'data': {}}, status=400) - if new_jieshao != moban.TemplateDesc: - moban.TemplateDesc = new_jieshao - update_fields.append('TemplateDesc') - - # 修改价格 - if new_jiage: - try: - jiage_decimal = Decimal(new_jiage) - if jiage_decimal <= 0 or jiage_decimal > 10000: - return Response({'code': 400, 'msg': '价格必须在0.1-10000元之间', 'data': {}}, status=400) - if jiage_decimal != moban.Price: - moban.Price = jiage_decimal - update_fields.append('Price') - except (InvalidOperation, ValueError, TypeError): - return Response({'code': 400, 'msg': '价格格式错误', 'data': {}}, status=400) - - # 修改标签 - if label_id is not None: # 允许传空字符串或不传,传0表示清空 - new_label = int(label_id) if label_id else None - if new_label != moban.TitleID: - moban.TitleID = new_label - update_fields.append('TitleID') - - # 修改佣金设置 - if commission_enabled is not None: - enabled = bool(commission_enabled) - if enabled: - if not commission_value: - return Response({'code': 400, 'msg': '佣金价格不能为空', 'data': {}}, status=400) - try: - comm_decimal = Decimal(commission_value) - if comm_decimal < 0.1 or comm_decimal > 50: - return Response({'code': 400, 'msg': '佣金需在0.1-50元之间', 'data': {}}, status=400) - if moban.CommissionGrabEnabled != True or moban.CommissionPrice != comm_decimal: - moban.CommissionGrabEnabled = True - moban.CommissionPrice = comm_decimal - update_fields.extend(['CommissionGrabEnabled', 'CommissionPrice']) - except (InvalidOperation, ValueError, TypeError): - return Response({'code': 400, 'msg': '佣金格式错误', 'data': {}}, status=400) - else: - if moban.CommissionGrabEnabled != False or moban.CommissionPrice is not None: - moban.CommissionGrabEnabled = False - moban.CommissionPrice = None - update_fields.extend(['CommissionGrabEnabled', 'CommissionPrice']) - - if not update_fields: - return Response({'code': 400, 'msg': '没有修改内容', 'data': {}}, status=400) - - # 检查介绍唯一性 - if 'TemplateDesc' in update_fields: - if ShangjiaMoban.query.filter( - UserID=yonghu_id, - ProductTypeID=leixing_id, - TemplateDesc=moban.TemplateDesc - ).exclude(id=moban_id).exists(): - return Response({'code': 400, 'msg': '已存在相同介绍的模板', 'data': {}}, status=400) - - with transaction.atomic(): - moban.save(update_fields=update_fields) - - # 返回最新数据 - return Response({ - 'code': 200, - 'msg': '模板修改成功', - 'data': { - 'mobanId': moban.id, - 'shangpinTypeId': moban.ProductTypeID, - 'jieshao': moban.TemplateDesc, - 'jiage': str(moban.Price), - 'fabushuliang': moban.PublishedCount, - 'commissionEnabled': moban.CommissionGrabEnabled, - 'commissionValue': str(moban.CommissionPrice) if moban.CommissionPrice else None, - 'labelId': moban.TitleID, - 'updateTime': moban.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') - } - }) - - except Exception as e: - logger = logging.getLogger(__name__) - logger.error(f"修改商家模板失败: {str(e)}", exc_info=True) - return Response({'code': 500, 'msg': '服务器内部错误', 'data': {}}, status=500) - - - - - -class ShangjiaGenerateLinkView(APIView): - """商家生成发单链接接口(支持模板佣金/标签)""" - permission_classes = [IsAuthenticated] - - def post(self, request): - try: - user = request.user - # 获取参数 - shangpin_type_id = request.data.get('shangpinTypeId') - moban_id = request.data.get('mobanId') - if not all([shangpin_type_id, moban_id]): - return Response({'code': 400, 'msg': '缺少参数', 'data': {}}, status=400) - - try: - shangpin_type_id = int(shangpin_type_id) - moban_id = int(moban_id) - except ValueError: - return Response({'code': 400, 'msg': '参数格式错误', 'data': {}}, status=400) - - # 商家信息 - try: - shangjia = user.ShopProfile - except UserShangjia.DoesNotExist: - return Response({'code': 403, 'msg': '商家信息未完善', 'data': {}}, status=403) - if shangjia.zhuangtai != 1: - return Response({'code': 403, 'msg': '商家状态异常', 'data': {}}, status=403) - - # 商品类型 - try: - shangpin_leixing = ShangpinLeixing.query.get(id=shangpin_type_id, shenhezhuangtai=1) - except ShangpinLeixing.DoesNotExist: - return Response({'code': 400, 'msg': '商品类型不存在或已禁用', 'data': {}}, status=400) - - # 查询模板 - try: - moban = ShangjiaMoban.query.get(id=moban_id, UserID=user.UserUID, ProductTypeID=shangpin_type_id) - except ShangjiaMoban.DoesNotExist: - return Response({'code': 400, 'msg': '模板不存在', 'data': {}}, status=400) - - jiage = moban.Price - if jiage > shangjia.yue: - return Response({'code': 400, 'msg': f'余额不足,当前余额{shangjia.yue}元', 'data': {}}, status=400) - - # 利率 - try: - lilu = float(CommissionRate.query.get(Platform='3').Rate) or 1.0 - except CommissionRate.DoesNotExist: - lilu = 1.0 - dashou_fencheng = Decimal(str(round(float(jiage) * lilu, 2))) - - # 抢单要求:根据模板是否启用佣金 - chenghao_id = moban.TitleID - if moban.CommissionGrabEnabled and moban.CommissionPrice is not None: - final_yaoqiuleixing = 2 # 佣金模式 - final_yongjin = moban.CommissionPrice - final_huiyuan_id = '' - else: - final_yaoqiuleixing = shangpin_leixing.yaoqiuleixing or 1 - final_yongjin = None - final_huiyuan_id = shangpin_leixing.huiyuan_id or '' - - # 获取标签名称 - label_name = '' - if chenghao_id: - try: - label_name = Chenghao.query.get(id=chenghao_id).mingcheng - except Chenghao.DoesNotExist: - pass - - # 生成ID和Token - def generate_dingdan_id(): - timestamp = int(time.time() * 1000) - process_id = os.getpid() % 1000 - random_num = random.randint(1000, 9999) - return f"SJ{timestamp}{process_id:03d}{random_num}" - - def generate_secure_token(dd_id, uid): - timestamp = int(time.time() * 1_000_000) - salt = secrets.token_hex(8) - raw = f"{timestamp}:{dd_id}:{uid}:{salt}" - token = hashlib.sha256(raw.encode()).hexdigest()[:24] - # 确保唯一性(极小概率重复再试一次) - if ShangjiaLianjie.query.filter(LinkToken=token).exists(): - return generate_secure_token(dd_id, uid) - return token - - def generate_link_url(token): - base = getattr(settings, 'H5_DOMAIN', 'https://h5.yourdomain.com').rstrip('/') - return f"{base}/order/{token}" - - with transaction.atomic(): - dingdan_id = generate_dingdan_id() - secure_token = generate_secure_token(dingdan_id, user.UserUID) - - # 创建订单主表 - dingdan = Order.query.create( - OrderID=dingdan_id, - Status=13, - Platform=2, - Amount=jiage, - PlayerCommission=dashou_fencheng, - ProductTypeID=shangpin_type_id, - GrabRequirement=final_yaoqiuleixing, - MembershipID=final_huiyuan_id, - CommissionReq=final_yongjin, - Description=moban.TemplateDesc[:2000], - User1ID=f"Sj{user.UserUID}", - ImageURL=user.Avatar or '', - ) - - # 商家扩展表 - MerchantOrderExt.query.create( - Order=dingdan, - MerchantID=user.UserUID, - MerchantNickname=shangjia.nicheng or f"商家{user.UserUID}", - ) - - # 关联标签 - if chenghao_id: - try: - ch = Chenghao.query.get(id=chenghao_id) - DingdanBiaoqian.query.create(dingdan=dingdan, chenghao=ch) - except Chenghao.DoesNotExist: - logger.warning(f"称号{chenghao_id}不存在,跳过关联") - - # 更新商家余额和统计 - UserShangjia.query.filter(id=shangjia.id).update( - fabu=F('fabu') + 1, - yue=F('yue') - jiage, - jinridingdan=F('jinridingdan') + 1, - jinriliushui=F('jinriliushui') + jiage, - jinyuedingdan=F('jinyuedingdan') + 1, - jinyueliushui=F('jinyueliushui') + jiage, - ) - - # 生成链接并保存 - link_url = generate_link_url(secure_token) - lianjie_obj = ShangjiaLianjie.query.create( - UserID=user.UserUID, - ProductTypeID=shangpin_type_id, - TemplateID=moban_id, - LinkURL=link_url, - LinkToken=secure_token, - OrderID=dingdan_id, - is_used=False, - ExpireTime=timezone.now() + timezone.timedelta(days=1) - ) - - # 更新模板发布数量 - ShangjiaMoban.query.filter(id=moban_id).update(PublishedCount=F('PublishedCount') + 1) - shangjia.refresh_from_db() - - logger.info(f"链接生成成功 - 订单ID: {dingdan_id}, 标签: {label_name}") - - return Response({ - 'code': 200, - 'msg': '链接生成成功', - 'data': { - 'linkUrl': link_url, - 'newBalance': str(shangjia.yue), - 'dingdanId': dingdan_id, - 'dingdanJieshao': moban.TemplateDesc, - 'jiage': str(jiage), - 'labelId': chenghao_id, - 'labelName': label_name, - 'youxiaoShijian': lianjie_obj.ExpireTime.strftime('%Y-%m-%d %H:%M:%S'), - } - }) - - except Exception as e: - logger.error(f"生成链接事务失败: {str(e)}", exc_info=True) - return Response({'code': 500, 'msg': '系统错误,生成链接失败', 'data': {}}, status=500) - - - - -class ShangjiaLianjieListView(APIView): - """获取已生成的链接列表(支持筛选是否使用)""" - permission_classes = [IsAuthenticated] - - def post(self, request): - try: - user = request.user - yonghu_id = user.UserUID - - moban_id = request.data.get('mobanId') - used = int(request.data.get('used', 0)) # 0未使用,1已使用 - page = int(request.data.get('page', 1)) - page_size = int(request.data.get('pageSize', 5)) - page_size = min(page_size, 50) - - if not moban_id: - return Response({'code': 400, 'msg': '模板ID不能为空', 'data': {}}, status=400) - - # 校验模板归属 - try: - moban = ShangjiaMoban.query.get(id=moban_id, UserID=yonghu_id) - except ShangjiaMoban.DoesNotExist: - return Response({'code': 404, 'msg': '模板不存在或无权查看', 'data': {}}, status=404) - - links = ShangjiaLianjie.query.filter( - UserID=yonghu_id, - TemplateID=moban_id, - is_used=(used == 1) - ).order_by('-CreateTime') - - paginator = Paginator(links, page_size) - if page < 1 or page > paginator.num_pages: - return Response({'code': 400, 'msg': '页码超出范围', 'data': {}}, status=400) - - current_page = paginator.page(page) - data_list = [] - for link in current_page.object_list: - data_list.append({ - 'id': link.id, - 'lianjie': link.LinkURL, - 'LinkToken': link.LinkToken, - 'is_used': link.is_used, - 'dingdan_id': link.OrderID, - 'ExpireTime': link.ExpireTime.strftime('%Y-%m-%d %H:%M:%S') if link.ExpireTime else '', - 'CreateTime': link.CreateTime.strftime('%Y-%m-%d %H:%M:%S') - }) - - return Response({ - 'code': 200, - 'msg': '获取成功', - 'data': { - 'list': data_list, - 'total': paginator.count, - 'hasMore': current_page.has_next(), - 'currentPage': page, - 'pageSize': page_size - } - }) - - except Exception as e: - logger.error(f"获取链接列表失败: {str(e)}", exc_info=True) - return Response({'code': 500, 'msg': '服务器内部错误', 'data': {}}, status=500) - - - - - - -class IPUtils: - """IP工具类""" - - @staticmethod - def get_client_ip(request): - """ - 获取客户端真实IP,处理代理情况 - 优先级: X-Real-IP > X-Forwarded-For > remote_addr - """ - ip = None - - try: - # 1. 尝试从 X-Real-IP 获取 - x_real_ip = request.META.get('HTTP_X_REAL_IP') - if x_real_ip: - ip = x_real_ip.strip() - logger.info(f"从 X-Real-IP 获取IP: {ip}") - return ip - - # 2. 尝试从 X-Forwarded-For 获取(处理代理链) - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') - if x_forwarded_for: - # 可能有多个代理IP,第一个是真实IP - ips = x_forwarded_for.split(',') - for possible_ip in ips: - possible_ip = possible_ip.strip() - if possible_ip and possible_ip.lower() != 'unknown': - ip = possible_ip - logger.info(f"从 X-Forwarded-For 获取IP: {ip}") - return ip - - # 3. 使用 remote_addr - ip = request.META.get('REMOTE_ADDR', '') - logger.info(f"从 REMOTE_ADDR 获取IP: {ip}") - - except Exception as e: - logger.error(f"获取IP失败: {str(e)}", exc_info=True) - ip = '0.0.0.0' - - return ip - - @staticmethod - def validate_ip(ip): - """验证IP格式是否合法""" - if not ip: - return False - - # IPv4正则 - ipv4_pattern = r'^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$' - - # IPv6正则(简化版) - ipv6_pattern = r'^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$' - - return bool(re.match(ipv4_pattern, ip) or re.match(ipv6_pattern, ip)) - - @staticmethod - def is_private_ip(ip): - """判断是否为内网IP""" - if not ip: - return False - - # 内网IP段 - private_ranges = [ - ('10.0.0.0', '10.255.255.255'), - ('172.16.0.0', '172.31.255.255'), - ('192.168.0.0', '192.168.255.255'), - ('127.0.0.0', '127.255.255.255'), - ] - - try: - # 将IP转换为整数比较 - def ip_to_int(ip): - parts = ip.split('.') - return (int(parts[0]) << 24) + (int(parts[1]) << 16) + (int(parts[2]) << 8) + int(parts[3]) - - ip_num = ip_to_int(ip) - for start, end in private_ranges: - if ip_to_int(start) <= ip_num <= ip_to_int(end): - return True - except: - pass - - return False - - -class OrderLinkThrottle(SimpleRateThrottle): - """订单链接访问频率限制""" - - scope = 'order_link' - rate = '10/minute' # 每分钟最多10次 - - def get_cache_key(self, request, view): - # 基于IP和接口路径进行限制 - ip = IPUtils.get_client_ip(request) - if not ip or IPUtils.is_private_ip(ip): - # 内网IP或不合法IP,使用默认 - ip = 'default' - - return f'throttle_{view.__class__.__name__}_{ip}' - - - - - - - - -class KehuGetDingdanLianjieView(APIView): - """ - 客户获取订单链接信息接口 - 路径:GET /peizhi/khhqddlj?token=xxx - 返回字段包括:订单信息、打手标识、商家标识、俱乐部配置(客服链接、存储桶、GoEasy AppKey等) - """ - - throttle_classes = [OrderLinkThrottle] - permission_classes = [] # 无需认证,任何人都可访问 - - def get(self, request): - """ - 处理GET请求,获取订单链接信息 - """ - # 1. 记录请求开始 - logger.info("=== 客户获取订单链接信息接口开始 ===") - - # 2. 获取客户端真实IP - client_ip = IPUtils.get_client_ip(request) - logger.info(f"客户端IP: {client_ip}") - - # 3. 验证IP合法性 - if not IPUtils.validate_ip(client_ip): - logger.warning(f"IP格式不合法: {client_ip}") - return Response({ - 'code': 400, - 'msg': '网络连接异常', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 4. 获取Token参数 - token = request.query_params.get('token', '') - if not token: - logger.warning(f"Token参数缺失 - IP: {client_ip}") - return Response({ - 'code': 400, - 'msg': '缺少链接参数', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - logger.info(f"接收到Token: {token[:8]}...") - - # 5. 查询链接记录 - try: - lianjie_obj = ShangjiaLianjie.query.select_related().get(LinkToken=token) - logger.info(f"找到链接记录 - 链接ID: {lianjie_obj.id}, 商家ID: {lianjie_obj.UserID}") - except ShangjiaLianjie.DoesNotExist: - logger.warning(f"链接不存在 - Token: {token[:8]}..., IP: {client_ip}") - return Response({ - 'code': 404, - 'msg': '链接不存在或已失效', - 'data': None - }, status=status.HTTP_404_NOT_FOUND) - except Exception as e: - logger.error(f"查询链接异常: {str(e)}", exc_info=True) - return Response({ - 'code': 500, - 'msg': '系统错误,请稍后重试', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - # 6. 更新访问者IP(无论是否已使用,都记录最新访问IP) - try: - lianjie_obj.UserIP = client_ip - lianjie_obj.save(update_fields=['UserIP']) - logger.info(f"更新访问者IP成功: {client_ip}") - except Exception as e: - logger.warning(f"更新IP失败: {str(e)}") - # 继续处理,不影响主要逻辑 - - # 7. 检查链接有效期 - '''now = timezone.now() - if lianjie_obj.ExpireTime and lianjie_obj.ExpireTime < now: - logger.warning(f"链接已过期 - 过期时间: {lianjie_obj.ExpireTime}, 当前: {now}") - return Response({ - 'code': 400, - 'msg': '链接已过期', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST)''' - - # 8. 准备返回数据 - response_data = { - 'is_used': lianjie_obj.is_used, - 'dingdan_id': lianjie_obj.OrderID or '', - 'youxiao_shijian': lianjie_obj.ExpireTime.strftime( - '%Y-%m-%d %H:%M:%S') if lianjie_obj.ExpireTime else '', - } - - # 9. 获取订单信息 - if lianjie_obj.OrderID: - try: - # 查询订单主表 - dingdan_obj = Order.query.get(OrderID=lianjie_obj.OrderID) - - # 获取商家昵称 - shangjia_nicheng = '' - try: - user_main = User.query.get(UserUID=lianjie_obj.UserID) - shangjia_obj = user_main.ShopProfile - shangjia_nicheng = shangjia_obj.nicheng - except (User.DoesNotExist, UserShangjia.DoesNotExist): - logger.warning(f"商家信息不存在 - 用户ID: {lianjie_obj.UserID}") - - # ========== 新增:确定俱乐部配置 ========== - # 默认头像(相对路径) - default_avatar = 'a_long/morentouxiang.jpg' - # 从 settings 获取配置 - kefu_link = getattr(settings, 'KEFU_LINK', '') - oss_url = getattr(settings, 'COS_DOMAIN', '') - goeasy_appkey = getattr(settings, 'GOEASY_APPKEY', '') - - # ========== 构建订单信息 ========== - dingdan_info = { - 'jieshao': dingdan_obj.Description or '', - 'shangjia_mingcheng': shangjia_nicheng, - 'zhuangtai': dingdan_obj.Status, - 'jiedan_dashou_id': dingdan_obj.PlayerID or '', - # 新增配置字段 - 'kefu_link': kefu_link, - 'oss_url': oss_url, - 'default_avatar': default_avatar, - 'goeasy_appkey': goeasy_appkey, - # 前端用于连接GoEasy的自身用户ID(固定B前缀+商家ID) - 'self_user_id': f"B{lianjie_obj.UserID}" if lianjie_obj.UserID else '', - } - - # 如果已使用,返回已填写的信息 - if lianjie_obj.is_used: - laoban_shouji = '' - try: - laoban_shouji = dingdan_obj.shangjia_kuozhan.BossPhone or '' - except Exception: - pass - dingdan_info.update({ - 'youxi_nicheng': dingdan_obj.Nickname or '', - 'zhiding_dashou': dingdan_obj.AssignedID or '', - 'beizhu': dingdan_obj.Remark or '', - 'waibu_dingdan_id': dingdan_obj.ExternalOrderID or '', - 'laoban_shouji': laoban_shouji, - }) - - # 获取聊天相关标识(新配对群优先,旧单保留 order 群) - chat_meta, _sub_ok = subscribe_merchant_link_chat(dingdan_obj, lianjie_obj.UserID) - - # 添加聊天标识 - dingdan_info.update({ - 'dashou_biaoshi': chat_meta.get('dashou_biaoshi', ''), - 'shangjia_biaoshi': chat_meta.get('shangjia_biaoshi', ''), - 'you_dashou': chat_meta.get('you_dashou', False), - 'group_id': chat_meta.get('group_id', ''), - 'is_pair_group': chat_meta.get('is_pair_group', False), - 'legacy_group_id': chat_meta.get('legacy_group_id', ''), - }) - - response_data.update(dingdan_info) - - except Order.DoesNotExist: - logger.error(f"订单不存在 - 订单ID: {lianjie_obj.OrderID}") - response_data.update({ - 'jieshao': '', - 'shangjia_mingcheng': '', - 'zhuangtai': None, - 'dashou_biaoshi': '', - 'shangjia_biaoshi': '', - 'you_dashou': False, - 'kefu_link': '', - 'oss_url': '', - 'default_avatar': 'a_long/morentouxiang.jpg', - 'goeasy_appkey': '', - 'self_user_id': '', - }) - except Exception as e: - logger.error(f"获取订单信息异常: {str(e)}", exc_info=True) - response_data.update({ - 'jieshao': '', - 'shangjia_mingcheng': '', - 'zhuangtai': None, - 'dashou_biaoshi': '', - 'shangjia_biaoshi': '', - 'you_dashou': False, - 'kefu_link': '', - 'oss_url': '', - 'default_avatar': 'a_long/morentouxiang.jpg', - 'goeasy_appkey': '', - 'self_user_id': '', - }) - else: - # 如果订单ID为空 - logger.warning(f"链接记录中订单ID为空 - 链接ID: {lianjie_obj.id}") - response_data.update({ - 'jieshao': '', - 'shangjia_mingcheng': '', - 'zhuangtai': None, - 'dashou_biaoshi': '', - 'shangjia_biaoshi': '', - 'you_dashou': False, - 'kefu_link': '', - 'oss_url': '', - 'default_avatar': 'a_long/morentouxiang.jpg', - 'goeasy_appkey': '', - 'self_user_id': '', - }) - - # 10. 判断订单状态是否允许修改 - if not lianjie_obj.is_used and response_data.get('zhuangtai') == 13: - response_data['allow_modify'] = True - else: - response_data['allow_modify'] = False - - # 11. 记录成功日志并返回 - logger.info( - f"返回订单信息成功 - Token: {token[:8]}..., IP: {client_ip}, " - f"是否允许修改: {response_data['allow_modify']}, " - f"是否有打手: {response_data.get('you_dashou', False)}" - ) - - return Response({ - 'code': 200, - 'msg': '成功', - 'data': response_data - }, status=status.HTTP_200_OK) - - - - - - - -class KehuTianxieDingdanView(APIView): - """ - 客户填写订单信息接口 - 路径:POST /peizhi/kehutx - """ - - # 添加频率限制(比查询接口更严格) - throttle_classes = [OrderLinkThrottle] - permission_classes = [] # 无需认证 - - def post(self, request): - """ - 处理POST请求,填写订单信息 - """ - # 1. 记录请求开始 - logger.info("=== 客户填写订单信息接口开始 ===") - - # 2. 获取客户端真实IP - client_ip = IPUtils.get_client_ip(request) - logger.info(f"客户端IP: {client_ip}") - - # 3. 验证IP合法性 - if not IPUtils.validate_ip(client_ip): - logger.warning(f"IP格式不合法: {client_ip}") - return Response({ - 'code': 400, - 'msg': '网络连接异常', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 4. 获取请求参数 - data = request.data - token = data.get('token', '') - youxi_nicheng = data.get('youxi_nicheng', '').strip() - zhiding_dashou = data.get('zhiding_dashou', '').strip() # 可选 - beizhu = data.get('beizhu', '').strip() # 可选 - waibu_dingdan_id = str(data.get('waibu_dingdan_id') or data.get('waibuDingdanId') or '').strip()[:64] - laoban_shouji = str(data.get('laoban_shouji') or data.get('bossPhone') or data.get('laobanPhone') or '').strip()[:11] - - - # 4.0 临时关闭指定功能(如需开启,请删除或注释掉下面两行) - #if zhiding_dashou: - #return Response({'code': 8, 'msg': '指定功能尚未开启,请不要填写指定打手ID', 'data': None}) - #if beizhu: - #return Response({'code': 8, 'msg': '备注功能尚未开启,请不要填写备注', 'data': None}) - - - logger.info( - f"请求参数 - Token: {token[:8]}..., 昵称: {youxi_nicheng[:10]}..., 指定打手: {zhiding_dashou}, 备注长度: {len(beizhu)}, 拼多多ID: {waibu_dingdan_id[:16] if waibu_dingdan_id else ''}") - - # 5. 验证必填参数 - if not token: - logger.warning(f"Token参数缺失 - IP: {client_ip}") - return Response({ - 'code': 400, - 'msg': '缺少链接参数', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - if not youxi_nicheng: - logger.warning(f"游戏昵称为空 - IP: {client_ip}") - return Response({ - 'code': 400, - 'msg': '游戏昵称不能为空', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - if not waibu_dingdan_id: - logger.warning(f"拼多多订单号为空 - IP: {client_ip}") - return Response({ - 'code': 400, - 'msg': '拼多多订单号不能为空', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - ok_pdd, msg_pdd = validate_pdd_order_id(waibu_dingdan_id) - if not ok_pdd: - logger.warning(f"拼多多订单号校验失败 - IP: {client_ip}, 值: {waibu_dingdan_id[:24]}") - return Response({ - 'code': 400, - 'msg': msg_pdd, - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - ok_phone, msg_phone = validate_cn_mobile(laoban_shouji, required=False) - if not ok_phone: - logger.warning(f"老板手机号校验失败 - IP: {client_ip}") - return Response({ - 'code': 400, - 'msg': msg_phone, - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 6. 验证游戏昵称格式 - if len(youxi_nicheng) > 50: - logger.warning(f"游戏昵称过长 - IP: {client_ip}, 长度: {len(youxi_nicheng)}") - return Response({ - 'code': 400, - 'msg': '游戏昵称不能超过50个字符', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 过滤危险字符 - danger_chars = ['<', '>', "'", '"', '&', ';', '|'] - for char in danger_chars: - if char in youxi_nicheng: - logger.warning(f"游戏昵称包含危险字符 - IP: {client_ip}, 字符: {char}") - return Response({ - 'code': 400, - 'msg': '昵称包含非法字符', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 7. 验证指定打手ID格式(如果提供) - if zhiding_dashou and len(zhiding_dashou) > 32: - logger.warning(f"指定打手ID过长 - IP: {client_ip}, 长度: {len(zhiding_dashou)}") - return Response({ - 'code': 400, - 'msg': '指定打手ID不能超过32个字符', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 8. 验证拼多多订单号长度(格式已在上方校验) - if len(waibu_dingdan_id) > 64: - return Response({ - 'code': 400, - 'msg': '拼多多订单号不能超过64个字符', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 9. 验证备注长度 - if len(beizhu) > 500: - logger.warning(f"备注过长 - IP: {client_ip}, 长度: {len(beizhu)}") - return Response({ - 'code': 400, - 'msg': '备注不能超过500个字符', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 9. 查询链接记录 - try: - lianjie_obj = ShangjiaLianjie.query.get(LinkToken=token) - logger.info( - f"找到链接记录 - 链接ID: {lianjie_obj.id}, 商家ID: {lianjie_obj.UserID}, 是否已使用: {lianjie_obj.is_used}") - except ShangjiaLianjie.DoesNotExist: - logger.warning(f"链接不存在 - Token: {token[:8]}..., IP: {client_ip}") - return Response({ - 'code': 404, - 'msg': '链接不存在或已失效', - 'data': None - }, status=status.HTTP_404_NOT_FOUND) - - # 10. 检查链接有效期 - now = timezone.now() - if lianjie_obj.ExpireTime and lianjie_obj.ExpireTime < now: - logger.warning(f"链接已过期 - 过期时间: {lianjie_obj.ExpireTime}") - return Response({ - 'code': 400, - 'msg': '链接已过期', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 11. 检查链接是否已使用 - if lianjie_obj.is_used: - logger.warning(f"链接已使用 - 链接ID: {lianjie_obj.id}, IP: {client_ip}") - return Response({ - 'code': 400, - 'msg': '链接已被使用,无法重复提交', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 12. 验证指定打手(如果需要) - zhuangtai_xin = 1 # 默认普通订单状态 - if zhiding_dashou: - # 验证打手是否存在且有效 - try: - # 先查询用户主表 - dashou_user = User.query.get(UserUID=zhiding_dashou) - logger.info(f"找到打手用户 - 用户ID: {zhiding_dashou}") - - # 通过反向关系获取打手扩展信息 - try: - dashou_profile = dashou_user.DashouProfile - - # 检查打手状态(zhuangtai) - if dashou_profile.zhuangtai != 1: - logger.warning(f"打手状态异常 - 打手ID: {zhiding_dashou}, 状态: {dashou_profile.zhuangtai}") - return Response({ - 'code': 400, - 'msg': '指定打手当前无法接单', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 检查打手账号状态(zhanghaozhuangtai) - if dashou_profile.zhanghaozhuangtai != 1: - logger.warning( - f"打手账号状态异常 - 打手ID: {zhiding_dashou}, 账号状态: {dashou_profile.zhanghaozhuangtai}") - return Response({ - 'code': 400, - 'msg': '指定打手账号异常', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 验证通过,设置为指定订单状态 - zhuangtai_xin = 7 # 指定打手订单状态 - logger.info(f"指定打手验证通过 - 打手ID: {zhiding_dashou}, 昵称: {dashou_profile.nicheng}") - - except UserDashou.DoesNotExist: - logger.warning(f"打手扩展信息不存在 - 用户ID: {zhiding_dashou}") - return Response({ - 'code': 400, - 'msg': '指定打手信息不完整', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - except User.DoesNotExist: - logger.warning(f"指定打手用户不存在 - 用户ID: {zhiding_dashou}") - return Response({ - 'code': 400, - 'msg': '指定打手不存在', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 13. 使用事务确保数据一致性 - try: - with transaction.atomic(): - # 更新链接记录 - lianjie_obj.UserIP = client_ip - lianjie_obj.is_used = True - lianjie_obj.save(update_fields=['UserIP', 'is_used']) - logger.info(f"更新链接记录成功 - 链接ID: {lianjie_obj.id}, 设置为已使用") - - # 查询订单 - if not lianjie_obj.OrderID: - logger.error(f"链接记录中订单ID为空 - 链接ID: {lianjie_obj.id}") - raise ValueError("订单ID为空") - - dingdan_obj = Order.query.get(OrderID=lianjie_obj.OrderID) - - # 检查订单状态是否为13(灰色状态) - if dingdan_obj.Status != 13: - logger.warning( - f"订单状态不允许修改 - 订单ID: {dingdan_obj.OrderID}, 当前状态: {dingdan_obj.Status}") - # 回滚事务 - transaction.set_rollback(True) - return Response({ - 'code': 400, - 'msg': '订单状态异常,无法修改', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 更新订单信息 - dingdan_obj.Nickname = youxi_nicheng # 游戏昵称 - dingdan_obj.Remark = beizhu if beizhu else '' # 备注 - dingdan_obj.ExternalOrderID = waibu_dingdan_id - dingdan_obj.Status = zhuangtai_xin # 新状态:1或7 - - # 如果是指定打手,设置zhiding_id - if zhiding_dashou and zhuangtai_xin == 7: - dingdan_obj.AssignedID = zhiding_dashou - - dingdan_obj.save() - logger.info( - f"更新订单成功 - 订单ID: {dingdan_obj.OrderID}, 新状态: {zhuangtai_xin}, 指定打手: {zhiding_dashou or '无'}") - - try: - shangjia_ext = dingdan_obj.shangjia_kuozhan - shangjia_ext.BossPhone = laoban_shouji - shangjia_ext.save(update_fields=['BossPhone']) - except MerchantOrderExt.DoesNotExist: - logger.warning(f"商家扩展表不存在,无法保存手机号 - 订单: {dingdan_obj.OrderID}") - - # 构建通知数据(你的 _get_game_type_name 方法保留在视图里直接用) - order_info = { - 'dingdan_id': dingdan_obj.OrderID, - 'game_type': self._get_game_type_name(dingdan_obj.ProductTypeID), - 'amount': str(dingdan_obj.Amount), - 'order_desc': (dingdan_obj.Description or '')[:50], - } - dingdan_guangbo.delay(order_info) # 瞬间返回,不阻塞请求 - - # 获取商家信息用于返回 - try: - user_main = User.query.get(UserUID=lianjie_obj.UserID) - shangjia_obj = user_main.ShopProfile - shangjia_mingcheng = shangjia_obj.nicheng - except: - shangjia_mingcheng = f"商家{lianjie_obj.UserID}" - logger.warning(f"获取商家昵称失败,使用默认 - 用户ID: {lianjie_obj.UserID}") - - # 14. 返回成功响应 - response_data = { - #'dingdan_id': dingdan_obj.dingdan_id, - #'CreateTime': dingdan_obj.CreateTime.strftime('%Y-%m-%d %H:%M:%S'), - #'shangpin_mingcheng': dingdan_obj.jieshao[:50] if dingdan_obj.jieshao else '', # 截取前50字符 - #'jiage': str(dingdan_obj.jine) if dingdan_obj.jine else '0.00', - 'shangjia_mingcheng': shangjia_mingcheng, - 'youxi_nicheng': youxi_nicheng, - 'zhuangtai': zhuangtai_xin, - 'zhuangtai_text': '普通订单' if zhuangtai_xin == 1 else '指定打手订单' - } - - logger.info(f"订单提交成功 - 订单ID: {dingdan_obj.OrderID}, IP: {client_ip}") - - # 新方法(行为驱动,更安全) - update_shangjia_daily( - yonghuid=lianjie_obj.UserID, - amount=Decimal(str(dingdan_obj.Amount)), - action=1, # 1 = 派发 - order_id=dingdan_obj.OrderID, - ) - - return Response({ - 'code': 200, - 'msg': '提交成功', - 'data': response_data - }, status=status.HTTP_200_OK) - - except Order.DoesNotExist: - logger.error(f"订单不存在 - 订单ID: {lianjie_obj.OrderID}") - return Response({ - 'code': 500, - 'msg': '订单数据异常', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - except Exception as e: - logger.error(f"处理订单异常: {str(e)}", exc_info=True) - return Response({ - 'code': 500, - 'msg': '系统错误,请稍后重试', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - def _get_game_type_name(self, leixing_id): - try: - gt = ShangpinLeixing.query.filter(id=leixing_id).first() - return gt.jieshao if gt else '游戏订单' - except Exception as e: - logger.error(f"获取游戏类型名称异常: {e}") - return '游戏订单' - - def send_order_notification_async(self, dingdan, youxi_nicheng, zhiding_dashou, zhuangtai): - """异步发送订单通知(客户填写订单后)""" - try: - # 避免阻塞主线程,使用线程异步执行 - def notification_task(): - try: - # 获取游戏类型名称 - try: - leixing_obj = ShangpinLeixing.query.get(id=dingdan.ProductTypeID) - game_type = leixing_obj.jieshao - except: - game_type = '未知游戏' - - # 构建订单信息 - order_info = { - 'dingdan_id': dingdan.OrderID, - 'game_type': game_type, - 'amount': str(dingdan.Amount), - 'order_desc': dingdan.Description[:50] if dingdan.Description else '新订单', - 'youxi_nicheng': youxi_nicheng, - 'order_type': '指定订单' if zhuangtai == 7 else '普通订单', - 'zhiding_dashou': zhiding_dashou or '' - } - - # 调用微信广播发送器 - sender = WeixinBroadcastSender() - result = sender.broadcast_order(order_info) - - if result['success']: - logger.info(f"【客户填写订单】微信通知发送成功: {result['msg']}") - else: - logger.warning(f"【客户填写订单】微信通知发送失败: {result['msg']}") - - except Exception as e: - logger.error(f"【客户填写订单】发送通知任务异常: {str(e)}") - - # 启动异步线程 - thread = threading.Thread(target=notification_task) - thread.daemon = True - thread.start() - - except Exception as e: - logger.error(f"【客户填写订单】启动通知线程异常: {str(e)}") - - - - - - -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 - - - - -class PopupConfigView(APIView): - """ - 获取指定页面的弹窗配置 - 请求格式:POST { "pageKey": "dashouzhongxin" } - 返回格式:{ "code": 0, "data": { "serverTime": "...", "popups": [...] } } - """ - authentication_classes = [] - permission_classes = [AllowAny] - - def post(self, request): - # 获取参数 pageKey - page_key = request.data.get('pageKey') - if not page_key: - return Response({ - 'code': 400, - 'msg': '缺少参数 pageKey' - }, status=status.HTTP_400_BAD_REQUEST) - - # 3. 查询页面配置(按俱乐部) - from jituan.services.display_config import get_gonggao_content - from jituan.services.club_context import resolve_club_id_from_request - - club_id = resolve_club_id_from_request(request) - try: - popup_page = PopupPage.query.get(club_id=club_id, page_key=page_key, is_active=True) - except PopupPage.DoesNotExist: - # 该页面没有配置弹窗,返回空 - return Response({ - 'code': 0, - 'data': { - 'serverTime': timezone.now().isoformat(), - 'popups': [] - } - }) - - # 4. 查询该页面下所有启用的弹窗(且在有效时间范围内) - now = timezone.now() - popups = PopupConfig.query.filter( - page=popup_page, - is_active=True, - start_time__lte=now, - end_time__gte=now - ).prefetch_related('images') # 预加载图片,避免N+1查询 - # 按 sort_order 排序(model Meta 已定义) - - # 5. 序列化 - serializer = PopupConfigSerializer(popups, many=True) - - # 6. 返回数据 - return Response({ - 'code': 0, - 'data': { - 'serverTime': now.isoformat(), - 'ignore_user_mute': popup_page.ignore_user_mute, # 新增 - 'popups': serializer.data - } - }) - - - - -class GetWithdrawModeView(APIView): - """ - 获取提现模式配置 - 前端请求 /peizhi/hqtxzsym - 返回格式: { "code": 0, "data": { "mode": 1 or 2 } } - """ - permission_classes = [IsAuthenticated] # JWT 认证 - - def post(self, request, *args, **kwargs): - from jituan.services.club_context import resolve_club_id_from_request - from jituan.services.club_user import get_user_club_id - from jituan.services.withdraw_config import get_withdraw_mode - - user = getattr(request, 'user', None) - club_id = get_user_club_id(user) if user and getattr(user, 'is_authenticated', False) else None - if not club_id: - club_id = resolve_club_id_from_request(request) - mode = get_withdraw_mode(club_id) - - return Response({ - 'code': 0, - 'data': { - 'mode': mode, - 'club_id': club_id, - } - }) - - - - - -class CheckPhoneAuthView(APIView): - """ - 判断当前登录用户是否需要强制手机号认证 - - 规则: - - 已认证过手机号(PhoneVerified=True) → 不需要 - - 未认证时,满足以下任一条件 → 需要认证: - 打手:有会员购买记录,或押金 > 0 - 商家:已是商家(存在商家扩展表) - 管事:已是管事(存在管事扩展表) - 组长:已是组长(存在组长扩展表) - 老板:有下单记录(alldingdan > 0) - 子客服:已是有效商家客服(merchant_staff_member 活跃) - """ - - permission_classes = [IsAuthenticated] - - def post(self, request): - user = request.user - - if getattr(user, 'PhoneVerified', False): - return Response({'code': 0, 'need_auth': False}) - - if self._dashou_meets_threshold(user): - return Response({'code': 0, 'need_auth': True}) - if self._shangjia_meets_threshold(user): - return Response({'code': 0, 'need_auth': True}) - if self._guanshi_meets_threshold(user): - return Response({'code': 0, 'need_auth': True}) - if self._zuzhang_meets_threshold(user): - return Response({'code': 0, 'need_auth': True}) - if self._boss_meets_threshold(user): - return Response({'code': 0, 'need_auth': True}) - if self._staff_meets_threshold(user): - return Response({'code': 0, 'need_auth': True}) - - return Response({'code': 0, 'need_auth': False}) - - def _staff_meets_threshold(self, user): - """子客服:已是有效商家客服""" - try: - from merchant_ops.services.authz import get_active_staff_member - return get_active_staff_member(user) is not None - except Exception: - return False - - def _dashou_meets_threshold(self, user): - """打手:有会员记录、押金 > 0、或有过接单/成交订单""" - try: - dashou = UserDashou.query.get(user=user) - except UserDashou.DoesNotExist: - return False - if dashou.yajin and dashou.yajin > 0: - return True - from jituan.services.club_context import resolve_effective_club_id - club_id = resolve_effective_club_id(self.request, user) - if Huiyuangoumai.query.filter(yonghu_id=user.UserUID, club_id=club_id).exists(): - return True - if (dashou.jiedanzongliang or 0) > 0 or (dashou.chengjiaozongliang or 0) > 0: - return True - return Order.query.filter(PlayerID=user.UserUID).exists() - - def _shangjia_meets_threshold(self, user): - """商家:已是商家身份""" - return UserShangjia.query.filter(user=user).exists() - - def _guanshi_meets_threshold(self, user): - """管事:已是管事身份""" - return UserGuanshi.query.filter(user=user).exists() - - def _zuzhang_meets_threshold(self, user): - """组长:已是组长身份""" - return UserZuzhang.query.filter(user=user).exists() - - def _boss_meets_threshold(self, user): - """老板:有下单记录""" - try: - boss = UserBoss.query.get(user=user) - except UserBoss.DoesNotExist: - return False - return (boss.alldingdan or 0) > 0 - - -# ---------- 小程序客服话术 / 自动回复 ---------- - -MINIAPP_SCRIPT_DEFAULTS = { - 'cs_welcome': { - 'title': '', - 'content': '你好,请问有什么可以帮到您的?', - 'confirm_text': '', - 'cancel_text': '', - }, - 'chat_image_confirm': { - 'title': '温馨提示', - 'content': '平台禁止任何违法违规行为,聊天内容仅限于商家与接单员正常业务对接。严禁赌博、诈骗及其他非法行为,违者后果自负。', - 'confirm_text': '确认', - 'cancel_text': '取消', - }, - 'merchant_dispatch_confirm': { - 'title': '确认派单', - 'content': '确认向该打手派发此订单?', - 'confirm_text': '确认', - 'cancel_text': '取消', - }, -} - - -def _resolve_script_club_id(request): - from jituan.services.club_context import resolve_club_id_from_request - from jituan.services.club_user import get_user_club_id - user = getattr(request, 'user', None) - club_id = get_user_club_id(user) if user and getattr(user, 'is_authenticated', False) else None - if not club_id: - club_id = resolve_club_id_from_request(request) - return club_id or 'xq' - - -def _merge_script_scene(scene_key, row): - base = dict(MINIAPP_SCRIPT_DEFAULTS.get(scene_key) or {}) - if not row: - return base - if row.title: - base['title'] = row.title - if row.content: - base['content'] = row.content - if row.confirm_text: - base['confirm_text'] = row.confirm_text - if row.cancel_text: - base['cancel_text'] = row.cancel_text - return base - - -class HuashuHqView(APIView): - """POST /peizhi/huashuhq 批量获取场景话术""" - permission_classes = [IsAuthenticated] - - def post(self, request): - scene_keys = request.data.get('scene_keys') or [] - if not isinstance(scene_keys, list) or not scene_keys: - return Response({'code': 400, 'msg': 'scene_keys 不能为空', 'data': None}, status=400) - club_id = _resolve_script_club_id(request) - rows = MiniappScriptScene.query.filter( - club_id=club_id, scene_key__in=scene_keys, enabled=True, - ) - row_map = {r.scene_key: r for r in rows} - data = {} - for key in scene_keys: - if not isinstance(key, str) or not key.strip(): - continue - data[key.strip()] = _merge_script_scene(key.strip(), row_map.get(key.strip())) - return Response({'code': 200, 'msg': 'success', 'data': data}) - - -class HuashuMatchView(APIView): - """POST /peizhi/huashu_match 客服关键词自动回复匹配""" - permission_classes = [IsAuthenticated] - - def post(self, request): - scene_key = (request.data.get('scene_key') or '').strip() - user_text = (request.data.get('user_text') or '').strip() - if scene_key != 'cs_auto_reply': - return Response({'code': 400, 'msg': '不支持的 scene_key', 'data': {'matched': False, 'content': ''}}) - if not user_text: - return Response({'code': 200, 'msg': 'success', 'data': {'matched': False, 'content': ''}}) - club_id = _resolve_script_club_id(request) - rules = MiniappScriptAutoReply.query.filter( - club_id=club_id, enabled=True, - ).order_by('-sort_order', 'id') - text_lower = user_text.lower() - for rule in rules: - kw_raw = (rule.keyword or '').strip() - if not kw_raw: - continue - keywords = [k.strip() for k in kw_raw.split(',') if k.strip()] - for kw in keywords: - if kw.lower() in text_lower or kw in user_text: - reply_type = rule.reply_type or MiniappScriptAutoReply.REPLY_TEXT - return Response({ - 'code': 200, - 'msg': 'success', - 'data': { - 'matched': True, - 'content': rule.reply_content or '', - 'reply_type': reply_type, - }, - }) - return Response({'code': 200, 'msg': 'success', 'data': {'matched': False, 'content': ''}}) - - -# ---------- 后台:客服话术配置(/houtai/hthqhs、/houtai/htxghs) ---------- - -SCRIPT_CONFIG_PERMS = ('kfhs666', '8080a') -SCRIPT_SCENE_KEYS = ('chat_image_confirm', 'cs_welcome', 'merchant_dispatch_confirm') - - -def _has_script_config_perm(permissions): - return any(code in permissions for code in SCRIPT_CONFIG_PERMS) - - -def _script_scene_item_type(scene_key): - return 'text_display' if scene_key == 'cs_welcome' else 'confirm_modal' - - -def _serialize_script_scene(row): - return { - 'id': row.id, - 'scene_key': row.scene_key, - 'item_type': _script_scene_item_type(row.scene_key), - 'title': row.title or '', - 'content': row.content or '', - 'confirm_text': row.confirm_text or '', - 'cancel_text': row.cancel_text or '', - 'is_active': bool(row.enabled), - } - - -def _serialize_script_auto_reply(row): - keywords = [k.strip() for k in (row.keyword or '').split(',') if k.strip()] - return { - 'id': row.id, - 'scene_key': 'cs_auto_reply', - 'item_type': 'auto_reply', - 'title': '', - 'keywords': keywords, - 'match_mode': 'contains', - 'priority': row.sort_order or 0, - 'content': row.reply_content or '', - 'is_active': bool(row.enabled), - } - - -class MiniappScriptHoutaiListView(APIView): - """POST /houtai/hthqhs 后台获取话术与自动回复配置""" - permission_classes = [IsAuthenticated] - - def post(self, request): - from backend.utils import verify_kefu_permission - - username = request.data.get('username') - kefu, permissions = verify_kefu_permission(request, username) - if kefu is None: - return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) - if not _has_script_config_perm(permissions): - return Response({'code': 403, 'msg': '您没有权限访问客服聊天配置'}) - - club_id = _resolve_script_club_id(request) - scenes = MiniappScriptScene.query.filter( - club_id=club_id, scene_key__in=SCRIPT_SCENE_KEYS, - ).order_by('scene_key') - auto_rules = MiniappScriptAutoReply.query.filter(club_id=club_id).order_by('-sort_order', 'id') - - items = [_serialize_script_scene(r) for r in scenes] - items.extend(_serialize_script_auto_reply(r) for r in auto_rules) - return Response({'code': 0, 'data': {'items': items, 'club_id': club_id}}) - - -class MiniappScriptHoutaiModifyView(APIView): - """POST /houtai/htxghs 后台增删改话术与自动回复""" - permission_classes = [IsAuthenticated] - - def post(self, request): - from backend.utils import verify_kefu_permission - from jituan.services.display_config import forbid_display_write_in_all_scope - - username = request.data.get('username') - kefu, permissions = verify_kefu_permission(request, username) - if kefu is None: - return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) - if not _has_script_config_perm(permissions): - return Response({'code': 403, 'msg': '您没有权限操作客服聊天配置'}) - - action = (request.data.get('action') or '').strip() - if not action: - return Response({'code': 400, 'msg': '缺少 action 参数'}) - - club_id = _resolve_script_club_id(request) - - if action == 'delete': - return self._delete_item(request, club_id) - - deny = forbid_display_write_in_all_scope(request) - if deny: - return deny - - if action in ('create', 'update'): - item_type = (request.data.get('item_type') or '').strip() - if item_type == 'auto_reply': - return self._save_auto_reply(request, club_id, action) - return self._save_scene(request, club_id, action) - - return Response({'code': 400, 'msg': f'未知的 action: {action}'}) - - def _save_scene(self, request, club_id, action): - scene_key = (request.data.get('scene_key') or '').strip() - if scene_key not in SCRIPT_SCENE_KEYS: - return Response({'code': 400, 'msg': '不支持的 scene_key'}) - - content = (request.data.get('content') or '').strip() - if not content: - return Response({'code': 400, 'msg': '请填写正文内容'}) - - fields = { - 'title': (request.data.get('title') or '').strip(), - 'content': content, - 'confirm_text': (request.data.get('confirm_text') or '').strip(), - 'cancel_text': (request.data.get('cancel_text') or '').strip(), - 'enabled': bool(request.data.get('is_active', True)), - } - - if action == 'update': - row_id = request.data.get('id') - if not row_id: - return Response({'code': 400, 'msg': '缺少 id'}) - row = MiniappScriptScene.query.filter(id=row_id, club_id=club_id).first() - if not row: - return Response({'code': 404, 'msg': '配置不存在'}) - for k, v in fields.items(): - setattr(row, k, v) - row.save() - return Response({'code': 0, 'msg': '保存成功', 'data': _serialize_script_scene(row)}) - - existing = MiniappScriptScene.query.filter(club_id=club_id, scene_key=scene_key).first() - if existing: - for k, v in fields.items(): - setattr(existing, k, v) - existing.save() - return Response({'code': 0, 'msg': '保存成功', 'data': _serialize_script_scene(existing)}) - - row = MiniappScriptScene.query.create(club_id=club_id, scene_key=scene_key, **fields) - return Response({'code': 0, 'msg': '创建成功', 'data': _serialize_script_scene(row)}) - - def _save_auto_reply(self, request, club_id, action): - keywords = request.data.get('keywords') or [] - if not isinstance(keywords, list) or not keywords: - return Response({'code': 400, 'msg': '请至少添加一个触发关键词'}) - - kw_list = [str(k).strip() for k in keywords if str(k).strip()] - if not kw_list: - return Response({'code': 400, 'msg': '请至少添加一个触发关键词'}) - - content = (request.data.get('content') or '').strip() - if not content: - return Response({'code': 400, 'msg': '请填写回复内容'}) - - fields = { - 'keyword': ','.join(kw_list), - 'reply_content': content, - 'sort_order': int(request.data.get('priority') or 0), - 'enabled': bool(request.data.get('is_active', True)), - 'reply_type': MiniappScriptAutoReply.REPLY_TEXT, - } - - if action == 'update': - row_id = request.data.get('id') - if not row_id: - return Response({'code': 400, 'msg': '缺少 id'}) - row = MiniappScriptAutoReply.query.filter(id=row_id, club_id=club_id).first() - if not row: - return Response({'code': 404, 'msg': '规则不存在'}) - for k, v in fields.items(): - setattr(row, k, v) - row.save() - return Response({'code': 0, 'msg': '保存成功', 'data': _serialize_script_auto_reply(row)}) - - row = MiniappScriptAutoReply.query.create(club_id=club_id, **fields) - return Response({'code': 0, 'msg': '创建成功', 'data': _serialize_script_auto_reply(row)}) - - def _delete_item(self, request, club_id): - from jituan.services.display_config import forbid_display_write_in_all_scope - - deny = forbid_display_write_in_all_scope(request) - if deny: - return deny - - row_id = request.data.get('id') - if not row_id: - return Response({'code': 400, 'msg': '缺少 id'}) - - row = MiniappScriptAutoReply.query.filter(id=row_id, club_id=club_id).first() - if row: - row.delete() - return Response({'code': 0, 'msg': '删除成功'}) - - row = MiniappScriptScene.query.filter(id=row_id, club_id=club_id).first() - if row: - return Response({'code': 400, 'msg': '场景话术不支持删除,请改为禁用'}) - - return Response({'code': 404, 'msg': '记录不存在'}) \ No newline at end of file diff --git a/config/views/__init__.py b/config/views/__init__.py new file mode 100644 index 0000000..7ebfc55 --- /dev/null +++ b/config/views/__init__.py @@ -0,0 +1,74 @@ +""" +配置应用视图聚合模块(config/views/__init__.py) + +本模块原为 config/views.py(3718 行单文件),已按功能组拆分到 config/views/ 子包。 +本文件作为聚合 re-export 入口,重新导出所有视图类与公共符号。 +外部引用(config/urls.py、backend/urls.py、merchant_ops/views_templates.py)保持不变。 + +功能组拆分: +- miniapp_config 小程序动态配置(动态配置/海报/弹窗/提现方式/手机号认证) +- admin_config 管理员配置(商品公告/配置查询/图片上传/群与利率更新) +- admin_stats 管理员统计/记录(打手排行/账号查询/修改记录) +- merchant_templates 商家订单模板 +- merchant_links 商家链接/客户订单(含 IPUtils、OrderLinkThrottle) +- qrcode_poster 二维码/海报(关注/管事二维码/组长海报) +- scripts 话术配置(含辅助函数) +""" +import logging + +# 各功能组视图 +from .miniapp_config import ( + GetDynamicConfigView, HaibaoPeizhiView, PopupConfigView, + GetWithdrawModeView, CheckPhoneAuthView, +) +from .admin_config import ( + ShangpinGonggaoView, AdminConfigQueryView, AdminUploadImageView, + AdminUpdateConfigView, +) +from .admin_stats import ( + DashouPaihangView, GetAdminZhanghaoView, GetXiugaiJiluView, +) +from .merchant_templates import ( + ShangjiaMobanListView, ShangjiaTianjiaMobanView, + ShangjiaShanchuMobanView, ShangjiaGengxinMobanView, +) +from .merchant_links import ( + ShangjiaGenerateLinkView, ShangjiaLianjieListView, + KehuGetDingdanLianjieView, KehuTianxieDingdanView, + IPUtils, OrderLinkThrottle, +) +from .qrcode_poster import ( + GuanZhuAListView, GuanshiQRCodeView, ZuzhangHaibaoView, +) +from .scripts import ( + HuashuHqView, HuashuMatchView, + MiniappScriptHoutaiListView, MiniappScriptHoutaiModifyView, +) + +# 模块级符号(保持向后兼容) +logger = logging.getLogger(__name__) + +__all__ = [ + # miniapp_config + 'GetDynamicConfigView', 'HaibaoPeizhiView', 'PopupConfigView', + 'GetWithdrawModeView', 'CheckPhoneAuthView', + # admin_config + 'ShangpinGonggaoView', 'AdminConfigQueryView', 'AdminUploadImageView', + 'AdminUpdateConfigView', + # admin_stats + 'DashouPaihangView', 'GetAdminZhanghaoView', 'GetXiugaiJiluView', + # merchant_templates + 'ShangjiaMobanListView', 'ShangjiaTianjiaMobanView', + 'ShangjiaShanchuMobanView', 'ShangjiaGengxinMobanView', + # merchant_links + 'ShangjiaGenerateLinkView', 'ShangjiaLianjieListView', + 'KehuGetDingdanLianjieView', 'KehuTianxieDingdanView', + 'IPUtils', 'OrderLinkThrottle', + # qrcode_poster + 'GuanZhuAListView', 'GuanshiQRCodeView', 'ZuzhangHaibaoView', + # scripts + 'HuashuHqView', 'HuashuMatchView', + 'MiniappScriptHoutaiListView', 'MiniappScriptHoutaiModifyView', + # 公共符号 + 'logger', +] diff --git a/config/views/admin_config.py b/config/views/admin_config.py new file mode 100644 index 0000000..9158179 --- /dev/null +++ b/config/views/admin_config.py @@ -0,0 +1,612 @@ +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 ShangpinGonggaoView(APIView): + """ + 商品公告和轮播图接口 + 获取类型为1的公告和轮播图 + """ + throttle_classes = [AnonRateThrottle] + permission_classes = [AllowAny] + authentication_classes = [] + + def post(self, request): + """ + 处理POST请求,返回公告和轮播图数据 + + 请求: POST /api/peizhi/shangpingonggao/ + 返回: { + "shangpingonggao": "公告内容字符串", + "shangpinlunbo": ["轮播图URL1", "轮播图URL2"] + } + """ + try: + logger.info("收到商品公告和轮播图请求") + from jituan.services.display_config import get_gonggao_content, get_lunbo_urls, normalize_page_key + + page_key = normalize_page_key(request.data.get('page_key'), image_type=1) + gonggao_content = get_gonggao_content(request, notice_type=1, page_key=page_key) + lunbo_urls = get_lunbo_urls(request, page_key=page_key, image_type=1) + response_data = { + "shangpingonggao": gonggao_content, + "shangpinlunbo": lunbo_urls, + "page_key": page_key, + } + logger.info(f"返回数据:公告长度{len(gonggao_content)},轮播图数量{len(lunbo_urls)} page_key={page_key}") + # 4. 返回响应 + return Response(response_data, status=status.HTTP_200_OK) + except Exception as e: + # 记录异常 + logger.error(f"获取商品公告和轮播图时发生错误:{str(e)}") + # 返回错误响应 + error_data = { + "error": "服务器内部错误", + "detail": "获取数据失败,请稍后重试" + } + return Response(error_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + + +class AdminConfigQueryView(APIView): + """ + 管理员获取系统配置接口 + URL: /peizhi/adpzhq + 方法: POST + 权限: JWT Token认证 + 管理员权限 + 参数: zhanghao (管理员账号) + """ + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + try: + # 获取前端传递的账号 + zhanghao = request.data.get('zhanghao') + if not zhanghao: + return Response({ + 'code': 1, + 'msg': '账号不能为空', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 获取当前用户(User实例) + user_main = request.user + + # 验证账号:比较user_main的phone与前端传递的zhanghao + if user_main.Phone != zhanghao: + return Response({ + 'code': 1, + 'msg': '账号不匹配', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + + # 验证用户类型是否为管理员 + if user_main.UserType != 'admin': + return Response({ + 'code': 1, + 'msg': '无权限', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + + # 验证管理员扩展表是否存在 + try: + admin_profile = user_main.AdminProfile + except AdminProfile.DoesNotExist: + return Response({ + 'code': 1, + 'msg': '无权限', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + + # 查询图片配置 + rule_image = Tupianpeizhi.query.filter(ImageType=1).first() + default_avatar = Tupianpeizhi.query.filter(ImageType=2).first() + + # 查询群配置 + qun_configs = Qunpeizhi.query.all() + qq_groups = [] + for config in qun_configs: + qq_groups.append({ + 'peizhiid': config.GroupType, + 'neirong': config.GroupContent if config.GroupContent else '', + 'qunid': config.GroupID if config.GroupID else '', + 'jieshao': config.Description if config.Description else '' + }) + + # 查询利率配置 + platform_rate_obj = CommissionRate.query.filter(Platform='1').first() + shop_rate_obj = CommissionRate.query.filter(Platform='3').first() + # 🔴【新增】打手提现抽成利率(字符5) + dashou_withdraw_obj = CommissionRate.query.filter(Platform='5').first() + # 🔴【新增】管事提现抽成利率(字符6) + guanshi_withdraw_obj = CommissionRate.query.filter(Platform='6').first() + + # 构建返回数据 + data = { + 'images': { + 'rule_image': rule_image.ImageURL if rule_image and rule_image.ImageURL else '', + 'default_avatar': default_avatar.ImageURL if default_avatar and default_avatar.ImageURL else '' + }, + 'qq_groups': qq_groups, + 'rates': { + 'platform_rate': float(platform_rate_obj.Rate) if platform_rate_obj and platform_rate_obj.Rate else 0.0, + 'shop_rate': float(shop_rate_obj.Rate) if shop_rate_obj and shop_rate_obj.Rate else 0.0, + # 🔴【新增】打手提现抽成利率 + 'dashou_withdraw_rate': float( + dashou_withdraw_obj.Rate) if dashou_withdraw_obj and dashou_withdraw_obj.Rate else 0.0, + # 🔴【新增】管事提现抽成利率 + 'guanshi_withdraw_rate': float( + guanshi_withdraw_obj.Rate) if guanshi_withdraw_obj and guanshi_withdraw_obj.Rate else 0.0 + + } + } + + return Response({ + 'code': 0, + 'msg': 'success', + 'data': data + }) + + except Exception as e: + return Response({ + 'code': 99, + 'msg': f'系统错误: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class AdminUploadImageView(APIView): + """ + 管理员上传配置图片接口 + URL: /peizhi/adxgtp + 方法: POST + 权限: JWT Token认证 + 管理员权限 + 参数: zhanghao, leixing (1:打手规则图片, 2:默认头像图片), image (文件) + """ + permission_classes = [permissions.IsAuthenticated] + parser_classes = (MultiPartParser, FormParser) + + def post(self, request): + try: + zhanghao = request.data.get('zhanghao') + leixing = request.data.get('leixing') + image_file = request.FILES.get('image') + + if not zhanghao: + return Response({ + 'code': 1, + 'msg': '账号不能为空', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + if not leixing: + return Response({ + 'code': 2, + 'msg': '图片类型不能为空', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + if not image_file: + return Response({ + 'code': 3, + 'msg': '请选择图片文件', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 验证用户权限 + user_main = request.user + if user_main.Phone != zhanghao: + return Response({ + 'code': 1, + 'msg': '账号不匹配', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + + if user_main.UserType != 'admin': + return Response({ + 'code': 1, + 'msg': '无权限', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + + try: + admin_profile = user_main.AdminProfile + except AdminProfile.DoesNotExist: + return Response({ + 'code': 1, + 'msg': '无权限', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + + # 验证图片类型 + if leixing not in ['1', '2']: + return Response({ + 'code': 4, + 'msg': '图片类型错误', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 验证图片文件 + is_valid, error_msg = validate_image(image_file) + if not is_valid: + return Response({ + 'code': 5, + 'msg': f'图片验证失败: {error_msg}', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 根据类型确定固定相对URL和文件名 + if leixing == '1': + # 打手规则图片 + fixed_relative_url = 'a_long/dashouguize.jpg' + oss_file_name = 'dashouguize.jpg' + else: + # 默认头像图片 + fixed_relative_url = 'a_long/morentouxiang.jpg' + oss_file_name = 'morentouxiang.jpg' + + # 构建OSS文件路径 + oss_file_path = fixed_relative_url # a_long/dashouguize.jpg 或 a_long/morentouxiang.jpg + + # 上传到OSS(覆盖原有文件) + image_file.seek(0) # 重置文件指针 + full_url = upload_to_oss(image_file, oss_file_path) + + if not full_url: + return Response({ + 'code': 6, + 'msg': '图片上传到存储桶失败', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # 更新数据库 + with transaction.atomic(): + # 获取或创建图片配置记录 + tupian_peizhi, created = Tupianpeizhi.query.get_or_create(ImageType=int(leixing)) + tupian_peizhi.ImageURL = fixed_relative_url + tupian_peizhi.save() + + return Response({ + 'code': 0, + 'msg': '图片上传成功', + 'data': { + 'image_url': fixed_relative_url + } + }) + + except Exception as e: + return Response({ + 'code': 99, + 'msg': f'系统错误: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class AdminUpdateConfigView(APIView): + """ + 管理员更新系统配置接口(QQ群、利率)- 已修复 + URL: /peizhi/adupdate + 方法: POST + 权限: JWT Token认证 + 管理员权限 + 参数: + - zhanghao: 管理员账号 + - qq_groups: [{"peizhiid": 1, "neirong": "", "qunid": "", "jieshao": ""}, ...] (可选) + - rates: {"platform_rate": 0.1, "shop_rate": 0.2} (可选) + """ + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + try: + zhanghao = request.data.get('zhanghao') + if not zhanghao: + return Response({ + 'code': 1, + 'msg': '账号不能为空', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 验证用户权限 + user_main = request.user + if user_main.Phone != zhanghao: + return Response({ + 'code': 1, + 'msg': '账号不匹配', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + + if user_main.UserType != 'admin': + return Response({ + 'code': 1, + 'msg': '无权限', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + + try: + admin_profile = user_main.AdminProfile + except AdminProfile.DoesNotExist: + return Response({ + 'code': 1, + 'msg': '无权限', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + + response_data = {} + + # 🔴 修复:更新QQ群配置 - 确保根据peizhiid正确更新对应的记录 + qq_groups = request.data.get('qq_groups') + if qq_groups and isinstance(qq_groups, list): + updated_groups = [] + for group_data in qq_groups: + peizhiid = group_data.get('peizhiid') + if not peizhiid: + continue + + # 🔴 修复:使用filter()确保查询到正确的记录 + qun_configs = Qunpeizhi.query.filter(GroupType=peizhiid) + + if qun_configs.exists(): + # 如果存在多个相同peizhiid的记录,取第一个(应该只有一个) + qun_config = qun_configs.first() + + # 更新字段 + neirong = group_data.get('neirong') + qunid = group_data.get('qunid') + jieshao = group_data.get('jieshao') + + if neirong is not None: + qun_config.GroupContent = neirong or '' + if qunid is not None: + qun_config.GroupID = qunid or '' + if jieshao is not None: + qun_config.Description = jieshao or '' + + qun_config.save() + else: + # 如果不存在,创建新的(使用提供的peizhiid) + qun_config = Qunpeizhi.query.create( + GroupType=peizhiid, + GroupContent=group_data.get('neirong', '') or '', + GroupID=group_data.get('qunid', '') or '', + Description=group_data.get('jieshao', '') or '' + ) + + updated_groups.append({ + 'peizhiid': qun_config.GroupType, + 'neirong': qun_config.GroupContent, + 'qunid': qun_config.GroupID, + 'jieshao': qun_config.Description + }) + + if updated_groups: + response_data['qq_groups'] = updated_groups + + # 🔴 修复:更新利率配置 - 确保更新现有记录而不是创建新记录 + rates = request.data.get('rates') + if rates and isinstance(rates, dict): + updated_rates = {} + + # 平台发单利率(类型1,fadanpingtai='1') + platform_rate = rates.get('platform_rate') + if platform_rate is not None: + try: + platform_rate_float = float(platform_rate) + if 0 <= platform_rate_float <= 1: + # 🔴 修复:查询现有记录,如果不存在则创建 + platform_rate_obj = CommissionRate.query.filter(Platform='1').first() + + if platform_rate_obj: + # 更新现有记录 + platform_rate_obj.Rate = platform_rate_float + platform_rate_obj.save() + else: + # 创建新记录 + platform_rate_obj = CommissionRate.query.create( + Rate=platform_rate_float, + Platform='1' + ) + + updated_rates['platform_rate'] = platform_rate_float + else: + return Response({ + 'code': 3, + 'msg': '平台发单利率必须在0-1之间', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + except ValueError: + return Response({ + 'code': 4, + 'msg': '平台发单利率格式错误', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 商家发单利率(类型2,fadanpingtai='2') + shop_rate = rates.get('shop_rate') + if shop_rate is not None: + try: + shop_rate_float = float(shop_rate) + if 0 <= shop_rate_float <= 1: + # 🔴 修复:查询现有记录,如果不存在则创建 + shop_rate_obj = CommissionRate.query.filter(Platform='3').first() + + if shop_rate_obj: + # 更新现有记录 + shop_rate_obj.Rate = shop_rate_float + shop_rate_obj.save() + else: + # 创建新记录 + shop_rate_obj = CommissionRate.query.create( + Rate=shop_rate_float, + Platform='3' + ) + + updated_rates['shop_rate'] = shop_rate_float + else: + return Response({ + 'code': 5, + 'msg': '商家发单利率必须在0-1之间', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + except ValueError: + return Response({ + 'code': 6, + 'msg': '商家发单利率格式错误', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + + + + # 🔴【新增】打手提现抽成利率(类型5,fadanpingtai='5') + dashou_withdraw_rate = rates.get('dashou_withdraw_rate') + if dashou_withdraw_rate is not None: + try: + dashou_withdraw_rate_float = float(dashou_withdraw_rate) + if 0 <= dashou_withdraw_rate_float <= 1: + # 查询现有记录,如果不存在则创建 + dashou_withdraw_obj = CommissionRate.query.filter(Platform='5').first() + + if dashou_withdraw_obj: + # 更新现有记录 + dashou_withdraw_obj.Rate = dashou_withdraw_rate_float + dashou_withdraw_obj.save() + else: + # 创建新记录 + dashou_withdraw_obj = CommissionRate.query.create( + Rate=dashou_withdraw_rate_float, + Platform='5' + ) + + updated_rates['dashou_withdraw_rate'] = dashou_withdraw_rate_float + else: + return Response({ + 'code': 7, # 注意:这里需要调整错误码,不要重复 + 'msg': '打手提现抽成利率必须在0-1之间', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + except ValueError: + return Response({ + 'code': 8, + 'msg': '打手提现抽成利率格式错误', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 🔴【新增】管事提现抽成利率(类型6,fadanpingtai='6') + guanshi_withdraw_rate = rates.get('guanshi_withdraw_rate') + if guanshi_withdraw_rate is not None: + try: + guanshi_withdraw_rate_float = float(guanshi_withdraw_rate) + if 0 <= guanshi_withdraw_rate_float <= 1: + # 查询现有记录,如果不存在则创建 + guanshi_withdraw_obj = CommissionRate.query.filter(Platform='6').first() + + if guanshi_withdraw_obj: + # 更新现有记录 + guanshi_withdraw_obj.Rate = guanshi_withdraw_rate_float + guanshi_withdraw_obj.save() + else: + # 创建新记录 + guanshi_withdraw_obj = CommissionRate.query.create( + Rate=guanshi_withdraw_rate_float, + Platform='6' + ) + + updated_rates['guanshi_withdraw_rate'] = guanshi_withdraw_rate_float + else: + return Response({ + 'code': 9, + 'msg': '管事提现抽成利率必须在0-1之间', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + except ValueError: + return Response({ + 'code': 10, + 'msg': '管事提现抽成利率格式错误', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + if updated_rates: + response_data['rates'] = updated_rates + + # 如果没有任何更新 + if not response_data: + return Response({ + 'code': 7, + 'msg': '没有需要更新的数据', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + return Response({ + 'code': 0, + 'msg': '更新成功', + 'data': response_data + }) + + except Exception as e: + return Response({ + 'code': 99, + 'msg': f'系统错误: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + + diff --git a/config/views/admin_stats.py b/config/views/admin_stats.py new file mode 100644 index 0000000..93fa84b --- /dev/null +++ b/config/views/admin_stats.py @@ -0,0 +1,718 @@ +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 DashouPaihangView(APIView): + """ + 排行榜接口 + 路径:/peizhi/dsph + 方法:POST + 认证:JWT Token认证 + + 请求参数: + - leixing: 1=今日收入,2=今月收入 + - yonghu_leixing: 1=打手,2=管事,3=商家 + + 返回数据: + - code: 0=成功,其他=失败 + - msg: 提示信息 + - data: { + xianshi: True/False, # 是否展示真实数据 + paihang_list: [ # 排行榜列表 + { + uid: '用户ID', + touxiang: '头像相对URL', + nicheng: '用户昵称', + jine: 收入金额, + mingci: 排名 + }, + ... + ] + } + """ + + # =============== 可配置参数 =============== + # 每次查询返回的最大记录数(可修改) + ZUI_DAO_CHA_XUN_SHU_LIANG = 50 + + # 最少需要多少人才能展示(可修改) + ZUI_SHAO_REN_SHU_YAO_QIU = 1 + + # 第一名最低金额要求(单位:元)(可修改) + DI_YI_MING_ZUI_DI_JIN_E = Decimal('10.00') + + # 管事第一名最低数量要求(可修改) + DI_YI_MING_ZUI_DI_SHU_LIANG = 1 + # =============== 可配置参数结束 =============== + + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + # 获取请求参数 - 修复参数类型问题 + leixing_str = request.data.get('leixing', '1') + yonghu_leixing_str = request.data.get('yonghu_leixing', '1') + + # 转换为整数 + try: + leixing = int(leixing_str) + except (ValueError, TypeError): + leixing = 1 + + try: + yonghu_leixing = int(yonghu_leixing_str) + except (ValueError, TypeError): + yonghu_leixing = 1 + + # 参数验证 + if leixing not in [1, 2]: + return Response({ + 'code': 400, + 'msg': '参数错误:leixing必须为1(今日)或2(今月)', + 'data': {'xianshi': False} + }) + + if yonghu_leixing not in [1, 2, 3]: + return Response({ + 'code': 400, + 'msg': '参数错误:yonghu_leixing必须为1(打手)、2(管事)或3(商家)', + 'data': {'xianshi': False} + }) + + # 根据用户类型调用不同的查询方法 + if yonghu_leixing == 1: + # 打手排行榜 + paihang_list, xianshi_flag = self.chaxun_dashou_paihang(leixing) + elif yonghu_leixing == 2: + # 管事排行榜 + paihang_list, xianshi_flag = self.chaxun_guanshi_paihang(leixing) + else: + # 商家排行榜 + paihang_list, xianshi_flag = self.chaxun_shangjia_paihang(leixing) + + # 返回结果 + return Response({ + 'code': 0, + 'msg': '获取成功', + 'data': { + 'xianshi': xianshi_flag, + 'paihang_list': paihang_list + } + }) + + except Exception as e: + logger.error(f"排行榜查询失败: {str(e)}", exc_info=True) + return Response({ + 'code': 500, + 'msg': '服务器内部错误', + 'data': {'xianshi': False} + }) + + def chaxun_dashou_paihang(self, leixing): + """ + 查询打手排行榜 + :param leixing: 1=今日收入,2=今月收入 + :return: (排行榜列表, 是否展示标志) + """ + # 确定查询的字段 + if leixing == 1: + # 今日收入 + shouyi_ziduan = 'jinrishouyi' + else: + # 今月收入 + shouyi_ziduan = 'jinyueshouyi' + + # 查询条件:收入大于0,打手账号状态正常(1=正常) + # 注意:打手表中的zhanghaozhuangtai字段 + chaxun_tiaojian = Q(**{f'{shouyi_ziduan}__gt': 0}) & Q(zhanghaozhuangtai=1) + + # 查询打手扩展表,并关联用户主表 + dashou_list = UserDashou.query.select_related('user').filter( + chaxun_tiaojian + ).order_by(f'-{shouyi_ziduan}')[:self.ZUI_DAO_CHA_XUN_SHU_LIANG] + + # 检查是否满足展示条件 + if len(dashou_list) < self.ZUI_SHAO_REN_SHU_YAO_QIU: + # 人数不足,不展示 + return [], False + + # 检查第一名金额是否达到最低要求 + if leixing == 1: + di_yi_ming_jine = dashou_list[0].jinrishouyi + else: + di_yi_ming_jine = dashou_list[0].jinyueshouyi + + if di_yi_ming_jine < self.DI_YI_MING_ZUI_DI_JIN_E: + # 第一名金额不足,不展示 + return [], False + + # 构建返回数据 + paihang_list = [] + for index, dashou in enumerate(dashou_list): + # 获取用户主表信息 + user_main = dashou.user + + # 打手的昵称直接使用打手扩展表的nicheng字段 + dashou_nicheng = dashou.nicheng or '未设置昵称' + + # 获取头像(相对URL) + touxiang_url = user_main.Avatar or '' + + # 获取收入金额 + if leixing == 1: + shouyi_jine = dashou.jinrishouyi + else: + shouyi_jine = dashou.jinyueshouyi + + paihang_list.append({ + 'uid': user_main.UserUID, + 'touxiang': touxiang_url, + 'nicheng': dashou_nicheng, + 'jine': float(shouyi_jine), # 转换为float方便前端处理 + 'mingci': index + 1 + }) + + return paihang_list, True + + def chaxun_guanshi_paihang(self, leixing): + """ + 查询管事排行榜 + :param leixing: 1=今日,2=今月 + :return: (排行榜列表, 是否展示标志) + """ + # 确定查询的字段 + if leixing == 1: + # 今日充值打手数量 + chaxun_ziduan = 'jinrichongzhi' + else: + # 今月充值打手数量 + chaxun_ziduan = 'jinyuechongzhi' + + # 查询条件:数量大于0,管事状态正常(状态1为正常) + # 注意:管手表中的zhuangtai字段 + chaxun_tiaojian = Q(**{f'{chaxun_ziduan}__gt': 0}) & Q(zhuangtai=1) + + # 查询管事扩展表,并关联用户主表 + guanshi_list = UserGuanshi.query.select_related('user').filter( + chaxun_tiaojian + ).order_by(f'-{chaxun_ziduan}')[:self.ZUI_DAO_CHA_XUN_SHU_LIANG] + + # 检查是否满足展示条件 + if len(guanshi_list) < self.ZUI_SHAO_REN_SHU_YAO_QIU: + # 人数不足,不展示 + return [], False + + # 检查第一名数量是否达到最低要求 + if leixing == 1: + di_yi_ming_shuliang = guanshi_list[0].jinrichongzhi + else: + di_yi_ming_shuliang = guanshi_list[0].jinyuechongzhi + + if di_yi_ming_shuliang < self.DI_YI_MING_ZUI_DI_SHU_LIANG: + # 第一名数量不足,不展示 + return [], False + + # 构建返回数据 + paihang_list = [] + for index, guanshi in enumerate(guanshi_list): + # 获取用户主表信息 + user_main = guanshi.user + + # 管事的昵称需要查询老板扩展表 + boss_profile = UserBoss.query.filter(user=user_main).first() + guanshi_nicheng = boss_profile.nickname if boss_profile else '未设置昵称' + + # 获取头像(相对URL) + touxiang_url = user_main.Avatar or '' + + # 获取数量 + if leixing == 1: + chongzhi_shuliang = guanshi.jinrichongzhi + else: + chongzhi_shuliang = guanshi.jinyuechongzhi + + paihang_list.append({ + 'uid': user_main.UserUID, + 'touxiang': touxiang_url, + 'nicheng': guanshi_nicheng, + 'jine': float(chongzhi_shuliang), # 这里jine字段实际存储的是数量 + 'mingci': index + 1 + }) + + return paihang_list, True + + def chaxun_shangjia_paihang(self, leixing): + """ + 查询商家排行榜 + :param leixing: 1=今日流水,2=今月流水 + :return: (排行榜列表, 是否展示标志) + """ + # 确定查询的字段 + if leixing == 1: + # 今日流水 + liushui_ziduan = 'jinriliushui' + else: + # 今月流水 + liushui_ziduan = 'jinyueliushui' + + # 查询条件:流水大于0,商家状态正常(状态1为正常) + # 注意:商家表中的zhuangtai字段 + chaxun_tiaojian = Q(**{f'{liushui_ziduan}__gt': 0}) & Q(zhuangtai=1) + + # 查询商家扩展表,并关联用户主表 + shangjia_list = UserShangjia.query.select_related('user').filter( + chaxun_tiaojian + ).order_by(f'-{liushui_ziduan}')[:self.ZUI_DAO_CHA_XUN_SHU_LIANG] + + # 检查是否满足展示条件 + if len(shangjia_list) < self.ZUI_SHAO_REN_SHU_YAO_QIU: + # 人数不足,不展示 + return [], False + + # 检查第一名金额是否达到最低要求 + if leixing == 1: + di_yi_ming_liushui = shangjia_list[0].jinriliushui + else: + di_yi_ming_liushui = shangjia_list[0].jinyueliushui + + if di_yi_ming_liushui < self.DI_YI_MING_ZUI_DI_JIN_E: + # 第一名流水不足,不展示 + return [], False + + # 构建返回数据 + paihang_list = [] + for index, shangjia in enumerate(shangjia_list): + # 获取用户主表信息 + user_main = shangjia.user + + # 商家的昵称需要查询老板扩展表 + boss_profile = UserBoss.query.filter(user=user_main).first() + shangjia_nicheng = boss_profile.nickname if boss_profile else '未设置昵称' + + # 获取头像(相对URL) + touxiang_url = user_main.Avatar or '' + + # 获取流水金额 + if leixing == 1: + liushui_jine = shangjia.jinriliushui + else: + liushui_jine = shangjia.jinyueliushui + + paihang_list.append({ + 'uid': user_main.UserUID, + 'touxiang': touxiang_url, + 'nicheng': shangjia_nicheng, + 'jine': float(liushui_jine), + 'mingci': index + 1 + }) + + return paihang_list, True + + + + + + + + + + +class GetAdminZhanghaoView(APIView): + """ + 获取管理员账号列表接口 + 路径:/peizhi/adzhhq + 方法:POST + 权限:JWT Token + 管理员验证 + 参数: + - zhanghao: 当前登录的管理员账号(字符串) + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + # 🔥 1. 身份验证(100%按表模型) + user = request.user + + # 获取当前用户的手机号(CharField,字符串类型) + current_phone = getattr(user, 'Phone', '') + if not current_phone: + return Response({ + 'code': 401, + 'message': '用户手机号信息不完整', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + + # 检查前端传递的账号是否匹配(都是字符串) + frontend_zhanghao = request.data.get('zhanghao', '').strip() + if frontend_zhanghao != current_phone: + return Response({ + 'code': 403, + 'message': '账号不匹配,无权限访问', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) + + # 🔥 2. 验证是否为管理员(user_type是CharField,值是'admin'字符串) + user_type = getattr(user, 'user_type', '') + if user_type != 'admin': + # 检查是否有管理员扩展表 + if not hasattr(user, 'AdminProfile'): + return Response({ + 'code': 403, + 'message': '无管理员权限', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) + + # 🔥 3. 查询管理员账号列表(全部按字符串查询) + with connection.cursor() as cursor: + # 🔥 查询所有管理员账号(user_type='admin' 字符串) + # 🔥 phone和yonghuid都是CharField,xiugaiid也是CharField + sql = """ + SELECT + um.id, + um.phone, + um.yonghuid, + um.UserType, + COALESCE(COUNT(xjl.id), 0) as jilushuliang + FROM user_main um + LEFT JOIN xiugaijilu xjl ON um.phone = xjl.xiugaiid + WHERE um.UserType = 'admin' + GROUP BY um.id, um.phone, um.yonghuid, um.UserType + ORDER BY um.CreateTime DESC + """ + + cursor.execute(sql) + results = cursor.fetchall() + + # 🔥 4. 处理查询结果(全部保持字符串类型) + zhanghao_list = [] + for row in results: + zhanghao_list.append({ + #'id': row[0], + 'id': str(row[1]) if row[1] else str(row[2]), # 🔥 保持字符串 + 'zhanghao':str(row[1]) if row[1] else str(row[2]), # 🔥 保持字符串 + 'yonghuid': str(row[2]) if row[2] else '', # 🔥 保持字符串 + 'user_type': str(row[3]) if row[3] else '', # 🔥 保持字符串 + 'jilushuliang': int(row[4]) # 只有这个是数字 + }) + + return Response({ + 'code': 0, + 'message': '获取成功', + 'data': zhanghao_list + }, status=status.HTTP_200_OK) + + except Exception as e: + print(f"获取管理员账号列表失败: {str(e)}") + traceback.print_exc() + return Response({ + 'code': 500, + 'message': '系统错误', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class GetXiugaiJiluView(APIView): + """ + 获取修改记录列表接口 + 路径:/peizhi/adhqxg + 方法:POST + 权限:JWT Token + 管理员验证 + 参数: + - zhanghao: 当前登录的管理员账号(字符串) + - xiugaizhe_zhanghaoid: 选中的管理员账号ID(字符串,对应phone或yonghuid) + - page: 页码(整数) + - pagesize: 每页条数(整数) + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + # 🔥 1. 身份验证(100%按表模型) + user = request.user + + # 获取当前用户的手机号(CharField,字符串) + current_phone = getattr(user, 'Phone', '') + if not current_phone: + return Response({ + 'code': 401, + 'message': '用户手机号信息不完整', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + + # 检查前端传递的账号是否匹配(字符串比较) + frontend_zhanghao = request.data.get('zhanghao', '') + if not isinstance(frontend_zhanghao, str): + frontend_zhanghao = str(frontend_zhanghao) + frontend_zhanghao = frontend_zhanghao.strip() + + if frontend_zhanghao != current_phone: + return Response({ + 'code': 403, + 'message': '账号不匹配,无权限访问', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) + + # 🔥 2. 验证是否为管理员(user_type='admin'字符串) + user_type = getattr(user, 'user_type', '') + if user_type != 'admin': + if not hasattr(user, 'AdminProfile'): + return Response({ + 'code': 403, + 'message': '无管理员权限', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) + + # 🔥 3. 获取参数(100%按实际类型) + # 🔥 xiugaizhe_zhanghaoid 是字符串(对应xiugaiid字段,CharField) + xiugaizhe_zhanghaoid_param = request.data.get('xiugaizhe_zhanghaoid', '') + + + # 🔥 强制转为字符串(因为数据库是CharField) + if xiugaizhe_zhanghaoid_param is None: + xiugaizhe_zhanghaoid = '' + else: + # 无论如何都转为字符串 + xiugaizhe_zhanghaoid = str(xiugaizhe_zhanghaoid_param).strip() + + + # 🔥 分页参数转为整数 + try: + page = int(request.data.get('page', 1)) + except: + page = 1 + + try: + pagesize = int(request.data.get('pagesize', 10)) + except: + pagesize = 10 + + # 🔥 4. 参数验证 + if not xiugaizhe_zhanghaoid: + return Response({ + 'code': 400, + 'message': '修改者账号ID不能为空', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + if page < 1: + page = 1 + if pagesize < 1 or pagesize > 50: + pagesize = 10 + + # 🔥 5. 高效查询(100%按表字段类型) + with connection.cursor() as cursor: + # 🔥 5.1 查询总记录数(xiugaiid是CharField,用字符串查询) + count_sql = """ + SELECT COUNT(*) + FROM xiugaijilu + WHERE xiugaiid = %s + """ + cursor.execute(count_sql, [xiugaizhe_zhanghaoid]) + total_count = cursor.fetchone()[0] + + # 🔥 5.2 计算分页 + offset = (page - 1) * pagesize + + # 🔥 5.3 查询当前页数据(使用参数化查询,防止SQL注入) + data_sql = """ + SELECT + xjl.id, + xjl.yonghuid, -- CharField + xjl.xiugaiid, -- CharField + xjl.leixing, -- IntegerField + xjl.xiugaitijiao, -- DecimalField + xjl.xiugaitijiaoq, -- DecimalField + xjl.jifen, -- IntegerField + xjl.yjifen, -- IntegerField + xjl.yajin, -- DecimalField + xjl.yyajin, -- DecimalField + xjl.shangjiayue, -- DecimalField + xjl.shangjiayueq, -- DecimalField + xjl.guanshiyue, -- DecimalField + xjl.guanshiyueq, -- DecimalField + xjl.huiyuants, -- IntegerField + xjl.huiyuan_id, -- CharField + xjl.qitashuoming, -- TextField + xjl.CreateTime, -- DateTimeField + um.avatar -- CharField + FROM xiugaijilu xjl + LEFT JOIN user_main um ON xjl.yonghuid = um.yonghuid + WHERE xjl.xiugaiid = %s + ORDER BY xjl.CreateTime DESC + LIMIT %s OFFSET %s + """ + + cursor.execute(data_sql, [xiugaizhe_zhanghaoid, pagesize, offset]) + records = cursor.fetchall() + + # 🔥 6. 处理查询结果(严格按照字段类型处理) + jilu_list = [] + for record in records: + # 获取修改者账号信息(都是字符串) + xiugaizhe_zhanghao = '' + with connection.cursor() as cursor2: + # 🔥 phone和yonghuid都是CharField,用字符串查询 + cursor2.execute( + "SELECT phone, yonghuid FROM user_main WHERE phone = %s OR yonghuid = %s LIMIT 1", + [xiugaizhe_zhanghaoid, xiugaizhe_zhanghaoid] + ) + xiugaizhe_info = cursor2.fetchone() + if xiugaizhe_info: + # 🔥 都是字符串 + xiugaizhe_zhanghao = str(xiugaizhe_info[0]) if xiugaizhe_info[0] else str(xiugaizhe_info[1]) if \ + xiugaizhe_info[1] else xiugaizhe_zhanghaoid + + # 获取会员名称(如果有) + huiyuan_mingzi = '' + if record[15]: # huiyuan_id是CharField + with connection.cursor() as cursor3: + cursor3.execute( + "SELECT jieshao FROM huiyuan WHERE huiyuan_id = %s LIMIT 1", + [str(record[15])] # 🔥 转为字符串查询 + ) + huiyuan_info = cursor3.fetchone() + if huiyuan_info: + huiyuan_mingzi = str(huiyuan_info[0]) if huiyuan_info[0] else '' + + # 🔥 严格按照前端字段名映射,保持正确类型 + jilu_data = { + # 基础字段(字符串) + 'id': record[0], + 'beixiugai_yonghuid': str(record[1]) if record[1] else '', # yonghuid + 'xiugaizhe_id': str(record[2]) if record[2] else '', # xiugaiid + 'xiugaizhe_zhanghao': xiugaizhe_zhanghao, # 字符串 + 'beixiugai_yonghuleixing': record[3], # leixing是整数 + + # 打手字段(Decimal转float,Integer转int) + 'xiugaihou_dashoutixianjine': float(record[4]) if record[4] is not None else 0.00, + 'xiugaiqian_dashoutixianjine': float(record[5]) if record[5] is not None else 0.00, + 'xiugaihou_dashoujifen': int(record[6]) if record[6] is not None else 0, + 'xiugaiqian_dashoujifen': int(record[7]) if record[7] is not None else 0, + 'xiugaihou_dashouyajin': float(record[8]) if record[8] is not None else 0.00, + 'xiugaiqian_dashouyajin': float(record[9]) if record[9] is not None else 0.00, + + # 商家字段 + 'xiugaihou_shangjiayue': float(record[10]) if record[10] is not None else 0.00, + 'xiugaiqian_shangjiayue': float(record[11]) if record[11] is not None else 0.00, + + # 管事字段 + 'xiugaihou_guanshiyue': float(record[12]) if record[12] is not None else 0.00, + 'xiugaiqian_guanshiyue': float(record[13]) if record[13] is not None else 0.00, + + # 会员字段 + 'tianjia_huiyuantianshu': int(record[14]) if record[14] is not None else 0, + 'huiyuan_id': str(record[15]) if record[15] else '', # 字符串 + 'huiyuan_mingzi': huiyuan_mingzi, # 字符串 + + # 其他字段 + 'qita_shuoming': str(record[16]) if record[16] else '', # 字符串 + 'xiugai_time': record[17].strftime('%Y-%m-%d %H:%M:%S') if record[17] else None, + 'touxiang': str(record[18]) if record[18] else '' # avatar是字符串 + } + + jilu_list.append(jilu_data) + + # 🔥 7. 分页控制 + queried_records = len(jilu_list) + has_more = False + + if queried_records == pagesize: + total_queried = (page - 1) * pagesize + queried_records + has_more = total_queried < total_count + + return Response({ + 'code': 0, + 'message': '获取成功', + 'data': { + 'list': jilu_list, + 'zongshuliang': total_count, + 'dangqian_page': page, + 'page_size': pagesize, + 'has_more': has_more, + 'shijichaxundediaoshuliang': queried_records + } + }, status=status.HTTP_200_OK) + + except ValueError as e: + print(f"参数错误: {str(e)}") + return Response({ + 'code': 400, + 'message': '参数错误', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + print(f"获取修改记录失败: {str(e)}") + traceback.print_exc() + return Response({ + 'code': 500, + 'message': '系统错误', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + + + + + + + diff --git a/config/views/merchant_links.py b/config/views/merchant_links.py new file mode 100644 index 0000000..d5f3af4 --- /dev/null +++ b/config/views/merchant_links.py @@ -0,0 +1,1032 @@ +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 ShangjiaGenerateLinkView(APIView): + """商家生成发单链接接口(支持模板佣金/标签)""" + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + user = request.user + # 获取参数 + shangpin_type_id = request.data.get('shangpinTypeId') + moban_id = request.data.get('mobanId') + if not all([shangpin_type_id, moban_id]): + return Response({'code': 400, 'msg': '缺少参数', 'data': {}}, status=400) + + try: + shangpin_type_id = int(shangpin_type_id) + moban_id = int(moban_id) + except ValueError: + return Response({'code': 400, 'msg': '参数格式错误', 'data': {}}, status=400) + + # 商家信息 + try: + shangjia = user.ShopProfile + except UserShangjia.DoesNotExist: + return Response({'code': 403, 'msg': '商家信息未完善', 'data': {}}, status=403) + if shangjia.zhuangtai != 1: + return Response({'code': 403, 'msg': '商家状态异常', 'data': {}}, status=403) + + # 商品类型 + try: + shangpin_leixing = ShangpinLeixing.query.get(id=shangpin_type_id, shenhezhuangtai=1) + except ShangpinLeixing.DoesNotExist: + return Response({'code': 400, 'msg': '商品类型不存在或已禁用', 'data': {}}, status=400) + + # 查询模板 + try: + moban = ShangjiaMoban.query.get(id=moban_id, UserID=user.UserUID, ProductTypeID=shangpin_type_id) + except ShangjiaMoban.DoesNotExist: + return Response({'code': 400, 'msg': '模板不存在', 'data': {}}, status=400) + + jiage = moban.Price + if jiage > shangjia.yue: + return Response({'code': 400, 'msg': f'余额不足,当前余额{shangjia.yue}元', 'data': {}}, status=400) + + # 利率 + try: + lilu = float(CommissionRate.query.get(Platform='3').Rate) or 1.0 + except CommissionRate.DoesNotExist: + lilu = 1.0 + dashou_fencheng = Decimal(str(round(float(jiage) * lilu, 2))) + + # 抢单要求:根据模板是否启用佣金 + chenghao_id = moban.TitleID + if moban.CommissionGrabEnabled and moban.CommissionPrice is not None: + final_yaoqiuleixing = 2 # 佣金模式 + final_yongjin = moban.CommissionPrice + final_huiyuan_id = '' + else: + final_yaoqiuleixing = shangpin_leixing.yaoqiuleixing or 1 + final_yongjin = None + final_huiyuan_id = shangpin_leixing.huiyuan_id or '' + + # 获取标签名称 + label_name = '' + if chenghao_id: + try: + label_name = Chenghao.query.get(id=chenghao_id).mingcheng + except Chenghao.DoesNotExist: + pass + + # 生成ID和Token + def generate_dingdan_id(): + timestamp = int(time.time() * 1000) + process_id = os.getpid() % 1000 + random_num = random.randint(1000, 9999) + return f"SJ{timestamp}{process_id:03d}{random_num}" + + def generate_secure_token(dd_id, uid): + timestamp = int(time.time() * 1_000_000) + salt = secrets.token_hex(8) + raw = f"{timestamp}:{dd_id}:{uid}:{salt}" + token = hashlib.sha256(raw.encode()).hexdigest()[:24] + # 确保唯一性(极小概率重复再试一次) + if ShangjiaLianjie.query.filter(LinkToken=token).exists(): + return generate_secure_token(dd_id, uid) + return token + + def generate_link_url(token): + base = getattr(settings, 'H5_DOMAIN', 'https://h5.yourdomain.com').rstrip('/') + return f"{base}/order/{token}" + + with transaction.atomic(): + dingdan_id = generate_dingdan_id() + secure_token = generate_secure_token(dingdan_id, user.UserUID) + + # 创建订单主表 + dingdan = Order.query.create( + OrderID=dingdan_id, + Status=13, + Platform=2, + Amount=jiage, + PlayerCommission=dashou_fencheng, + ProductTypeID=shangpin_type_id, + GrabRequirement=final_yaoqiuleixing, + MembershipID=final_huiyuan_id, + CommissionReq=final_yongjin, + Description=moban.TemplateDesc[:2000], + User1ID=f"Sj{user.UserUID}", + ImageURL=user.Avatar or '', + ) + + # 商家扩展表 + MerchantOrderExt.query.create( + Order=dingdan, + MerchantID=user.UserUID, + MerchantNickname=shangjia.nicheng or f"商家{user.UserUID}", + ) + + # 关联标签 + if chenghao_id: + try: + ch = Chenghao.query.get(id=chenghao_id) + DingdanBiaoqian.query.create(dingdan=dingdan, chenghao=ch) + except Chenghao.DoesNotExist: + logger.warning(f"称号{chenghao_id}不存在,跳过关联") + + # 更新商家余额和统计 + UserShangjia.query.filter(id=shangjia.id).update( + fabu=F('fabu') + 1, + yue=F('yue') - jiage, + jinridingdan=F('jinridingdan') + 1, + jinriliushui=F('jinriliushui') + jiage, + jinyuedingdan=F('jinyuedingdan') + 1, + jinyueliushui=F('jinyueliushui') + jiage, + ) + + # 生成链接并保存 + link_url = generate_link_url(secure_token) + lianjie_obj = ShangjiaLianjie.query.create( + UserID=user.UserUID, + ProductTypeID=shangpin_type_id, + TemplateID=moban_id, + LinkURL=link_url, + LinkToken=secure_token, + OrderID=dingdan_id, + is_used=False, + ExpireTime=timezone.now() + timezone.timedelta(days=1) + ) + + # 更新模板发布数量 + ShangjiaMoban.query.filter(id=moban_id).update(PublishedCount=F('PublishedCount') + 1) + shangjia.refresh_from_db() + + logger.info(f"链接生成成功 - 订单ID: {dingdan_id}, 标签: {label_name}") + + return Response({ + 'code': 200, + 'msg': '链接生成成功', + 'data': { + 'linkUrl': link_url, + 'newBalance': str(shangjia.yue), + 'dingdanId': dingdan_id, + 'dingdanJieshao': moban.TemplateDesc, + 'jiage': str(jiage), + 'labelId': chenghao_id, + 'labelName': label_name, + 'youxiaoShijian': lianjie_obj.ExpireTime.strftime('%Y-%m-%d %H:%M:%S'), + } + }) + + except Exception as e: + logger.error(f"生成链接事务失败: {str(e)}", exc_info=True) + return Response({'code': 500, 'msg': '系统错误,生成链接失败', 'data': {}}, status=500) + + + + +class ShangjiaLianjieListView(APIView): + """获取已生成的链接列表(支持筛选是否使用)""" + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + user = request.user + yonghu_id = user.UserUID + + moban_id = request.data.get('mobanId') + used = int(request.data.get('used', 0)) # 0未使用,1已使用 + page = int(request.data.get('page', 1)) + page_size = int(request.data.get('pageSize', 5)) + page_size = min(page_size, 50) + + if not moban_id: + return Response({'code': 400, 'msg': '模板ID不能为空', 'data': {}}, status=400) + + # 校验模板归属 + try: + moban = ShangjiaMoban.query.get(id=moban_id, UserID=yonghu_id) + except ShangjiaMoban.DoesNotExist: + return Response({'code': 404, 'msg': '模板不存在或无权查看', 'data': {}}, status=404) + + links = ShangjiaLianjie.query.filter( + UserID=yonghu_id, + TemplateID=moban_id, + is_used=(used == 1) + ).order_by('-CreateTime') + + paginator = Paginator(links, page_size) + if page < 1 or page > paginator.num_pages: + return Response({'code': 400, 'msg': '页码超出范围', 'data': {}}, status=400) + + current_page = paginator.page(page) + data_list = [] + for link in current_page.object_list: + data_list.append({ + 'id': link.id, + 'lianjie': link.LinkURL, + 'LinkToken': link.LinkToken, + 'is_used': link.is_used, + 'dingdan_id': link.OrderID, + 'ExpireTime': link.ExpireTime.strftime('%Y-%m-%d %H:%M:%S') if link.ExpireTime else '', + 'CreateTime': link.CreateTime.strftime('%Y-%m-%d %H:%M:%S') + }) + + return Response({ + 'code': 200, + 'msg': '获取成功', + 'data': { + 'list': data_list, + 'total': paginator.count, + 'hasMore': current_page.has_next(), + 'currentPage': page, + 'pageSize': page_size + } + }) + + except Exception as e: + logger.error(f"获取链接列表失败: {str(e)}", exc_info=True) + return Response({'code': 500, 'msg': '服务器内部错误', 'data': {}}, status=500) + + + + + + +class IPUtils: + """IP工具类""" + + @staticmethod + def get_client_ip(request): + """ + 获取客户端真实IP,处理代理情况 + 优先级: X-Real-IP > X-Forwarded-For > remote_addr + """ + ip = None + + try: + # 1. 尝试从 X-Real-IP 获取 + x_real_ip = request.META.get('HTTP_X_REAL_IP') + if x_real_ip: + ip = x_real_ip.strip() + logger.info(f"从 X-Real-IP 获取IP: {ip}") + return ip + + # 2. 尝试从 X-Forwarded-For 获取(处理代理链) + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + # 可能有多个代理IP,第一个是真实IP + ips = x_forwarded_for.split(',') + for possible_ip in ips: + possible_ip = possible_ip.strip() + if possible_ip and possible_ip.lower() != 'unknown': + ip = possible_ip + logger.info(f"从 X-Forwarded-For 获取IP: {ip}") + return ip + + # 3. 使用 remote_addr + ip = request.META.get('REMOTE_ADDR', '') + logger.info(f"从 REMOTE_ADDR 获取IP: {ip}") + + except Exception as e: + logger.error(f"获取IP失败: {str(e)}", exc_info=True) + ip = '0.0.0.0' + + return ip + + @staticmethod + def validate_ip(ip): + """验证IP格式是否合法""" + if not ip: + return False + + # IPv4正则 + ipv4_pattern = r'^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$' + + # IPv6正则(简化版) + ipv6_pattern = r'^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$' + + return bool(re.match(ipv4_pattern, ip) or re.match(ipv6_pattern, ip)) + + @staticmethod + def is_private_ip(ip): + """判断是否为内网IP""" + if not ip: + return False + + # 内网IP段 + private_ranges = [ + ('10.0.0.0', '10.255.255.255'), + ('172.16.0.0', '172.31.255.255'), + ('192.168.0.0', '192.168.255.255'), + ('127.0.0.0', '127.255.255.255'), + ] + + try: + # 将IP转换为整数比较 + def ip_to_int(ip): + parts = ip.split('.') + return (int(parts[0]) << 24) + (int(parts[1]) << 16) + (int(parts[2]) << 8) + int(parts[3]) + + ip_num = ip_to_int(ip) + for start, end in private_ranges: + if ip_to_int(start) <= ip_num <= ip_to_int(end): + return True + except: + pass + + return False + + +class OrderLinkThrottle(SimpleRateThrottle): + """订单链接访问频率限制""" + + scope = 'order_link' + rate = '10/minute' # 每分钟最多10次 + + def get_cache_key(self, request, view): + # 基于IP和接口路径进行限制 + ip = IPUtils.get_client_ip(request) + if not ip or IPUtils.is_private_ip(ip): + # 内网IP或不合法IP,使用默认 + ip = 'default' + + return f'throttle_{view.__class__.__name__}_{ip}' + + + + + + + + +class KehuGetDingdanLianjieView(APIView): + """ + 客户获取订单链接信息接口 + 路径:GET /peizhi/khhqddlj?token=xxx + 返回字段包括:订单信息、打手标识、商家标识、俱乐部配置(客服链接、存储桶、GoEasy AppKey等) + """ + + throttle_classes = [OrderLinkThrottle] + permission_classes = [] # 无需认证,任何人都可访问 + + def get(self, request): + """ + 处理GET请求,获取订单链接信息 + """ + # 1. 记录请求开始 + logger.info("=== 客户获取订单链接信息接口开始 ===") + + # 2. 获取客户端真实IP + client_ip = IPUtils.get_client_ip(request) + logger.info(f"客户端IP: {client_ip}") + + # 3. 验证IP合法性 + if not IPUtils.validate_ip(client_ip): + logger.warning(f"IP格式不合法: {client_ip}") + return Response({ + 'code': 400, + 'msg': '网络连接异常', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 4. 获取Token参数 + token = request.query_params.get('token', '') + if not token: + logger.warning(f"Token参数缺失 - IP: {client_ip}") + return Response({ + 'code': 400, + 'msg': '缺少链接参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + logger.info(f"接收到Token: {token[:8]}...") + + # 5. 查询链接记录 + try: + lianjie_obj = ShangjiaLianjie.query.select_related().get(LinkToken=token) + logger.info(f"找到链接记录 - 链接ID: {lianjie_obj.id}, 商家ID: {lianjie_obj.UserID}") + except ShangjiaLianjie.DoesNotExist: + logger.warning(f"链接不存在 - Token: {token[:8]}..., IP: {client_ip}") + return Response({ + 'code': 404, + 'msg': '链接不存在或已失效', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"查询链接异常: {str(e)}", exc_info=True) + return Response({ + 'code': 500, + 'msg': '系统错误,请稍后重试', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # 6. 更新访问者IP(无论是否已使用,都记录最新访问IP) + try: + lianjie_obj.UserIP = client_ip + lianjie_obj.save(update_fields=['UserIP']) + logger.info(f"更新访问者IP成功: {client_ip}") + except Exception as e: + logger.warning(f"更新IP失败: {str(e)}") + # 继续处理,不影响主要逻辑 + + # 7. 检查链接有效期 + '''now = timezone.now() + if lianjie_obj.ExpireTime and lianjie_obj.ExpireTime < now: + logger.warning(f"链接已过期 - 过期时间: {lianjie_obj.ExpireTime}, 当前: {now}") + return Response({ + 'code': 400, + 'msg': '链接已过期', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST)''' + + # 8. 准备返回数据 + response_data = { + 'is_used': lianjie_obj.is_used, + 'dingdan_id': lianjie_obj.OrderID or '', + 'youxiao_shijian': lianjie_obj.ExpireTime.strftime( + '%Y-%m-%d %H:%M:%S') if lianjie_obj.ExpireTime else '', + } + + # 9. 获取订单信息 + if lianjie_obj.OrderID: + try: + # 查询订单主表 + dingdan_obj = Order.query.get(OrderID=lianjie_obj.OrderID) + + # 获取商家昵称 + shangjia_nicheng = '' + try: + user_main = User.query.get(UserUID=lianjie_obj.UserID) + shangjia_obj = user_main.ShopProfile + shangjia_nicheng = shangjia_obj.nicheng + except (User.DoesNotExist, UserShangjia.DoesNotExist): + logger.warning(f"商家信息不存在 - 用户ID: {lianjie_obj.UserID}") + + # ========== 新增:确定俱乐部配置 ========== + # 默认头像(相对路径) + default_avatar = 'a_long/morentouxiang.jpg' + # 从 settings 获取配置 + kefu_link = getattr(settings, 'KEFU_LINK', '') + oss_url = getattr(settings, 'COS_DOMAIN', '') + goeasy_appkey = getattr(settings, 'GOEASY_APPKEY', '') + + # ========== 构建订单信息 ========== + dingdan_info = { + 'jieshao': dingdan_obj.Description or '', + 'shangjia_mingcheng': shangjia_nicheng, + 'zhuangtai': dingdan_obj.Status, + 'jiedan_dashou_id': dingdan_obj.PlayerID or '', + # 新增配置字段 + 'kefu_link': kefu_link, + 'oss_url': oss_url, + 'default_avatar': default_avatar, + 'goeasy_appkey': goeasy_appkey, + # 前端用于连接GoEasy的自身用户ID(固定B前缀+商家ID) + 'self_user_id': f"B{lianjie_obj.UserID}" if lianjie_obj.UserID else '', + } + + # 如果已使用,返回已填写的信息 + if lianjie_obj.is_used: + laoban_shouji = '' + try: + laoban_shouji = dingdan_obj.shangjia_kuozhan.BossPhone or '' + except Exception: + pass + dingdan_info.update({ + 'youxi_nicheng': dingdan_obj.Nickname or '', + 'zhiding_dashou': dingdan_obj.AssignedID or '', + 'beizhu': dingdan_obj.Remark or '', + 'waibu_dingdan_id': dingdan_obj.ExternalOrderID or '', + 'laoban_shouji': laoban_shouji, + }) + + # 获取聊天相关标识(新配对群优先,旧单保留 order 群) + chat_meta, _sub_ok = subscribe_merchant_link_chat(dingdan_obj, lianjie_obj.UserID) + + # 添加聊天标识 + dingdan_info.update({ + 'dashou_biaoshi': chat_meta.get('dashou_biaoshi', ''), + 'shangjia_biaoshi': chat_meta.get('shangjia_biaoshi', ''), + 'you_dashou': chat_meta.get('you_dashou', False), + 'group_id': chat_meta.get('group_id', ''), + 'is_pair_group': chat_meta.get('is_pair_group', False), + 'legacy_group_id': chat_meta.get('legacy_group_id', ''), + }) + + response_data.update(dingdan_info) + + except Order.DoesNotExist: + logger.error(f"订单不存在 - 订单ID: {lianjie_obj.OrderID}") + response_data.update({ + 'jieshao': '', + 'shangjia_mingcheng': '', + 'zhuangtai': None, + 'dashou_biaoshi': '', + 'shangjia_biaoshi': '', + 'you_dashou': False, + 'kefu_link': '', + 'oss_url': '', + 'default_avatar': 'a_long/morentouxiang.jpg', + 'goeasy_appkey': '', + 'self_user_id': '', + }) + except Exception as e: + logger.error(f"获取订单信息异常: {str(e)}", exc_info=True) + response_data.update({ + 'jieshao': '', + 'shangjia_mingcheng': '', + 'zhuangtai': None, + 'dashou_biaoshi': '', + 'shangjia_biaoshi': '', + 'you_dashou': False, + 'kefu_link': '', + 'oss_url': '', + 'default_avatar': 'a_long/morentouxiang.jpg', + 'goeasy_appkey': '', + 'self_user_id': '', + }) + else: + # 如果订单ID为空 + logger.warning(f"链接记录中订单ID为空 - 链接ID: {lianjie_obj.id}") + response_data.update({ + 'jieshao': '', + 'shangjia_mingcheng': '', + 'zhuangtai': None, + 'dashou_biaoshi': '', + 'shangjia_biaoshi': '', + 'you_dashou': False, + 'kefu_link': '', + 'oss_url': '', + 'default_avatar': 'a_long/morentouxiang.jpg', + 'goeasy_appkey': '', + 'self_user_id': '', + }) + + # 10. 判断订单状态是否允许修改 + if not lianjie_obj.is_used and response_data.get('zhuangtai') == 13: + response_data['allow_modify'] = True + else: + response_data['allow_modify'] = False + + # 11. 记录成功日志并返回 + logger.info( + f"返回订单信息成功 - Token: {token[:8]}..., IP: {client_ip}, " + f"是否允许修改: {response_data['allow_modify']}, " + f"是否有打手: {response_data.get('you_dashou', False)}" + ) + + return Response({ + 'code': 200, + 'msg': '成功', + 'data': response_data + }, status=status.HTTP_200_OK) + + + + + + + +class KehuTianxieDingdanView(APIView): + """ + 客户填写订单信息接口 + 路径:POST /peizhi/kehutx + """ + + # 添加频率限制(比查询接口更严格) + throttle_classes = [OrderLinkThrottle] + permission_classes = [] # 无需认证 + + def post(self, request): + """ + 处理POST请求,填写订单信息 + """ + # 1. 记录请求开始 + logger.info("=== 客户填写订单信息接口开始 ===") + + # 2. 获取客户端真实IP + client_ip = IPUtils.get_client_ip(request) + logger.info(f"客户端IP: {client_ip}") + + # 3. 验证IP合法性 + if not IPUtils.validate_ip(client_ip): + logger.warning(f"IP格式不合法: {client_ip}") + return Response({ + 'code': 400, + 'msg': '网络连接异常', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 4. 获取请求参数 + data = request.data + token = data.get('token', '') + youxi_nicheng = data.get('youxi_nicheng', '').strip() + zhiding_dashou = data.get('zhiding_dashou', '').strip() # 可选 + beizhu = data.get('beizhu', '').strip() # 可选 + waibu_dingdan_id = str(data.get('waibu_dingdan_id') or data.get('waibuDingdanId') or '').strip()[:64] + laoban_shouji = str(data.get('laoban_shouji') or data.get('bossPhone') or data.get('laobanPhone') or '').strip()[:11] + + + # 4.0 临时关闭指定功能(如需开启,请删除或注释掉下面两行) + #if zhiding_dashou: + #return Response({'code': 8, 'msg': '指定功能尚未开启,请不要填写指定打手ID', 'data': None}) + #if beizhu: + #return Response({'code': 8, 'msg': '备注功能尚未开启,请不要填写备注', 'data': None}) + + + logger.info( + f"请求参数 - Token: {token[:8]}..., 昵称: {youxi_nicheng[:10]}..., 指定打手: {zhiding_dashou}, 备注长度: {len(beizhu)}, 拼多多ID: {waibu_dingdan_id[:16] if waibu_dingdan_id else ''}") + + # 5. 验证必填参数 + if not token: + logger.warning(f"Token参数缺失 - IP: {client_ip}") + return Response({ + 'code': 400, + 'msg': '缺少链接参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + if not youxi_nicheng: + logger.warning(f"游戏昵称为空 - IP: {client_ip}") + return Response({ + 'code': 400, + 'msg': '游戏昵称不能为空', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + if not waibu_dingdan_id: + logger.warning(f"拼多多订单号为空 - IP: {client_ip}") + return Response({ + 'code': 400, + 'msg': '拼多多订单号不能为空', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + ok_pdd, msg_pdd = validate_pdd_order_id(waibu_dingdan_id) + if not ok_pdd: + logger.warning(f"拼多多订单号校验失败 - IP: {client_ip}, 值: {waibu_dingdan_id[:24]}") + return Response({ + 'code': 400, + 'msg': msg_pdd, + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + ok_phone, msg_phone = validate_cn_mobile(laoban_shouji, required=False) + if not ok_phone: + logger.warning(f"老板手机号校验失败 - IP: {client_ip}") + return Response({ + 'code': 400, + 'msg': msg_phone, + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 6. 验证游戏昵称格式 + if len(youxi_nicheng) > 50: + logger.warning(f"游戏昵称过长 - IP: {client_ip}, 长度: {len(youxi_nicheng)}") + return Response({ + 'code': 400, + 'msg': '游戏昵称不能超过50个字符', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 过滤危险字符 + danger_chars = ['<', '>', "'", '"', '&', ';', '|'] + for char in danger_chars: + if char in youxi_nicheng: + logger.warning(f"游戏昵称包含危险字符 - IP: {client_ip}, 字符: {char}") + return Response({ + 'code': 400, + 'msg': '昵称包含非法字符', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 7. 验证指定打手ID格式(如果提供) + if zhiding_dashou and len(zhiding_dashou) > 32: + logger.warning(f"指定打手ID过长 - IP: {client_ip}, 长度: {len(zhiding_dashou)}") + return Response({ + 'code': 400, + 'msg': '指定打手ID不能超过32个字符', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 8. 验证拼多多订单号长度(格式已在上方校验) + if len(waibu_dingdan_id) > 64: + return Response({ + 'code': 400, + 'msg': '拼多多订单号不能超过64个字符', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 9. 验证备注长度 + if len(beizhu) > 500: + logger.warning(f"备注过长 - IP: {client_ip}, 长度: {len(beizhu)}") + return Response({ + 'code': 400, + 'msg': '备注不能超过500个字符', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 9. 查询链接记录 + try: + lianjie_obj = ShangjiaLianjie.query.get(LinkToken=token) + logger.info( + f"找到链接记录 - 链接ID: {lianjie_obj.id}, 商家ID: {lianjie_obj.UserID}, 是否已使用: {lianjie_obj.is_used}") + except ShangjiaLianjie.DoesNotExist: + logger.warning(f"链接不存在 - Token: {token[:8]}..., IP: {client_ip}") + return Response({ + 'code': 404, + 'msg': '链接不存在或已失效', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 10. 检查链接有效期 + now = timezone.now() + if lianjie_obj.ExpireTime and lianjie_obj.ExpireTime < now: + logger.warning(f"链接已过期 - 过期时间: {lianjie_obj.ExpireTime}") + return Response({ + 'code': 400, + 'msg': '链接已过期', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 11. 检查链接是否已使用 + if lianjie_obj.is_used: + logger.warning(f"链接已使用 - 链接ID: {lianjie_obj.id}, IP: {client_ip}") + return Response({ + 'code': 400, + 'msg': '链接已被使用,无法重复提交', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 12. 验证指定打手(如果需要) + zhuangtai_xin = 1 # 默认普通订单状态 + if zhiding_dashou: + # 验证打手是否存在且有效 + try: + # 先查询用户主表 + dashou_user = User.query.get(UserUID=zhiding_dashou) + logger.info(f"找到打手用户 - 用户ID: {zhiding_dashou}") + + # 通过反向关系获取打手扩展信息 + try: + dashou_profile = dashou_user.DashouProfile + + # 检查打手状态(zhuangtai) + if dashou_profile.zhuangtai != 1: + logger.warning(f"打手状态异常 - 打手ID: {zhiding_dashou}, 状态: {dashou_profile.zhuangtai}") + return Response({ + 'code': 400, + 'msg': '指定打手当前无法接单', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 检查打手账号状态(zhanghaozhuangtai) + if dashou_profile.zhanghaozhuangtai != 1: + logger.warning( + f"打手账号状态异常 - 打手ID: {zhiding_dashou}, 账号状态: {dashou_profile.zhanghaozhuangtai}") + return Response({ + 'code': 400, + 'msg': '指定打手账号异常', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 验证通过,设置为指定订单状态 + zhuangtai_xin = 7 # 指定打手订单状态 + logger.info(f"指定打手验证通过 - 打手ID: {zhiding_dashou}, 昵称: {dashou_profile.nicheng}") + + except UserDashou.DoesNotExist: + logger.warning(f"打手扩展信息不存在 - 用户ID: {zhiding_dashou}") + return Response({ + 'code': 400, + 'msg': '指定打手信息不完整', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + except User.DoesNotExist: + logger.warning(f"指定打手用户不存在 - 用户ID: {zhiding_dashou}") + return Response({ + 'code': 400, + 'msg': '指定打手不存在', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 13. 使用事务确保数据一致性 + try: + with transaction.atomic(): + # 更新链接记录 + lianjie_obj.UserIP = client_ip + lianjie_obj.is_used = True + lianjie_obj.save(update_fields=['UserIP', 'is_used']) + logger.info(f"更新链接记录成功 - 链接ID: {lianjie_obj.id}, 设置为已使用") + + # 查询订单 + if not lianjie_obj.OrderID: + logger.error(f"链接记录中订单ID为空 - 链接ID: {lianjie_obj.id}") + raise ValueError("订单ID为空") + + dingdan_obj = Order.query.get(OrderID=lianjie_obj.OrderID) + + # 检查订单状态是否为13(灰色状态) + if dingdan_obj.Status != 13: + logger.warning( + f"订单状态不允许修改 - 订单ID: {dingdan_obj.OrderID}, 当前状态: {dingdan_obj.Status}") + # 回滚事务 + transaction.set_rollback(True) + return Response({ + 'code': 400, + 'msg': '订单状态异常,无法修改', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 更新订单信息 + dingdan_obj.Nickname = youxi_nicheng # 游戏昵称 + dingdan_obj.Remark = beizhu if beizhu else '' # 备注 + dingdan_obj.ExternalOrderID = waibu_dingdan_id + dingdan_obj.Status = zhuangtai_xin # 新状态:1或7 + + # 如果是指定打手,设置zhiding_id + if zhiding_dashou and zhuangtai_xin == 7: + dingdan_obj.AssignedID = zhiding_dashou + + dingdan_obj.save() + logger.info( + f"更新订单成功 - 订单ID: {dingdan_obj.OrderID}, 新状态: {zhuangtai_xin}, 指定打手: {zhiding_dashou or '无'}") + + try: + shangjia_ext = dingdan_obj.shangjia_kuozhan + shangjia_ext.BossPhone = laoban_shouji + shangjia_ext.save(update_fields=['BossPhone']) + except MerchantOrderExt.DoesNotExist: + logger.warning(f"商家扩展表不存在,无法保存手机号 - 订单: {dingdan_obj.OrderID}") + + # 构建通知数据(你的 _get_game_type_name 方法保留在视图里直接用) + order_info = { + 'dingdan_id': dingdan_obj.OrderID, + 'game_type': self._get_game_type_name(dingdan_obj.ProductTypeID), + 'amount': str(dingdan_obj.Amount), + 'order_desc': (dingdan_obj.Description or '')[:50], + } + dingdan_guangbo.delay(order_info) # 瞬间返回,不阻塞请求 + + # 获取商家信息用于返回 + try: + user_main = User.query.get(UserUID=lianjie_obj.UserID) + shangjia_obj = user_main.ShopProfile + shangjia_mingcheng = shangjia_obj.nicheng + except: + shangjia_mingcheng = f"商家{lianjie_obj.UserID}" + logger.warning(f"获取商家昵称失败,使用默认 - 用户ID: {lianjie_obj.UserID}") + + # 14. 返回成功响应 + response_data = { + #'dingdan_id': dingdan_obj.dingdan_id, + #'CreateTime': dingdan_obj.CreateTime.strftime('%Y-%m-%d %H:%M:%S'), + #'shangpin_mingcheng': dingdan_obj.jieshao[:50] if dingdan_obj.jieshao else '', # 截取前50字符 + #'jiage': str(dingdan_obj.jine) if dingdan_obj.jine else '0.00', + 'shangjia_mingcheng': shangjia_mingcheng, + 'youxi_nicheng': youxi_nicheng, + 'zhuangtai': zhuangtai_xin, + 'zhuangtai_text': '普通订单' if zhuangtai_xin == 1 else '指定打手订单' + } + + logger.info(f"订单提交成功 - 订单ID: {dingdan_obj.OrderID}, IP: {client_ip}") + + # 新方法(行为驱动,更安全) + update_shangjia_daily( + yonghuid=lianjie_obj.UserID, + amount=Decimal(str(dingdan_obj.Amount)), + action=1, # 1 = 派发 + order_id=dingdan_obj.OrderID, + ) + + return Response({ + 'code': 200, + 'msg': '提交成功', + 'data': response_data + }, status=status.HTTP_200_OK) + + except Order.DoesNotExist: + logger.error(f"订单不存在 - 订单ID: {lianjie_obj.OrderID}") + return Response({ + 'code': 500, + 'msg': '订单数据异常', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + except Exception as e: + logger.error(f"处理订单异常: {str(e)}", exc_info=True) + return Response({ + 'code': 500, + 'msg': '系统错误,请稍后重试', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def _get_game_type_name(self, leixing_id): + try: + gt = ShangpinLeixing.query.filter(id=leixing_id).first() + return gt.jieshao if gt else '游戏订单' + except Exception as e: + logger.error(f"获取游戏类型名称异常: {e}") + return '游戏订单' + + def send_order_notification_async(self, dingdan, youxi_nicheng, zhiding_dashou, zhuangtai): + """异步发送订单通知(客户填写订单后)""" + try: + # 避免阻塞主线程,使用线程异步执行 + def notification_task(): + try: + # 获取游戏类型名称 + try: + leixing_obj = ShangpinLeixing.query.get(id=dingdan.ProductTypeID) + game_type = leixing_obj.jieshao + except: + game_type = '未知游戏' + + # 构建订单信息 + order_info = { + 'dingdan_id': dingdan.OrderID, + 'game_type': game_type, + 'amount': str(dingdan.Amount), + 'order_desc': dingdan.Description[:50] if dingdan.Description else '新订单', + 'youxi_nicheng': youxi_nicheng, + 'order_type': '指定订单' if zhuangtai == 7 else '普通订单', + 'zhiding_dashou': zhiding_dashou or '' + } + + # 调用微信广播发送器 + sender = WeixinBroadcastSender() + result = sender.broadcast_order(order_info) + + if result['success']: + logger.info(f"【客户填写订单】微信通知发送成功: {result['msg']}") + else: + logger.warning(f"【客户填写订单】微信通知发送失败: {result['msg']}") + + except Exception as e: + logger.error(f"【客户填写订单】发送通知任务异常: {str(e)}") + + # 启动异步线程 + thread = threading.Thread(target=notification_task) + thread.daemon = True + thread.start() + + except Exception as e: + logger.error(f"【客户填写订单】启动通知线程异常: {str(e)}") + + + + + + diff --git a/config/views/merchant_templates.py b/config/views/merchant_templates.py new file mode 100644 index 0000000..f3d3a03 --- /dev/null +++ b/config/views/merchant_templates.py @@ -0,0 +1,486 @@ +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 ShangjiaMobanListView(APIView): + """ + 商家订单模板列表接口 + POST /peizhi/sjddmblb + 权限: JWT认证 + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + user = request.user + yonghu_id = user.UserUID + + leixing_id = request.data.get('shangpinTypeId') + if not leixing_id: + return Response({'code': 400, 'msg': '商品类型ID不能为空', 'data': {}}, status=400) + + get_type = int(request.data.get('getType', 2)) + page = int(request.data.get('page', 1)) + page_size = int(request.data.get('pageSize', 50)) + + keyword = request.data.get('keyword', '') + min_price = request.data.get('minPrice', '') + label_id = request.data.get('labelId', None) + if label_id is not None: + try: + label_id = int(label_id) + except (ValueError, TypeError): + label_id = None + + query = Q(UserID=yonghu_id, ProductTypeID=leixing_id) + + if get_type == 1: # 搜索模式 + if keyword: + keyword = str(keyword).strip() + try: + mid = int(keyword) + query &= Q(id=mid) | Q(TemplateDesc__icontains=keyword) + except ValueError: + query &= Q(TemplateDesc__icontains=keyword) + if min_price: + try: + query &= Q(Price__gte=Decimal(str(min_price))) + except: + pass + if label_id: + query &= Q(TitleID=label_id) + + mobans = ShangjiaMoban.query.filter(query).order_by('-id') + formatted = [] + for moban in mobans: + texiao_json = '' + label_name = '' + if moban.TitleID: + try: + ch = Chenghao.query.get(id=moban.TitleID) + label_name = ch.mingcheng + texiao_json = ch.texiao_miaoshu or '' + except Chenghao.DoesNotExist: + pass + formatted.append({ + 'mobanId': moban.id, + 'shangpinTypeId': moban.ProductTypeID, + 'jieshao': moban.TemplateDesc, + 'jiage': str(moban.Price), + 'fabushuliang': moban.PublishedCount, + 'commissionEnabled': moban.CommissionGrabEnabled, + 'commissionValue': str(moban.CommissionPrice) if moban.CommissionPrice is not None else None, + 'labelId': moban.TitleID, + 'labelName': label_name, + 'texiaoJson': texiao_json, + 'createTime': moban.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if moban.CreateTime else '' + }) + return Response({'code': 200, 'msg': '搜索成功', 'data': {'list': formatted, 'total': len(formatted), 'hasMore': False}}) + + # 分页模式 + queryset = ShangjiaMoban.query.filter(query).order_by('-id') + paginator = Paginator(queryset, page_size) + if page < 1 or page > paginator.num_pages: + return Response({'code': 400, 'msg': '页码超出范围', 'data': {}}, status=400) + + current_page = paginator.page(page) + formatted = [] + for moban in current_page.object_list: + texiao_json = '' + label_name = '' + if moban.TitleID: + try: + ch = Chenghao.query.get(id=moban.TitleID) + label_name = ch.mingcheng + texiao_json = ch.texiao_miaoshu or '' + except Chenghao.DoesNotExist: + pass + formatted.append({ + 'mobanId': moban.id, + 'shangpinTypeId': moban.ProductTypeID, + 'jieshao': moban.TemplateDesc, + 'jiage': str(moban.Price), + 'fabushuliang': moban.PublishedCount, + 'commissionEnabled': moban.CommissionGrabEnabled, + 'commissionValue': str(moban.CommissionPrice) if moban.CommissionPrice is not None else None, + 'labelId': moban.TitleID, + 'labelName': label_name, + 'texiaoJson': texiao_json, + 'createTime': moban.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if moban.CreateTime else '' + }) + + has_more = current_page.has_next() + if len(formatted) < page_size: + has_more = False + + return Response({ + 'code': 200, + 'msg': '获取成功', + 'data': { + 'list': formatted, + 'total': paginator.count, + 'hasMore': has_more, + 'currentPage': page, + 'pageSize': page_size, + 'totalPages': paginator.num_pages + } + }) + + except Exception as e: + logger.error(f"获取模板列表失败: {str(e)}", exc_info=True) + return Response({'code': 500, 'msg': '服务器内部错误', 'data': {}}, status=500) + + + + + +class ShangjiaTianjiaMobanView(APIView): + """ + 商家添加订单模板接口 + POST /peizhi/sjtjddmb + 权限: JWT认证 + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + # 1. 获取当前商家用户ID + user = request.user + yonghu_id = user.UserUID + + # 2. 接收并清洗参数,避免类型错误 + leixing_id = request.data.get('shangpinTypeId') + jieshao = request.data.get('jieshao', '').strip() if request.data.get('jieshao') else '' + jiage = request.data.get('jiage', '') + # 佣金相关 + commission_enabled = request.data.get('commissionEnabled', False) + commission_value = request.data.get('commissionValue', None) + # 标签ID(称号ID) + label_id = request.data.get('labelId', None) + + # 3. 必填验证 + if not leixing_id or not jieshao or not jiage: + logger.warning(f"参数缺失 - leixing_id:{leixing_id}, jieshao:{jieshao}, jiage:{jiage}") + return Response({ + 'code': 400, + 'msg': '缺少必填字段(订单类型、介绍、价格)', + 'data': {} + }, status=400) + + # 4. 价格格式验证 + try: + jiage_decimal = Decimal(str(jiage)) + if jiage_decimal <= 0: + return Response({'code': 400, 'msg': '价格必须大于0', 'data': {}}, status=400) + if jiage_decimal > 10000: + return Response({'code': 400, 'msg': '价格不能超过10000元', 'data': {}}, status=400) + except (InvalidOperation, ValueError, TypeError): + return Response({'code': 400, 'msg': '价格格式错误', 'data': {}}, status=400) + + # 5. 介绍长度验证 + if len(jieshao) > 100: + return Response({'code': 400, 'msg': '订单介绍不能超过100字', 'data': {}}, status=400) + + # 6. 佣金验证(若启用) + CommissionGrabEnabled = False + CommissionPrice = None + if commission_enabled: + CommissionGrabEnabled = True + if not commission_value: + return Response({'code': 400, 'msg': '开启佣金时,佣金金额不能为空', 'data': {}}, status=400) + try: + comm_decimal = Decimal(str(commission_value)) + if comm_decimal <= 0: + return Response({'code': 400, 'msg': '佣金必须大于0', 'data': {}}, status=400) + CommissionPrice = comm_decimal + except (InvalidOperation, ValueError, TypeError): + return Response({'code': 400, 'msg': '佣金格式错误', 'data': {}}, status=400) + + # 7. 标签ID清洗(前端可能传数字或字符串) + cleaned_label_id = None + if label_id is not None and str(label_id).strip(): + try: + cleaned_label_id = int(label_id) + except (ValueError, TypeError): + return Response({'code': 400, 'msg': '标签ID格式错误', 'data': {}}, status=400) + + # 8. 验证商品类型是否存在且可用 + try: + shangpin_obj = ShangpinLeixing.query.get( + id=leixing_id, + shenhezhuangtai=1 # 审核通过 + ) + except ShangpinLeixing.DoesNotExist: + return Response({'code': 404, 'msg': '商品类型不存在或已禁用', 'data': {}}, status=404) + + # 9. 检查同一用户、同一类型下是否已存在相同介绍的模板 + if ShangjiaMoban.query.filter( + UserID=yonghu_id, + ProductTypeID=leixing_id, + TemplateDesc=jieshao + ).exists(): + return Response({'code': 400, 'msg': '已存在相同介绍的模板', 'data': {}}, status=400) + + # 10. 执行数据库创建(事务) + with transaction.atomic(): + moban = ShangjiaMoban.query.create( + UserID=yonghu_id, + ProductTypeID=leixing_id, + TemplateDesc=jieshao, + Price=jiage_decimal, + PublishedCount=0, + CommissionGrabEnabled=CommissionGrabEnabled, + CommissionPrice=CommissionPrice, + TitleID=cleaned_label_id + ) + + # 11. 返回成功数据(字段与前端对齐) + return Response({ + 'code': 200, + 'msg': '模板创建成功', + 'data': { + 'mobanId': moban.id, + 'shangpinTypeId': leixing_id, + 'jieshao': moban.TemplateDesc, + 'jiage': str(moban.Price), + 'fabushuliang': moban.PublishedCount, + 'createTime': moban.CreateTime.strftime('%Y-%m-%d %H:%M:%S'), + 'commissionEnabled': moban.CommissionGrabEnabled, + 'commissionValue': str(moban.CommissionPrice) if moban.CommissionPrice is not None else None, + 'labelId': moban.TitleID + } + }) + + except Exception as e: + # 打印完整错误堆栈到日志 + logger.error(f"添加模板失败: {str(e)}", exc_info=True) + return Response({ + 'code': 500, + 'msg': '服务器内部错误,添加模板失败,请联系管理员', + 'data': {} + }, status=500) + + + + +class ShangjiaShanchuMobanView(APIView): + """ + 删除模板 + POST /peizhi/sjddmbsc + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + user = request.user + yonghu_id = user.UserUID + + moban_id = request.data.get('mobanId') + leixing_id = request.data.get('shangpinTypeId') + + if not all([moban_id, leixing_id]): + return Response({'code': 400, 'msg': '参数缺失', 'data': {}}, status=400) + + try: + moban = ShangjiaMoban.query.get(id=moban_id, UserID=yonghu_id, ProductTypeID=leixing_id) + except ShangjiaMoban.DoesNotExist: + return Response({'code': 404, 'msg': '模板不存在', 'data': {}}, status=404) + + # 强制硬删除 + moban.delete() + + return Response({'code': 200, 'msg': '删除成功', 'data': {'mobanId': moban_id, 'shangpinTypeId': leixing_id}}) + except Exception as e: + logger.error(f"删除模板失败: {str(e)}", exc_info=True) + return Response({'code': 500, 'msg': '服务器内部错误', 'data': {}}, status=500) + + + + + + +class ShangjiaGengxinMobanView(APIView): + """ + 商家更新订单模板接口 + POST /peizhi/sjddmbgx + 权限: JWT认证 + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + user = request.user + yonghu_id = user.UserUID + + moban_id = request.data.get('mobanId') + leixing_id = request.data.get('shangpinTypeId') + new_jieshao = request.data.get('jieshao', '').strip() + new_jiage = request.data.get('jiage', '').strip() + # 新增字段 + commission_enabled = request.data.get('commissionEnabled') + commission_value = request.data.get('commissionValue') + label_id = request.data.get('labelId') + + if not all([moban_id, leixing_id]): + return Response({'code': 400, 'msg': '模板ID和商品类型ID不能为空', 'data': {}}, status=400) + + # 查询模板 + try: + moban = ShangjiaMoban.query.get( + id=moban_id, + UserID=yonghu_id, + ProductTypeID=leixing_id + ) + except ShangjiaMoban.DoesNotExist: + return Response({'code': 404, 'msg': '模板不存在或无权修改', 'data': {}}, status=404) + + update_fields = [] + + # 修改介绍 + if new_jieshao: + if len(new_jieshao) > 100: + return Response({'code': 400, 'msg': '订单介绍不能超过100字', 'data': {}}, status=400) + if new_jieshao != moban.TemplateDesc: + moban.TemplateDesc = new_jieshao + update_fields.append('TemplateDesc') + + # 修改价格 + if new_jiage: + try: + jiage_decimal = Decimal(new_jiage) + if jiage_decimal <= 0 or jiage_decimal > 10000: + return Response({'code': 400, 'msg': '价格必须在0.1-10000元之间', 'data': {}}, status=400) + if jiage_decimal != moban.Price: + moban.Price = jiage_decimal + update_fields.append('Price') + except (InvalidOperation, ValueError, TypeError): + return Response({'code': 400, 'msg': '价格格式错误', 'data': {}}, status=400) + + # 修改标签 + if label_id is not None: # 允许传空字符串或不传,传0表示清空 + new_label = int(label_id) if label_id else None + if new_label != moban.TitleID: + moban.TitleID = new_label + update_fields.append('TitleID') + + # 修改佣金设置 + if commission_enabled is not None: + enabled = bool(commission_enabled) + if enabled: + if not commission_value: + return Response({'code': 400, 'msg': '佣金价格不能为空', 'data': {}}, status=400) + try: + comm_decimal = Decimal(commission_value) + if comm_decimal < 0.1 or comm_decimal > 50: + return Response({'code': 400, 'msg': '佣金需在0.1-50元之间', 'data': {}}, status=400) + if moban.CommissionGrabEnabled != True or moban.CommissionPrice != comm_decimal: + moban.CommissionGrabEnabled = True + moban.CommissionPrice = comm_decimal + update_fields.extend(['CommissionGrabEnabled', 'CommissionPrice']) + except (InvalidOperation, ValueError, TypeError): + return Response({'code': 400, 'msg': '佣金格式错误', 'data': {}}, status=400) + else: + if moban.CommissionGrabEnabled != False or moban.CommissionPrice is not None: + moban.CommissionGrabEnabled = False + moban.CommissionPrice = None + update_fields.extend(['CommissionGrabEnabled', 'CommissionPrice']) + + if not update_fields: + return Response({'code': 400, 'msg': '没有修改内容', 'data': {}}, status=400) + + # 检查介绍唯一性 + if 'TemplateDesc' in update_fields: + if ShangjiaMoban.query.filter( + UserID=yonghu_id, + ProductTypeID=leixing_id, + TemplateDesc=moban.TemplateDesc + ).exclude(id=moban_id).exists(): + return Response({'code': 400, 'msg': '已存在相同介绍的模板', 'data': {}}, status=400) + + with transaction.atomic(): + moban.save(update_fields=update_fields) + + # 返回最新数据 + return Response({ + 'code': 200, + 'msg': '模板修改成功', + 'data': { + 'mobanId': moban.id, + 'shangpinTypeId': moban.ProductTypeID, + 'jieshao': moban.TemplateDesc, + 'jiage': str(moban.Price), + 'fabushuliang': moban.PublishedCount, + 'commissionEnabled': moban.CommissionGrabEnabled, + 'commissionValue': str(moban.CommissionPrice) if moban.CommissionPrice else None, + 'labelId': moban.TitleID, + 'updateTime': moban.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') + } + }) + + except Exception as e: + logger = logging.getLogger(__name__) + logger.error(f"修改商家模板失败: {str(e)}", exc_info=True) + return Response({'code': 500, 'msg': '服务器内部错误', 'data': {}}, status=500) + + + + + diff --git a/config/views/miniapp_config.py b/config/views/miniapp_config.py new file mode 100644 index 0000000..4d13add --- /dev/null +++ b/config/views/miniapp_config.py @@ -0,0 +1,344 @@ +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 GetDynamicConfigView(APIView): + permission_classes = [AllowAny] + parser_classes = [JSONParser] + + def post(self, request): + try: + from jituan.services.display_config import get_tupianpeizhi_url + + from jituan.services.miniapp_assets import build_miniapp_assets_payload + + cos_oss_url = getattr(settings, 'COS_DOMAIN', '') + if cos_oss_url and not cos_oss_url.endswith('/'): + cos_oss_url += '/' + cos_bucket = getattr(settings, 'COS_BUCKET', '') + cos_region = getattr(settings, 'COS_REGION', 'ap-shanghai') + goeasy_host = 'hangzhou.goeasy.io' + goeasy_appkey = getattr(settings, 'GOEASY_APPKEY', '') + kefu_link = getattr(settings, 'KEFU_LINK', '') + kefu_enterprise_id = getattr(settings, 'KEFU_ENTERPRISE_ID', '') + rule_img = get_tupianpeizhi_url(request, 1) + avatar_img = get_tupianpeizhi_url(request, 2) + morentouxiang = avatar_img or 'beijing/morentouxiang.jpg' + dashouguize = rule_img or 'a_long/dashouguize.jpg' + + # 构造返回数据 + data = { + "code": 0, + "data": { + "cos": { + "bucket": cos_bucket, + "region": cos_region, + "ossImageUrl": cos_oss_url, + "uploadPathPrefix": "order/" + }, + "goEasy": { + "host": goeasy_host, + "appkey": goeasy_appkey + }, + "otherConfig": { + "morentouxiang": morentouxiang, + "dashouguize": dashouguize + }, + "kefu": { + "link": kefu_link, + "enterpriseId": kefu_enterprise_id + }, + "miniappAssets": build_miniapp_assets_payload(request), + } + } + return Response(data) + + except Exception as e: + logger.error(f"获取动态配置失败: {str(e)}", exc_info=True) + return Response({"code": 500, "msg": "服务器内部错误"}, status=500) + + +class HaibaoPeizhiView(APIView): + """小程序推广海报背景配置(按俱乐部)。""" + permission_classes = [AllowAny] + parser_classes = [JSONParser] + + def post(self, request): + try: + from jituan.services.display_config import _display_club_id + from jituan.services.miniapp_assets import get_poster_payload + + club_id = _display_club_id(request) + poster = get_poster_payload(club_id) + return Response({ + 'code': 0, + 'msg': 'success', + 'data': { + 'club_id': club_id, + 'poster': poster, + }, + }) + except Exception as e: + logger.error('获取海报配置失败: %s', e, exc_info=True) + return Response({'code': 500, 'msg': '服务器内部错误'}, status=500) + + + + +class PopupConfigView(APIView): + """ + 获取指定页面的弹窗配置 + 请求格式:POST { "pageKey": "dashouzhongxin" } + 返回格式:{ "code": 0, "data": { "serverTime": "...", "popups": [...] } } + """ + authentication_classes = [] + permission_classes = [AllowAny] + + def post(self, request): + # 获取参数 pageKey + page_key = request.data.get('pageKey') + if not page_key: + return Response({ + 'code': 400, + 'msg': '缺少参数 pageKey' + }, status=status.HTTP_400_BAD_REQUEST) + + # 3. 查询页面配置(按俱乐部) + from jituan.services.display_config import get_gonggao_content + from jituan.services.club_context import resolve_club_id_from_request + + club_id = resolve_club_id_from_request(request) + try: + popup_page = PopupPage.query.get(club_id=club_id, page_key=page_key, is_active=True) + except PopupPage.DoesNotExist: + # 该页面没有配置弹窗,返回空 + return Response({ + 'code': 0, + 'data': { + 'serverTime': timezone.now().isoformat(), + 'popups': [] + } + }) + + # 4. 查询该页面下所有启用的弹窗(且在有效时间范围内) + now = timezone.now() + popups = PopupConfig.query.filter( + page=popup_page, + is_active=True, + start_time__lte=now, + end_time__gte=now + ).prefetch_related('images') # 预加载图片,避免N+1查询 + # 按 sort_order 排序(model Meta 已定义) + + # 5. 序列化 + serializer = PopupConfigSerializer(popups, many=True) + + # 6. 返回数据 + return Response({ + 'code': 0, + 'data': { + 'serverTime': now.isoformat(), + 'ignore_user_mute': popup_page.ignore_user_mute, # 新增 + 'popups': serializer.data + } + }) + + + + +class GetWithdrawModeView(APIView): + """ + 获取提现模式配置 + 前端请求 /peizhi/hqtxzsym + 返回格式: { "code": 0, "data": { "mode": 1 or 2 } } + """ + permission_classes = [IsAuthenticated] # JWT 认证 + + def post(self, request, *args, **kwargs): + from jituan.services.club_context import resolve_club_id_from_request + from jituan.services.club_user import get_user_club_id + from jituan.services.withdraw_config import get_withdraw_mode + + user = getattr(request, 'user', None) + club_id = get_user_club_id(user) if user and getattr(user, 'is_authenticated', False) else None + if not club_id: + club_id = resolve_club_id_from_request(request) + mode = get_withdraw_mode(club_id) + + return Response({ + 'code': 0, + 'data': { + 'mode': mode, + 'club_id': club_id, + } + }) + + + + + +class CheckPhoneAuthView(APIView): + """ + 判断当前登录用户是否需要强制手机号认证 + + 规则: + - 已认证过手机号(PhoneVerified=True) → 不需要 + - 未认证时,满足以下任一条件 → 需要认证: + 打手:有会员购买记录,或押金 > 0 + 商家:已是商家(存在商家扩展表) + 管事:已是管事(存在管事扩展表) + 组长:已是组长(存在组长扩展表) + 老板:有下单记录(alldingdan > 0) + 子客服:已是有效商家客服(merchant_staff_member 活跃) + """ + + permission_classes = [IsAuthenticated] + + def post(self, request): + user = request.user + + if getattr(user, 'PhoneVerified', False): + return Response({'code': 0, 'need_auth': False}) + + if self._dashou_meets_threshold(user): + return Response({'code': 0, 'need_auth': True}) + if self._shangjia_meets_threshold(user): + return Response({'code': 0, 'need_auth': True}) + if self._guanshi_meets_threshold(user): + return Response({'code': 0, 'need_auth': True}) + if self._zuzhang_meets_threshold(user): + return Response({'code': 0, 'need_auth': True}) + if self._boss_meets_threshold(user): + return Response({'code': 0, 'need_auth': True}) + if self._staff_meets_threshold(user): + return Response({'code': 0, 'need_auth': True}) + + return Response({'code': 0, 'need_auth': False}) + + def _staff_meets_threshold(self, user): + """子客服:已是有效商家客服""" + try: + from merchant_ops.services.authz import get_active_staff_member + return get_active_staff_member(user) is not None + except Exception: + return False + + def _dashou_meets_threshold(self, user): + """打手:有会员记录、押金 > 0、或有过接单/成交订单""" + try: + dashou = UserDashou.query.get(user=user) + except UserDashou.DoesNotExist: + return False + if dashou.yajin and dashou.yajin > 0: + return True + from jituan.services.club_context import resolve_effective_club_id + club_id = resolve_effective_club_id(self.request, user) + if Huiyuangoumai.query.filter(yonghu_id=user.UserUID, club_id=club_id).exists(): + return True + if (dashou.jiedanzongliang or 0) > 0 or (dashou.chengjiaozongliang or 0) > 0: + return True + return Order.query.filter(PlayerID=user.UserUID).exists() + + def _shangjia_meets_threshold(self, user): + """商家:已是商家身份""" + return UserShangjia.query.filter(user=user).exists() + + def _guanshi_meets_threshold(self, user): + """管事:已是管事身份""" + return UserGuanshi.query.filter(user=user).exists() + + def _zuzhang_meets_threshold(self, user): + """组长:已是组长身份""" + return UserZuzhang.query.filter(user=user).exists() + + def _boss_meets_threshold(self, user): + """老板:有下单记录""" + try: + boss = UserBoss.query.get(user=user) + except UserBoss.DoesNotExist: + return False + return (boss.alldingdan or 0) > 0 + + +# ---------- 小程序客服话术 / 自动回复 ---------- + +MINIAPP_SCRIPT_DEFAULTS = { + 'cs_welcome': { + 'title': '', + 'content': '你好,请问有什么可以帮到您的?', + 'confirm_text': '', + 'cancel_text': '', + }, + 'chat_image_confirm': { + 'title': '温馨提示', + 'content': '平台禁止任何违法违规行为,聊天内容仅限于商家与接单员正常业务对接。严禁赌博、诈骗及其他非法行为,违者后果自负。', + 'confirm_text': '确认', + 'cancel_text': '取消', + }, + 'merchant_dispatch_confirm': { + 'title': '确认派单', + 'content': '确认向该打手派发此订单?', + 'confirm_text': '确认', + 'cancel_text': '取消', + }, +} + + diff --git a/config/views/qrcode_poster.py b/config/views/qrcode_poster.py new file mode 100644 index 0000000..77cab56 --- /dev/null +++ b/config/views/qrcode_poster.py @@ -0,0 +1,553 @@ +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 + + + + diff --git a/config/views/scripts.py b/config/views/scripts.py new file mode 100644 index 0000000..357036e --- /dev/null +++ b/config/views/scripts.py @@ -0,0 +1,343 @@ +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__) + +def _resolve_script_club_id(request): + from jituan.services.club_context import resolve_club_id_from_request + from jituan.services.club_user import get_user_club_id + user = getattr(request, 'user', None) + club_id = get_user_club_id(user) if user and getattr(user, 'is_authenticated', False) else None + if not club_id: + club_id = resolve_club_id_from_request(request) + return club_id or 'xq' + + +def _merge_script_scene(scene_key, row): + base = dict(MINIAPP_SCRIPT_DEFAULTS.get(scene_key) or {}) + if not row: + return base + if row.title: + base['title'] = row.title + if row.content: + base['content'] = row.content + if row.confirm_text: + base['confirm_text'] = row.confirm_text + if row.cancel_text: + base['cancel_text'] = row.cancel_text + return base + + +class HuashuHqView(APIView): + """POST /peizhi/huashuhq 批量获取场景话术""" + permission_classes = [IsAuthenticated] + + def post(self, request): + scene_keys = request.data.get('scene_keys') or [] + if not isinstance(scene_keys, list) or not scene_keys: + return Response({'code': 400, 'msg': 'scene_keys 不能为空', 'data': None}, status=400) + club_id = _resolve_script_club_id(request) + rows = MiniappScriptScene.query.filter( + club_id=club_id, scene_key__in=scene_keys, enabled=True, + ) + row_map = {r.scene_key: r for r in rows} + data = {} + for key in scene_keys: + if not isinstance(key, str) or not key.strip(): + continue + data[key.strip()] = _merge_script_scene(key.strip(), row_map.get(key.strip())) + return Response({'code': 200, 'msg': 'success', 'data': data}) + + +class HuashuMatchView(APIView): + """POST /peizhi/huashu_match 客服关键词自动回复匹配""" + permission_classes = [IsAuthenticated] + + def post(self, request): + scene_key = (request.data.get('scene_key') or '').strip() + user_text = (request.data.get('user_text') or '').strip() + if scene_key != 'cs_auto_reply': + return Response({'code': 400, 'msg': '不支持的 scene_key', 'data': {'matched': False, 'content': ''}}) + if not user_text: + return Response({'code': 200, 'msg': 'success', 'data': {'matched': False, 'content': ''}}) + club_id = _resolve_script_club_id(request) + rules = MiniappScriptAutoReply.query.filter( + club_id=club_id, enabled=True, + ).order_by('-sort_order', 'id') + text_lower = user_text.lower() + for rule in rules: + kw_raw = (rule.keyword or '').strip() + if not kw_raw: + continue + keywords = [k.strip() for k in kw_raw.split(',') if k.strip()] + for kw in keywords: + if kw.lower() in text_lower or kw in user_text: + reply_type = rule.reply_type or MiniappScriptAutoReply.REPLY_TEXT + return Response({ + 'code': 200, + 'msg': 'success', + 'data': { + 'matched': True, + 'content': rule.reply_content or '', + 'reply_type': reply_type, + }, + }) + return Response({'code': 200, 'msg': 'success', 'data': {'matched': False, 'content': ''}}) + + +# ---------- 后台:客服话术配置(/houtai/hthqhs、/houtai/htxghs) ---------- + +SCRIPT_CONFIG_PERMS = ('kfhs666', '8080a') +SCRIPT_SCENE_KEYS = ('chat_image_confirm', 'cs_welcome', 'merchant_dispatch_confirm') + + +def _has_script_config_perm(permissions): + return any(code in permissions for code in SCRIPT_CONFIG_PERMS) + + +def _script_scene_item_type(scene_key): + return 'text_display' if scene_key == 'cs_welcome' else 'confirm_modal' + + +def _serialize_script_scene(row): + return { + 'id': row.id, + 'scene_key': row.scene_key, + 'item_type': _script_scene_item_type(row.scene_key), + 'title': row.title or '', + 'content': row.content or '', + 'confirm_text': row.confirm_text or '', + 'cancel_text': row.cancel_text or '', + 'is_active': bool(row.enabled), + } + + +def _serialize_script_auto_reply(row): + keywords = [k.strip() for k in (row.keyword or '').split(',') if k.strip()] + return { + 'id': row.id, + 'scene_key': 'cs_auto_reply', + 'item_type': 'auto_reply', + 'title': '', + 'keywords': keywords, + 'match_mode': 'contains', + 'priority': row.sort_order or 0, + 'content': row.reply_content or '', + 'is_active': bool(row.enabled), + } + + +class MiniappScriptHoutaiListView(APIView): + """POST /houtai/hthqhs 后台获取话术与自动回复配置""" + permission_classes = [IsAuthenticated] + + def post(self, request): + from backend.utils import verify_kefu_permission + + username = request.data.get('username') + kefu, permissions = verify_kefu_permission(request, username) + if kefu is None: + return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) + if not _has_script_config_perm(permissions): + return Response({'code': 403, 'msg': '您没有权限访问客服聊天配置'}) + + club_id = _resolve_script_club_id(request) + scenes = MiniappScriptScene.query.filter( + club_id=club_id, scene_key__in=SCRIPT_SCENE_KEYS, + ).order_by('scene_key') + auto_rules = MiniappScriptAutoReply.query.filter(club_id=club_id).order_by('-sort_order', 'id') + + items = [_serialize_script_scene(r) for r in scenes] + items.extend(_serialize_script_auto_reply(r) for r in auto_rules) + return Response({'code': 0, 'data': {'items': items, 'club_id': club_id}}) + + +class MiniappScriptHoutaiModifyView(APIView): + """POST /houtai/htxghs 后台增删改话术与自动回复""" + permission_classes = [IsAuthenticated] + + def post(self, request): + from backend.utils import verify_kefu_permission + from jituan.services.display_config import forbid_display_write_in_all_scope + + username = request.data.get('username') + kefu, permissions = verify_kefu_permission(request, username) + if kefu is None: + return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) + if not _has_script_config_perm(permissions): + return Response({'code': 403, 'msg': '您没有权限操作客服聊天配置'}) + + action = (request.data.get('action') or '').strip() + if not action: + return Response({'code': 400, 'msg': '缺少 action 参数'}) + + club_id = _resolve_script_club_id(request) + + if action == 'delete': + return self._delete_item(request, club_id) + + deny = forbid_display_write_in_all_scope(request) + if deny: + return deny + + if action in ('create', 'update'): + item_type = (request.data.get('item_type') or '').strip() + if item_type == 'auto_reply': + return self._save_auto_reply(request, club_id, action) + return self._save_scene(request, club_id, action) + + return Response({'code': 400, 'msg': f'未知的 action: {action}'}) + + def _save_scene(self, request, club_id, action): + scene_key = (request.data.get('scene_key') or '').strip() + if scene_key not in SCRIPT_SCENE_KEYS: + return Response({'code': 400, 'msg': '不支持的 scene_key'}) + + content = (request.data.get('content') or '').strip() + if not content: + return Response({'code': 400, 'msg': '请填写正文内容'}) + + fields = { + 'title': (request.data.get('title') or '').strip(), + 'content': content, + 'confirm_text': (request.data.get('confirm_text') or '').strip(), + 'cancel_text': (request.data.get('cancel_text') or '').strip(), + 'enabled': bool(request.data.get('is_active', True)), + } + + if action == 'update': + row_id = request.data.get('id') + if not row_id: + return Response({'code': 400, 'msg': '缺少 id'}) + row = MiniappScriptScene.query.filter(id=row_id, club_id=club_id).first() + if not row: + return Response({'code': 404, 'msg': '配置不存在'}) + for k, v in fields.items(): + setattr(row, k, v) + row.save() + return Response({'code': 0, 'msg': '保存成功', 'data': _serialize_script_scene(row)}) + + existing = MiniappScriptScene.query.filter(club_id=club_id, scene_key=scene_key).first() + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + existing.save() + return Response({'code': 0, 'msg': '保存成功', 'data': _serialize_script_scene(existing)}) + + row = MiniappScriptScene.query.create(club_id=club_id, scene_key=scene_key, **fields) + return Response({'code': 0, 'msg': '创建成功', 'data': _serialize_script_scene(row)}) + + def _save_auto_reply(self, request, club_id, action): + keywords = request.data.get('keywords') or [] + if not isinstance(keywords, list) or not keywords: + return Response({'code': 400, 'msg': '请至少添加一个触发关键词'}) + + kw_list = [str(k).strip() for k in keywords if str(k).strip()] + if not kw_list: + return Response({'code': 400, 'msg': '请至少添加一个触发关键词'}) + + content = (request.data.get('content') or '').strip() + if not content: + return Response({'code': 400, 'msg': '请填写回复内容'}) + + fields = { + 'keyword': ','.join(kw_list), + 'reply_content': content, + 'sort_order': int(request.data.get('priority') or 0), + 'enabled': bool(request.data.get('is_active', True)), + 'reply_type': MiniappScriptAutoReply.REPLY_TEXT, + } + + if action == 'update': + row_id = request.data.get('id') + if not row_id: + return Response({'code': 400, 'msg': '缺少 id'}) + row = MiniappScriptAutoReply.query.filter(id=row_id, club_id=club_id).first() + if not row: + return Response({'code': 404, 'msg': '规则不存在'}) + for k, v in fields.items(): + setattr(row, k, v) + row.save() + return Response({'code': 0, 'msg': '保存成功', 'data': _serialize_script_auto_reply(row)}) + + row = MiniappScriptAutoReply.query.create(club_id=club_id, **fields) + return Response({'code': 0, 'msg': '创建成功', 'data': _serialize_script_auto_reply(row)}) + + def _delete_item(self, request, club_id): + from jituan.services.display_config import forbid_display_write_in_all_scope + + deny = forbid_display_write_in_all_scope(request) + if deny: + return deny + + row_id = request.data.get('id') + if not row_id: + return Response({'code': 400, 'msg': '缺少 id'}) + + row = MiniappScriptAutoReply.query.filter(id=row_id, club_id=club_id).first() + if row: + row.delete() + return Response({'code': 0, 'msg': '删除成功'}) + + row = MiniappScriptScene.query.filter(id=row_id, club_id=club_id).first() + if row: + return Response({'code': 400, 'msg': '场景话术不支持删除,请改为禁用'}) + + return Response({'code': 404, 'msg': '记录不存在'})