181 lines
6.7 KiB
Go
181 lines
6.7 KiB
Go
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)
|
||
}
|
||
}
|