fix(phase2a): 代码审查修复 - 8项关键/重要问题

安全修复:
- WebSocket Token 增加 Redis 有效性校验(已登出用户无法建立 WS)

功能修复:
- GetRecommendFriends 改为批量查询,正确返回用户名/昵称/头像
- 上下线通知:OnlineService 通过接口注入获取好友列表推送状态变更
- 管理端在线用户 API 补充用户名信息

代码质量:
- 所有 json.Marshal/Redis 错误增加检查与日志
- ContactController 13 个 endpoint 统一走 handleError 业务错误映射
- 管理端 Controller 补全包注释、函数注释和结构化日志
- 前端 5 个联系人页面 avatar 工具函数抽取到 utils/avatar.js

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-03-02 18:48:28 +08:00
parent 8ec0261427
commit 2c59500e27
26 changed files with 632 additions and 153 deletions

View File

@@ -1,41 +1,56 @@
// Package controller 提供 admin 模块的 HTTP 接口处理
package controller package controller
import ( import (
"strconv" "strconv"
"github.com/echochat/backend/app/admin/service" "github.com/echochat/backend/app/admin/service"
"github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/utils" "github.com/echochat/backend/pkg/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
) )
// ContactManageController 联系人关系管理控制器(管理端)
type ContactManageController struct { type ContactManageController struct {
contactManageService *service.ContactManageService contactManageService *service.ContactManageService
} }
// NewContactManageController 创建 ContactManageController 实例
func NewContactManageController(contactManageService *service.ContactManageService) *ContactManageController { func NewContactManageController(contactManageService *service.ContactManageService) *ContactManageController {
return &ContactManageController{contactManageService: contactManageService} return &ContactManageController{contactManageService: contactManageService}
} }
// GetAllContacts GET /api/v1/admin/contacts // GetAllContacts 获取所有好友关系列表(分页)
// GET /api/v1/admin/contacts
func (ctl *ContactManageController) GetAllContacts(c *gin.Context) { func (ctl *ContactManageController) GetAllContacts(c *gin.Context) {
funcName := "admin.contact_manage_controller.GetAllContacts"
ctx := c.Request.Context() ctx := c.Request.Context()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
contacts, total, err := ctl.contactManageService.GetAllFriendships(ctx, page, pageSize) contacts, total, err := ctl.contactManageService.GetAllFriendships(ctx, page, pageSize)
if err != nil { if err != nil {
logs.Error(ctx, funcName, "获取好友关系列表失败", zap.Error(err))
utils.ResponseError(c, "获取好友关系列表失败") utils.ResponseError(c, "获取好友关系列表失败")
return return
} }
logs.Info(ctx, funcName, "获取好友关系列表成功",
zap.Int64("total", total), zap.Int("page", page))
utils.ResponseOK(c, gin.H{ utils.ResponseOK(c, gin.H{
"list": contacts, "list": contacts,
"total": total, "total": total,
}) })
} }
// DeleteContact DELETE /api/v1/admin/contacts/:id // DeleteContact 删除好友关系
// DELETE /api/v1/admin/contacts/:id
func (ctl *ContactManageController) DeleteContact(c *gin.Context) { func (ctl *ContactManageController) DeleteContact(c *gin.Context) {
funcName := "admin.contact_manage_controller.DeleteContact"
ctx := c.Request.Context() ctx := c.Request.Context()
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil { if err != nil {
utils.ResponseBadRequest(c, "ID 格式错误") utils.ResponseBadRequest(c, "ID 格式错误")
@@ -43,8 +58,12 @@ func (ctl *ContactManageController) DeleteContact(c *gin.Context) {
} }
if err := ctl.contactManageService.DeleteFriendship(ctx, id); err != nil { if err := ctl.contactManageService.DeleteFriendship(ctx, id); err != nil {
logs.Error(ctx, funcName, "删除好友关系失败",
zap.Int64("friendship_id", id), zap.Error(err))
utils.ResponseError(c, "删除好友关系失败") utils.ResponseError(c, "删除好友关系失败")
return return
} }
logs.Info(ctx, funcName, "删除好友关系成功", zap.Int64("friendship_id", id))
utils.ResponseOK(c, nil) utils.ResponseOK(c, nil)
} }

View File

@@ -1,37 +1,54 @@
// Package controller 提供 admin 模块的 HTTP 接口处理
package controller package controller
import ( import (
"github.com/echochat/backend/app/admin/service" "github.com/echochat/backend/app/admin/service"
"github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/utils" "github.com/echochat/backend/pkg/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
) )
// OnlineController 在线用户管理控制器(管理端)
type OnlineController struct { type OnlineController struct {
onlineManageService *service.OnlineManageService onlineManageService *service.OnlineManageService
} }
// NewOnlineController 创建 OnlineController 实例
func NewOnlineController(onlineManageService *service.OnlineManageService) *OnlineController { func NewOnlineController(onlineManageService *service.OnlineManageService) *OnlineController {
return &OnlineController{onlineManageService: onlineManageService} 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) { func (ctl *OnlineController) GetOnlineUsers(c *gin.Context) {
funcName := "admin.online_controller.GetOnlineUsers"
ctx := c.Request.Context() ctx := c.Request.Context()
userIDs, err := ctl.onlineManageService.GetOnlineUsers(ctx)
users, err := ctl.onlineManageService.GetOnlineUsers(ctx)
if err != nil { if err != nil {
logs.Error(ctx, funcName, "获取在线用户列表失败", zap.Error(err))
utils.ResponseError(c, "获取在线用户列表失败") utils.ResponseError(c, "获取在线用户列表失败")
return 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) { func (ctl *OnlineController) GetOnlineCount(c *gin.Context) {
funcName := "admin.online_controller.GetOnlineCount"
ctx := c.Request.Context() ctx := c.Request.Context()
count, err := ctl.onlineManageService.GetOnlineCount(ctx) count, err := ctl.onlineManageService.GetOnlineCount(ctx)
if err != nil { if err != nil {
logs.Error(ctx, funcName, "获取在线用户数失败", zap.Error(err))
utils.ResponseError(c, "获取在线用户数失败") utils.ResponseError(c, "获取在线用户数失败")
return return
} }
logs.Info(ctx, funcName, "获取在线用户数成功", zap.Int64("count", count))
utils.ResponseOK(c, gin.H{"count": count}) utils.ResponseOK(c, gin.H{"count": count})
} }

View File

@@ -33,7 +33,10 @@ func (s *ContactManageService) GetAllFriendships(ctx context.Context, page, page
logs.Debug(ctx, funcName, "管理端查询好友关系") logs.Debug(ctx, funcName, "管理端查询好友关系")
var total int64 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 var results []AdminFriendship
offset := (page - 1) * pageSize 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 { 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{}) if err := tx.Where("user_id = ? AND friend_id = ?", f.UserID, f.FriendID).
tx.Where("user_id = ? AND friend_id = ?", f.FriendID, f.UserID).Delete(&model.Friendship{}) Delete(&model.Friendship{}).Error; err != nil {
return nil return err
}
return tx.Where("user_id = ? AND friend_id = ?", f.FriendID, f.UserID).
Delete(&model.Friendship{}).Error
}) })
} }

View File

@@ -3,24 +3,72 @@ package service
import ( import (
"context" "context"
authModel "github.com/echochat/backend/app/auth/model"
wsApp "github.com/echochat/backend/app/ws" wsApp "github.com/echochat/backend/app/ws"
"github.com/echochat/backend/pkg/logs" "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 { type OnlineManageService struct {
onlineService *wsApp.OnlineService onlineService *wsApp.OnlineService
db *gorm.DB
} }
func NewOnlineManageService(onlineService *wsApp.OnlineService) *OnlineManageService { // NewOnlineManageService 创建 OnlineManageService 实例
return &OnlineManageService{onlineService: onlineService} 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" funcName := "service.online_manage_service.GetOnlineUsers"
logs.Debug(ctx, funcName, "获取在线用户列表") 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) { func (s *OnlineManageService) GetOnlineCount(ctx context.Context) (int64, error) {
return s.onlineService.GetOnlineCount(ctx) return s.onlineService.GetOnlineCount(ctx)
} }

View File

@@ -41,7 +41,7 @@ func (ctl *ContactController) GetFriendList(c *gin.Context) {
friends, err := ctl.contactService.GetFriendList(ctx, userID, groupID) friends, err := ctl.contactService.GetFriendList(ctx, userID, groupID)
if err != nil { if err != nil {
utils.ResponseError(c, "获取好友列表失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, friends) utils.ResponseOK(c, friends)
@@ -131,7 +131,7 @@ func (ctl *ContactController) GetPendingRequests(c *gin.Context) {
requests, err := ctl.contactService.GetPendingRequests(ctx, userID) requests, err := ctl.contactService.GetPendingRequests(ctx, userID)
if err != nil { if err != nil {
utils.ResponseError(c, "获取好友申请列表失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, requests) 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 { if err := ctl.contactService.DeleteFriend(ctx, userID, friendID); err != nil {
utils.ResponseError(c, "删除好友失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, nil) 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 { if err := ctl.contactService.UpdateRemark(ctx, userID, friendID, req.Remark); err != nil {
utils.ResponseError(c, "更新备注失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, nil) 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 { if err := ctl.contactService.UnblockUser(ctx, userID, targetID); err != nil {
utils.ResponseError(c, "取消拉黑失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, nil) utils.ResponseOK(c, nil)
@@ -248,7 +248,7 @@ func (ctl *ContactController) GetBlockList(c *gin.Context) {
blocked, err := ctl.contactService.GetBlockList(ctx, userID) blocked, err := ctl.contactService.GetBlockList(ctx, userID)
if err != nil { if err != nil {
utils.ResponseError(c, "获取黑名单失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, blocked) 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) users, total, err := ctl.contactService.SearchUsers(ctx, userID, keyword, page, pageSize)
if err != nil { if err != nil {
utils.ResponseError(c, "搜索用户失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, gin.H{ utils.ResponseOK(c, gin.H{
@@ -296,7 +296,7 @@ func (ctl *ContactController) GetRecommendFriends(c *gin.Context) {
friends, err := ctl.contactService.GetRecommendFriends(ctx, userID) friends, err := ctl.contactService.GetRecommendFriends(ctx, userID)
if err != nil { if err != nil {
utils.ResponseError(c, "获取好友推荐失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, friends) utils.ResponseOK(c, friends)
@@ -314,7 +314,7 @@ func (ctl *ContactController) GetGroups(c *gin.Context) {
groups, err := ctl.contactService.GetGroups(ctx, userID) groups, err := ctl.contactService.GetGroups(ctx, userID)
if err != nil { if err != nil {
utils.ResponseError(c, "获取分组列表失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, groups) utils.ResponseOK(c, groups)
@@ -338,7 +338,7 @@ func (ctl *ContactController) CreateGroup(c *gin.Context) {
group, err := ctl.contactService.CreateGroup(ctx, userID, req.Name) group, err := ctl.contactService.CreateGroup(ctx, userID, req.Name)
if err != nil { if err != nil {
utils.ResponseError(c, "创建分组失败") ctl.handleError(c, err)
return return
} }
utils.ResponseCreated(c, group) 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 { if err := ctl.contactService.UpdateGroup(ctx, userID, groupID, req.Name, req.SortOrder); err != nil {
utils.ResponseError(c, "修改分组失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, nil) 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 { if err := ctl.contactService.DeleteGroup(ctx, userID, groupID); err != nil {
utils.ResponseError(c, "删除分组失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, nil) 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 { if err := ctl.contactService.MoveToGroup(ctx, userID, friendID, req.GroupID); err != nil {
utils.ResponseError(c, "移动好友到分组失败") ctl.handleError(c, err)
return return
} }
utils.ResponseOK(c, nil) utils.ResponseOK(c, nil)

View File

@@ -81,9 +81,11 @@ func (d *FriendGroupDAO) DeleteGroup(ctx context.Context, groupID int64, userID
zap.Int64("group_id", groupID), zap.Int64("user_id", userID)) zap.Int64("group_id", groupID), zap.Int64("user_id", userID))
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { 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). 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). result := tx.Where("id = ? AND user_id = ?", groupID, userID).
Delete(&model.FriendGroup{}) Delete(&model.FriendGroup{})

View File

@@ -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)) zap.Int64("user_id", userID), zap.Int64("target_id", targetID))
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { 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). userID, targetID, targetID, userID).
Delete(&model.Friendship{}) Delete(&model.Friendship{}).Error; err != nil {
return err
}
block := &model.Friendship{ block := &model.Friendship{
UserID: userID, UserID: userID,
@@ -324,6 +326,23 @@ func (d *FriendshipDAO) GetFriendIDs(ctx context.Context, userID int64) ([]int64
return ids, err 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 统计每个分组的好友数 // CountFriendsByGroup 统计每个分组的好友数
func (d *FriendshipDAO) CountFriendsByGroup(ctx context.Context, userID int64) (map[int64]int, error) { func (d *FriendshipDAO) CountFriendsByGroup(ctx context.Context, userID int64) (map[int64]int, error) {
type GroupCount struct { type GroupCount struct {

View File

@@ -4,6 +4,7 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"sort"
"github.com/echochat/backend/app/contact/dao" "github.com/echochat/backend/app/contact/dao"
"github.com/echochat/backend/app/dto" "github.com/echochat/backend/app/dto"
@@ -287,7 +288,7 @@ func (s *ContactService) SearchUsers(ctx context.Context, userID int64, keyword
return result, total, nil return result, total, nil
} }
// GetRecommendFriends 好友推荐(基于共同好友) // GetRecommendFriends 好友推荐(基于共同好友数量排序
func (s *ContactService) GetRecommendFriends(ctx context.Context, userID int64) ([]dto.SearchUserInfo, error) { func (s *ContactService) GetRecommendFriends(ctx context.Context, userID int64) ([]dto.SearchUserInfo, error) {
funcName := "service.contact_service.GetRecommendFriends" funcName := "service.contact_service.GetRecommendFriends"
logs.Debug(ctx, funcName, "好友推荐", zap.Int64("user_id", userID)) logs.Debug(ctx, funcName, "好友推荐", zap.Int64("user_id", userID))
@@ -322,31 +323,47 @@ func (s *ContactService) GetRecommendFriends(ctx context.Context, userID int64)
id int64 id int64
count int count int
} }
var candidates []candidate candidates := make([]candidate, 0, len(candidateCount))
for id, count := range candidateCount { for id, count := range candidateCount {
candidates = append(candidates, candidate{id: id, count: count}) 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 limit := 10
if len(candidates) > limit { 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] 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)) result := make([]dto.SearchUserInfo, 0, len(candidates))
for _, c := range candidates { for _, c := range candidates {
users, _, err := s.friendshipDAO.SearchUsers(ctx, "", 0, 1, 1) if info, ok := userMap[c.id]; ok {
_ = users result = append(result, info)
_ = err }
result = append(result, dto.SearchUserInfo{
ID: c.id,
})
} }
return result, nil return result, nil

View File

@@ -47,13 +47,13 @@ func InitializeApp(cfg *config.Config) (*App, error) {
userManageController := controller2.NewUserManageController(userManageService) userManageController := controller2.NewUserManageController(userManageService)
hub := ws.ProvideHub() hub := ws.ProvideHub()
pubSub := ws.ProvidePubSub(client, hub) pubSub := ws.ProvidePubSub(client, hub)
onlineService := ws.ProvideOnlineService(client, hub, pubSub) friendshipDAO := dao3.NewFriendshipDAO(gormDB)
onlineManageService := service2.NewOnlineManageService(onlineService) onlineService := ws.ProvideOnlineService(client, hub, pubSub, friendshipDAO)
onlineManageService := service2.NewOnlineManageService(onlineService, gormDB)
onlineController := controller2.NewOnlineController(onlineManageService) onlineController := controller2.NewOnlineController(onlineManageService)
contactManageService := service2.NewContactManageService(gormDB) contactManageService := service2.NewContactManageService(gormDB)
contactManageController := controller2.NewContactManageController(contactManageService) contactManageController := controller2.NewContactManageController(contactManageService)
handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService) handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService, authService)
friendshipDAO := dao3.NewFriendshipDAO(gormDB)
friendGroupDAO := dao3.NewFriendGroupDAO(gormDB) friendGroupDAO := dao3.NewFriendGroupDAO(gormDB)
contactService := service3.NewContactService(friendshipDAO, friendGroupDAO, pubSub) contactService := service3.NewContactService(friendshipDAO, friendGroupDAO, pubSub)
contactController := controller3.NewContactController(contactService) contactController := controller3.NewContactController(contactService)

View File

@@ -15,29 +15,37 @@ import (
"go.uber.org/zap" "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{ var upgrader = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 1024, WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { CheckOrigin: func(r *http.Request) bool {
return true // 开发阶段允许所有来源,生产环境需限制 return true // TODO: 生产环境通过配置限制 allowed origins
}, },
} }
// Handler WebSocket 连接处理器 // Handler WebSocket 连接处理器
type Handler struct { type Handler struct {
hub *ws.Hub hub *ws.Hub
pubsub *ws.PubSub pubsub *ws.PubSub
jwtCfg *config.JWTConfig jwtCfg *config.JWTConfig
onlineService *OnlineService onlineService *OnlineService
tokenValidator TokenValidator
} }
// NewHandler 创建 WebSocket Handler 实例 // 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{ return &Handler{
hub: hub, hub: hub,
pubsub: pubsub, pubsub: pubsub,
jwtCfg: jwtCfg, jwtCfg: jwtCfg,
onlineService: onlineService, onlineService: onlineService,
tokenValidator: tokenValidator,
} }
} }
@@ -59,6 +67,17 @@ func (h *Handler) Upgrade(c *gin.Context) {
return 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) conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
logs.Error(nil, funcName, "WebSocket 升级失败", 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 := ws.NewClient(h.hub, conn, claims.UserID)
client.SetOnDisconnect(func(userID int64) { 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.pubsub.Unsubscribe(userID)
h.onlineService.UserOffline(context.Background(), userID) h.onlineService.UserOffline(context.Background(), userID)
}) })
@@ -97,7 +121,11 @@ func (h *Handler) createReadHandler(userID int64) ws.MessageHandler {
case "heartbeat": case "heartbeat":
h.onlineService.HeartbeatRenew(context.Background(), userID) h.onlineService.HeartbeatRenew(context.Background(), userID)
resp := ws.NewResponse(msg.Event, msg.Seq, 0, "pong", nil) 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) client.Send(data)
default: default:
resp := ws.NewResponse(msg.Event, msg.Seq, 0, "ok", nil) resp := ws.NewResponse(msg.Event, msg.Seq, 0, "ok", nil)

View File

@@ -25,19 +25,26 @@ type UserStatus struct {
IP string `json:"ip"` // 连接 IP IP string `json:"ip"` // 连接 IP
} }
// FriendIDsGetter 获取好友 ID 列表的接口(避免直接依赖 contact 模块)
type FriendIDsGetter interface {
GetFriendIDs(ctx context.Context, userID int64) ([]int64, error)
}
// OnlineService 在线状态管理服务 // OnlineService 在线状态管理服务
type OnlineService struct { type OnlineService struct {
rdb *redis.Client rdb *redis.Client
hub *ws.Hub hub *ws.Hub
pubsub *ws.PubSub pubsub *ws.PubSub
friendGetter FriendIDsGetter
} }
// NewOnlineService 创建 OnlineService 实例 // 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{ return &OnlineService{
rdb: rdb, rdb: rdb,
hub: hub, hub: hub,
pubsub: pubsub, 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"), ConnectAt: time.Now().Format("2006-01-02 15:04:05"),
IP: ip, 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) pipe.Set(ctx, statusKey(userID), statusJSON, statusTTL)
if _, err := pipe.Exec(ctx); err != nil { 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)) 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 + 通知在线好友 // UserOffline 用户下线:清除 Redis + 通知在线好友
@@ -79,11 +99,23 @@ func (s *OnlineService) UserOffline(ctx context.Context, userID int64) {
} }
logs.Info(ctx, funcName, "用户下线", zap.Int64("user_id", userID)) 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 // HeartbeatRenew 心跳续期:延长状态 TTL
func (s *OnlineService) HeartbeatRenew(ctx context.Context, userID int64) { 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 查询) // IsOnline 检查用户是否在线Redis 查询)

View File

@@ -8,6 +8,7 @@ import (
) )
// ProvideHub 创建并启动 Hub 实例 // ProvideHub 创建并启动 Hub 实例
// Hub.Run() 在后台 goroutine 运行,服务关闭时通过 hub.Stop() 优雅退出
func ProvideHub() *ws.Hub { func ProvideHub() *ws.Hub {
hub := ws.NewHub() hub := ws.NewHub()
go hub.Run() go hub.Run()
@@ -20,13 +21,13 @@ func ProvidePubSub(rdb *redis.Client, hub *ws.Hub) *ws.PubSub {
} }
// ProvideOnlineService 创建在线状态管理服务 // ProvideOnlineService 创建在线状态管理服务
func ProvideOnlineService(rdb *redis.Client, hub *ws.Hub, pubsub *ws.PubSub) *OnlineService { func ProvideOnlineService(rdb *redis.Client, hub *ws.Hub, pubsub *ws.PubSub, friendGetter FriendIDsGetter) *OnlineService {
return NewOnlineService(rdb, hub, pubsub) return NewOnlineService(rdb, hub, pubsub, friendGetter)
} }
// ProvideWSHandler 创建 WebSocket Handler // ProvideWSHandler 创建 WebSocket Handler
func ProvideWSHandler(hub *ws.Hub, pubsub *ws.PubSub, cfg *config.JWTConfig, onlineService *OnlineService) *Handler { func ProvideWSHandler(hub *ws.Hub, pubsub *ws.PubSub, cfg *config.JWTConfig, onlineService *OnlineService, tokenValidator TokenValidator) *Handler {
return NewHandler(hub, pubsub, cfg, onlineService) return NewHandler(hub, pubsub, cfg, onlineService, tokenValidator)
} }
// WSSet WebSocket 模块 Wire Provider Set // WSSet WebSocket 模块 Wire Provider Set

View File

@@ -2,6 +2,7 @@ package ws
import ( import (
"encoding/json" "encoding/json"
"sync/atomic"
"time" "time"
"github.com/echochat/backend/pkg/logs" "github.com/echochat/backend/pkg/logs"
@@ -28,6 +29,7 @@ type Client struct {
send chan []byte // 待发送消息缓冲队列 send chan []byte // 待发送消息缓冲队列
UserID int64 // 关联的用户 ID UserID int64 // 关联的用户 ID
onDisconnect DisconnectHandler // 断线回调(清理在线状态等) onDisconnect DisconnectHandler // 断线回调(清理在线状态等)
closedByHub int32 // 原子标记:是否被 Hub 主动踢出(用于避免重复连接时的竞态清理)
} }
// NewClient 创建客户端实例 // NewClient 创建客户端实例
@@ -45,6 +47,18 @@ func (c *Client) SetOnDisconnect(handler DisconnectHandler) {
c.onDisconnect = handler 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 消息处理回调函数类型 // MessageHandler 消息处理回调函数类型
type MessageHandler func(client *Client, msg *Message) type MessageHandler func(client *Client, msg *Message)

View File

@@ -8,12 +8,13 @@ import (
) )
// Hub 管理所有活跃的 WebSocket 客户端连接 // Hub 管理所有活跃的 WebSocket 客户端连接
// 提供按 userID 注册/注销/查找连接的能力 // 提供按 userID 注册/注销/查找连接的能力,支持优雅关闭
type Hub struct { type Hub struct {
clients map[int64]*Client // userID -> Client 映射 clients map[int64]*Client // userID -> Client 映射
register chan *Client // 注册通道 register chan *Client // 注册通道
unregister chan *Client // 注销通道 unregister chan *Client // 注销通道
mu sync.RWMutex // 保护 clients map 的读写锁 mu sync.RWMutex // 保护 clients map 的读写锁
stopCh chan struct{} // 停止信号,用于优雅关闭 Run 循环
} }
// NewHub 创建 Hub 实例 // NewHub 创建 Hub 实例
@@ -22,19 +23,32 @@ func NewHub() *Hub {
clients: make(map[int64]*Client), clients: make(map[int64]*Client),
register: make(chan *Client, 256), register: make(chan *Client, 256),
unregister: make(chan *Client, 256), unregister: make(chan *Client, 256),
stopCh: make(chan struct{}),
} }
} }
// Run 启动 Hub 主循环,处理连接注册和注销 // Run 启动 Hub 主循环,处理连接注册和注销
// 应在单独的 goroutine 中运行 // 应在单独的 goroutine 中运行,通过 Stop() 方法优雅关闭
func (h *Hub) Run() { func (h *Hub) Run() {
for { for {
select { 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: case client := <-h.register:
h.mu.Lock() h.mu.Lock()
if old, ok := h.clients[client.UserID]; ok { if old, ok := h.clients[client.UserID]; ok {
logs.Info(nil, "ws.hub.Run", "用户重复连接,关闭旧连接", logs.Info(nil, "ws.hub.Run", "用户重复连接,关闭旧连接",
zap.Int64("user_id", client.UserID)) zap.Int64("user_id", client.UserID))
old.SetClosedByHub()
close(old.send) close(old.send)
} }
h.clients[client.UserID] = client h.clients[client.UserID] = client
@@ -59,6 +73,11 @@ func (h *Hub) Run() {
} }
} }
// Stop 优雅关闭 Hub停止 Run 循环并断开所有客户端连接
func (h *Hub) Stop() {
close(h.stopCh)
}
// Register 注册客户端连接 // Register 注册客户端连接
func (h *Hub) Register(client *Client) { func (h *Hub) Register(client *Client) {
h.register <- client h.register <- client
@@ -78,7 +97,7 @@ func (h *Hub) GetClient(userID int64) (*Client, bool) {
} }
// SendToUser 向指定用户发送消息(仅本地 Hub // SendToUser 向指定用户发送消息(仅本地 Hub
// 如果用户不在本实例,返回 false // 如果用户不在本实例或缓冲区满,返回 false
func (h *Hub) SendToUser(userID int64, data []byte) bool { func (h *Hub) SendToUser(userID int64, data []byte) bool {
h.mu.RLock() h.mu.RLock()
client, ok := h.clients[userID] client, ok := h.clients[userID]
@@ -92,6 +111,9 @@ func (h *Hub) SendToUser(userID int64, data []byte) bool {
case client.send <- data: case client.send <- data:
return true return true
default: default:
logs.Warn(nil, "ws.hub.SendToUser", "发送缓冲区已满,消息被丢弃",
zap.Int64("user_id", userID),
zap.Int("data_len", len(data)))
return false return false
} }
} }

View File

@@ -89,8 +89,8 @@ 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 心跳续期) | ✅ 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 | 通知推送、会议邀请、好友申请通知 | 📋 后续 |
@@ -209,7 +209,7 @@ mediasoup C++ SFU 的 **"遥控器"**,不懂业务、不懂用户、只懂媒
| 规则 | 说明 | | 规则 | 说明 |
|------|------| |------|------|
| 模块间零直接引用 | `auth` 不会 import `im` 内部代码,通过 interface 通信 | | 模块间零直接引用 | `auth` 不会 import `im` 内部代码,通过 interface 通信实例ws.FriendIDsGetter 由 contact.FriendshipDAO 实现) |
| 模块自包含路由 | 每个模块在自己目录内维护 `router.go`,拆分时整目录搬走 | | 模块自包含路由 | 每个模块在自己目录内维护 `router.go`,拆分时整目录搬走 |
| 主路由仅做汇总 | `router/router.go` 只注册各模块路由,不含具体路由定义 | | 主路由仅做汇总 | `router/router.go` 只注册各模块路由,不含具体路由定义 |
| 数据库表按模块前缀 | `auth_users``im_messages``meeting_rooms`,后期可分库 | | 数据库表按模块前缀 | `auth_users``im_messages``meeting_rooms`,后期可分库 |

View File

@@ -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/messagesHTTP 发消息)
│ 或
├─ WS: im.message.sendWebSocket 发消息)
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. 集成测试 + 文档更新

View File

@@ -1,10 +1,11 @@
# EchoChat 项目开发进度 # EchoChat 项目开发进度
> **最后更新**2026-03-02Phase 2a 完成 - WebSocket 实时通讯与联系人管理 > **最后更新**2026-03-02Phase 2a 代码审查全面修复 + Phase 2b 架构建议
> **当前阶段**Phase 2a - WebSocket 实时通讯与联系人管理 > **当前阶段**Phase 2a 已完成Phase 2b 待设计
> **当前分支**`feature/phase2a-websocket-contacts` > **当前分支**`feature/phase2a-websocket-contacts`
> **实施计划**`phase_2a_实施计划_221003ce.plan.md` > **实施计划**`phase_2a_实施计划_221003ce.plan.md`
> **设计文档**`docs/plans/2026-03-02-phase2a-design.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 - 即时通讯消息系统 ### Phase 2b - 即时通讯消息系统
- 会话管理(单聊/群聊) - 会话管理(单聊/群聊)

View File

@@ -53,23 +53,12 @@
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useContactStore } from '@/store/contact' import { useContactStore } from '@/store/contact'
import { getAvatarColor, getInitial } from '@/utils/avatar'
const contactStore = useContactStore() const contactStore = useContactStore()
const loading = ref(true) const loading = ref(true)
const processingMap = reactive({}) const processingMap = reactive({})
const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#4F46E5']
const getAvatarColor = (name) => {
if (!name) return AVATAR_COLORS[0]
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
}
const getInitial = (name) => {
if (!name) return '?'
return name.charAt(0).toUpperCase()
}
const handleUnblock = (user) => { const handleUnblock = (user) => {
if (processingMap[user.user_id]) return if (processingMap[user.user_id]) return
uni.showModal({ uni.showModal({

View File

@@ -66,6 +66,7 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useContactStore } from '@/store/contact' import { useContactStore } from '@/store/contact'
import { getAvatarColor, getInitial } from '@/utils/avatar'
const contactStore = useContactStore() const contactStore = useContactStore()
const loading = ref(true) const loading = ref(true)
@@ -73,18 +74,6 @@ const friend = ref(null)
const userId = ref(0) const userId = ref(0)
const processing = ref(false) const processing = ref(false)
const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#4F46E5']
const getAvatarColor = (name) => {
if (!name) return AVATAR_COLORS[0]
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
}
const getInitial = (name) => {
if (!name) return '?'
return name.charAt(0).toUpperCase()
}
const currentGroupName = computed(() => { const currentGroupName = computed(() => {
if (!friend.value || !friend.value.group_id) return '默认分组' if (!friend.value || !friend.value.group_id) return '默认分组'
const group = contactStore.groups.find(g => g.id === friend.value.group_id) const group = contactStore.groups.find(g => g.id === friend.value.group_id)
@@ -128,7 +117,7 @@ const editRemark = () => {
} }
const selectGroup = () => { const selectGroup = () => {
const groups = [{ id: 0, name: '默认分组' }, ...contactStore.groups] const groups = [{ id: null, name: '默认分组' }, ...contactStore.groups]
const names = groups.map(g => g.name) const names = groups.map(g => g.name)
uni.showActionSheet({ uni.showActionSheet({

View File

@@ -116,6 +116,7 @@ import { ref, onMounted, computed } from 'vue'
import { useContactStore } from '@/store/contact' import { useContactStore } from '@/store/contact'
import { useWebSocketStore } from '@/store/websocket' import { useWebSocketStore } from '@/store/websocket'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { getAvatarColor, getInitial } from '@/utils/avatar'
import CustomTabBar from '@/components/CustomTabBar.vue' import CustomTabBar from '@/components/CustomTabBar.vue'
const contactStore = useContactStore() const contactStore = useContactStore()
@@ -125,19 +126,6 @@ const userStore = useUserStore()
const searchKeyword = ref('') const searchKeyword = ref('')
const loading = ref(true) const loading = ref(true)
const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#7C3AED', '#4F46E5']
const getAvatarColor = (name) => {
if (!name) return AVATAR_COLORS[0]
const code = name.charCodeAt(0)
return AVATAR_COLORS[code % AVATAR_COLORS.length]
}
const getInitial = (name) => {
if (!name) return '?'
return name.charAt(0).toUpperCase()
}
const filteredFriends = computed(() => { const filteredFriends = computed(() => {
if (!searchKeyword.value) return contactStore.friendList if (!searchKeyword.value) return contactStore.friendList
const kw = searchKeyword.value.toLowerCase() const kw = searchKeyword.value.toLowerCase()

View File

@@ -68,23 +68,12 @@
<script setup> <script setup>
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { useContactStore } from '@/store/contact' import { useContactStore } from '@/store/contact'
import { getAvatarColor, getInitial } from '@/utils/avatar'
const contactStore = useContactStore() const contactStore = useContactStore()
const loading = ref(true) const loading = ref(true)
const processingMap = reactive({}) const processingMap = reactive({})
const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#4F46E5']
const getAvatarColor = (name) => {
if (!name) return AVATAR_COLORS[0]
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
}
const getInitial = (name) => {
if (!name) return '?'
return name.charAt(0).toUpperCase()
}
const formatTime = (dateStr) => { const formatTime = (dateStr) => {
if (!dateStr) return '' if (!dateStr) return ''
const date = new Date(dateStr) const date = new Date(dateStr)

View File

@@ -110,6 +110,7 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import contactApi from '@/api/contact' import contactApi from '@/api/contact'
import { useContactStore } from '@/store/contact' import { useContactStore } from '@/store/contact'
import { getAvatarColor, getInitial } from '@/utils/avatar'
const contactStore = useContactStore() const contactStore = useContactStore()
@@ -121,18 +122,6 @@ const recommendLoading = ref(true)
const recommendations = ref([]) const recommendations = ref([])
const addingMap = reactive({}) const addingMap = reactive({})
const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#4F46E5']
const getAvatarColor = (name) => {
if (!name) return AVATAR_COLORS[0]
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
}
const getInitial = (name) => {
if (!name) return '?'
return name.charAt(0).toUpperCase()
}
const doSearch = async () => { const doSearch = async () => {
const kw = keyword.value.trim() const kw = keyword.value.trim()
if (!kw) { if (!kw) {
@@ -143,7 +132,7 @@ const doSearch = async () => {
searchLoading.value = true searchLoading.value = true
try { try {
const res = await contactApi.searchUsers(kw) const res = await contactApi.searchUsers(kw)
searchResults.value = res.data || [] searchResults.value = res.data?.list || []
} catch (e) { } catch (e) {
console.error('搜索用户失败', e) console.error('搜索用户失败', e)
searchResults.value = [] searchResults.value = []

View File

@@ -89,10 +89,18 @@ class WebSocketService {
* @returns {number} 消息序号 * @returns {number} 消息序号
*/ */
send(event, data = {}) { send(event, data = {}) {
// #ifdef H5
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn('[WS] 连接未就绪,无法发送') console.warn('[WS] 连接未就绪,无法发送')
return -1 return -1
} }
// #endif
// #ifndef H5
if (!this.ws) {
console.warn('[WS] 连接未就绪,无法发送')
return -1
}
// #endif
this.seq++ this.seq++
const msg = JSON.stringify({ const msg = JSON.stringify({

View File

@@ -144,8 +144,14 @@ export const useContactStore = defineStore('contact', () => {
if (friend) friend.is_online = online if (friend) friend.is_online = online
} }
/** 初始化 WebSocket 事件监听 */ /** 防止 WebSocket 事件监听重复注册 */
let _wsInitialized = false
/** 初始化 WebSocket 事件监听(幂等,多次调用只注册一次) */
const initWsListeners = () => { const initWsListeners = () => {
if (_wsInitialized) return
_wsInitialized = true
wsService.on('notify.friend.request', () => { wsService.on('notify.friend.request', () => {
fetchPendingRequests() fetchPendingRequests()
}) })

View File

@@ -20,17 +20,23 @@ export const useWebSocketStore = defineStore('websocket', () => {
/** 是否已连接 */ /** 是否已连接 */
const isConnected = computed(() => connected.value) const isConnected = computed(() => connected.value)
/** 防止 _connected/_disconnected 监听器重复注册 */
let _initialized = false
/** /**
* 初始化 WebSocket 连接 * 初始化 WebSocket 连接
* @param {string} [token] - JWT Token * @param {string} [token] - JWT Token
*/ */
const connect = (token) => { const connect = (token) => {
wsService.on('_connected', () => { if (!_initialized) {
connected.value = true wsService.on('_connected', () => {
}) connected.value = true
wsService.on('_disconnected', () => { })
connected.value = false wsService.on('_disconnected', () => {
}) connected.value = false
})
_initialized = true
}
wsService.connect(token) wsService.connect(token)
} }

View File

@@ -0,0 +1,21 @@
/**
* 头像工具函数
*
* 提供头像颜色生成和首字母提取功能,供联系人相关页面统一使用。
* 颜色基于用户名首字符 charCode 做确定性映射,保证同一用户每次展示颜色一致。
*/
export const AVATAR_COLORS = ['#7C3AED', '#2563EB', '#0891B2', '#059669', '#D97706', '#DC2626', '#7C3AED', '#4F46E5']
/** 根据用户名生成确定性头像背景色 */
export const getAvatarColor = (name) => {
if (!name) return AVATAR_COLORS[0]
const code = name.charCodeAt(0)
return AVATAR_COLORS[code % AVATAR_COLORS.length]
}
/** 提取用户名首字母(大写) */
export const getInitial = (name) => {
if (!name) return '?'
return name.charAt(0).toUpperCase()
}