// Package assetautorenewal 编排资产钱包自动续费:受控配置维护、每日扫描与尝试、续费事务闭合、 // 失败通知与复机可靠投递。 // // 本包不调用任何支付渠道或运营商接口:续购价格与可售判定复用应用层购买校验与价格策略, // 复机执行通过 ResumeCommander 端口复用既有停复机单一事实源,通知与复机都通过公共 Outbox // 在业务事务内写出事件(ENG-OUTBOX-001)。资金、订单、套餐与成功审计在同一事务内闭合(ENG-TX-001)。 package assetautorenewal import ( "context" "strconv" "time" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/model" assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal" "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "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) // ResumeOutcome 是一次复机调用可安全记录的结果摘要。 // // Applied 为 false 表示本次未满足可复机条件、没有发起任何复机调用;Result 取值 // constants.AuditResultSuccess / Failed / Unknown。 type ResumeOutcome struct { Applied bool IntegrationID string Result string SafeReason string } // ResumeCommander 是自动续费成功后复机的执行边界。 // // 实现必须复用既有停复机单一事实源(重试、Integration Log、统一审计、观测序列)与既有 // 套餐/流量/实名/风险判定;本包绝不复制这些规则,也绝不直接调用运营商接口。 type ResumeCommander interface { // AutoRenewalResumeReady 判断该资产当前是否满足自动复机条件;不满足时返回可安全记录的原因。 AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error) // ResumeAssetForAutoRenewal 执行复机并返回结果分类。 ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (ResumeOutcome, error) // QueryAutoRenewalResumeState 只查询运营商状态回填复机结果,绝不重复发起复机。 QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (online bool, known bool, integrationID string, err error) } // Dependencies 汇总自动续费用例的装配依赖。 // // DB 与 Redis 用于构造本用例独占的资产钱包、流水、订单、套餐与资产 Store; // 其余依赖是配置、价格、候选、复机与可靠事件的能力边界。 type Dependencies struct { DB *gorm.DB Redis *redis.Client Logger *zap.Logger Outbox *outbox.Repository AuditWriter *audit.Writer PurchaseValidation *purchase_validation.Service Candidates *assetquery.Query Resume ResumeCommander ObservationEvents cardObservationApp.SeriesEventWriter PriorityEvents priorityapp.PriorityEventWriter } // Service 执行资产钱包自动续费的配置维护、每日扫描、续费事务与终止态收敛。 type Service struct { db *gorm.DB configStore *postgres.AssetAutoRenewalConfigStore attemptStore *postgres.AssetAutoRenewalAttemptStore assetWalletStore *postgres.AssetWalletStore walletTransactionStore *postgres.AssetWalletTransactionStore orderStore *postgres.OrderStore packageUsageStore *postgres.PackageUsageStore packageStore *postgres.PackageStore iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore personalCustomerStore *postgres.PersonalCustomerStore candidates *assetquery.Query purchaseValidation *purchase_validation.Service outbox *outbox.Repository auditWriter *audit.Writer resume ResumeCommander observationEvents cardObservationApp.SeriesEventWriter priorityEvents priorityapp.PriorityEventWriter logger *zap.Logger now func() time.Time } // NewService 创建资产钱包自动续费用例。 func NewService(deps Dependencies) *Service { logger := deps.Logger if logger == nil { logger = zap.NewNop() } return &Service{ db: deps.DB, configStore: postgres.NewAssetAutoRenewalConfigStore(deps.DB), attemptStore: postgres.NewAssetAutoRenewalAttemptStore(deps.DB), assetWalletStore: postgres.NewAssetWalletStore(deps.DB, deps.Redis), walletTransactionStore: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis), orderStore: postgres.NewOrderStore(deps.DB, deps.Redis), packageUsageStore: postgres.NewPackageUsageStore(deps.DB, deps.Redis), packageStore: postgres.NewPackageStore(deps.DB), iotCardStore: postgres.NewIotCardStore(deps.DB, deps.Redis), deviceStore: postgres.NewDeviceStore(deps.DB, deps.Redis), personalCustomerStore: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), candidates: deps.Candidates, purchaseValidation: deps.PurchaseValidation, outbox: deps.Outbox, auditWriter: deps.AuditWriter, resume: deps.Resume, observationEvents: deps.ObservationEvents, priorityEvents: deps.PriorityEvents, logger: logger, now: time.Now, } } // today 返回当前上海自然日,作为触发日期与每日唯一键的统一口径。 func (s *Service) today() time.Time { return assetquery.Today(s.now()) } // assetIdentifier 读取资产对外的可读标识,取不到时回退为资产 ID 文本。 // 通知模板要求标识非空,因此绝不返回空串。 func (s *Service) assetIdentifier(ctx context.Context, assetType string, assetID uint) string { switch assetType { case constants.AssetWalletResourceTypeIotCard: if card, err := s.iotCardStore.GetByID(ctx, assetID); err == nil && card.ICCID != "" { return card.ICCID } case constants.AssetWalletResourceTypeDevice: if device, err := s.deviceStore.GetByID(ctx, assetID); err == nil { if device.VirtualNo != "" { return device.VirtualNo } if device.IMEI != "" { return device.IMEI } } } return strconv.FormatUint(uint64(assetID), 10) } // packageName 读取套餐商品名称,取不到时回退为套餐 ID 文本。 func (s *Service) packageName(ctx context.Context, packageID uint) string { if packageID == 0 { return "未知套餐" } if pkg, err := s.packageStore.GetByID(ctx, packageID); err == nil && pkg.PackageName != "" { return pkg.PackageName } return strconv.FormatUint(uint64(packageID), 10) } // loadPackagesByIDs 批量读取套餐商品,用于一次性取价与快照。 func (s *Service) loadPackagesByIDs(ctx context.Context, packageIDs []uint) (map[uint]*model.Package, error) { result := make(map[uint]*model.Package, len(packageIDs)) if len(packageIDs) == 0 { return result, nil } var packages []*model.Package if err := s.db.WithContext(ctx).Where("id IN ?", packageIDs).Find(&packages).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费套餐商品失败") } for _, pkg := range packages { result[pkg.ID] = pkg } return result, nil }