// Package packageexpiry 提供套餐临期提醒的 PostgreSQL 与 Outbox Adapter。 package packageexpiry import ( "context" "fmt" "strconv" "time" "gorm.io/gorm" notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "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" ) var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60) // ReminderPublisher 将临期节点事实转换为个人客户通知 Outbox。 type ReminderPublisher struct { db *gorm.DB outbox *outbox.Repository } // NewReminderPublisher 创建套餐临期通知 Outbox Adapter。 func NewReminderPublisher(db *gorm.DB, repository *outbox.Repository) *ReminderPublisher { return &ReminderPublisher{db: db, outbox: repository} } type recipientRow struct { AssetID uint CustomerID uint } // Publish 批量解析资产关联个人客户,并在同一事务内幂等追加通知事件。 func (p *ReminderPublisher) Publish(ctx context.Context, candidates []dto.ExpiringAssetItem) error { if p == nil || p.db == nil || p.outbox == nil { return errors.New(errors.CodeInternalError, "套餐临期通知 Publisher 未配置") } recipients, err := p.loadRecipients(ctx, candidates) if err != nil { return err } return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, candidate := range candidates { if candidate.DaysUntilFinalExpiry == nil || candidate.EstimatedFinalExpiresAt == nil { continue } key := recipientKey(candidate.AssetType, candidate.AssetID) for customerID := range recipients[key] { if err := p.appendNotification(ctx, tx, candidate, customerID); err != nil { return err } } } return nil }) } func (p *ReminderPublisher) loadRecipients(ctx context.Context, candidates []dto.ExpiringAssetItem) (map[string]map[uint]struct{}, error) { cardIDs := make([]uint, 0) deviceIDs := make([]uint, 0) for _, candidate := range candidates { if candidate.AssetType == constants.AssetTypeIotCard { cardIDs = append(cardIDs, candidate.AssetID) } else if candidate.AssetType == constants.AssetTypeDevice { deviceIDs = append(deviceIDs, candidate.AssetID) } } result := make(map[string]map[uint]struct{}) queries := []struct { assetType string ids []uint sql string }{ {constants.AssetTypeIotCard, cardIDs, ` SELECT c.id AS asset_id, b.customer_id FROM tb_iot_card c JOIN tb_personal_customer_device b ON c.virtual_no <> '' AND b.virtual_no = c.virtual_no WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`}, {constants.AssetTypeIotCard, cardIDs, ` SELECT c.id AS asset_id, b.customer_id FROM tb_iot_card c JOIN tb_personal_customer_iccid b ON c.virtual_no = '' AND (b.iccid = c.iccid OR (b.iccid_19 <> '' AND b.iccid_19 = c.iccid_19)) WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`}, {constants.AssetTypeDevice, deviceIDs, ` SELECT d.id AS asset_id, b.customer_id FROM tb_device d JOIN tb_personal_customer_device b ON b.virtual_no = CASE WHEN d.virtual_no <> '' THEN d.virtual_no ELSE d.imei END WHERE d.id IN ? AND d.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`}, } for _, query := range queries { if len(query.ids) == 0 { continue } var rows []recipientRow if err := p.db.WithContext(ctx).Raw(query.sql, query.ids).Scan(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询临期资产关联客户失败") } for _, row := range rows { key := recipientKey(query.assetType, row.AssetID) if result[key] == nil { result[key] = make(map[uint]struct{}) } result[key][row.CustomerID] = struct{}{} } } return result, nil } func (p *ReminderPublisher) appendNotification(ctx context.Context, tx *gorm.DB, candidate dto.ExpiringAssetItem, customerID uint) error { node := *candidate.DaysUntilFinalExpiry expiryDate := candidate.EstimatedFinalExpiresAt.In(shanghaiLocation).Format("20060102") assetCode := "c" if candidate.AssetType == constants.AssetTypeDevice { assetCode = "d" } eventID := fmt.Sprintf("pex:%s:%d:%s:%d:%d", assetCode, candidate.AssetID, expiryDate, node, customerID) assetID := strconv.FormatUint(uint64(candidate.AssetID), 10) _, err := p.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{ EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification, PayloadVersion: constants.NotificationPayloadVersionV1, AggregateType: "package_expiry", AggregateID: strconv.FormatUint(uint64(candidate.PackageUsageID), 10), ResourceType: candidate.AssetType, ResourceID: assetID, BusinessKey: eventID, Payload: notificationapp.PersonalCustomerDirectPayload{ RecipientID: customerID, NotificationType: constants.NotificationTypePackageExpiring, TemplateData: map[string]string{}, RefType: constants.NotificationRefTypeAsset, RefID: assetID, RefKey: candidate.Identifier, ExpiresAt: candidate.EstimatedFinalExpiresAt, }, }) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐临期通知事件失败") } return nil } func recipientKey(assetType string, assetID uint) string { return fmt.Sprintf("%s:%d", assetType, assetID) }