Files
junhong_cmp_fiber/internal/application/notification/delivery.go
邹进 713045a657
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m9s
fix: 第1批 M9 H5 进入资产的临期个人通知写入即已读
H5 进入资产时已同步弹窗展示临期提醒,通知中心再记未读会重复提醒;
通知按剩余天数每天新增一条,保持未读会持续累积红点。
直发通知载荷新增可选 mark_read,仅临期个人通知设置,其他直发通知与店铺临期通知仍为未读。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:45:20 +09:00

347 lines
15 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 notification 提供站内通知简单写用例与 Outbox 消费边界。
package notification
import (
"context"
"strings"
"time"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AdminDirectPayload 是明确后台账号通知的结构化 Outbox 载荷。
type AdminDirectPayload struct {
RecipientID uint `json:"recipient_id"`
NotificationType string `json:"notification_type"`
TemplateData map[string]string `json:"template_data"`
RefType string `json:"ref_type,omitempty"`
RefID string `json:"ref_id,omitempty"`
RefKey string `json:"ref_key,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
// MarkRead 为 true 时通知写入即已读,用于投递同时已在前端展示过的场景;缺省按未读写入。
MarkRead bool `json:"mark_read,omitempty"`
}
// PersonalCustomerDirectPayload 是明确个人客户通知的结构化 Outbox 载荷。
type PersonalCustomerDirectPayload = AdminDirectPayload
// AdminDynamicPayload 是按账号、平台角色或店铺动态解析后台接收人的结构化 Outbox 载荷。
type AdminDynamicPayload struct {
TargetKind string `json:"target_kind"`
TargetID uint `json:"target_id"`
NotificationType string `json:"notification_type"`
TemplateData map[string]string `json:"template_data"`
RefType string `json:"ref_type,omitempty"`
RefID string `json:"ref_id,omitempty"`
RefKey string `json:"ref_key,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type deliveryRequest struct {
notificationType string
templateData map[string]string
refType string
refID string
refKey string
expiresAt *time.Time
popupSnapshot *model.NotificationPopupSnapshot
markRead bool
}
// DeliveryService 校验接收人并幂等生成站内通知。
type DeliveryService struct {
repository *notificationinfra.Repository
registry *notificationinfra.Registry
resolver DynamicRecipientResolver
logger *zap.Logger
auditWriter *audit.Writer
now func() time.Time
}
// NewDeliveryService 创建站内通知投递用例。
func NewDeliveryService(repository *notificationinfra.Repository, registry *notificationinfra.Registry, resolver DynamicRecipientResolver, logger *zap.Logger, auditWriters ...*audit.Writer) *DeliveryService {
if logger == nil {
logger = zap.NewNop()
}
service := &DeliveryService{repository: repository, registry: registry, resolver: resolver, logger: logger, now: time.Now}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// Consume 消费明确或动态接收人通知事件;所有可恢复错误交给 Asynq 重试策略处理。
func (s *DeliveryService) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if envelope.PayloadVersion != constants.NotificationPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "通知事件类型或载荷版本不受支持")
}
if envelope.EventType == constants.OutboxEventTypeAdminDynamicNotification {
return s.consumeDynamic(ctx, envelope)
}
return s.consumeDirect(ctx, envelope)
}
func (s *DeliveryService) consumeDirect(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
recipientKind, err := recipientKindForDirectEvent(envelope.EventType)
if err != nil {
return err
}
var payload AdminDirectPayload
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "通知事件载荷格式错误")
}
if payload.RecipientID == 0 || payload.NotificationType == "" {
return errors.New(errors.CodeInvalidParam, "通知事件载荷不完整")
}
request := deliveryRequest{
notificationType: payload.NotificationType, templateData: payload.TemplateData,
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
markRead: payload.MarkRead,
}
if err := validateDeliveryRequest(request); err != nil {
return err
}
return s.deliver(ctx, envelope.EventID, recipientKind, []uint{payload.RecipientID}, request)
}
func (s *DeliveryService) consumeDynamic(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
var payload AdminDynamicPayload
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "通知事件载荷格式错误")
}
if payload.TargetKind == "" || payload.TargetID == 0 || payload.NotificationType == "" {
return errors.New(errors.CodeInvalidParam, "通知事件载荷不完整")
}
if s.resolver == nil {
return errors.New(errors.CodeInternalError, "通知动态接收人解析器未配置")
}
request := deliveryRequest{
notificationType: payload.NotificationType, templateData: payload.TemplateData,
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
}
// 载荷校验必须先于接收人解析:无效事件不应触发接收人查询。
if err := validateDeliveryRequest(request); err != nil {
return err
}
recipientIDs, err := s.resolver.Resolve(ctx, payload.TargetKind, payload.TargetID)
if err != nil {
s.logger.Error("站内通知接收人解析失败",
zap.String("event_id", envelope.EventID), zap.String("notification_type", payload.NotificationType),
zap.String("target_kind", payload.TargetKind), zap.Uint("target_id", payload.TargetID),
zap.String("failure_category", "recipient_resolution"))
return err
}
if len(recipientIDs) == 0 {
s.logger.Info("站内通知暂无可用接收人,已正常结束",
zap.String("event_id", envelope.EventID), zap.String("target_kind", payload.TargetKind), zap.Uint("target_id", payload.TargetID),
zap.String("resolution", "no_recipient"))
return nil
}
return s.deliver(ctx, envelope.EventID, constants.NotificationRecipientKindAccount, recipientIDs, request)
}
func validateDeliveryRequest(request deliveryRequest) error {
if strings.Contains(request.refID, "://") || strings.Contains(request.refKey, "://") {
return errors.New(errors.CodeInvalidParam, "通知资源引用禁止包含任意 URL")
}
if request.refType == "" && (request.refID != "" || request.refKey != "") {
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少受控类型")
}
if request.refType != "" && request.refID == "" && request.refKey == "" {
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少定位值")
}
// 投放快照与弹窗类型必须成对出现:非弹窗类型不得写快照,弹窗类型不得缺少快照。
isPopup := constants.IsH5PopupNotificationType(request.notificationType)
if request.popupSnapshot != nil && !isPopup {
return errors.New(errors.CodeInvalidParam, "投放快照只允许用于弹窗通知类型")
}
if isPopup && request.popupSnapshot == nil {
return errors.New(errors.CodeInvalidParam, "弹窗通知缺少投放快照")
}
return nil
}
// preparedDelivery 是一次事件共享的渲染结果、展示期与审计来源,与接收人数量无关。
type preparedDelivery struct {
rendered notificationinfra.Rendered
now time.Time
expiresAt *time.Time
origin deliveryOrigin
}
// deliveryOrigin 是投递审计的操作者与入口。
// Outbox 消费路径留空,由统一审计从任务上下文补齐(与既有 worker 入口一致);
// API 直投路径必须显式提供,因为个人客户请求上下文不携带审计上下文。
type deliveryOrigin struct {
actor audit.ActorInput
source string
}
// prepareDelivery 渲染模板并计算展示期;同一事件只计算一次,不随接收人重复计算。
// 审计接缝缺失在此一次性判空:与既有行为一致,渲染之前就失败,而不是按接收人重复判断。
func (s *DeliveryService) prepareDelivery(eventID, recipientKind string, request deliveryRequest, origin deliveryOrigin) (*preparedDelivery, error) {
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
rendered, err := s.registry.Render(request.notificationType, request.templateData, request.refType, recipientKind)
if err != nil {
s.logger.Error("站内通知模板校验失败",
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
zap.String("failure_category", "template"))
return nil, errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
}
now := s.now().UTC()
expiresAt, err := notificationDisplayExpiry(rendered.Category, request.expiresAt, now)
if err != nil {
s.logger.Error("站内通知展示期限校验失败",
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
zap.String("failure_category", "display_policy"))
return nil, err
}
return &preparedDelivery{rendered: rendered, now: now, expiresAt: expiresAt, origin: origin}, nil
}
// deliver 对每个接收人执行同一套单接收人投放规则;接收人不可用时跳过,不影响其他接收人。
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
prepared, err := s.prepareDelivery(eventID, recipientKind, request, deliveryOrigin{})
if err != nil {
return err
}
for _, recipientID := range recipientIDs {
notification, created, err := s.deliverOne(ctx, eventID, recipientKind, recipientID, request, prepared)
if err != nil {
return err
}
if notification != nil && !created {
s.logger.Info("站内通知重复事件已幂等忽略",
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
}
}
return nil
}
// deliverOne 校验接收人并在单事务内幂等写入一条通知。
// 事件键与接收人已存在时不重复投放,回查并返回既有行(created=false);接收人不可用时返回 (nil, false, nil)。
// Outbox 消费与候选查询直投共用本方法,落库规则只有一处。
func (s *DeliveryService) deliverOne(ctx context.Context, eventID, recipientKind string, recipientID uint, request deliveryRequest, prepared *preparedDelivery) (*model.Notification, bool, error) {
if eventID == "" || recipientID == 0 || prepared == nil {
return nil, false, errors.New(errors.CodeInvalidParam, "通知事件或接收人不完整")
}
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
if err != nil {
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
}
if !active {
s.logger.Info("站内通知接收人不可用,已跳过",
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
return nil, false, nil
}
notification := &model.Notification{
EventID: eventID, RecipientKind: recipientKind,
RecipientID: recipientID, Category: prepared.rendered.Category, Type: prepared.rendered.Type,
Severity: prepared.rendered.Severity, Title: prepared.rendered.Title, Body: prepared.rendered.Body,
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
ExpiresAt: prepared.expiresAt, CreatedAt: prepared.now, PopupSnapshot: request.popupSnapshot,
}
if request.markRead {
readAt := prepared.now
notification.IsRead = true
notification.ReadAt = &readAt
}
created := false
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var createErr error
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
if createErr != nil || !created {
return createErr
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
Actor: prepared.origin.actor, Source: prepared.origin.source,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
Metadata: map[string]any{"outbox_event_id": eventID},
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
nil, map[string]any{"created": true, "is_read": notification.IsRead})},
})
})
if err != nil {
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
}
if created {
return notification, true, nil
}
existing, err := s.repository.FindByEventRecipient(ctx, eventID, recipientKind, recipientID)
if err != nil {
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "回查既有站内通知失败")
}
return existing, false, nil
}
func notificationDisplayExpiry(category string, requested *time.Time, now time.Time) (*time.Time, error) {
switch category {
case constants.NotificationCategoryApproval:
return nil, nil
case constants.NotificationCategoryExpiry:
if requested == nil {
return nil, errors.New(errors.CodeInvalidParam, "临期通知缺少业务到期时间")
}
expiresAt := requested.UTC()
return &expiresAt, nil
case constants.NotificationCategorySync:
return cappedNotificationExpiry(requested, now, constants.NotificationSyncDisplayDays), nil
case constants.NotificationCategorySystem:
return cappedNotificationExpiry(requested, now, constants.NotificationSystemMaxDisplayDays), nil
default:
return nil, errors.New(errors.CodeInvalidParam, "通知类别不支持展示期限策略")
}
}
func cappedNotificationExpiry(requested *time.Time, now time.Time, maxDays int) *time.Time {
maximum := now.AddDate(0, 0, maxDays)
if requested == nil {
if maxDays == constants.NotificationSystemMaxDisplayDays {
defaultExpiry := now.AddDate(0, 0, constants.NotificationSystemDefaultDisplayDays)
return &defaultExpiry
}
return &maximum
}
expiresAt := requested.UTC()
if expiresAt.After(maximum) {
expiresAt = maximum
}
return &expiresAt
}
func recipientKindForDirectEvent(eventType string) (string, error) {
switch eventType {
case constants.OutboxEventTypeAdminDirectNotification:
return constants.NotificationRecipientKindAccount, nil
case constants.OutboxEventTypePersonalCustomerDirectNotification:
return constants.NotificationRecipientKindPersonalCustomer, nil
default:
return "", errors.New(errors.CodeInvalidParam, "通知事件类型或载荷版本不受支持")
}
}
func (s *DeliveryService) isActiveRecipient(ctx context.Context, recipientKind string, recipientID uint) (bool, error) {
switch recipientKind {
case constants.NotificationRecipientKindAccount:
return s.repository.IsActiveAccount(ctx, recipientID)
case constants.NotificationRecipientKindPersonalCustomer:
return s.repository.IsActivePersonalCustomer(ctx, recipientID)
default:
return false, nil
}
}