feat:单聊页面bug修复+后台管理端好友页面bug修复+项目进度、记忆文件更新
This commit is contained in:
@@ -16,9 +16,12 @@ alwaysApply: true
|
|||||||
|
|
||||||
- **Phase 1(基础设施与用户认证)**:✅ 全部完成(11 个 Task)
|
- **Phase 1(基础设施与用户认证)**:✅ 全部完成(11 个 Task)
|
||||||
- **Phase 2a(WebSocket 实时通讯与联系人管理)**:✅ 全部完成(13 个 Task + 后期 Bug 修复 3 项)
|
- **Phase 2a(WebSocket 实时通讯与联系人管理)**:✅ 全部完成(13 个 Task + 后期 Bug 修复 3 项)
|
||||||
- **Phase 2b(即时通讯消息系统)**:✅ 核心功能开发完成(10 个 Task),设计文档 `docs/plans/2026-03-03-phase2b-design.md`
|
- **Phase 2b(即时通讯消息系统)**:✅ 全部完成(10 个 Task + 代码审查修复 7 项 + 用户测试修复 8 项),设计文档 `docs/plans/2026-03-03-phase2b-design.md`
|
||||||
- 分支:`feature/phase2b-instant-messaging`
|
- 分支:`feature/phase2b-instant-messaging`
|
||||||
- **跨模块通信模式**:已建立接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler)
|
- 代码审查修复:ClearHistory 个人视图、Redis 负数保护、N+1 查询优化、全文索引搜索、撤回更新预览、推送补全 sender 信息
|
||||||
|
- 用户测试修复:好友申请/接受唯一约束、Redis 在线状态残留清理、WS 全局初始化、管理端字段修正、好友列表在线状态初始值、聊天页布局约束
|
||||||
|
- **Phase 2c(群聊与增强)**:待规划设计
|
||||||
|
- **跨模块通信模式**:已建立接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler,contact.OnlineChecker → ws.OnlineService)
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="user-cell">
|
<div class="user-cell">
|
||||||
<span class="user-id">#{{ row.user_id }}</span>
|
<span class="user-id">#{{ row.user_id }}</span>
|
||||||
<span class="username">{{ row.username || '--' }}</span>
|
<span class="username">{{ row.user_username || '--' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -130,7 +130,7 @@ const fetchList = async () => {
|
|||||||
const handleDelete = async (row) => {
|
const handleDelete = async (row) => {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定要删除用户 ${row.username || row.user_id} 与 ${row.friend_username || row.friend_id} 之间的好友关系吗?此操作将双向解除。`,
|
`确定要删除用户 ${row.user_username || row.user_id} 与 ${row.friend_username || row.friend_id} 之间的好友关系吗?此操作将双向解除。`,
|
||||||
'删除确认',
|
'删除确认',
|
||||||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
|
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func (d *FriendshipDAO) CreateRequest(ctx context.Context, userID, friendID int6
|
|||||||
return f, err
|
return f, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcceptRequest 接受好友申请(事务内:更新 A→B 为 accepted + 创建 B→A)
|
// AcceptRequest 接受好友申请(事务内:更新 A→B 为 accepted + 创建或更新 B→A 为 accepted)
|
||||||
func (d *FriendshipDAO) AcceptRequest(ctx context.Context, requestID, userID int64) error {
|
func (d *FriendshipDAO) AcceptRequest(ctx context.Context, requestID, userID int64) error {
|
||||||
funcName := "dao.friendship_dao.AcceptRequest"
|
funcName := "dao.friendship_dao.AcceptRequest"
|
||||||
logs.Info(ctx, funcName, "接受好友申请",
|
logs.Info(ctx, funcName, "接受好友申请",
|
||||||
@@ -65,6 +65,15 @@ func (d *FriendshipDAO) AcceptRequest(ctx context.Context, requestID, userID int
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var existing model.Friendship
|
||||||
|
err := tx.Where("user_id = ? AND friend_id = ?", userID, req.UserID).First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
return tx.Model(&existing).Updates(map[string]interface{}{
|
||||||
|
"status": constants.FriendshipStatusAccepted,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
reverse := &model.Friendship{
|
reverse := &model.Friendship{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
FriendID: req.UserID,
|
FriendID: req.UserID,
|
||||||
@@ -268,6 +277,28 @@ func (d *FriendshipDAO) HasPendingRequest(ctx context.Context, userID, friendID
|
|||||||
return count > 0, err
|
return count > 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReactivateRejectedRequest 将已拒绝的申请重新激活为待处理状态
|
||||||
|
// 返回是否找到并更新了已拒绝的记录
|
||||||
|
func (d *FriendshipDAO) ReactivateRejectedRequest(ctx context.Context, userID, friendID int64, message string) (bool, error) {
|
||||||
|
funcName := "dao.friendship_dao.ReactivateRejectedRequest"
|
||||||
|
logs.Info(ctx, funcName, "重新激活已拒绝的好友申请",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("friend_id", friendID))
|
||||||
|
|
||||||
|
result := d.db.WithContext(ctx).
|
||||||
|
Model(&model.Friendship{}).
|
||||||
|
Where("user_id = ? AND friend_id = ? AND status = ?", userID, friendID, constants.FriendshipStatusRejected).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": constants.FriendshipStatusPending,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
logs.Error(ctx, funcName, "重新激活好友申请失败", zap.Error(result.Error))
|
||||||
|
return false, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRequestByID 根据 ID 获取好友申请
|
// GetRequestByID 根据 ID 获取好友申请
|
||||||
func (d *FriendshipDAO) GetRequestByID(ctx context.Context, id int64) (*model.Friendship, error) {
|
func (d *FriendshipDAO) GetRequestByID(ctx context.Context, id int64) (*model.Friendship, error) {
|
||||||
var f model.Friendship
|
var f model.Friendship
|
||||||
|
|||||||
@@ -25,11 +25,17 @@ var (
|
|||||||
ErrUserNotFound = errors.New("用户不存在")
|
ErrUserNotFound = errors.New("用户不存在")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OnlineChecker 在线状态查询接口(避免直接依赖 ws 模块的 OnlineService)
|
||||||
|
type OnlineChecker interface {
|
||||||
|
BatchCheckOnline(ctx context.Context, userIDs []int64) map[int64]bool
|
||||||
|
}
|
||||||
|
|
||||||
// ContactService 联系人业务服务
|
// ContactService 联系人业务服务
|
||||||
type ContactService struct {
|
type ContactService struct {
|
||||||
friendshipDAO *dao.FriendshipDAO
|
friendshipDAO *dao.FriendshipDAO
|
||||||
friendGroupDAO *dao.FriendGroupDAO
|
friendGroupDAO *dao.FriendGroupDAO
|
||||||
pubsub *ws.PubSub
|
pubsub *ws.PubSub
|
||||||
|
onlineChecker OnlineChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContactService 创建 ContactService 实例
|
// NewContactService 创建 ContactService 实例
|
||||||
@@ -37,11 +43,13 @@ func NewContactService(
|
|||||||
friendshipDAO *dao.FriendshipDAO,
|
friendshipDAO *dao.FriendshipDAO,
|
||||||
friendGroupDAO *dao.FriendGroupDAO,
|
friendGroupDAO *dao.FriendGroupDAO,
|
||||||
pubsub *ws.PubSub,
|
pubsub *ws.PubSub,
|
||||||
|
onlineChecker OnlineChecker,
|
||||||
) *ContactService {
|
) *ContactService {
|
||||||
return &ContactService{
|
return &ContactService{
|
||||||
friendshipDAO: friendshipDAO,
|
friendshipDAO: friendshipDAO,
|
||||||
friendGroupDAO: friendGroupDAO,
|
friendGroupDAO: friendGroupDAO,
|
||||||
pubsub: pubsub,
|
pubsub: pubsub,
|
||||||
|
onlineChecker: onlineChecker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +88,16 @@ func (s *ContactService) SendFriendRequest(ctx context.Context, userID, targetID
|
|||||||
return ErrPendingExists
|
return ErrPendingExists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reactivated, err := s.friendshipDAO.ReactivateRejectedRequest(ctx, userID, targetID, message)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !reactivated {
|
||||||
_, err = s.friendshipDAO.CreateRequest(ctx, userID, targetID, message)
|
_, err = s.friendshipDAO.CreateRequest(ctx, userID, targetID, message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
push := ws.NewPushMessage("notify.friend.request", map[string]interface{}{
|
push := ws.NewPushMessage("notify.friend.request", map[string]interface{}{
|
||||||
"from_user_id": userID,
|
"from_user_id": userID,
|
||||||
@@ -141,7 +155,7 @@ func (s *ContactService) RejectFriendRequest(ctx context.Context, requestID, use
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFriendList 获取好友列表
|
// GetFriendList 获取好友列表(包含在线状态)
|
||||||
func (s *ContactService) GetFriendList(ctx context.Context, userID int64, groupID *int64) ([]dto.FriendInfo, error) {
|
func (s *ContactService) GetFriendList(ctx context.Context, userID int64, groupID *int64) ([]dto.FriendInfo, error) {
|
||||||
funcName := "service.contact_service.GetFriendList"
|
funcName := "service.contact_service.GetFriendList"
|
||||||
logs.Debug(ctx, funcName, "获取好友列表", zap.Int64("user_id", userID))
|
logs.Debug(ctx, funcName, "获取好友列表", zap.Int64("user_id", userID))
|
||||||
@@ -151,6 +165,15 @@ func (s *ContactService) GetFriendList(ctx context.Context, userID int64, groupI
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
friendIDs := make([]int64, 0, len(friends))
|
||||||
|
for _, f := range friends {
|
||||||
|
friendIDs = append(friendIDs, f.UserID)
|
||||||
|
}
|
||||||
|
onlineMap := make(map[int64]bool)
|
||||||
|
if s.onlineChecker != nil && len(friendIDs) > 0 {
|
||||||
|
onlineMap = s.onlineChecker.BatchCheckOnline(ctx, friendIDs)
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]dto.FriendInfo, 0, len(friends))
|
result := make([]dto.FriendInfo, 0, len(friends))
|
||||||
for _, f := range friends {
|
for _, f := range friends {
|
||||||
result = append(result, dto.FriendInfo{
|
result = append(result, dto.FriendInfo{
|
||||||
@@ -161,6 +184,7 @@ func (s *ContactService) GetFriendList(ctx context.Context, userID int64, groupI
|
|||||||
Avatar: f.Avatar,
|
Avatar: f.Avatar,
|
||||||
Remark: f.Remark,
|
Remark: f.Remark,
|
||||||
GroupID: f.GroupID,
|
GroupID: f.GroupID,
|
||||||
|
IsOnline: onlineMap[f.UserID],
|
||||||
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"),
|
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,20 +77,22 @@ func (d *ConversationDAO) CreateWithMembers(ctx context.Context, conv *model.Con
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUserConversations 获取用户的会话列表(排除已软删除的)
|
// GetUserConversations 获取用户的会话列表(排除已软删除的)
|
||||||
// 返回会话基本信息和该用户的成员视图(未读数、置顶等)
|
// 返回会话基本信息、该用户的成员视图(未读数、置顶等)和单聊对方用户 ID
|
||||||
func (d *ConversationDAO) GetUserConversations(ctx context.Context, userID int64) ([]ConversationWithMember, error) {
|
func (d *ConversationDAO) GetUserConversations(ctx context.Context, userID int64) ([]ConversationWithMember, error) {
|
||||||
funcName := "dao.conversation_dao.GetUserConversations"
|
funcName := "dao.conversation_dao.GetUserConversations"
|
||||||
logs.Debug(ctx, funcName, "查询用户会话列表", zap.Int64("user_id", userID))
|
logs.Debug(ctx, funcName, "查询用户会话列表", zap.Int64("user_id", userID))
|
||||||
|
|
||||||
var results []ConversationWithMember
|
var results []ConversationWithMember
|
||||||
err := d.db.WithContext(ctx).
|
err := d.db.WithContext(ctx).
|
||||||
Raw(`SELECT c.id, c.type, c.last_msg_content, c.last_msg_time, c.last_msg_sender_id,
|
Raw(`SELECT c.id, c.type, c.last_message_id, c.last_msg_content, c.last_msg_time, c.last_msg_sender_id,
|
||||||
cm.is_pinned, cm.unread_count
|
cm.is_pinned, cm.unread_count, cm.clear_before_msg_id,
|
||||||
|
peer.user_id AS peer_user_id
|
||||||
FROM im_conversations c
|
FROM im_conversations c
|
||||||
JOIN im_conversation_members cm ON cm.conversation_id = c.id
|
JOIN im_conversation_members cm ON cm.conversation_id = c.id AND cm.user_id = ?
|
||||||
WHERE cm.user_id = ? AND cm.is_deleted = false
|
LEFT JOIN im_conversation_members peer ON peer.conversation_id = c.id AND peer.user_id != ?
|
||||||
|
WHERE cm.is_deleted = false
|
||||||
ORDER BY cm.is_pinned DESC, c.last_msg_time DESC NULLS LAST`,
|
ORDER BY cm.is_pinned DESC, c.last_msg_time DESC NULLS LAST`,
|
||||||
userID).
|
userID, userID).
|
||||||
Scan(&results).Error
|
Scan(&results).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,15 +101,18 @@ func (d *ConversationDAO) GetUserConversations(ctx context.Context, userID int64
|
|||||||
return results, err
|
return results, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConversationWithMember 会话列表查询结果(JOIN 成员表)
|
// ConversationWithMember 会话列表查询结果(JOIN 成员表 + LEFT JOIN 对方成员)
|
||||||
type ConversationWithMember struct {
|
type ConversationWithMember struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Type int `json:"type"`
|
Type int `json:"type"`
|
||||||
|
LastMessageID *int64 `json:"last_message_id"`
|
||||||
LastMsgContent string `json:"last_msg_content"`
|
LastMsgContent string `json:"last_msg_content"`
|
||||||
LastMsgTime *time.Time `json:"last_msg_time"`
|
LastMsgTime *time.Time `json:"last_msg_time"`
|
||||||
LastMsgSenderID *int64 `json:"last_msg_sender_id"`
|
LastMsgSenderID *int64 `json:"last_msg_sender_id"`
|
||||||
IsPinned bool `json:"is_pinned"`
|
IsPinned bool `json:"is_pinned"`
|
||||||
UnreadCount int `json:"unread_count"`
|
UnreadCount int `json:"unread_count"`
|
||||||
|
ClearBeforeMsgID int64 `json:"clear_before_msg_id"`
|
||||||
|
PeerUserID int64 `json:"peer_user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMember 获取指定会话中指定用户的成员记录
|
// GetMember 获取指定会话中指定用户的成员记录
|
||||||
@@ -244,6 +249,22 @@ func (d *ConversationDAO) GetUnreadConversations(ctx context.Context, userID int
|
|||||||
return results, err
|
return results, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateClearBefore 更新用户的清空记录截止消息 ID(个人视图操作,不影响对方)
|
||||||
|
func (d *ConversationDAO) UpdateClearBefore(ctx context.Context, conversationID, userID, lastMsgID int64) error {
|
||||||
|
funcName := "dao.conversation_dao.UpdateClearBefore"
|
||||||
|
logs.Info(ctx, funcName, "更新清空记录截止 ID",
|
||||||
|
zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID),
|
||||||
|
zap.Int64("last_msg_id", lastMsgID))
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).
|
||||||
|
Model(&model.ConversationMember{}).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"clear_before_msg_id": lastMsgID,
|
||||||
|
"unread_count": 0,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// GetByID 根据 ID 获取会话
|
// GetByID 根据 ID 获取会话
|
||||||
func (d *ConversationDAO) GetByID(ctx context.Context, id int64) (*model.Conversation, error) {
|
func (d *ConversationDAO) GetByID(ctx context.Context, id int64) (*model.Conversation, error) {
|
||||||
var conv model.Conversation
|
var conv model.Conversation
|
||||||
|
|||||||
@@ -45,12 +45,14 @@ func (d *MessageDAO) GetByID(ctx context.Context, id int64) (*model.Message, err
|
|||||||
|
|
||||||
// GetByConversation 获取会话的历史消息(游标分页,按 ID 降序)
|
// GetByConversation 获取会话的历史消息(游标分页,按 ID 降序)
|
||||||
// beforeID > 0 时作为游标,查询 ID 小于 beforeID 的消息
|
// beforeID > 0 时作为游标,查询 ID 小于 beforeID 的消息
|
||||||
// beforeID = 0 时查询最新的 limit 条消息
|
// clearBeforeMsgID > 0 时过滤掉用户已清空的消息(个人视图)
|
||||||
func (d *MessageDAO) GetByConversation(ctx context.Context, conversationID int64, beforeID int64, limit int) ([]model.Message, error) {
|
func (d *MessageDAO) GetByConversation(ctx context.Context, conversationID int64, beforeID int64, clearBeforeMsgID int64, limit int) ([]model.Message, error) {
|
||||||
funcName := "dao.message_dao.GetByConversation"
|
funcName := "dao.message_dao.GetByConversation"
|
||||||
logs.Debug(ctx, funcName, "查询历史消息",
|
logs.Debug(ctx, funcName, "查询历史消息",
|
||||||
zap.Int64("conversation_id", conversationID),
|
zap.Int64("conversation_id", conversationID),
|
||||||
zap.Int64("before_id", beforeID), zap.Int("limit", limit))
|
zap.Int64("before_id", beforeID),
|
||||||
|
zap.Int64("clear_before_msg_id", clearBeforeMsgID),
|
||||||
|
zap.Int("limit", limit))
|
||||||
|
|
||||||
query := d.db.WithContext(ctx).
|
query := d.db.WithContext(ctx).
|
||||||
Where("conversation_id = ? AND status != ?", conversationID, constants.MessageStatusDeleted)
|
Where("conversation_id = ? AND status != ?", conversationID, constants.MessageStatusDeleted)
|
||||||
@@ -58,6 +60,9 @@ func (d *MessageDAO) GetByConversation(ctx context.Context, conversationID int64
|
|||||||
if beforeID > 0 {
|
if beforeID > 0 {
|
||||||
query = query.Where("id < ?", beforeID)
|
query = query.Where("id < ?", beforeID)
|
||||||
}
|
}
|
||||||
|
if clearBeforeMsgID > 0 {
|
||||||
|
query = query.Where("id > ?", clearBeforeMsgID)
|
||||||
|
}
|
||||||
|
|
||||||
var messages []model.Message
|
var messages []model.Message
|
||||||
err := query.Order("id DESC").Limit(limit).Find(&messages).Error
|
err := query.Order("id DESC").Limit(limit).Find(&messages).Error
|
||||||
@@ -79,18 +84,6 @@ func (d *MessageDAO) UpdateStatus(ctx context.Context, id int64, status int) err
|
|||||||
Update("status", status).Error
|
Update("status", status).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteByConversation 软删除会话中所有消息(标记 status=3)
|
|
||||||
func (d *MessageDAO) DeleteByConversation(ctx context.Context, conversationID int64) error {
|
|
||||||
funcName := "dao.message_dao.DeleteByConversation"
|
|
||||||
logs.Info(ctx, funcName, "清空会话消息",
|
|
||||||
zap.Int64("conversation_id", conversationID))
|
|
||||||
|
|
||||||
return d.db.WithContext(ctx).
|
|
||||||
Model(&model.Message{}).
|
|
||||||
Where("conversation_id = ? AND status = ?", conversationID, constants.MessageStatusNormal).
|
|
||||||
Update("status", constants.MessageStatusDeleted).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchMessages 全局消息搜索(按关键词匹配,仅搜索用户所在会话的消息)
|
// SearchMessages 全局消息搜索(按关键词匹配,仅搜索用户所在会话的消息)
|
||||||
// 返回匹配的消息列表(已 JOIN 成员表确保权限)
|
// 返回匹配的消息列表(已 JOIN 成员表确保权限)
|
||||||
func (d *MessageDAO) SearchMessages(ctx context.Context, userID int64, keyword string, limit int) ([]MessageSearchResult, error) {
|
func (d *MessageDAO) SearchMessages(ctx context.Context, userID int64, keyword string, limit int) ([]MessageSearchResult, error) {
|
||||||
@@ -104,10 +97,11 @@ func (d *MessageDAO) SearchMessages(ctx context.Context, userID int64, keyword s
|
|||||||
FROM im_messages m
|
FROM im_messages m
|
||||||
JOIN im_conversation_members cm ON cm.conversation_id = m.conversation_id
|
JOIN im_conversation_members cm ON cm.conversation_id = m.conversation_id
|
||||||
WHERE cm.user_id = ? AND cm.is_deleted = false
|
WHERE cm.user_id = ? AND cm.is_deleted = false
|
||||||
AND m.status = ? AND m.content LIKE ?
|
AND m.status = ?
|
||||||
|
AND to_tsvector('simple', m.content) @@ plainto_tsquery('simple', ?)
|
||||||
ORDER BY m.created_at DESC
|
ORDER BY m.created_at DESC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
userID, constants.MessageStatusNormal, "%"+keyword+"%", limit).
|
userID, constants.MessageStatusNormal, keyword, limit).
|
||||||
Scan(&results).Error
|
Scan(&results).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ func (h *EventHandler) handleMarkRead(client *ws.Client, msg *ws.Message) {
|
|||||||
h.sendACK(client, msg, 0, "ok", nil)
|
h.sendACK(client, msg, 0, "ok", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTyping 处理正在输入事件(转发给对方)
|
// handleTyping 处理正在输入事件(通过 PubSub 转发给对方,支持跨实例)
|
||||||
func (h *EventHandler) handleTyping(client *ws.Client, msg *ws.Message) {
|
func (h *EventHandler) handleTyping(client *ws.Client, msg *ws.Message) {
|
||||||
funcName := "handler.event_handler.handleTyping"
|
funcName := "handler.event_handler.handleTyping"
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -123,23 +123,7 @@ func (h *EventHandler) handleTyping(client *ws.Client, msg *ws.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
peerID, err := h.imService.GetPeerUserID(ctx, req.ConversationID, client.UserID)
|
h.imService.PushTypingNotification(ctx, req.ConversationID, client.UserID)
|
||||||
if err != nil {
|
|
||||||
logs.Warn(ctx, funcName, "查询对方用户 ID 失败",
|
|
||||||
zap.Int64("conversation_id", req.ConversationID), zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
push := ws.NewPushMessage("im.typing", map[string]interface{}{
|
|
||||||
"conversation_id": req.ConversationID,
|
|
||||||
"user_id": client.UserID,
|
|
||||||
})
|
|
||||||
data, err := ws.MarshalPush(push)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.hub.SendToUser(peerID, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendACK 发送 ACK 响应给客户端
|
// sendACK 发送 ACK 响应给客户端
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type ConversationMember struct {
|
|||||||
IsDeleted bool `json:"is_deleted" gorm:"default:false"` // 是否删除该会话(软删除,不影响对方)
|
IsDeleted bool `json:"is_deleted" gorm:"default:false"` // 是否删除该会话(软删除,不影响对方)
|
||||||
UnreadCount int `json:"unread_count" gorm:"default:0"` // 该成员在此会话中的未读消息数
|
UnreadCount int `json:"unread_count" gorm:"default:0"` // 该成员在此会话中的未读消息数
|
||||||
LastReadMsgID int64 `json:"last_read_msg_id" gorm:"default:0"` // 该成员最后已读消息 ID
|
LastReadMsgID int64 `json:"last_read_msg_id" gorm:"default:0"` // 该成员最后已读消息 ID
|
||||||
|
ClearBeforeMsgID int64 `json:"clear_before_msg_id" gorm:"default:0"` // 清空记录时的消息截止 ID(个人视图,不影响对方)
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 加入会话时间
|
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 加入会话时间
|
||||||
UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"` // 最后更新时间
|
UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"` // 最后更新时间
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.Se
|
|||||||
|
|
||||||
s.incrementTotalUnread(ctx, peerID)
|
s.incrementTotalUnread(ctx, peerID)
|
||||||
|
|
||||||
s.pushToUser(ctx, peerID, "im.message.new", map[string]interface{}{
|
pushData := map[string]interface{}{
|
||||||
"id": msg.ID,
|
"id": msg.ID,
|
||||||
"conversation_id": convID,
|
"conversation_id": convID,
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
@@ -168,12 +168,18 @@ func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.Se
|
|||||||
"content": msg.Content,
|
"content": msg.Content,
|
||||||
"client_msg_id": msg.ClientMsgID,
|
"client_msg_id": msg.ClientMsgID,
|
||||||
"created_at": msg.CreatedAt.Format("2006-01-02 15:04:05"),
|
"created_at": msg.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
})
|
}
|
||||||
|
if senderUsers, sErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID}); sErr == nil && len(senderUsers) > 0 {
|
||||||
|
pushData["sender_name"] = senderUsers[0].Nickname
|
||||||
|
pushData["sender_avatar"] = senderUsers[0].Avatar
|
||||||
|
}
|
||||||
|
s.pushToUser(ctx, peerID, "im.message.new", pushData)
|
||||||
|
|
||||||
return s.toMessageDTO(msg), nil
|
return s.toMessageDTO(msg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecallMessage 撤回消息(2分钟内)
|
// RecallMessage 撤回消息(2分钟内)
|
||||||
|
// 撤回成功后,若被撤回消息是会话最后一条,则同步更新会话预览文本
|
||||||
func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID int64) error {
|
func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID int64) error {
|
||||||
funcName := "service.im_service.RecallMessage"
|
funcName := "service.im_service.RecallMessage"
|
||||||
logs.Info(ctx, funcName, "撤回消息",
|
logs.Info(ctx, funcName, "撤回消息",
|
||||||
@@ -195,6 +201,20 @@ func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conv, err := s.convDAO.GetByID(ctx, msg.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "获取会话信息失败", zap.Error(err))
|
||||||
|
} else if conv.LastMessageID != nil && *conv.LastMessageID == msg.ID {
|
||||||
|
senderInfo, infoErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID})
|
||||||
|
recallText := "撤回了一条消息"
|
||||||
|
if infoErr == nil && len(senderInfo) > 0 {
|
||||||
|
recallText = senderInfo[0].Nickname + " 撤回了一条消息"
|
||||||
|
}
|
||||||
|
if updateErr := s.convDAO.UpdateLastMessage(ctx, msg.ConversationID, msg.ID, recallText, senderID, msg.CreatedAt); updateErr != nil {
|
||||||
|
logs.Error(ctx, funcName, "更新会话预览失败", zap.Error(updateErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
memberIDs, err := s.convDAO.GetConversationMemberIDs(ctx, msg.ConversationID)
|
memberIDs, err := s.convDAO.GetConversationMemberIDs(ctx, msg.ConversationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logs.Error(ctx, funcName, "获取会话成员失败", zap.Error(err))
|
logs.Error(ctx, funcName, "获取会话成员失败", zap.Error(err))
|
||||||
@@ -207,6 +227,7 @@ func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID
|
|||||||
s.pushToUser(ctx, uid, "im.message.recalled", map[string]interface{}{
|
s.pushToUser(ctx, uid, "im.message.recalled", map[string]interface{}{
|
||||||
"message_id": messageID,
|
"message_id": messageID,
|
||||||
"conversation_id": msg.ConversationID,
|
"conversation_id": msg.ConversationID,
|
||||||
|
"sender_id": senderID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,15 +245,10 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto
|
|||||||
}
|
}
|
||||||
|
|
||||||
peerIDs := make([]int64, 0, len(convs))
|
peerIDs := make([]int64, 0, len(convs))
|
||||||
convIDToPeerID := make(map[int64]int64, len(convs))
|
|
||||||
for _, c := range convs {
|
for _, c := range convs {
|
||||||
peerID, err := s.convDAO.GetPeerUserID(ctx, c.ID, userID)
|
if c.PeerUserID > 0 {
|
||||||
if err != nil {
|
peerIDs = append(peerIDs, c.PeerUserID)
|
||||||
logs.Error(ctx, funcName, "查询对方用户 ID 失败", zap.Int64("conv_id", c.ID), zap.Error(err))
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
peerIDs = append(peerIDs, peerID)
|
|
||||||
convIDToPeerID[c.ID] = peerID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
userMap := make(map[int64]*userBrief)
|
userMap := make(map[int64]*userBrief)
|
||||||
@@ -250,7 +266,7 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto
|
|||||||
|
|
||||||
list := make([]dto.ConversationDTO, 0, len(convs))
|
list := make([]dto.ConversationDTO, 0, len(convs))
|
||||||
for _, c := range convs {
|
for _, c := range convs {
|
||||||
peerID := convIDToPeerID[c.ID]
|
peerID := c.PeerUserID
|
||||||
item := dto.ConversationDTO{
|
item := dto.ConversationDTO{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
Type: c.Type,
|
Type: c.Type,
|
||||||
@@ -260,6 +276,10 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto
|
|||||||
IsPinned: c.IsPinned,
|
IsPinned: c.IsPinned,
|
||||||
UnreadCount: c.UnreadCount,
|
UnreadCount: c.UnreadCount,
|
||||||
}
|
}
|
||||||
|
if c.ClearBeforeMsgID > 0 && c.LastMessageID != nil && *c.LastMessageID <= c.ClearBeforeMsgID {
|
||||||
|
item.LastMsgContent = ""
|
||||||
|
item.LastMsgSenderID = nil
|
||||||
|
}
|
||||||
if c.LastMsgTime != nil {
|
if c.LastMsgTime != nil {
|
||||||
item.LastMsgTime = c.LastMsgTime.Format("2006-01-02 15:04:05")
|
item.LastMsgTime = c.LastMsgTime.Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
@@ -292,7 +312,7 @@ func (s *IMService) GetHistoryMessages(ctx context.Context, userID int64, req *d
|
|||||||
limit = maxPageSize
|
limit = maxPageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
messages, err := s.msgDAO.GetByConversation(ctx, req.ConversationID, req.BeforeID, limit+1)
|
messages, err := s.msgDAO.GetByConversation(ctx, req.ConversationID, req.BeforeID, member.ClearBeforeMsgID, limit+1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -370,7 +390,8 @@ func (s *IMService) DeleteConversation(ctx context.Context, userID int64, conver
|
|||||||
return s.convDAO.SoftDeleteMember(ctx, conversationID, userID)
|
return s.convDAO.SoftDeleteMember(ctx, conversationID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearHistory 清空聊天记录(标记消息为已删除)
|
// ClearHistory 清空聊天记录(个人视图操作,仅影响当前用户,不影响对方)
|
||||||
|
// 通过记录清空截止消息 ID 实现,而非真正删除消息
|
||||||
func (s *IMService) ClearHistory(ctx context.Context, userID int64, conversationID int64) error {
|
func (s *IMService) ClearHistory(ctx context.Context, userID int64, conversationID int64) error {
|
||||||
funcName := "service.im_service.ClearHistory"
|
funcName := "service.im_service.ClearHistory"
|
||||||
logs.Info(ctx, funcName, "清空聊天记录",
|
logs.Info(ctx, funcName, "清空聊天记录",
|
||||||
@@ -380,7 +401,23 @@ func (s *IMService) ClearHistory(ctx context.Context, userID int64, conversation
|
|||||||
if err != nil || member == nil {
|
if err != nil || member == nil {
|
||||||
return ErrNotMember
|
return ErrNotMember
|
||||||
}
|
}
|
||||||
return s.msgDAO.DeleteByConversation(ctx, conversationID)
|
|
||||||
|
latestMsgID, err := s.msgDAO.GetLatestMessageID(ctx, conversationID)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error(ctx, funcName, "获取最新消息 ID 失败", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.convDAO.UpdateClearBefore(ctx, conversationID, userID, latestMsgID); err != nil {
|
||||||
|
logs.Error(ctx, funcName, "更新清空截止 ID 失败", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if member.UnreadCount > 0 {
|
||||||
|
s.decrementTotalUnread(ctx, userID, member.UnreadCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchMessages 全局消息搜索
|
// SearchMessages 全局消息搜索
|
||||||
@@ -459,6 +496,20 @@ func (s *IMService) GetPeerUserID(ctx context.Context, conversationID, userID in
|
|||||||
return s.convDAO.GetPeerUserID(ctx, conversationID, userID)
|
return s.convDAO.GetPeerUserID(ctx, conversationID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PushTypingNotification 向对方推送正在输入通知(通过 PubSub 支持跨实例)
|
||||||
|
func (s *IMService) PushTypingNotification(ctx context.Context, conversationID, senderID int64) {
|
||||||
|
peerID, err := s.convDAO.GetPeerUserID(ctx, conversationID, senderID)
|
||||||
|
if err != nil {
|
||||||
|
logs.Warn(ctx, "service.im_service.PushTypingNotification", "查询对方用户 ID 失败",
|
||||||
|
zap.Int64("conversation_id", conversationID), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pushToUser(ctx, peerID, "im.typing", map[string]interface{}{
|
||||||
|
"conversation_id": conversationID,
|
||||||
|
"user_id": senderID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ====== 内部辅助方法 ======
|
// ====== 内部辅助方法 ======
|
||||||
|
|
||||||
// getOrCreatePrivateConversation 查找或创建单聊会话
|
// getOrCreatePrivateConversation 查找或创建单聊会话
|
||||||
@@ -510,11 +561,19 @@ func (s *IMService) incrementTotalUnread(ctx context.Context, userID int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// decrementTotalUnread Redis 全局未读 -N(下限为 0)
|
// decrementTotalUnread Redis 全局未读 -N(使用 Lua 脚本保证原子性,下限为 0)
|
||||||
func (s *IMService) decrementTotalUnread(ctx context.Context, userID int64, count int) {
|
func (s *IMService) decrementTotalUnread(ctx context.Context, userID int64, count int) {
|
||||||
key := fmt.Sprintf("%s%d", unreadKeyPrefix, userID)
|
key := fmt.Sprintf("%s%d", unreadKeyPrefix, userID)
|
||||||
if err := s.rdb.DecrBy(ctx, key, int64(count)).Err(); err != nil {
|
script := redis.NewScript(`
|
||||||
logs.Error(ctx, "service.im_service.decrementTotalUnread", "Redis DECRBY 失败",
|
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||||
|
local decr = tonumber(ARGV[1])
|
||||||
|
local newVal = current - decr
|
||||||
|
if newVal < 0 then newVal = 0 end
|
||||||
|
redis.call('SET', KEYS[1], newVal)
|
||||||
|
return newVal
|
||||||
|
`)
|
||||||
|
if err := script.Run(ctx, s.rdb, []string{key}, count).Err(); err != nil {
|
||||||
|
logs.Error(ctx, "service.im_service.decrementTotalUnread", "Redis Lua 脚本执行失败",
|
||||||
zap.Int64("user_id", userID), zap.Error(err))
|
zap.Int64("user_id", userID), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"github.com/echochat/backend/app/admin"
|
"github.com/echochat/backend/app/admin"
|
||||||
"github.com/echochat/backend/app/auth"
|
"github.com/echochat/backend/app/auth"
|
||||||
"github.com/echochat/backend/app/contact"
|
"github.com/echochat/backend/app/contact"
|
||||||
|
authService "github.com/echochat/backend/app/auth/service"
|
||||||
contactDAO "github.com/echochat/backend/app/contact/dao"
|
contactDAO "github.com/echochat/backend/app/contact/dao"
|
||||||
|
contactService "github.com/echochat/backend/app/contact/service"
|
||||||
imApp "github.com/echochat/backend/app/im"
|
imApp "github.com/echochat/backend/app/im"
|
||||||
imService "github.com/echochat/backend/app/im/service"
|
imService "github.com/echochat/backend/app/im/service"
|
||||||
wsApp "github.com/echochat/backend/app/ws"
|
wsApp "github.com/echochat/backend/app/ws"
|
||||||
@@ -24,8 +26,11 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
wsApp.WSSet,
|
wsApp.WSSet,
|
||||||
contact.ContactSet,
|
contact.ContactSet,
|
||||||
imApp.IMSet,
|
imApp.IMSet,
|
||||||
|
wire.Bind(new(wsApp.FriendIDsGetter), new(*contactDAO.FriendshipDAO)),
|
||||||
|
wire.Bind(new(wsApp.TokenValidator), new(*authService.AuthService)),
|
||||||
wire.Bind(new(imService.FriendChecker), new(*contactDAO.FriendshipDAO)),
|
wire.Bind(new(imService.FriendChecker), new(*contactDAO.FriendshipDAO)),
|
||||||
wire.Bind(new(imService.UserInfoGetter), new(*contactDAO.FriendshipDAO)),
|
wire.Bind(new(imService.UserInfoGetter), new(*contactDAO.FriendshipDAO)),
|
||||||
|
wire.Bind(new(contactService.OnlineChecker), new(*wsApp.OnlineService)),
|
||||||
)
|
)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
contactManageController := controller2.NewContactManageController(contactManageService)
|
contactManageController := controller2.NewContactManageController(contactManageService)
|
||||||
handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService, authService)
|
handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService, authService)
|
||||||
friendGroupDAO := dao3.NewFriendGroupDAO(gormDB)
|
friendGroupDAO := dao3.NewFriendGroupDAO(gormDB)
|
||||||
contactService := service3.NewContactService(friendshipDAO, friendGroupDAO, pubSub)
|
contactService := service3.NewContactService(friendshipDAO, friendGroupDAO, pubSub, onlineService)
|
||||||
contactController := controller3.NewContactController(contactService)
|
contactController := controller3.NewContactController(contactService)
|
||||||
|
|
||||||
// IM 模块初始化
|
// IM 模块初始化
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ func (h *Handler) Upgrade(c *gin.Context) {
|
|||||||
|
|
||||||
client := ws.NewClient(h.hub, conn, claims.UserID)
|
client := ws.NewClient(h.hub, conn, claims.UserID)
|
||||||
client.SetOnDisconnect(func(userID int64) {
|
client.SetOnDisconnect(func(userID int64) {
|
||||||
if client.IsClosedByHub() {
|
if client.IsClosedByHub() && h.hub.IsOnline(userID) {
|
||||||
logs.Info(nil, "ws.handler.onDisconnect", "连接被 Hub 踢出(重复连接),跳过下线清理",
|
logs.Info(nil, "ws.handler.onDisconnect", "连接被新连接替换,跳过下线清理",
|
||||||
zap.Int64("user_id", userID))
|
zap.Int64("user_id", userID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,13 +39,44 @@ type OnlineService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewOnlineService 创建 OnlineService 实例
|
// NewOnlineService 创建 OnlineService 实例
|
||||||
|
// 启动时清理 Redis 中残留的在线状态数据,防止服务重启后出现"幽灵在线"
|
||||||
func NewOnlineService(rdb *redis.Client, hub *ws.Hub, pubsub *ws.PubSub, friendGetter FriendIDsGetter) *OnlineService {
|
func NewOnlineService(rdb *redis.Client, hub *ws.Hub, pubsub *ws.PubSub, friendGetter FriendIDsGetter) *OnlineService {
|
||||||
return &OnlineService{
|
svc := &OnlineService{
|
||||||
rdb: rdb,
|
rdb: rdb,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
pubsub: pubsub,
|
pubsub: pubsub,
|
||||||
friendGetter: friendGetter,
|
friendGetter: friendGetter,
|
||||||
}
|
}
|
||||||
|
svc.cleanStaleOnlineData()
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanStaleOnlineData 服务启动时清理上次残留的在线状态
|
||||||
|
// 单实例部署下,启动时不可能有合法的在线用户,直接清空即可
|
||||||
|
func (s *OnlineService) cleanStaleOnlineData() {
|
||||||
|
ctx := context.Background()
|
||||||
|
funcName := "ws.online_service.cleanStaleOnlineData"
|
||||||
|
|
||||||
|
members, err := s.rdb.SMembers(ctx, onlineSetKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
logs.Warn(ctx, funcName, "读取旧在线集合失败", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(members) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pipe := s.rdb.Pipeline()
|
||||||
|
pipe.Del(ctx, onlineSetKey)
|
||||||
|
for _, m := range members {
|
||||||
|
pipe.Del(ctx, statusKeyPrefix+m)
|
||||||
|
}
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
logs.Error(ctx, funcName, "清理旧在线数据失败", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
logs.Info(ctx, funcName, "已清理残留在线状态",
|
||||||
|
zap.Int("stale_count", len(members)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserOnline 用户上线:写入 Redis + 通知在线好友
|
// UserOnline 用户上线:写入 Redis + 通知在线好友
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ CREATE TABLE im_conversation_members (
|
|||||||
is_deleted BOOLEAN DEFAULT FALSE,
|
is_deleted BOOLEAN DEFAULT FALSE,
|
||||||
unread_count INT DEFAULT 0,
|
unread_count INT DEFAULT 0,
|
||||||
last_read_msg_id BIGINT DEFAULT 0,
|
last_read_msg_id BIGINT DEFAULT 0,
|
||||||
|
clear_before_msg_id BIGINT DEFAULT 0,
|
||||||
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE (conversation_id, user_id)
|
UNIQUE (conversation_id, user_id)
|
||||||
@@ -200,6 +201,7 @@ COMMENT ON COLUMN im_conversation_members.is_pinned IS '是否置顶该
|
|||||||
COMMENT ON COLUMN im_conversation_members.is_deleted IS '是否删除该会话(软删除,不影响对方视图)';
|
COMMENT ON COLUMN im_conversation_members.is_deleted IS '是否删除该会话(软删除,不影响对方视图)';
|
||||||
COMMENT ON COLUMN im_conversation_members.unread_count IS '该成员在此会话中的未读消息数';
|
COMMENT ON COLUMN im_conversation_members.unread_count IS '该成员在此会话中的未读消息数';
|
||||||
COMMENT ON COLUMN im_conversation_members.last_read_msg_id IS '该成员最后已读消息 ID';
|
COMMENT ON COLUMN im_conversation_members.last_read_msg_id IS '该成员最后已读消息 ID';
|
||||||
|
COMMENT ON COLUMN im_conversation_members.clear_before_msg_id IS '清空聊天记录时的消息截止 ID(个人视图,不影响对方)';
|
||||||
COMMENT ON COLUMN im_conversation_members.created_at IS '加入会话时间';
|
COMMENT ON COLUMN im_conversation_members.created_at IS '加入会话时间';
|
||||||
COMMENT ON COLUMN im_conversation_members.updated_at IS '最后更新时间';
|
COMMENT ON COLUMN im_conversation_members.updated_at IS '最后更新时间';
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
| [frontend/auth.md](frontend/auth.md) | 用户认证 | ✅ Phase 1 | 注册、登录、Token 刷新、个人信息管理 |
|
| [frontend/auth.md](frontend/auth.md) | 用户认证 | ✅ Phase 1 | 注册、登录、Token 刷新、个人信息管理 |
|
||||||
| [frontend/contact.md](frontend/contact.md) | 联系人 | ✅ Phase 2a | 17 个 API:好友申请/管理、好友分组、黑名单、搜索/推荐、在线状态 |
|
| [frontend/contact.md](frontend/contact.md) | 联系人 | ✅ Phase 2a | 17 个 API:好友申请/管理、好友分组、黑名单、搜索/推荐、在线状态 |
|
||||||
| [frontend/websocket.md](frontend/websocket.md) | WebSocket | ✅ Phase 2a | 前端 WebSocket 连接管理、事件协议、心跳、重连 |
|
| [frontend/websocket.md](frontend/websocket.md) | WebSocket | ✅ Phase 2a | 前端 WebSocket 连接管理、事件协议、心跳、重连 |
|
||||||
| [frontend/im.md](frontend/im.md) | 即时通讯 | 📋 Phase 2b | 会话列表、消息历史、群聊创建与管理 |
|
| [frontend/im.md](frontend/im.md) | 即时通讯 | ✅ Phase 2b | 7 个 API:会话列表/置顶/删除/清空、历史消息、全局搜索、未读数 |
|
||||||
| [frontend/meeting.md](frontend/meeting.md) | 会议 | 📋 后续 | 即时会议、预约会议、加入/离开、会议列表 |
|
| [frontend/meeting.md](frontend/meeting.md) | 会议 | 📋 后续 | 即时会议、预约会议、加入/离开、会议列表 |
|
||||||
| [frontend/notify.md](frontend/notify.md) | 通知 | 📋 后续 | 通知列表、标记已读 |
|
| [frontend/notify.md](frontend/notify.md) | 通知 | 📋 后续 | 通知列表、标记已读 |
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
| 文档 | 状态 | 说明 |
|
| 文档 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| [websocket.md](websocket.md) | ✅ Phase 2a | WebSocket 实时事件协议(联系人通知、在线状态、心跳) |
|
| [websocket.md](websocket.md) | ✅ Phase 2a/2b | WebSocket 实时事件协议(IM 消息收发/撤回/已读/输入、联系人通知、在线状态、心跳) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ docs/api/
|
|||||||
│ ├── auth.md # 用户认证 ✅ Phase 1
|
│ ├── auth.md # 用户认证 ✅ Phase 1
|
||||||
│ ├── contact.md # 联系人管理(17 个 API) ✅ Phase 2a
|
│ ├── contact.md # 联系人管理(17 个 API) ✅ Phase 2a
|
||||||
│ ├── websocket.md # WebSocket 事件协议 ✅ Phase 2a
|
│ ├── websocket.md # WebSocket 事件协议 ✅ Phase 2a
|
||||||
│ ├── im.md # 即时通讯 📋 Phase 2b
|
│ ├── im.md # 即时通讯(7 个 API) ✅ Phase 2b
|
||||||
│ ├── meeting.md # 会议 📋 后续
|
│ ├── meeting.md # 会议 📋 后续
|
||||||
│ └── notify.md # 通知 📋 后续
|
│ └── notify.md # 通知 📋 后续
|
||||||
├── admin/ # 后台管理端 API
|
├── admin/ # 后台管理端 API
|
||||||
@@ -204,5 +204,5 @@ docs/api/
|
|||||||
│ ├── contact.md # 好友关系管理 ✅ Phase 2a
|
│ ├── contact.md # 好友关系管理 ✅ Phase 2a
|
||||||
│ ├── meeting.md # 会议管理 📋 后续
|
│ ├── meeting.md # 会议管理 📋 后续
|
||||||
│ └── system.md # 系统管理 📋 待定
|
│ └── system.md # 系统管理 📋 待定
|
||||||
└── websocket.md # WebSocket 全量事件协议 ✅ Phase 2a
|
└── websocket.md # WebSocket 全量事件协议 ✅ Phase 2a/2b
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# 即时通讯模块 API (IM)
|
# 即时通讯模块 REST API (IM)
|
||||||
|
|
||||||
> 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md)
|
> 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md)
|
||||||
> 消息的实时收发通过 WebSocket 完成,见 [websocket.md](../websocket.md)
|
> 消息的实时收发(发送/撤回/标记已读/正在输入)通过 WebSocket 完成,见 [websocket.md](../websocket.md)
|
||||||
> 本文档中的接口用于会话管理和消息历史查询等非实时操作。
|
> 本文档中的接口用于会话管理和消息历史查询等非实时操作。
|
||||||
|
> **最后更新:** 2026-03-03(代码审查修复后同步)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,166 +11,84 @@
|
|||||||
|
|
||||||
| 方法 | 路径 | 权限 | 说明 |
|
| 方法 | 路径 | 权限 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| GET | /api/v1/conversations | 需认证 | 获取会话列表 |
|
| GET | /api/v1/im/conversations | 需认证 | 获取会话列表 |
|
||||||
| POST | /api/v1/conversations | 需认证 | 创建群聊 |
|
| GET | /api/v1/im/messages | 需认证 | 获取历史消息(游标分页) |
|
||||||
| GET | /api/v1/conversations/:id | 需认证 | 获取会话详情 |
|
| PUT | /api/v1/im/conversations/:id/pin | 需认证 | 置顶/取消置顶 |
|
||||||
| GET | /api/v1/conversations/:id/messages | 需认证 | 获取消息历史 |
|
| DELETE | /api/v1/im/conversations/:id | 需认证 | 删除会话(软删除) |
|
||||||
| POST | /api/v1/conversations/:id/members | 需认证 | 邀请成员加入群聊 |
|
| DELETE | /api/v1/im/conversations/:id/messages | 需认证 | 清空聊天记录(个人视图) |
|
||||||
| DELETE | /api/v1/conversations/:id/members/:uid | 需认证 | 移除群聊成员 |
|
| GET | /api/v1/im/messages/search | 需认证 | 全局消息搜索 |
|
||||||
|
| GET | /api/v1/im/unread | 需认证 | 获取全局未读消息总数 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 获取会话列表
|
## 1. 获取会话列表
|
||||||
|
|
||||||
`GET /api/v1/conversations`
|
`GET /api/v1/im/conversations`
|
||||||
|
|
||||||
**权限:** 需认证
|
**权限:** 需认证
|
||||||
|
|
||||||
**说明:** 返回当前用户的所有会话,按最后消息时间倒序排列。单聊会话的 `name`/`avatar` 为空,前端应使用 `target_user` 的信息展示。
|
**说明:** 返回当前用户的所有会话,排序:置顶优先 → 最后消息时间降序。已软删除的会话不返回。通过 LEFT JOIN 一次获取单聊对方用户 ID,避免 N+1 查询。
|
||||||
|
|
||||||
**成功响应:**
|
**成功响应:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "ok",
|
"message": "success",
|
||||||
"data": [
|
"data": {
|
||||||
|
"list": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"type": 1,
|
"type": 1,
|
||||||
"name": "",
|
"peer_user_id": 2,
|
||||||
"avatar": "",
|
"peer_nickname": "李四",
|
||||||
"target_user": {
|
"peer_avatar": "https://...",
|
||||||
"id": 2,
|
"last_msg_content": "你好",
|
||||||
"nickname": "李四",
|
"last_msg_time": "2026-03-03 10:30:00",
|
||||||
"avatar": "https://cdn.echochat.com/avatar/2.jpg",
|
"last_msg_sender_id": 2,
|
||||||
"online": true
|
"is_pinned": false,
|
||||||
},
|
"unread_count": 3
|
||||||
"last_message": {
|
|
||||||
"id": 100,
|
|
||||||
"type": 1,
|
|
||||||
"content": "你好",
|
|
||||||
"sender_id": 2,
|
|
||||||
"created_at": "2026-02-27 10:30:00"
|
|
||||||
},
|
|
||||||
"unread_count": 3,
|
|
||||||
"is_pinned": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"type": 2,
|
|
||||||
"name": "产品讨论组",
|
|
||||||
"avatar": "https://cdn.echochat.com/group/5.jpg",
|
|
||||||
"target_user": null,
|
|
||||||
"last_message": {
|
|
||||||
"id": 205,
|
|
||||||
"type": 1,
|
|
||||||
"content": "明天开会",
|
|
||||||
"sender_id": 3,
|
|
||||||
"created_at": "2026-02-27 11:00:00"
|
|
||||||
},
|
|
||||||
"unread_count": 0,
|
|
||||||
"is_pinned": true,
|
|
||||||
"member_count": 8
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 创建群聊
|
|
||||||
|
|
||||||
`POST /api/v1/conversations`
|
|
||||||
|
|
||||||
**权限:** 需认证
|
|
||||||
|
|
||||||
**请求参数:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| name | string | 是 | 群聊名称 |
|
|
||||||
| member_ids | int[] | 是 | 初始成员用户 ID 列表(不含自己,至少 2 人) |
|
|
||||||
|
|
||||||
**说明:** 创建者自动成为群主(role=2),被邀请的成员为普通成员(role=0)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 获取会话详情
|
|
||||||
|
|
||||||
`GET /api/v1/conversations/:id`
|
|
||||||
|
|
||||||
**权限:** 需认证,且为该会话成员
|
|
||||||
|
|
||||||
**成功响应(群聊示例):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"message": "ok",
|
|
||||||
"data": {
|
|
||||||
"id": 5,
|
|
||||||
"type": 2,
|
|
||||||
"name": "产品讨论组",
|
|
||||||
"avatar": "https://cdn.echochat.com/group/5.jpg",
|
|
||||||
"owner_id": 1,
|
|
||||||
"max_members": 200,
|
|
||||||
"member_count": 8,
|
|
||||||
"members": [
|
|
||||||
{ "user_id": 1, "nickname": "张三", "role": 2, "online": true },
|
|
||||||
{ "user_id": 2, "nickname": "李四", "role": 0, "online": false }
|
|
||||||
],
|
|
||||||
"created_at": "2026-02-20 09:00:00"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 获取消息历史
|
## 2. 获取历史消息
|
||||||
|
|
||||||
`GET /api/v1/conversations/:id/messages`
|
`GET /api/v1/im/messages`
|
||||||
|
|
||||||
**权限:** 需认证,且为该会话成员
|
**权限:** 需认证,且为该会话成员
|
||||||
|
|
||||||
**查询参数:**
|
**查询参数:**
|
||||||
|
|
||||||
| 参数 | 类型 | 默认值 | 说明 |
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|------|------|--------|------|
|
|------|------|------|--------|------|
|
||||||
| before_id | int | 无 | 获取此消息 ID 之前的消息(用于向上翻页加载历史) |
|
| conversation_id | int | 是 | - | 会话 ID |
|
||||||
| limit | int | 30 | 每次获取数量,最大 50 |
|
| before_id | int | 否 | 0 | 游标:查询 ID 小于此值的消息,0=最新 |
|
||||||
|
| limit | int | 否 | 30 | 每次获取数量,最大 100 |
|
||||||
|
|
||||||
|
**说明:** 支持 `clear_before_msg_id` 个人视图过滤(清空聊天记录后,仅过滤当前用户视图,不影响对方)。
|
||||||
|
|
||||||
**成功响应:**
|
**成功响应:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "ok",
|
"message": "success",
|
||||||
"data": {
|
"data": {
|
||||||
"messages": [
|
"list": [
|
||||||
{
|
|
||||||
"id": 98,
|
|
||||||
"sender_id": 1,
|
|
||||||
"sender_name": "张三",
|
|
||||||
"sender_avatar": "https://...",
|
|
||||||
"type": 1,
|
|
||||||
"content": "明天几点开会?",
|
|
||||||
"extra": {},
|
|
||||||
"status": 1,
|
|
||||||
"created_at": "2026-02-27 10:28:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": 99,
|
"id": 99,
|
||||||
|
"conversation_id": 1,
|
||||||
"sender_id": 2,
|
"sender_id": 2,
|
||||||
"sender_name": "李四",
|
"type": 1,
|
||||||
"sender_avatar": "https://...",
|
"content": "你好",
|
||||||
"type": 2,
|
|
||||||
"content": "",
|
|
||||||
"extra": {
|
|
||||||
"url": "https://cdn.echochat.com/img/xxx.jpg",
|
|
||||||
"width": 800,
|
|
||||||
"height": 600,
|
|
||||||
"thumbnail": "https://cdn.echochat.com/img/xxx_thumb.jpg"
|
|
||||||
},
|
|
||||||
"status": 1,
|
"status": 1,
|
||||||
"created_at": "2026-02-27 10:29:00"
|
"client_msg_id": "",
|
||||||
|
"created_at": "2026-03-03 10:29:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"has_more": true
|
"has_more": true
|
||||||
@@ -179,30 +98,97 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 邀请成员加入群聊
|
## 3. 置顶/取消置顶会话
|
||||||
|
|
||||||
`POST /api/v1/conversations/:id/members`
|
`PUT /api/v1/im/conversations/:id/pin`
|
||||||
|
|
||||||
**权限:** 需认证,且为该群聊成员
|
**权限:** 需认证,且为该会话成员
|
||||||
|
|
||||||
**请求参数:**
|
**请求参数:**
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| user_ids | int[] | 是 | 要邀请的用户 ID 列表 |
|
| is_pinned | bool | 是 | true=置顶, false=取消 |
|
||||||
|
|
||||||
**可能的错误码:** 3001(会话不存在),3002(非会话成员),4003(超出人数上限)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 移除群聊成员
|
## 4. 删除会话
|
||||||
|
|
||||||
`DELETE /api/v1/conversations/:id/members/:uid`
|
`DELETE /api/v1/im/conversations/:id`
|
||||||
|
|
||||||
**权限:** 需认证,且为群主或管理员
|
**权限:** 需认证,且为该会话成员
|
||||||
|
|
||||||
**路径参数:**
|
**说明:** 软删除,仅影响当前用户视图,不影响对方。同时清零未读数并更新 Redis 全局未读。
|
||||||
- `id` — 会话 ID
|
|
||||||
- `uid` — 被移除的用户 ID
|
|
||||||
|
|
||||||
**可能的错误码:** 3001, 3002, 1003(非群主/管理员无权操作)
|
---
|
||||||
|
|
||||||
|
## 5. 清空聊天记录
|
||||||
|
|
||||||
|
`DELETE /api/v1/im/conversations/:id/messages`
|
||||||
|
|
||||||
|
**权限:** 需认证,且为该会话成员
|
||||||
|
|
||||||
|
**说明:** 个人视图操作,不影响对方的消息。实现方式:记录清空时的最后消息 ID(`clear_before_msg_id`),后续查询历史消息时过滤。同时清零该会话未读数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 全局消息搜索
|
||||||
|
|
||||||
|
`GET /api/v1/im/messages/search`
|
||||||
|
|
||||||
|
**权限:** 需认证
|
||||||
|
|
||||||
|
**查询参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| keyword | string | 是 | - | 搜索关键词 |
|
||||||
|
| limit | int | 否 | 50 | 返回条数上限 |
|
||||||
|
|
||||||
|
**说明:** 使用 PostgreSQL GIN 全文索引(`to_tsvector('simple', content) @@ plainto_tsquery('simple', ?)`),仅搜索用户所在会话的消息。
|
||||||
|
|
||||||
|
**成功响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"conversation_id": 1,
|
||||||
|
"sender_id": 2,
|
||||||
|
"type": 1,
|
||||||
|
"content": "你好世界",
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2026-03-03 10:29:00",
|
||||||
|
"sender_nickname": "李四",
|
||||||
|
"sender_avatar": "https://..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 获取全局未读消息总数
|
||||||
|
|
||||||
|
`GET /api/v1/im/unread`
|
||||||
|
|
||||||
|
**权限:** 需认证
|
||||||
|
|
||||||
|
**说明:** 从 Redis STRING 读取全局未读总数,用于 TabBar badge 显示。
|
||||||
|
|
||||||
|
**成功响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"total_unread": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -84,24 +84,38 @@
|
|||||||
|
|
||||||
**方向:** 客户端 → 服务端
|
**方向:** 客户端 → 服务端
|
||||||
|
|
||||||
**说明:** 发送消息到会话
|
**说明:** 发送消息到会话。`conversation_id` 和 `target_user_id` 二选一:首次发消息使用 `target_user_id`(自动创建会话),后续使用 `conversation_id`。
|
||||||
|
|
||||||
**data 参数:**
|
**data 参数:**
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| conversation_id | int | 是 | 目标会话 ID |
|
| conversation_id | int | 否 | 已有会话 ID(与 target_user_id 二选一) |
|
||||||
| type | int | 是 | 消息类型:1=文本,2=图片,3=文件,4=语音 |
|
| target_user_id | int | 否 | 对方用户 ID(首次发消息时使用) |
|
||||||
| content | string | 否 | 文本内容 |
|
| type | int | 是 | 消息类型:1=文本 |
|
||||||
| extra | object | 否 | 附加数据(图片/文件信息) |
|
| content | string | 是 | 文本内容 |
|
||||||
|
| client_msg_id | string | 否 | 客户端消息唯一 ID,用于幂等去重 |
|
||||||
|
|
||||||
**ACK 响应 data:** `{ "msg_id": 10086 }`
|
**ACK 响应 data:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 10086,
|
||||||
|
"conversation_id": 1,
|
||||||
|
"sender_id": 1,
|
||||||
|
"type": 1,
|
||||||
|
"content": "你好",
|
||||||
|
"status": 1,
|
||||||
|
"client_msg_id": "xxxx-xxxx",
|
||||||
|
"created_at": "2026-03-03 10:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### im.message.new
|
### im.message.new
|
||||||
|
|
||||||
**方向:** 服务端 → 客户端
|
**方向:** 服务端 → 客户端(推送)
|
||||||
|
|
||||||
**说明:** 收到新消息推送
|
**说明:** 收到新消息推送
|
||||||
|
|
||||||
@@ -116,50 +130,76 @@
|
|||||||
"sender_avatar": "https://...",
|
"sender_avatar": "https://...",
|
||||||
"type": 1,
|
"type": 1,
|
||||||
"content": "你好",
|
"content": "你好",
|
||||||
"extra": {},
|
"client_msg_id": "",
|
||||||
"created_at": "2026-02-27 10:30:00"
|
"created_at": "2026-03-03 10:30:00"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### im.message.revoke
|
### im.message.recall
|
||||||
|
|
||||||
**方向:** 客户端 → 服务端
|
**方向:** 客户端 → 服务端
|
||||||
|
|
||||||
**说明:** 撤回消息(发送后 2 分钟内)
|
**说明:** 撤回消息(发送后 2 分钟内)。撤回成功后若该消息是会话最后一条,会同步更新会话预览为"XX 撤回了一条消息"。
|
||||||
|
|
||||||
**data 参数:** `{ "message_id": 10086 }`
|
**data 参数:** `{ "message_id": 10086 }`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### im.message.read
|
### im.message.recalled
|
||||||
|
|
||||||
**方向:** 客户端 → 服务端
|
**方向:** 服务端 → 客户端(推送)
|
||||||
|
|
||||||
**说明:** 消息已读回执
|
**说明:** 消息被撤回通知
|
||||||
|
|
||||||
**data 参数:** `{ "conversation_id": 1, "message_id": 10086 }`
|
**data 内容:** `{ "message_id": 10086, "conversation_id": 1, "sender_id": 2 }`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### im.typing.start
|
### im.conversation.read
|
||||||
|
|
||||||
**方向:** 客户端 → 服务端
|
**方向:** 客户端 → 服务端
|
||||||
|
|
||||||
**说明:** 通知对方"正在输入"
|
**说明:** 标记会话已读(清零未读数 + 更新 Redis 全局未读数)
|
||||||
|
|
||||||
**data 参数:** `{ "conversation_id": 1 }`
|
**data 参数:** `{ "conversation_id": 1 }`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### im.typing.stop
|
### im.typing
|
||||||
|
|
||||||
**方向:** 客户端 → 服务端
|
**方向:** 双向(客户端发送 → 服务端转发给对方)
|
||||||
|
|
||||||
**说明:** 停止输入
|
**说明:** 正在输入通知。客户端发送后服务端转发给对方,前端收到后设置 3 秒超时自动清除。
|
||||||
|
|
||||||
**data 参数:** `{ "conversation_id": 1 }`
|
**data 参数(客户端发送):** `{ "conversation_id": 1 }`
|
||||||
|
|
||||||
|
**data 内容(服务端推送):** `{ "conversation_id": 1, "user_id": 2 }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### im.offline.sync
|
||||||
|
|
||||||
|
**方向:** 服务端 → 客户端(推送)
|
||||||
|
|
||||||
|
**说明:** WebSocket 连接成功后服务端主动推送离线未读摘要
|
||||||
|
|
||||||
|
**data 内容:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_unread": 5,
|
||||||
|
"conversations": [
|
||||||
|
{
|
||||||
|
"conversation_id": 1,
|
||||||
|
"unread_count": 3,
|
||||||
|
"last_msg_content": "你好",
|
||||||
|
"last_msg_time": "2026-03-03 10:30:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ EchoChat 采用 **「精简单体 + 媒体微服务」** 架构,核心思想
|
|||||||
| auth | 用户注册/登录、有状态 JWT Token 管理(Redis 存储 + client_type 隔离)、RBAC 角色权限(level 等级体系:1=超管, 10=管理员, 100=用户) | ✅ Phase 1 |
|
| auth | 用户注册/登录、有状态 JWT Token 管理(Redis 存储 + client_type 隔离)、RBAC 角色权限(level 等级体系:1=超管, 10=管理员, 100=用户) | ✅ Phase 1 |
|
||||||
| ws | WebSocket 连接管理(Hub/Client/PubSub)、在线状态管理(Redis SET + TTL 心跳续期)、好友上下线实时通知(FriendIDsGetter 接口注入) | ✅ Phase 2a |
|
| ws | WebSocket 连接管理(Hub/Client/PubSub)、在线状态管理(Redis SET + TTL 心跳续期)、好友上下线实时通知(FriendIDsGetter 接口注入) | ✅ Phase 2a |
|
||||||
| contact | 好友关系管理(申请/接受/拒绝/删除/拉黑)、好友分组(CRUD + 移动)、用户搜索、好友推荐(批量查询优化) | ✅ Phase 2a |
|
| contact | 好友关系管理(申请/接受/拒绝/删除/拉黑)、好友分组(CRUD + 移动)、用户搜索、好友推荐(批量查询优化) | ✅ Phase 2a |
|
||||||
| im | 即时消息收发、会话管理、消息存储 | 🔜 Phase 2b |
|
| im | 即时消息收发(单聊)、会话管理、消息存储、撤回、搜索、离线推送 | ✅ Phase 2b |
|
||||||
| meeting | 会议创建/管理、信令转发、mediasoup 资源编排 | 📋 后续 |
|
| meeting | 会议创建/管理、信令转发、mediasoup 资源编排 | 📋 后续 |
|
||||||
| notify | 通知推送、会议邀请、好友申请通知 | 📋 后续 |
|
| notify | 通知推送、会议邀请、好友申请通知 | 📋 后续 |
|
||||||
| admin | 后台管理(用户管理 + 角色权限管理 + 在线监控 + 好友关系管理、会议监控、系统配置) | ✅ Phase 1/2a |
|
| admin | 后台管理(用户管理 + 角色权限管理 + 在线监控 + 好友关系管理、会议监控、系统配置) | ✅ Phase 1/2a |
|
||||||
@@ -126,7 +126,7 @@ main.go
|
|||||||
├── admin.RegisterRoutes(engine, ...) // /api/v1/admin/*
|
├── admin.RegisterRoutes(engine, ...) // /api/v1/admin/*
|
||||||
├── contact.RegisterRoutes(engine, ...) // /api/v1/contacts/* ✅ Phase 2a
|
├── contact.RegisterRoutes(engine, ...) // /api/v1/contacts/* ✅ Phase 2a
|
||||||
├── ws.RegisterRoutes(engine, ...) // /ws ✅ Phase 2a
|
├── ws.RegisterRoutes(engine, ...) // /ws ✅ Phase 2a
|
||||||
├── im.RegisterRoutes(engine, ...) // /api/v1/im/* (Phase 2b)
|
├── im.RegisterRoutes(engine, ...) // /api/v1/im/* ✅ Phase 2b
|
||||||
└── meeting.RegisterRoutes(engine, ...) // /api/v1/meeting/* (后续)
|
└── meeting.RegisterRoutes(engine, ...) // /api/v1/meeting/* (后续)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ mediasoup C++ SFU 的 **"遥控器"**,不懂业务、不懂用户、只懂媒
|
|||||||
|
|
||||||
| 规则 | 说明 |
|
| 规则 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 模块间零直接引用 | `auth` 不会 import `im` 内部代码,通过 interface 通信(实例:ws.FriendIDsGetter 由 contact.FriendshipDAO 实现) |
|
| 模块间零直接引用 | `auth` 不会 import `im` 内部代码,通过 interface 通信(实例:ws.FriendIDsGetter / im.FriendChecker / contact.OnlineChecker 均通过接口注入) |
|
||||||
| 模块自包含路由 | 每个模块在自己目录内维护 `router.go`,拆分时整目录搬走 |
|
| 模块自包含路由 | 每个模块在自己目录内维护 `router.go`,拆分时整目录搬走 |
|
||||||
| 主路由仅做汇总 | `router/router.go` 只注册各模块路由,不含具体路由定义 |
|
| 主路由仅做汇总 | `router/router.go` 只注册各模块路由,不含具体路由定义 |
|
||||||
| 数据库表按模块前缀 | `auth_users`、`im_messages`、`meeting_rooms`,后期可分库 |
|
| 数据库表按模块前缀 | `auth_users`、`im_messages`、`meeting_rooms`,后期可分库 |
|
||||||
|
|||||||
@@ -353,93 +353,101 @@ COMMENT ON COLUMN contact_groups.sort_order IS '排序权重,数值越小越
|
|||||||
COMMENT ON COLUMN contact_groups.created_at IS '创建时间';
|
COMMENT ON COLUMN contact_groups.created_at IS '创建时间';
|
||||||
```
|
```
|
||||||
|
|
||||||
#### im 模块 — 即时通讯(统一会话模型)
|
#### im 模块 — 即时通讯(统一会话模型)✅ Phase 2b 已实现
|
||||||
|
|
||||||
单聊和群聊统一抽象为"会话",本质都是"一组人在一个空间里收发消息"。这是微信、钉钉、Slack 等主流 IM 的标准模型。
|
单聊和群聊统一抽象为"会话",本质都是"一组人在一个空间里收发消息"。这是微信、钉钉、Slack 等主流 IM 的标准模型。
|
||||||
|
|
||||||
|
> **Phase 2b 实际实现说明:** 当前已实现单聊功能,表结构已针对实际需求进行了优化(冗余 last_msg_* 字段提升查询效率,新增 clear_before_msg_id 实现个人视图清空,新增 client_msg_id 实现消息幂等)。群聊相关字段(name/avatar/owner_id/max_members/role/nickname/is_muted)保留在总体设计中,将在 Phase 2c 实现。
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- im_conversations: 会话表
|
-- im_conversations: 会话表(Phase 2b 实际实现版本)
|
||||||
-- 统一抽象单聊和群聊,单聊时 name/avatar 为空(前端用对方信息展示)
|
-- 统一抽象单聊和群聊,含冗余 last_msg_* 字段避免 JOIN 查询
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
CREATE TABLE im_conversations (
|
CREATE TABLE im_conversations (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
type SMALLINT NOT NULL,
|
type SMALLINT NOT NULL DEFAULT 1,
|
||||||
name VARCHAR(100) DEFAULT '',
|
creator_id BIGINT NOT NULL,
|
||||||
avatar VARCHAR(500) DEFAULT '',
|
last_message_id BIGINT DEFAULT NULL,
|
||||||
owner_id BIGINT DEFAULT NULL,
|
last_msg_content TEXT DEFAULT '',
|
||||||
max_members INT NOT NULL DEFAULT 200,
|
last_msg_time TIMESTAMP WITH TIME ZONE,
|
||||||
status SMALLINT NOT NULL DEFAULT 1,
|
last_msg_sender_id BIGINT DEFAULT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE im_conversations IS '会话表,统一管理单聊和群聊会话';
|
COMMENT ON TABLE im_conversations IS '会话表,统一管理单聊和群聊会话';
|
||||||
COMMENT ON COLUMN im_conversations.id IS '会话唯一标识';
|
COMMENT ON COLUMN im_conversations.id IS '会话唯一标识';
|
||||||
COMMENT ON COLUMN im_conversations.type IS '会话类型:1=单聊(两人私聊),2=群聊(多人群组)';
|
COMMENT ON COLUMN im_conversations.type IS '会话类型:1=单聊,2=群聊(Phase 2c)';
|
||||||
COMMENT ON COLUMN im_conversations.name IS '会话名称,群聊时为群名,单聊时为空(前端取对方昵称展示)';
|
COMMENT ON COLUMN im_conversations.creator_id IS '会话创建者用户 ID';
|
||||||
COMMENT ON COLUMN im_conversations.avatar IS '会话头像 URL,群聊时为群头像,单聊时为空(前端取对方头像展示)';
|
COMMENT ON COLUMN im_conversations.last_message_id IS '最后一条消息 ID(冗余字段,避免 JOIN)';
|
||||||
COMMENT ON COLUMN im_conversations.owner_id IS '群主用户 ID,仅群聊时有值,单聊时为 NULL';
|
COMMENT ON COLUMN im_conversations.last_msg_content IS '最后消息预览文本(冗余字段)';
|
||||||
COMMENT ON COLUMN im_conversations.max_members IS '最大成员数,单聊固定为2,群聊默认200';
|
COMMENT ON COLUMN im_conversations.last_msg_time IS '最后消息时间(冗余字段,用于排序)';
|
||||||
COMMENT ON COLUMN im_conversations.status IS '会话状态:1=正常,2=已解散(仅群聊可解散)';
|
COMMENT ON COLUMN im_conversations.last_msg_sender_id IS '最后消息发送者 ID(冗余字段)';
|
||||||
COMMENT ON COLUMN im_conversations.created_at IS '会话创建时间';
|
|
||||||
COMMENT ON COLUMN im_conversations.updated_at IS '最后更新时间';
|
CREATE INDEX idx_im_conversations_updated ON im_conversations(updated_at DESC);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- im_conversation_members: 会话成员表
|
-- im_conversation_members: 会话成员表(Phase 2b 实际实现版本)
|
||||||
-- 记录每个会话中的参与成员及其个性化设置
|
-- 记录每个会话中的参与成员及其个人视图设置
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
CREATE TABLE im_conversation_members (
|
CREATE TABLE im_conversation_members (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
conversation_id BIGINT NOT NULL REFERENCES im_conversations(id),
|
conversation_id BIGINT NOT NULL REFERENCES im_conversations(id),
|
||||||
user_id BIGINT NOT NULL REFERENCES auth_users(id),
|
user_id BIGINT NOT NULL,
|
||||||
role SMALLINT NOT NULL DEFAULT 0,
|
is_pinned BOOLEAN DEFAULT FALSE,
|
||||||
nickname VARCHAR(50) DEFAULT '',
|
is_deleted BOOLEAN DEFAULT FALSE,
|
||||||
is_muted BOOLEAN NOT NULL DEFAULT FALSE,
|
unread_count INT DEFAULT 0,
|
||||||
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
last_read_msg_id BIGINT DEFAULT 0,
|
last_read_msg_id BIGINT DEFAULT 0,
|
||||||
joined_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
clear_before_msg_id BIGINT DEFAULT 0,
|
||||||
UNIQUE (conversation_id, user_id)
|
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(conversation_id, user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE im_conversation_members IS '会话成员表,记录成员列表及每人的个性化设置';
|
COMMENT ON TABLE im_conversation_members IS '会话成员表,记录成员列表及个人视图设置';
|
||||||
COMMENT ON COLUMN im_conversation_members.id IS '记录唯一标识';
|
COMMENT ON COLUMN im_conversation_members.id IS '记录唯一标识';
|
||||||
COMMENT ON COLUMN im_conversation_members.conversation_id IS '所属会话 ID';
|
COMMENT ON COLUMN im_conversation_members.conversation_id IS '所属会话 ID';
|
||||||
COMMENT ON COLUMN im_conversation_members.user_id IS '成员用户 ID';
|
COMMENT ON COLUMN im_conversation_members.user_id IS '成员用户 ID';
|
||||||
COMMENT ON COLUMN im_conversation_members.role IS '成员角色:0=普通成员,1=管理员(群聊可设置),2=群主';
|
COMMENT ON COLUMN im_conversation_members.is_pinned IS '是否置顶(个人设置)';
|
||||||
COMMENT ON COLUMN im_conversation_members.nickname IS '群内昵称,仅在该群聊中生效,为空则使用用户全局昵称';
|
COMMENT ON COLUMN im_conversation_members.is_deleted IS '是否软删除(个人视图,不影响对方)';
|
||||||
COMMENT ON COLUMN im_conversation_members.is_muted IS '是否被禁言:false=正常发言,true=已被禁言(仅管理员/群主可操作)';
|
COMMENT ON COLUMN im_conversation_members.unread_count IS '未读消息数';
|
||||||
COMMENT ON COLUMN im_conversation_members.is_pinned IS '是否置顶该会话:false=不置顶,true=置顶(个人设置,不影响他人)';
|
COMMENT ON COLUMN im_conversation_members.last_read_msg_id IS '最后已读消息 ID';
|
||||||
COMMENT ON COLUMN im_conversation_members.last_read_msg_id IS '最后已读消息 ID,用于计算未读消息数';
|
COMMENT ON COLUMN im_conversation_members.clear_before_msg_id IS '清空记录截止消息 ID(个人视图,查询时过滤 id <= 此值的消息)';
|
||||||
COMMENT ON COLUMN im_conversation_members.joined_at IS '加入会话的时间';
|
|
||||||
|
CREATE INDEX idx_im_conv_members_user ON im_conversation_members(user_id, is_deleted);
|
||||||
|
CREATE INDEX idx_im_conv_members_conv ON im_conversation_members(conversation_id);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- im_messages: 消息表
|
-- im_messages: 消息表(Phase 2b 实际实现版本)
|
||||||
-- 系统数据量最大的表,存储所有聊天消息内容
|
-- 系统数据量最大的表,含 client_msg_id 实现幂等 + GIN 全文搜索索引
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
CREATE TABLE im_messages (
|
CREATE TABLE im_messages (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
conversation_id BIGINT NOT NULL,
|
conversation_id BIGINT NOT NULL REFERENCES im_conversations(id),
|
||||||
sender_id BIGINT NOT NULL,
|
sender_id BIGINT NOT NULL,
|
||||||
type SMALLINT NOT NULL DEFAULT 1,
|
type SMALLINT NOT NULL DEFAULT 1,
|
||||||
content TEXT NOT NULL DEFAULT '',
|
content TEXT NOT NULL,
|
||||||
extra JSONB DEFAULT '{}',
|
extra JSONB DEFAULT NULL,
|
||||||
status SMALLINT NOT NULL DEFAULT 1,
|
status SMALLINT NOT NULL DEFAULT 1,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
client_msg_id VARCHAR(64) DEFAULT '',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE im_messages IS '聊天消息表,存储所有会话的消息记录(系统数据量最大的表)';
|
COMMENT ON TABLE im_messages IS '聊天消息表,存储所有会话的消息记录';
|
||||||
COMMENT ON COLUMN im_messages.id IS '消息唯一标识,全局自增';
|
COMMENT ON COLUMN im_messages.id IS '消息唯一标识,全局自增';
|
||||||
COMMENT ON COLUMN im_messages.conversation_id IS '所属会话 ID';
|
COMMENT ON COLUMN im_messages.conversation_id IS '所属会话 ID';
|
||||||
COMMENT ON COLUMN im_messages.sender_id IS '发送者用户 ID';
|
COMMENT ON COLUMN im_messages.sender_id IS '发送者用户 ID';
|
||||||
COMMENT ON COLUMN im_messages.type IS '消息类型:1=文本消息,2=图片消息,3=文件消息,4=语音消息,5=系统通知消息';
|
COMMENT ON COLUMN im_messages.type IS '消息类型:1=文本(预留 2=图片 3=语音 4=文件 5=系统通知)';
|
||||||
COMMENT ON COLUMN im_messages.content IS '消息内容,文本消息为文字,其他类型为描述文字或为空';
|
COMMENT ON COLUMN im_messages.content IS '消息内容';
|
||||||
COMMENT ON COLUMN im_messages.extra IS '附加数据(JSON),图片消息存 {url,width,height},文件消息存 {url,name,size},语音消息存 {url,duration}';
|
COMMENT ON COLUMN im_messages.extra IS '附加数据(JSON),预留扩展';
|
||||||
COMMENT ON COLUMN im_messages.status IS '消息状态:1=正常,2=已撤回(发送者撤回),3=已删除(管理员删除)';
|
COMMENT ON COLUMN im_messages.status IS '消息状态:1=正常,2=已撤回,3=已删除';
|
||||||
|
COMMENT ON COLUMN im_messages.client_msg_id IS '客户端消息唯一 ID,用于幂等去重';
|
||||||
COMMENT ON COLUMN im_messages.created_at IS '消息发送时间';
|
COMMENT ON COLUMN im_messages.created_at IS '消息发送时间';
|
||||||
|
|
||||||
CREATE INDEX idx_im_messages_conv_time ON im_messages(conversation_id, created_at DESC);
|
CREATE INDEX idx_im_messages_conv_time ON im_messages(conversation_id, created_at DESC);
|
||||||
COMMENT ON INDEX idx_im_messages_conv_time IS '会话消息时间索引,用于按时间倒序查询会话历史消息';
|
CREATE INDEX idx_im_messages_conv_id ON im_messages(conversation_id, id DESC);
|
||||||
|
CREATE INDEX idx_im_messages_content_search ON im_messages USING gin(to_tsvector('simple', content));
|
||||||
```
|
```
|
||||||
|
|
||||||
#### meeting 模块 — 音视频会议
|
#### meeting 模块 — 音视频会议
|
||||||
@@ -586,9 +594,8 @@ echo:auth:refresh:{client_type}:{user_id} → Refresh Token (STRING, TTL 由
|
|||||||
echo:user:online → 在线用户集合 (SET)
|
echo:user:online → 在线用户集合 (SET)
|
||||||
echo:user:status:{user_id} → 用户状态 JSON (STRING, TTL 自动过期)
|
echo:user:status:{user_id} → 用户状态 JSON (STRING, TTL 自动过期)
|
||||||
|
|
||||||
# 即时通讯
|
# 即时通讯(Phase 2b 实际实现)
|
||||||
echo:im:unread:{user_id} → 各会话未读数 (HASH: conv_id → count)
|
echo:im:unread:{user_id} → 全局未读消息总数 (STRING,Lua 脚本原子递减,下限 0)
|
||||||
echo:im:typing:{conversation_id} → 正在输入的用户 (SET, TTL 5秒)
|
|
||||||
|
|
||||||
# 会议实时状态
|
# 会议实时状态
|
||||||
echo:meeting:room:{room_code} → 房间实时状态 JSON (STRING)
|
echo:meeting:room:{room_code} → 房间实时状态 JSON (STRING)
|
||||||
@@ -702,13 +709,14 @@ GET /api/v1/contacts/recommend 好友推荐
|
|||||||
# 在线状态
|
# 在线状态
|
||||||
GET /api/v1/contacts/online 批量查询好友在线状态
|
GET /api/v1/contacts/online 批量查询好友在线状态
|
||||||
|
|
||||||
# 即时通讯模块
|
# 即时通讯模块(Phase 2b 已实现)
|
||||||
GET /api/v1/conversations
|
GET /api/v1/im/conversations 会话列表(含未读数、对方信息)
|
||||||
POST /api/v1/conversations
|
GET /api/v1/im/messages 历史消息(游标分页 before_id + limit)
|
||||||
GET /api/v1/conversations/:id
|
PUT /api/v1/im/conversations/:id/pin 置顶/取消置顶
|
||||||
GET /api/v1/conversations/:id/messages
|
DELETE /api/v1/im/conversations/:id 删除会话(软删除)
|
||||||
POST /api/v1/conversations/:id/members
|
DELETE /api/v1/im/conversations/:id/messages 清空聊天记录(个人视图)
|
||||||
DELETE /api/v1/conversations/:id/members/:uid
|
GET /api/v1/im/messages/search 全局消息搜索(GIN 全文索引)
|
||||||
|
GET /api/v1/im/unread 全局未读消息总数
|
||||||
|
|
||||||
# 会议模块
|
# 会议模块
|
||||||
POST /api/v1/meetings
|
POST /api/v1/meetings
|
||||||
@@ -811,9 +819,11 @@ pages/
|
|||||||
│ ├── register.vue # 注册页
|
│ ├── register.vue # 注册页
|
||||||
│ └── forgot-password.vue # 忘记密码
|
│ └── forgot-password.vue # 忘记密码
|
||||||
├── chat/
|
├── chat/
|
||||||
│ ├── index.vue # 会话列表
|
│ ├── index.vue # 会话列表 ✅ Phase 2b
|
||||||
│ ├── conversation.vue # 聊天对话页
|
│ ├── conversation.vue # 聊天对话页 ✅ Phase 2b
|
||||||
│ └── group-create.vue # 创建群聊
|
│ ├── settings.vue # 聊天设置页 ✅ Phase 2b
|
||||||
|
│ ├── search.vue # 消息搜索页 ✅ Phase 2b
|
||||||
|
│ └── group-create.vue # 创建群聊 📋 Phase 2c
|
||||||
├── contact/
|
├── contact/
|
||||||
│ ├── index.vue # 联系人列表(含搜索/在线状态) ✅ Phase 2a
|
│ ├── index.vue # 联系人列表(含搜索/在线状态) ✅ Phase 2a
|
||||||
│ ├── search.vue # 搜索添加好友 + 好友推荐 ✅ Phase 2a
|
│ ├── search.vue # 搜索添加好友 + 好友推荐 ✅ Phase 2a
|
||||||
@@ -937,10 +947,25 @@ services:
|
|||||||
- 在线状态管理(混合推拉方案)
|
- 在线状态管理(混合推拉方案)
|
||||||
- 管理端扩展(在线监控 + 好友关系管理)
|
- 管理端扩展(在线监控 + 好友关系管理)
|
||||||
|
|
||||||
#### Phase 2b:即时通讯消息系统 🔜 待开发
|
#### Phase 2b:即时通讯消息系统 ✅ 已完成
|
||||||
- 即时聊天(单聊 + 群聊,文字/图片/文件)
|
- 单聊即时通讯(WebSocket 全双工消息收发、三态 ACK 确认)
|
||||||
|
- 会话管理(自动创建、置顶、软删除、清空聊天记录 — 个人视图)
|
||||||
|
- 消息撤回(2 分钟内)、正在输入提示
|
||||||
|
- 离线消息推送(WS 连接后服务端主动推送未读摘要)
|
||||||
|
- 未读消息管理(会话 badge + TabBar 总未读数,Lua 脚本原子操作)
|
||||||
|
- 全局消息搜索(PostgreSQL GIN 全文索引)
|
||||||
|
- WS 事件路由表机制(Hub.RegisterEvent/DispatchEvent)
|
||||||
|
- 前台 4 个页面 + chat Store + API 封装
|
||||||
|
- 设计文档:`docs/plans/2026-03-03-phase2b-design.md`
|
||||||
|
|
||||||
#### Phase 2c:会议与通知 📋 待规划
|
#### Phase 2c:群聊与增强 📋 待规划
|
||||||
|
- 群聊会话(建群/加入/退出/管理)
|
||||||
|
- 群消息收发
|
||||||
|
- 已读回执(单聊 + 群聊)
|
||||||
|
- 消息类型扩展(图片/语音/文件)
|
||||||
|
- 管理端消息管理功能
|
||||||
|
|
||||||
|
#### Phase 2d:会议与通知 📋 待规划
|
||||||
- 多人音视频会议(即时会议 + 预约会议)
|
- 多人音视频会议(即时会议 + 预约会议)
|
||||||
- 消息通知系统
|
- 消息通知系统
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Phase 2b 设计文档:即时通讯消息系统(单聊)
|
# Phase 2b 设计文档:即时通讯消息系统(单聊)
|
||||||
|
|
||||||
> **状态:** 📋 设计完成,待实施
|
> **状态:** ✅ 已完成(含代码审查修复)
|
||||||
> **分支:** `feature/phase2b-instant-messaging`
|
> **分支:** `feature/phase2b-instant-messaging`
|
||||||
> **前置依赖:** Phase 2a 全部完成(WebSocket + 联系人管理)
|
> **前置依赖:** Phase 2a 全部完成(WebSocket + 联系人管理)
|
||||||
> **架构备忘:** `docs/plans/2026-03-02-phase2b-architecture-notes.md`
|
> **架构备忘:** `docs/plans/2026-03-02-phase2b-architecture-notes.md`
|
||||||
> **最后更新:** 2026-03-03
|
> **最后更新:** 2026-03-03(含代码审查修复 7 项 + 用户测试修复 8 项 + 文档同步)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ func (h *Hub) RegisterEvent(event string, handler EventHandler)
|
|||||||
func (h *Hub) DispatchEvent(client *Client, msg *Message) bool
|
func (h *Hub) DispatchEvent(client *Client, msg *Message) bool
|
||||||
```
|
```
|
||||||
|
|
||||||
IM 模块在启动时向 Hub 注册事件:`im.message.send`、`im.message.recall`、`im.typing.start`、`im.typing.stop`、`im.conversation.sync`。
|
IM 模块在启动时向 Hub 注册事件:`im.message.send`、`im.message.recall`、`im.conversation.read`、`im.typing`。
|
||||||
|
|
||||||
### 3.3 离线消息推送
|
### 3.3 离线消息推送
|
||||||
|
|
||||||
@@ -117,14 +117,14 @@ IM 模块在启动时向 Hub 注册事件:`im.message.send`、`im.message.reca
|
|||||||
|
|
||||||
```
|
```
|
||||||
发送方 Client
|
发送方 Client
|
||||||
│ WS: im.message.recall { message_id, conversation_id }
|
│ WS: im.message.recall { message_id }
|
||||||
▼
|
▼
|
||||||
IM Service.RecallMessage()
|
IM Service.RecallMessage()
|
||||||
├── 校验:是否为自己的消息、是否在 2 分钟内
|
├── 校验:是否为自己的消息、是否在 2 分钟内
|
||||||
├── DB: UPDATE im_messages SET status = 2(已撤回)
|
├── DB: UPDATE im_messages SET status = 2(已撤回)
|
||||||
├── DB: UPDATE im_conversations (last_msg_content = "XX 撤回了一条消息")
|
├── 若被撤回的是会话最后一条消息 → UPDATE im_conversations (last_msg_content = "XX 撤回了一条消息")
|
||||||
├── WS Response: im.message.recall.ack { message_id }
|
├── WS Response: im.message.recall.ack { message_id }
|
||||||
└── PubSub.PublishToUser(receiverID, im.message.recalled)
|
└── PubSub.PublishToUser(receiverID, im.message.recalled { message_id, conversation_id, sender_id })
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.5 消息收发时序图
|
### 3.5 消息收发时序图
|
||||||
@@ -205,8 +205,9 @@ CREATE TABLE im_conversation_members (
|
|||||||
is_deleted BOOLEAN DEFAULT FALSE, -- 是否删除会话(软删除,不影响对方)
|
is_deleted BOOLEAN DEFAULT FALSE, -- 是否删除会话(软删除,不影响对方)
|
||||||
unread_count INT DEFAULT 0, -- 未读消息数
|
unread_count INT DEFAULT 0, -- 未读消息数
|
||||||
last_read_msg_id BIGINT DEFAULT 0, -- 最后已读消息 ID
|
last_read_msg_id BIGINT DEFAULT 0, -- 最后已读消息 ID
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
clear_before_msg_id BIGINT DEFAULT 0, -- 清空记录截止消息 ID(个人视图,不影响对方)
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
UNIQUE(conversation_id, user_id)
|
UNIQUE(conversation_id, user_id)
|
||||||
);
|
);
|
||||||
@@ -215,6 +216,8 @@ CREATE INDEX idx_im_conv_members_user ON im_conversation_members(user_id, is_del
|
|||||||
CREATE INDEX idx_im_conv_members_conv ON im_conversation_members(conversation_id);
|
CREATE INDEX idx_im_conv_members_conv ON im_conversation_members(conversation_id);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **设计说明**:`clear_before_msg_id` 用于实现"清空聊天记录"的个人视图。用户 A 清空记录后,仅 A 看不到清空前的消息,用户 B 不受影响。查询历史消息时增加 `id > clear_before_msg_id` 过滤条件。
|
||||||
|
|
||||||
### 4.3 im_messages(消息表)
|
### 4.3 im_messages(消息表)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -253,11 +256,13 @@ LIMIT 1
|
|||||||
|
|
||||||
| Key | 类型 | 说明 |
|
| Key | 类型 | 说明 |
|
||||||
|-----|------|------|
|
|-----|------|------|
|
||||||
| `echo:im:unread:{user_id}` | HASH | 每个会话的未读数 `{ conv_id: count }` |
|
| `echo:im:unread:{user_id}` | STRING | 全局未读消息总数(会话级别的未读数存储在 DB `im_conversation_members.unread_count`) |
|
||||||
|
|
||||||
- 收到新消息:`HINCRBY echo:im:unread:{user_id} {conv_id} 1`
|
- 收到新消息:`INCR echo:im:unread:{user_id}`(全局 +1)
|
||||||
- 标记已读:`HDEL echo:im:unread:{user_id} {conv_id}`
|
- 标记已读/清空/删除会话:Lua 脚本原子递减(下限为 0,防止负数)
|
||||||
- 获取总未读数:`HVALS echo:im:unread:{user_id}` 求和
|
- 获取总未读数:`GET echo:im:unread:{user_id}`
|
||||||
|
|
||||||
|
> **设计说明**:选择 STRING 而非 HASH 的原因:DB 已有会话级别的 `unread_count` 字段,Redis 仅用于 TabBar badge 的快速读取,不需要冗余存储每个会话的未读数。Lua 脚本保证递减操作的原子性和下限保护。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -267,11 +272,12 @@ LIMIT 1
|
|||||||
|
|
||||||
| 事件 | 说明 | Data 字段 |
|
| 事件 | 说明 | Data 字段 |
|
||||||
|------|------|-----------|
|
|------|------|-----------|
|
||||||
| `im.message.send` | 发送消息 | `{ target_user_id, content, type, client_msg_id }` |
|
| `im.message.send` | 发送消息 | `{ conversation_id, target_user_id, content, type, client_msg_id }` |
|
||||||
| `im.message.recall` | 撤回消息 | `{ message_id, conversation_id }` |
|
| `im.message.recall` | 撤回消息 | `{ message_id }` |
|
||||||
| `im.typing.start` | 开始输入 | `{ conversation_id }` |
|
| `im.conversation.read` | 标记已读 | `{ conversation_id }` |
|
||||||
| `im.typing.stop` | 停止输入 | `{ conversation_id }` |
|
| `im.typing` | 正在输入 | `{ conversation_id }` |
|
||||||
| `im.conversation.sync` | 请求同步离线消息 | `{ conversation_id, last_msg_id }` |
|
|
||||||
|
> **说明**:`im.message.send` 中 `conversation_id` 和 `target_user_id` 二选一。首次发消息时使用 `target_user_id`(自动创建会话),后续使用 `conversation_id`。
|
||||||
|
|
||||||
### 5.2 服务端 → 客户端(ACK 响应)
|
### 5.2 服务端 → 客户端(ACK 响应)
|
||||||
|
|
||||||
@@ -288,10 +294,12 @@ LIMIT 1
|
|||||||
|
|
||||||
| 事件 | 说明 | Data 字段 |
|
| 事件 | 说明 | Data 字段 |
|
||||||
|------|------|-----------|
|
|------|------|-----------|
|
||||||
| `im.message.new` | 新消息推送 | `{ message_id, conversation_id, sender_id, sender_name, sender_avatar, type, content, created_at }` |
|
| `im.message.new` | 新消息推送 | `{ id, conversation_id, sender_id, sender_name, sender_avatar, type, content, client_msg_id, created_at }` |
|
||||||
| `im.message.recalled` | 消息被撤回 | `{ message_id, conversation_id, sender_id }` |
|
| `im.message.recalled` | 消息被撤回 | `{ message_id, conversation_id, sender_id }` |
|
||||||
| `im.typing.notify` | 正在输入通知 | `{ conversation_id, user_id, is_typing }` |
|
| `im.typing` | 正在输入通知 | `{ conversation_id, user_id }` |
|
||||||
| `im.conversation.unread` | 离线未读摘要 | `{ conversations: [{ id, type, unread_count, last_msg_content, last_msg_time, peer_user_id, peer_nickname, peer_avatar }] }` |
|
| `im.offline.sync` | 离线未读摘要 | `{ total_unread, conversations: [{ conversation_id, unread_count, last_msg_content, last_msg_time }] }` |
|
||||||
|
|
||||||
|
> **说明**:正在输入通知前端收到后设置 3 秒超时自动清除。离线同步事件在 WS 连接成功后由服务端主动推送。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -302,14 +310,14 @@ LIMIT 1
|
|||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| GET | `/conversations` | 获取会话列表(含未读数、最后消息、对方信息) |
|
| GET | `/conversations` | 获取会话列表(含未读数、最后消息、对方信息) |
|
||||||
| GET | `/conversations/:id/messages` | 获取历史消息(游标分页:`before_id` + `limit`) |
|
| GET | `/messages?conversation_id=xx&before_id=xx&limit=30` | 获取历史消息(游标分页) |
|
||||||
| PUT | `/conversations/:id/pin` | 置顶/取消置顶 |
|
| PUT | `/conversations/:id/pin` | 置顶/取消置顶 |
|
||||||
| DELETE | `/conversations/:id` | 删除会话(软删除) |
|
| DELETE | `/conversations/:id` | 删除会话(软删除) |
|
||||||
| DELETE | `/conversations/:id/messages` | 清空聊天记录 |
|
| DELETE | `/conversations/:id/messages` | 清空聊天记录(个人视图,不影响对方) |
|
||||||
| PUT | `/conversations/:id/read` | 标记会话已读(清零未读数 + 清 Redis) |
|
| GET | `/messages/search?keyword=xxx&limit=50` | 全局消息搜索(GIN 全文索引) |
|
||||||
| GET | `/messages/search?keyword=xxx` | 全局消息搜索(结果按会话分组) |
|
| GET | `/unread` | 获取全局未读消息总数 |
|
||||||
|
|
||||||
共 **7 个 REST API**,均需 JWT 认证。
|
共 **7 个 REST API**,均需 JWT 认证。标记已读通过 WS 事件 `im.conversation.read` 实现。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -363,7 +371,7 @@ Wire 注入链:
|
|||||||
| `GetMessages(ctx, userID, conversationID, beforeID, limit)` | 获取历史消息(游标分页) |
|
| `GetMessages(ctx, userID, conversationID, beforeID, limit)` | 获取历史消息(游标分页) |
|
||||||
| `PinConversation(ctx, userID, conversationID, isPinned)` | 置顶/取消置顶 |
|
| `PinConversation(ctx, userID, conversationID, isPinned)` | 置顶/取消置顶 |
|
||||||
| `DeleteConversation(ctx, userID, conversationID)` | 删除会话(软删除) |
|
| `DeleteConversation(ctx, userID, conversationID)` | 删除会话(软删除) |
|
||||||
| `ClearMessages(ctx, userID, conversationID)` | 清空聊天记录 |
|
| `ClearMessages(ctx, userID, conversationID)` | 清空聊天记录(个人视图,ClearBeforeMsgID) |
|
||||||
| `MarkAsRead(ctx, userID, conversationID)` | 标记已读 |
|
| `MarkAsRead(ctx, userID, conversationID)` | 标记已读 |
|
||||||
| `SearchMessages(ctx, userID, keyword)` | 全局搜索 |
|
| `SearchMessages(ctx, userID, keyword)` | 全局搜索 |
|
||||||
| `PushOfflineMessages(ctx, userID)` | 推送离线未读摘要 |
|
| `PushOfflineMessages(ctx, userID)` | 推送离线未读摘要 |
|
||||||
@@ -406,8 +414,8 @@ frontend/src/pages/chat/
|
|||||||
**WS 事件监听:**
|
**WS 事件监听:**
|
||||||
- `im.message.new` → 更新 conversations + messages + totalUnread
|
- `im.message.new` → 更新 conversations + messages + totalUnread
|
||||||
- `im.message.recalled` → 更新消息状态为"已撤回"
|
- `im.message.recalled` → 更新消息状态为"已撤回"
|
||||||
- `im.typing.notify` → 更新 typingUsers
|
- `im.typing` → 更新 typingUsers(3 秒自动清除)
|
||||||
- `im.conversation.unread` → 初始化未读数 + 会话列表
|
- `im.offline.sync` → 初始化未读数 + 刷新会话列表
|
||||||
|
|
||||||
### 8.3 API 封装(api/chat.js)
|
### 8.3 API 封装(api/chat.js)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# EchoChat 项目开发进度
|
# EchoChat 项目开发进度
|
||||||
|
|
||||||
> **最后更新**:2026-03-03(Phase 2b 即时通讯核心 + ui-ux-pro-max 规范改造完成)
|
> **最后更新**:2026-03-03(Phase 2b 全部完成,含代码审查修复 + 用户测试修复)
|
||||||
> **当前阶段**:Phase 2b 已完成
|
> **当前阶段**:Phase 2b 全部完成,准备进入 Phase 2c
|
||||||
> **当前分支**:`feature/phase2b-instant-messaging`
|
> **当前分支**:`feature/phase2b-instant-messaging`
|
||||||
> **实施计划**:`docs/plans/2026-03-03-phase2b-implementation.plan.md`
|
> **实施计划**:`docs/plans/2026-03-03-phase2b-implementation.plan.md`
|
||||||
> **设计文档**:`docs/plans/2026-03-03-phase2b-design.md`
|
> **设计文档**:`docs/plans/2026-03-03-phase2b-design.md`
|
||||||
@@ -23,6 +23,33 @@
|
|||||||
| Task 8 | 设置页 + 搜索页 + 联系人改造 | ✅ 完成 | 2 个辅助页面 + 发消息跳转 |
|
| Task 8 | 设置页 + 搜索页 + 联系人改造 | ✅ 完成 | 2 个辅助页面 + 发消息跳转 |
|
||||||
| Task 9 | 文档更新 + 代码审查 | ✅ 完成 | 进度/架构文档同步 |
|
| Task 9 | 文档更新 + 代码审查 | ✅ 完成 | 进度/架构文档同步 |
|
||||||
| UI 改造 | ui-ux-pro-max 规范改造 | ✅ 完成 | uni-icons 替换 emoji + 设计规范文件 |
|
| UI 改造 | ui-ux-pro-max 规范改造 | ✅ 完成 | uni-icons 替换 emoji + 设计规范文件 |
|
||||||
|
| 代码审查修复 | 后端 7 项修复 | ✅ 完成 | P0×2 + P1×3 + 推送补全×2 |
|
||||||
|
| 用户测试修复 | 8 项 Bug 修复 | ✅ 完成 | 好友申请/接受、在线状态、UI 布局等 |
|
||||||
|
|
||||||
|
### 代码审查修复详情
|
||||||
|
|
||||||
|
| # | 优先级 | 修复内容 |
|
||||||
|
|---|--------|----------|
|
||||||
|
| Fix 1 | P0 | ClearHistory 改为个人视图操作(ClearBeforeMsgID),不再删除双方消息 |
|
||||||
|
| Fix 2 | P0 | Redis 未读数负数保护(Lua 脚本原子递减,下限为 0) |
|
||||||
|
| Fix 3 | P1 | GetConversationList N+1 查询优化(LEFT JOIN 一次获取 peerID) |
|
||||||
|
| Fix 4 | P1 | 消息搜索改用 GIN 全文索引(to_tsvector/plainto_tsquery 替代 LIKE) |
|
||||||
|
| Fix 5 | P1 | 撤回消息后更新会话预览(last_msg_content = "XX 撤回了一条消息") |
|
||||||
|
| Fix 6 | - | im.message.new 推送补充 sender_name、sender_avatar |
|
||||||
|
| Fix 7 | - | im.message.recalled 推送补充 sender_id |
|
||||||
|
|
||||||
|
### 用户测试修复详情
|
||||||
|
|
||||||
|
| # | 修复内容 |
|
||||||
|
|---|----------|
|
||||||
|
| Fix 8 | 好友申请拒绝后重新申请失败 — FriendshipDAO 新增 ReactivateRejectedRequest 方法 |
|
||||||
|
| Fix 9 | 好友接受申请失败(反向记录 UNIQUE 冲突)— AcceptRequest 先查后改,避免重复插入 |
|
||||||
|
| Fix 10 | Redis 在线状态残留 — OnlineService 启动时清理旧在线数据(cleanStaleOnlineData) |
|
||||||
|
| Fix 11 | WS 断开时在线状态未清理 — 修正 onDisconnect 判断条件(closedByHub && isOnline) |
|
||||||
|
| Fix 12 | 前端 WS 连接未全局初始化 — App.vue onLaunch/onShow + login.vue 登录后建立连接 |
|
||||||
|
| Fix 13 | 后台管理端好友关系页用户 A 列名称错误 — 修正字段绑定 row.user_username |
|
||||||
|
| Fix 14 | 前台好友在线状态初始值缺失 — ContactService 注入 OnlineChecker,GetFriendList 返回 is_online |
|
||||||
|
| Fix 15 | 聊天页消息过多时输入框被挤出 — scroll-view 添加 height:0 + min-height:0 约束 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -37,9 +64,9 @@
|
|||||||
|
|
||||||
### 会话管理
|
### 会话管理
|
||||||
- **自动创建**:首次发消息时自动创建单聊会话
|
- **自动创建**:首次发消息时自动创建单聊会话
|
||||||
- **会话列表**:置顶优先 → 最后消息时间降序,冗余 last_msg_* 避免 JOIN
|
- **会话列表**:置顶优先 → 最后消息时间降序,LEFT JOIN 一次获取 peerID(N+1 优化)
|
||||||
- **会话操作**:置顶/取消、软删除(不影响对方)、清空聊天记录
|
- **会话操作**:置顶/取消、软删除(不影响对方)、清空聊天记录(个人视图 ClearBeforeMsgID)
|
||||||
- **未读管理**:DB unread_count + Redis 全局未读数,TabBar badge 显示
|
- **未读管理**:DB unread_count + Redis STRING 全局未读数(Lua 脚本负数保护),TabBar badge 显示
|
||||||
|
|
||||||
### WebSocket 事件路由表
|
### WebSocket 事件路由表
|
||||||
- **Hub.RegisterEvent**:业务模块注册事件处理器
|
- **Hub.RegisterEvent**:业务模块注册事件处理器
|
||||||
@@ -212,8 +239,9 @@ cd frontend && npm run dev:h5
|
|||||||
|
|
||||||
## 八、下一阶段规划
|
## 八、下一阶段规划
|
||||||
|
|
||||||
### Phase 2c - 群聊与增强
|
### Phase 2c - 群聊与增强(待规划)
|
||||||
- 群聊会话(建群/加入/退出)
|
- 群聊会话(建群/加入/退出/管理)
|
||||||
- 群消息收发
|
- 群消息收发
|
||||||
- 已读回执
|
- 已读回执(单聊 + 群聊)
|
||||||
- 消息类型扩展(图片/语音/文件)
|
- 消息类型扩展(图片/语音/文件)
|
||||||
|
- 管理端消息管理功能
|
||||||
|
|||||||
@@ -1,14 +1,44 @@
|
|||||||
<script>
|
<script>
|
||||||
|
/**
|
||||||
|
* 应用入口
|
||||||
|
*
|
||||||
|
* 全局生命周期:
|
||||||
|
* - onLaunch: 初始化 WebSocket 连接(如果用户已登录)和事件监听
|
||||||
|
* - onShow: 检查 WebSocket 连接状态并恢复
|
||||||
|
*/
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { useWebSocketStore } from '@/store/websocket'
|
||||||
|
import { useChatStore } from '@/store/chat'
|
||||||
|
import { useContactStore } from '@/store/contact'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
onLaunch: function () {
|
onLaunch() {
|
||||||
console.log('App Launch')
|
console.log('App Launch')
|
||||||
|
this._initGlobalWS()
|
||||||
},
|
},
|
||||||
onShow: function () {
|
onShow() {
|
||||||
console.log('App Show')
|
console.log('App Show')
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
if (userStore.isLoggedIn && !wsStore.isConnected) {
|
||||||
|
wsStore.connect()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onHide: function () {
|
onHide() {
|
||||||
console.log('App Hide')
|
console.log('App Hide')
|
||||||
},
|
},
|
||||||
|
methods: {
|
||||||
|
_initGlobalWS() {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
if (!userStore.isLoggedIn) return
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
wsStore.connect()
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
const contactStore = useContactStore()
|
||||||
|
chatStore.initWsListeners()
|
||||||
|
contactStore.initWsListeners()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,9 @@
|
|||||||
* 登录成功后 reLaunch 到首页
|
* 登录成功后 reLaunch 到首页
|
||||||
*/
|
*/
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { useWebSocketStore } from '@/store/websocket'
|
||||||
|
import { useChatStore } from '@/store/chat'
|
||||||
|
import { useContactStore } from '@/store/contact'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'LoginPage',
|
name: 'LoginPage',
|
||||||
@@ -152,6 +155,10 @@ export default {
|
|||||||
account: this.form.account.trim(),
|
account: this.form.account.trim(),
|
||||||
password: this.form.password
|
password: this.form.password
|
||||||
})
|
})
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
wsStore.connect()
|
||||||
|
useChatStore().initWsListeners()
|
||||||
|
useContactStore().initWsListeners()
|
||||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||||
setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 800)
|
setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 800)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -222,8 +222,13 @@ export default {
|
|||||||
finally { loadingMore.value = false }
|
finally { loadingMore.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canRecall = (msg) => {
|
||||||
|
if (!msg.created_at) return false
|
||||||
|
return (Date.now() - new Date(msg.created_at).getTime()) < 2 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
const onMsgLongPress = (msg) => {
|
const onMsgLongPress = (msg) => {
|
||||||
if (!isSelf(msg) || msg.status === 2) return
|
if (!isSelf(msg) || msg.status === 2 || !canRecall(msg)) return
|
||||||
uni.showActionSheet({
|
uni.showActionSheet({
|
||||||
itemList: ['撤回'],
|
itemList: ['撤回'],
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
@@ -304,9 +309,12 @@ export default {
|
|||||||
/* ===== 消息列表 ===== */
|
/* ===== 消息列表 ===== */
|
||||||
.msg-list {
|
.msg-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
min-height: 0;
|
||||||
padding: 16rpx 24rpx;
|
padding: 16rpx 24rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.load-more { text-align: center; padding: 16rpx 0; }
|
.load-more { text-align: center; padding: 16rpx 0; }
|
||||||
.load-more-text { font-size: 24rpx; color: #94A3B8; }
|
.load-more-text { font-size: 24rpx; color: #94A3B8; }
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
focus
|
focus
|
||||||
@confirm="onSearch"
|
@confirm="onSearch"
|
||||||
/>
|
/>
|
||||||
|
<view v-if="keyword" class="search-clear" @tap="keyword = ''; groups = []; searched = false">
|
||||||
|
<uni-icons type="clear" size="18" color="#94A3B8" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="search-cancel" @tap="goBack">
|
<view class="search-cancel" @tap="goBack">
|
||||||
<text class="search-cancel-text">取消</text>
|
<text class="search-cancel-text">取消</text>
|
||||||
@@ -29,28 +32,35 @@
|
|||||||
|
|
||||||
<!-- 搜索结果 -->
|
<!-- 搜索结果 -->
|
||||||
<scroll-view scroll-y class="result-list">
|
<scroll-view scroll-y class="result-list">
|
||||||
<!-- 空状态 -->
|
<view v-if="searching" class="status-state">
|
||||||
<view v-if="searched && results.length === 0" class="empty-state">
|
<text class="status-text">搜索中...</text>
|
||||||
<text class="empty-text">未找到相关消息</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 结果列表 -->
|
<view v-else-if="searched && groups.length === 0" class="status-state">
|
||||||
|
<text class="status-text">未找到相关消息</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 按会话分组展示 -->
|
||||||
|
<view v-for="group in groups" :key="group.conversation_id" class="result-group">
|
||||||
|
<view class="group-header" @tap="openConversation(group)">
|
||||||
|
<view class="group-avatar-wrap">
|
||||||
|
<image v-if="group.peer_avatar" class="group-avatar" :src="group.peer_avatar" mode="aspectFill" />
|
||||||
|
<view v-else class="group-avatar group-avatar-placeholder">
|
||||||
|
<text class="avatar-text">{{ (group.peer_name || '?')[0] }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="group-name">{{ group.peer_name || '未知用户' }}</text>
|
||||||
|
<text class="group-count">{{ group.messages.length }} 条相关</text>
|
||||||
|
</view>
|
||||||
<view
|
<view
|
||||||
v-for="item in results"
|
v-for="msg in group.messages"
|
||||||
:key="item.id"
|
:key="msg.id"
|
||||||
class="result-item"
|
class="result-item"
|
||||||
@tap="openChat(item)"
|
@tap="openConversation(group)"
|
||||||
>
|
>
|
||||||
<view class="result-avatar-wrap">
|
<text class="result-sender">{{ msg.sender_nickname || '未知' }}:</text>
|
||||||
<image v-if="item.sender_avatar" class="result-avatar" :src="item.sender_avatar" mode="aspectFill" />
|
<text class="result-content">{{ msg.content }}</text>
|
||||||
<view v-else class="result-avatar result-avatar-placeholder">
|
<text class="result-time">{{ msg.created_at }}</text>
|
||||||
<text class="avatar-text">{{ (item.sender_nickname || '?')[0] }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="result-info">
|
|
||||||
<text class="result-name">{{ item.sender_nickname || '未知用户' }}</text>
|
|
||||||
<text class="result-content">{{ item.content }}</text>
|
|
||||||
<text class="result-time">{{ item.created_at }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
@@ -58,32 +68,66 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import imApi from '@/api/im'
|
import imApi from '@/api/im'
|
||||||
|
import { useChatStore } from '@/store/chat'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ChatSearch',
|
name: 'ChatSearch',
|
||||||
setup() {
|
setup() {
|
||||||
|
const chatStore = useChatStore()
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const results = ref([])
|
const groups = ref([])
|
||||||
const searched = ref(false)
|
const searched = ref(false)
|
||||||
|
const searching = ref(false)
|
||||||
|
|
||||||
|
let debounceTimer = null
|
||||||
|
watch(keyword, (val) => {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
if (!val.trim()) { groups.value = []; searched.value = false; return }
|
||||||
|
debounceTimer = setTimeout(() => onSearch(), 300)
|
||||||
|
})
|
||||||
|
|
||||||
const onSearch = async () => {
|
const onSearch = async () => {
|
||||||
const kw = keyword.value.trim()
|
const kw = keyword.value.trim()
|
||||||
if (!kw) return
|
if (!kw) return
|
||||||
|
|
||||||
|
searching.value = true
|
||||||
searched.value = true
|
searched.value = true
|
||||||
try {
|
try {
|
||||||
const res = await imApi.searchMessages(kw)
|
const res = await imApi.searchMessages(kw)
|
||||||
results.value = (res.data && res.data.list) || []
|
const list = (res.data && res.data.list) || []
|
||||||
|
groups.value = _groupByConversation(list)
|
||||||
} catch {
|
} catch {
|
||||||
results.value = []
|
groups.value = []
|
||||||
|
} finally {
|
||||||
|
searching.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openChat = (item) => {
|
/** 将扁平消息列表按 conversation_id 分组,附加会话的 peer 信息 */
|
||||||
|
const _groupByConversation = (messages) => {
|
||||||
|
const map = {}
|
||||||
|
for (const msg of messages) {
|
||||||
|
const convId = msg.conversation_id
|
||||||
|
if (!map[convId]) {
|
||||||
|
const conv = chatStore.conversationList.find(c => c.id === convId)
|
||||||
|
map[convId] = {
|
||||||
|
conversation_id: convId,
|
||||||
|
peer_name: conv?.peer_nickname || msg.sender_nickname || '未知',
|
||||||
|
peer_avatar: conv?.peer_avatar || msg.sender_avatar || '',
|
||||||
|
peer_id: conv?.peer_user_id || 0,
|
||||||
|
messages: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map[convId].messages.push(msg)
|
||||||
|
}
|
||||||
|
return Object.values(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openConversation = (group) => {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/chat/conversation?conversationId=${item.conversation_id}&peerId=${item.sender_id}&peerName=${encodeURIComponent(item.sender_nickname || '')}&peerAvatar=${encodeURIComponent(item.sender_avatar || '')}`
|
url: `/pages/chat/conversation?conversationId=${group.conversation_id}&peerId=${group.peer_id}&peerName=${encodeURIComponent(group.peer_name)}&peerAvatar=${encodeURIComponent(group.peer_avatar)}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,14 +135,7 @@ export default {
|
|||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { keyword, groups, searched, searching, onSearch, openConversation, goBack }
|
||||||
keyword,
|
|
||||||
results,
|
|
||||||
searched,
|
|
||||||
onSearch,
|
|
||||||
openChat,
|
|
||||||
goBack
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -127,12 +164,8 @@ export default {
|
|||||||
height: 68rpx;
|
height: 68rpx;
|
||||||
gap: 12rpx;
|
gap: 12rpx;
|
||||||
}
|
}
|
||||||
|
.search-input { flex: 1; font-size: 28rpx; color: #1E293B; }
|
||||||
.search-input {
|
.search-clear { min-width: 44rpx; min-height: 44rpx; display: flex; align-items: center; justify-content: center; }
|
||||||
flex: 1;
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #1E293B;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-cancel {
|
.search-cancel {
|
||||||
margin-left: 20rpx;
|
margin-left: 20rpx;
|
||||||
@@ -142,92 +175,42 @@ export default {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
.search-cancel-text { font-size: 28rpx; color: #2563EB; }
|
||||||
|
.search-cancel:active { opacity: 0.6; }
|
||||||
|
|
||||||
.search-cancel-text {
|
.result-list { height: calc(100vh - 100rpx); }
|
||||||
font-size: 28rpx;
|
|
||||||
color: #2563EB;
|
/* 分组 */
|
||||||
}
|
.result-group { margin-bottom: 16rpx; }
|
||||||
|
.group-header {
|
||||||
.search-cancel:active {
|
display: flex;
|
||||||
opacity: 0.6;
|
align-items: center;
|
||||||
}
|
padding: 20rpx 32rpx;
|
||||||
|
background-color: #FFFFFF;
|
||||||
.result-list {
|
border-bottom: 1rpx solid #E2E8F0;
|
||||||
height: calc(100vh - 100rpx);
|
|
||||||
}
|
}
|
||||||
|
.group-header:active { background-color: #F1F5F9; }
|
||||||
|
.group-avatar-wrap { flex-shrink: 0; margin-right: 16rpx; }
|
||||||
|
.group-avatar { width: 64rpx; height: 64rpx; border-radius: 16rpx; }
|
||||||
|
.group-avatar-placeholder { background-color: #2563EB; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.avatar-text { color: #FFFFFF; font-size: 26rpx; font-weight: 600; }
|
||||||
|
.group-name { flex: 1; font-size: 30rpx; font-weight: 500; color: #1E293B; }
|
||||||
|
.group-count { font-size: 24rpx; color: #94A3B8; }
|
||||||
|
|
||||||
|
/* 消息条目 */
|
||||||
.result-item {
|
.result-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 24rpx 32rpx;
|
padding: 16rpx 32rpx 16rpx 112rpx;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border-bottom: 1rpx solid #F1F5F9;
|
border-bottom: 1rpx solid #F8FAFC;
|
||||||
transition: background-color 150ms ease;
|
|
||||||
}
|
}
|
||||||
|
.result-item:active { background-color: #F1F5F9; }
|
||||||
|
.result-sender { font-size: 26rpx; color: #64748B; flex-shrink: 0; }
|
||||||
|
.result-content { flex: 1; font-size: 26rpx; color: #1E293B; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.result-time { font-size: 22rpx; color: #94A3B8; margin-left: 12rpx; flex-shrink: 0; }
|
||||||
|
|
||||||
.result-item:active {
|
/* 状态 */
|
||||||
background-color: #F1F5F9;
|
.status-state { display: flex; align-items: center; justify-content: center; padding-top: 200rpx; }
|
||||||
}
|
.status-text { font-size: 28rpx; color: #94A3B8; }
|
||||||
|
|
||||||
.result-avatar-wrap {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-avatar {
|
|
||||||
width: 80rpx;
|
|
||||||
height: 80rpx;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-avatar-placeholder {
|
|
||||||
background-color: #2563EB;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-text {
|
|
||||||
color: #FFFFFF;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-info {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-name {
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1E293B;
|
|
||||||
margin-bottom: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #64748B;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-bottom: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-time {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #94A3B8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 200rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #94A3B8;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -67,9 +67,13 @@ export default {
|
|||||||
|
|
||||||
const togglePin = async () => {
|
const togglePin = async () => {
|
||||||
const newVal = !isPinned.value
|
const newVal = !isPinned.value
|
||||||
|
try {
|
||||||
await chatStore.pinConversation(conversationId.value, newVal)
|
await chatStore.pinConversation(conversationId.value, newVal)
|
||||||
isPinned.value = newVal
|
isPinned.value = newVal
|
||||||
uni.showToast({ title: newVal ? '已置顶' : '已取消置顶', icon: 'none' })
|
uni.showToast({ title: newVal ? '已置顶' : '已取消置顶', icon: 'none' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClearHistory = () => {
|
const onClearHistory = () => {
|
||||||
@@ -78,8 +82,12 @@ export default {
|
|||||||
content: '确定清空聊天记录?此操作不可恢复',
|
content: '确定清空聊天记录?此操作不可恢复',
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
await chatStore.clearHistory(conversationId.value)
|
await chatStore.clearHistory(conversationId.value)
|
||||||
uni.showToast({ title: '已清空', icon: 'none' })
|
uni.showToast({ title: '已清空', icon: 'none' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '清空失败', icon: 'none' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -91,8 +99,12 @@ export default {
|
|||||||
content: '确定删除该会话?',
|
content: '确定删除该会话?',
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
await chatStore.deleteConversation(conversationId.value)
|
await chatStore.deleteConversation(conversationId.value)
|
||||||
uni.navigateBack({ delta: 2 })
|
uni.navigateBack({ delta: 2 })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ const handleAccept = async (requestId) => {
|
|||||||
uni.showToast({ title: '已接受', icon: 'success' })
|
uni.showToast({ title: '已接受', icon: 'success' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('接受好友申请失败', e)
|
console.error('接受好友申请失败', e)
|
||||||
|
uni.showToast({ title: e?.data?.message || '接受申请失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
processingMap[requestId] = false
|
processingMap[requestId] = false
|
||||||
}
|
}
|
||||||
@@ -110,6 +111,7 @@ const handleReject = async (requestId) => {
|
|||||||
uni.showToast({ title: '已拒绝', icon: 'none' })
|
uni.showToast({ title: '已拒绝', icon: 'none' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('拒绝好友申请失败', e)
|
console.error('拒绝好友申请失败', e)
|
||||||
|
uni.showToast({ title: e?.data?.message || '拒绝申请失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
processingMap[requestId] = false
|
processingMap[requestId] = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<view v-else-if="searchResults.length > 0" class="result-list">
|
<view v-else-if="searchResults.length > 0" class="result-list">
|
||||||
<view
|
<view
|
||||||
v-for="user in searchResults"
|
v-for="user in searchResults"
|
||||||
:key="user.user_id"
|
:key="user.id"
|
||||||
class="user-item"
|
class="user-item"
|
||||||
>
|
>
|
||||||
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
|
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
|
||||||
@@ -53,10 +53,10 @@
|
|||||||
<view
|
<view
|
||||||
v-else
|
v-else
|
||||||
class="add-btn"
|
class="add-btn"
|
||||||
:class="{ 'add-btn--disabled': addingMap[user.user_id] }"
|
:class="{ 'add-btn--disabled': addingMap[user.id] }"
|
||||||
@tap="showAddDialog(user)"
|
@tap="showAddDialog(user)"
|
||||||
>
|
>
|
||||||
<text class="add-btn-text">{{ addingMap[user.user_id] ? '已发送' : '添加' }}</text>
|
<text class="add-btn-text">{{ addingMap[user.id] ? '已发送' : '添加' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
<view v-else-if="recommendations.length > 0" class="result-list">
|
<view v-else-if="recommendations.length > 0" class="result-list">
|
||||||
<view
|
<view
|
||||||
v-for="user in recommendations"
|
v-for="user in recommendations"
|
||||||
:key="user.user_id"
|
:key="user.id"
|
||||||
class="user-item"
|
class="user-item"
|
||||||
>
|
>
|
||||||
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
|
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
|
||||||
@@ -91,10 +91,10 @@
|
|||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
class="add-btn"
|
class="add-btn"
|
||||||
:class="{ 'add-btn--disabled': addingMap[user.user_id] }"
|
:class="{ 'add-btn--disabled': addingMap[user.id] }"
|
||||||
@tap="showAddDialog(user)"
|
@tap="showAddDialog(user)"
|
||||||
>
|
>
|
||||||
<text class="add-btn-text">{{ addingMap[user.user_id] ? '已发送' : '添加' }}</text>
|
<text class="add-btn-text">{{ addingMap[user.id] ? '已发送' : '添加' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -141,7 +141,7 @@ const doSearch = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showAddDialog = (user) => {
|
const showAddDialog = (user) => {
|
||||||
if (addingMap[user.user_id]) return
|
if (addingMap[user.id]) return
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '添加好友',
|
title: '添加好友',
|
||||||
editable: true,
|
editable: true,
|
||||||
@@ -149,13 +149,17 @@ const showAddDialog = (user) => {
|
|||||||
content: '',
|
content: '',
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
addingMap[user.user_id] = true
|
addingMap[user.id] = true
|
||||||
try {
|
try {
|
||||||
await contactStore.sendRequest(user.user_id, res.content || '')
|
await contactStore.sendRequest(user.id, res.content || '')
|
||||||
uni.showToast({ title: '申请已发送', icon: 'success' })
|
uni.showToast({ title: '申请已发送', icon: 'success' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('发送好友申请失败', e)
|
console.error('发送好友申请失败', e)
|
||||||
addingMap[user.user_id] = false
|
addingMap[user.id] = false
|
||||||
|
uni.showToast({
|
||||||
|
title: e?.data?.message || '发送申请失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
// ==================== Computed ====================
|
// ==================== Computed ====================
|
||||||
|
|
||||||
/** 当前会话的消息列表 */
|
/** 当前会话的消息列表(conversationId 为 null 时取 pending 队列) */
|
||||||
const currentMessages = computed(() => {
|
const currentMessages = computed(() => {
|
||||||
if (!currentConversationId.value) return []
|
if (!currentConversationId.value) {
|
||||||
|
return messagesMap.value['pending'] || []
|
||||||
|
}
|
||||||
return messagesMap.value[currentConversationId.value] || []
|
return messagesMap.value[currentConversationId.value] || []
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -98,9 +100,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
_sending: true
|
_sending: true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conversationId) {
|
const displayConvId = conversationId || 'pending'
|
||||||
_appendMessage(conversationId, tempMsg)
|
_appendMessage(displayConvId, tempMsg)
|
||||||
}
|
|
||||||
|
|
||||||
pendingMessages.value[clientMsgId] = { status: 'sending', tempMsg }
|
pendingMessages.value[clientMsgId] = { status: 'sending', tempMsg }
|
||||||
|
|
||||||
@@ -226,12 +227,16 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
created_at: data.created_at
|
created_at: data.created_at
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const existingConv = conversationList.value.find(c => c.id === data.conversation_id)
|
||||||
|
if (existingConv) {
|
||||||
_updateConversationPreview(data.conversation_id, data.content, data.created_at, data.sender_id)
|
_updateConversationPreview(data.conversation_id, data.content, data.created_at, data.sender_id)
|
||||||
|
} else {
|
||||||
|
fetchConversations()
|
||||||
|
}
|
||||||
|
|
||||||
if (data.conversation_id !== currentConversationId.value) {
|
if (data.conversation_id !== currentConversationId.value) {
|
||||||
const conv = conversationList.value.find(c => c.id === data.conversation_id)
|
if (existingConv) {
|
||||||
if (conv) {
|
existingConv.unread_count = (existingConv.unread_count || 0) + 1
|
||||||
conv.unread_count = (conv.unread_count || 0) + 1
|
|
||||||
}
|
}
|
||||||
totalUnread.value++
|
totalUnread.value++
|
||||||
} else {
|
} else {
|
||||||
@@ -268,6 +273,12 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
if (pending.tempMsg && pending.tempMsg.conversation_id === 0 && convId) {
|
if (pending.tempMsg && pending.tempMsg.conversation_id === 0 && convId) {
|
||||||
currentConversationId.value = convId
|
currentConversationId.value = convId
|
||||||
|
const pendingMsgs = messagesMap.value['pending'] || []
|
||||||
|
if (pendingMsgs.length > 0) {
|
||||||
|
if (!messagesMap.value[convId]) messagesMap.value[convId] = []
|
||||||
|
messagesMap.value[convId].push(...pendingMsgs)
|
||||||
|
delete messagesMap.value['pending']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = messagesMap.value[convId]
|
const messages = messagesMap.value[convId]
|
||||||
@@ -312,12 +323,19 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
// ==================== 内部工具方法 ====================
|
// ==================== 内部工具方法 ====================
|
||||||
|
|
||||||
/** 追加消息到缓存 */
|
/** 追加消息到缓存(带去重检查,防止重连/ACK 导致重复) */
|
||||||
const _appendMessage = (conversationId, message) => {
|
const _appendMessage = (conversationId, message) => {
|
||||||
if (!messagesMap.value[conversationId]) {
|
if (!messagesMap.value[conversationId]) {
|
||||||
messagesMap.value[conversationId] = []
|
messagesMap.value[conversationId] = []
|
||||||
}
|
}
|
||||||
messagesMap.value[conversationId].push(message)
|
const list = messagesMap.value[conversationId]
|
||||||
|
const isDup = list.some(m =>
|
||||||
|
(message.id && m.id === message.id) ||
|
||||||
|
(message.client_msg_id && m.client_msg_id === message.client_msg_id)
|
||||||
|
)
|
||||||
|
if (!isDup) {
|
||||||
|
list.push(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新会话列表中的预览信息 */
|
/** 更新会话列表中的预览信息 */
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import authApi from '@/api/auth'
|
import authApi from '@/api/auth'
|
||||||
|
import { useWebSocketStore } from '@/store/websocket'
|
||||||
import {
|
import {
|
||||||
saveToken,
|
saveToken,
|
||||||
getToken,
|
getToken,
|
||||||
@@ -112,10 +113,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
* 3. 重置 Store 状态
|
* 3. 重置 Store 状态
|
||||||
*/
|
*/
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
wsStore.disconnect()
|
||||||
try {
|
try {
|
||||||
await authApi.logout()
|
await authApi.logout()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 即使后端请求失败,也要清除本地状态(避免用户被卡住)
|
|
||||||
console.warn('登出 API 调用失败,仍然清除本地状态', e)
|
console.warn('登出 API 调用失败,仍然清除本地状态', e)
|
||||||
}
|
}
|
||||||
token.value = ''
|
token.value = ''
|
||||||
|
|||||||
Reference in New Issue
Block a user