Files
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

226 lines
10 KiB
Go

package notification
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ReadService 执行后台账号与个人客户的幂等已读事务脚本。
type ReadService struct {
db *gorm.DB
auditWriter *audit.Writer
now func() time.Time
}
// NewReadService 创建单条已读用例。
func NewReadService(db *gorm.DB, auditWriters ...*audit.Writer) *ReadService {
service := &ReadService{db: db, now: time.Now}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// MarkRead 仅首次更新当前接收人的未过期未读通知。
func (s *ReadService) MarkRead(ctx context.Context, recipientID, notificationID uint) error {
return s.markOneRead(ctx, constants.NotificationRecipientKindAccount, recipientID, notificationID,
constants.AuditActorAccount, constants.AuditSourceAdminAPI)
}
// MarkAllRead 将当前后台账号全部或指定类别的未过期通知幂等标记为已读。
func (s *ReadService) MarkAllRead(ctx context.Context, recipientID uint, request dto.NotificationReadAllRequest) (*dto.NotificationReadAllResponse, error) {
if recipientID == 0 || !isReadAllCategory(request.Category) {
return nil, errors.New(errors.CodeInvalidParam)
}
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindAccount, recipientID, request.Category,
constants.AuditActorAccount, constants.AuditSourceAdminAPI)
if err != nil {
return nil, err
}
return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil
}
func isReadAllCategory(category string) bool {
switch category {
case "", constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry,
constants.NotificationCategorySync, constants.NotificationCategorySystem:
return true
default:
return false
}
}
// MarkPersonalRead 仅首次更新当前个人客户可见的未过期未读通知。
func (s *ReadService) MarkPersonalRead(ctx context.Context, customerID, notificationID uint) error {
return s.markOneRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, notificationID,
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
}
// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。
func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint) (*dto.NotificationReadAllResponse, error) {
if customerID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, "",
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
if err != nil {
return nil, err
}
return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil
}
func (s *ReadService) markOneRead(ctx context.Context, recipientKind string, recipientID, notificationID uint, actorKind, source string) error {
if recipientID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
now := s.now().UTC()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var notification model.Notification
query := readScope(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), recipientKind, recipientID, now).
Where("id = ? AND is_read = ?", notificationID, false).Take(&notification)
if stderrors.Is(query.Error, gorm.ErrRecordNotFound) {
return nil
}
if query.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, query.Error, "查询通知已读状态失败")
}
if err := tx.WithContext(ctx).Model(&model.Notification{}).Where("id = ? AND is_read = ?", notification.ID, false).
Updates(map[string]any{"is_read": true, "read_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新通知已读状态失败")
}
return s.appendReadAudit(ctx, tx, &notification, now, actorKind, source, "")
})
}
func (s *ReadService) markAllRead(ctx context.Context, recipientKind string, recipientID uint, category, actorKind, source string) (int64, error) {
if s.auditWriter == nil {
return 0, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
now := s.now().UTC()
var updated int64
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var notifications []*model.Notification
query := readScope(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), recipientKind, recipientID, now).
Where("is_read = ?", false)
if category != "" {
query = query.Where("category = ?", category)
}
if err := query.Order("id ASC").Find(&notifications).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量通知已读状态失败")
}
if len(notifications) == 0 {
return nil
}
ids := make([]uint, 0, len(notifications))
for _, notification := range notifications {
ids = append(ids, notification.ID)
}
result := tx.WithContext(ctx).Model(&model.Notification{}).Where("id IN ? AND is_read = ?", ids, false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
}
if result.RowsAffected != int64(len(notifications)) {
return errors.New(errors.CodeInvalidStatus, "通知已读状态发生并发变化")
}
updated = result.RowsAffected
return s.appendReadAllAudit(ctx, tx, notifications, now, recipientKind, recipientID, category, actorKind, source)
})
return updated, err
}
func (s *ReadService) appendReadAudit(ctx context.Context, tx *gorm.DB, notification *model.Notification, now time.Time, actorKind, source, parentEventID string) error {
scopeType, scopeID := notificationScope(notification.RecipientKind, notification.RecipientID)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"),
ActionCode: constants.AuditActionNotificationRead, Summary: "标记通知已读",
Actor: audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(notification.RecipientID), 10)},
Source: source, ScopeType: scopeType, ScopeID: scopeID,
Result: constants.AuditResultSuccess, ParentEventID: parentEventID,
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})},
})
}
func (s *ReadService) appendReadAllAudit(ctx context.Context, tx *gorm.DB, notifications []*model.Notification, now time.Time, recipientKind string, recipientID uint, category, actorKind, source string) error {
rootID := "evt_" + uuid.NewString()
actor := audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(recipientID), 10)}
scopeType, scopeID := notificationScope(recipientKind, recipientID)
children := make([]audit.AppendInput, 0, len(notifications))
for _, notification := range notifications {
children = append(children, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"),
ActionCode: constants.AuditActionNotificationRead, Summary: "批量标记通知已读",
Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess,
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})},
})
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: rootID, ActionCode: constants.AuditActionNotificationReadAll, Summary: "批量标记通知已读",
Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess,
BatchTotal: len(notifications), SuccessCount: len(notifications),
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceNotificationReadBatch, Key: rootID, DisplayName: "通知批量已读",
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: map[string]any{
"recipient_kind": recipientKind, "recipient_id": recipientID,
"category": category, "updated_count": len(notifications),
}, SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func notificationScope(recipientKind string, recipientID uint) (string, string) {
if recipientKind == constants.NotificationRecipientKindPersonalCustomer {
return constants.AuditScopePersonalCustomer, strconv.FormatUint(uint64(recipientID), 10)
}
return constants.AuditScopePlatform, ""
}
func readScope(db *gorm.DB, recipientKind string, recipientID uint, now time.Time) *gorm.DB {
if recipientKind == constants.NotificationRecipientKindPersonalCustomer {
return personalReadScope(db, recipientID, now)
}
return db.Model(&model.Notification{}).Where(
"recipient_kind = ? AND recipient_id = ? AND (expires_at IS NULL OR expires_at > ?)",
recipientKind, recipientID, now,
)
}
func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
return db.Where(`recipient_kind = ? AND recipient_id = ?
AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`,
constants.NotificationRecipientKindPersonalCustomer,
customerID,
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem},
[]string{
constants.NotificationTypePackageExpiring,
constants.NotificationTypeExchangeShippingCreated,
constants.NotificationTypeH5PopupRiskExchange,
constants.NotificationTypeH5PopupOperation,
},
now,
)
}