713 lines
22 KiB
Go
713 lines
22 KiB
Go
// 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) == ""
|
||
}
|