All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230) - 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合 - 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试 - 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单 - 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用 - 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计 - tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
179 lines
7.7 KiB
Go
179 lines
7.7 KiB
Go
// 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
|
||
}
|