package service import ( "context" "log/slog" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" ) // NotificationService 通知业务服务。 type NotificationService struct { notificationDao *mysqldao.NotificationDao } // NewNotificationService 创建通知服务。 func NewNotificationService() *NotificationService { return &NotificationService{ notificationDao: mysqldao.NewNotificationDao(), } } // List 分页查询当前用户通知。 func (s *NotificationService) List(ctx context.Context, userID int64, request *dto.NotificationListRequest) (*dto.NotificationListResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.NotificationList") defer span.End() page, pageSize := normalizePage(request.Page, request.PageSize) list, total, err := s.notificationDao.NotificationListByUserID(ctx, userID, request.IsRead, request.MsgType, page, pageSize) if err != nil { return nil, errcode.DatabaseError } items := make([]dto.NotificationGetByUUIDResponse, 0, len(list)) for i := range list { items = append(items, buildNotificationResponse(&list[i])) } return &dto.NotificationListResponse{ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, List: items, }, nil } // GetByUUID 获取通知详情(校验归属)。 func (s *NotificationService) GetByUUID(ctx context.Context, userID int64, request *dto.NotificationGetByUUIDRequest) (*dto.NotificationGetByUUIDResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.NotificationGetByUUID") defer span.End() n, err := s.notificationDao.NotificationGetByUUID(ctx, request.NotificationUUID) if err != nil { if isNotFound(err) { return nil, errcode.NotificationNotFound } return nil, errcode.DatabaseError } if n.UserID != userID { return nil, errcode.Forbidden } response := buildNotificationResponse(n) return &response, nil } // Read 标记通知已读(校验归属),并同步扣减未读数。 func (s *NotificationService) Read(ctx context.Context, userID int64, request *dto.NotificationReadRequest) (*dto.NotificationUnreadResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.NotificationRead") defer span.End() n, err := s.notificationDao.NotificationGetByUUID(ctx, request.NotificationUUID) if err != nil { if isNotFound(err) { return nil, errcode.NotificationNotFound } return nil, errcode.DatabaseError } if n.UserID != userID { return nil, errcode.Forbidden } // 已读则直接返回当前未读数。 if n.IsRead == NotificationRead { cnt, cerr := s.unreadCount(ctx, userID) if cerr != nil { return nil, cerr } return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil } // 条件标记已读(仅未读 -> 已读),防并发重复标记。 if _, err := s.notificationDao.NotificationUpdateReadByUUIDIfUnread(ctx, request.NotificationUUID); err != nil { return nil, errcode.DatabaseError } // 无论本次是否真的标记成功,都按 DB 重新统计并回填缓存: // Redis 计数在 key 过期后会被 INCR 从 1 重建,直接返回 Redis 的 decr 结果会长期少报。 cnt, cerr := s.unreadCount(ctx, userID) if cerr != nil { return nil, cerr } return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil } // Unread 获取当前用户未读数(以 MySQL 为权威)。 func (s *NotificationService) Unread(ctx context.Context, userID int64) (*dto.NotificationUnreadResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.NotificationUnread") defer span.End() cnt, cerr := s.unreadCount(ctx, userID) if cerr != nil { return nil, cerr } return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil } // unreadCount 以 MySQL 为权威统计未读数并回填 Redis 缓存。 // 此前「Redis 优先、只有 0 或异常才回源」的策略在 key 过期后会长期少报 //(key 过期 → 新通知 INCR 从 1 开始 → Unread 读到正数就不再回源),改为 DB 权威。 // DB 异常时才用 Redis 兜底,保证降级可用性。 func (s *NotificationService) unreadCount(ctx context.Context, userID int64) (int64, *errcode.Error) { cnt, err := s.notificationDao.NotificationCountUnread(ctx, userID) if err != nil { if rcnt, rerr := redisdao.NotificationUnreadGet(ctx, userID); rerr == nil { slog.WarnContext(ctx, "count unread from db failed, fallback to redis", "user_id", userID, "error", err) return rcnt, nil } return 0, errcode.DatabaseError } if serr := redisdao.NotificationUnreadSet(ctx, userID, cnt); serr != nil { slog.WarnContext(ctx, "backfill notification unread cache failed", "user_id", userID, "error", serr) } return cnt, nil } // buildNotificationResponse 将通知模型映射为响应。 func buildNotificationResponse(n *mysqlmodel.UserNotification) dto.NotificationGetByUUIDResponse { return dto.NotificationGetByUUIDResponse{ NotificationUUID: n.NotificationUUID, MsgType: n.MsgType, Title: n.Title, Content: n.Content, IsRead: n.IsRead, CreatedAt: n.CreatedAt, } } // sendNotification 发送一条通知并同步递增接收方未读数(供 like/order 等复用,best-effort)。 func sendNotification(ctx context.Context, recipientUserID, senderID int64, msgType int8, title, content string) { dao := mysqldao.NewNotificationDao() n := &mysqlmodel.UserNotification{ NotificationUUID: genUUID(), UserID: recipientUserID, MsgType: msgType, SenderID: senderID, Title: title, Content: content, IsRead: NotificationUnread, } if err := dao.NotificationCreate(ctx, n); err != nil { slog.WarnContext(ctx, "create notification failed", "user_id", recipientUserID, "msg_type", msgType, "error", err) return } if _, err := redisdao.NotificationUnreadIncr(ctx, recipientUserID); err != nil { slog.ErrorContext(ctx, "notification unread incr failed", "user_id", recipientUserID, "error", err) } }