Files
EchoChat/backend/go-service/app/contact/service/contact_service.go
bujinyuan f1853f125d feat: Phase 2e-1 统一通知中心 + 我的 TabBar 聚合未读红点
后端(notify 模块)
- 新增 notify 模块:DAO/Service/Pusher 接口/Controller/Router/CleanupTask
- 数据库 DDL:notify_notifications 表 + 3 索引(user+created/user+is_read/user+category)
- 11 种 type 枚举(好友/群聊 9 种 + meeting_* 2 种预留)+ 4 种 category
- 跨模块集成:contact 3 处 Pusher(friend_request/accepted/rejected)
- 跨模块集成:group 6 处 Pusher(invite/join_request/approved/rejected/kicked/role_changed)
- WS handler 断线补偿:连接建立即推送 notify.unread.total
- 5 REST API(4 用户 + 1 管理员广播)+ 2 WS 事件(notify.new / notify.unread.total)
- 30 天已读通知定时清理(未读永久保留)
- Provider/Wire 依赖注入(NotifyPusher、NotifyConnectHook、UserInfoResolver 接口)

前端
- 新增 notify 模块:API/Pinia Store(5 分类分页缓存 + 未读数 + WS 事件)/NotifyItem/通知中心主页
- profile 入口:铃铛 badge + 菜单项 badge + 数字显示
- App.vue/login 初始化 notifyStore WS 监听;logout 调用 notifyStore.reset() 清缓存
- 清理 contact.js/group.js 中散落 toast 与冗余 notify.friend.request/group.join.request 处理
- CustomTabBar 新增 hasDot() 聚合指示器:我的 Tab 显示纯红点(无数字),
  当前聚合 notifyStore.unreadTotal,未来可扩展「资料待完善/安全提醒/新版本」等

文档
- 新增 Phase 2e 整体路线图 docs/plans/2026-04-20-phase2e-design.md
- 新增 Phase 2e-1 专用设计 docs/plans/2026-04-20-phase2e-1-design.md(§6.4 TabBar 聚合红点)
- 新增 Phase 2e-1 实施计划 docs/plans/2026-04-20-phase2e-1-implementation.plan.md
- 新增 E2E 验证报告 test-report-phase2e-1-notification.md(含 Playwright MCP 2 个现场 Bug 修复记录)
- 更新 docs/progress/CURRENT_STATUS.md、docs/api/README.md、docs/api/frontend/notify.md
- 更新 .cursor/rules/project-context.mdc、docs/plans/2026-02-27-echochat-system-design.md

其他
- .gitignore 排除 .playwright-mcp/ MCP 临时快照

架构决策
- 单端 WS 连接:沿用现有 ws.Hub,多端已读同步推迟到 Phase 2f/二期
- 跨模块依赖:contact/group → notify 严格单向(接口注入模式)
- 降级策略:Pusher 先入库后推送;WS 失败不回滚入库;入库失败仅 Warn 不影响业务

Playwright MCP 回归(4 类场景全通)
- 实时推送(admin 广播 → 1s 内前端自动插入 + 角标 +1)
- Deep-link 跳转(好友申请通知 → contact/request 页)
- 批量清零(全部已读按钮)
- TabBar 聚合红点(有未读亮/全部已读灭)与 notifyStore.unreadTotal 三层同步

Made-with: Cursor
2026-04-21 10:21:01 +08:00

506 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package service 提供 contact 模块的业务逻辑
package service
import (
"context"
"errors"
"sort"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/contact/dao"
"github.com/echochat/backend/app/dto"
notifyService "github.com/echochat/backend/app/notify/service"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
"gorm.io/gorm"
)
var (
ErrSelfRequest = errors.New("不能添加自己为好友")
ErrAlreadyFriend = errors.New("已经是好友了")
ErrPendingExists = errors.New("已有待处理的好友申请")
ErrBlocked = errors.New("对方已将你拉黑")
ErrRequestNotFound = errors.New("好友申请不存在")
ErrFriendNotFound = errors.New("好友关系不存在")
ErrGroupNotFound = errors.New("分组不存在")
ErrUserNotFound = errors.New("用户不存在")
)
// OnlineChecker 在线状态查询接口(避免直接依赖 ws 模块的 OnlineService
type OnlineChecker interface {
BatchCheckOnline(ctx context.Context, userIDs []int64) map[int64]bool
}
// NotifyPusher 通知推送接口
// 由 notify.service.NotifyService 隐式实现contact → notify 单向依赖
// 此处以接口形式注入,便于测试替换并明确调用契约
type NotifyPusher interface {
Push(ctx context.Context, payload *notifyService.PushPayload)
}
// ContactService 联系人业务服务
type ContactService struct {
friendshipDAO *dao.FriendshipDAO
friendGroupDAO *dao.FriendGroupDAO
onlineChecker OnlineChecker
notifyPusher NotifyPusher
}
// NewContactService 创建 ContactService 实例
func NewContactService(
friendshipDAO *dao.FriendshipDAO,
friendGroupDAO *dao.FriendGroupDAO,
onlineChecker OnlineChecker,
notifyPusher NotifyPusher,
) *ContactService {
return &ContactService{
friendshipDAO: friendshipDAO,
friendGroupDAO: friendGroupDAO,
onlineChecker: onlineChecker,
notifyPusher: notifyPusher,
}
}
// SendFriendRequest 发送好友申请
func (s *ContactService) SendFriendRequest(ctx context.Context, userID, targetID int64, message string) error {
funcName := "service.contact_service.SendFriendRequest"
logs.Info(ctx, funcName, "发送好友申请",
zap.Int64("user_id", userID), zap.Int64("target_id", targetID))
if userID == targetID {
return ErrSelfRequest
}
blocked, err := s.friendshipDAO.IsBlocked(ctx, userID, targetID)
if err != nil {
logs.Error(ctx, funcName, "检查拉黑状态失败", zap.Error(err))
return err
}
if blocked {
return ErrBlocked
}
isFriend, err := s.friendshipDAO.IsFriend(ctx, userID, targetID)
if err != nil {
return err
}
if isFriend {
return ErrAlreadyFriend
}
pending, err := s.friendshipDAO.HasPendingRequest(ctx, userID, targetID)
if err != nil {
return err
}
if pending {
return ErrPendingExists
}
reactivated, err := s.friendshipDAO.ReactivateRejectedRequest(ctx, userID, targetID, message)
if err != nil {
return err
}
if !reactivated {
_, err = s.friendshipDAO.CreateRequest(ctx, userID, targetID, message)
if err != nil {
return err
}
}
if s.notifyPusher != nil {
actorID := userID
targetUser := targetID
s.notifyPusher.Push(ctx, &notifyService.PushPayload{
UserID: targetID,
Type: constants.NotifyTypeFriendRequest,
Content: message,
ActorID: &actorID,
TargetType: constants.NotifyTargetUser,
TargetID: &targetUser,
Extra: map[string]interface{}{
"message": message,
},
})
}
return nil
}
// AcceptFriendRequest 接受好友申请
func (s *ContactService) AcceptFriendRequest(ctx context.Context, requestID, userID int64) error {
funcName := "service.contact_service.AcceptFriendRequest"
logs.Info(ctx, funcName, "接受好友申请",
zap.Int64("request_id", requestID), zap.Int64("user_id", userID))
req, err := s.friendshipDAO.GetRequestByID(ctx, requestID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrRequestNotFound
}
return err
}
if req.FriendID != userID {
return ErrRequestNotFound
}
if err := s.friendshipDAO.AcceptRequest(ctx, requestID, userID); err != nil {
return err
}
if s.notifyPusher != nil {
actorID := userID
targetUser := userID
s.notifyPusher.Push(ctx, &notifyService.PushPayload{
UserID: req.UserID,
Type: constants.NotifyTypeFriendAccepted,
Content: "对方已接受你的好友申请",
ActorID: &actorID,
TargetType: constants.NotifyTargetUser,
TargetID: &targetUser,
Extra: map[string]interface{}{
"request_id": requestID,
},
})
}
return nil
}
// RejectFriendRequest 拒绝好友申请
func (s *ContactService) RejectFriendRequest(ctx context.Context, requestID, userID int64) error {
funcName := "service.contact_service.RejectFriendRequest"
logs.Info(ctx, funcName, "拒绝好友申请",
zap.Int64("request_id", requestID), zap.Int64("user_id", userID))
req, err := s.friendshipDAO.GetRequestByID(ctx, requestID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrRequestNotFound
}
return err
}
if req.FriendID != userID {
return ErrRequestNotFound
}
if err := s.friendshipDAO.RejectRequest(ctx, requestID, userID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrRequestNotFound
}
return err
}
if s.notifyPusher != nil {
actorID := userID
targetUser := userID
s.notifyPusher.Push(ctx, &notifyService.PushPayload{
UserID: req.UserID,
Type: constants.NotifyTypeFriendRejected,
Content: "对方拒绝了你的好友申请",
ActorID: &actorID,
TargetType: constants.NotifyTargetUser,
TargetID: &targetUser,
Extra: map[string]interface{}{
"request_id": requestID,
},
})
}
return nil
}
// GetFriendList 获取好友列表(包含在线状态)
func (s *ContactService) GetFriendList(ctx context.Context, userID int64, groupID *int64) ([]dto.FriendInfo, error) {
funcName := "service.contact_service.GetFriendList"
logs.Debug(ctx, funcName, "获取好友列表", zap.Int64("user_id", userID))
friends, err := s.friendshipDAO.GetFriendList(ctx, userID, groupID)
if err != nil {
return nil, err
}
friendIDs := make([]int64, 0, len(friends))
for _, f := range friends {
friendIDs = append(friendIDs, f.UserID)
}
onlineMap := make(map[int64]bool)
if s.onlineChecker != nil && len(friendIDs) > 0 {
onlineMap = s.onlineChecker.BatchCheckOnline(ctx, friendIDs)
}
result := make([]dto.FriendInfo, 0, len(friends))
for _, f := range friends {
result = append(result, dto.FriendInfo{
ID: f.ID,
UserID: f.UserID,
Username: f.Username,
Nickname: f.Nickname,
Avatar: f.Avatar,
Remark: f.Remark,
GroupID: f.GroupID,
IsOnline: onlineMap[f.UserID],
CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
return result, nil
}
// GetPendingRequests 获取待处理的好友申请
func (s *ContactService) GetPendingRequests(ctx context.Context, userID int64) ([]dto.FriendRequestInfo, error) {
funcName := "service.contact_service.GetPendingRequests"
logs.Debug(ctx, funcName, "获取待处理申请", zap.Int64("user_id", userID))
requests, err := s.friendshipDAO.GetPendingRequests(ctx, userID)
if err != nil {
return nil, err
}
result := make([]dto.FriendRequestInfo, 0, len(requests))
for _, r := range requests {
result = append(result, dto.FriendRequestInfo{
ID: r.ID,
UserID: r.UserID,
Username: r.Username,
Nickname: r.Nickname,
Avatar: r.Avatar,
Message: r.Message,
Status: r.Status,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
return result, nil
}
// DeleteFriend 删除好友
func (s *ContactService) DeleteFriend(ctx context.Context, userID, friendID int64) error {
funcName := "service.contact_service.DeleteFriend"
logs.Info(ctx, funcName, "删除好友",
zap.Int64("user_id", userID), zap.Int64("friend_id", friendID))
return s.friendshipDAO.DeleteFriend(ctx, userID, friendID)
}
// UpdateRemark 更新好友备注
func (s *ContactService) UpdateRemark(ctx context.Context, userID, friendID int64, remark string) error {
funcName := "service.contact_service.UpdateRemark"
logs.Info(ctx, funcName, "更新好友备注",
zap.Int64("user_id", userID), zap.Int64("friend_id", friendID))
return s.friendshipDAO.UpdateRemark(ctx, userID, friendID, remark)
}
// BlockUser 拉黑用户
func (s *ContactService) BlockUser(ctx context.Context, userID, targetID int64) error {
funcName := "service.contact_service.BlockUser"
logs.Info(ctx, funcName, "拉黑用户",
zap.Int64("user_id", userID), zap.Int64("target_id", targetID))
if userID == targetID {
return ErrSelfRequest
}
return s.friendshipDAO.BlockUser(ctx, userID, targetID)
}
// UnblockUser 取消拉黑
func (s *ContactService) UnblockUser(ctx context.Context, userID, targetID int64) error {
funcName := "service.contact_service.UnblockUser"
logs.Info(ctx, funcName, "取消拉黑",
zap.Int64("user_id", userID), zap.Int64("target_id", targetID))
return s.friendshipDAO.UnblockUser(ctx, userID, targetID)
}
// GetBlockList 获取黑名单
func (s *ContactService) GetBlockList(ctx context.Context, userID int64) ([]dto.FriendInfo, error) {
funcName := "service.contact_service.GetBlockList"
logs.Debug(ctx, funcName, "获取黑名单", zap.Int64("user_id", userID))
blocked, err := s.friendshipDAO.GetBlockList(ctx, userID)
if err != nil {
return nil, err
}
result := make([]dto.FriendInfo, 0, len(blocked))
for _, f := range blocked {
result = append(result, dto.FriendInfo{
ID: f.ID,
UserID: f.UserID,
Username: f.Username,
Nickname: f.Nickname,
Avatar: f.Avatar,
})
}
return result, nil
}
// SearchUsers 搜索用户
func (s *ContactService) SearchUsers(ctx context.Context, userID int64, keyword string, page, pageSize int) ([]dto.SearchUserInfo, int64, error) {
funcName := "service.contact_service.SearchUsers"
logs.Debug(ctx, funcName, "搜索用户", zap.String("keyword", keyword))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 50 {
pageSize = 20
}
users, total, err := s.friendshipDAO.SearchUsers(ctx, keyword, userID, page, pageSize)
if err != nil {
return nil, 0, err
}
friendIDs, _ := s.friendshipDAO.GetFriendIDs(ctx, userID)
friendSet := make(map[int64]bool, len(friendIDs))
for _, id := range friendIDs {
friendSet[id] = true
}
result := make([]dto.SearchUserInfo, 0, len(users))
for _, u := range users {
result = append(result, dto.SearchUserInfo{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
IsFriend: friendSet[u.ID],
})
}
return result, total, nil
}
// 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))
friendIDs, err := s.friendshipDAO.GetFriendIDs(ctx, userID)
if err != nil {
return nil, err
}
if len(friendIDs) == 0 {
return []dto.SearchUserInfo{}, nil
}
candidateCount := make(map[int64]int)
friendSet := make(map[int64]bool, len(friendIDs))
for _, id := range friendIDs {
friendSet[id] = true
}
for _, fid := range friendIDs {
fofIDs, err := s.friendshipDAO.GetFriendIDs(ctx, fid)
if err != nil {
continue
}
for _, fof := range fofIDs {
if fof != userID && !friendSet[fof] {
candidateCount[fof]++
}
}
}
type candidate struct {
id int64
count int
}
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 {
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 {
if info, ok := userMap[c.id]; ok {
result = append(result, info)
}
}
return result, nil
}
// CreateGroup 创建好友分组
func (s *ContactService) CreateGroup(ctx context.Context, userID int64, name string) (*dto.GroupInfo, error) {
funcName := "service.contact_service.CreateGroup"
logs.Info(ctx, funcName, "创建好友分组",
zap.Int64("user_id", userID), zap.String("name", name))
group, err := s.friendGroupDAO.CreateGroup(ctx, userID, name)
if err != nil {
return nil, err
}
return &dto.GroupInfo{
ID: group.ID,
Name: group.Name,
SortOrder: group.SortOrder,
}, nil
}
// GetGroups 获取好友分组列表
func (s *ContactService) GetGroups(ctx context.Context, userID int64) ([]dto.GroupInfo, error) {
funcName := "service.contact_service.GetGroups"
logs.Debug(ctx, funcName, "获取好友分组", zap.Int64("user_id", userID))
groups, err := s.friendGroupDAO.GetGroups(ctx, userID)
if err != nil {
return nil, err
}
countMap, _ := s.friendshipDAO.CountFriendsByGroup(ctx, userID)
result := make([]dto.GroupInfo, 0, len(groups))
for _, g := range groups {
result = append(result, dto.GroupInfo{
ID: g.ID,
Name: g.Name,
SortOrder: g.SortOrder,
FriendCount: countMap[g.ID],
})
}
return result, nil
}
// UpdateGroup 更新好友分组
func (s *ContactService) UpdateGroup(ctx context.Context, userID, groupID int64, name string, sortOrder *int) error {
return s.friendGroupDAO.UpdateGroup(ctx, groupID, userID, name, sortOrder)
}
// DeleteGroup 删除好友分组
func (s *ContactService) DeleteGroup(ctx context.Context, userID, groupID int64) error {
return s.friendGroupDAO.DeleteGroup(ctx, groupID, userID)
}
// MoveToGroup 移动好友到分组
func (s *ContactService) MoveToGroup(ctx context.Context, userID, friendID int64, groupID *int64) error {
return s.friendGroupDAO.MoveToGroup(ctx, userID, friendID, groupID)
}