677 lines
18 KiB
Markdown
677 lines
18 KiB
Markdown
# 相宜相亲 - 即时通讯方案说明
|
||
|
||
**文档版本**: 1.0
|
||
**创建日期**: 2026-01-14
|
||
**原始设计**: 2025-12-28
|
||
|
||
---
|
||
|
||
## 一、原计划方案:SignalR
|
||
|
||
根据项目技术栈文档(`server/技术栈与开发规范.md`),**原先计划使用 SignalR 作为即时通讯方案**。
|
||
|
||
### 1.1 技术选型确认
|
||
|
||
| 技术 | 版本 | 说明 |
|
||
|-----|------|------|
|
||
| **实时通信** | SignalR | 8.0 | 聊天消息实时推送 |
|
||
|
||
**原文引用**:
|
||
```
|
||
| **实时通信** | SignalR | 8.0 | 聊天消息实时推送 |
|
||
```
|
||
|
||
### 1.2 SignalR 方案优势
|
||
|
||
1. **原生集成** - ASP.NET Core 8.0 内置支持
|
||
2. **自动降级** - WebSocket → Server-Sent Events → Long Polling
|
||
3. **连接管理** - 自动处理断线重连
|
||
4. **分组功能** - 支持用户分组推送
|
||
5. **跨平台** - 支持小程序、Web、移动端
|
||
|
||
### 1.3 数据库设计已就绪
|
||
|
||
数据库已经设计了完整的聊天表结构:
|
||
|
||
#### Chat_Session (聊天会话表)
|
||
```sql
|
||
CREATE TABLE Chat_Session (
|
||
Id BIGINT PRIMARY KEY IDENTITY(1,1),
|
||
User1Id BIGINT NOT NULL,
|
||
User2Id BIGINT NOT NULL,
|
||
LastMessageId BIGINT,
|
||
LastMessageTime DATETIME2,
|
||
User1UnreadCount INT NOT NULL DEFAULT 0,
|
||
User2UnreadCount INT NOT NULL DEFAULT 0,
|
||
CreateTime DATETIME2 NOT NULL DEFAULT GETDATE(),
|
||
UpdateTime DATETIME2,
|
||
CONSTRAINT UQ_Chat_Session UNIQUE (User1Id, User2Id)
|
||
);
|
||
```
|
||
|
||
#### Chat_Message (聊天消息表)
|
||
```sql
|
||
CREATE TABLE Chat_Message (
|
||
Id BIGINT PRIMARY KEY IDENTITY(1,1),
|
||
SessionId BIGINT NOT NULL,
|
||
SenderId BIGINT NOT NULL,
|
||
ReceiverId BIGINT NOT NULL,
|
||
MessageType INT NOT NULL,
|
||
Content NVARCHAR(MAX),
|
||
VoiceUrl NVARCHAR(500),
|
||
VoiceDuration INT,
|
||
ExtraData NVARCHAR(MAX),
|
||
Status INT NOT NULL DEFAULT 1,
|
||
IsRead BIT NOT NULL DEFAULT 0,
|
||
CreateTime DATETIME2 NOT NULL DEFAULT GETDATE()
|
||
);
|
||
```
|
||
|
||
#### 消息类型枚举
|
||
```csharp
|
||
public enum MessageType
|
||
{
|
||
Text = 1, // 文本
|
||
Voice = 2, // 语音
|
||
Image = 3, // 图片
|
||
ExchangeWeChatRequest = 4, // 交换微信请求
|
||
ExchangeWeChatResult = 5, // 交换微信结果
|
||
ExchangePhotoRequest = 6, // 交换照片请求
|
||
ExchangePhotoResult = 7 // 交换照片结果
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 二、SignalR 实现方案
|
||
|
||
### 2.1 后端实现架构
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ 小程序客户端 │
|
||
│ (uni-app WebSocket) │
|
||
└────────────────────┬────────────────────────────────┘
|
||
│ WebSocket 连接
|
||
▼
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ SignalR Hub (ChatHub) │
|
||
│ - OnConnectedAsync() 用户上线 │
|
||
│ - OnDisconnectedAsync() 用户下线 │
|
||
│ - SendMessage() 发送消息 │
|
||
│ - JoinGroup() 加入会话组 │
|
||
├─────────────────────────────────────────────────────┤
|
||
│ 业务服务层 │
|
||
│ - ChatService 聊天业务逻辑 │
|
||
│ - MessageService 消息处理 │
|
||
│ - NotificationService 推送通知 │
|
||
└────────────────────┬────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ 数据库 (SQL Server) │
|
||
│ - Chat_Session 会话表 │
|
||
│ - Chat_Message 消息表 │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2.2 后端代码示例
|
||
|
||
#### ChatHub.cs
|
||
```csharp
|
||
using Microsoft.AspNetCore.SignalR;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
|
||
namespace XiangYi.AppApi.Hubs
|
||
{
|
||
[Authorize]
|
||
public class ChatHub : Hub
|
||
{
|
||
private readonly IChatService _chatService;
|
||
private readonly ILogger<ChatHub> _logger;
|
||
|
||
public ChatHub(IChatService chatService, ILogger<ChatHub> logger)
|
||
{
|
||
_chatService = chatService;
|
||
_logger = logger;
|
||
}
|
||
|
||
// 用户连接
|
||
public override async Task OnConnectedAsync()
|
||
{
|
||
var userId = Context.User?.FindFirst("UserId")?.Value;
|
||
if (!string.IsNullOrEmpty(userId))
|
||
{
|
||
// 将用户加入自己的组(用于接收消息)
|
||
await Groups.AddToGroupAsync(Context.ConnectionId, $"user_{userId}");
|
||
_logger.LogInformation($"用户 {userId} 已连接,ConnectionId: {Context.ConnectionId}");
|
||
}
|
||
await base.OnConnectedAsync();
|
||
}
|
||
|
||
// 用户断开
|
||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||
{
|
||
var userId = Context.User?.FindFirst("UserId")?.Value;
|
||
if (!string.IsNullOrEmpty(userId))
|
||
{
|
||
_logger.LogInformation($"用户 {userId} 已断开连接");
|
||
}
|
||
await base.OnDisconnectedAsync(exception);
|
||
}
|
||
|
||
// 发送消息
|
||
public async Task SendMessage(SendMessageDto dto)
|
||
{
|
||
var senderId = long.Parse(Context.User?.FindFirst("UserId")?.Value ?? "0");
|
||
|
||
// 保存消息到数据库
|
||
var message = await _chatService.SendMessageAsync(senderId, dto);
|
||
|
||
// 推送给接收者
|
||
await Clients.Group($"user_{dto.ReceiverId}")
|
||
.SendAsync("ReceiveMessage", message);
|
||
|
||
// 确认发送成功给发送者
|
||
await Clients.Caller.SendAsync("MessageSent", new {
|
||
tempId = dto.TempId,
|
||
messageId = message.Id
|
||
});
|
||
}
|
||
|
||
// 标记消息已读
|
||
public async Task MarkAsRead(long sessionId)
|
||
{
|
||
var userId = long.Parse(Context.User?.FindFirst("UserId")?.Value ?? "0");
|
||
await _chatService.MarkSessionAsReadAsync(userId, sessionId);
|
||
}
|
||
|
||
// 交换微信
|
||
public async Task ExchangeWeChat(long receiverId)
|
||
{
|
||
var senderId = long.Parse(Context.User?.FindFirst("UserId")?.Value ?? "0");
|
||
var message = await _chatService.ExchangeWeChatAsync(senderId, receiverId);
|
||
|
||
// 推送给接收者
|
||
await Clients.Group($"user_{receiverId}")
|
||
.SendAsync("ReceiveMessage", message);
|
||
}
|
||
|
||
// 响应交换请求
|
||
public async Task RespondExchange(long messageId, bool accept)
|
||
{
|
||
var userId = long.Parse(Context.User?.FindFirst("UserId")?.Value ?? "0");
|
||
var result = await _chatService.RespondExchangeAsync(userId, messageId, accept);
|
||
|
||
// 推送给请求者
|
||
await Clients.Group($"user_{result.RequesterId}")
|
||
.SendAsync("ExchangeResponded", result);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Program.cs 配置
|
||
```csharp
|
||
// 添加 SignalR
|
||
builder.Services.AddSignalR(options =>
|
||
{
|
||
options.EnableDetailedErrors = true;
|
||
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
|
||
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
|
||
});
|
||
|
||
// 映射 Hub
|
||
app.MapHub<ChatHub>("/hubs/chat");
|
||
```
|
||
|
||
### 2.3 前端实现(小程序)
|
||
|
||
#### utils/signalr.js
|
||
```javascript
|
||
/**
|
||
* SignalR 连接管理
|
||
*/
|
||
import { getToken } from './storage'
|
||
import config from '../config/index'
|
||
|
||
class SignalRClient {
|
||
constructor() {
|
||
this.connection = null
|
||
this.isConnected = false
|
||
this.reconnectAttempts = 0
|
||
this.maxReconnectAttempts = 5
|
||
this.messageHandlers = []
|
||
}
|
||
|
||
/**
|
||
* 连接 SignalR
|
||
*/
|
||
async connect() {
|
||
if (this.isConnected) {
|
||
console.log('SignalR 已连接')
|
||
return
|
||
}
|
||
|
||
const token = getToken()
|
||
if (!token) {
|
||
console.error('未登录,无法连接 SignalR')
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 构建 WebSocket URL
|
||
const wsUrl = config.API_BASE_URL.replace('http', 'ws') + '/hubs/chat'
|
||
|
||
// 创建 WebSocket 连接
|
||
this.connection = uni.connectSocket({
|
||
url: `${wsUrl}?access_token=${token}`,
|
||
header: {
|
||
'content-type': 'application/json'
|
||
},
|
||
protocols: ['websocket'],
|
||
success: () => {
|
||
console.log('SignalR WebSocket 创建成功')
|
||
}
|
||
})
|
||
|
||
// 监听连接打开
|
||
uni.onSocketOpen(() => {
|
||
console.log('SignalR 连接成功')
|
||
this.isConnected = true
|
||
this.reconnectAttempts = 0
|
||
this.sendHandshake()
|
||
})
|
||
|
||
// 监听消息
|
||
uni.onSocketMessage((res) => {
|
||
this.handleMessage(res.data)
|
||
})
|
||
|
||
// 监听连接关闭
|
||
uni.onSocketClose(() => {
|
||
console.log('SignalR 连接关闭')
|
||
this.isConnected = false
|
||
this.handleReconnect()
|
||
})
|
||
|
||
// 监听错误
|
||
uni.onSocketError((err) => {
|
||
console.error('SignalR 连接错误:', err)
|
||
this.isConnected = false
|
||
})
|
||
|
||
} catch (error) {
|
||
console.error('SignalR 连接失败:', error)
|
||
this.handleReconnect()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送握手消息(SignalR 协议)
|
||
*/
|
||
sendHandshake() {
|
||
const handshake = {
|
||
protocol: 'json',
|
||
version: 1
|
||
}
|
||
this.send(JSON.stringify(handshake) + '\x1e')
|
||
}
|
||
|
||
/**
|
||
* 处理接收到的消息
|
||
*/
|
||
handleMessage(data) {
|
||
try {
|
||
// SignalR 消息以 \x1e 分隔
|
||
const messages = data.split('\x1e').filter(m => m)
|
||
|
||
messages.forEach(msg => {
|
||
const message = JSON.parse(msg)
|
||
|
||
// 处理不同类型的消息
|
||
if (message.type === 1) {
|
||
// 调用方法响应
|
||
this.handleInvocation(message)
|
||
} else if (message.type === 3) {
|
||
// 完成响应
|
||
console.log('方法调用完成:', message)
|
||
}
|
||
})
|
||
} catch (error) {
|
||
console.error('解析消息失败:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理服务端调用
|
||
*/
|
||
handleInvocation(message) {
|
||
const { target, arguments: args } = message
|
||
|
||
// 触发对应的事件处理器
|
||
this.messageHandlers.forEach(handler => {
|
||
if (handler.event === target) {
|
||
handler.callback(...args)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 发送消息
|
||
*/
|
||
send(data) {
|
||
if (!this.isConnected) {
|
||
console.error('SignalR 未连接')
|
||
return
|
||
}
|
||
|
||
uni.sendSocketMessage({
|
||
data,
|
||
success: () => {
|
||
console.log('消息发送成功')
|
||
},
|
||
fail: (err) => {
|
||
console.error('消息发送失败:', err)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 调用服务端方法
|
||
*/
|
||
invoke(method, ...args) {
|
||
const message = {
|
||
type: 1,
|
||
target: method,
|
||
arguments: args
|
||
}
|
||
this.send(JSON.stringify(message) + '\x1e')
|
||
}
|
||
|
||
/**
|
||
* 监听服务端事件
|
||
*/
|
||
on(event, callback) {
|
||
this.messageHandlers.push({ event, callback })
|
||
}
|
||
|
||
/**
|
||
* 移除事件监听
|
||
*/
|
||
off(event, callback) {
|
||
this.messageHandlers = this.messageHandlers.filter(
|
||
h => h.event !== event || h.callback !== callback
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 断线重连
|
||
*/
|
||
handleReconnect() {
|
||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||
console.error('SignalR 重连次数超限')
|
||
return
|
||
}
|
||
|
||
this.reconnectAttempts++
|
||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000)
|
||
|
||
console.log(`${delay}ms 后尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
||
|
||
setTimeout(() => {
|
||
this.connect()
|
||
}, delay)
|
||
}
|
||
|
||
/**
|
||
* 断开连接
|
||
*/
|
||
disconnect() {
|
||
if (this.connection) {
|
||
uni.closeSocket()
|
||
this.isConnected = false
|
||
this.connection = null
|
||
}
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const signalR = new SignalRClient()
|
||
|
||
export default signalR
|
||
```
|
||
|
||
#### 在聊天页面使用
|
||
```javascript
|
||
// pages/chat/index.vue
|
||
import { signalR } from '@/utils/signalr'
|
||
|
||
onMounted(() => {
|
||
// 连接 SignalR
|
||
signalR.connect()
|
||
|
||
// 监听接收消息
|
||
signalR.on('ReceiveMessage', (message) => {
|
||
console.log('收到新消息:', message)
|
||
messages.value.push(message)
|
||
scrollToBottom()
|
||
})
|
||
|
||
// 监听消息发送成功
|
||
signalR.on('MessageSent', ({ tempId, messageId }) => {
|
||
const msg = messages.value.find(m => m.id === tempId)
|
||
if (msg) {
|
||
msg.id = messageId
|
||
msg.status = MessageStatus.SENT
|
||
}
|
||
})
|
||
|
||
// 监听交换响应
|
||
signalR.on('ExchangeResponded', (result) => {
|
||
console.log('交换响应:', result)
|
||
const msg = messages.value.find(m => m.id === result.messageId)
|
||
if (msg) {
|
||
msg.status = result.accepted ? ExchangeStatus.ACCEPTED : ExchangeStatus.REJECTED
|
||
if (result.accepted && result.exchangedData) {
|
||
msg.exchangedContent = result.exchangedData
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
// 移除监听
|
||
signalR.off('ReceiveMessage')
|
||
signalR.off('MessageSent')
|
||
signalR.off('ExchangeResponded')
|
||
})
|
||
|
||
// 发送消息
|
||
const handleSendMessage = () => {
|
||
const content = inputText.value.trim()
|
||
if (!content) return
|
||
|
||
const tempId = Date.now()
|
||
const localMessage = {
|
||
id: tempId,
|
||
sessionId: sessionId.value,
|
||
senderId: myUserId.value,
|
||
receiverId: targetUserId.value,
|
||
messageType: MessageType.TEXT,
|
||
content,
|
||
status: MessageStatus.SENDING,
|
||
createTime: new Date().toISOString(),
|
||
isMine: true
|
||
}
|
||
|
||
messages.value.push(localMessage)
|
||
inputText.value = ''
|
||
scrollToBottom()
|
||
|
||
// 通过 SignalR 发送
|
||
signalR.invoke('SendMessage', {
|
||
tempId,
|
||
sessionId: sessionId.value,
|
||
receiverId: targetUserId.value,
|
||
messageType: MessageType.TEXT,
|
||
content
|
||
})
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三、实施步骤
|
||
|
||
### 3.1 后端开发(优先级 P0)
|
||
|
||
1. **创建 ChatHub**
|
||
- [ ] 实现 `ChatHub.cs`
|
||
- [ ] 配置 SignalR 中间件
|
||
- [ ] 添加 JWT 认证支持
|
||
|
||
2. **实现聊天服务**
|
||
- [ ] `ChatService.cs` - 聊天业务逻辑
|
||
- [ ] `MessageService.cs` - 消息处理
|
||
- [ ] 数据库操作(FreeSql)
|
||
|
||
3. **测试**
|
||
- [ ] 单元测试
|
||
- [ ] 集成测试
|
||
- [ ] 压力测试
|
||
|
||
### 3.2 前端开发(优先级 P0)
|
||
|
||
1. **SignalR 客户端封装**
|
||
- [ ] 创建 `utils/signalr.js`
|
||
- [ ] 实现连接管理
|
||
- [ ] 实现断线重连
|
||
- [ ] 实现心跳检测
|
||
|
||
2. **集成到聊天页面**
|
||
- [ ] 修改 `pages/chat/index.vue`
|
||
- [ ] 接收实时消息
|
||
- [ ] 发送消息通过 SignalR
|
||
- [ ] 处理交换请求
|
||
|
||
3. **测试**
|
||
- [ ] 功能测试
|
||
- [ ] 兼容性测试
|
||
- [ ] 性能测试
|
||
|
||
### 3.3 部署配置
|
||
|
||
1. **服务器配置**
|
||
- [ ] 开启 WebSocket 支持
|
||
- [ ] 配置 Nginx 反向代理
|
||
- [ ] 配置 SSL 证书
|
||
|
||
2. **监控告警**
|
||
- [ ] 连接数监控
|
||
- [ ] 消息延迟监控
|
||
- [ ] 错误日志告警
|
||
|
||
---
|
||
|
||
## 四、技术难点与解决方案
|
||
|
||
### 4.1 小程序 WebSocket 限制
|
||
|
||
**问题**: 小程序同时只能有 5 个 WebSocket 连接
|
||
|
||
**解决方案**:
|
||
- 使用单例模式管理 SignalR 连接
|
||
- 页面切换时保持连接不断开
|
||
- 只在必要时创建连接
|
||
|
||
### 4.2 断线重连
|
||
|
||
**问题**: 网络不稳定导致频繁断线
|
||
|
||
**解决方案**:
|
||
- 指数退避重连策略
|
||
- 最大重连次数限制
|
||
- 重连成功后重新加载未读消息
|
||
|
||
### 4.3 消息可靠性
|
||
|
||
**问题**: 消息可能丢失或重复
|
||
|
||
**解决方案**:
|
||
- 消息发送确认机制
|
||
- 本地消息队列
|
||
- 消息去重(基于消息ID)
|
||
|
||
### 4.4 离线消息
|
||
|
||
**问题**: 用户离线时收不到消息
|
||
|
||
**解决方案**:
|
||
- 用户上线时拉取离线消息
|
||
- 服务号推送通知
|
||
- 未读消息计数
|
||
|
||
---
|
||
|
||
## 五、性能优化
|
||
|
||
### 5.1 连接管理
|
||
|
||
- 使用 Redis 存储用户连接映射
|
||
- 支持水平扩展(多服务器)
|
||
- 连接池管理
|
||
|
||
### 5.2 消息推送
|
||
|
||
- 批量推送优化
|
||
- 消息压缩
|
||
- 分组推送
|
||
|
||
### 5.3 数据库优化
|
||
|
||
- 消息表分表(按月)
|
||
- 索引优化
|
||
- 定期归档历史消息
|
||
|
||
---
|
||
|
||
## 六、总结
|
||
|
||
### 当前状态
|
||
- ✅ 数据库设计完成
|
||
- ✅ API 接口定义完成
|
||
- ✅ 前端页面完成
|
||
- ✅ **SignalR 后端已完整实现**
|
||
- ✅ **SignalR 前端已完整实现**
|
||
- ✅ **实时消息推送已实现**
|
||
- ✅ **断线重连机制已实现**
|
||
- ✅ **心跳检测已实现**
|
||
|
||
### ✅ 已完成工作
|
||
1. ✅ **后端实现** - ChatHub 完整实现
|
||
2. ✅ **前端封装** - SignalR 客户端完整封装
|
||
3. ✅ **功能集成** - 聊天页面完整集成
|
||
4. ⏳ **测试验证** - 待进行完整测试
|
||
5. ⏳ **生产部署** - 待配置生产环境
|
||
|
||
### 下一步行动
|
||
1. **功能测试** - 参考 `miniapp/SignalR测试指南.md`
|
||
2. **性能测试** - 压力测试和性能优化
|
||
3. **生产部署** - 参考 `server/SignalR生产环境配置.md`
|
||
4. **监控告警** - 配置监控和告警系统
|
||
|
||
### 实际工作量
|
||
- 后端开发:✅ 已完成(ChatHub.cs 已存在)
|
||
- 前端开发:✅ 已完成(signalr.js 已存在)
|
||
- 功能集成:✅ 已完成(聊天页面已集成)
|
||
- **总计:功能已完整实现**
|
||
|
||
---
|
||
|
||
## 七、参考资料
|
||
|
||
- [SignalR 官方文档](https://learn.microsoft.com/zh-cn/aspnet/core/signalr/introduction)
|
||
- [uni-app WebSocket API](https://uniapp.dcloud.net.cn/api/request/websocket.html)
|
||
- [SignalR 协议规范](https://github.com/dotnet/aspnetcore/blob/main/src/SignalR/docs/specs/HubProtocol.md)
|