diff --git a/IM_API_Collection.postman_collection.json b/IM_API_Collection.postman_collection.json index 0597276..a0c1421 100644 --- a/IM_API_Collection.postman_collection.json +++ b/IM_API_Collection.postman_collection.json @@ -38,7 +38,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"account\": \"user1@example.com\",\n \"password\": \"12345678\",\n \"remember\": true\n}" + "raw": "{\n \"account\": \"workerqi@163.com\",\n \"password\": \"12345678\",\n \"remember\": true\n}" }, "url": { "raw": "{{base_url}}/api/login", @@ -52,9 +52,13 @@ "script": { "exec": [ "if (pm.response.code === 200) {", - " var jsonData = pm.response.json();", - " pm.collectionVariables.set(\"token\", jsonData.token);", - " pm.collectionVariables.set(\"user_id\", jsonData.user.id);", + " const body = pm.response.json();", + " if (body.code === 0 && body.result && body.result.token) {", + " pm.environment.set(\"token\", body.result.token);", + " if (body.result.user && body.result.user.id) {", + " pm.environment.set(\"user_id\", body.result.user.id);", + " }", + " }", "}" ] } @@ -143,6 +147,103 @@ } ] }, + { + "name": "会话管理", + "item": [ + { + "name": "获取会话列表", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/conversations", + "host": ["{{base_url}}"], + "path": ["api", "conversations"] + } + } + }, + { + "name": "重置会话未读数", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"target_id\": \"10002\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/conversations/reset-unread", + "host": ["{{base_url}}"], + "path": ["api", "conversations", "reset-unread"] + } + } + }, + { + "name": "更新会话标记", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"target_id\": \"10002\",\n \"is_top\": true,\n \"is_muted\": false,\n \"is_special_care\": true\n}" + }, + "url": { + "raw": "{{base_url}}/api/conversations/update", + "host": ["{{base_url}}"], + "path": ["api", "conversations", "update"] + } + } + }, + { + "name": "删除会话", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"target_id\": \"10002\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/conversations/delete", + "host": ["{{base_url}}"], + "path": ["api", "conversations", "delete"] + } + } + } + ] + }, { "name": "用户管理", "item": [ diff --git a/cmd/server/main.go b/cmd/server/main.go index 463ed41..f562322 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -95,6 +95,7 @@ func initDB() *gorm.DB { &model.UserContact{}, &model.ContactGroup{}, &model.ChatRoom{}, + &model.ChatConversation{}, &model.FriendRequest{}, &model.VerificationCode{}, &model.Attachment{}, @@ -113,6 +114,7 @@ func initDB() *gorm.DB { {"chat_messages", "聊天消息表,持久化存储聊天记录,包括文本、多媒体和信令状态"}, {"users", "用户基本信息表"}, {"user_contacts", "用户联系人表,存储好友关系、分组、备注等信息"}, + {"chat_conversations", "用户会话表,记录每个用户的最近会话列表和未读信息"}, {"contact_groups", "联系人分组表"}, {"chat_rooms", "聊天房间表,支持点对点和群聊"}, {"friend_requests", "好友申请表"}, @@ -205,13 +207,14 @@ func main() { } // 步骤4: 初始化所有业务服务 - service.InitChatService(db, rdb) // 聊天服务(消息处理、WebSocket分发) - service.InitAuthService(db, rdb) // 认证服务(登录、注册、验证码) - service.InitUserService(db) // 用户服务(用户信息管理) - service.InitContactService(db) // 联系人服务(好友管理、分组管理) - service.InitRoomService(db) // 房间服务(聊天房间管理) - service.InitAttachmentService(db) // 附件服务(文件上传、管理) - service.InitLoginLogService(db) // 登录日志服务(记录登录历史) + service.InitChatService(db, rdb) // 聊天服务(消息处理、WebSocket分发) + service.InitAuthService(db, rdb) // 认证服务(登录、注册、验证码) + service.InitUserService(db) // 用户服务(用户信息管理) + service.InitContactService(db) // 联系人服务(好友管理、分组管理) + service.InitConversationService(db) // 会话服务(最近聊天列表) + service.InitRoomService(db) // 房间服务(聊天房间管理) + service.InitAttachmentService(db) // 附件服务(文件上传、管理) + service.InitLoginLogService(db) // 登录日志服务(记录登录历史) // 步骤5: 启动TURN服务器(用于WebRTC音视频通话) go turnserver.Start() @@ -339,6 +342,12 @@ func main() { authGroup.POST("/contacts/update/:id", api.UpdateContactHandler) authGroup.POST("/contacts/delete/:id", api.DeleteContactHandler) + // 会话管理 + authGroup.GET("/conversations", api.GetConversationListHandler) + authGroup.POST("/conversations/reset-unread", api.ResetConversationUnreadHandler) + authGroup.POST("/conversations/update", api.UpdateConversationHandler) + authGroup.POST("/conversations/delete", api.DeleteConversationHandler) + // 房间管理 authGroup.POST("/rooms", api.CreateRoomHandler) authGroup.GET("/rooms/:id", api.GetRoomHandler) diff --git a/internal/api/conversation_handler.go b/internal/api/conversation_handler.go new file mode 100644 index 0000000..9d4175b --- /dev/null +++ b/internal/api/conversation_handler.go @@ -0,0 +1,122 @@ +/** + * package api + * 作用:会话列表(最近聊天)相关 API 处理器 + */ +package api + +import ( + "xk-websocket-v2/internal/service" + "xk-websocket-v2/internal/utils" + + "github.com/gin-gonic/gin" +) + +/** + * GetConversationListHandler + * 功能:获取当前用户的会话列表 + * 路径:GET /api/conversations + */ +func GetConversationListHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + list, err := service.ConversationSvc.GetConversations(userID.(string)) + if err != nil { + utils.InternalError(c, "查询失败") + return + } + + utils.SuccessWithData(c, list, "获取成功") +} + +/** + * ResetConversationUnreadHandler + * 功能:重置会话未读数 + * 路径:POST /api/conversations/reset-unread + */ +func ResetConversationUnreadHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + var req struct { + TargetID string `json:"target_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if err := service.ConversationSvc.ResetUnread(userID.(string), req.TargetID); err != nil { + utils.InternalError(c, "操作失败") + return + } + + utils.Success(c, "已重置未读") +} + +/** + * UpdateConversationHandler + * 功能:更新会话标记(置顶、免打扰、特别关心) + * 路径:POST /api/conversations/update + */ +func UpdateConversationHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + var req struct { + TargetID string `json:"target_id" binding:"required"` + IsTop *bool `json:"is_top"` + IsMuted *bool `json:"is_muted"` + IsSpecialCare *bool `json:"is_special_care"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + updates := make(map[string]interface{}) + if req.IsTop != nil { + updates["is_top"] = *req.IsTop + } + if req.IsMuted != nil { + updates["is_muted"] = *req.IsMuted + } + if req.IsSpecialCare != nil { + updates["is_special_care"] = *req.IsSpecialCare + } + + if len(updates) == 0 { + utils.BadRequest(c, "没有可更新的字段") + return + } + + if err := service.ConversationSvc.UpdateConversationFlags(userID.(string), req.TargetID, updates); err != nil { + utils.InternalError(c, "更新失败") + return + } + + utils.Success(c, "更新成功") +} + +/** + * DeleteConversationHandler + * 功能:删除会话 + * 路径:POST /api/conversations/delete + */ +func DeleteConversationHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + var req struct { + TargetID string `json:"target_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if err := service.ConversationSvc.DeleteConversation(userID.(string), req.TargetID); err != nil { + utils.InternalError(c, "删除失败") + return + } + + utils.Success(c, "删除成功") +} + + diff --git a/internal/model/types.go b/internal/model/types.go index 9bbe722..4d823ba 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -183,6 +183,10 @@ type UserContact struct { IsTop bool `gorm:"type:tinyint(1);default:0;comment:是否置顶" json:"is_top"` // 是否免打扰 IsMuted bool `gorm:"type:tinyint(1);default:0;comment:是否免打扰" json:"is_muted"` + // 是否特别关心(高优先级提醒,类似 QQ 特别关心) + IsSpecialCare bool `gorm:"type:tinyint(1);default:0;comment:是否特别关心" json:"is_special_care"` + // 是否拉黑(加入黑名单,不再接收消息/出现在默认列表中) + IsBlocked bool `gorm:"type:tinyint(1);default:0;comment:是否拉黑" json:"is_blocked"` // 最后聊天时间 LastChatTime *time.Time `gorm:"type:datetime;comment:最后聊天时间" json:"last_chat_time"` // 最后一条消息 @@ -380,6 +384,45 @@ func (ApiRequestLog) TableName() string { return "api_request_logs" } +/** + * ChatConversation + * 对应数据库表:chat_conversations + * 作用:用户级别的最近会话列表,支持置顶、免打扰和未读计数等功能 + */ +type ChatConversation struct { + // 主键ID + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + // 所属用户ID(当前登录用户) + UserID string `gorm:"type:varchar(100);index;comment:用户ID" json:"user_id"` + // 目标ID:好友ID或群ID + TargetID string `gorm:"type:varchar(100);index;comment:目标ID(好友或群)" json:"target_id"` + // 房间ID:与 chat_messages / chat_rooms 中的 room_id 一致 + RoomID string `gorm:"type:varchar(100);index;comment:房间ID" json:"room_id"` + // 会话类型:1-私聊 2-群聊 + Type int `gorm:"type:int;comment:会话类型(1=私聊,2=群聊)" json:"type"` + // 是否置顶 + IsTop bool `gorm:"type:tinyint(1);default:0;comment:是否置顶" json:"is_top"` + // 是否免打扰 + IsMuted bool `gorm:"type:tinyint(1);default:0;comment:是否免打扰" json:"is_muted"` + // 是否特别关心(用于在前端高亮显示) + IsSpecialCare bool `gorm:"type:tinyint(1);default:0;comment:是否特别关心" json:"is_special_care"` + // 未读消息数 + UnreadCount int `gorm:"type:int;default:0;comment:未读消息数" json:"unread_count"` + // 最后一条消息内容(用于列表摘要) + LastMessage string `gorm:"type:varchar(500);comment:最后一条消息内容" json:"last_message"` + // 最后消息时间 + LastTime time.Time `gorm:"type:datetime;index;comment:最后消息时间" json:"last_time"` + // 创建时间 + CreatedAt time.Time `gorm:"autoCreateTime;comment:创建时间" json:"created_at"` + // 更新时间 + UpdatedAt time.Time `gorm:"autoUpdateTime;comment:更新时间" json:"updated_at"` +} + +// TableName 指定表名和注释 +func (ChatConversation) TableName() string { + return "chat_conversations" +} + /** * LoginLog * 对应数据库表:login_logs diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 60ede06..181fd4d 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -141,7 +141,22 @@ func (s *ChatService) HandleUserMessage(senderClient *manager.Client, req *model log.Printf("❌ 消息持久化失败: %v", err) } - // 4. 构建推送消息体 (DTO) + // 4. 更新会话列表(发送方与接收方) + if ConversationSvc != nil { + // 当前实现仅支持点对点私聊 + senderID := senderClient.UserID + receiverID := req.ReceiverUserID + + // 对发送方更新会话(不增加未读) + if senderID != "" && receiverID != "" { + // sender 视角 target 为 receiver + _ = ConversationSvc.UpsertConversationOnMessage(senderID, receiverID, req.RoomID, msg, true) + // receiver 视角 target 为 sender + _ = ConversationSvc.UpsertConversationOnMessage(receiverID, senderID, req.RoomID, msg, false) + } + } + + // 5. 构建推送消息体 (DTO) // 将持久化后的完整对象推给前端 pushMsg := model.WsPayload{ RequestType: "receive_message", diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go new file mode 100644 index 0000000..1cff096 --- /dev/null +++ b/internal/service/conversation_service.go @@ -0,0 +1,149 @@ +/** + * package service + * 作用:会话列表(最近聊天)管理服务 + */ +package service + +import ( + "time" + + "xk-websocket-v2/internal/model" + + "gorm.io/gorm" +) + +// ConversationService 会话服务结构体 +type ConversationService struct { + DB *gorm.DB +} + +// ConversationSvc 全局单例 +var ConversationSvc *ConversationService + +// InitConversationService 初始化会话服务 +func InitConversationService(db *gorm.DB) { + ConversationSvc = &ConversationService{DB: db} +} + +/** + * GetConversations + * 功能:获取用户的最近会话列表 + */ +func (s *ConversationService) GetConversations(userID string) ([]model.ChatConversation, error) { + var list []model.ChatConversation + err := s.DB.Where("user_id = ?", userID). + Order("is_top DESC, last_time DESC, id DESC"). + Find(&list).Error + return list, err +} + +/** + * UpsertConversationOnMessage + * 功能:在发送/接收消息时更新会话记录 + * @param userID 会话所属用户 + * @param targetID 好友ID或群ID + * @param roomID 房间ID + * @param msg 已持久化的消息 + * @param isSender 是否为发送方(发送方通常不增加未读数) + */ +func (s *ConversationService) UpsertConversationOnMessage(userID, targetID, roomID string, msg model.ChatMessage, isSender bool) error { + if s == nil { + return nil + } + + var conv model.ChatConversation + tx := s.DB.Where("user_id = ? AND target_id = ?", userID, targetID).First(&conv) + now := time.Now() + + // 计算摘要 + summary := buildMessageSummary(msg) + + if tx.Error != nil { + if tx.Error == gorm.ErrRecordNotFound { + // 新建会话 + conv = model.ChatConversation{ + UserID: userID, + TargetID: targetID, + RoomID: roomID, + Type: 1, // 目前只支持私聊,后续可扩展群聊 + LastMessage: summary, + LastTime: now, + } + // 接收方增加未读 + if !isSender { + conv.UnreadCount = 1 + } + return s.DB.Create(&conv).Error + } + return tx.Error + } + + // 已存在会话则更新 + updates := map[string]interface{}{ + "room_id": roomID, + "last_message": summary, + "last_time": now, + } + if !isSender { + updates["unread_count"] = conv.UnreadCount + 1 + } + + return s.DB.Model(&model.ChatConversation{}). + Where("id = ?", conv.ID). + Updates(updates).Error +} + +/** + * ResetUnread + * 功能:清空某个目标的未读消息数 + */ +func (s *ConversationService) ResetUnread(userID, targetID string) error { + return s.DB.Model(&model.ChatConversation{}). + Where("user_id = ? AND target_id = ?", userID, targetID). + Update("unread_count", 0).Error +} + +/** + * UpdateConversationFlags + * 功能:更新会话标记(置顶、免打扰、特别关心等) + */ +func (s *ConversationService) UpdateConversationFlags(userID, targetID string, updates map[string]interface{}) error { + return s.DB.Model(&model.ChatConversation{}). + Where("user_id = ? AND target_id = ?", userID, targetID). + Updates(updates).Error +} + +/** + * DeleteConversation + * 功能:删除单个会话记录(不删除聊天记录) + */ +func (s *ConversationService) DeleteConversation(userID, targetID string) error { + return s.DB.Where("user_id = ? AND target_id = ?", userID, targetID). + Delete(&model.ChatConversation{}).Error +} + +// buildMessageSummary 根据消息类型构建会话摘要 +func buildMessageSummary(msg model.ChatMessage) string { + switch msg.MessageType { + case 1: + return "[图片]" + case 2: + return "[语音]" + case 3: + return "[视频]" + case 6, 7: + return "[通话]" + case 8: + return "[文件]" + default: + if msg.Content == "" { + return "[空消息]" + } + if len([]rune(msg.Content)) > 50 { + return string([]rune(msg.Content)[:50]) + "..." + } + return msg.Content + } +} + +