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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 查询)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user