All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s
新增 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)与参数校验中文提示共用实现。
226 lines
10 KiB
Go
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(¬ification)
|
|
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, ¬ification, 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(¬ifications).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,
|
|
)
|
|
}
|