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

@@ -33,6 +33,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。

View File

@@ -42,6 +42,8 @@ func generateAdminDocs(outputPath string) error {
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。

View File

@@ -16,6 +16,7 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
@@ -28,6 +29,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
@@ -94,6 +96,8 @@ type workerRuntime struct {
refundChannelService *refundchannelApp.Service
// carrierThresholdService 是通道流量阈值停复机与两个计划任务的唯一用例实例。
carrierThresholdService *carrierThresholdApp.Service
// assetAutoRenewalService 是资产钱包自动续费的每日扫描、复机消费者与恢复扫描的唯一用例实例。
assetAutoRenewalService *assetAutoRenewalApp.Service
}
func main() {
@@ -167,6 +171,7 @@ func runWorker(cfg *config.Config) {
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerCarrierThresholdTasks(taskHandler.GetMux(), runtime, appLogger)
registerAssetAutoRenewalTasks(taskHandler.GetMux(), runtime, appLogger)
registerRefundCommissionRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger)
outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
@@ -342,12 +347,15 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
lifecycleSvc: lifecycleSvc,
// 通道阈值停复机复用既有停复机服务作为唯一执行事实源重试、Integration Log、统一审计与既有判定
carrierThresholdService: workerResult.Services.CarrierThreshold,
// 自动续费的复机执行同样复用既有停复机单一事实源。
assetAutoRenewalService: workerResult.Services.AssetAutoRenewal,
}
registerNotificationOutboxConsumer(runtime, appLogger)
registerWalletOutboxConsumer(runtime, appLogger)
registerCardObservationOutboxConsumer(runtime, appLogger)
registerPriorityPollingOutboxConsumer(runtime, appLogger)
registerCarrierThresholdOutboxConsumer(runtime, appLogger)
registerAssetAutoRenewalOutboxConsumer(runtime, appLogger)
registerWeComApprovalOutboxConsumer(runtime, cfg, appLogger)
return runtime
}
@@ -387,6 +395,19 @@ func registerCarrierThresholdOutboxConsumer(runtime *workerRuntime, appLogger *z
}
}
// registerAssetAutoRenewalOutboxConsumer 注册资产钱包自动续费的复机请求事件消费者。
// 消费者按尝试记录认领执行权后执行复机,重复投递不会产生第二次外部调用。
func registerAssetAutoRenewalOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.assetAutoRenewalService == nil {
appLogger.Fatal("资产钱包自动续费用例未配置")
}
consumer := assetAutoRenewalApp.NewResumeConsumer(runtime.assetAutoRenewalService)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAssetAutoRenewalResumeRequested, consumer); err != nil {
appLogger.Fatal("注册资产钱包自动续费 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeAssetAutoRenewalResumeRequested), zap.Error(err))
}
}
// registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。
func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
@@ -588,6 +609,20 @@ func registerCarrierThresholdTasks(mux *asynq.ServeMux, runtime *workerRuntime,
appLogger.Info("注册运营商通道流量阈值结果恢复任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdRecovery))
}
// registerAssetAutoRenewalTasks 注册资产钱包自动续费的每日扫描与复机结果恢复任务。
// 两个任务共用同一用例实例:每日扫描负责终态收敛与续购执行,恢复扫描只查询状态回填,绝不重复发起复机。
func registerAssetAutoRenewalTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
if runtime == nil || runtime.assetAutoRenewalService == nil {
appLogger.Fatal("资产钱包自动续费用例未配置")
}
scanHandler := assetAutoRenewalInfra.NewDailyScanTaskHandler(runtime.assetAutoRenewalService)
mux.HandleFunc(constants.TaskTypeAssetAutoRenewalScan, scanHandler.Handle)
appLogger.Info("注册资产钱包自动续费每日扫描任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalScan))
recoveryHandler := assetAutoRenewalInfra.NewRecoveryTaskHandler(runtime.assetAutoRenewalService)
mux.HandleFunc(constants.TaskTypeAssetAutoRenewalRecovery, recoveryHandler.Handle)
appLogger.Info("注册资产钱包自动续费复机结果恢复任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalRecovery))
}
// registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。
func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
@@ -948,6 +983,25 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
)); err != nil {
return fmt.Errorf("注册每日套餐真流量达量预警扫描定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 7 * * *", asynq.NewTask(
constants.TaskTypeAssetAutoRenewalScan,
nil,
asynq.MaxRetry(3),
asynq.Timeout(30*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalScan)),
)); err != nil {
return fmt.Errorf("注册每日资产钱包自动续费扫描定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 5m", asynq.NewTask(
constants.TaskTypeAssetAutoRenewalRecovery,
nil,
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.Unique(5*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalRecovery)),
)); err != nil {
return fmt.Errorf("注册资产钱包自动续费复机结果恢复定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register(
"0 2 * * *",
asynq.NewTask(