feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
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,零外部渠道调用)
This commit is contained in:
2026-09-17 16:39:26 +08:00
parent 70e6b186df
commit d52be16802
54 changed files with 5164 additions and 97 deletions

View File

@@ -0,0 +1,178 @@
// 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
}