Files
junhong_cmp_fiber/internal/application/notification/delivery.go
break 333ba4b647
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s
feat(H5弹窗): AUG26-007 风险换卡与运营弹窗投放通知
新增 000225 迁移:运营弹窗配置表 tb_h5_popup_configuration(页面/范围/优先级/频率/受控动作/启停/有效期/版本)
与 tb_notification 可空 JSONB 列 popup_snapshot。

新增通知直建窄接口 DirectWriter.CreateOrGetPersonal:与 Outbox 消费共用 prepareDelivery 的渲染、
展示期与 CreateIdempotent 规则,冲突时回查返回既有行;同步扩展个人通知查询与已读两处类型白名单,
并按个人客户入口补齐投递审计来源。

新增 H5 候选与风险换卡:GET /api/c/v1/popup-candidates 先判风险资格(广电卡 + 风险停机 +
无活动物流换货单),命中只返回风险候选;未命中再按时间/启停/页面/店铺/设备类型/卡类型范围/频率
匹配运营配置。POST /api/c/v1/risk-exchanges/:asset_id/address 锁资产行后幂等创建待发货物流换货单,
首次地址锁定,不沿用资产级群发通知。

新增后台运营弹窗配置 CRUD 与启停(仅超级管理员与平台账号),更新递增版本并刷新最近更新时间,
标题与正文统一拒绝 URL 与前端路由,全部写操作记录操作者、前后值、版本与时间。

同步 OpenAPI(cmd/gendocs、cmd/api/docs.go、pkg/openapi/handlers.go)与参数校验中文提示共用实现。
2026-09-15 15:23:52 +08:00

338 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"`
}
// 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
}
// 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,
}
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,
}
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": false})},
})
})
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
}
}