package assetautorenewal import ( "context" "fmt" "strconv" "time" "go.uber.org/zap" "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/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/outboxid" ) // failureNotification 是一条失败通知事件所需冻结的事实。 type failureNotification struct { AttemptID uint AssetType string AssetID uint Identifier string ShopID *uint CustomerID uint TriggerDate time.Time Reason string PackageName string FinalExpiresAt *time.Time } // appendFailureNotifications 在调用方事务内为当前个人客户与资产所属店铺各写一条幂等通知事件。 // // 每日至多一条由上锁的两个条件推出:同一资产同一自然日至多一次尝试,且幂等键内嵌资产类型与资产 ID、 // 上海自然日、原因类型与接收人。资产所属店铺当时无有效业务员时不阻断:店铺接收人由既有店铺解析 // 在投递期完成,解析为空列表即正常结束,不影响续费事实与尝试记录;资产无店铺归属时只创建客户通知。 // 非通知原因(如占位中断收敛)一律不投递,未登记原因按 fail-closed 处理。 func (s *Service) appendFailureNotifications(ctx context.Context, tx *gorm.DB, request failureNotification) error { if s.outbox == nil { return errors.New(errors.CodeInvalidStatus, "自动续费通知 Outbox 未配置") } if !constants.IsAssetAutoRenewalNotifiableFailureReason(request.Reason) { s.logger.Warn("自动续费失败原因不属于通知口径,已跳过通知投递", zap.Uint("attempt_id", request.AttemptID), zap.String("reason", request.Reason)) return nil } templateData := map[string]string{ "asset_identifier": request.Identifier, "package_name": request.PackageName, "failure_reason": constants.GetAssetAutoRenewalFailureReasonName(request.Reason), "expiry_date": formatShanghaiDate(request.FinalExpiresAt, s.now()), } assetIDText := strconv.FormatUint(uint64(request.AssetID), 10) // 资源引用按资产类型选择既有可跳转目标:卡用 iot_card 详情、设备用 device 详情 // (两者都在 internal/query/notification/target.go 的目标定义里,idTarget + 可用性复核), // 使店铺/业务员点开通知能进入对应资产详情,而不是落到无目标类型。 refType := assetRefType(request.AssetType) expiresAt := request.FinalExpiresAt if expiresAt == nil { fallback := s.now().UTC() expiresAt = &fallback } if request.CustomerID > 0 { eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "c", request.CustomerID) _, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{ EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification, PayloadVersion: constants.NotificationPayloadVersionV1, AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10), ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID, Payload: notificationapp.PersonalCustomerDirectPayload{ RecipientID: request.CustomerID, NotificationType: constants.NotificationTypeAssetAutoRenewalFailed, TemplateData: templateData, RefType: refType, RefID: assetIDText, ExpiresAt: expiresAt, }, }) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费客户通知事件失败") } } if request.ShopID == nil || *request.ShopID == 0 { return nil } eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "shop", *request.ShopID) _, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{ EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification, PayloadVersion: constants.NotificationPayloadVersionV1, AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10), ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID, Payload: notificationapp.AdminDynamicPayload{ TargetKind: constants.NotificationTargetKindShop, TargetID: *request.ShopID, NotificationType: constants.NotificationTypeAssetAutoRenewalFailed, TemplateData: templateData, RefType: refType, RefID: assetIDText, ExpiresAt: expiresAt, }, }) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费店铺通知事件失败") } return nil } // assetRefType 把资产类型映射为可跳转的通知引用类型(卡片详情 / 设备详情)。 func assetRefType(assetType string) string { if assetType == constants.AssetWalletResourceTypeDevice { return constants.NotificationRefTypeDevice } return constants.NotificationRefTypeIotCard } // failureEventID 构造失败通知的稳定幂等键。 // // 键内嵌资产类型与资产 ID、上海自然日、原因类型与接收人;复机失败沿用该次尝试的日期键, // 因此同一尝试只通知一次且不跨日新增。超长时由 outboxid.Stable 追加稳定摘要,仍保持唯一。 func failureEventID(assetType string, assetID uint, triggerDate time.Time, reason, recipientKind string, recipientID uint) string { dateKey := triggerDate.In(shanghaiLocation).Format("20060102") return outboxid.Stable("aar:", fmt.Sprintf("%s:%d:%s:%s:%s:%d", assetCode(assetType), assetID, dateKey, reason, recipientKind, recipientID)) } // assetCode 把资产类型压缩为单字母代码,只为把幂等键长度压进 Outbox 预算。 func assetCode(assetType string) string { if assetType == constants.AssetWalletResourceTypeDevice { return "d" } return "c" } // formatShanghaiDate 把业务到期时间格式化为上海自然日文本,供通知模板与展示期使用。 func formatShanghaiDate(value *time.Time, fallback time.Time) string { target := fallback if value != nil { target = *value } return target.In(shanghaiLocation).Format("2006-01-02") }