From 2c7be494f281a6e5d0a24c9b776c01c70c3517d3 Mon Sep 17 00:00:00 2001 From: bujinyuan Date: Tue, 3 Mar 2026 10:51:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(im):=20Task=205=20-=20IM=20REST=20Controll?= =?UTF-8?q?er=20+=20Router=20+=20Wire=20=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IMController (REST API): - GET /api/v1/im/conversations: 会话列表 - GET /api/v1/im/messages: 历史消息(游标分页) - PUT /api/v1/im/conversations/:id/pin: 置顶/取消 - DELETE /api/v1/im/conversations/:id: 删除会话 - DELETE /api/v1/im/conversations/:id/messages: 清空记录 - GET /api/v1/im/messages/search: 全局搜索 - GET /api/v1/im/unread: 全局未读数 Wire 集成: - IMSet Provider Set + wire.Bind 接口注入 - App struct 新增 IM 字段 - wire_gen.go 手动更新 - OfflinePusher 通过 SetOfflinePusher 注入 WS Handler Made-with: Cursor --- .../app/im/controller/im_controller.go | 213 ++++++++++++++++++ backend/go-service/app/im/provider.go | 60 +++++ backend/go-service/app/im/router.go | 27 +++ backend/go-service/app/provider/provider.go | 36 ++- backend/go-service/app/provider/wire.go | 6 + backend/go-service/app/provider/wire_gen.go | 12 +- backend/go-service/router/router.go | 3 +- 7 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 backend/go-service/app/im/controller/im_controller.go create mode 100644 backend/go-service/app/im/provider.go create mode 100644 backend/go-service/app/im/router.go diff --git a/backend/go-service/app/im/controller/im_controller.go b/backend/go-service/app/im/controller/im_controller.go new file mode 100644 index 0000000..aa87459 --- /dev/null +++ b/backend/go-service/app/im/controller/im_controller.go @@ -0,0 +1,213 @@ +// Package controller 提供 IM 模块的 HTTP 接口处理 +package controller + +import ( + "strconv" + + "github.com/echochat/backend/app/dto" + "github.com/echochat/backend/app/im/service" + "github.com/echochat/backend/pkg/middleware" + "github.com/echochat/backend/pkg/utils" + "github.com/gin-gonic/gin" +) + +// IMController 即时通讯控制器(REST API) +type IMController struct { + imService *service.IMService +} + +// NewIMController 创建 IMController 实例 +func NewIMController(imService *service.IMService) *IMController { + return &IMController{imService: imService} +} + +// GetConversations 获取会话列表 +// GET /api/v1/im/conversations +func (ctl *IMController) GetConversations(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + result, err := ctl.imService.GetConversationList(ctx, userID) + if err != nil { + ctl.handleError(c, err, "获取会话列表失败") + return + } + utils.ResponseOK(c, result) +} + +// GetHistoryMessages 获取历史消息 +// GET /api/v1/im/messages?conversation_id=xx&before_id=xx&limit=30 +func (ctl *IMController) GetHistoryMessages(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + var req dto.HistoryMessageRequest + if err := c.ShouldBindQuery(&req); err != nil { + utils.ResponseBadRequest(c, "请求参数错误: "+err.Error()) + return + } + + result, err := ctl.imService.GetHistoryMessages(ctx, userID, &req) + if err != nil { + ctl.handleError(c, err, "获取历史消息失败") + return + } + utils.ResponseOK(c, result) +} + +// PinConversation 置顶/取消置顶会话 +// PUT /api/v1/im/conversations/:id/pin +func (ctl *IMController) PinConversation(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + convID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "会话 ID 格式错误") + return + } + + var req dto.PinConversationRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "请求参数错误: "+err.Error()) + return + } + + if err := ctl.imService.PinConversation(ctx, userID, convID, req.IsPinned); err != nil { + ctl.handleError(c, err, "更新置顶状态失败") + return + } + utils.ResponseOK(c, nil) +} + +// DeleteConversation 删除会话 +// DELETE /api/v1/im/conversations/:id +func (ctl *IMController) DeleteConversation(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + convID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "会话 ID 格式错误") + return + } + + if err := ctl.imService.DeleteConversation(ctx, userID, convID); err != nil { + ctl.handleError(c, err, "删除会话失败") + return + } + utils.ResponseOK(c, nil) +} + +// ClearHistory 清空聊天记录 +// DELETE /api/v1/im/conversations/:id/messages +func (ctl *IMController) ClearHistory(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + convID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "会话 ID 格式错误") + return + } + + if err := ctl.imService.ClearHistory(ctx, userID, convID); err != nil { + ctl.handleError(c, err, "清空聊天记录失败") + return + } + utils.ResponseOK(c, nil) +} + +// SearchMessages 全局消息搜索 +// GET /api/v1/im/messages/search?keyword=xx&limit=50 +func (ctl *IMController) SearchMessages(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + var req dto.SearchMessageRequest + if err := c.ShouldBindQuery(&req); err != nil { + utils.ResponseBadRequest(c, "请求参数错误: "+err.Error()) + return + } + + result, err := ctl.imService.SearchMessages(ctx, userID, &req) + if err != nil { + ctl.handleError(c, err, "消息搜索失败") + return + } + utils.ResponseOK(c, result) +} + +// GetTotalUnread 获取全局未读消息总数 +// GET /api/v1/im/unread +func (ctl *IMController) GetTotalUnread(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + count, err := ctl.imService.GetTotalUnread(ctx, userID) + if err != nil { + ctl.handleError(c, err, "获取未读消息数失败") + return + } + utils.ResponseOK(c, gin.H{"total_unread": count}) +} + +// handleError 统一业务错误映射 +// 已知业务错误 → 返回 Service 层定义的具体提示 +// 未知错误 → 返回 fallbackMsg(未传则默认"服务器内部错误") +func (ctl *IMController) handleError(c *gin.Context, err error, fallbackMsg ...string) { + switch err { + case service.ErrNotFriend: + utils.ResponseForbidden(c, err.Error()) + case service.ErrEmptyContent: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrInvalidMsgType: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrConvNotFound: + utils.ResponseNotFound(c, err.Error()) + case service.ErrMsgNotFound: + utils.ResponseNotFound(c, err.Error()) + case service.ErrNotSender: + utils.ResponseForbidden(c, err.Error()) + case service.ErrRecallTimeout: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrNotMember: + utils.ResponseForbidden(c, err.Error()) + case service.ErrDuplicateMsg: + utils.ResponseBadRequest(c, err.Error()) + default: + msg := "服务器内部错误" + if len(fallbackMsg) > 0 && fallbackMsg[0] != "" { + msg = fallbackMsg[0] + } + utils.ResponseError(c, msg) + } +} diff --git a/backend/go-service/app/im/provider.go b/backend/go-service/app/im/provider.go new file mode 100644 index 0000000..f042342 --- /dev/null +++ b/backend/go-service/app/im/provider.go @@ -0,0 +1,60 @@ +package im + +import ( + "github.com/echochat/backend/app/im/controller" + "github.com/echochat/backend/app/im/dao" + "github.com/echochat/backend/app/im/handler" + "github.com/echochat/backend/app/im/service" + "github.com/echochat/backend/pkg/ws" + "github.com/google/wire" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// ProvideConversationDAO 创建 ConversationDAO 实例 +func ProvideConversationDAO(db *gorm.DB) *dao.ConversationDAO { + return dao.NewConversationDAO(db) +} + +// ProvideMessageDAO 创建 MessageDAO 实例 +func ProvideMessageDAO(db *gorm.DB) *dao.MessageDAO { + return dao.NewMessageDAO(db) +} + +// ProvideIMService 创建 IMService 实例 +// friendChecker 和 userInfoGetter 由 contact 模块的 FriendshipDAO 隐式实现 +func ProvideIMService( + convDAO *dao.ConversationDAO, + msgDAO *dao.MessageDAO, + pubsub *ws.PubSub, + rdb *redis.Client, + friendChecker service.FriendChecker, + userInfoGetter service.UserInfoGetter, +) *service.IMService { + return service.NewIMService(convDAO, msgDAO, pubsub, rdb, friendChecker, userInfoGetter) +} + +// ProvideIMEventHandler 创建 IM WS 事件处理器并注册事件到 Hub +func ProvideIMEventHandler(imService *service.IMService, hub *ws.Hub) *handler.EventHandler { + return handler.NewEventHandler(imService, hub) +} + +// ProvideOfflinePusher 创建离线消息推送器 +func ProvideOfflinePusher(imService *service.IMService, convDAO *dao.ConversationDAO, pubsub *ws.PubSub) *handler.OfflinePusher { + return handler.NewOfflinePusher(imService, convDAO, pubsub) +} + +// ProvideIMController 创建 IM REST 控制器 +func ProvideIMController(imService *service.IMService) *controller.IMController { + return controller.NewIMController(imService) +} + +// IMSet IM 模块 Wire Provider Set +var IMSet = wire.NewSet( + ProvideConversationDAO, + ProvideMessageDAO, + ProvideIMService, + ProvideIMEventHandler, + ProvideOfflinePusher, + ProvideIMController, +) diff --git a/backend/go-service/app/im/router.go b/backend/go-service/app/im/router.go new file mode 100644 index 0000000..1dcce79 --- /dev/null +++ b/backend/go-service/app/im/router.go @@ -0,0 +1,27 @@ +// Package im 提供即时通讯模块 +package im + +import ( + "github.com/echochat/backend/app/im/controller" + "github.com/gin-gonic/gin" +) + +// RegisterRoutes 注册 IM 模块的所有路由(需要 JWT 中间件) +func RegisterRoutes(r *gin.Engine, ctrl *controller.IMController, jwtAuth gin.HandlerFunc) { + authed := r.Group("/api/v1/im") + authed.Use(jwtAuth) + { + // 会话管理 + authed.GET("/conversations", ctrl.GetConversations) + authed.PUT("/conversations/:id/pin", ctrl.PinConversation) + authed.DELETE("/conversations/:id", ctrl.DeleteConversation) + authed.DELETE("/conversations/:id/messages", ctrl.ClearHistory) + + // 消息 + authed.GET("/messages", ctrl.GetHistoryMessages) + authed.GET("/messages/search", ctrl.SearchMessages) + + // 未读数 + authed.GET("/unread", ctrl.GetTotalUnread) + } +} diff --git a/backend/go-service/app/provider/provider.go b/backend/go-service/app/provider/provider.go index 265c6db..6adc268 100644 --- a/backend/go-service/app/provider/provider.go +++ b/backend/go-service/app/provider/provider.go @@ -7,6 +7,8 @@ import ( authController "github.com/echochat/backend/app/auth/controller" "github.com/echochat/backend/app/auth/service" contactController "github.com/echochat/backend/app/contact/controller" + imController "github.com/echochat/backend/app/im/controller" + imHandler "github.com/echochat/backend/app/im/handler" wsApp "github.com/echochat/backend/app/ws" "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/db" @@ -32,6 +34,9 @@ type App struct { PubSub *ws.PubSub // Redis Pub/Sub 消息路由 OnlineService *wsApp.OnlineService // 在线状态管理服务 ContactController *contactController.ContactController // 联系人控制器 + IMController *imController.IMController // IM 即时通讯控制器 + IMEventHandler *imHandler.EventHandler // IM WS 事件处理器 + OfflinePusher *imHandler.OfflinePusher // 离线消息推送器 } // NewApp 创建应用实例 @@ -50,22 +55,31 @@ func NewApp( pubsub *ws.PubSub, onlineService *wsApp.OnlineService, contactCtrl *contactController.ContactController, + imCtrl *imController.IMController, + imEventHandler *imHandler.EventHandler, + offlinePusher *imHandler.OfflinePusher, ) *App { + // 注入离线消息推送器到 WS Handler + wsHandler.SetOfflinePusher(offlinePusher) + return &App{ - Config: cfg, - DB: gormDB, - Redis: redisClient, - AuthService: authService, - AuthController: authCtrl, - AdminAuthController: adminAuthCtrl, + Config: cfg, + DB: gormDB, + Redis: redisClient, + AuthService: authService, + AuthController: authCtrl, + AdminAuthController: adminAuthCtrl, UserManageController: userManageCtrl, OnlineController: onlineCtrl, ContactManageController: contactManageCtrl, - WSHandler: wsHandler, - Hub: hub, - PubSub: pubsub, - OnlineService: onlineService, - ContactController: contactCtrl, + WSHandler: wsHandler, + Hub: hub, + PubSub: pubsub, + OnlineService: onlineService, + ContactController: contactCtrl, + IMController: imCtrl, + IMEventHandler: imEventHandler, + OfflinePusher: offlinePusher, } } diff --git a/backend/go-service/app/provider/wire.go b/backend/go-service/app/provider/wire.go index 9e88c87..e65a057 100644 --- a/backend/go-service/app/provider/wire.go +++ b/backend/go-service/app/provider/wire.go @@ -7,6 +7,9 @@ import ( "github.com/echochat/backend/app/admin" "github.com/echochat/backend/app/auth" "github.com/echochat/backend/app/contact" + contactDAO "github.com/echochat/backend/app/contact/dao" + imApp "github.com/echochat/backend/app/im" + imService "github.com/echochat/backend/app/im/service" wsApp "github.com/echochat/backend/app/ws" "github.com/echochat/backend/config" "github.com/google/wire" @@ -20,6 +23,9 @@ func InitializeApp(cfg *config.Config) (*App, error) { admin.AdminSet, wsApp.WSSet, contact.ContactSet, + imApp.IMSet, + wire.Bind(new(imService.FriendChecker), new(*contactDAO.FriendshipDAO)), + wire.Bind(new(imService.UserInfoGetter), new(*contactDAO.FriendshipDAO)), ) return nil, nil } diff --git a/backend/go-service/app/provider/wire_gen.go b/backend/go-service/app/provider/wire_gen.go index e6e4ac8..ffe7ba2 100644 --- a/backend/go-service/app/provider/wire_gen.go +++ b/backend/go-service/app/provider/wire_gen.go @@ -16,6 +16,7 @@ import ( controller3 "github.com/echochat/backend/app/contact/controller" dao3 "github.com/echochat/backend/app/contact/dao" service3 "github.com/echochat/backend/app/contact/service" + imApp "github.com/echochat/backend/app/im" "github.com/echochat/backend/app/ws" "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/db" @@ -57,6 +58,15 @@ func InitializeApp(cfg *config.Config) (*App, error) { friendGroupDAO := dao3.NewFriendGroupDAO(gormDB) contactService := service3.NewContactService(friendshipDAO, friendGroupDAO, pubSub) contactController := controller3.NewContactController(contactService) - app := NewApp(cfg, gormDB, client, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, handler, hub, pubSub, onlineService, contactController) + + // IM 模块初始化 + conversationDAO := imApp.ProvideConversationDAO(gormDB) + messageDAO := imApp.ProvideMessageDAO(gormDB) + imService := imApp.ProvideIMService(conversationDAO, messageDAO, pubSub, client, friendshipDAO, friendshipDAO) + imEventHandler := imApp.ProvideIMEventHandler(imService, hub) + offlinePusher := imApp.ProvideOfflinePusher(imService, conversationDAO, pubSub) + imController := imApp.ProvideIMController(imService) + + app := NewApp(cfg, gormDB, client, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, handler, hub, pubSub, onlineService, contactController, imController, imEventHandler, offlinePusher) return app, nil } diff --git a/backend/go-service/router/router.go b/backend/go-service/router/router.go index f66b0b6..4611115 100644 --- a/backend/go-service/router/router.go +++ b/backend/go-service/router/router.go @@ -9,6 +9,7 @@ import ( "github.com/echochat/backend/app/admin" "github.com/echochat/backend/app/auth" "github.com/echochat/backend/app/contact" + imApp "github.com/echochat/backend/app/im" "github.com/echochat/backend/app/provider" wsApp "github.com/echochat/backend/app/ws" "github.com/echochat/backend/pkg/middleware" @@ -36,8 +37,8 @@ func Setup(engine *gin.Engine, app *provider.App) { admin.RegisterRoutes(engine, app.UserManageController, app.OnlineController, app.ContactManageController, jwtAuth) wsApp.RegisterRoutes(engine, app.WSHandler) contact.RegisterRoutes(engine, app.ContactController, jwtAuth) + imApp.RegisterRoutes(engine, app.IMController, jwtAuth) - // [未来] im.RegisterRoutes(engine, app.ImController, jwtAuth) // [未来] meeting.RegisterRoutes(engine, app.MeetingController, jwtAuth) // [未来] notify.RegisterRoutes(engine, app.NotifyController, jwtAuth) }