chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
@@ -0,0 +1,451 @@
// message-service.uts
// 实时消息中枢:统一接管 WebSocket 连接、消息分发与会话管理。
//
// 职责:
// 1. 在用户已登录且已拿到后端 appConf.socketUrl 后,自动注入 token 建立连接;
// 2. 监听 wsManager 的 open/message/close/error/reconnect 事件;
// 3. 将服务器下发的消息按 header.type 分发为应用级事件(uni.$emit),
// 各页面(聊天/通知/系统)只需监听对应事件即可,无需关心底层 WS;
// 4. 维护内存中的会话列表与消息列表,提供排序、未读、置顶、免打扰等会话管理;
// 5. 提供 sendChat() 便捷发送单聊/群聊消息。
//
// 注意:连接鉴权采用与后端约定一致的 Authorization 头(Bearer token),
// 由 wsManager.connect 透传到 uni.connectSocket 的 header 中。
import { wsManager } from './websocket.uts'
import {
IMessageBase,
IMessageHeader,
IMessagePayload,
IMessagePreview,
IMessageSession,
IMessageServiceCallbacks,
IChatMessage,
IConnectOptions
} from './socket-types.uts'
import { getUserToken } from '@/stores/user.uts'
import userState from '@/stores/user.uts'
class MessageService {
private initialized = false
private connected = false
private token = ''
// 会话管理状态
private callbacks : IMessageServiceCallbacks = {}
private sessions : Map<string, IMessageSession> = new Map()
private messages : Map<string, IMessageBase[]> = new Map() // session_id -> messages[]
private currentUserId = userState.uid
// 注册 WS 事件监听(幂等,只执行一次)
init() : void {
if (this.initialized == true) {
return
}
this.initialized = true
wsManager.on({
onOpen: (res : any) => {
this.connected = true
console.log('[MessageService] WS 已连接')
uni.$emit('onWsConnected', res)
},
onMessage: (msg : IMessageBase) => {
// 1) 维护会话与消息列表
this.handleWebSocketMessage(msg)
// 2) 应用级事件分发
this.dispatch(msg)
},
onClose: (res : any) => {
this.connected = false
console.log('[MessageService] WS 已关闭')
uni.$emit('onWsDisconnected', res)
},
onError: (err : any) => {
console.error('[MessageService] WS 错误', err)
uni.$emit('onWsError', err)
},
onReconnect: (res : any) => {
console.log('[MessageService] WS 重连中', res)
uni.$emit('onWsReconnect', res)
},
onReconnectFailed: (res : any) => {
console.error('[MessageService] WS 重连失败', res)
uni.$emit('onWsReconnectFailed', res)
}
})
}
/**
* 已登录时按需建立 WS 连接(幂等)。
* @param socketUrl 后端 appConf.socketUrl,为空则跳过
*/
connectIfNeeded(socketUrl : string | null) : void {
this.init()
if (socketUrl == null || socketUrl.length == 0) {
console.warn('[MessageService] 未配置 socketUrl,跳过 WS 连接')
return
}
const tokenResult = getUserToken()
if (tokenResult == null || tokenResult.accessToken == null || tokenResult.accessToken.length == 0) {
console.warn('[MessageService] 未登录或 token 为空,跳过 WS 连接')
return
}
if (wsManager.isConnected() == true) {
return
}
this.token = tokenResult.accessToken!
const options : IConnectOptions = {
reconnectInterval: 3000,
heartbeatInterval: 20000,
maxReconnectCount: 5,
header: {
'Authorization': `Bearer ${this.token}`
}
}
wsManager.connect(socketUrl, options)
}
// 主动断开
disconnect() : void {
wsManager.close(1000, 'user logout')
this.connected = false
}
isConnected() : boolean {
return this.connected
}
/**
* 发送聊天消息(单聊/群聊)。
* 真实送达以服务器回执为准,本方法仅保证消息已提交至 WS。
*/
sendChat(receiver : number, sessionId : string, contentType : string, content : any) : void {
const header : IMessageHeader = {
id: `c_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
type: 'chat',
target: 'single',
seqno: 1,
timestamp: Date.now(),
source: 'client',
version: '1.0.0',
token: this.token
}
const msg : IMessageBase = {
header: header,
payload: {
uid: userState.uid,
receiver: receiver,
session_id: sessionId,
type: contentType,
status: 'sending',
content: content
} as any
}
wsManager.send(msg)
}
// 按消息类型分发为应用级事件
private dispatch(msg : IMessageBase) : void {
const type = msg.header.type
switch (type) {
case 'chat':
uni.$emit('onChatMessage', msg)
break
case 'notify':
uni.$emit('onWsNotify', msg)
break
case 'status':
uni.$emit('onWsStatus', msg)
break
case 'system':
uni.$emit('onWsSystem', msg)
break
case 'pong':
// 心跳回包,wsManager 内部已处理,无需外发
break
default:
uni.$emit('onWsMessage', msg)
}
}
/**
* 处理 WebSocket 消息,维护会话与消息列表
*/
private handleWebSocketMessage(message : IMessageBase) : void {
if (message == null) {
return
}
const { header, payload } = message
const session_id = this.getSessionId(header.target, payload)
// 1. 更新会话信息
this.updateSession(session_id, message)
// 2. 添加到消息列表
this.addMessageToSession(session_id, message)
// 3. 触发 UI 更新
uni.$emit('session_updated', session_id)
}
/**
* 获取会话ID
*/
private getSessionId(target : string, payload : IMessagePayload) : string {
const sessionPayload = payload as IChatMessage
const sessionId = sessionPayload.session_id ?? ''
switch (target) {
case 'single':
return 'single' + sessionId
case 'group':
return 'group' + sessionId
case 'room':
return 'room' + sessionId
case 'system':
default:
return 'system' + sessionId
}
}
/**
* 获取会话名称
*/
private getSessionName(payload : IMessagePayload, type : string) : string {
const chatPayload = payload as IChatMessage
switch (type) {
case 'single':
return chatPayload.nickname ?? '未知用户'
case 'group':
return '群聊'
case 'room':
return chatPayload.nickname ?? '房间'
case 'system':
return '系统消息'
default:
return '会话'
}
}
/**
* 创建会话
*/
private createSession(session_id : string, message : IMessageBase) : IMessageSession {
const { header, payload } = message
const session_type = header.target
const chatPayload = payload as IChatMessage
return {
session_id,
session_type,
session_name: this.getSessionName(payload, session_type),
session_avatar: chatPayload.avatar ?? 'https://cdn.example.com/default_avatar.png',
last_message: this.createMessagePreview(message),
unread_count: 1,
is_pinned: false,
is_muted: false,
last_active_time: message.header.timestamp,
created_time: message.header.timestamp,
status: 'normal',
ext: {}
}
}
/**
* 创建消息预览
*/
private createMessagePreview(message : IMessageBase) : IMessagePreview {
const { header, payload } = message
const chatPayload = payload as IChatMessage
return {
id: header.id,
type: chatPayload.type ?? 'text',
content_preview: this.formatContentPreview(payload),
timestamp: header.timestamp,
msg_status: chatPayload.status ?? 'delivered',
is_mentioned: this.isMentioned(payload),
is_important: this.isImportant(payload),
ext: chatPayload.ext ?? {}
}
}
/**
* 判断是否@我
*/
private isMentioned(payload : IMessagePayload) : boolean {
const mentionPayload = payload as IChatMessage
if (mentionPayload.at_user == null)
return false
else if (mentionPayload.at_user == 'all')
return true
else if ((mentionPayload.at_user as number[]).includes(this.currentUserId))
return true
else
return false
}
/**
* 判断是否重要消息
*/
private isImportant(payload : IMessagePayload) : boolean {
const chatPayload = payload as IChatMessage
// 红包、转账、重要通知等
const importantTypes = ['red_packet', 'transfer', 'system_notify']
return importantTypes.includes(chatPayload.type ?? '')
}
/**
* 格式化消息预览内容
*/
private formatContentPreview(payload : IMessagePayload) : string {
const chatPayload = payload as IChatMessage
if (chatPayload.content == null) return '[消息]'
const type = chatPayload.type ?? 'text'
switch (type) {
case 'text':
return '[文本消息]'
case 'image':
return '[图片]'
case 'audio':
return '[语音]'
case 'video':
return '[视频]'
case 'file':
return '[文件]'
case 'location':
return '[位置]'
case 'emoji':
return '[表情]'
case 'sticker':
return '[贴纸]'
case 'red_packet':
return '[红包]'
case 'recall':
return '[消息已撤回]'
case 'system':
return '[系统消息]'
default:
return '[消息]'
}
}
/**
* 更新会话信息
*/
private updateSession(session_id : string, message : IMessageBase) : void {
const { header, payload } = message
const chatPayload = payload as IChatMessage
// 获取或创建会话
let session = this.sessions.get(session_id)
if (session == null) {
session = this.createSession(session_id, message)
}
// 更新最后消息
session.last_message = this.createMessagePreview(message)
session.last_active_time = header.timestamp
// 增加未读数(如果不是自己发送的消息)
if (chatPayload.uid != this.currentUserId) {
session.unread_count++
}
this.sessions.set(session_id, session)
}
/**
* 添加消息到会话
*/
private addMessageToSession(session_id : string, message : IMessageBase) : void {
if (!this.messages.has(session_id)) {
this.messages.set(session_id, [])
}
const sessionMessages = this.messages.get(session_id)!
sessionMessages.push(message)
// 保持消息按时间排序
sessionMessages.sort((a, b) => a.header.timestamp - b.header.timestamp)
// 限制每会话最多保存100条消息
if (sessionMessages.length > 100) {
sessionMessages.splice(0, sessionMessages.length - 100)
}
}
/**
* 获取排序后的会话列表
*/
getSortedSessions() : IMessageSession[] {
const sessions : IMessageSession[] = []
this.sessions.forEach((value, key) => {
sessions.push(value)
})
// 排序规则:
// 1. 置顶的在前
// 2. 未读消息数多的在前
// 3. 未读且被@的在前
// 4. 最后活跃时间新的在前
sessions.sort((a, b) => {
// 置顶排序
if (a.is_pinned != b.is_pinned) {
return a.is_pinned ? -1 : 1
}
// 未读数排序
if (a.unread_count != b.unread_count) {
return b.unread_count - a.unread_count
}
// @消息排序
const aHasMention = a.last_message?.is_mentioned ?? false
const bHasMention = b.last_message?.is_mentioned ?? false
if (aHasMention != bHasMention) {
return aHasMention ? -1 : 1
}
// 最后活跃时间排序
return b.last_active_time - a.last_active_time
})
return sessions
}
/**
* 获取会话消息列表
*/
getSessionMessages(session_id : string) : any[] {
return (this.messages.get(session_id) as any[] | null) ?? []
}
/**
* 清空会话未读数
*/
clearUnread(session_id : string) : void {
const session = this.sessions.get(session_id)
if (session != null) {
session.unread_count = 0
uni.$emit('session_updated', session_id)
}
}
/**
* 置顶/取消置顶
*/
togglePin(session_id : string, is_pinned : boolean = true) : void {
const session = this.sessions.get(session_id)
if (session != null) {
session.is_pinned = is_pinned
uni.$emit('session_updated', session_id)
}
}
/**
* 免打扰/取消免打扰
*/
toggleMute(session_id : string, is_muted : boolean = true) : void {
const session = this.sessions.get(session_id)
if (session != null) {
session.is_muted = is_muted
uni.$emit('session_updated', session_id)
}
}
/**
* 注册消息回调
*/
on(callbacks : IMessageServiceCallbacks) : void {
if (callbacks.onNewMessage != null) this.callbacks.onNewMessage = callbacks.onNewMessage
if (callbacks.onMessageStatusChange != null) this.callbacks.onMessageStatusChange = callbacks.onMessageStatusChange
}
}
export { IMessageSession }
export const messageService = new MessageService()
export const messageManager = messageService
export default messageService
@@ -0,0 +1,453 @@
export type ContentType =
| 'text' // 文本
| 'image' // 图片
| 'audio' // 音频
| 'video' // 视频
| 'file' // 文件
| 'location' // 位置
| 'emoji' // 表情
| 'sticker' // 贴纸
| 'card' // 卡片
| 'system' // 系统消息
| 'notify' // 通知消息
| 'status' // 状态消息
| 'game' // 游戏消息
| 'room' // 房间消息
| 'user' // 用户消息
| 'error' // 错误消息
| 'red_packet' // 红包
| 'transfer' // 转账
| 'recall' // 撤回
export type MessageStatus =
| 'sending' // 发送中
| 'sent' // 已发送
| 'delivereadBase' //已送达
| 'read' // 已读
| 'failed' // 失败
| 'recalled'; // 已撤回
// ==================== 1. 文本消息 ====================
export type IHeartbeatMessage = {
uid : number; // 用户ID
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
status : MessageStatus;
rtt ?: number; // 往返延迟(毫秒,响应时返回)
type ?: 'text';
content ?: string | IMessageContent
at_all ?: boolean;
at_user ?: null | 'all' | number[];
ext ?: any; // 扩展字段
}
// ==================== 2. 图片消息 ====================
type IImageField = {
// 图片消息特有字段
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_width ?: number;
file_height ?: number;
thumbnail_url ?: string;
thumbnail_size ?: number;
at_user ?: number[];
}
// ==================== 3. 音频消息 ====================
type IVoiceField = {
// 音频消息特有字段
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_duration ?: number;
thumbnail_url ?: string;
}
// ==================== 4. 视频消息 ====================
type IVideoField = {
// 音频消息特有字段
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_duration ?: number;
thumbnail_url ?: string;
}
// ==================== 5. 文件消息 ====================
type IFileField = {
// 文件消息特有字段
file_url : string;
file_name : string;
file_size : number;
file_mime ?: string;
}
// ==================== 6. 位置消息 ====================
type ILocationField = {
// 位置消息特有字段
latitude : number;
longitude : number;
address ?: string;
}
// ==================== 7. 表情消息 ====================
type IEmojiField = {
// 表情消息特有字段
emoji_code : string;
}
// ==================== 8. 贴纸消息 ====================
type IStickerField = {
// 贴纸消息特有字段
sticker_id : string;
sticker_url : string;
}
// ==================== 9. 卡片消息 ====================
type ICardField = {
// 卡片消息特有字段
card_title : string;
card_desc ?: string;
card_image ?: string;
card_url ?: string;
card_data ?: any;
}
// ==================== 10. 系统消息 ====================
type ISystemField = {
// 系统消息特有字段
content : string;
sub_type ?: string;
error_code ?: number;
error_msg ?: string;
error_detail ?: string;
}
export type IMessageContent =
string
| IImageField
| IVoiceField
| IVideoField
| IFileField
| ILocationField
| IEmojiField
| IStickerField
| ICardField
| ISystemField
export interface IChatMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number // 接收用户UID
type : ContentType;
status : MessageStatus;
msg_id ?: string;
ext ?: any;
// 文本消息特有字段
content : IMessageContent;
at_user ?: 'all' | number[];
}
// ==================== 11. 通知消息 ====================
export type NotifyType = 'system' | 'friend' | 'group' | 'game';
export type ActionType = 'accept' | 'reject' | 'ignore';
export interface INotifyMessage {
// 基础字段
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
type : 'notify';
status : MessageStatus;
msg_id ?: string;
receiver : number
at_user ?: number[];
session_id ?: string;
ext ?: any;
content ?: string | IMessageContent
// 通知消息特有字段
notify_type : NotifyType;
notify_title ?: string;
notify_content : string;
notify_icon ?: string;
need_confirm ?: boolean;
action ?: ActionType;
}
// ==================== 12. 状态消息 ====================
export interface IStatusMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number // 接收用户UID
content ?: string | IMessageContent
type : 'status';
status : MessageStatus;
msg_id ?: string;
at_user ?: number[];
ext ?: any;
// 状态消息特有字段
status_type : 'online' | 'offline' | 'typing' | 'recording';
status_data ?: any;
}
// ==================== 13. 游戏消息 ====================
export type GameType = 'chess' | 'poker' | 'mahjong' | 'custom';
export type GameAction = 'start' | 'move' | 'end' | 'ready' | 'leave';
export type GameState = 'waiting' | 'playing' | 'finished';
export interface IGameMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number // 接收用户UID
content ?: string | IMessageContent
type : 'game';
status : MessageStatus;
msg_id ?: string;
at_user ?: number[];
ext ?: any;
// 游戏消息特有字段
game_type : GameType;
game_action : GameAction;
game_data ?: any;
game_status ?: GameState;
current_player ?: string;
game_score ?: number;
}
// ==================== 14. 房间消息 ====================
export type RoomType = 'game' | 'chat';
export type RoomStatus = 'waiting' | 'playing' | 'finished' | 'closed';
export interface IRoomMember {
member_id : string;
nickname ?: string;
avatar ?: string;
role ?: 'owner' | 'admin' | 'member';
join_time ?: number;
online ?: boolean;
}
export interface IRoomMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number // 接收用户UID
content ?: string | IMessageContent
type : 'room';
status : MessageStatus;
msg_id ?: string;
at_user ?: number[];
ext ?: any;
// 房间消息特有字段
room_name ?: string;
room_type : RoomType;
room_status ?: RoomStatus;
room_members ?: IRoomMember[];
room_max_members ?: number;
room_description ?: string;
room_created_time ?: number;
}
// ==================== 15. 用户消息 ====================
export type UserStatus = 'online' | 'offline' | 'away' | 'busy';
export interface IUserMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number // 接收用户UID
content ?: string | IMessageContent
type : 'user';
status : MessageStatus;
msg_id ?: string;
at_user ?: number[];
ext ?: any;
user_status ?: UserStatus;
signature ?: string;
user_data ?: any;
}
// ==================== 16. 错误消息 ====================
export interface IErrorMessage {
uid : number;
nickname ?: string; // 昵称(可选,用于显示)
avatar ?: string; // 头像URL(可选,用于显示)
session_id ?: string; // 会话ID uid | group_id | room _id
receiver : number; // 接收用户UID
type? : 'error';
content ?: string | IMessageContent
status? : MessageStatus;
msg_id ?: string;
at_user ?: number[];
ext ?: any;
// 错误消息特有字段
error_code ?: number;
error_msg ?: string;
error_detail ?: string;
error_data ?: any;
}
// ==================== 17. 联合类型组织 ====================
// 聊天消息(用户之间的消息)
// 完整的消息载荷类型
export type IMessagePayload =
IHeartbeatMessage
| IChatMessage // 聊天消息
| INotifyMessage // 通知消息
| IStatusMessage // 状态消息
| IGameMessage // 游戏消息
| IRoomMessage // 房间消息
| IUserMessage // 用户消息
| IErrorMessage; // 错误消息
export type IMessageHeader = {
// 消息唯一ID(雪花算法生成,必填)
id : string;
// 消息类型(必填)
type : 'ping' | 'pong' | 'chat' | 'notify' | 'status' | 'game' | 'system';
// 目标会话类型(single/group/room/system,选填)
target : "single" | "group" | "room" | "system";
// 客户端序列号(用于去重和顺序保证,必填)
seqno : number;
// 时间戳(毫秒,必填)
timestamp : number;
// 消息来源(client/server,选填)
source ?: string;
version : "1.0.0" | string
token ?: string
platform ?: 'ios' | 'android' | 'web'; // 平台类型
device_id ?: string; // 设备ID
network_type ?: 'wifi' | '4g' | '5g' | 'unknown'; // 网络类型
}
export type IMessageBase = {
// 消息头
header : IMessageHeader;
// 消息体
payload : IMessagePayload;
}
export type IMessageSession = {
// 会话唯一标识(user_id/group_id/room_id/system
session_id : string;
// 会话类型(single/group/room/system
session_type : 'single' | 'group' | 'room' | 'system';
// 会话名称
session_name : string;
// 会话头像/封面
session_avatar : string;
// 最后一条消息
last_message : IMessagePreview | null;
// 未读消息数
unread_count : number;
// 是否置顶
is_pinned : boolean;
// 是否免打扰
is_muted : boolean;
// 最后活跃时间(毫秒时间戳)
last_active_time : number;
// 创建时间
created_time : number;
// 会话状态(normal/archived/deleted
status : 'normal' | 'archived' | 'deleted';
// 扩展字段
ext ?: any;
}
export type IMessagePreview = {
// 消息ID
id : string;
// 消息类型
type : string;
// 消息内容预览
content_preview : string;
// 发送者(单聊时显示对方昵称)
sender_name ?: string;
// 发送者头像
sender_avatar ?: string;
// 消息时间
timestamp : number;
// 消息状态
msg_status : string;
// 是否@我
is_mentioned : boolean;
// 是否为重要消息
is_important : boolean;
// 消息扩展信息
ext ?: any;
}
export type IWebSocketPayload = string | UTSJSONObject | ArrayBuffer | IMessageBase
export type IConnectOptions = {
reconnectInterval ?: number
heartbeatInterval ?: number
maxReconnectCount ?: number
protocols ?: Array<string> | null
header ?: UTSJSONObject | null
heartbeatTimeout ?: number
connectTimeout ?: number
debug ?: boolean
}
export type IWebSocketCallbacks = {
onOpen ?: ((res : any) => void) | null
onMessage ?: ((res : IMessageBase) => void) | null
onError ?: ((res : any) => void) | null
onClose ?: ((res : any) => void) | null
onReconnect ?: ((res : any) => void) | null
onReconnectFailed ?: ((res : any) => void) | null
onHeartbeatTimeout ?: ((res : any) => void) | null
}
export type IMessageQueueItem = {
data : IMessageBase | IWebSocketPayload
success : (() => void) | null
fail : ((err : any) => void) | null
}
export type IMessageServiceCallbacks = {
onNewMessage ?: ((msg : IMessageBase) => void) | null
//onConversationUpdate ?: ((conv : IConversation) => void) | null
onMessageStatusChange ?: ((msgId : string, status : string) => void) | null
}
@@ -0,0 +1,244 @@
// 消息类型 文本 图片 音频 视频 文件 // 位置 表情 // 贴纸// 卡片 系统消息
export type ContentType = 'text' | 'image' | 'audio' | 'video' | 'file' | 'location' | 'emoji' | 'sticker' | 'card' | 'system'
// 消息状态 发送中 已发送 已送达 已读 失败 已撤回
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'read' | 'failed' | 'recalled'
export interface IPayloadBase {
msg_id : string; // 消息ID
seq_no : number; // 序列号
timestamp : number; // 时间戳
uid : number; // 发送者ID
msg_status : MessageStatus; // 消息状态
target_msg_id ?: string; // 目标消息ID
to_user_id ?: string; // 会话路由
group_id ?: string;
room_id ?: string;
session_id ?: string;
ext ?: any; // 扩展字段
}
export interface IHeartbeatMessage extends IPayloadBase {
msg_id : string; // 消息ID
seq_no : number; // 序列号
timestamp : number; // 时间戳
uid : number; // 发送者ID
}
// ==================== 3. 聊天消息类型 ====================
// 文本消息
export interface ITextMessage extends IPayloadBase {
content_type : ContentType
content : string;
at_all ?: boolean;
at_users ?: number[];
sub_type ?: string;
}
// 图片消息
export interface IImageMessage extends IPayloadBase {
content_type : ContentType
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_width ?: number;
file_height ?: number;
thumbnail_url ?: string;
thumbnail_size ?: number;
at_all ?: boolean;
at_users ?: number[];
}
// 音频消息
export interface IAudioMessage extends IPayloadBase {
content_type : ContentType
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_duration ?: number;
thumbnail_url ?: string;
}
// 视频消息
export interface IVideoMessage extends IPayloadBase {
content_type : ContentType
file_url : string;
file_name ?: string;
file_size ?: number;
file_mime ?: string;
file_duration ?: number;
file_width ?: number;
file_height ?: number;
thumbnail_url ?: string;
thumbnail_size ?: number;
}
// 文件消息
export interface IFileMessage extends IPayloadBase {
content_type : ContentType
file_url : string;
file_name : string;
file_size : number;
file_mime ?: string;
}
// 位置消息
export interface ILocationMessage extends IPayloadBase {
content_type : ContentType
latitude : number;
longitude : number;
address ?: string;
}
// 表情消息
export interface IEmojiMessage extends IPayloadBase {
content_type : ContentType
emoji_code : string;
}
// 贴纸消息
export interface IStickerMessage extends IPayloadBase {
content_type : ContentType
sticker_id : string;
sticker_url : string;
}
// 卡片消息
export interface ICardMessage extends IPayloadBase {
content_type : ContentType
card_title : string;
card_desc ?: string;
card_image ?: string;
card_url ?: string;
card_data ?: any;
}
// 系统消息
export interface ISystemMessage extends IPayloadBase {
content_type : ContentType
content : string;
sub_type ?: string;
error_code ?: number;
error_msg ?: string;
error_detail ?: string;
}
// ==================== 4. 通知消息类型 ====================
export type NotifyType = 'system' | 'friend' | 'group' | 'game';
export type ActionType = 'accept' | 'reject' | 'ignore';
export interface INotifyMessage extends IPayloadBase {
notify_type : NotifyType;
notify_title ?: string;
notify_content : string;
notify_icon ?: string;
need_confirm ?: boolean;
action ?: ActionType;
}
// ==================== 5. 状态消息类型 ====================
export interface IStatusMessage extends IPayloadBase {
status_type : 'online' | 'offline' | 'typing' | 'recording';
status_data ?: any;
}
// ==================== 6. 游戏消息类型 ====================
export type GameType = 'chess' | 'poker' | 'mahjong' | 'custom';
export type GameAction = 'start' | 'move' | 'end' | 'ready' | 'leave';
export type GameState = 'waiting' | 'playing' | 'finished';
export interface IGameMessage extends IPayloadBase {
game_type : GameType;
game_action : GameAction;
game_data ?: any;
game_status ?: GameState;
current_player ?: string;
game_score ?: number;
}
// ==================== 7. 房间消息类型 ====================
export type RoomType = 'game' | 'chat';
export type RoomStatus = 'waiting' | 'playing' | 'finished' | 'closed';
export interface IRoomMember {
member_id : string;
nickname ?: string;
avatar ?: string;
role ?: 'owner' | 'admin' | 'member';
join_time ?: number;
online ?: boolean;
}
export interface IRoomMessage extends IPayloadBase {
room_name ?: string;
room_type : RoomType;
room_status ?: RoomStatus;
room_members ?: IRoomMember[];
room_max_members ?: number;
room_description ?: string;
room_created_time ?: number;
}
// ==================== 8. 用户消息类型 ====================
export type UserStatus = 'online' | 'offline' | 'away' | 'busy';
export interface IUserMessage extends IPayloadBase {
nickname ?: string;
avatar ?: string;
user_status ?: UserStatus;
signature ?: string;
user_data ?: any;
}
// ==================== 9. 错误消息类型 ====================
export interface IErrorMessage extends IPayloadBase {
error_code : number;
error_msg : string;
error_detail ?: string;
error_data ?: any;
}
// ==================== 10. 联合类型组织 ====================
// 聊天消息(用户之间的消息)
export type IChatMessage = ITextMessage | IImageMessage | IAudioMessage | IVideoMessage | IFileMessage | ILocationMessage | IEmojiMessage | IStickerMessage | ICardMessage | ISystemMessage;
// 通知消息
export type INotifyMessageUnion = INotifyMessage;
// 系统状态消息
export type IStatusMessageUnion = IStatusMessage;
// 游戏消息
export type IGameMessageUnion = IGameMessage;
// 房间消息
export type IRoomMessageUnion = IRoomMessage;
// 用户消息
export type IUserMessageUnion = IUserMessage;
// 错误消息
export type IErrorMessageUnion = IErrorMessage;
// 完整的消息载荷类型 // 聊天消息 通知消息 状态消息 游戏消息 房间消息 用户消息 错误消息
type IMessagePayload = IHeartbeatMessage | IChatMessage | INotifyMessage | IStatusMessage | IGameMessage | IRoomMessage | IUserMessage | IErrorMessage;
export default IMessagePayload;
@@ -0,0 +1,424 @@
import {
IMessageBase,
IConnectOptions,
IWebSocketCallbacks,
IMessageQueueItem,
IChatMessage,
IHeartbeatMessage,
IWebSocketPayload
} from './socket-types.uts'
import userState from '@/stores/user.uts'
class WebSocketManager {
private static instance : WebSocketManager | null = null
private socketTask : SocketTask | null = null
private url = ''
private protocols : Array<string> = []
private header : UTSJSONObject | null = null
private connected = false
private connecting = false
private manualClose = false
private reconnectTimer : number | null = null
private heartbeatTimer : number | null = null
private lastHeartbeatAt = 0
private waitingHeartbeat = false
private reconnectCount = 0
private reconnectInterval = 3000
private heartbeatInterval = 20000
private heartbeatTimeout = 10000
private connectTimeout = 10000
private maxReconnectCount = 5
private debug = false
private callbacks : IWebSocketCallbacks = {}
private messageQueue : Array<IMessageQueueItem> = []
static getInstance() : WebSocketManager {
if (WebSocketManager.instance == null) {
WebSocketManager.instance = new WebSocketManager()
}
return WebSocketManager.instance
}
connect(url : string, options : IConnectOptions | null = null) : void {
this.log('WebSocket connect')
if (url.length == 0) {
this.log('WebSocket url 不能为空')
return
}
const urlChanged = this.url.length > 0 && this.url != url
this.url = url
this.applyOptions(options)
if (urlChanged == true) {
this.releaseSocket()
}
if (this.connected == true || this.connecting == true) {
return
}
this.manualClose = false
this.connecting = true
this.clearReconnectTimer()
this.createSocketTask()
}
/**
* 应用配置
*/
private applyOptions(options : IConnectOptions | null) : void {
if (options == null) {
return
}
if (options.reconnectInterval != null) this.reconnectInterval = options.reconnectInterval
if (options.heartbeatInterval != null) this.heartbeatInterval = options.heartbeatInterval
if (options.maxReconnectCount != null) this.maxReconnectCount = options.maxReconnectCount
if (options.protocols != null) this.protocols = options.protocols
if (options.header != null) this.header = options.header
if (options.heartbeatTimeout != null) this.heartbeatTimeout = options.heartbeatTimeout
if (options.connectTimeout != null) this.connectTimeout = options.connectTimeout
if (options.debug != null) this.debug = options.debug
}
private createSocketTask() : void {
this.releaseSocket()
let finished = false
const finishConnect = () : void => {
if (finished == true) {
return
}
finished = true
this.connecting = false
}
const connectTimeoutTimer = setTimeout(() => {
finishConnect()
this.handleError({ errMsg: 'WebSocket 连接超时' })
this.scheduleReconnect('connect timeout')
}, this.connectTimeout) as number
try {
this.socketTask = uni.connectSocket({
url: this.url,
header: this.header,
protocols: this.protocols
})
const currentTask = this.socketTask
if (currentTask == null) {
clearTimeout(connectTimeoutTimer)
finishConnect()
return
}
currentTask.onOpen((res : any) => {
clearTimeout(connectTimeoutTimer)
finishConnect()
this.connected = true
this.reconnectCount = 0
this.waitingHeartbeat = false
this.startHeartbeat()
this.flushMessageQueue()
if (this.callbacks.onOpen != null) {
this.callbacks.onOpen(res)
}
uni.$emit('onWsOpen', res)
})
currentTask.onMessage((res : OnSocketMessageCallbackResult) => {
console.log(res)
this.handleMessage(res)
})
currentTask.onError((err : any) => {
clearTimeout(connectTimeoutTimer)
finishConnect()
uni.$emit('onWsError', err)
this.handleError(err)
})
currentTask.onClose((res : any) => {
clearTimeout(connectTimeoutTimer)
finishConnect()
uni.$emit('onWsClose', res)
this.handleClose(res)
})
} catch (error) {
clearTimeout(connectTimeoutTimer)
finishConnect()
this.handleError(error)
}
}
private handleMessage(res : OnSocketMessageCallbackResult) : void {
const dataStr = res.data as string
// #ifndef APP
const rawData = JSON.parse(dataStr) as IMessageBase
// #endif
// #ifdef APP
const rawJson = JSON.parseObject(dataStr)
console.log("OnMessage:", rawJson)
const rawData = rawJson?.parse<IMessageBase>()
// #endif
//if (rawData.type == "ping")
console.log("OnMessage:", rawData)
if (rawData != null && rawData.header.type == "pong") {
this.waitingHeartbeat = false
this.lastHeartbeatAt = Date.now()
return
}
if (rawData != null && this.callbacks.onMessage != null) {
uni.$emit('onWsMessage', rawData)
this.callbacks.onMessage(rawData)
}
}
send(data : IWebSocketPayload, success : (() => void) | null = null, fail : ((err : any) => void) | null = null) : void {
let payload : string | IMessageBase | UTSJSONObject | null = null
try {
payload = JSON.stringify(data)
} catch (error) {
payload = null
}
if (payload == null) {
if (fail != null) {
fail({ errMsg: 'WebSocket 消息序列化失败' })
}
return
}
if (this.socketTask == null || this.connected == false) {
this.enqueueMessage(data, success, fail)
return
}
this.socketTask.send({
data: payload,
success: () => {
if (success != null) {
success()
}
},
fail: (err : any) => {
this.enqueueMessage(data, success, fail)
if (fail != null) {
fail(err)
}
}
})
}
close(code : number = 1000, reason : string = 'manual close') : void {
this.manualClose = true
this.stopHeartbeat()
this.clearReconnectTimer()
this.connected = false
this.connecting = false
if (this.socketTask != null) {
try {
this.socketTask.close({ code, reason })
} catch (error) {
this.log('关闭 WebSocket 失败', error)
}
}
this.releaseSocket()
}
on(callbacks : IWebSocketCallbacks) : void {
if (callbacks.onOpen != null) this.callbacks.onOpen = callbacks.onOpen
if (callbacks.onMessage != null) this.callbacks.onMessage = callbacks.onMessage
if (callbacks.onError != null) this.callbacks.onError = callbacks.onError
if (callbacks.onClose != null) this.callbacks.onClose = callbacks.onClose
if (callbacks.onReconnect != null) this.callbacks.onReconnect = callbacks.onReconnect
if (callbacks.onReconnectFailed != null) this.callbacks.onReconnectFailed = callbacks.onReconnectFailed
if (callbacks.onHeartbeatTimeout != null) this.callbacks.onHeartbeatTimeout = callbacks.onHeartbeatTimeout
}
isConnected() : boolean {
return this.connected
}
getReconnectCount() : number {
return this.reconnectCount
}
getStats() : UTSJSONObject {
return {
url: this.url,
connected: this.connected,
connecting: this.connecting,
reconnectCount: this.reconnectCount,
queueSize: this.messageQueue.length,
lastHeartbeatAt: this.lastHeartbeatAt
} as UTSJSONObject
}
removeAllListeners() : void {
this.callbacks = {}
}
/**
* 开始心跳
*/
private startHeartbeat() : void {
this.stopHeartbeat()
if (this.heartbeatInterval <= 0) return
this.heartbeatTimer = setInterval(() => {
if (this.connected == false) {
this.stopHeartbeat()
return
}
if (this.waitingHeartbeat == true) {
const expired = Date.now() - this.lastHeartbeatAt > this.heartbeatTimeout
if (expired == true) {
if (this.callbacks.onHeartbeatTimeout != null) {
this.callbacks.onHeartbeatTimeout({ errMsg: 'WebSocket 心跳超时' })
}
this.scheduleReconnect('heartbeat timeout')
return
}
}
this.waitingHeartbeat = true
this.lastHeartbeatAt = Date.now()
const heartbeatUserId = userState.uid
const heartData : IMessageBase = {
header: {
id: "heartbeat_1700000000000",
type: "ping",
seqno: 1,
timestamp: Date.now(),
source: "client",
target: "system",
version: "1.0.0",
platform: 'android',
device_id: "device_id",
network_type: 'wifi',
},
payload: {
uid: heartbeatUserId,
status: 'sent',
type: 'text',
content: 'ping'
}
}
this.send(heartData)
}, this.heartbeatInterval) as number
}
/**
* 停止心跳
*/
private stopHeartbeat() : void {
const timer = this.heartbeatTimer
if (timer != null) {
clearInterval(timer)
this.heartbeatTimer = null
}
this.waitingHeartbeat = false
}
/**
* 将消息入队
* @param data IWebSocketPayload
* @param success (() => void) | null
* @param fail fail : ((err : any) => void) | null
* @return { void }
*/
private enqueueMessage(data : IWebSocketPayload, success : (() => void) | null, fail : ((err : any) => void) | null) : void {
this.messageQueue.push({ data, success, fail })
if (this.messageQueue.length > 100) {
this.messageQueue.splice(0, this.messageQueue.length - 100)
}
}
/**
* 刷新消息队列
* @return {void}
*/
private flushMessageQueue() : void {
if (this.messageQueue.length == 0) {
return
}
const queue = this.messageQueue.slice()
this.messageQueue = []
for (let i = 0; i < queue.length; i++) {
const item = queue[i]
this.send(item.data, item.success, item.fail)
}
}
private handleError(err : any) : void {
if (this.callbacks.onError != null) {
this.callbacks.onError(err)
}
this.scheduleReconnect('socket error')
}
private handleClose(res : any) : void {
this.connected = false
this.connecting = false
this.stopHeartbeat()
this.releaseSocket()
if (this.callbacks.onClose != null) {
this.callbacks.onClose(res)
}
if (this.manualClose == false) {
this.scheduleReconnect('socket close')
}
}
private scheduleReconnect(reason : string) : void {
if (this.manualClose == true || this.url.length == 0) {
return
}
if (this.reconnectTimer != null) {
return
}
if (this.reconnectCount >= this.maxReconnectCount) {
if (this.callbacks.onReconnectFailed != null) {
this.callbacks.onReconnectFailed({ errMsg: reason, reconnectCount: this.reconnectCount })
}
return
}
this.connected = false
this.connecting = false
this.stopHeartbeat()
this.releaseSocket()
this.reconnectCount = this.reconnectCount + 1
if (this.callbacks.onReconnect != null) {
this.callbacks.onReconnect({ reason, reconnectCount: this.reconnectCount })
}
const delay = this.reconnectInterval * this.reconnectCount
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect(this.url)
}, delay) as number
}
private clearReconnectTimer() : void {
const timer = this.reconnectTimer
if (timer != null) {
clearTimeout(timer)
this.reconnectTimer = null
}
}
private releaseSocket() : void {
this.socketTask = null
}
private log(message : string, detail : any | null = null) : void {
if (this.debug == false) {
return
}
if (detail != null) {
console.log('[WebSocketManager]', message, detail)
return
}
console.log('[WebSocketManager]', message)
}
}
export type { IConnectOptions, IWebSocketCallbacks }
export const wsManager = WebSocketManager.getInstance()
export default WebSocketManager