第一次提交:阿龙电竞 Django 后端最新版本

This commit is contained in:
XingQue
2026-06-14 02:23:24 +08:00
commit 4f7bf00224
9654 changed files with 5417000 additions and 0 deletions

0
utils/__init__.py Normal file
View File

3
utils/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
utils/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class UtilsConfig(AppConfig):
name = "utils"

120
utils/celery_utils.py Normal file
View File

@@ -0,0 +1,120 @@
"""
阿龙电竞 - Celery工具函数
提供通用的Celery辅助函数确保数据安全和操作稳定
【修复版】已解决装饰器导致Celery任务无法识别的问题
"""
import logging
from django.db import transaction
from datetime import datetime
from decimal import Decimal, InvalidOperation
import functools # 【核心修复】必须导入此模块
from django.conf import settings
# 配置日志
logger = logging.getLogger(__name__)
def safe_decimal_operation(value, default=Decimal('0.00')):
"""
安全处理Decimal操作防止类型转换错误
:param value: 输入值
:param default: 默认值
:return: Decimal对象
"""
try:
if value is None:
return default
if isinstance(value, Decimal):
return value
if isinstance(value, (int, float)):
return Decimal(str(value))
if isinstance(value, str):
# 移除可能的空白字符和特殊字符
value = value.strip()
if not value:
return default
return Decimal(value)
# 其他类型尝试转换
return Decimal(str(value))
except (InvalidOperation, TypeError, ValueError) as e:
logger.warning(f"Decimal转换失败: {e}, 输入值: {value}, 类型: {type(value)}, 使用默认值: {default}")
return default
except Exception as e:
logger.error(f"Decimal转换发生未知错误: {e}, 输入值: {value}, 使用默认值: {default}")
return default
def log_task_execution(task_name, success=True, details=""):
"""
记录任务执行日志
:param task_name: 任务名称
:param success: 是否成功
:param details: 详细信息
"""
try:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
status = "成功" if success else "失败"
log_message = f"[{timestamp}] {task_name} - {status}"
if details:
log_message += f" | {details}"
if success:
logger.info(log_message)
else:
logger.error(log_message)
return log_message
except Exception as e:
# 即使日志记录失败也不影响主流程
print(f"日志记录失败: {e}")
return f"日志记录失败: {e}"
def rollback_on_failure(operation_name):
"""
事务回滚装饰器(修复版)
:param operation_name: 操作名称
"""
def decorator(func):
@functools.wraps(func) # 【核心修复】保留原函数元数据使Celery能正确识别任务
def wrapper(*args, **kwargs):
try:
with transaction.atomic():
return func(*args, **kwargs)
except Exception as e:
logger.error(f"{operation_name}事务执行失败: {str(e)}")
raise
return wrapper
return decorator
def retry_on_failure(max_retries=3, delay=60):
"""
重试装饰器修复版可用于Celery任务
:param max_retries: 最大重试次数
:param delay: 重试延迟(秒)
"""
def decorator(func):
@functools.wraps(func) # 【核心修复】保留原函数元数据
def wrapper(*args, **kwargs):
retries = 0
while retries < max_retries:
try:
return func(*args, **kwargs)
except Exception as e:
retries += 1
if retries == max_retries:
logger.error(f"任务{func.__name__}重试{max_retries}次后失败: {str(e)}")
raise
logger.warning(f"任务{func.__name__}{retries}次失败,{delay}秒后重试: {str(e)}")
import time
time.sleep(delay)
return wrapper
return decorator

374
utils/chat_utils.py Normal file
View File

@@ -0,0 +1,374 @@
# utils/chat_utils.py
import json
import logging
import requests
from django.conf import settings
from dingdan.models import Dingdan, DingdanPingtai, DingdanShangjia
from peizhi.models import ClubConfig
from yonghu.models import UserMain, UserDashou, UserBoss, UserShangjia
logger = logging.getLogger('chat_utils')
# ========== 辅助配置获取(从 ClubConfig ==========
def _get_self_club_config():
if not hasattr(_get_self_club_config, '_config'):
try:
_get_self_club_config._config = ClubConfig.objects.filter(is_self=1).first()
except Exception as e:
logger.warning(f"获取我方俱乐部配置失败: {e}")
_get_self_club_config._config = None
return _get_self_club_config._config
def _get_self_goeasy_appkey():
cfg = _get_self_club_config()
if cfg and cfg.chat_api_key:
return cfg.chat_api_key
return getattr(settings, 'GOEASY_APPKEY', '')
def _get_self_goeasy_secret():
cfg = _get_self_club_config()
if cfg and cfg.chat_api_id:
return cfg.chat_api_id
return getattr(settings, 'GOEASY_SECRET', '')
def _get_self_storage_domain():
cfg = _get_self_club_config()
if cfg and cfg.storage_bucket_domain:
return cfg.storage_bucket_domain
return ''
def _get_self_default_avatar():
cfg = _get_self_club_config()
if cfg and cfg.club_avatar:
return cfg.club_avatar
return ''
# ========== 头像拼接 ==========
def _full_local_avatar(relative_url):
storage_domain = _get_self_storage_domain()
default_avatar = _get_self_default_avatar()
if not relative_url:
return default_avatar or ''
if relative_url.startswith('http'):
return relative_url
if storage_domain.endswith('/'):
return storage_domain + relative_url.lstrip('/')
return storage_domain + '/' + relative_url if storage_domain else relative_url
def _full_remote_avatar(storage_bucket_domain, relative_url):
if not relative_url:
return ''
if relative_url.startswith('http'):
return relative_url
if storage_bucket_domain.endswith('/'):
return storage_bucket_domain + relative_url.lstrip('/')
return storage_bucket_domain + '/' + relative_url if storage_bucket_domain else relative_url
# ========== GoEasy 订阅 ==========
def _subscribe_users_to_group(user_ids, group_ids, appkey=None, secret=None):
if not appkey:
appkey = _get_self_goeasy_appkey()
if not secret:
secret = _get_self_goeasy_secret()
if not appkey:
logger.error("GoEasy AppKey 未配置")
return False
url = 'https://rest-hangzhou.goeasy.io/v2/im/subscribe-groups'
body = {"appkey": appkey, "userIds": user_ids, "groupIds": group_ids}
headers = {"Content-Type": "application/json"}
if secret:
headers["Authorization"] = f"Bearer {secret}"
try:
resp = requests.post(url, headers=headers, json=body, timeout=10)
if resp.status_code == 200:
return True
logger.error(f"订阅失败: {resp.status_code} {resp.text}")
return False
except Exception as e:
logger.error(f"订阅异常: {e}", exc_info=True)
return False
# ========== 发送群消息(完整参数) ==========
def _send_group_message(appkey, secret, group_id, sender_id, sender_name, sender_avatar,
message_text, custom_payload=None, group_name=None, order_id=None, is_cross=0):
if not appkey:
appkey = _get_self_goeasy_appkey()
if not secret:
secret = _get_self_goeasy_secret()
if not appkey:
logger.error("GoEasy AppKey 未配置")
return False
url = 'https://rest-hangzhou.goeasy.io/v2/im/message'
# to.data 包含群聊展示信息及业务字段
to_data = {
"name": group_name or group_id,
"avatar": sender_avatar or "",
}
if order_id:
to_data["orderId"] = order_id
to_data["isCross"] = is_cross
payload_str = custom_payload.get("text", message_text) if custom_payload else message_text
request_body = {
"appkey": appkey,
"senderId": sender_id,
"senderData": {"avatar": sender_avatar, "name": sender_name},
"to": {
"type": "group",
"id": group_id,
"data": to_data
},
"type": "text",
"payload": payload_str
}
headers = {"Content-Type": "application/json"}
if secret:
headers["Authorization"] = f"Bearer {secret}"
try:
resp = requests.post(url, headers=headers, json=request_body, timeout=10)
if resp.status_code == 200:
return True
logger.error(f"群聊消息发送失败,状态码:{resp.status_code},响应:{resp.text}")
return False
except Exception as e:
logger.error(f"发送群聊消息异常: {e}", exc_info=True)
return False
# ========== 核心入口 ==========
def establish_order_chat(dingdan_id):
try:
order = Dingdan.objects.select_related('pingtai_kuozhan', 'shangjia_kuozhan').get(dingdan_id=dingdan_id)
except Dingdan.DoesNotExist:
logger.error(f"订单 {dingdan_id} 不存在")
return False
dashou_uid = order.jiedan_dashou_id
dashou_goeasy_id = f"Ds{dashou_uid}"
dashou_nickname = f'打手{dashou_uid[:6]}'
dashou_avatar_relative = ''
try:
dashou_user = UserMain.objects.get(yonghuid=dashou_uid)
dashou_avatar_relative = dashou_user.avatar or ''
dashou_profile = UserDashou.objects.get(user=dashou_user)
if dashou_profile.nicheng:
dashou_nickname = dashou_profile.nicheng
except Exception as e:
logger.warning(f"获取打手信息失败: {e}")
dashou_avatar = _full_local_avatar(dashou_avatar_relative)
appkey = _get_self_goeasy_appkey()
secret = _get_self_goeasy_secret()
if not appkey:
logger.error("GoEasy AppKey 未配置")
return False
# 判断是否跨平台
if order.is_cross != 1:
return _handle_local_order(order, dashou_goeasy_id, dashou_nickname, dashou_avatar, appkey, secret)
else:
if order.dispatch_type == 1:
# 我方派单,与普通订单处理一致
return _handle_local_order(order, dashou_goeasy_id, dashou_nickname, dashou_avatar, appkey, secret)
elif order.dispatch_type == 2:
return _handle_cross_partner_order(order, dashou_goeasy_id, dashou_nickname, dashou_avatar, appkey, secret)
else:
return False
# ========== 普通订单 / 我方派单 ==========
def _handle_local_order(order, dashou_goeasy_id, dashou_name, dashou_avatar, appkey, secret):
group_id = f"group_{order.dingdan_id}"
group_name = (order.jieshao[:20] + '') if order.jieshao and len(order.jieshao) > 20 else (order.jieshao or f"订单{order.dingdan_id}")
group_avatar = _full_local_avatar(order.tupian) if order.tupian else _full_local_avatar('')
partner_goeasy_id, partner_name, partner_avatar = _get_local_partner_info(order)
if not partner_goeasy_id:
return False
# 订阅双方
if not _subscribe_users_to_group([dashou_goeasy_id, partner_goeasy_id], [group_id], appkey, secret):
return False
# 1. 打手发送初始化消息
init_msg_text = f"订单已接单,内容:{order.jieshao},备注:{order.beizhu}游戏ID{order.nicheng}"
success1 = _send_group_message(
appkey, secret, group_id, dashou_goeasy_id, dashou_name, group_avatar,
init_msg_text, None, group_name=group_name, order_id=order.dingdan_id,
is_cross=order.is_cross
)
# 2. 【新增】派单方也发一条消息,确保他也能看到群聊
partner_msg = f"订单已确认,请开始服务。"
success2 = _send_group_message(
appkey, secret, group_id, partner_goeasy_id, partner_name, partner_avatar,
partner_msg, None, group_name=group_name, order_id=order.dingdan_id,
is_cross=order.is_cross
)
return success1 and success2
def _get_local_partner_info(order):
"""本地订单下单方:老板或商家"""
if order.fadan_pingtai == 1: # 老板
try:
ext = order.pingtai_kuozhan
laoban_id = ext.laoban_id
avatar_full = _full_local_avatar('')
nickname = f'老板{laoban_id[:6]}'
try:
boss_user = UserMain.objects.get(yonghuid=laoban_id)
avatar_full = _full_local_avatar(boss_user.avatar or '')
boss_profile = UserBoss.objects.get(user=boss_user)
if boss_profile.nickname:
nickname = boss_profile.nickname
except Exception:
pass
return f"Boss{laoban_id}", nickname, avatar_full
except Exception as e:
logger.error(f"获取老板信息失败: {e}")
return None, None, None
elif order.fadan_pingtai == 2: # 商家
try:
ext = order.shangjia_kuozhan
shangjia_id = ext.shangjia_id
nickname = ext.sjnicheng or f'商家{shangjia_id[:6]}'
avatar_full = _full_local_avatar('')
try:
sj_user = UserMain.objects.get(yonghuid=shangjia_id)
avatar_full = _full_local_avatar(sj_user.avatar or '')
except Exception:
pass
return f"Sj{shangjia_id}", nickname, avatar_full
except Exception as e:
logger.error(f"获取商家信息失败: {e}")
return None, None, None
else:
if order.user1_id:
return order.user1_id, "用户", _full_local_avatar('')
return None, None, None
# ========== 跨平台 + 对方派单 ==========
def _handle_cross_partner_order(order, dashou_goeasy_id, dashou_name, dashou_avatar, appkey, secret):
partner_club_id = order.partner_club_id
if not partner_club_id:
logger.warning("缺少 partner_club_id")
return _create_local_group_only(order, dashou_goeasy_id, dashou_name, dashou_avatar, appkey, secret)
try:
partner_config = ClubConfig.objects.get(club_id=partner_club_id, is_self=0)
except ClubConfig.DoesNotExist:
logger.error(f"未找到对方俱乐部配置: {partner_club_id}")
return _create_local_group_only(order, dashou_goeasy_id, dashou_name, dashou_avatar, appkey, secret)
partner_order_id = order.partner_order_id
if not partner_order_id:
logger.error("partner_order_id 为空")
return _create_local_group_only(order, dashou_goeasy_id, dashou_name, dashou_avatar, appkey, secret)
# 推断对方下单方标识
target_partner_goeasy_id = _get_cross_partner_identity(order, partner_order_id)
if not target_partner_goeasy_id:
logger.error("无法推断对方下单方标识")
return _create_local_group_only(order, dashou_goeasy_id, dashou_name, dashou_avatar, appkey, secret)
# 对方默认头像
partner_default_avatar = _full_remote_avatar(partner_config.storage_bucket_domain or '', partner_config.club_avatar or '') or _full_local_avatar('')
# ---- 1. 在我方建群 ----
local_group_id = f"group_{order.dingdan_id}"
local_group_name = (order.jieshao[:20] + '') if order.jieshao and len(order.jieshao) > 20 else f"跨平台订单{order.dingdan_id}"
local_group_avatar = _full_local_avatar(order.tupian) if order.tupian else _full_local_avatar('')
proxy_partner_id = f"{partner_order_id}_{target_partner_goeasy_id}"
proxy_partner_name = f"对方用户({partner_club_id})"
# 对方代理头像使用对方默认头像
proxy_partner_avatar = partner_default_avatar
if not _subscribe_users_to_group([dashou_goeasy_id, proxy_partner_id], [local_group_id], appkey, secret):
return False
# 1. 打手发送初始化消息
init_msg_text = f"跨平台订单已接单,内容:{order.jieshao},备注:{order.beizhu}"
_send_group_message(
appkey, secret, local_group_id, dashou_goeasy_id, dashou_name, local_group_avatar,
init_msg_text, None, group_name=local_group_name, order_id=order.dingdan_id,
is_cross=1
)
# 2. 【新增】对方代理也发一条消息,确保对方能看到群聊
partner_msg = f"订单已确认,请等待服务。"
_send_group_message(
appkey, secret, local_group_id, proxy_partner_id, proxy_partner_name, proxy_partner_avatar,
partner_msg, None, group_name=local_group_name, order_id=order.dingdan_id,
is_cross=1
)
# ---- 2. 在对方建群 ----
partner_appkey = partner_config.chat_api_key
partner_secret = partner_config.chat_api_id or ''
if not partner_appkey:
return True # 无对方 key仅完成我方建群
partner_group_id = f"group_{partner_order_id}"
partner_group_name = f"跨平台订单-我方打手已接单"
# 代理我方打手 ID
our_agent_id = f"{order.dingdan_id}_{dashou_goeasy_id}"
our_agent_name = f"[我方]{dashou_name}"
# 对方频道头像用对方俱乐部默认头像
partner_group_avatar = partner_default_avatar
if not _subscribe_users_to_group([our_agent_id, target_partner_goeasy_id], [partner_group_id], partner_appkey, partner_secret):
logger.error("向对方频道订阅失败")
return True
# 我方打手在对方频道发消息
cross_msg_text = f"我方打手已接单,订单:{order.jieshao},请确认"
_send_group_message(
partner_appkey, partner_secret, partner_group_id, our_agent_id, our_agent_name, partner_group_avatar,
cross_msg_text, None, group_name=partner_group_name, order_id=partner_order_id,
is_cross=1
)
# 【新增】对方用户在对方频道也发一条消息(以对方原始身份)
target_partner_name = f"用户{target_partner_goeasy_id}" # 简单命名,可按需优化
_send_group_message(
partner_appkey, partner_secret, partner_group_id, target_partner_goeasy_id, target_partner_name, partner_default_avatar,
"订单信息已收到。", None, group_name=partner_group_name, order_id=partner_order_id,
is_cross=1
)
return True
def _get_cross_partner_identity(order, partner_order_id):
"""
获取对方下单方的 GoEasy 标识。
优先从商家扩展表取 partner_yonghu_id否则按 partner_order_id 前缀推断。
"""
# 尝试从商家扩展表取(仅当发单平台为商家且存在 partner_yonghu_id
if order.fadan_pingtai == 2 and hasattr(order, 'shangjia_kuozhan'):
ext = order.shangjia_kuozhan
if ext and ext.partner_yonghu_id:
# 直接根据订单前缀构造标识
prefix = partner_order_id[:2].upper()
if prefix == 'SJ':
return f"Sj{ext.partner_yonghu_id}"
elif prefix == 'PT':
return f"Boss{ext.partner_yonghu_id}"
else:
return f"User{ext.partner_yonghu_id}"
# 后备:根据 partner_order_id 前缀推断
prefix = partner_order_id[:2].upper()
uid = partner_order_id[2:] if len(partner_order_id) > 2 else ''
if prefix == 'SJ':
return f"Sj{uid}"
elif prefix == 'PT':
return f"Boss{uid}"
else:
return f"User{uid}"
def _create_local_group_only(order, dashou_goeasy_id, dashou_name, dashou_avatar, appkey, secret):
group_id = f"group_{order.dingdan_id}"
_subscribe_users_to_group([dashou_goeasy_id], [group_id], appkey, secret)
return False

View File

@@ -0,0 +1,58 @@
# /opt/1panel/apps/openresty/nginx/www/sites/43.142.166.152.443/django/utils/exception_handler.py
from rest_framework.views import exception_handler
from rest_framework.response import Response
from rest_framework import status
import logging
import os
logger = logging.getLogger('django')
def custom_exception_handler(exc, context):
"""
生产环境安全异常处理器 - 不泄露任何敏感信息
基于你之前项目的代码,做了小优化
"""
# 先调用默认异常处理器
response = exception_handler(exc, context)
if response is not None:
# 🔥 记录原始错误信息到日志(便于排查)
request = context['request']
user_info = f"用户: {request.user.id if request.user.is_authenticated else '未登录'}"
logger.error(
f"API异常 - 路径: {request.path}, "
f"方法: {request.method}, {user_info}, "
f"状态码: {response.status_code}, 错误: {str(exc)}"
)
# 🔥 生产环境返回标准化错误,不暴露任何细节
error_messages = {
status.HTTP_400_BAD_REQUEST: {'code': 400, 'message': '请求参数错误'},
status.HTTP_401_UNAUTHORIZED: {'code': 401, 'message': '请先登录'},
status.HTTP_403_FORBIDDEN: {'code': 403, 'message': '权限不足'},
status.HTTP_404_NOT_FOUND: {'code': 404, 'message': '资源不存在'},
status.HTTP_405_METHOD_NOT_ALLOWED: {'code': 405, 'message': '请求方法不允许'},
status.HTTP_408_REQUEST_TIMEOUT: {'code': 408, 'message': '请求超时'},
status.HTTP_429_TOO_MANY_REQUESTS: {'code': 429, 'message': '请求过于频繁'},
status.HTTP_500_INTERNAL_SERVER_ERROR: {'code': 500, 'message': '服务器内部错误'},
status.HTTP_502_BAD_GATEWAY: {'code': 502, 'message': '网关错误'},
status.HTTP_503_SERVICE_UNAVAILABLE: {'code': 503, 'message': '服务暂不可用'},
status.HTTP_504_GATEWAY_TIMEOUT: {'code': 504, 'message': '网关超时'},
}
# 🔥 如果是调试模式,返回详细错误(便于开发)
from django.conf import settings
if settings.DEBUG:
return response
# 🔥 生产环境返回标准错误
standard_response = error_messages.get(
response.status_code,
{'code': response.status_code, 'message': '请求错误'}
)
return Response(standard_response, status=response.status_code)
# 对于非DRF异常这里可以根据需要处理
return response

51
utils/fadan_utils.py Normal file
View File

@@ -0,0 +1,51 @@
# utils/fadan_utils.py
from dingdan.models import Fadan
import logging
logger = logging.getLogger('xiaochengxu')
def check_fadan_qiangdan_eligible(yonghuid: str, action_type: int) -> tuple:
"""
通用罚单资格检查。
参数:
yonghuid : 被检查的用户 yonghuid
action_type : 1 = 抢单, 2 = 提现
返回:
(is_eligible: bool, message: str)
True 允许操作False 返回拦截原因。
"""
try:
if action_type == 1:
# 抢单:仅当存在“影响抢单”且“未解决”的罚单时拦截(待缴纳或申诉中)
blocked = Fadan.objects.filter(
beichufa_id=yonghuid,
yingxiang_qiangdan=1,
zhuangtai__in=[1, 3] # 1=待缴纳, 3=申诉中
).exists()
if blocked:
logger.info(f"用户 {yonghuid} 抢单被拦截:存在未解决的影响抢单罚单")
return (False, "您有未缴纳或申诉中的罚单,请先处理后再抢单")
return (True, "无罚单限制")
elif action_type == 2:
# 提现:只要存在任意未解决的罚单(待缴纳/申诉中),不论是否影响抢单,一律禁止提现
blocked = Fadan.objects.filter(
beichufa_id=yonghuid,
zhuangtai__in=[1, 3] # 1=待缴纳, 3=申诉中
).exists()
if blocked:
logger.info(f"用户 {yonghuid} 提现被拦截:存在未解决的罚单")
return (False, "您有未处理完的罚单,需全部完成或驳回后方可提现")
return (True, "罚单状态正常,可提现")
else:
logger.warning(f"未知的检查类型: {action_type}")
return (False, "罚单查询系统异常(无效的检查类型)")
except Exception as e:
logger.error(f"检查用户 {yonghuid} 罚单状态异常: {e}", exc_info=True)
# 安全起见,异常时禁止操作
return (False, "罚单查询系统繁忙,请稍后重试")

103
utils/goeasy_service.py Normal file
View File

@@ -0,0 +1,103 @@
# apps/dingdan/utils/goeasy_service.py
import requests
import json
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
class GoEasyService:
"""
GoEasy聊天服务工具类
用于建立用户间的私聊关系
"""
def __init__(self):
self.appkey = settings.GOEASY_APPKEY # 需要在settings.py中配置
self.secret = settings.GOEASY_SECRET # GoEasy控制台获取的Secret
self.rest_host = "https://rest-hz.goeasy.io" # 杭州区域
def create_private_chat(self, user1_id, user2_id, user1_info=None, user2_info=None):
"""
建立两个用户间的私聊关系
Args:
user1_id: 用户1在GoEasy的标识
user2_id: 用户2在GoEasy的标识
user1_info: 用户1的附加信息 (avatar, nickname等)
user2_info: 用户2的附加信息
Returns:
bool: 是否成功
"""
try:
# 构造消息数据
message_data = {
"appkey": self.appkey,
"senderId": "system_admin",
"senderData": {
"avatar": "/static/system_avatar.png",
"name": "系统通知"
},
"to": {
"type": "private",
"id": user1_id,
"data": user2_info or {
"avatar": "",
"name": f"用户{user2_id}"
}
},
"type": "system",
"payload": {
"action": "create_chat",
"partner_id": user2_id,
"message": "已为您建立聊天连接"
},
"notification": {
"title": "新聊天",
"body": "您有新的聊天连接"
}
}
# 发送请求
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.secret}"
}
response = requests.post(
f"{self.rest_host}/v2/im/message",
headers=headers,
json=message_data,
timeout=5 # 5秒超时
)
if response.status_code == 200:
logger.info(f"成功建立聊天关系: {user1_id}{user2_id}")
return True
else:
logger.error(f"建立聊天关系失败: {response.status_code}, {response.text}")
return False
except Exception as e:
logger.error(f"调用GoEasy API异常: {str(e)}")
return False
def establish_chat_for_order(self, order, dashou_yonghuid, dashou_info=None):
"""
为订单建立聊天关系
"""
# user1_id 是老板/商家标识 (从订单中获取)
# user2_id 是打手标识
user1_id = order.user1_id
user2_id = f"Ds{dashou_yonghuid}"
if not user1_id or not user2_id:
logger.warning(f"缺少用户标识,无法建立聊天: user1_id={user1_id}, user2_id={user2_id}")
return False
# 建立双向聊天关系
success1 = self.create_private_chat(user1_id, user2_id, user2_info=dashou_info)
success2 = self.create_private_chat(user2_id, user1_id)
return success1 or success2 # 只要有一个方向成功就认为成功

View File

3
utils/models.py Normal file
View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

510
utils/oss_utils.py Normal file
View File

@@ -0,0 +1,510 @@
# utils/oss_utils.py
import os
import uuid
from django.conf import settings
from PIL import Image, UnidentifiedImageError
import io
'''try:
from qcloud_cos import CosConfig, CosS3Client
TENCENT_COS_AVAILABLE = True
except ImportError:
TENCENT_COS_AVAILABLE = False
try:
import oss2
ALIYUN_OSS_AVAILABLE = True
except ImportError:
ALIYUN_OSS_AVAILABLE = False
def validate_image(file):
"""
验证图片文件
使用Pillow检查图片是否有效
"""
try:
# 打开图片但不保存到磁盘
img = Image.open(file)
img.verify() # 验证文件完整性
# 重置文件指针因为PIL读取后会移动指针
file.seek(0)
# 检查图片格式
allowed_formats = ['JPEG', 'PNG', 'GIF', 'WEBP', 'BMP']
if img.format not in allowed_formats:
return False, f"不支持{img.format}格式请使用JPEG、PNG、GIF、WEBP或BMP格式"
# 检查图片大小最大5MB
file_size = len(file.read())
file.seek(0)
if file_size > 5 * 1024 * 1024:
return False, "图片大小不能超过5MB"
# 检查图片尺寸最大2000x2000
img = Image.open(file)
file.seek(0)
if img.width > 3000 or img.height > 3000:
return False, "图片尺寸不能超过2000x2000像素"
return True, "图片验证通过"
except UnidentifiedImageError:
return False, "文件不是有效的图片"
except Exception as e:
return False, f"图片验证失败: {str(e)}"
def get_oss_client():
"""
获取OSS客户端
根据配置自动选择云厂商
"""
oss_type = getattr(settings, 'OSS_TYPE', 'tencent') # 默认为腾讯云
if oss_type == 'tencent' and TENCENT_COS_AVAILABLE:
# 腾讯云COS
config = CosConfig(
Region=getattr(settings, 'COS_REGION', 'ap-shanghai'),
SecretId=getattr(settings, 'COS_SECRET_ID', ''),
SecretKey=getattr(settings, 'COS_SECRET_KEY', ''),
Scheme=getattr(settings, 'COS_SCHEME', 'https')
)
return CosS3Client(config)
elif oss_type == 'aliyun' and ALIYUN_OSS_AVAILABLE:
# 阿里云OSS
auth = oss2.Auth(
getattr(settings, 'ALIYUN_ACCESS_KEY_ID', ''),
getattr(settings, 'ALIYUN_ACCESS_KEY_SECRET', '')
)
endpoint = getattr(settings, 'ALIYUN_OSS_ENDPOINT', '')
bucket_name = getattr(settings, 'ALIYUN_OSS_BUCKET', '')
return oss2.Bucket(auth, endpoint, bucket_name)
else:
raise ImportError(f"不支持{oss_type}云存储或相关SDK未安装")
def upload_to_oss(file_obj, file_path):
"""
通用上传文件到OSS
file_obj: Django的UploadedFile对象或类文件对象
file_path: 在OSS中的相对路径'avatar/123/abc.jpg'
返回: 文件访问URL
"""
try:
oss_type = getattr(settings, 'OSS_TYPE', 'tencent')
if oss_type == 'tencent':
return _upload_to_tencent_cos(file_obj, file_path)
elif oss_type == 'aliyun':
return _upload_to_aliyun_oss(file_obj, file_path)
else:
raise ValueError(f"不支持的OSS类型: {oss_type}")
except Exception as e:
print(f"OSS上传失败: {e}")
return None
def _upload_to_tencent_cos(file_obj, file_path):
"""
上传到腾讯云COS
"""
client = get_oss_client()
# 获取文件扩展名
ext = file_path.split('.')[-1].lower() if '.' in file_path else ''
content_type_map = {
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
'png': 'image/png', 'gif': 'image/gif',
'webp': 'image/webp', 'bmp': 'image/bmp'
}
content_type = content_type_map.get(ext, 'image/jpeg')
# 如果是Django的UploadedFile需要读取内容
if hasattr(file_obj, 'read'):
file_content = file_obj.read()
else:
file_content = file_obj
# 上传文件
response = client.put_object(
Bucket=getattr(settings, 'COS_BUCKET', ''),
Body=file_content,
Key=file_path,
ContentType=content_type,
ContentDisposition='inline'
)
# 返回完整URL
domain = getattr(settings, 'COS_DOMAIN', '')
return f"{domain}/{file_path}"
def _upload_to_aliyun_oss(file_obj, file_path):
"""
上传到阿里云OSS
"""
bucket = get_oss_client()
# 如果是Django的UploadedFile需要读取内容
if hasattr(file_obj, 'read'):
file_content = file_obj.read()
else:
file_content = file_obj
# 上传文件
result = bucket.put_object(file_path, file_content)
if result.status == 200:
# 阿里云OSS返回的URL
domain = getattr(settings, 'ALIYUN_OSS_DOMAIN', '')
return f"{domain}/{file_path}"
else:
return None
def delete_from_oss(file_url):
"""
从OSS删除文件
file_url: 可以是完整URL或相对路径
"""
try:
if not file_url:
return False
oss_type = getattr(settings, 'OSS_TYPE', 'tencent')
# 从URL中提取文件路径
if oss_type == 'tencent':
domain = getattr(settings, 'COS_DOMAIN', '')
# 判断是完整URL还是相对路径
if file_url.startswith('http'):
# 完整URL
if domain not in file_url:
return False # URL不属于我们的域名
file_key = file_url.replace(f"{domain}/", "")
else:
# 相对路径,直接使用
file_key = file_url
return _delete_from_tencent_cos(file_key)
elif oss_type == 'aliyun':
domain = getattr(settings, 'ALIYUN_OSS_DOMAIN', '')
if file_url.startswith('http'):
if domain not in file_url:
return False
file_key = file_url.replace(f"{domain}/", "")
else:
file_key = file_url
return _delete_from_aliyun_oss(file_key)
else:
return False
except Exception as e:
print(f"OSS删除失败: {e}")
return False
def _delete_from_tencent_cos(file_key):
"""
从腾讯云COS删除文件
"""
client = get_oss_client()
try:
client.delete_object(
Bucket=getattr(settings, 'COS_BUCKET', ''),
Key=file_key
)
return True
except Exception as e:
print(f"腾讯云COS删除失败: {e}")
return False
def _delete_from_aliyun_oss(file_key):
"""
从阿里云OSS删除文件
"""
bucket = get_oss_client()
try:
bucket.delete_object(file_key)
return True
except Exception as e:
print(f"阿里云OSS删除失败: {e}")
return False'''
# utils/oss_utils.py
import os
import uuid
from django.conf import settings
from PIL import Image, UnidentifiedImageError
import io
try:
from qcloud_cos import CosConfig, CosS3Client
TENCENT_COS_AVAILABLE = True
except ImportError:
TENCENT_COS_AVAILABLE = False
try:
import oss2
ALIYUN_OSS_AVAILABLE = True
except ImportError:
ALIYUN_OSS_AVAILABLE = False
# ========== 通用 MIME 类型映射(扩展支持非图片文件) ==========
MIME_TYPES = {
# 图片
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
'gif': 'image/gif', 'webp': 'image/webp', 'bmp': 'image/bmp',
# JSON / 文本
'json': 'application/json', 'txt': 'text/plain',
# 音频(小程序录音常见格式)
'mp3': 'audio/mpeg', 'aac': 'audio/aac', 'wav': 'audio/wav',
'm4a': 'audio/mp4', 'ogg': 'audio/ogg',
# 视频
'mp4': 'video/mp4', 'webm': 'video/webm',
# 二进制
'bin': 'application/octet-stream',
}
# ====================================================================
def validate_image(file):
"""
验证图片文件
使用Pillow检查图片是否有效
"""
try:
# 打开图片但不保存到磁盘
img = Image.open(file)
img.verify() # 验证文件完整性
# 重置文件指针因为PIL读取后会移动指针
file.seek(0)
# 检查图片格式
allowed_formats = ['JPEG', 'PNG', 'GIF', 'WEBP', 'BMP']
if img.format not in allowed_formats:
return False, f"不支持{img.format}格式请使用JPEG、PNG、GIF、WEBP或BMP格式"
# 检查图片大小最大5MB
file_size = len(file.read())
file.seek(0)
if file_size > 5 * 1024 * 1024:
return False, "图片大小不能超过5MB"
# 检查图片尺寸最大2000x2000
img = Image.open(file)
file.seek(0)
if img.width > 3000 or img.height > 3000:
return False, "图片尺寸不能超过2000x2000像素"
return True, "图片验证通过"
except UnidentifiedImageError:
return False, "文件不是有效的图片"
except Exception as e:
return False, f"图片验证失败: {str(e)}"
def get_oss_client():
"""
获取OSS客户端
根据配置自动选择云厂商
"""
oss_type = getattr(settings, 'OSS_TYPE', 'tencent') # 默认为腾讯云
if oss_type == 'tencent' and TENCENT_COS_AVAILABLE:
# 腾讯云COS
config = CosConfig(
Region=getattr(settings, 'COS_REGION', 'ap-shanghai'),
SecretId=getattr(settings, 'COS_SECRET_ID', ''),
SecretKey=getattr(settings, 'COS_SECRET_KEY', ''),
Scheme=getattr(settings, 'COS_SCHEME', 'https')
)
return CosS3Client(config)
elif oss_type == 'aliyun' and ALIYUN_OSS_AVAILABLE:
# 阿里云OSS
auth = oss2.Auth(
getattr(settings, 'ALIYUN_ACCESS_KEY_ID', ''),
getattr(settings, 'ALIYUN_ACCESS_KEY_SECRET', '')
)
endpoint = getattr(settings, 'ALIYUN_OSS_ENDPOINT', '')
bucket_name = getattr(settings, 'ALIYUN_OSS_BUCKET', '')
return oss2.Bucket(auth, endpoint, bucket_name)
else:
raise ImportError(f"不支持{oss_type}云存储或相关SDK未安装")
def upload_to_oss(file_obj, file_path, content_type=None):
"""
通用上传文件到OSS
file_obj: Django的UploadedFile对象或类文件对象
file_path: 在OSS中的相对路径'avatar/123/abc.jpg'
content_type: 可选MIME类型若不传则根据文件扩展名自动推断
返回: 文件访问URL
"""
try:
oss_type = getattr(settings, 'OSS_TYPE', 'tencent')
if oss_type == 'tencent':
return _upload_to_tencent_cos(file_obj, file_path, content_type)
elif oss_type == 'aliyun':
return _upload_to_aliyun_oss(file_obj, file_path)
else:
raise ValueError(f"不支持的OSS类型: {oss_type}")
except Exception as e:
print(f"OSS上传失败: {e}")
return None
def _upload_to_tencent_cos(file_obj, file_path, content_type=None):
"""
上传到腾讯云COS扩展支持任意文件类型
"""
client = get_oss_client()
# 若调用方未指定 Content-Type则根据扩展名自动推断
if not content_type:
ext = file_path.rsplit('.', 1)[-1].lower() if '.' in file_path else ''
content_type = MIME_TYPES.get(ext, 'application/octet-stream')
# 读取文件内容(兼容 Django UploadedFile 和普通 bytes
if hasattr(file_obj, 'read'):
file_content = file_obj.read()
else:
file_content = file_obj
# 上传文件
response = client.put_object(
Bucket=getattr(settings, 'COS_BUCKET', ''),
Body=file_content,
Key=file_path,
ContentType=content_type,
ContentDisposition='inline'
)
# 返回完整URL
domain = getattr(settings, 'COS_DOMAIN', '')
return f"{domain.rstrip('/')}/{file_path.lstrip('/')}"
def _upload_to_aliyun_oss(file_obj, file_path):
"""
上传到阿里云OSS保持原样未修改
"""
bucket = get_oss_client()
# 如果是Django的UploadedFile需要读取内容
if hasattr(file_obj, 'read'):
file_content = file_obj.read()
else:
file_content = file_obj
# 上传文件
result = bucket.put_object(file_path, file_content)
if result.status == 200:
# 阿里云OSS返回的URL
domain = getattr(settings, 'ALIYUN_OSS_DOMAIN', '')
return f"{domain}/{file_path}"
else:
return None
def delete_from_oss(file_url):
"""
从OSS删除文件
file_url: 可以是完整URL或相对路径
"""
try:
if not file_url:
return False
oss_type = getattr(settings, 'OSS_TYPE', 'tencent')
# 从URL中提取文件路径
if oss_type == 'tencent':
domain = getattr(settings, 'COS_DOMAIN', '')
# 判断是完整URL还是相对路径
if file_url.startswith('http'):
# 完整URL
if domain not in file_url:
return False # URL不属于我们的域名
file_key = file_url.replace(f"{domain}/", "")
else:
# 相对路径,直接使用
file_key = file_url
return _delete_from_tencent_cos(file_key)
elif oss_type == 'aliyun':
domain = getattr(settings, 'ALIYUN_OSS_DOMAIN', '')
if file_url.startswith('http'):
if domain not in file_url:
return False
file_key = file_url.replace(f"{domain}/", "")
else:
file_key = file_url
return _delete_from_aliyun_oss(file_key)
else:
return False
except Exception as e:
print(f"OSS删除失败: {e}")
return False
def _delete_from_tencent_cos(file_key):
"""
从腾讯云COS删除文件
"""
client = get_oss_client()
try:
client.delete_object(
Bucket=getattr(settings, 'COS_BUCKET', ''),
Key=file_key
)
return True
except Exception as e:
print(f"腾讯云COS删除失败: {e}")
return False
def _delete_from_aliyun_oss(file_key):
"""
从阿里云OSS删除文件
"""
bucket = get_oss_client()
try:
bucket.delete_object(file_key)
return True
except Exception as e:
print(f"阿里云OSS删除失败: {e}")
return False

18
utils/redis_lock.py Normal file
View File

@@ -0,0 +1,18 @@
import redis
from django.conf import settings
redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
password=settings.REDIS_PASSWORD,
db=2,
decode_responses=True
)
def acquire_lock(lock_key, timeout=10):
"""获取锁,返回是否成功"""
return redis_client.set(lock_key, '1', nx=True, ex=timeout)
def release_lock(lock_key):
"""释放锁"""
redis_client.delete(lock_key)

3
utils/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
utils/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

210
utils/wechat_v3.py Normal file
View File

@@ -0,0 +1,210 @@
# utils/wechat_v3.py
'''import time
import hashlib
import base64
import json
from urllib.parse import urlparse
import rsa
from Crypto.Cipher import AES
from django.conf import settings
def load_private_key():
"""加载商户私钥PEM格式"""
with open(settings.WECHAT_PAY_V3_CONFIG['PRIVATE_KEY_PATH'], 'rb') as f:
key_data = f.read()
return rsa.PrivateKey.load_pkcs1(key_data)
def build_authorization(method, url, body):
"""
生成V3接口的Authorization头
:param method: GET/POST
:param url: 完整URL含域名和路径
:param body: 请求体JSON字符串
:return: Authorization字符串
"""
private_key = load_private_key()
mchid = settings.WECHAT_PAY_V3_CONFIG['MCHID']
serial_no = settings.WECHAT_PAY_V3_CONFIG['CERT_SERIAL_NO']
# 提取URL路径不含query
parsed = urlparse(url)
path = parsed.path
if parsed.query:
path += '?' + parsed.query
timestamp = str(int(time.time()))
nonce = hashlib.md5((timestamp + 'wechat').encode()).hexdigest()[:32]
# 构造签名串
message = '\n'.join([method, path, timestamp, nonce, body]) + '\n'
# 使用私钥签名SHA256
signature = rsa.sign(message.encode('utf-8'), private_key, 'SHA-256')
signature_b64 = base64.b64encode(signature).decode('utf-8')
# 组装Authorization
auth = f'WECHATPAY2-SHA256-RSA2048 mchid="{mchid}",nonce_str="{nonce}",timestamp="{timestamp}",serial_no="{serial_no}",signature="{signature_b64}"'
return auth
def verify_wechat_sign(headers, body):
"""
验证微信回调的签名
需要提前从微信平台下载证书公钥,存放在 PLATFORM_CERT_DIR 下,文件名 = 序列号.pem
:param headers: 请求头
:param body: 原始请求体字符串
:return: bool
"""
serial = headers.get('Wechatpay-Serial')
signature = headers.get('Wechatpay-Signature')
timestamp = headers.get('Wechatpay-Timestamp')
nonce = headers.get('Wechatpay-Nonce')
if not all([serial, signature, timestamp, nonce]):
return False
# 构造验签名串
message = '\n'.join([timestamp, nonce, body]) + '\n'
# 读取对应序列号的平台证书公钥
cert_path = f"{settings.WECHAT_PAY_V3_CONFIG['PLATFORM_CERT_DIR']}/{serial}.pem"
try:
with open(cert_path, 'rb') as f:
pub_key = rsa.PublicKey.load_pkcs1_openssl_pem(f.read())
except FileNotFoundError:
# 如果证书不存在,可尝试下载(生产环境建议定时下载)
return False
signature_bytes = base64.b64decode(signature)
try:
rsa.verify(message.encode('utf-8'), signature_bytes, pub_key)
return True
except rsa.VerificationError:
return False
def decrypt_callback_ciphertext(associated_data, nonce, ciphertext):
"""
使用APIv3密钥解密回调中的加密数据
:param associated_data: 附加数据
:param nonce: 随机串
:param ciphertext: 密文Base64
:return: 解密后的JSON字符串
"""
api_v3_key = settings.WECHAT_PAY_V3_CONFIG['API_V3_KEY'].encode('utf-8')
nonce_bytes = nonce.encode('utf-8')
ciphertext_bytes = base64.b64decode(ciphertext)
# AES-GCM解密
cipher = AES.new(api_v3_key, AES.MODE_GCM, nonce=nonce_bytes)
if associated_data:
cipher.update(associated_data.encode('utf-8'))
plaintext = cipher.decrypt_and_verify(ciphertext_bytes[:-16], ciphertext_bytes[-16:])
return plaintext.decode('utf-8')'''
# utils/wechat_v3.py
import time
import hashlib
import base64
import json
from urllib.parse import urlparse
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
from Crypto.Cipher import AES
from django.conf import settings
import os
def load_private_key():
"""加载私钥(支持 PKCS#1 和 PKCS#8"""
key_path = settings.WECHAT_PAY_V3_CONFIG['PRIVATE_KEY_PATH']
with open(key_path, 'rb') as f:
key_data = f.read()
# cryptography 自动识别格式
private_key = serialization.load_pem_private_key(
key_data,
password=None,
backend=default_backend()
)
return private_key
def build_authorization(method, url, body):
"""生成V3接口的Authorization头"""
private_key = load_private_key()
mchid = settings.WECHAT_PAY_V3_CONFIG['MCHID']
serial_no = settings.WECHAT_PAY_V3_CONFIG['CERT_SERIAL_NO']
parsed = urlparse(url)
path = parsed.path
if parsed.query:
path += '?' + parsed.query
timestamp = str(int(time.time()))
nonce = hashlib.md5((timestamp + 'wechat').encode()).hexdigest()[:32]
# 构造签名串
message = '\n'.join([method, path, timestamp, nonce, body]) + '\n'
# 使用 cryptography 签名
signature = private_key.sign(
message.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
signature_b64 = base64.b64encode(signature).decode('utf-8')
auth = f'WECHATPAY2-SHA256-RSA2048 mchid="{mchid}",nonce_str="{nonce}",timestamp="{timestamp}",serial_no="{serial_no}",signature="{signature_b64}"'
return auth
def verify_wechat_sign(headers, body):
"""验证微信回调签名(需提前下载平台证书)"""
# 这部分使用 rsa 库验签,也可以改用 cryptography但 rsa 已足够
import rsa
serial = headers.get('Wechatpay-Serial')
signature = headers.get('Wechatpay-Signature')
timestamp = headers.get('Wechatpay-Timestamp')
nonce = headers.get('Wechatpay-Nonce')
if not all([serial, signature, timestamp, nonce]):
return False
message = '\n'.join([timestamp, nonce, body]) + '\n'
cert_path = f"{settings.WECHAT_PAY_V3_CONFIG['PLATFORM_CERT_DIR']}/{serial}.pem"
if not os.path.exists(cert_path):
return False
with open(cert_path, 'rb') as f:
pub_key = rsa.PublicKey.load_pkcs1_openssl_pem(f.read())
signature_bytes = base64.b64decode(signature)
try:
rsa.verify(message.encode('utf-8'), signature_bytes, pub_key)
return True
except rsa.VerificationError:
return False
def decrypt_callback_ciphertext(associated_data, nonce, ciphertext):
"""解密回调数据(使用 AES-GCM"""
api_v3_key = settings.WECHAT_PAY_V3_CONFIG['API_V3_KEY'].encode('utf-8')
nonce_bytes = nonce.encode('utf-8')
ciphertext_bytes = base64.b64decode(ciphertext)
cipher = AES.new(api_v3_key, AES.MODE_GCM, nonce=nonce_bytes)
if associated_data:
cipher.update(associated_data.encode('utf-8'))
plaintext = cipher.decrypt_and_verify(ciphertext_bytes[:-16], ciphertext_bytes[-16:])
return plaintext.decode('utf-8')

201
utils/weixin_broadcast.py Normal file
View File

@@ -0,0 +1,201 @@
# utils/weixin_broadcast.py - 强调试版本
import time
import logging
import json # 新增,用于格式化打印
import requests
from django.core.cache import cache
from django.conf import settings
from yonghu.models import OfficialAccountUser
logger = logging.getLogger('weixin_broadcast')
class WeixinBroadcastSender:
"""
微信模板消息广播发送器 - 调试版
"""
def __init__(self):
self.appid = getattr(settings, 'WEIXIN_OFFICIAL_APPID', '')
self.secret = getattr(settings, 'WEIXIN_OFFICIAL_SECRET', '')
self.template_id = getattr(settings, 'WEIXIN_TEMPLATE_ID', '')
# 发送控制参数
self.batch_size = 100
self.delay_between_batches = 1.5
def get_access_token(self):
"""获取服务号access_token"""
cache_key = f'weixin_official_token_{self.appid}'
token = cache.get(cache_key)
if token:
return token
url = 'https://api.weixin.qq.com/cgi-bin/token'
params = {'grant_type': 'client_credential', 'appid': self.appid, 'secret': self.secret}
try:
response = requests.get(url, params=params, timeout=10)
result = response.json()
if 'access_token' in result:
token = result['access_token']
expires_in = result.get('expires_in', 7200) - 300
cache.set(cache_key, token, expires_in)
logger.info("✅ 获取access_token成功")
return token
else:
logger.error(f"❌ 获取access_token失败: {result}")
return None
except Exception as e:
logger.error(f"❌ 获取access_token异常: {str(e)}")
return None
def build_order_message_data(self, order_info):
"""
构建订单消息数据 - 【调试关键点1】
打印传入的 order_info 和构建出的最终数据
"""
# 🟡 调试点1打印传入的参数
logger.info(
f"🟡【调试-输入】build_order_message_data 收到的 order_info: {json.dumps(order_info, ensure_ascii=False, indent=2)}")
# 这是你现在的构建逻辑,我们原样保留但加上调试
# 注意这里保持你目前的字段名short_thing5, short_thing4, thing15, amount8
data = {
"short_thing5": {
"value": order_info.get('order_type', '平台发单'),
"color": "#173177"
},
"short_thing4": {
"value": order_info.get('game_type', '游戏订单'),
"color": "#173177"
},
"thing15": {
"value": str(order_info.get('order_desc', '新订单'))[:20],
"color": "#173177"
},
"amount8": {
"value": f"{order_info.get('amount', '0')}",
"color": "#173177"
}
}
# 🟡 调试点2打印构建出的最终数据结构
logger.info(f"🟡【调试-输出】构建完成的模板数据 data 字段:")
for key, content in data.items():
logger.info(f" 字段名: '{key}' -> 值: '{content.get('value', '')}'")
logger.info(f"🟡【调试-输出】完整 data 结构: {json.dumps(data, ensure_ascii=False)}")
# 🔴 关键检查:确认 short_thing5 是否存在,值是否为空
if 'short_thing5' not in data:
logger.error("❌【致命错误】构建的数据中根本没有 'short_thing5' 这个字段!")
else:
st5_value = data['short_thing5'].get('value', '')
if not st5_value:
logger.error(f"❌【致命错误】'short_thing5' 字段的值为空!")
else:
logger.info(f"'short_thing5' 字段存在,值为: '{st5_value}'")
return data
def send_to_user(self, official_openid, template_data, order_id=None):
"""发送模板消息给单个用户 - 【调试关键点2】"""
# 🟡 调试点3打印每次发送前的数据
logger.info(f"🟡【调试-发送前】准备发送给 {official_openid[:10]}... 的数据:")
logger.info(f" 模板ID: {self.template_id}")
logger.info(
f" pagepath: pages/dingdan-xiangqing/dingdan-xiangqing?id={order_id}" if order_id else " 无pagepath")
for key, content in template_data.items():
logger.info(f" 字段 '{key}': '{content.get('value', '')}'")
access_token = self.get_access_token()
if not access_token:
return {'success': False, 'msg': '获取access_token失败', 'retry': True}
request_data = {
"touser": official_openid,
"template_id": self.template_id,
"data": template_data
}
if order_id and hasattr(settings, 'WEIXIN_APPID'):
request_data["miniprogram"] = {
"appid": settings.WEIXIN_APPID,
"pagepath": f"pages/jiedan/jiedan?id={order_id}"
}
send_url = f'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={access_token}'
try:
response = requests.post(send_url, json=request_data, timeout=10)
result = response.json()
logger.info(f"🟡【调试-微信响应】{official_openid[:10]}... 的发送结果: {result}")
if result.get('errcode') == 0:
return {'success': True, 'msg': '发送成功', 'msgid': result.get('msgid')}
else:
# 如果是未关注或拒收,更新本地状态
if result.get('errcode') in [43004, 43101]:
try:
OfficialAccountUser.objects.filter(official_openid=official_openid).update(is_subscribed=False)
except:
pass
return {'success': False, 'msg': f"[{result.get('errcode')}]{result.get('errmsg')}", 'retry': False}
except Exception as e:
logger.error(f"❌ 发送给用户异常: {official_openid[:10]}..., 错误: {str(e)}")
return {'success': False, 'msg': f'请求异常: {str(e)}', 'retry': True}
def broadcast_order(self, order_info):
"""广播订单消息给所有关注用户 - 【调试入口】"""
logger.info(f"🚀【开始广播】订单: {order_info.get('dingdan_id')}")
# 1. 获取所有关注用户
try:
subscribed_users = OfficialAccountUser.objects.filter(is_subscribed=True).values_list('official_openid',
flat=True)
total_users = subscribed_users.count()
logger.info(f"📊【广播统计】将从数据库查询到 {total_users} 个订阅用户")
if total_users == 0:
return {'success': True, 'msg': '无订阅用户', 'total': 0}
except Exception as e:
logger.error(f"❌ 获取订阅用户列表失败: {str(e)}")
return {'success': False, 'msg': f'获取用户失败: {str(e)}'}
# 2. 构建模板数据(这里会触发上面的调试打印)
template_data = self.build_order_message_data(order_info)
order_id = order_info.get('dingdan_id')
# 3. 分批发送
success_count = 0
fail_count = 0
current_batch = 0
openid_list = list(subscribed_users)
logger.info(f"🟡【调试-广播循环】开始遍历 {len(openid_list)} 个用户进行发送...")
for i, openid in enumerate(openid_list):
try:
result = self.send_to_user(openid, template_data, order_id)
if result['success']:
success_count += 1
else:
fail_count += 1
if fail_count <= 5: # 只打印前几个失败原因
logger.warning(f"⚠️ 发送失败 {openid[:10]}...: {result.get('msg')}")
# 分批控制
current_batch += 1
if current_batch >= self.batch_size:
time.sleep(self.delay_between_batches)
current_batch = 0
except Exception as e:
fail_count += 1
logger.error(f"❌ 处理用户 {openid[:10]}... 异常: {str(e)}")
logger.info(f"🏁【广播完成】订单 {order_id},总计: {total_users},成功: {success_count},失败: {fail_count}")
return {
'success': True,
'msg': f'广播完成: 成功{success_count}, 失败{fail_count}',
'total': total_users,
'success_count': success_count,
'fail_count': fail_count
}

89
utils/yaoqingma_utils.py Normal file
View File

@@ -0,0 +1,89 @@
"""
阿龙电竞陪玩平台 - 邀请码生成工具
生成规则:毫秒时间戳 + 用户ID + 随机数 -> Base62编码 -> 固定20位邀请码
特点:唯一性高、生成快、无序不可读
"""
import time
import random
# Base62 编码字符集 (数字+小写字母+大写字母)
_BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
_BASE62_LENGTH = 62
def base62_encode(num):
"""将数字转换为Base62字符串"""
if num == 0:
return _BASE62_CHARS[0]
result = []
while num > 0:
num, remainder = divmod(num, _BASE62_LENGTH)
result.append(_BASE62_CHARS[remainder])
# 反转结果字符串
return ''.join(reversed(result))
def chuangjianYaoqingma(yonghuid_str):
"""
创建唯一邀请码 (20位固定长度)
参数:
yonghuid_str: 用户ID字符串"123456"
返回:
20位的Base62编码邀请码字符串
"""
# 1. 获取当前毫秒时间戳 (13位)
timestamp_ms = int(time.time() * 1000)
# 2. 将用户ID字符串转换为数字 (确保处理字符串)
try:
# 先尝试直接转换整数
yonghuid_num = int(yonghuid_str)
except ValueError:
# 如果用户ID不是纯数字使用哈希值
yonghuid_num = abs(hash(yonghuid_str)) % (10 ** 8) # 取8位数字
# 3. 生成一个随机数 (0-9999)
random_salt = random.randint(0, 9999)
# 4. 组合数字: 时间戳(13位) + 用户ID数字(最多8位) + 随机数(4位)
# 示例: 1647850123456 + 123456 + 7890 = 16478501234561234567890
combined_num = int(f"{timestamp_ms}{yonghuid_num:08d}{random_salt:04d}")
# 5. Base62编码
encoded_str = base62_encode(combined_num)
# 6. 确保固定20位长度: 不足补0超过取后20位
# 理论上组合数字非常大编码后通常会超过20位
if len(encoded_str) < 20:
# 左补0 (理论上不会发生,但保持安全)
encoded_str = encoded_str.rjust(20, _BASE62_CHARS[0])
else:
# 取后20位 (更随机)
encoded_str = encoded_str[-20:]
return encoded_str
def yanzhengYaoqingmaWeiyixing(yaoqingma):
"""
验证邀请码是否有效 (简单的格式验证)
参数:
yaoqingma: 待验证的邀请码
返回:
bool: 邀请码格式是否有效
"""
if not yaoqingma or len(yaoqingma) != 20:
return False
# 检查是否只包含Base62字符
for char in yaoqingma:
if char not in _BASE62_CHARS:
return False
return True