topfans/backend/services/notificationService/service/notification_service.go
2026-06-16 21:30:58 +08:00

633 lines
19 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 提供通知服务的业务逻辑层。
//
// 设计约定:
// - 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/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
}
// NewNotificationService 创建 NotificationService。
func NewNotificationService(db *gorm.DB) *NotificationService {
return &NotificationService{
db: db,
notifRepo: repository.NewNotificationRepository(db),
statsRepo: repository.NewNotificationStatsRepository(db),
}
}
// 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))
return &notifPb.CreateNotificationResponse{
Base: appErrors.FormatSuccessResponse(),
Id: newID,
}, nil
}
// 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 := &notifPb.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 &notifPb.GetUnreadCountResponse{
Base: appErrors.FormatSuccessResponse(),
Counts: &notifPb.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 &notifPb.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 &notifPb.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 &notifPb.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 &notifPb.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 &notifPb.DeleteByTargetResponse{
Base: appErrors.FormatSuccessResponse(),
Affected: affected,
}, nil
}
// ========== Helpers内部使用跨文件复用需要再 export ==========
// errResp 构造带 base 的统一错误响应(用于 CreateNotification
func errResp(c codes.Code, msg string) *notifPb.CreateNotificationResponse {
return &notifPb.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 := &notifPb.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 := &notifPb.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, &notifPb.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) == ""
}