// Package service 提供通知服务的业务逻辑层。 // // 设计约定: // - service 层负责参数校验、事务编排、领域逻辑(如聚合标题生成)。 // - 所有写操作(Create / MarkAsRead / Delete 等)走 db.WithContext().Transaction(func(tx *gorm.DB) error {...}), // 并在事务回调内调用 repository 的写方法(Create / MarkAsReadByID / IncrementByType 等)。 // - 所有读操作走 repository 的只读方法(ListLikesAggregated / ListSystemActivity / Get)。 // - 任何对数据库的 SQL 执行都通过 repository 暴露的方法完成,service 不直接写 Raw SQL。 package service import ( "context" "encoding/json" "fmt" "strings" "time" appErrors "github.com/topfans/backend/pkg/errors" "github.com/topfans/backend/pkg/logger" pbCommon "github.com/topfans/backend/pkg/proto/common" notifPb "github.com/topfans/backend/pkg/proto/notification" "github.com/topfans/backend/pkg/push" "github.com/topfans/backend/services/notificationService/model" "github.com/topfans/backend/services/notificationService/repository" "github.com/topfans/backend/pkg/validator" "go.uber.org/zap" "google.golang.org/grpc/codes" "google.golang.org/protobuf/types/known/structpb" "gorm.io/gorm" ) // 字段长度上限(与 model 注释保持一致)。 const ( maxTitleLen = 200 maxContentLen = 500 maxDataBytes = 4096 // data JSON 体积上限(防止前端塞超长 payload) ) // allowedTypes 是合法的通知类型白名单。 var allowedTypes = map[string]struct{}{ "like": {}, "system": {}, "activity": {}, } // NotificationService 通知服务业务层。 type NotificationService struct { db *gorm.DB notifRepo *repository.NotificationRepository statsRepo *repository.NotificationStatsRepository device *UserDeviceService // 用于推送时拉取用户活跃 cid;若 nil 则跳过推送 pusher push.Pusher // 推送客户端;若 nil 则跳过推送 } // NewNotificationService 创建 NotificationService。 // // 参数 device 与 pusher 用于在 CreateNotification 成功后触发手机通知栏推送; // 若任一为 nil,则不会触发推送(便于测试 / 关闭推送功能)。 func NewNotificationService(db *gorm.DB, device *UserDeviceService, pusher push.Pusher) *NotificationService { return &NotificationService{ db: db, notifRepo: repository.NewNotificationRepository(db), statsRepo: repository.NewNotificationStatsRepository(db), device: device, pusher: pusher, } } // DB 返回底层 gorm.DB(用于 main.go 在初始化阶段做 ping/health check)。 func (s *NotificationService) DB() *gorm.DB { return s.db } // CreateNotification 创建一条通知(事务内写 notifications + 累加 stats)。 func (s *NotificationService) CreateNotification( ctx context.Context, req *notifPb.CreateNotificationRequest, ) (*notifPb.CreateNotificationResponse, error) { if req == nil { return nil, appErrors.NewError(codes.InvalidArgument, "request is nil") } // 参数校验 if !validator.ValidateUserID(req.UserId) { return nil, appErrors.NewError(codes.InvalidArgument, "user_id is required") } if !validator.ValidateStarID(req.StarId) { return nil, appErrors.NewError(codes.InvalidArgument, "star_id is required") } if _, ok := allowedTypes[req.Type]; !ok { return nil, appErrors.NewError(codes.InvalidArgument, "invalid notification type, must be one of like/system/activity") } if strings.TrimSpace(req.Title) == "" { return nil, appErrors.NewError(codes.InvalidArgument, "title is required") } if len(req.Title) > maxTitleLen { return nil, appErrors.NewError(codes.InvalidArgument, fmt.Sprintf("title too long (max %d)", maxTitleLen)) } if len(req.Content) > maxContentLen { return nil, appErrors.NewError(codes.InvalidArgument, fmt.Sprintf("content too long (max %d)", maxContentLen)) } // data: structpb.Struct -> JSON string var dataJSON string if req.Data != nil { m := req.Data.AsMap() if len(m) > 0 { b, err := json.Marshal(m) if err != nil { return nil, appErrors.NewError(codes.InvalidArgument, fmt.Sprintf("invalid data payload: %v", err)) } if len(b) > maxDataBytes { return nil, appErrors.NewError(codes.InvalidArgument, fmt.Sprintf("data payload too large (max %d bytes)", maxDataBytes)) } dataJSON = string(b) } } now := time.Now().UnixMilli() notif := &model.Notification{ UserID: req.UserId, StarID: req.StarId, Type: req.Type, Title: req.Title, Content: req.Content, Data: dataJSON, IsRead: false, IsDeleted: false, CreatedAt: now, ReadAt: 0, } var newID int64 err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { id, err := s.notifRepo.Create(ctx, tx, notif) if err != nil { return err } newID = id if err := s.statsRepo.IncrementByType(ctx, tx, req.UserId, req.StarId, req.Type, now); err != nil { return err } return nil }) if err != nil { logger.Logger.Error("CreateNotification failed", zap.Int64("user_id", req.UserId), zap.Int64("star_id", req.StarId), zap.String("type", req.Type), zap.Error(err)) return nil, err } logger.Logger.Info("notification created", zap.Int64("id", newID), zap.Int64("user_id", req.UserId), zap.Int64("star_id", req.StarId), zap.String("type", req.Type)) // 异步触发推送(不影响 RPC 返回;失败仅 warn 不影响 DB 结果) s.triggerPush(notif) return ¬ifPb.CreateNotificationResponse{ Base: appErrors.FormatSuccessResponse(), Id: newID, }, nil } // triggerPush 异步触发手机通知栏推送。 // // 设计要点: // - 必须在事务提交后调用,避免推送先于 DB 写入。 // - 使用 background context + 5s 超时,避免跟随 RPC ctx 提前结束。 // - 任何错误(无 cid / 网络失败 / cids 为空)只 warn,不返回错误(不影响主流程)。 func (s *NotificationService) triggerPush(n *model.Notification) { if s.pusher == nil || s.device == nil { return } if n == nil { return } // 拉取该用户所有活跃 cid cids, err := s.device.ListActiveCIDs(context.Background(), n.UserID) if err != nil { logger.Logger.Warn("triggerPush: list cids failed", zap.Int64("user_id", n.UserID), zap.Error(err)) return } if len(cids) == 0 { // 用户没注册过设备,无需推送;debug log 便于排查 logger.Logger.Debug("triggerPush: no active cids, skip", zap.Int64("user_id", n.UserID), zap.Int64("notification_id", n.ID)) return } // 组装 data:通知 id + 类型 + 业务 data(若有)+ 跳转 url data := map[string]interface{}{ "notification_id": n.ID, "type": n.Type, "star_id": n.StarID, } if n.Data != "" { var extra map[string]interface{} if err := json.Unmarshal([]byte(n.Data), &extra); err == nil { for k, v := range extra { // 不允许覆盖约定的字段 if _, ok := data[k]; ok { continue } data[k] = v } } } // 客户端 onPushMessage 在 type==click 时读 data.payload.url 决定跳转; // 我们不强制 url,由具体通知在 data 中按需提供。 go func() { cctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.pusher.Send(cctx, push.Payload{ CIDs: cids, Title: n.Title, Content: n.Content, Data: data, }); err != nil { logger.Logger.Warn("triggerPush: uniPush send failed", zap.Int64("notification_id", n.ID), zap.Int64("user_id", n.UserID), zap.Int("cid_count", len(cids)), zap.Error(err)) } }() } // GetNotifications 获取通知列表(type=like 时聚合;其他走 system/activity 单条列表)。 func (s *NotificationService) GetNotifications( ctx context.Context, userID, starID int64, ntype, tab string, page, pageSize int32, ) (*notifPb.GetNotificationsResponse, error) { if !validator.ValidateUserID(userID) { return nil, appErrors.NewError(codes.InvalidArgument, "user_id is required") } if !validator.ValidateStarID(starID) { return nil, appErrors.NewError(codes.InvalidArgument, "star_id is required") } // 默认分页 if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } resp := ¬ifPb.GetNotificationsResponse{ Base: appErrors.FormatSuccessResponse(), Items: []*notifPb.Notification{}, Page: page, PageSize: pageSize, } if ntype == "like" { items, total, err := s.notifRepo.ListLikesAggregated(ctx, userID, starID, tab, int(page), int(pageSize)) if err != nil { logger.Logger.Error("ListLikesAggregated failed", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) return nil, err } resp.Total = total for _, item := range items { pb := aggToProto(item) if pb != nil { resp.Items = append(resp.Items, pb) } } return resp, nil } // 非 like:走 system / activity if _, ok := allowedTypes[ntype]; !ok { return nil, appErrors.NewError(codes.InvalidArgument, "invalid notification type, must be like/system/activity") } items, total, err := s.notifRepo.ListSystemActivity(ctx, userID, starID, ntype, tab, int(page), int(pageSize)) if err != nil { logger.Logger.Error("ListSystemActivity failed", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.String("type", ntype), zap.Error(err)) return nil, err } resp.Total = total for _, item := range items { pb := rawToProto(item) if pb != nil { resp.Items = append(resp.Items, pb) } } return resp, nil } // GetUnreadCount 获取未读计数。 func (s *NotificationService) GetUnreadCount( ctx context.Context, userID, starID int64, ) (*notifPb.GetUnreadCountResponse, error) { if !validator.ValidateUserID(userID) { return nil, appErrors.NewError(codes.InvalidArgument, "user_id is required") } if !validator.ValidateStarID(starID) { return nil, appErrors.NewError(codes.InvalidArgument, "star_id is required") } stats, err := s.statsRepo.Get(ctx, userID, starID) if err != nil { logger.Logger.Error("GetUnreadCount failed", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) return nil, err } return ¬ifPb.GetUnreadCountResponse{ Base: appErrors.FormatSuccessResponse(), Counts: ¬ifPb.UnreadCount{ Like: int32(stats.LikeUnreadCount), System: int32(stats.SystemUnreadCount), Activity: int32(stats.ActivityUnreadCount), Total: int32(stats.TotalUnreadCount), }, }, nil } // MarkAsRead 单条标已读(事务内)。 func (s *NotificationService) MarkAsRead( ctx context.Context, userID, starID, id, now int64, ) (*notifPb.MarkAsReadResponse, error) { if !validator.ValidateUserID(userID) || !validator.ValidateStarID(starID) || id <= 0 { return nil, appErrors.NewError(codes.InvalidArgument, "invalid user_id/star_id/id") } err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { ntype, isRead, err := s.notifRepo.GetTypeByID(ctx, tx, id, userID, starID) if err != nil { return err } // 不存在或已删除 if ntype == "" { return appErrors.NewError(codes.NotFound, "notification not found") } if isRead { // 幂等:已读直接返回 return nil } if _, err := s.notifRepo.MarkAsReadByID(ctx, tx, userID, starID, id, now); err != nil { return err } if err := s.statsRepo.DecrementByType(ctx, tx, userID, starID, ntype, 1, now); err != nil { return err } return nil }) if err != nil { logger.Logger.Error("MarkAsRead failed", zap.Int64("id", id), zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) return nil, err } return ¬ifPb.MarkAsReadResponse{ Base: appErrors.FormatSuccessResponse(), }, nil } // MarkAsReadByTarget 将某个 target_id 下的所有未读 like 标已读。 func (s *NotificationService) MarkAsReadByTarget( ctx context.Context, userID, starID, targetID, now int64, ) (*notifPb.MarkAsReadByTargetResponse, error) { if !validator.ValidateUserID(userID) || !validator.ValidateStarID(starID) || targetID <= 0 { return nil, appErrors.NewError(codes.InvalidArgument, "invalid user_id/star_id/target_id") } var affected int32 err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { n, err := s.notifRepo.MarkAsReadByTarget(ctx, tx, userID, starID, targetID, now) if err != nil { return err } affected = n if n > 0 { if err := s.statsRepo.DecrementByType(ctx, tx, userID, starID, "like", int(n), now); err != nil { return err } } return nil }) if err != nil { logger.Logger.Error("MarkAsReadByTarget failed", zap.Int64("target_id", targetID), zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) return nil, err } return ¬ifPb.MarkAsReadByTargetResponse{ Base: appErrors.FormatSuccessResponse(), Affected: affected, }, nil } // MarkAllAsRead 将某类型未读通知全部标已读(type 为空表示全部类型)。 func (s *NotificationService) MarkAllAsRead( ctx context.Context, userID, starID int64, ntype string, now int64, ) (*notifPb.MarkAllAsReadResponse, error) { if !validator.ValidateUserID(userID) || !validator.ValidateStarID(starID) { return nil, appErrors.NewError(codes.InvalidArgument, "invalid user_id/star_id") } if ntype != "" { if _, ok := allowedTypes[ntype]; !ok { return nil, appErrors.NewError(codes.InvalidArgument, "invalid notification type, must be like/system/activity") } } var affected int32 err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if ntype == "" { // 全部类型:累加各 type 的 affected 数;最终用 ResetByType("") 把所有计数清零 total := int32(0) for _, t := range []string{"like", "system", "activity"} { n, err := s.notifRepo.MarkAllAsRead(ctx, tx, userID, starID, t, now) if err != nil { return err } total += n } affected = total return s.statsRepo.ResetByType(ctx, tx, userID, starID, "", now) } n, err := s.notifRepo.MarkAllAsRead(ctx, tx, userID, starID, ntype, now) if err != nil { return err } affected = n if n > 0 { if err := s.statsRepo.DecrementByType(ctx, tx, userID, starID, ntype, int(n), now); err != nil { return err } } return nil }) if err != nil { logger.Logger.Error("MarkAllAsRead failed", zap.String("type", ntype), zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) return nil, err } return ¬ifPb.MarkAllAsReadResponse{ Base: appErrors.FormatSuccessResponse(), Affected: affected, }, nil } // DeleteNotification 软删单条通知(事务内)。 func (s *NotificationService) DeleteNotification( ctx context.Context, userID, starID, id, now int64, ) (*notifPb.DeleteNotificationResponse, error) { if !validator.ValidateUserID(userID) || !validator.ValidateStarID(starID) || id <= 0 { return nil, appErrors.NewError(codes.InvalidArgument, "invalid user_id/star_id/id") } err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { ntype, isRead, err := s.notifRepo.GetTypeByID(ctx, tx, id, userID, starID) if err != nil { return err } if ntype == "" { return appErrors.NewError(codes.NotFound, "notification not found") } n, err := s.notifRepo.SoftDeleteByID(ctx, tx, userID, starID, id) if err != nil { return err } if n > 0 && !isRead { if err := s.statsRepo.DecrementByType(ctx, tx, userID, starID, ntype, 1, now); err != nil { return err } } return nil }) if err != nil { logger.Logger.Error("DeleteNotification failed", zap.Int64("id", id), zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) return nil, err } return ¬ifPb.DeleteNotificationResponse{ Base: appErrors.FormatSuccessResponse(), }, nil } // DeleteByTarget 软删某个 target_id 下所有 like 通知。 func (s *NotificationService) DeleteByTarget( ctx context.Context, userID, starID, targetID, now int64, ) (*notifPb.DeleteByTargetResponse, error) { if !validator.ValidateUserID(userID) || !validator.ValidateStarID(starID) || targetID <= 0 { return nil, appErrors.NewError(codes.InvalidArgument, "invalid user_id/star_id/target_id") } var affected int32 err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 先数该 target 下未读的 like 数,用于后续扣减 stats var unread int32 if err := tx.WithContext(ctx).Raw(` SELECT COUNT(*) FROM public.notifications WHERE user_id=$1 AND star_id=$2 AND type='like' AND (data->>'target_id')::bigint = $3 AND is_read = FALSE AND is_deleted = FALSE `, userID, starID, targetID).Scan(&unread).Error; err != nil { return fmt.Errorf("count unread by target: %w", err) } n, err := s.notifRepo.SoftDeleteByTarget(ctx, tx, userID, starID, targetID) if err != nil { return err } affected = n if n > 0 && unread > 0 { if err := s.statsRepo.DecrementByType(ctx, tx, userID, starID, "like", int(unread), now); err != nil { return err } } return nil }) if err != nil { logger.Logger.Error("DeleteByTarget failed", zap.Int64("target_id", targetID), zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) return nil, err } return ¬ifPb.DeleteByTargetResponse{ Base: appErrors.FormatSuccessResponse(), Affected: affected, }, nil } // ========== Helpers(内部使用,跨文件复用需要再 export) ========== // errResp 构造带 base 的统一错误响应(用于 CreateNotification)。 func errResp(c codes.Code, msg string) *notifPb.CreateNotificationResponse { return ¬ifPb.CreateNotificationResponse{ Base: appErrors.BuildBaseResponseWithMessage(c, msg), } } // okBase 构造成功基础响应。 func okBase() *pbCommon.BaseResponse { return appErrors.FormatSuccessResponse() } // parseJSONData 把 data JSON 字符串解析为 map(用于生成聚合标题时取 asset_title 等字段)。 // 解析失败返回空 map,不阻塞主流程。 func parseJSONData(s string) map[string]interface{} { if s == "" { return map[string]interface{}{} } m := map[string]interface{}{} if err := json.Unmarshal([]byte(s), &m); err != nil { return map[string]interface{}{} } return m } // extractAssetTitle 从 data JSON 中安全取出 asset_title;找不到时返回空。 func extractAssetTitle(dataStr string) string { m := parseJSONData(dataStr) if v, ok := m["asset_title"]; ok { if s, ok := v.(string); ok { return s } } return "" } // actorDisplayName 构造 actor 的展示名(nickname 缺省时回退为 "用户{id}")。 func actorDisplayName(a model.ActorPreview) string { if strings.TrimSpace(a.Nickname) != "" { return a.Nickname } return fmt.Sprintf("用户%d", a.UserID) } // buildAggregatedLikeTitle 生成 like 聚合列表的展示标题。 // // - 1 个 actor: "{name} 赞了你的《{title}》" // - 2 个 actor: "{name1}、{name2} 赞了你的《{title}》" // - 3+ 个 actor: "{name1}、{name2} 等 N 人赞了你的《{title}》" // - 0 个 actor(极端情况): "有 N 人赞了你的《{title}》" // // 当 assetTitle 为空时使用 "你的藏品" 作为兜底。 func buildAggregatedLikeTitle(actors []model.ActorPreview, total int32, assetTitle string) string { if strings.TrimSpace(assetTitle) == "" { assetTitle = "你的藏品" } if total <= 0 { total = int32(len(actors)) } switch len(actors) { case 0: return fmt.Sprintf("有 %d 人赞了你的《%s》", total, assetTitle) case 1: return fmt.Sprintf("%s 赞了你的《%s》", actorDisplayName(actors[0]), assetTitle) case 2: return fmt.Sprintf("%s、%s 赞了你的《%s》", actorDisplayName(actors[0]), actorDisplayName(actors[1]), assetTitle) default: return fmt.Sprintf("%s、%s 等 %d 人赞了你的《%s》", actorDisplayName(actors[0]), actorDisplayName(actors[1]), total, assetTitle) } } // rawToProto 把单条 Notification model 转 proto。 func rawToProto(n *model.Notification) *notifPb.Notification { if n == nil { return nil } pb := ¬ifPb.Notification{ Id: n.ID, UserId: n.UserID, StarId: n.StarID, Type: n.Type, Title: n.Title, Content: n.Content, IsRead: n.IsRead, CreatedAt: n.CreatedAt, ReadAt: n.ReadAt, } if n.Data != "" { if s, err := structpb.NewStruct(parseJSONData(n.Data)); err == nil { pb.Data = s } } return pb } // aggToProto 把聚合结果转 proto,并生成聚合标题。 func aggToProto(a *model.AggregatedNotification) *notifPb.Notification { if a == nil { return nil } // 标题:优先用聚合生成;data 中拿不到 asset_title 时回退到 DB 中已有 title assetTitle := extractAssetTitle(a.Data) title := a.Title if title == "" || isGenericLikeTitle(title) { title = buildAggregatedLikeTitle(a.Actors, a.TotalCount, assetTitle) } pb := ¬ifPb.Notification{ Id: a.ID, UserId: a.UserID, StarId: a.StarID, Type: a.Type, Title: title, Content: a.Content, IsRead: a.IsRead, CreatedAt: a.CreatedAt, ReadAt: a.ReadAt, Aggregated: true, TotalCount: a.TotalCount, TargetId: a.TargetID, Actors: make([]*notifPb.ActorPreview, 0, len(a.Actors)), } for i := range a.Actors { ap := a.Actors[i] pb.Actors = append(pb.Actors, ¬ifPb.ActorPreview{ UserId: ap.UserID, Nickname: ap.Nickname, Avatar: ap.Avatar, LikedAt: ap.LikedAt, }) } if a.Data != "" { if s, err := structpb.NewStruct(parseJSONData(a.Data)); err == nil { pb.Data = s } } return pb } // isGenericLikeTitle 判定 DB 中存的 like 标题是否是占位/空标题(这种情况才走聚合生成)。 // 当前策略:title 为空即认为需要重新生成。后续如果有写死标题的写入路径,可在此过滤。 func isGenericLikeTitle(title string) bool { return strings.TrimSpace(title) == "" }