第一次提交:小程序前端最新版本
This commit is contained in:
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
BIN
utils/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
utils/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/admin.cpython-313.pyc
Normal file
BIN
utils/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/apps.cpython-313.pyc
Normal file
BIN
utils/__pycache__/apps.cpython-313.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/celery_utils.cpython-313.pyc
Normal file
BIN
utils/__pycache__/celery_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/goeasy_service.cpython-313.pyc
Normal file
BIN
utils/__pycache__/goeasy_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/models.cpython-313.pyc
Normal file
BIN
utils/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/oss_utils.cpython-313.pyc
Normal file
BIN
utils/__pycache__/oss_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/wechat_v3.cpython-313.pyc
Normal file
BIN
utils/__pycache__/wechat_v3.cpython-313.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/weixin_broadcast.cpython-313.pyc
Normal file
BIN
utils/__pycache__/weixin_broadcast.cpython-313.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/yaoqingma_utils.cpython-313.pyc
Normal file
BIN
utils/__pycache__/yaoqingma_utils.cpython-313.pyc
Normal file
Binary file not shown.
3
utils/admin.py
Normal file
3
utils/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
5
utils/apps.py
Normal file
5
utils/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UtilsConfig(AppConfig):
|
||||
name = "utils"
|
||||
110
utils/celery_utils.py
Normal file
110
utils/celery_utils.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
阿龙电竞 - Celery工具函数
|
||||
提供通用的Celery辅助函数,确保数据安全和操作稳定
|
||||
"""
|
||||
|
||||
import logging
|
||||
from django.db import transaction
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
# 配置日志
|
||||
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):
|
||||
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):
|
||||
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
|
||||
662
utils/chat-core.js
Normal file
662
utils/chat-core.js
Normal file
@@ -0,0 +1,662 @@
|
||||
// utils/chat-core.js
|
||||
import GoEasy from '../static/lib/goeasy-2.13.24.esm.min';
|
||||
import { jianquanxian } from './imAuth/jianquanxian';
|
||||
|
||||
let _globalPrivateHandler = null;
|
||||
let _globalGroupHandler = null;
|
||||
let _globalConvHandler = null;
|
||||
|
||||
function initGlobalMessageSystem(app) {
|
||||
app.updateUnreadCount = (count) => updateUnreadCount(app, count);
|
||||
app.disconnectGoEasy = () => disconnectGoEasy(app);
|
||||
app.ensureConnection = () => ensureConnection(app);
|
||||
app.connectWithIdentity = (identityType, userId, isAutoRestore) => connectWithIdentity(app, identityType, userId, isAutoRestore);
|
||||
app.connectForCurrentRole = () => connectForCurrentRole(app);
|
||||
app.switchRoleAndReconnect = (newRole) => switchRoleAndReconnect(app, newRole);
|
||||
app.toggleDoNotDisturb = (enabled) => toggleDoNotDisturb(app, enabled);
|
||||
app.toggleNotificationMute = (enabled) => toggleNotificationMute(app, enabled);
|
||||
app.closeNotification = () => closeNotification(app);
|
||||
app.loadConversations = () => loadConversations(app);
|
||||
app.updateCurrentPageState = () => updateCurrentPageState(app);
|
||||
app.getSavedConnection = () => getSavedConnection(app);
|
||||
app.clearSavedConnection = () => clearSavedConnection(app);
|
||||
app.saveConnectionState = () => saveConnectionState(app);
|
||||
app.handleNewMessage = (message) => handleNewMessage(app, message);
|
||||
|
||||
loadUserSettings(app);
|
||||
initNetworkListener(app);
|
||||
initAppStateListener(app);
|
||||
checkAndRestoreConnection(app);
|
||||
setupEventSystem(app);
|
||||
setupMessageListeners(app);
|
||||
}
|
||||
|
||||
function getCurrentGoEasyUserId() {
|
||||
return (wx.goEasy && wx.goEasy.im && wx.goEasy.im.userId) || null;
|
||||
}
|
||||
|
||||
function loadUserSettings(app) {
|
||||
try {
|
||||
const messageSettings = wx.getStorageSync(app.globalData.messageManager.cacheKeys.messageSettings);
|
||||
if (messageSettings) {
|
||||
app.globalData.messageManager = { ...app.globalData.messageManager, ...messageSettings };
|
||||
}
|
||||
const connectionSettings = wx.getStorageSync(app.globalData.goEasyConnection.cacheKeys.savedConnection);
|
||||
if (connectionSettings) {
|
||||
app.globalData.goEasyConnection = { ...app.globalData.goEasyConnection, ...connectionSettings };
|
||||
}
|
||||
const savedUnread = wx.getStorageSync(app.globalData.messageManager.cacheKeys.unreadTotal);
|
||||
if (savedUnread !== '') {
|
||||
app.globalData.messageManager.unreadTotal = savedUnread;
|
||||
updateTabBarBadge(app, savedUnread);
|
||||
}
|
||||
const savedMessages = wx.getStorageSync(app.globalData.messageManager.cacheKeys.latestMessages);
|
||||
if (savedMessages) {
|
||||
app.globalData.messageManager.latestMessages = savedMessages;
|
||||
}
|
||||
const notificationMuted = wx.getStorageSync(app.globalData.messageManager.cacheKeys.notificationMuted);
|
||||
if (notificationMuted !== '') {
|
||||
app.globalData.messageManager.notificationMuted = notificationMuted;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('加载用户设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function initNetworkListener(app) {
|
||||
wx.onNetworkStatusChange((res) => {
|
||||
if (res.isConnected) {
|
||||
ensureConnection(app);
|
||||
} else {
|
||||
app.globalData.goEasyConnection.status = 'disconnected';
|
||||
app.emitEvent('connectionChanged', { status: 'disconnected', reason: 'network' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initAppStateListener(app) {
|
||||
wx.onAppShow(() => {
|
||||
ensureConnection(app);
|
||||
updateCurrentPageState(app);
|
||||
// ✅ 连接正常时,主动刷一次未读角标
|
||||
if (wx.goEasy.getConnectionStatus && wx.goEasy.getConnectionStatus() === 'connected') {
|
||||
loadConversations(app);
|
||||
}
|
||||
});
|
||||
wx.onAppHide(() => {
|
||||
pauseHeartbeat(app);
|
||||
saveConnectionState(app);
|
||||
});
|
||||
}
|
||||
|
||||
function checkAndRestoreConnection(app) {
|
||||
try {
|
||||
const savedConnection = wx.getStorageSync(app.globalData.goEasyConnection.cacheKeys.savedConnection);
|
||||
if (savedConnection && savedConnection.userId && savedConnection.identityType) {
|
||||
const now = Date.now();
|
||||
const lastTime = savedConnection.lastConnectTime || 0;
|
||||
const hoursDiff = (now - lastTime) / (1000 * 60 * 60);
|
||||
const validityHours = app.globalData.goEasyConnection.config.cacheValidityHours || 12;
|
||||
if (hoursDiff > validityHours) {
|
||||
clearSavedConnection(app);
|
||||
} else {
|
||||
ensureConnection(app);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('检查保存的连接信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventSystem(app) {
|
||||
app.globalData.eventListeners = {};
|
||||
}
|
||||
|
||||
// 🔥 修改点:不依赖 saved 缓存,只要连接状态正常就刷新角标
|
||||
function ensureConnection(app) {
|
||||
const connectionStatus = wx.goEasy.getConnectionStatus ? wx.goEasy.getConnectionStatus() : 'disconnected';
|
||||
if (connectionStatus === 'connected' || connectionStatus === 'reconnected') {
|
||||
setupMessageListeners(app);
|
||||
loadConversations(app); // 每次进入前台都刷新未读
|
||||
return;
|
||||
}
|
||||
|
||||
// 连接不存在时尝试恢复
|
||||
const saved = getSavedConnection(app);
|
||||
if (!saved || !saved.userId || !saved.identityType) return;
|
||||
|
||||
connectWithIdentity(app, saved.identityType, saved.userId, true);
|
||||
}
|
||||
|
||||
function getSavedConnection(app) {
|
||||
try {
|
||||
const saved = wx.getStorageSync(app.globalData.goEasyConnection.cacheKeys.savedConnection);
|
||||
return saved ? JSON.parse(saved) : null;
|
||||
} catch (error) {
|
||||
console.error('获取保存的连接信息失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveConnectionState(app) {
|
||||
try {
|
||||
const { goEasyConnection } = app.globalData;
|
||||
const connectionState = {
|
||||
userId: goEasyConnection.userId,
|
||||
identityType: goEasyConnection.identityType,
|
||||
lastConnectTime: Date.now(),
|
||||
autoReconnect: goEasyConnection.autoReconnect,
|
||||
status: goEasyConnection.status,
|
||||
};
|
||||
wx.setStorageSync(goEasyConnection.cacheKeys.savedConnection, JSON.stringify(connectionState));
|
||||
} catch (error) {
|
||||
console.warn('保存连接状态失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSavedConnection(app) {
|
||||
const { cacheKeys } = app.globalData.goEasyConnection;
|
||||
wx.removeStorageSync(cacheKeys.savedConnection);
|
||||
wx.removeStorageSync(cacheKeys.userId);
|
||||
wx.removeStorageSync(cacheKeys.identityType);
|
||||
app.globalData.goEasyConnection.userId = '';
|
||||
app.globalData.goEasyConnection.identityType = '';
|
||||
app.globalData.goEasyConnection.lastConnectTime = 0;
|
||||
}
|
||||
|
||||
function disconnectGoEasy(app) {
|
||||
return new Promise((resolve) => {
|
||||
app.globalData.goEasyConnection.status = 'disconnected';
|
||||
app.globalData.goEasyConnection.autoReconnect = false;
|
||||
stopHeartbeat(app);
|
||||
removeGlobalMessageListeners();
|
||||
if (wx.goEasy && wx.goEasy.disconnect) {
|
||||
wx.goEasy.disconnect({
|
||||
onSuccess: () => {
|
||||
clearSavedConnection(app);
|
||||
app.globalData.messageManager.unreadTotal = 0;
|
||||
updateTabBarBadge(app, 0);
|
||||
app.emitEvent('connectionChanged', { status: 'disconnected', manual: true });
|
||||
resolve();
|
||||
},
|
||||
onFailed: () => {
|
||||
clearSavedConnection(app);
|
||||
app.globalData.messageManager.unreadTotal = 0;
|
||||
updateTabBarBadge(app, 0);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
clearSavedConnection(app);
|
||||
app.globalData.messageManager.unreadTotal = 0;
|
||||
updateTabBarBadge(app, 0);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function connectWithIdentity(app, identityType, userId, isAutoRestore = false) {
|
||||
const quanxian = await jianquanxian(app);
|
||||
if (!quanxian.allowed) return Promise.reject(quanxian.reason);
|
||||
|
||||
app.globalData.goEasyConnection.autoReconnect = true;
|
||||
app.globalData.goEasyConnection.status = 'connecting';
|
||||
app.globalData.goEasyConnection.userId = userId;
|
||||
app.globalData.goEasyConnection.identityType = identityType;
|
||||
if (!isAutoRestore) app.globalData.goEasyConnection.reconnectAttempts = 0;
|
||||
saveConnectionState(app);
|
||||
|
||||
const uid = wx.getStorageSync('uid');
|
||||
const currentUser = app.globalData.currentUser || {
|
||||
id: uid,
|
||||
name: '用户' + (uid ? uid.substring(0, 6) : ''),
|
||||
avatar: app.globalData.ossImageUrl + app.globalData.morentouxiang,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.goEasy.connect({
|
||||
id: userId,
|
||||
data: { name: currentUser.name, avatar: currentUser.avatar, identityType },
|
||||
onSuccess: () => {
|
||||
app.globalData.goEasyConnection.status = 'connected';
|
||||
app.globalData.goEasyConnection.lastConnectTime = Date.now();
|
||||
app.globalData.goEasyConnection.reconnectAttempts = 0;
|
||||
saveConnectionState(app);
|
||||
startHeartbeat(app);
|
||||
setupMessageListeners(app);
|
||||
loadConversations(app); // 连接成功立刻拉未读数
|
||||
app.emitEvent('connectionChanged', { status: 'connected', identityType, userId });
|
||||
if (!isAutoRestore) {
|
||||
wx.showToast({ title: '连接成功', icon: 'success', duration: 2000 });
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
onFailed: (error) => {
|
||||
console.error('GoEasy连接失败:', error);
|
||||
if (error.code === 900) {
|
||||
app.globalData.goEasyConnection.autoReconnect = false;
|
||||
app.globalData.goEasyConnection.status = 'disconnected';
|
||||
clearSavedConnection(app);
|
||||
stopHeartbeat(app);
|
||||
wx.showModal({
|
||||
title: '消息功能暂不可用',
|
||||
content: '当前使用人数爆满,消息功能暂时无法使用。请联系管理员升级套餐后重试。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了'
|
||||
});
|
||||
app.emitEvent('connectionChanged', { status: 'disconnected', error, reason: 'overcapacity' });
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
app.globalData.goEasyConnection.status = 'disconnected';
|
||||
app.emitEvent('connectionChanged', { status: 'disconnected', error, isAutoRestore });
|
||||
const { autoReconnect, reconnectAttempts, maxReconnectAttempts } = app.globalData.goEasyConnection;
|
||||
if (autoReconnect && reconnectAttempts < maxReconnectAttempts) {
|
||||
app.globalData.goEasyConnection.reconnectAttempts++;
|
||||
setTimeout(() => {
|
||||
if (app.globalData.goEasyConnection.autoReconnect) {
|
||||
connectWithIdentity(app, identityType, userId, true);
|
||||
}
|
||||
}, app.globalData.goEasyConnection.config.reconnectDelay);
|
||||
reject(error);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function connectForCurrentRole(app) {
|
||||
const uid = wx.getStorageSync('uid');
|
||||
if (!uid) return;
|
||||
const role = app.globalData.currentRole || 'normal';
|
||||
const prefixMap = { normal: 'Boss', dashou: 'Ds', shangjia: 'Sj', guanshi: 'Gs', zuzhang: 'Zz' , kaoheguan: 'Kh'};
|
||||
const prefix = prefixMap[role] || 'Boss';
|
||||
const targetUserId = prefix + uid;
|
||||
|
||||
const currentUserId = getCurrentGoEasyUserId();
|
||||
if (currentUserId === targetUserId) {
|
||||
loadConversations(app);
|
||||
setupMessageListeners(app);
|
||||
return;
|
||||
}
|
||||
if (currentUserId) {
|
||||
wx.goEasy.disconnect({ onSuccess: () => {}, onFailed: () => {} });
|
||||
}
|
||||
connectWithIdentity(app, role, targetUserId, false);
|
||||
}
|
||||
|
||||
async function switchRoleAndReconnect(app, newRole) {
|
||||
try {
|
||||
await disconnectGoEasy(app);
|
||||
app.globalData.currentRole = newRole;
|
||||
wx.setStorageSync('currentRole', newRole);
|
||||
const uid = wx.getStorageSync('uid');
|
||||
const prefixMap = { normal: 'Boss', dashou: 'Ds', shangjia: 'Sj', guanshi: 'Gs', zuzhang: 'Zz' , kaoheguan: 'Kh'};
|
||||
const prefix = prefixMap[newRole] || 'Boss';
|
||||
const targetUserId = prefix + uid;
|
||||
await connectWithIdentity(app, newRole, targetUserId, false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
wx.showToast({ title: '聊天功能不可用,可能是人数爆满或未充值会员', icon: 'none' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeat(app) {
|
||||
const { goEasyConnection } = app.globalData;
|
||||
stopHeartbeat(app);
|
||||
goEasyConnection.heartbeatInterval = setInterval(() => {
|
||||
if (goEasyConnection.status === 'connected' && wx.goEasy?.im) {
|
||||
wx.goEasy.im.ping({
|
||||
onSuccess: () => {},
|
||||
onFailed: (error) => {
|
||||
console.error('心跳失败:', error);
|
||||
goEasyConnection.status = 'reconnecting';
|
||||
attemptReconnect(app);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, goEasyConnection.config.heartbeatInterval);
|
||||
}
|
||||
|
||||
function stopHeartbeat(app) {
|
||||
if (app.globalData.goEasyConnection.heartbeatInterval) {
|
||||
clearInterval(app.globalData.goEasyConnection.heartbeatInterval);
|
||||
app.globalData.goEasyConnection.heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function pauseHeartbeat(app) { stopHeartbeat(app); }
|
||||
|
||||
function attemptReconnect(app) {
|
||||
const { goEasyConnection } = app.globalData;
|
||||
if (!goEasyConnection.autoReconnect) return;
|
||||
if (goEasyConnection.userId && goEasyConnection.identityType) {
|
||||
connectWithIdentity(app, goEasyConnection.identityType, goEasyConnection.userId, true);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 消息监听器 ---------- */
|
||||
function removeGlobalMessageListeners() {
|
||||
if (_globalPrivateHandler) {
|
||||
wx.goEasy.im.off(wx.GoEasy.IM_EVENT.PRIVATE_MESSAGE_RECEIVED, _globalPrivateHandler);
|
||||
_globalPrivateHandler = null;
|
||||
}
|
||||
if (_globalGroupHandler) {
|
||||
wx.goEasy.im.off(wx.GoEasy.IM_EVENT.GROUP_MESSAGE_RECEIVED, _globalGroupHandler);
|
||||
_globalGroupHandler = null;
|
||||
}
|
||||
if (_globalConvHandler) {
|
||||
wx.goEasy.im.off(wx.GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, _globalConvHandler);
|
||||
_globalConvHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupMessageListeners(app) {
|
||||
removeGlobalMessageListeners();
|
||||
|
||||
_globalPrivateHandler = (message) => {
|
||||
app.handleNewMessage(message);
|
||||
};
|
||||
_globalGroupHandler = (message) => {
|
||||
app.handleNewMessage(message);
|
||||
};
|
||||
_globalConvHandler = (conversations) => {
|
||||
const unreadTotal = conversations.unreadTotal || 0;
|
||||
app.globalData.messageManager.unreadTotal = unreadTotal;
|
||||
updateTabBarBadge(app, unreadTotal);
|
||||
wx.setStorageSync(app.globalData.messageManager.cacheKeys.unreadTotal, unreadTotal);
|
||||
if (typeof app.emitEvent === 'function') {
|
||||
app.emitEvent('conversationsUpdated', conversations);
|
||||
}
|
||||
};
|
||||
|
||||
wx.goEasy.im.on(wx.GoEasy.IM_EVENT.PRIVATE_MESSAGE_RECEIVED, _globalPrivateHandler);
|
||||
wx.goEasy.im.on(wx.GoEasy.IM_EVENT.GROUP_MESSAGE_RECEIVED, _globalGroupHandler);
|
||||
wx.goEasy.im.on(wx.GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, _globalConvHandler);
|
||||
}
|
||||
|
||||
function handleNewMessage(app, message) {
|
||||
const messageId = message.messageId || message.id;
|
||||
const fingerprint = `${message.senderId}_${message.timestamp}_${message.type}`;
|
||||
const msgKey = messageId || fingerprint;
|
||||
|
||||
if (!app._processedMessages) app._processedMessages = new Set();
|
||||
if (app._processedMessages.has(msgKey)) return;
|
||||
app._processedMessages.add(msgKey);
|
||||
if (app._processedMessages.size > 200) {
|
||||
app._processedMessages.delete(app._processedMessages.values().next().value);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const msgTime = message.timestamp || now;
|
||||
if (now - msgTime > 60000) {
|
||||
cacheMessage(app, message);
|
||||
return;
|
||||
}
|
||||
|
||||
cacheMessage(app, message);
|
||||
|
||||
if (shouldShowNotification(app)) {
|
||||
let notificationType = 'private';
|
||||
let extraData = {};
|
||||
if (message.groupId) {
|
||||
notificationType = 'group';
|
||||
extraData = {
|
||||
groupId: message.groupId,
|
||||
orderId: message.orderId || '',
|
||||
groupName: '',
|
||||
groupAvatar: '',
|
||||
isCross: message.isCross || 0
|
||||
};
|
||||
const groupInfo = app.globalData.groupInfoMap?.[message.groupId];
|
||||
if (groupInfo) {
|
||||
extraData.groupName = groupInfo.name || '';
|
||||
extraData.groupAvatar = groupInfo.avatar || '';
|
||||
}
|
||||
} else if (message.teamId) {
|
||||
notificationType = 'cs';
|
||||
extraData = { teamId: message.teamId };
|
||||
}
|
||||
showNotificationDirect(app, {
|
||||
senderId: message.senderId,
|
||||
senderName: message.senderData?.name || '用户',
|
||||
avatar: message.senderData?.avatar || (app.globalData.ossImageUrl + app.globalData.morentouxiang),
|
||||
content: formatMessageForNotification(message),
|
||||
message,
|
||||
notificationType,
|
||||
...extraData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowNotification(app) {
|
||||
if (app.globalData.messageManager.notificationMuted) return false;
|
||||
if (isDoNotDisturbTime(app)) return false;
|
||||
if (app.globalData.pageState.isInChatPage) {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1];
|
||||
if (currentPage.data && currentPage.data.currentChatId) {
|
||||
if (isMessageFromCurrentChat(app, currentPage.data.currentChatId)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isDoNotDisturbTime(app) {
|
||||
const { doNotDisturb, doNotDisturbStart, doNotDisturbEnd } = app.globalData.messageManager;
|
||||
if (!doNotDisturb) return false;
|
||||
const now = new Date();
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||
const [sH, sM] = doNotDisturbStart.split(':').map(Number);
|
||||
const [eH, eM] = doNotDisturbEnd.split(':').map(Number);
|
||||
const startTime = sH * 60 + sM;
|
||||
const endTime = eH * 60 + eM;
|
||||
if (startTime < endTime) {
|
||||
return currentTime >= startTime && currentTime < endTime;
|
||||
} else {
|
||||
return currentTime >= startTime || currentTime < endTime;
|
||||
}
|
||||
}
|
||||
|
||||
function isMessageFromCurrentChat(app, currentChatId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function showNotificationDirect(app, data) {
|
||||
if (app.globalData.globalNotification?.show) {
|
||||
try {
|
||||
app.globalData.globalNotification.show(data);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('全局通知显示失败:', error);
|
||||
}
|
||||
}
|
||||
app.emitEvent('showNotification', data);
|
||||
}
|
||||
|
||||
async function updateUnreadCount(app, customCount) {
|
||||
let unreadTotal = customCount;
|
||||
if (unreadTotal === undefined) {
|
||||
try {
|
||||
const result = await getUnreadCount(app);
|
||||
unreadTotal = result.unreadTotal || 0;
|
||||
} catch (error) {
|
||||
console.error('获取未读数失败:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
app.globalData.messageManager.unreadTotal = unreadTotal;
|
||||
updateTabBarBadge(app, unreadTotal);
|
||||
wx.setStorageSync(app.globalData.messageManager.cacheKeys.unreadTotal, unreadTotal);
|
||||
app.emitEvent('unreadCountChanged', { unreadTotal });
|
||||
}
|
||||
|
||||
function getUnreadCount(app) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!getCurrentGoEasyUserId()) {
|
||||
resolve({ unreadTotal: 0 });
|
||||
return;
|
||||
}
|
||||
wx.goEasy.im.latestConversations({
|
||||
onSuccess: (result) => resolve({
|
||||
unreadTotal: result.unreadTotal || 0,
|
||||
conversations: result.content?.conversations || [],
|
||||
}),
|
||||
onFailed: reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatMessageForNotification(message) {
|
||||
switch (message.type) {
|
||||
case 'text': return message.payload.text || '[文本]';
|
||||
case 'image': return '[图片]';
|
||||
case 'audio': return '[语音]';
|
||||
case 'video': return '[视频]';
|
||||
case 'file': return '[文件]';
|
||||
case 'order': return '[订单]';
|
||||
default: return '[新消息]';
|
||||
}
|
||||
}
|
||||
|
||||
function cacheMessage(app, message) {
|
||||
const { latestMessages } = app.globalData.messageManager;
|
||||
latestMessages.unshift({
|
||||
id: message.messageId,
|
||||
type: message.type,
|
||||
senderId: message.senderId,
|
||||
senderName: message.senderData?.name,
|
||||
content: formatMessageForNotification(message),
|
||||
timestamp: message.timestamp || Date.now(),
|
||||
conversationId: message.conversationId,
|
||||
});
|
||||
if (latestMessages.length > 50) latestMessages.length = 50;
|
||||
wx.setStorageSync(app.globalData.messageManager.cacheKeys.latestMessages, latestMessages);
|
||||
}
|
||||
|
||||
function loadConversations(app) {
|
||||
if (!getCurrentGoEasyUserId()) return;
|
||||
wx.goEasy.im.latestConversations({
|
||||
onSuccess: (result) => {
|
||||
if (result.unreadTotal !== undefined) {
|
||||
updateUnreadCount(app, result.unreadTotal);
|
||||
}
|
||||
app.emitEvent('conversationsUpdated', result);
|
||||
const conversations = result?.content?.conversations || result?.conversations || [];
|
||||
if (!app.globalData.groupInfoMap) app.globalData.groupInfoMap = {};
|
||||
conversations.forEach(c => {
|
||||
if (c.type === 'group' && c.groupId) {
|
||||
app.globalData.groupInfoMap[c.groupId] = c.data || {};
|
||||
}
|
||||
});
|
||||
_ensureGroupSubscriptions(app, conversations);
|
||||
},
|
||||
onFailed: (error) => console.error('加载会话列表失败:', error),
|
||||
});
|
||||
}
|
||||
|
||||
function _ensureGroupSubscriptions(app, conversations) {
|
||||
const groupIds = conversations
|
||||
.filter(c => c.type === 'group' && c.groupId)
|
||||
.map(c => c.groupId);
|
||||
|
||||
if (groupIds.length === 0) return;
|
||||
if (!wx.goEasy?.im || typeof wx.goEasy.im.subscribeGroup !== 'function') {
|
||||
console.error('subscribeGroup 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
wx.goEasy.im.subscribeGroup({
|
||||
groupIds: groupIds,
|
||||
onSuccess: () => {},
|
||||
onFailed: (error) => console.error('全局订阅群组失败:', error),
|
||||
});
|
||||
}
|
||||
|
||||
function updateCurrentPageState(app) {
|
||||
try {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1];
|
||||
app.globalData.pageState.currentPage = currentPage.route;
|
||||
app.globalData.pageState.isInChatPage = currentPage.route === 'pages/liaotian/liaotian';
|
||||
app.globalData.pageState.lastPageUpdate = Date.now();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('更新页面状态失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function saveUserSettings(app) {
|
||||
try {
|
||||
wx.setStorageSync(app.globalData.messageManager.cacheKeys.messageSettings, {
|
||||
soundEnabled: app.globalData.messageManager.soundEnabled,
|
||||
vibrationEnabled: app.globalData.messageManager.vibrationEnabled,
|
||||
doNotDisturb: app.globalData.messageManager.doNotDisturb,
|
||||
doNotDisturbStart: app.globalData.messageManager.doNotDisturbStart,
|
||||
doNotDisturbEnd: app.globalData.messageManager.doNotDisturbEnd,
|
||||
notificationMuted: app.globalData.messageManager.notificationMuted,
|
||||
notificationStyle: app.globalData.messageManager.notificationStyle,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('保存用户设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDoNotDisturb(app, enabled) {
|
||||
app.globalData.messageManager.doNotDisturb = enabled;
|
||||
saveUserSettings(app);
|
||||
wx.showToast({ title: `免打扰${enabled ? '开启' : '关闭'}`, icon: 'success', duration: 2000 });
|
||||
}
|
||||
|
||||
function toggleNotificationMute(app, enabled) {
|
||||
app.globalData.messageManager.notificationMuted = enabled;
|
||||
wx.setStorageSync(app.globalData.messageManager.cacheKeys.notificationMuted, enabled);
|
||||
saveUserSettings(app);
|
||||
wx.showToast({ title: `通知${enabled ? '静音' : '开启'}`, icon: 'success', duration: 2000 });
|
||||
}
|
||||
|
||||
function closeNotification(app) {
|
||||
app.emitEvent('hideNotification');
|
||||
}
|
||||
|
||||
function updateTabBarBadge(app, unreadCount) {
|
||||
const { messageManager } = app.globalData;
|
||||
if (!messageManager.showTabBarBadge) return;
|
||||
let badgeText = '';
|
||||
if (unreadCount > 0) {
|
||||
badgeText = unreadCount > 99 ? '99+' : unreadCount.toString();
|
||||
}
|
||||
messageManager.tabBarBadgeText = badgeText;
|
||||
app.emitEvent('tabBarBadgeChanged', {
|
||||
index: messageManager.tabBarIndex,
|
||||
badgeText,
|
||||
showRedDot: unreadCount > 0,
|
||||
forceUpdate: true,
|
||||
});
|
||||
updateTabBarDirectly(app, badgeText);
|
||||
saveTabBarBadgeToStorage(app, badgeText);
|
||||
}
|
||||
|
||||
function updateTabBarDirectly(app, badgeText) {
|
||||
try {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 0) {
|
||||
const tabBar = pages[pages.length - 1].selectComponent('#custom-tab-bar');
|
||||
if (tabBar && tabBar.setData) tabBar.setData({ badgeText });
|
||||
}
|
||||
} catch (error) { console.warn('直接更新TabBar失败:', error); }
|
||||
}
|
||||
|
||||
function saveTabBarBadgeToStorage(app, badgeText) {
|
||||
try {
|
||||
wx.setStorageSync('tabBarMessageBadge', badgeText);
|
||||
} catch (error) { console.warn('保存TabBar徽章失败:', error); }
|
||||
}
|
||||
|
||||
module.exports = { initGlobalMessageSystem };
|
||||
374
utils/chat_utils.py
Normal file
374
utils/chat_utils.py
Normal 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
|
||||
13296
utils/cos-wx-sdk-v5.js
Normal file
13296
utils/cos-wx-sdk-v5.js
Normal file
File diff suppressed because one or more lines are too long
140
utils/fadan_utils.py
Normal file
140
utils/fadan_utils.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# 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, "罚单查询系统繁忙,请稍后重试")
|
||||
|
||||
|
||||
|
||||
import logging
|
||||
from django.db import transaction
|
||||
from decimal import Decimal
|
||||
from dingdan.models import Fadan, FadanFenhong, FadanFenhongLilv
|
||||
|
||||
logger = logging.getLogger('xiaochengxu')
|
||||
|
||||
def fadan_fenhong(fadan_id):
|
||||
"""
|
||||
罚款缴纳后,给申请处罚人累加分红。
|
||||
自动映射身份,异常不抛出,不影响主流程。
|
||||
"""
|
||||
try:
|
||||
# 1. 查询罚单
|
||||
try:
|
||||
fadan = Fadan.objects.get(id=fadan_id)
|
||||
except Fadan.DoesNotExist:
|
||||
logger.warning(f"罚单 {fadan_id} 不存在,分红跳过")
|
||||
return
|
||||
|
||||
if fadan.zhuangtai != 2:
|
||||
logger.info(f"罚单 {fadan_id} 状态非已缴纳,不分红")
|
||||
return
|
||||
|
||||
fenhongzhe_id = fadan.shenqing_chufa or ''
|
||||
shenfen_in_fadan = fadan.shenqingren_shenfen # 罚单中的申请人身份
|
||||
jine = fadan.fakuanjine
|
||||
|
||||
if not fenhongzhe_id or jine <= 0:
|
||||
logger.info(f"罚单 {fadan_id} 缺少申请处罚人或金额为0,不分红")
|
||||
return
|
||||
|
||||
# 2. 直接用罚单身份的原始值查询费率(费率表 shenfen 与之含义一致)
|
||||
rate_record = FadanFenhongLilv.objects.filter(shenfen=shenfen_in_fadan).first()
|
||||
if not rate_record or rate_record.lilv <= 0:
|
||||
logger.info(f"身份 {shenfen_in_fadan} 无有效分红费率,不分红")
|
||||
return
|
||||
|
||||
rate = rate_record.lilv
|
||||
|
||||
# 3. 计算分红金额
|
||||
amount = (jine * rate).quantize(Decimal('0.01'))
|
||||
if amount <= 0:
|
||||
logger.info(f"罚单 {fadan_id} 分红金额为0,不分红")
|
||||
return
|
||||
|
||||
# 4. 硬编码映射:罚单身份 → 分红记录表身份
|
||||
SHENFEN_MAP = {
|
||||
1: 2, # 客服 → 客服
|
||||
2: 5, # 售后 → 售后
|
||||
3: 3, # 管理员 → 平台管理者
|
||||
4: 4, # 店铺 → 店铺
|
||||
5: 1, # 商家 → 商家
|
||||
}
|
||||
shenfen_in_fenhong = SHENFEN_MAP.get(shenfen_in_fadan)
|
||||
if shenfen_in_fenhong is None:
|
||||
logger.info(f"罚单 {fadan_id} 身份 {shenfen_in_fadan} 无对应的分红记录映射,不分红")
|
||||
return
|
||||
|
||||
# 5. 事务累加分红记录(同一用户+身份只保留一条记录)
|
||||
with transaction.atomic():
|
||||
record = FadanFenhong.objects.select_for_update().filter(
|
||||
fenhongzhe_id=fenhongzhe_id,
|
||||
shenfen=shenfen_in_fenhong # 使用映射后的身份值
|
||||
).first()
|
||||
|
||||
if record:
|
||||
record.zonge += amount
|
||||
record.ketixian_fenhong += amount
|
||||
record.fenhong_cishu += 1
|
||||
record.yingde_lilv = rate
|
||||
record.save()
|
||||
else:
|
||||
FadanFenhong.objects.create(
|
||||
fenhongzhe_id=fenhongzhe_id,
|
||||
shenfen=shenfen_in_fenhong,
|
||||
zonge=amount,
|
||||
ketixian_fenhong=amount,
|
||||
fenhong_cishu=1,
|
||||
yingde_lilv=rate,
|
||||
)
|
||||
|
||||
logger.info(f"罚单 {fadan_id} 分红完成:用户 {fenhongzhe_id} 身份 {shenfen_in_fenhong} 金额 {amount}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"罚单 {fadan_id} 分红异常:{e}", exc_info=True)
|
||||
103
utils/goeasy_service.py
Normal file
103
utils/goeasy_service.py
Normal 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 # 只要有一个方向成功就认为成功
|
||||
51
utils/imAuth/dashoujianquan.js
Normal file
51
utils/imAuth/dashoujianquan.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// utils/imAuth/dashoujianquan.js
|
||||
const imRequest = require('./imRequest.js');
|
||||
|
||||
/**
|
||||
* 打手 IM 权限检测
|
||||
* POST /yonghu/dshqltqx
|
||||
* 后端返回 { code: 0, allow: 1 } 或 { code: 0, allow: 0 }
|
||||
*/
|
||||
async function dashoujianquan(app) {
|
||||
const uid = wx.getStorageSync('uid');
|
||||
if (!uid) return { allowed: false, reason: '未登录' };
|
||||
|
||||
try {
|
||||
const res = await imRequest({
|
||||
url: '/yonghu/dshqltqx',
|
||||
method: 'POST',
|
||||
data: { uid }
|
||||
});
|
||||
|
||||
if (res && res.data && res.data.code === 0) {
|
||||
const allow = res.data.allow;
|
||||
if (allow === 1) {
|
||||
return { allowed: true };
|
||||
} else {
|
||||
await app.disconnectGoEasy();
|
||||
wx.showToast({
|
||||
title: '当前身份已不支持消息聊天功能,请充值会员或联系管理员后尝试',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
mask: true
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 2100));
|
||||
return { allowed: false, reason: '打手消息权限被限制' };
|
||||
}
|
||||
}
|
||||
throw new Error(res.data?.msg || '鉴权接口异常');
|
||||
} catch (err) {
|
||||
console.error('打手鉴权失败:', err);
|
||||
await app.disconnectGoEasy();
|
||||
wx.showToast({
|
||||
title: '消息服务暂不可用,请稍后重试',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
mask: true
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 2100));
|
||||
return { allowed: false, reason: '鉴权请求失败' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { dashoujianquan };
|
||||
67
utils/imAuth/imRequest.js
Normal file
67
utils/imAuth/imRequest.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// utils/imAuth/imRequest.js
|
||||
// 专用于 IM 鉴权模块的请求封装
|
||||
// 与 utils/request.js 功能完全一致,但不依赖 getApp(),API 域名硬编码
|
||||
const API_BASE_URL = 'https://6ccy.top/hqhd';
|
||||
|
||||
function imRequest(options) {
|
||||
const token = wx.getStorageSync('token');
|
||||
const header = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.header,
|
||||
};
|
||||
if (token) {
|
||||
header['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
url: API_BASE_URL + options.url,
|
||||
method: options.method,
|
||||
data: options.data || {},
|
||||
header,
|
||||
success: async (res) => {
|
||||
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
// 如果全局有登录 Promise 在等待,则等待完成后重试
|
||||
const app = getApp();
|
||||
if (app && app.globalData && app.globalData.loginPromise) {
|
||||
try {
|
||||
await app.globalData.loginPromise;
|
||||
const retryRes = await imRequest(options);
|
||||
resolve(retryRes);
|
||||
} catch (err) {
|
||||
wx.showModal({
|
||||
content: '操作失败,请先登录',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
wx.removeStorageSync('token');
|
||||
wx.switchTab({ url: '/pages/wode/wode' });
|
||||
}
|
||||
},
|
||||
});
|
||||
resolve(res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 常规未登录提示
|
||||
wx.showModal({
|
||||
content: '操作失败,请先登录',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
wx.removeStorageSync('token');
|
||||
wx.switchTab({ url: '/pages/wode/wode' });
|
||||
}
|
||||
},
|
||||
});
|
||||
resolve(res);
|
||||
return;
|
||||
}
|
||||
// 正常响应
|
||||
resolve(res);
|
||||
},
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = imRequest;
|
||||
58
utils/imAuth/jianquanxian.js
Normal file
58
utils/imAuth/jianquanxian.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// utils/imAuth/jianquanxian.js
|
||||
// 原:require('./laobanjianquan')
|
||||
// 改:
|
||||
const { laobanjianquan } = require('./laobanjianquan.js');
|
||||
const { dashoujianquan } = require('./dashoujianquan.js');
|
||||
|
||||
/**
|
||||
* IM 长连接权限统一入口
|
||||
* 按顺序进行:
|
||||
* 1. 检查本地 token/uid 是否存在
|
||||
* 2. 若不存在,强制断开已有连接,弹窗提示未登录(强制阅读 2 秒)
|
||||
* 3. 若存在,根据当前角色调用具体的鉴权模块
|
||||
*
|
||||
* @param {Object} app 全局 App 实例
|
||||
* @returns {Promise<{ allowed: boolean, reason?: string }>}
|
||||
*/
|
||||
async function jianquanxian(app) {
|
||||
// 1. 检测登录态(用 uid 或 token)
|
||||
const token = wx.getStorageSync('token') || wx.getStorageSync('uid');
|
||||
if (!token) {
|
||||
// 没登录:先强制断开(如果已连接)
|
||||
await app.disconnectGoEasy();
|
||||
// 弹窗提示,强制阅读 2 秒
|
||||
wx.showToast({
|
||||
title: '您当前处于未登录状态,消息功能暂不可用',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
mask: true
|
||||
});
|
||||
// 等待提示结束,确保用户看到
|
||||
await new Promise(resolve => setTimeout(resolve, 2100));
|
||||
return { allowed: false, reason: '未登录' };
|
||||
}
|
||||
|
||||
// 2. 获取当前选中的角色
|
||||
const role = app.globalData.currentRole || 'normal';
|
||||
|
||||
// 3. 根据角色分发到具体鉴权模块
|
||||
switch (role) {
|
||||
case 'normal': // 点单老板
|
||||
return laobanjianquan(app);
|
||||
|
||||
case 'dashou': // 打手
|
||||
return dashoujianquan(app);
|
||||
|
||||
// 其他角色暂未细分,统一放行(后续可新增对应文件)
|
||||
case 'shangjia':
|
||||
case 'guanshi':
|
||||
case 'zuzhang':
|
||||
case 'kaoheguan':
|
||||
return { allowed: true };
|
||||
|
||||
default:
|
||||
return { allowed: false, reason: '未知角色' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { jianquanxian };
|
||||
51
utils/imAuth/laobanjianquan.js
Normal file
51
utils/imAuth/laobanjianquan.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// utils/imAuth/laobanjianquan.js
|
||||
const imRequest = require('./imRequest.js');
|
||||
|
||||
/**
|
||||
* 老板(点单用户)IM 权限检测
|
||||
* POST /yonghu/lbhqltqx
|
||||
* 后端返回 { code: 0, allow: 1 } 或 { code: 0, allow: 0 }
|
||||
*/
|
||||
async function laobanjianquan(app) {
|
||||
const uid = wx.getStorageSync('uid');
|
||||
if (!uid) return { allowed: false, reason: '未登录' };
|
||||
|
||||
try {
|
||||
const res = await imRequest({
|
||||
url: '/yonghu/lbhqltqx',
|
||||
method: 'POST',
|
||||
data: { uid }
|
||||
});
|
||||
|
||||
if (res && res.data && res.data.code === 0) {
|
||||
const allow = res.data.allow;
|
||||
if (allow === 1) {
|
||||
return { allowed: true };
|
||||
} else {
|
||||
await app.disconnectGoEasy();
|
||||
wx.showToast({
|
||||
title: '当前身份已不支持使用消息功能',
|
||||
icon: 'none',
|
||||
duration: 1000,
|
||||
mask: true
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 1100));
|
||||
return { allowed: false, reason: '当前身份无消息权限' };
|
||||
}
|
||||
}
|
||||
throw new Error(res.data?.msg || '鉴权接口异常');
|
||||
} catch (err) {
|
||||
console.error('老板鉴权失败:', err);
|
||||
await app.disconnectGoEasy();
|
||||
wx.showToast({
|
||||
title: '消息服务暂不可用,请稍后重试',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
mask: true
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 2100));
|
||||
return { allowed: false, reason: '鉴权请求失败' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { laobanjianquan };
|
||||
74
utils/imageUrl.js
Normal file
74
utils/imageUrl.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// /utils/imageUrl.js - 统一的COS图片路径管理
|
||||
const app = getApp();
|
||||
|
||||
/**
|
||||
* 获取完整的COS图片URL
|
||||
* @param {string} path - 图片相对路径,如 'a_long/images/common/arrow-down.png'
|
||||
* @returns {string} 完整的URL
|
||||
*/
|
||||
export function getImageUrl(path) {
|
||||
if (!app || !app.globalData || !app.globalData.ossImageUrl) {
|
||||
console.warn('⚠️ App.globalData.ossImageUrl 未初始化');
|
||||
return path; // 返回原路径作为fallback
|
||||
}
|
||||
|
||||
// 如果已经是完整URL,直接返回
|
||||
if (path.startsWith('http')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// 拼接完整URL
|
||||
return app.globalData.ossImageUrl + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取常用图片的URL
|
||||
*/
|
||||
export const imageUrls = {
|
||||
// 身份相关
|
||||
identityIcon: 'a_long/images/identity/identity-icon.png',
|
||||
arrowDown: 'a_long/images/common/arrow-down.png',
|
||||
bossIcon: 'a_long/images/identity/boss-icon.png',
|
||||
dashouIcon: 'a_long/images/identity/dashou-icon.png',
|
||||
shangjiaIcon: 'a_long/images/identity/shangjia-icon.png',
|
||||
|
||||
// 公共图标
|
||||
actionIcon: 'a_long/images/common/action.png',
|
||||
emptyMessage: 'a_long/images/common/empty-message.png',
|
||||
|
||||
// 通知相关
|
||||
notificationOn: 'a_long/images/notifications/notification-on.png',
|
||||
notificationOff: 'a_long/images/notifications/notification-off.png',
|
||||
|
||||
// TabBar图标
|
||||
tabbarHome: 'a_long/images/tabbar/home.png',
|
||||
tabbarHomeActive: 'a_long/images/tabbar/home-active.png',
|
||||
tabbarCategory: 'a_long/images/tabbar/category.png',
|
||||
tabbarCategoryActive: 'a_long/images/tabbar/category-active.png',
|
||||
tabbarMessage: 'a_long/images/tabbar/message.png',
|
||||
tabbarMessageActive: 'a_long/images/tabbar/message-active.png',
|
||||
tabbarProfile: 'a_long/images/tabbar/profile.png',
|
||||
tabbarProfileActive: 'a_long/images/tabbar/profile-active.png',
|
||||
|
||||
// 用户相关
|
||||
defaultAvatar: 'a_long/images/common/default-avatar.png'
|
||||
};
|
||||
|
||||
/**
|
||||
* 预加载常用图片
|
||||
*/
|
||||
export function preloadCommonImages() {
|
||||
const images = Object.values(imageUrls).map(path => getImageUrl(path));
|
||||
|
||||
images.forEach(src => {
|
||||
wx.getImageInfo({
|
||||
src: src,
|
||||
success: () => {
|
||||
console.log('✅ 图片预加载成功:', src);
|
||||
},
|
||||
fail: (err) => {
|
||||
console.warn('⚠️ 图片预加载失败:', src, err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
152
utils/message-sender.js
Normal file
152
utils/message-sender.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// utils/message-sender.js
|
||||
const app = getApp();
|
||||
import { formatDate } from '../static/lib/utils';
|
||||
import request from './request.js';
|
||||
|
||||
function sendGroupMessage(options) {
|
||||
const { msgData, onSuccess, onSendStart } = options;
|
||||
const { type, currentUser, groupId, orderId, isCross, groupName, groupAvatar } = msgData;
|
||||
|
||||
const localMsg = buildLocalMessage(msgData);
|
||||
if (onSendStart) onSendStart(localMsg);
|
||||
|
||||
// 只有 isCross 明确为 0 才走前端 SDK,其余一律走后端
|
||||
if (isCross === 0) {
|
||||
sendViaSDK(localMsg, currentUser, groupId, onSuccess, orderId, isCross, groupName, groupAvatar);
|
||||
return;
|
||||
}
|
||||
sendViaBackend(localMsg, currentUser, groupId, orderId, onSuccess);
|
||||
}
|
||||
|
||||
function buildLocalMessage(data) {
|
||||
const { type, text, filePath, orderPayload, currentUser, groupId } = data;
|
||||
const now = Date.now();
|
||||
const base = {
|
||||
messageId: `${type}-${now}`,
|
||||
timestamp: now,
|
||||
senderId: currentUser.id,
|
||||
groupId: groupId,
|
||||
senderData: {
|
||||
name: currentUser.name,
|
||||
avatar: currentUser.avatar
|
||||
},
|
||||
status: 'sending',
|
||||
formattedTime: formatDate(now)
|
||||
};
|
||||
|
||||
if (type === 'text') return { ...base, type: 'text', payload: { text } };
|
||||
if (type === 'image') return { ...base, type: 'image', payload: { url: filePath } };
|
||||
if (type === 'order') return { ...base, type: 'order', payload: orderPayload };
|
||||
return base;
|
||||
}
|
||||
|
||||
function sendViaSDK(localMsg, currentUser, groupId, onSuccess, orderId, isCross, groupName, groupAvatar) {
|
||||
const toData = {
|
||||
name: groupName || '订单群聊',
|
||||
avatar: groupAvatar || currentUser.avatar
|
||||
};
|
||||
if (orderId) toData.orderId = orderId;
|
||||
toData.isCross = isCross || 0;
|
||||
|
||||
const to = {
|
||||
type: wx.GoEasy.IM_SCENE.GROUP,
|
||||
id: groupId,
|
||||
data: toData
|
||||
};
|
||||
|
||||
let message;
|
||||
if (localMsg.type === 'text') {
|
||||
message = wx.goEasy.im.createTextMessage({ text: localMsg.payload.text, to });
|
||||
} else if (localMsg.type === 'image') {
|
||||
message = wx.goEasy.im.createImageMessage({ file: localMsg.payload.url, to });
|
||||
} else if (localMsg.type === 'order') {
|
||||
message = wx.goEasy.im.createCustomMessage({ type: 'order', payload: localMsg.payload, to });
|
||||
}
|
||||
|
||||
wx.goEasy.im.sendMessage({
|
||||
message,
|
||||
onSuccess: () => { if (onSuccess) onSuccess(localMsg.messageId, 'success'); },
|
||||
onFailed: () => { if (onSuccess) onSuccess(localMsg.messageId, 'failed'); }
|
||||
});
|
||||
}
|
||||
|
||||
function sendViaBackend(localMsg, currentUser, groupId, orderId, onSuccess) {
|
||||
const apiUrl = '/dingdan/kptxxfs';
|
||||
|
||||
// 🔥 实时获取当前身份,不再依赖外部传入的 currentUser.id
|
||||
const uid = wx.getStorageSync('uid');
|
||||
const role = app.globalData.currentRole || 'normal';
|
||||
const prefixMap = { normal: 'Boss', dashou: 'Ds', shangjia: 'Sj', guanshi: 'Gs', zuzhang: 'Zz' };
|
||||
const identityType = role === 'dashou' ? 'dashou' : (role === 'shangjia' ? 'shangjia' : 'boss');
|
||||
const prefix = prefixMap[role] || 'Boss';
|
||||
const senderId = prefix + uid;
|
||||
|
||||
const params = {
|
||||
orderId: orderId,
|
||||
groupId: groupId,
|
||||
identityType: identityType, // 告诉后端当前是什么身份
|
||||
senderId: senderId,
|
||||
senderName: currentUser.name || ('用户' + uid),
|
||||
senderAvatar: currentUser.avatar || (app.globalData.ossImageUrl + app.globalData.morentouxiang),
|
||||
messageType: localMsg.type,
|
||||
messageId: localMsg.messageId,
|
||||
timestamp: localMsg.timestamp
|
||||
};
|
||||
|
||||
if (localMsg.type === 'text' || localMsg.type === 'order') {
|
||||
if (localMsg.type === 'text') {
|
||||
params.text = localMsg.payload.text;
|
||||
} else {
|
||||
params.orderPayload = localMsg.payload;
|
||||
}
|
||||
request({
|
||||
url: apiUrl,
|
||||
method: 'POST',
|
||||
data: params
|
||||
}).then((res) => {
|
||||
if (res && res.data && res.data.code === 200) {
|
||||
if (onSuccess) onSuccess(localMsg.messageId, 'success');
|
||||
} else {
|
||||
if (onSuccess) onSuccess(localMsg.messageId, 'failed');
|
||||
}
|
||||
}).catch(() => {
|
||||
if (onSuccess) onSuccess(localMsg.messageId, 'failed');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 图片消息
|
||||
if (localMsg.type === 'image') {
|
||||
const token = wx.getStorageSync('token');
|
||||
const formData = {
|
||||
...params,
|
||||
senderInfo: JSON.stringify({ id: senderId, name: params.senderName, avatar: params.senderAvatar })
|
||||
};
|
||||
wx.uploadFile({
|
||||
url: app.globalData.apiBaseUrl + apiUrl,
|
||||
filePath: localMsg.payload.url,
|
||||
name: 'file',
|
||||
formData: formData,
|
||||
header: {
|
||||
'Authorization': 'Bearer ' + (token || '')
|
||||
},
|
||||
success(res) {
|
||||
try {
|
||||
const data = JSON.parse(res.data);
|
||||
if (data.code === 200) {
|
||||
if (onSuccess) onSuccess(localMsg.messageId, 'success');
|
||||
} else {
|
||||
if (onSuccess) onSuccess(localMsg.messageId, 'failed');
|
||||
}
|
||||
} catch (e) {
|
||||
if (onSuccess) onSuccess(localMsg.messageId, 'failed');
|
||||
}
|
||||
},
|
||||
fail() {
|
||||
if (onSuccess) onSuccess(localMsg.messageId, 'failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendGroupMessage };
|
||||
0
utils/migrations/__init__.py
Normal file
0
utils/migrations/__init__.py
Normal file
BIN
utils/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
utils/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
3
utils/models.py
Normal file
3
utils/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
512
utils/oss_utils.py
Normal file
512
utils/oss_utils.py
Normal file
@@ -0,0 +1,512 @@
|
||||
# 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
|
||||
165
utils/phone-auth.js
Normal file
165
utils/phone-auth.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 手机号强制认证 - 完全自包含逻辑模块
|
||||
*
|
||||
* 特点:
|
||||
* 1. 不引用项目中的 request.js / upload.js,避免其顶层 getApp() 带来的加载顺序错误
|
||||
* 2. 所有请求直接使用 wx.request / wx.uploadFile,基础 URL 写死(与 app.globalData.apiBaseUrl 保持一致)
|
||||
* 3. check() 内部捕获所有异常,永远返回 false 或 true,不会抛出错误
|
||||
* 4. submit() 抛出错误由认证页面自己处理,不影响主流程
|
||||
*
|
||||
* 用法:
|
||||
* app.js 中:
|
||||
* try {
|
||||
* const { check } = require('./utils/phone-auth');
|
||||
* if (await check()) { wx.reLaunch(...); return; }
|
||||
* } catch(e) {}
|
||||
*
|
||||
* 认证页面中:
|
||||
* const { submit } = require('../../utils/phone-auth');
|
||||
* await submit(phoneCode, avatarPath);
|
||||
*/
|
||||
|
||||
// ==================== 基础配置 ====================
|
||||
const API_BASE = 'https://6ccy.top/hqhd'; // 与你 app.js 里的 apiBaseUrl 一致
|
||||
const AUTH_CHECK_URL = '/peizhi/yhbdsjh'; // 检查是否需要认证
|
||||
const AUTH_SUBMIT_URL = '/yonghu/xiugai'; // 提交认证(复用修改信息接口)
|
||||
|
||||
// ==================== 内部封装:wx.request ====================
|
||||
function innerRequest(url, data = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = wx.getStorageSync('token'); // 运行时获取 token,不提前取
|
||||
const header = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (token) {
|
||||
header['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
wx.request({
|
||||
url: API_BASE + url,
|
||||
method: 'POST',
|
||||
data,
|
||||
header,
|
||||
success: (res) => {
|
||||
// 正常响应直接 resolve 整个响应对象,让上层自己解析
|
||||
resolve(res);
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 内部封装:wx.uploadFile ====================
|
||||
function innerUpload(url, filePath, formData = {}, fileName = 'avatar') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = wx.getStorageSync('token');
|
||||
if (!token) {
|
||||
reject(new Error('未登录'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 先验证文件是否存在
|
||||
wx.getFileInfo({
|
||||
filePath,
|
||||
success: () => {
|
||||
wx.uploadFile({
|
||||
url: API_BASE + url,
|
||||
filePath,
|
||||
name: fileName,
|
||||
formData,
|
||||
header: {
|
||||
'Authorization': 'Bearer ' + token
|
||||
},
|
||||
success: (res) => {
|
||||
// ✅ 修复:wx.uploadFile 成功时 statusCode 为 200
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
const data = JSON.parse(res.data);
|
||||
resolve(data); // 直接返回解析后的对象,方便下游使用
|
||||
} catch (e) {
|
||||
reject(new Error('解析响应数据失败'));
|
||||
}
|
||||
} else if (res.statusCode === 401) {
|
||||
wx.removeStorageSync('token');
|
||||
reject(new Error('认证失败'));
|
||||
} else {
|
||||
reject(new Error(`上传失败,状态码:${res.statusCode}`));
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(new Error('网络请求失败'));
|
||||
}
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
reject(new Error('文件不存在或无法访问'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 公开方法 ====================
|
||||
|
||||
/**
|
||||
* 检查是否需要手机号认证
|
||||
* - 无 token 或 任何异常 → 返回 false
|
||||
* - 后端明确 need_auth: true → 返回 true
|
||||
*/
|
||||
async function check() {
|
||||
const token = wx.getStorageSync('token');
|
||||
if (!token) return false;
|
||||
|
||||
try {
|
||||
const res = await innerRequest(AUTH_CHECK_URL, {});
|
||||
// 假设后端返回格式:{ data: { need_auth: true/false } } 或直接在顶层
|
||||
const body = res.data;
|
||||
return body && body.need_auth === true;
|
||||
} catch (e) {
|
||||
// 任何错误(网络、后端异常)都吞掉,不影响主流程
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交认证(手机号 code + 头像文件)
|
||||
* @param {string} phoneCode 微信 getPhoneNumber 返回的 code
|
||||
* @param {string} avatarPath 头像临时路径
|
||||
* @returns {Promise<object>} 后端返回的完整数据
|
||||
*/
|
||||
async function submit(phoneCode, avatarPath) {
|
||||
if (!phoneCode || !avatarPath) {
|
||||
throw new Error('缺少参数');
|
||||
}
|
||||
|
||||
// 将手机号 code 放入 formData
|
||||
const formData = {
|
||||
shoujihao_code: phoneCode
|
||||
};
|
||||
|
||||
// 调用上传
|
||||
const data = await innerUpload(AUTH_SUBMIT_URL, avatarPath, formData);
|
||||
|
||||
// ✅ 修复:正确判断后端返回的 code 字段,后端成功时 code === 0
|
||||
if (data && data.code === 0) {
|
||||
// data.data 包含 { touxiang: '相对路径', phone: '...' }
|
||||
const respData = data.data || data;
|
||||
|
||||
// 存储手机号
|
||||
if (respData.phone) {
|
||||
wx.setStorageSync('phone', respData.phone);
|
||||
}
|
||||
|
||||
// ✅ 修复:存储头像相对路径,与 xiugai 页面处理方式一致
|
||||
if (respData.touxiang) {
|
||||
wx.setStorageSync('touxiang', respData.touxiang);
|
||||
}
|
||||
|
||||
return respData;
|
||||
} else {
|
||||
throw new Error((data && data.msg) || '认证失败');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { check, submit };
|
||||
18
utils/redis_lock.py
Normal file
18
utils/redis_lock.py
Normal 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)
|
||||
66
utils/request.js
Normal file
66
utils/request.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// utils/request.js
|
||||
const app = getApp();
|
||||
|
||||
function request(options) {
|
||||
const token = wx.getStorageSync('token');
|
||||
const header = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.header,
|
||||
};
|
||||
if (token) {
|
||||
header['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
url: app.globalData.apiBaseUrl + options.url,
|
||||
method: options.method,
|
||||
data: options.data || {},
|
||||
header,
|
||||
success: async (res) => {
|
||||
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
// 如果当前正在执行静默登录,等待登录完成后重试一次
|
||||
if (app.globalData.loginPromise) {
|
||||
try {
|
||||
await app.globalData.loginPromise; // 等待登录成功
|
||||
// 登录成功后重新发起原请求,并将结果返回给调用方
|
||||
const retryRes = await request(options);
|
||||
resolve(retryRes);
|
||||
} catch (err) {
|
||||
// 登录失败,按原逻辑弹窗
|
||||
wx.showModal({
|
||||
content: '操作失败,请先登录',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
wx.removeStorageSync('token');
|
||||
wx.reLaunch({ url: '/pages/wode/wode' });
|
||||
}
|
||||
},
|
||||
});
|
||||
resolve(res); // 返回原始响应让调用方知晓
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 无登录流程进行中,直接弹窗(原逻辑)
|
||||
wx.showModal({
|
||||
content: '操作失败,请先登录',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
wx.removeStorageSync('token');
|
||||
wx.reLaunch({ url: '/pages/wode/wode' });
|
||||
}
|
||||
},
|
||||
});
|
||||
resolve(res);
|
||||
return;
|
||||
}
|
||||
// 正常响应
|
||||
resolve(res);
|
||||
},
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default request;
|
||||
0
utils/role-manager.js
Normal file
0
utils/role-manager.js
Normal file
3
utils/tests.py
Normal file
3
utils/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
95
utils/upload.js
Normal file
95
utils/upload.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// utils/upload.js
|
||||
import request from './request.js'
|
||||
|
||||
const app = getApp()
|
||||
|
||||
/**
|
||||
* 统一的异步上传方法
|
||||
* @param {object} options 配置项
|
||||
* @returns {Promise} Promise对象
|
||||
*/
|
||||
const upload = async (options) => {
|
||||
const {
|
||||
url,
|
||||
formData = {},
|
||||
filePath = null,
|
||||
fileName = 'file'
|
||||
} = options
|
||||
|
||||
// 获取token
|
||||
const token = wx.getStorageSync('token')
|
||||
|
||||
if (!token) {
|
||||
throw new Error('未登录')
|
||||
}
|
||||
|
||||
// 拼接完整URL
|
||||
const fullUrl = `${app.globalData.apiBaseUrl}${url}`
|
||||
|
||||
if (filePath) {
|
||||
// 有文件上传,使用wx.uploadFile
|
||||
return new Promise((resolve, reject) => {
|
||||
// 先验证文件是否存在
|
||||
wx.getFileInfo({
|
||||
filePath,
|
||||
success: () => {
|
||||
// 文件存在,开始上传
|
||||
wx.uploadFile({
|
||||
url: fullUrl,
|
||||
filePath,
|
||||
name: fileName,
|
||||
formData,
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
success: (res) => {
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
data
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('解析JSON失败:', e, '原始数据:', res.data)
|
||||
reject(new Error('解析响应数据失败'))
|
||||
}
|
||||
} else if (res.statusCode === 401) {
|
||||
// token过期或无效
|
||||
wx.removeStorageSync('token')
|
||||
wx.removeStorageSync('userInfo')
|
||||
reject(new Error('认证失败'))
|
||||
} else {
|
||||
console.error('上传失败,状态码:', res.statusCode, '响应:', res)
|
||||
reject(new Error(`上传失败,状态码:${res.statusCode}`))
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('wx.uploadFile失败:', err)
|
||||
reject(new Error('网络请求失败'))
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('文件不存在或无法访问:', err)
|
||||
reject(new Error('文件不存在或无法访问'))
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 没有文件,使用封装的request
|
||||
// 注意:这里应该使用传入的url,而不是硬编码
|
||||
return request({
|
||||
url, // 使用传入的url
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default upload
|
||||
3
utils/views.py
Normal file
3
utils/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
207
utils/wechat_v3.py
Normal file
207
utils/wechat_v3.py
Normal file
@@ -0,0 +1,207 @@
|
||||
# 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
201
utils/weixin_broadcast.py
Normal 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
|
||||
}
|
||||
214
utils/xiaoxilj.js
Normal file
214
utils/xiaoxilj.js
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 连接管理模块 - xiaoxilj.js (根治发送失败:跳转前确保群组已订阅)
|
||||
* 从详情页跳转订单群聊时,提前订阅群组,避免发送消息时订阅未完成而失败
|
||||
*/
|
||||
const app = getApp();
|
||||
|
||||
function waitForConnection(expectedUserId, timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
app.off('connectionChanged', handler);
|
||||
reject(new Error('连接超时'));
|
||||
}, timeout);
|
||||
|
||||
const handler = (event) => {
|
||||
if (event.status === 'connected') {
|
||||
if (event.userId === expectedUserId) {
|
||||
clearTimeout(timer);
|
||||
app.off('connectionChanged', handler);
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`连接身份不匹配: 期望 ${expectedUserId}, 实际 ${event.userId}`);
|
||||
wx.removeStorageSync('savedGoEasyConnection');
|
||||
wx.removeStorageSync('goEasyUserId');
|
||||
wx.removeStorageSync('currentGoEasyIdentity');
|
||||
if (app.clearSavedConnection) app.clearSavedConnection();
|
||||
clearTimeout(timer);
|
||||
app.off('connectionChanged', handler);
|
||||
reject(new Error(`连接身份不匹配: 期望 ${expectedUserId}, 实际 ${event.userId}`));
|
||||
}
|
||||
} else if (event.status === 'disconnected' && !event.manual) {
|
||||
wx.removeStorageSync('savedGoEasyConnection');
|
||||
wx.removeStorageSync('goEasyUserId');
|
||||
wx.removeStorageSync('currentGoEasyIdentity');
|
||||
if (app.clearSavedConnection) app.clearSavedConnection();
|
||||
clearTimeout(timer);
|
||||
app.off('connectionChanged', handler);
|
||||
reject(new Error('连接失败'));
|
||||
}
|
||||
};
|
||||
app.on('connectionChanged', handler);
|
||||
});
|
||||
}
|
||||
|
||||
class ConnectionManager {
|
||||
// 原有的私聊方法保持不变
|
||||
async connectAndChat(params) {
|
||||
const { identityType, userId, targetUser } = params;
|
||||
if (!identityType || !userId || !targetUser || !targetUser.id) {
|
||||
throw new Error('参数不完整');
|
||||
}
|
||||
|
||||
if (identityType === 'dashou' && !userId.startsWith('Ds')) {
|
||||
throw new Error(`打手身份的用户ID必须以Ds开头,实际为 ${userId}`);
|
||||
}
|
||||
if (identityType === 'shangjia' && !userId.startsWith('Sj')) {
|
||||
throw new Error(`商家身份的用户ID必须以Sj开头,实际为 ${userId}`);
|
||||
}
|
||||
if (identityType === 'boss' && !userId.startsWith('Boss')) {
|
||||
throw new Error(`老板身份的用户ID必须以Boss开头,实际为 ${userId}`);
|
||||
}
|
||||
|
||||
wx.removeStorageSync('savedGoEasyConnection');
|
||||
wx.removeStorageSync('goEasyUserId');
|
||||
wx.removeStorageSync('currentGoEasyIdentity');
|
||||
if (app.clearSavedConnection) app.clearSavedConnection();
|
||||
|
||||
if (wx.goEasy && wx.goEasy.getConnectionStatus() === 'connected') {
|
||||
wx.goEasy.disconnect();
|
||||
}
|
||||
|
||||
const waitPromise = waitForConnection(userId);
|
||||
app.connectWithIdentity(identityType, userId, false);
|
||||
|
||||
try {
|
||||
await waitPromise;
|
||||
|
||||
const currentUser = {
|
||||
id: userId,
|
||||
name: app.globalData.currentUser?.name || '用户',
|
||||
avatar: app.globalData.currentUser?.avatar || (app.globalData.ossImageUrl + app.globalData.morentouxiang)
|
||||
};
|
||||
|
||||
const to = {
|
||||
id: targetUser.id,
|
||||
name: targetUser.name || '用户',
|
||||
avatar: targetUser.avatar || (app.globalData.ossImageUrl + app.globalData.morentouxiang)
|
||||
};
|
||||
|
||||
const param = { to, currentUser };
|
||||
const path = '/pages/liaotian/liaotian?data=' + encodeURIComponent(JSON.stringify(param));
|
||||
wx.navigateTo({ url: path });
|
||||
} catch (err) {
|
||||
console.error('连接失败:', err);
|
||||
wx.showToast({ title: err.message || '连接失败', icon: 'none' });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async connectToGroupChat(params) {
|
||||
const { identityType, userId, orderId, groupName, groupAvatar, isCross } = params;
|
||||
if (!identityType || !userId || !orderId) {
|
||||
throw new Error('参数不完整:identityType, userId, orderId 必填');
|
||||
}
|
||||
|
||||
// 校验 userId 前缀
|
||||
if (identityType === 'dashou' && !userId.startsWith('Ds')) {
|
||||
throw new Error(`打手身份的用户ID必须以Ds开头,实际为 ${userId}`);
|
||||
}
|
||||
if (identityType === 'shangjia' && !userId.startsWith('Sj')) {
|
||||
throw new Error(`商家身份的用户ID必须以Sj开头,实际为 ${userId}`);
|
||||
}
|
||||
if (identityType === 'boss' && !userId.startsWith('Boss')) {
|
||||
throw new Error(`老板身份的用户ID必须以Boss开头,实际为 ${userId}`);
|
||||
}
|
||||
|
||||
// 1. 同步全局角色,确保群聊页初始化正确
|
||||
app.globalData.currentRole = identityType;
|
||||
wx.setStorageSync('currentRole', identityType);
|
||||
|
||||
const uid = userId.replace(/^(Ds|Sj|Boss|Gs|Zz)/, '');
|
||||
const avatar = wx.getStorageSync('touxiang') ||
|
||||
(app.globalData.ossImageUrl + app.globalData.morentouxiang);
|
||||
app.globalData.currentUser = {
|
||||
id: userId,
|
||||
name: app.globalData.currentUser?.name || `用户${uid}`,
|
||||
avatar: avatar
|
||||
};
|
||||
|
||||
// 2. 确保 GoEasy 已使用目标身份连接
|
||||
let needConnect = false;
|
||||
if (!wx.goEasy || !wx.goEasy.getConnectionStatus || wx.goEasy.getConnectionStatus() !== 'connected') {
|
||||
needConnect = true;
|
||||
} else {
|
||||
const currentUserId = wx.goEasy.im?.userId;
|
||||
if (currentUserId !== userId) {
|
||||
needConnect = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needConnect) {
|
||||
wx.removeStorageSync('savedGoEasyConnection');
|
||||
wx.removeStorageSync('goEasyUserId');
|
||||
if (app.clearSavedConnection) app.clearSavedConnection();
|
||||
if (wx.goEasy && wx.goEasy.getConnectionStatus() === 'connected') {
|
||||
wx.goEasy.disconnect();
|
||||
}
|
||||
const waitPromise = waitForConnection(userId);
|
||||
app.connectWithIdentity(identityType, userId, false);
|
||||
try {
|
||||
await waitPromise;
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '连接失败', icon: 'none' });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 从会话列表查找真实群组ID
|
||||
let realGroupId = null;
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
wx.goEasy.im.latestConversations({
|
||||
onSuccess: (res) => {
|
||||
const conversations = res.content?.conversations || [];
|
||||
const found = conversations.find(c => c.type === 'group' && c.data && c.data.orderId === orderId);
|
||||
resolve(found ? found.groupId : null);
|
||||
},
|
||||
onFailed: reject
|
||||
});
|
||||
});
|
||||
realGroupId = result;
|
||||
} catch (err) {
|
||||
console.error('获取会话列表失败:', err);
|
||||
wx.showToast({ title: '获取群聊信息失败', icon: 'none' });
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!realGroupId) {
|
||||
wx.showToast({ title: '暂未创建群聊', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 🔥 关键:主动订阅群组并等待成功,确保发送消息时订阅已完成
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
wx.goEasy.im.subscribeGroup({
|
||||
groupIds: [realGroupId],
|
||||
onSuccess: () => {
|
||||
resolve();
|
||||
},
|
||||
onFailed: (error) => {
|
||||
console.error('订阅群组失败:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '订阅群组失败', icon: 'none' });
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 5. 跳转群聊页(参数与消息列表完全一致)
|
||||
const param = {
|
||||
groupId: realGroupId,
|
||||
orderId: orderId,
|
||||
groupName: groupName || '订单群聊',
|
||||
groupAvatar: groupAvatar || '',
|
||||
isCross: isCross || 0
|
||||
};
|
||||
const path = '/pages/qunliaotian/qunliaotian?data=' + encodeURIComponent(JSON.stringify(param));
|
||||
wx.navigateTo({ url: path });
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConnectionManager();
|
||||
89
utils/yaoqingma_utils.py
Normal file
89
utils/yaoqingma_utils.py
Normal 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
|
||||
Reference in New Issue
Block a user