Files
zt-closet-backend/internal/service/notification.go
T
2026-09-16 00:23:49 +08:00

181 lines
6.7 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
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
}
slog.ErrorContext(ctx, "read notification: query notification failed",
"notification_uuid", request.NotificationUUID, "user_id", userID, "error", err)
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 {
slog.ErrorContext(ctx, "mark notification read failed",
"notification_uuid", request.NotificationUUID, "user_id", userID, "error", err)
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
} else {
slog.ErrorContext(ctx, "count unread failed: db and redis both unavailable",
"user_id", userID, "db_error", err, "redis_error", rerr)
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 {
// 未读数是 best-effort 缓存:失败不影响通知已落库,级别与「通知创建失败」保持一致。
slog.WarnContext(ctx, "notification unread incr failed", "user_id", recipientUserID, "error", err)
}
}