diff --git a/backend/go-service/app/admin/controller/contact_manage_controller.go b/backend/go-service/app/admin/controller/contact_manage_controller.go index 29e1252..e39d57e 100644 --- a/backend/go-service/app/admin/controller/contact_manage_controller.go +++ b/backend/go-service/app/admin/controller/contact_manage_controller.go @@ -1,41 +1,56 @@ +// Package controller 提供 admin 模块的 HTTP 接口处理 package controller import ( "strconv" "github.com/echochat/backend/app/admin/service" + "github.com/echochat/backend/pkg/logs" "github.com/echochat/backend/pkg/utils" "github.com/gin-gonic/gin" + "go.uber.org/zap" ) +// ContactManageController 联系人关系管理控制器(管理端) type ContactManageController struct { contactManageService *service.ContactManageService } +// NewContactManageController 创建 ContactManageController 实例 func NewContactManageController(contactManageService *service.ContactManageService) *ContactManageController { return &ContactManageController{contactManageService: contactManageService} } -// GetAllContacts GET /api/v1/admin/contacts +// GetAllContacts 获取所有好友关系列表(分页) +// GET /api/v1/admin/contacts func (ctl *ContactManageController) GetAllContacts(c *gin.Context) { + funcName := "admin.contact_manage_controller.GetAllContacts" ctx := c.Request.Context() + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) contacts, total, err := ctl.contactManageService.GetAllFriendships(ctx, page, pageSize) if err != nil { + logs.Error(ctx, funcName, "获取好友关系列表失败", zap.Error(err)) utils.ResponseError(c, "获取好友关系列表失败") return } + + logs.Info(ctx, funcName, "获取好友关系列表成功", + zap.Int64("total", total), zap.Int("page", page)) utils.ResponseOK(c, gin.H{ "list": contacts, "total": total, }) } -// DeleteContact DELETE /api/v1/admin/contacts/:id +// DeleteContact 删除好友关系 +// DELETE /api/v1/admin/contacts/:id func (ctl *ContactManageController) DeleteContact(c *gin.Context) { + funcName := "admin.contact_manage_controller.DeleteContact" ctx := c.Request.Context() + id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { utils.ResponseBadRequest(c, "ID 格式错误") @@ -43,8 +58,12 @@ func (ctl *ContactManageController) DeleteContact(c *gin.Context) { } if err := ctl.contactManageService.DeleteFriendship(ctx, id); err != nil { + logs.Error(ctx, funcName, "删除好友关系失败", + zap.Int64("friendship_id", id), zap.Error(err)) utils.ResponseError(c, "删除好友关系失败") return } + + logs.Info(ctx, funcName, "删除好友关系成功", zap.Int64("friendship_id", id)) utils.ResponseOK(c, nil) } diff --git a/backend/go-service/app/admin/controller/online_controller.go b/backend/go-service/app/admin/controller/online_controller.go index 9a024e1..87301c5 100644 --- a/backend/go-service/app/admin/controller/online_controller.go +++ b/backend/go-service/app/admin/controller/online_controller.go @@ -1,37 +1,54 @@ +// Package controller 提供 admin 模块的 HTTP 接口处理 package controller import ( "github.com/echochat/backend/app/admin/service" + "github.com/echochat/backend/pkg/logs" "github.com/echochat/backend/pkg/utils" "github.com/gin-gonic/gin" + "go.uber.org/zap" ) +// OnlineController 在线用户管理控制器(管理端) type OnlineController struct { onlineManageService *service.OnlineManageService } +// NewOnlineController 创建 OnlineController 实例 func NewOnlineController(onlineManageService *service.OnlineManageService) *OnlineController { return &OnlineController{onlineManageService: onlineManageService} } -// GetOnlineUsers GET /api/v1/admin/online/users +// GetOnlineUsers 获取在线用户列表 +// GET /api/v1/admin/online/users func (ctl *OnlineController) GetOnlineUsers(c *gin.Context) { + funcName := "admin.online_controller.GetOnlineUsers" ctx := c.Request.Context() - userIDs, err := ctl.onlineManageService.GetOnlineUsers(ctx) + + users, err := ctl.onlineManageService.GetOnlineUsers(ctx) if err != nil { + logs.Error(ctx, funcName, "获取在线用户列表失败", zap.Error(err)) utils.ResponseError(c, "获取在线用户列表失败") return } - utils.ResponseOK(c, userIDs) + + logs.Info(ctx, funcName, "获取在线用户列表成功", zap.Int("count", len(users))) + utils.ResponseOK(c, users) } -// GetOnlineCount GET /api/v1/admin/online/count +// GetOnlineCount 获取在线用户总数 +// GET /api/v1/admin/online/count func (ctl *OnlineController) GetOnlineCount(c *gin.Context) { + funcName := "admin.online_controller.GetOnlineCount" ctx := c.Request.Context() + count, err := ctl.onlineManageService.GetOnlineCount(ctx) if err != nil { + logs.Error(ctx, funcName, "获取在线用户数失败", zap.Error(err)) utils.ResponseError(c, "获取在线用户数失败") return } + + logs.Info(ctx, funcName, "获取在线用户数成功", zap.Int64("count", count)) utils.ResponseOK(c, gin.H{"count": count}) } diff --git a/backend/go-service/app/admin/service/contact_manage_service.go b/backend/go-service/app/admin/service/contact_manage_service.go index 2f2e184..7316fa5 100644 --- a/backend/go-service/app/admin/service/contact_manage_service.go +++ b/backend/go-service/app/admin/service/contact_manage_service.go @@ -33,7 +33,10 @@ func (s *ContactManageService) GetAllFriendships(ctx context.Context, page, page logs.Debug(ctx, funcName, "管理端查询好友关系") var total int64 - s.db.WithContext(ctx).Model(&model.Friendship{}).Count(&total) + if err := s.db.WithContext(ctx).Model(&model.Friendship{}).Count(&total).Error; err != nil { + logs.Error(ctx, funcName, "统计好友关系总数失败", zap.Error(err)) + return nil, 0, err + } var results []AdminFriendship offset := (page - 1) * pageSize @@ -60,8 +63,11 @@ func (s *ContactManageService) DeleteFriendship(ctx context.Context, friendshipI } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - tx.Where("user_id = ? AND friend_id = ?", f.UserID, f.FriendID).Delete(&model.Friendship{}) - tx.Where("user_id = ? AND friend_id = ?", f.FriendID, f.UserID).Delete(&model.Friendship{}) - return nil + if err := tx.Where("user_id = ? AND friend_id = ?", f.UserID, f.FriendID). + Delete(&model.Friendship{}).Error; err != nil { + return err + } + return tx.Where("user_id = ? AND friend_id = ?", f.FriendID, f.UserID). + Delete(&model.Friendship{}).Error }) } diff --git a/backend/go-service/app/admin/service/online_service.go b/backend/go-service/app/admin/service/online_service.go index 3a03df5..820661e 100644 --- a/backend/go-service/app/admin/service/online_service.go +++ b/backend/go-service/app/admin/service/online_service.go @@ -3,24 +3,72 @@ package service import ( "context" + authModel "github.com/echochat/backend/app/auth/model" wsApp "github.com/echochat/backend/app/ws" "github.com/echochat/backend/pkg/logs" + "go.uber.org/zap" + "gorm.io/gorm" ) +// OnlineUserInfo 在线用户信息(包含用户名,用于管理端展示) +type OnlineUserInfo struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` +} + +// OnlineManageService 管理端在线状态查询服务 type OnlineManageService struct { onlineService *wsApp.OnlineService + db *gorm.DB } -func NewOnlineManageService(onlineService *wsApp.OnlineService) *OnlineManageService { - return &OnlineManageService{onlineService: onlineService} +// NewOnlineManageService 创建 OnlineManageService 实例 +func NewOnlineManageService(onlineService *wsApp.OnlineService, db *gorm.DB) *OnlineManageService { + return &OnlineManageService{ + onlineService: onlineService, + db: db, + } } -func (s *OnlineManageService) GetOnlineUsers(ctx context.Context) ([]int64, error) { +// GetOnlineUsers 获取在线用户列表(含用户名) +func (s *OnlineManageService) GetOnlineUsers(ctx context.Context) ([]OnlineUserInfo, error) { funcName := "service.online_manage_service.GetOnlineUsers" logs.Debug(ctx, funcName, "获取在线用户列表") - return s.onlineService.GetOnlineUserIDs(ctx) + + ids, err := s.onlineService.GetOnlineUserIDs(ctx) + if err != nil { + return nil, err + } + if len(ids) == 0 { + return []OnlineUserInfo{}, nil + } + + var users []authModel.User + if err := s.db.WithContext(ctx). + Model(&authModel.User{}). + Select("id, username"). + Where("id IN ?", ids). + Find(&users).Error; err != nil { + logs.Error(ctx, funcName, "查询在线用户信息失败", zap.Error(err)) + return nil, err + } + + userMap := make(map[int64]string, len(users)) + for _, u := range users { + userMap[u.ID] = u.Username + } + + result := make([]OnlineUserInfo, 0, len(ids)) + for _, id := range ids { + result = append(result, OnlineUserInfo{ + UserID: id, + Username: userMap[id], + }) + } + return result, nil } +// GetOnlineCount 获取在线用户总数 func (s *OnlineManageService) GetOnlineCount(ctx context.Context) (int64, error) { return s.onlineService.GetOnlineCount(ctx) } diff --git a/backend/go-service/app/contact/controller/contact_controller.go b/backend/go-service/app/contact/controller/contact_controller.go index e23724f..99852da 100644 --- a/backend/go-service/app/contact/controller/contact_controller.go +++ b/backend/go-service/app/contact/controller/contact_controller.go @@ -41,7 +41,7 @@ func (ctl *ContactController) GetFriendList(c *gin.Context) { friends, err := ctl.contactService.GetFriendList(ctx, userID, groupID) if err != nil { - utils.ResponseError(c, "获取好友列表失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, friends) @@ -131,7 +131,7 @@ func (ctl *ContactController) GetPendingRequests(c *gin.Context) { requests, err := ctl.contactService.GetPendingRequests(ctx, userID) if err != nil { - utils.ResponseError(c, "获取好友申请列表失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, requests) @@ -154,7 +154,7 @@ func (ctl *ContactController) DeleteFriend(c *gin.Context) { } if err := ctl.contactService.DeleteFriend(ctx, userID, friendID); err != nil { - utils.ResponseError(c, "删除好友失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, nil) @@ -183,7 +183,7 @@ func (ctl *ContactController) UpdateRemark(c *gin.Context) { } if err := ctl.contactService.UpdateRemark(ctx, userID, friendID, req.Remark); err != nil { - utils.ResponseError(c, "更新备注失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, nil) @@ -230,7 +230,7 @@ func (ctl *ContactController) UnblockUser(c *gin.Context) { } if err := ctl.contactService.UnblockUser(ctx, userID, targetID); err != nil { - utils.ResponseError(c, "取消拉黑失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, nil) @@ -248,7 +248,7 @@ func (ctl *ContactController) GetBlockList(c *gin.Context) { blocked, err := ctl.contactService.GetBlockList(ctx, userID) if err != nil { - utils.ResponseError(c, "获取黑名单失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, blocked) @@ -275,7 +275,7 @@ func (ctl *ContactController) SearchUsers(c *gin.Context) { users, total, err := ctl.contactService.SearchUsers(ctx, userID, keyword, page, pageSize) if err != nil { - utils.ResponseError(c, "搜索用户失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, gin.H{ @@ -296,7 +296,7 @@ func (ctl *ContactController) GetRecommendFriends(c *gin.Context) { friends, err := ctl.contactService.GetRecommendFriends(ctx, userID) if err != nil { - utils.ResponseError(c, "获取好友推荐失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, friends) @@ -314,7 +314,7 @@ func (ctl *ContactController) GetGroups(c *gin.Context) { groups, err := ctl.contactService.GetGroups(ctx, userID) if err != nil { - utils.ResponseError(c, "获取分组列表失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, groups) @@ -338,7 +338,7 @@ func (ctl *ContactController) CreateGroup(c *gin.Context) { group, err := ctl.contactService.CreateGroup(ctx, userID, req.Name) if err != nil { - utils.ResponseError(c, "创建分组失败") + ctl.handleError(c, err) return } utils.ResponseCreated(c, group) @@ -367,7 +367,7 @@ func (ctl *ContactController) UpdateGroup(c *gin.Context) { } if err := ctl.contactService.UpdateGroup(ctx, userID, groupID, req.Name, req.SortOrder); err != nil { - utils.ResponseError(c, "修改分组失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, nil) @@ -390,7 +390,7 @@ func (ctl *ContactController) DeleteGroup(c *gin.Context) { } if err := ctl.contactService.DeleteGroup(ctx, userID, groupID); err != nil { - utils.ResponseError(c, "删除分组失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, nil) @@ -419,7 +419,7 @@ func (ctl *ContactController) MoveToGroup(c *gin.Context) { } if err := ctl.contactService.MoveToGroup(ctx, userID, friendID, req.GroupID); err != nil { - utils.ResponseError(c, "移动好友到分组失败") + ctl.handleError(c, err) return } utils.ResponseOK(c, nil) diff --git a/backend/go-service/app/contact/dao/friend_group_dao.go b/backend/go-service/app/contact/dao/friend_group_dao.go index 8550845..d313386 100644 --- a/backend/go-service/app/contact/dao/friend_group_dao.go +++ b/backend/go-service/app/contact/dao/friend_group_dao.go @@ -81,9 +81,11 @@ func (d *FriendGroupDAO) DeleteGroup(ctx context.Context, groupID int64, userID zap.Int64("group_id", groupID), zap.Int64("user_id", userID)) return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - tx.Model(&model.Friendship{}). + if err := tx.Model(&model.Friendship{}). Where("user_id = ? AND group_id = ?", userID, groupID). - Update("group_id", nil) + Update("group_id", nil).Error; err != nil { + return err + } result := tx.Where("id = ? AND user_id = ?", groupID, userID). Delete(&model.FriendGroup{}) diff --git a/backend/go-service/app/contact/dao/friendship_dao.go b/backend/go-service/app/contact/dao/friendship_dao.go index 3d22d6b..c33d156 100644 --- a/backend/go-service/app/contact/dao/friendship_dao.go +++ b/backend/go-service/app/contact/dao/friendship_dao.go @@ -196,9 +196,11 @@ func (d *FriendshipDAO) BlockUser(ctx context.Context, userID, targetID int64) e zap.Int64("user_id", userID), zap.Int64("target_id", targetID)) return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - tx.Where("(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)", + if err := tx.Where("(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)", userID, targetID, targetID, userID). - Delete(&model.Friendship{}) + Delete(&model.Friendship{}).Error; err != nil { + return err + } block := &model.Friendship{ UserID: userID, @@ -324,6 +326,23 @@ func (d *FriendshipDAO) GetFriendIDs(ctx context.Context, userID int64) ([]int64 return ids, err } +// GetUsersByIDs 批量查询用户信息(仅返回 id/username/nickname/avatar) +func (d *FriendshipDAO) GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error) { + funcName := "dao.friendship_dao.GetUsersByIDs" + logs.Debug(ctx, funcName, "批量查询用户信息", zap.Int("count", len(userIDs))) + + var users []authModel.User + err := d.db.WithContext(ctx). + Model(&authModel.User{}). + Select("id, username, nickname, avatar"). + Where("id IN ?", userIDs). + Find(&users).Error + if err != nil { + logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(err)) + } + return users, err +} + // CountFriendsByGroup 统计每个分组的好友数 func (d *FriendshipDAO) CountFriendsByGroup(ctx context.Context, userID int64) (map[int64]int, error) { type GroupCount struct { diff --git a/backend/go-service/app/contact/service/contact_service.go b/backend/go-service/app/contact/service/contact_service.go index 63f8f57..e296901 100644 --- a/backend/go-service/app/contact/service/contact_service.go +++ b/backend/go-service/app/contact/service/contact_service.go @@ -4,6 +4,7 @@ package service import ( "context" "errors" + "sort" "github.com/echochat/backend/app/contact/dao" "github.com/echochat/backend/app/dto" @@ -287,7 +288,7 @@ func (s *ContactService) SearchUsers(ctx context.Context, userID int64, keyword return result, total, nil } -// GetRecommendFriends 好友推荐(基于共同好友) +// GetRecommendFriends 好友推荐(基于共同好友数量排序) func (s *ContactService) GetRecommendFriends(ctx context.Context, userID int64) ([]dto.SearchUserInfo, error) { funcName := "service.contact_service.GetRecommendFriends" logs.Debug(ctx, funcName, "好友推荐", zap.Int64("user_id", userID)) @@ -322,31 +323,47 @@ func (s *ContactService) GetRecommendFriends(ctx context.Context, userID int64) id int64 count int } - var candidates []candidate + candidates := make([]candidate, 0, len(candidateCount)) for id, count := range candidateCount { candidates = append(candidates, candidate{id: id, count: count}) } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].count > candidates[j].count + }) + limit := 10 if len(candidates) > limit { - for i := 0; i < limit; i++ { - for j := i + 1; j < len(candidates); j++ { - if candidates[j].count > candidates[i].count { - candidates[i], candidates[j] = candidates[j], candidates[i] - } - } - } candidates = candidates[:limit] } + candidateIDs := make([]int64, len(candidates)) + for i, c := range candidates { + candidateIDs[i] = c.id + } + + users, err := s.friendshipDAO.GetUsersByIDs(ctx, candidateIDs) + if err != nil { + logs.Error(ctx, funcName, "批量查询推荐用户信息失败", zap.Error(err)) + return nil, err + } + + userMap := make(map[int64]dto.SearchUserInfo, len(users)) + for _, u := range users { + userMap[u.ID] = dto.SearchUserInfo{ + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Avatar: u.Avatar, + IsFriend: false, + } + } + result := make([]dto.SearchUserInfo, 0, len(candidates)) for _, c := range candidates { - users, _, err := s.friendshipDAO.SearchUsers(ctx, "", 0, 1, 1) - _ = users - _ = err - result = append(result, dto.SearchUserInfo{ - ID: c.id, - }) + if info, ok := userMap[c.id]; ok { + result = append(result, info) + } } return result, nil diff --git a/backend/go-service/app/provider/wire_gen.go b/backend/go-service/app/provider/wire_gen.go index 5909908..e6e4ac8 100644 --- a/backend/go-service/app/provider/wire_gen.go +++ b/backend/go-service/app/provider/wire_gen.go @@ -47,13 +47,13 @@ func InitializeApp(cfg *config.Config) (*App, error) { userManageController := controller2.NewUserManageController(userManageService) hub := ws.ProvideHub() pubSub := ws.ProvidePubSub(client, hub) - onlineService := ws.ProvideOnlineService(client, hub, pubSub) - onlineManageService := service2.NewOnlineManageService(onlineService) + friendshipDAO := dao3.NewFriendshipDAO(gormDB) + onlineService := ws.ProvideOnlineService(client, hub, pubSub, friendshipDAO) + onlineManageService := service2.NewOnlineManageService(onlineService, gormDB) onlineController := controller2.NewOnlineController(onlineManageService) contactManageService := service2.NewContactManageService(gormDB) contactManageController := controller2.NewContactManageController(contactManageService) - handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService) - friendshipDAO := dao3.NewFriendshipDAO(gormDB) + handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService, authService) friendGroupDAO := dao3.NewFriendGroupDAO(gormDB) contactService := service3.NewContactService(friendshipDAO, friendGroupDAO, pubSub) contactController := controller3.NewContactController(contactService) diff --git a/backend/go-service/app/ws/handler.go b/backend/go-service/app/ws/handler.go index f883534..6d6b698 100644 --- a/backend/go-service/app/ws/handler.go +++ b/backend/go-service/app/ws/handler.go @@ -15,29 +15,37 @@ import ( "go.uber.org/zap" ) +// TokenValidator 有状态 JWT 验证接口(检查 Token 是否在 Redis 中有效) +// 由 auth.AuthService 实现,用于防止已登出用户建立 WebSocket 连接 +type TokenValidator interface { + ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool +} + var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { - return true // 开发阶段允许所有来源,生产环境需限制 + return true // TODO: 生产环境通过配置限制 allowed origins }, } // Handler WebSocket 连接处理器 type Handler struct { - hub *ws.Hub - pubsub *ws.PubSub - jwtCfg *config.JWTConfig - onlineService *OnlineService + hub *ws.Hub + pubsub *ws.PubSub + jwtCfg *config.JWTConfig + onlineService *OnlineService + tokenValidator TokenValidator } // NewHandler 创建 WebSocket Handler 实例 -func NewHandler(hub *ws.Hub, pubsub *ws.PubSub, jwtCfg *config.JWTConfig, onlineService *OnlineService) *Handler { +func NewHandler(hub *ws.Hub, pubsub *ws.PubSub, jwtCfg *config.JWTConfig, onlineService *OnlineService, tokenValidator TokenValidator) *Handler { return &Handler{ - hub: hub, - pubsub: pubsub, - jwtCfg: jwtCfg, - onlineService: onlineService, + hub: hub, + pubsub: pubsub, + jwtCfg: jwtCfg, + onlineService: onlineService, + tokenValidator: tokenValidator, } } @@ -59,6 +67,17 @@ func (h *Handler) Upgrade(c *gin.Context) { return } + clientType := claims.ClientType + if clientType == "" { + clientType = "frontend" + } + if h.tokenValidator != nil && !h.tokenValidator.ValidateAccessToken(c.Request.Context(), claims.UserID, clientType, token) { + logs.Warn(nil, funcName, "WebSocket Token 已失效(Redis 校验)", + zap.Int64("user_id", claims.UserID)) + utils.ResponseUnauthorized(c, "认证已失效,请重新登录") + return + } + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { logs.Error(nil, funcName, "WebSocket 升级失败", @@ -68,6 +87,11 @@ func (h *Handler) Upgrade(c *gin.Context) { client := ws.NewClient(h.hub, conn, claims.UserID) client.SetOnDisconnect(func(userID int64) { + if client.IsClosedByHub() { + logs.Info(nil, "ws.handler.onDisconnect", "连接被 Hub 踢出(重复连接),跳过下线清理", + zap.Int64("user_id", userID)) + return + } h.pubsub.Unsubscribe(userID) h.onlineService.UserOffline(context.Background(), userID) }) @@ -97,7 +121,11 @@ func (h *Handler) createReadHandler(userID int64) ws.MessageHandler { case "heartbeat": h.onlineService.HeartbeatRenew(context.Background(), userID) resp := ws.NewResponse(msg.Event, msg.Seq, 0, "pong", nil) - data, _ := ws.MarshalResponse(resp) + data, err := ws.MarshalResponse(resp) + if err != nil { + logs.Error(nil, funcName, "序列化心跳响应失败", zap.Error(err)) + return + } client.Send(data) default: resp := ws.NewResponse(msg.Event, msg.Seq, 0, "ok", nil) diff --git a/backend/go-service/app/ws/online_service.go b/backend/go-service/app/ws/online_service.go index 6b1f036..1a2ffd4 100644 --- a/backend/go-service/app/ws/online_service.go +++ b/backend/go-service/app/ws/online_service.go @@ -25,19 +25,26 @@ type UserStatus struct { IP string `json:"ip"` // 连接 IP } +// FriendIDsGetter 获取好友 ID 列表的接口(避免直接依赖 contact 模块) +type FriendIDsGetter interface { + GetFriendIDs(ctx context.Context, userID int64) ([]int64, error) +} + // OnlineService 在线状态管理服务 type OnlineService struct { - rdb *redis.Client - hub *ws.Hub - pubsub *ws.PubSub + rdb *redis.Client + hub *ws.Hub + pubsub *ws.PubSub + friendGetter FriendIDsGetter } // NewOnlineService 创建 OnlineService 实例 -func NewOnlineService(rdb *redis.Client, hub *ws.Hub, pubsub *ws.PubSub) *OnlineService { +func NewOnlineService(rdb *redis.Client, hub *ws.Hub, pubsub *ws.PubSub, friendGetter FriendIDsGetter) *OnlineService { return &OnlineService{ - rdb: rdb, - hub: hub, - pubsub: pubsub, + rdb: rdb, + hub: hub, + pubsub: pubsub, + friendGetter: friendGetter, } } @@ -53,7 +60,11 @@ func (s *OnlineService) UserOnline(ctx context.Context, userID int64, ip string) ConnectAt: time.Now().Format("2006-01-02 15:04:05"), IP: ip, } - statusJSON, _ := json.Marshal(status) + statusJSON, err := json.Marshal(status) + if err != nil { + logs.Error(ctx, funcName, "序列化用户状态失败", zap.Int64("user_id", userID), zap.Error(err)) + return + } pipe.Set(ctx, statusKey(userID), statusJSON, statusTTL) if _, err := pipe.Exec(ctx); err != nil { @@ -63,6 +74,15 @@ func (s *OnlineService) UserOnline(ctx context.Context, userID int64, ip string) } logs.Info(ctx, funcName, "用户上线", zap.Int64("user_id", userID)) + + if s.friendGetter != nil { + friendIDs, err := s.friendGetter.GetFriendIDs(ctx, userID) + if err != nil { + logs.Warn(ctx, funcName, "获取好友列表失败,跳过上线通知", zap.Error(err)) + } else if len(friendIDs) > 0 { + s.NotifyFriendsStatusChange(ctx, userID, true, friendIDs) + } + } } // UserOffline 用户下线:清除 Redis + 通知在线好友 @@ -79,11 +99,23 @@ func (s *OnlineService) UserOffline(ctx context.Context, userID int64) { } logs.Info(ctx, funcName, "用户下线", zap.Int64("user_id", userID)) + + if s.friendGetter != nil { + friendIDs, err := s.friendGetter.GetFriendIDs(ctx, userID) + if err != nil { + logs.Warn(ctx, funcName, "获取好友列表失败,跳过下线通知", zap.Error(err)) + } else if len(friendIDs) > 0 { + s.NotifyFriendsStatusChange(ctx, userID, false, friendIDs) + } + } } // HeartbeatRenew 心跳续期:延长状态 TTL func (s *OnlineService) HeartbeatRenew(ctx context.Context, userID int64) { - s.rdb.Expire(ctx, statusKey(userID), statusTTL) + if err := s.rdb.Expire(ctx, statusKey(userID), statusTTL).Err(); err != nil { + logs.Warn(ctx, "ws.online_service.HeartbeatRenew", "心跳续期失败", + zap.Int64("user_id", userID), zap.Error(err)) + } } // IsOnline 检查用户是否在线(Redis 查询) diff --git a/backend/go-service/app/ws/provider.go b/backend/go-service/app/ws/provider.go index 2965756..2f631ba 100644 --- a/backend/go-service/app/ws/provider.go +++ b/backend/go-service/app/ws/provider.go @@ -8,6 +8,7 @@ import ( ) // ProvideHub 创建并启动 Hub 实例 +// Hub.Run() 在后台 goroutine 运行,服务关闭时通过 hub.Stop() 优雅退出 func ProvideHub() *ws.Hub { hub := ws.NewHub() go hub.Run() @@ -20,13 +21,13 @@ func ProvidePubSub(rdb *redis.Client, hub *ws.Hub) *ws.PubSub { } // ProvideOnlineService 创建在线状态管理服务 -func ProvideOnlineService(rdb *redis.Client, hub *ws.Hub, pubsub *ws.PubSub) *OnlineService { - return NewOnlineService(rdb, hub, pubsub) +func ProvideOnlineService(rdb *redis.Client, hub *ws.Hub, pubsub *ws.PubSub, friendGetter FriendIDsGetter) *OnlineService { + return NewOnlineService(rdb, hub, pubsub, friendGetter) } // ProvideWSHandler 创建 WebSocket Handler -func ProvideWSHandler(hub *ws.Hub, pubsub *ws.PubSub, cfg *config.JWTConfig, onlineService *OnlineService) *Handler { - return NewHandler(hub, pubsub, cfg, onlineService) +func ProvideWSHandler(hub *ws.Hub, pubsub *ws.PubSub, cfg *config.JWTConfig, onlineService *OnlineService, tokenValidator TokenValidator) *Handler { + return NewHandler(hub, pubsub, cfg, onlineService, tokenValidator) } // WSSet WebSocket 模块 Wire Provider Set diff --git a/backend/go-service/pkg/ws/client.go b/backend/go-service/pkg/ws/client.go index 24daa9f..255cc5a 100644 --- a/backend/go-service/pkg/ws/client.go +++ b/backend/go-service/pkg/ws/client.go @@ -2,6 +2,7 @@ package ws import ( "encoding/json" + "sync/atomic" "time" "github.com/echochat/backend/pkg/logs" @@ -28,6 +29,7 @@ type Client struct { send chan []byte // 待发送消息缓冲队列 UserID int64 // 关联的用户 ID onDisconnect DisconnectHandler // 断线回调(清理在线状态等) + closedByHub int32 // 原子标记:是否被 Hub 主动踢出(用于避免重复连接时的竞态清理) } // NewClient 创建客户端实例 @@ -45,6 +47,18 @@ func (c *Client) SetOnDisconnect(handler DisconnectHandler) { c.onDisconnect = handler } +// SetClosedByHub 标记此连接被 Hub 主动关闭(重复连接踢出场景) +// 线程安全,使用 atomic 操作 +func (c *Client) SetClosedByHub() { + atomic.StoreInt32(&c.closedByHub, 1) +} + +// IsClosedByHub 检查此连接是否被 Hub 主动关闭 +// 断线回调中使用此方法判断是否需要执行下线清理 +func (c *Client) IsClosedByHub() bool { + return atomic.LoadInt32(&c.closedByHub) == 1 +} + // MessageHandler 消息处理回调函数类型 type MessageHandler func(client *Client, msg *Message) diff --git a/backend/go-service/pkg/ws/hub.go b/backend/go-service/pkg/ws/hub.go index cae90a2..4df7201 100644 --- a/backend/go-service/pkg/ws/hub.go +++ b/backend/go-service/pkg/ws/hub.go @@ -8,12 +8,13 @@ import ( ) // Hub 管理所有活跃的 WebSocket 客户端连接 -// 提供按 userID 注册/注销/查找连接的能力 +// 提供按 userID 注册/注销/查找连接的能力,支持优雅关闭 type Hub struct { clients map[int64]*Client // userID -> Client 映射 register chan *Client // 注册通道 unregister chan *Client // 注销通道 mu sync.RWMutex // 保护 clients map 的读写锁 + stopCh chan struct{} // 停止信号,用于优雅关闭 Run 循环 } // NewHub 创建 Hub 实例 @@ -22,19 +23,32 @@ func NewHub() *Hub { clients: make(map[int64]*Client), register: make(chan *Client, 256), unregister: make(chan *Client, 256), + stopCh: make(chan struct{}), } } // Run 启动 Hub 主循环,处理连接注册和注销 -// 应在单独的 goroutine 中运行 +// 应在单独的 goroutine 中运行,通过 Stop() 方法优雅关闭 func (h *Hub) Run() { for { select { + case <-h.stopCh: + h.mu.Lock() + for uid, client := range h.clients { + client.SetClosedByHub() + close(client.send) + delete(h.clients, uid) + } + h.mu.Unlock() + logs.Info(nil, "ws.hub.Run", "Hub 已停止,所有连接已关闭") + return + case client := <-h.register: h.mu.Lock() if old, ok := h.clients[client.UserID]; ok { logs.Info(nil, "ws.hub.Run", "用户重复连接,关闭旧连接", zap.Int64("user_id", client.UserID)) + old.SetClosedByHub() close(old.send) } h.clients[client.UserID] = client @@ -59,6 +73,11 @@ func (h *Hub) Run() { } } +// Stop 优雅关闭 Hub,停止 Run 循环并断开所有客户端连接 +func (h *Hub) Stop() { + close(h.stopCh) +} + // Register 注册客户端连接 func (h *Hub) Register(client *Client) { h.register <- client @@ -78,7 +97,7 @@ func (h *Hub) GetClient(userID int64) (*Client, bool) { } // SendToUser 向指定用户发送消息(仅本地 Hub) -// 如果用户不在本实例,返回 false +// 如果用户不在本实例或缓冲区满,返回 false func (h *Hub) SendToUser(userID int64, data []byte) bool { h.mu.RLock() client, ok := h.clients[userID] @@ -92,6 +111,9 @@ func (h *Hub) SendToUser(userID int64, data []byte) bool { case client.send <- data: return true default: + logs.Warn(nil, "ws.hub.SendToUser", "发送缓冲区已满,消息被丢弃", + zap.Int64("user_id", userID), + zap.Int("data_len", len(data))) return false } } diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index 3b98792..23ddcbc 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -89,8 +89,8 @@ EchoChat 采用 **「精简单体 + 媒体微服务」** 架构,核心思想 | 模块 | 职责 | 状态 | |------|------|------| | auth | 用户注册/登录、有状态 JWT Token 管理(Redis 存储 + client_type 隔离)、RBAC 角色权限(level 等级体系:1=超管, 10=管理员, 100=用户) | ✅ Phase 1 | -| ws | WebSocket 连接管理(Hub/Client/PubSub)、在线状态管理(Redis SET + TTL 心跳续期) | ✅ Phase 2a | -| contact | 好友关系管理(申请/接受/拒绝/删除/拉黑)、好友分组(CRUD + 移动)、用户搜索、好友推荐 | ✅ Phase 2a | +| ws | WebSocket 连接管理(Hub/Client/PubSub)、在线状态管理(Redis SET + TTL 心跳续期)、好友上下线实时通知(FriendIDsGetter 接口注入) | ✅ Phase 2a | +| contact | 好友关系管理(申请/接受/拒绝/删除/拉黑)、好友分组(CRUD + 移动)、用户搜索、好友推荐(批量查询优化) | ✅ Phase 2a | | im | 即时消息收发、会话管理、消息存储 | 🔜 Phase 2b | | meeting | 会议创建/管理、信令转发、mediasoup 资源编排 | 📋 后续 | | notify | 通知推送、会议邀请、好友申请通知 | 📋 后续 | @@ -209,7 +209,7 @@ mediasoup C++ SFU 的 **"遥控器"**,不懂业务、不懂用户、只懂媒 | 规则 | 说明 | |------|------| -| 模块间零直接引用 | `auth` 不会 import `im` 内部代码,通过 interface 通信 | +| 模块间零直接引用 | `auth` 不会 import `im` 内部代码,通过 interface 通信(实例:ws.FriendIDsGetter 由 contact.FriendshipDAO 实现) | | 模块自包含路由 | 每个模块在自己目录内维护 `router.go`,拆分时整目录搬走 | | 主路由仅做汇总 | `router/router.go` 只注册各模块路由,不含具体路由定义 | | 数据库表按模块前缀 | `auth_users`、`im_messages`、`meeting_rooms`,后期可分库 | diff --git a/docs/plans/2026-03-02-phase2b-architecture-notes.md b/docs/plans/2026-03-02-phase2b-architecture-notes.md new file mode 100644 index 0000000..618d543 --- /dev/null +++ b/docs/plans/2026-03-02-phase2b-architecture-notes.md @@ -0,0 +1,229 @@ +# Phase 2b 架构建议备忘录:即时通讯消息系统 + +> **创建日期:** 2026-03-02 +> **状态:** 📋 待设计(供 Phase 2b 设计阶段参考) +> **前置依赖:** Phase 2a 全部完成(WebSocket + 联系人管理) + +--- + +## 一、Phase 2a 经验总结与架构教训 + +### 1.1 接口注入模式(Interface Injection) + +Phase 2a 后期修复中,`ws.OnlineService` 需要获取好友列表来推送上下线通知,但不应直接依赖 `contact` 包。通过定义 `FriendIDsGetter` 接口,由 `FriendshipDAO` 隐式实现,在 Wire 中注入——这是跨模块通信的标准模式。 + +**Phase 2b 建议:** IM 模块同样需要获取好友/联系人数据(如验证单聊权限、获取会话成员信息),必须遵循相同的接口注入模式,避免 `im` 包直接 import `contact` 包。 + +需要预定义的接口: + +```go +// im 模块可能需要的外部依赖接口 +type FriendChecker interface { + IsFriend(ctx context.Context, userID, targetID int64) (bool, error) +} + +type UserInfoGetter interface { + GetUsersByIDs(ctx context.Context, userIDs []int64) ([]UserBasicInfo, error) +} +``` + +### 1.2 批量查询模式 + +Phase 2a 的 `GetRecommendFriends` 最初对每个候选人单独查询用户信息,性能极差。修复后使用 `GetUsersByIDs` 一次批量查询 + Map 映射。 + +**Phase 2b 建议:** IM 消息列表中需要展示发送者信息(头像、昵称),必须使用批量查询模式: +- 收集一页消息的所有 `sender_id` +- 去重后一次 IN 查询获取用户信息 +- 构建 `map[int64]UserBasicInfo` 映射 +- 按消息顺序填充发送者信息 + +### 1.3 Admin 端查询模式 + +`OnlineManageService` 最初只返回 `[]int64`,缺少用户名导致管理端无法展示。修复后注入 `*gorm.DB` 查询用户表补充信息。 + +**Phase 2b 建议:** 管理端的消息管理/会话管理 API 从一开始就设计为返回完整信息的 DTO,不要只返回 ID。 + +--- + +## 二、IM 模块架构建议 + +### 2.1 模块结构 + +``` +app/im/ +├── controller/ +│ └── im_controller.go # REST API 控制器 +├── service/ +│ └── im_service.go # 业务逻辑(会话管理、消息收发) +├── dao/ +│ ├── conversation_dao.go # 会话 DAO +│ └── message_dao.go # 消息 DAO +├── model/ +│ ├── conversation.go # im_conversations 模型 +│ ├── conversation_member.go # im_conversation_members 模型 +│ └── message.go # im_messages 模型 +├── handler/ +│ └── message_handler.go # WebSocket 消息事件处理器 +├── router.go # 路由注册 +└── provider.go # Wire Provider Set +``` + +### 2.2 消息收发链路设计 + +``` +发送者 Client + │ + ├─ REST: POST /api/v1/im/conversations/:id/messages(HTTP 发消息) + │ 或 + ├─ WS: im.message.send(WebSocket 发消息) + │ + ▼ +IM Service + │── 权限校验(是否为会话成员) + │── 消息写入 PostgreSQL (im_messages) + │── 更新会话 last_message 信息 + │── 更新 Redis 未读计数 (HINCRBY echo:im:unread:{member_id} conv_id 1) + │ + ▼ +PubSub.PublishToUser(复用 Phase 2a 的 Pub/Sub 基础设施) + │── 推送给会话内所有在线成员 + │── 事件: im.message.new + │ + ▼ +接收者 Client(实时收到消息推送) +``` + +### 2.3 依赖注入设计 + +```go +type IMService struct { + conversationDAO *dao.ConversationDAO + messageDAO *dao.MessageDAO + pubsub *ws.PubSub // 复用 Phase 2a 的 Pub/Sub + friendChecker FriendChecker // 接口注入,contact.FriendshipDAO 实现 + userInfoGetter UserInfoGetter // 接口注入,批量获取用户信息 +} +``` + +Wire 注入链: +- `FriendshipDAO` → 隐式实现 `FriendChecker`(已有 `IsFriend` 方法) +- `FriendshipDAO` → 隐式实现 `UserInfoGetter`(已有 `GetUsersByIDs` 方法) + +### 2.4 WebSocket 事件处理器 + +Phase 2a 的 WebSocket Handler 只处理心跳和连接管理。Phase 2b 需要增加消息事件路由: + +```go +// 建议在 app/ws/handler.go 中增加事件分发机制 +// 或在 app/im/handler/ 下实现 IM 事件处理器,注册到 Hub + +// 需要处理的 WS 事件 +// im.message.send → 发送消息 +// im.message.read → 标记已读 +// im.typing.start → 正在输入 +// im.typing.stop → 停止输入 +``` + +**建议方案:** 在 `pkg/ws/hub.go` 或 `app/ws/handler.go` 中实现事件路由表(`map[string]EventHandler`),各模块注册自己的事件处理器,避免 handler.go 膨胀成巨型文件。 + +--- + +## 三、数据库注意事项 + +### 3.1 单聊会话去重 + +单聊会话应保证两个用户之间只有一个会话。建议方案: +- 创建单聊时,用两个 user_id 的较小值和较大值组合查询是否已存在 +- 或增加唯一约束 + 规范化存储(小 ID 在前) + +### 3.2 消息分页查询 + +`im_messages` 将是数据量最大的表。已有索引 `idx_im_messages_conv_time (conversation_id, created_at DESC)`。 + +分页建议: +- 使用游标分页(`WHERE created_at < ? AND conversation_id = ? ORDER BY created_at DESC LIMIT ?`)而非 OFFSET 分页 +- 前端传 `before_msg_id` 或 `before_time` 参数 + +### 3.3 未读消息计数 + +Redis HASH `echo:im:unread:{user_id}` → `{conv_id: count}` +- 收到新消息:`HINCRBY echo:im:unread:{user_id} {conv_id} 1` +- 标记已读:`HDEL echo:im:unread:{user_id} {conv_id}` +- 获取总未读数:`HVALS echo:im:unread:{user_id}` 求和 + +--- + +## 四、DTO 设计建议 + +```go +// 会话列表项(首页会话列表展示用) +type ConversationItem struct { + ID int64 `json:"id"` + Type int `json:"type"` // 1=单聊, 2=群聊 + Name string `json:"name"` // 群聊名称 / 对方昵称 + Avatar string `json:"avatar"` // 群头像 / 对方头像 + LastMessage string `json:"last_message"` // 最后一条消息预览 + LastMessageTime string `json:"last_message_time"` // 最后消息时间 + UnreadCount int `json:"unread_count"` // 未读消息数 + IsPinned bool `json:"is_pinned"` // 是否置顶 +} + +// 消息项 +type MessageItem struct { + ID int64 `json:"id"` + SenderID int64 `json:"sender_id"` + SenderName string `json:"sender_name"` // 批量查询填充 + SenderAvatar string `json:"sender_avatar"` // 批量查询填充 + Type int `json:"type"` + Content string `json:"content"` + Extra interface{} `json:"extra,omitempty"` + Status int `json:"status"` + CreatedAt string `json:"created_at"` +} +``` + +--- + +## 五、前端注意事项 + +### 5.1 WebSocket 事件集成 + +前台 `services/websocket.js` 已支持事件监听(`on/off`),Phase 2b 需要注册新事件: +- `im.message.new` → 更新会话列表 + 未读数 + 当前聊天窗口 +- `im.typing.start/stop` → 显示"正在输入..." + +### 5.2 Store 设计 + +``` +store/ +├── chat.js # 会话列表、当前会话、消息缓存(新增) +└── ...existing stores +``` + +消息缓存策略: +- 每个会话缓存最新 N 条消息在内存中 +- 向上翻页时 REST 拉取历史消息 +- 新消息通过 WS 推送自动追加 + +--- + +## 六、管理端扩展建议 + +Phase 2b 管理端可暂不实现消息管理,但如果实现,参考 Phase 2a 的模式: +- `admin/service/im_manage_service.go` +- 注入 `*gorm.DB` 查询 im_messages + auth_users +- 返回包含发送者用户名的完整 DTO(不要只返回 ID) + +--- + +## 七、Phase 2b 建议的 Task 拆分思路 + +1. 设计文档 + 数据库迁移(im_conversations, im_conversation_members, im_messages) +2. IM Model + DAO 层 +3. IM Service 层(含接口注入设计) +4. IM Controller + 路由 + Wire 集成 +5. WebSocket 事件路由机制 + IM 事件处理器 +6. 前台会话列表页 + 聊天页 +7. 前台 chat Store + API 封装 +8. 管理端扩展(可选) +9. 集成测试 + 文档更新 diff --git a/docs/progress/CURRENT_STATUS.md b/docs/progress/CURRENT_STATUS.md index e01e8af..552080c 100644 --- a/docs/progress/CURRENT_STATUS.md +++ b/docs/progress/CURRENT_STATUS.md @@ -1,10 +1,11 @@ # EchoChat 项目开发进度 -> **最后更新**:2026-03-02(Phase 2a 完成 - WebSocket 实时通讯与联系人管理) -> **当前阶段**:Phase 2a - WebSocket 实时通讯与联系人管理 +> **最后更新**:2026-03-02(Phase 2a 代码审查全面修复 + Phase 2b 架构建议) +> **当前阶段**:Phase 2a 已完成,Phase 2b 待设计 > **当前分支**:`feature/phase2a-websocket-contacts` > **实施计划**:`phase_2a_实施计划_221003ce.plan.md` > **设计文档**:`docs/plans/2026-03-02-phase2a-design.md` +> **Phase 2b 架构备忘**:`docs/plans/2026-03-02-phase2b-architecture-notes.md` --- @@ -200,7 +201,46 @@ cd frontend && npm run dev:h5 --- -## 八、下一阶段规划 +## 八、Phase 2a 后期 Bug 修复记录(2026-03-02) + +### 修复 1: GetRecommendFriends 返回空数据 +- **问题**:对每个候选人调用 `SearchUsers` 并忽略结果,返回的 DTO 只有 ID 字段 +- **修复**:FriendshipDAO 新增 `GetUsersByIDs` 批量查询方法,Service 层收集所有候选人 ID 后一次查询,正确填充 Username/Nickname/Avatar +- **改进**:排序算法从手动冒泡改为 `sort.Slice` + +### 修复 2: 上下线好友通知未实现 +- **问题**:`UserOnline`/`UserOffline` 没有调用已有的 `NotifyFriendsStatusChange` 方法 +- **修复**:OnlineService 新增 `FriendIDsGetter` 接口依赖(由 FriendshipDAO 隐式实现),在上线/下线时获取好友列表并推送状态变更通知 +- **架构**:使用接口注入避免 ws 包直接依赖 contact 包 + +### 修复 3: 管理端在线用户 API 缺少用户名 +- **问题**:`GetOnlineUsers` 只返回 `[]int64`,管理端无法展示用户名 +- **修复**:OnlineManageService 注入 `*gorm.DB`,返回 `[]OnlineUserInfo`(含 user_id + username),查询 auth_users 表补充信息 + +### 修复 4: WebSocket Token 未校验 Redis 状态(I2) +- **问题**:`ws.handler.Upgrade` 只校验 JWT 签名和过期时间,未检查 Token 是否仍在 Redis 中有效(已登出用户仍可建立 WS 连接) +- **修复**:新增 `TokenValidator` 接口,由 `AuthService` 实现;`Upgrade` 流程增加 Redis Token 校验步骤 +- **架构**:使用接口注入避免 ws 包直接依赖 auth 包 + +### 修复 5: HeartbeatRenew Redis 错误未检查(M6) +- **问题**:`HeartbeatRenew` 中 `rdb.Expire` 调用结果被忽略 +- **修复**:增加错误检查与日志记录 + +### 修复 6: json.Marshal 错误未处理(M2) +- **问题**:`handler.go` 心跳/默认响应的 `MarshalResponse` 和 `online_service.go` 中 `json.Marshal` 错误被忽略 +- **修复**:所有 Marshal 调用增加错误检查,失败时记录日志并提前返回 + +### 修复 7: Controller 统一错误处理(I8) +- **问题**:`contact_controller.go` 中多数 endpoint 使用硬编码的 `utils.ResponseError`,未经过 `handleError` 业务错误映射 +- **修复**:所有 13 个 endpoint 统一走 `handleError`,确保已知业务错误(404/400/403)被正确返回 + +### 修复 8: 管理端 Controller 注释与日志规范化(M1) +- **问题**:`online_controller.go` 和 `contact_manage_controller.go` 缺少包注释、函数注释和 `funcName` 日志模式 +- **修复**:补全所有注释,增加 `funcName` + `logs.Error/Info` 结构化日志 + +--- + +## 九、下一阶段规划 ### Phase 2b - 即时通讯消息系统 - 会话管理(单聊/群聊) diff --git a/frontend/src/pages/contact/blacklist.vue b/frontend/src/pages/contact/blacklist.vue index 1d94d93..5a17fbf 100644 --- a/frontend/src/pages/contact/blacklist.vue +++ b/frontend/src/pages/contact/blacklist.vue @@ -53,23 +53,12 @@