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,56 @@
// Package assetautorenewal 提供资产钱包自动续费的停复机执行适配与异步任务入口。
package assetautorenewal
import (
"context"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CardCommander 把自动续费复机端口转发到既有停复机单一事实源。
//
// 它只做类型与结果分类的转换判定规则、重试、Integration Log、统一审计与观测序列全部由
// internal/service/iot_card 提供,本适配器不复制任何规则,也不直接调用运营商接口。
type CardCommander struct {
service *iotCardSvc.StopResumeService
}
// NewCardCommander 创建自动续费复机执行适配器。
func NewCardCommander(service *iotCardSvc.StopResumeService) *CardCommander {
return &CardCommander{service: service}
}
// AutoRenewalResumeReady 判断该资产是否满足自动复机条件。
func (c *CardCommander) AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error) {
if c == nil || c.service == nil {
return false, "", errCommanderUnavailable()
}
return c.service.AutoRenewalResumeReady(ctx, assetType, assetID)
}
// ResumeAssetForAutoRenewal 执行复机并把结果转换为本用例的结果摘要。
func (c *CardCommander) ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (assetAutoRenewalApp.ResumeOutcome, error) {
if c == nil || c.service == nil {
return assetAutoRenewalApp.ResumeOutcome{}, errCommanderUnavailable()
}
outcome, err := c.service.ResumeAssetForAutoRenewal(ctx, assetType, assetID)
return assetAutoRenewalApp.ResumeOutcome{
Applied: outcome.Applied, IntegrationID: outcome.IntegrationID,
Result: outcome.Result, SafeReason: outcome.SafeReason,
}, err
}
// QueryAutoRenewalResumeState 只查询运营商卡状态,供恢复扫描回填复机结果。
func (c *CardCommander) QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (bool, bool, string, error) {
if c == nil || c.service == nil {
return false, false, "", errCommanderUnavailable()
}
return c.service.QueryAutoRenewalResumeState(ctx, assetType, assetID)
}
// errCommanderUnavailable 返回复机执行端口未配置的稳定错误。
func errCommanderUnavailable() error {
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行能力未配置")
}

View File

@@ -0,0 +1,70 @@
package assetautorenewal
import (
"context"
"github.com/hibiken/asynq"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// DailyScanTaskHandler 执行资产钱包自动续费的每日扫描任务。
//
// 审计上下文固定为计划任务来源,使终态收敛、续费成功与失败事实都可按计划任务维度追溯。
type DailyScanTaskHandler struct {
service *assetAutoRenewalApp.Service
}
// NewDailyScanTaskHandler 创建自动续费每日扫描任务处理器。
func NewDailyScanTaskHandler(service *assetAutoRenewalApp.Service) *DailyScanTaskHandler {
return &DailyScanTaskHandler{service: service}
}
// Handle 执行一次每日扫描;只有扫描级失败才返回错误交既有任务重试。
func (h *DailyScanTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "自动续费扫描任务未配置")
}
taskType := constants.TaskTypeAssetAutoRenewalScan
if task != nil && task.Type() != "" {
taskType = task.Type()
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
ActorName: "资产钱包自动续费每日扫描计划任务", Source: constants.AuditSourceScheduler,
})
_, err := h.service.RunDailyScan(ctx)
return err
}
// RecoveryTaskHandler 执行资产钱包自动续费的复机结果恢复扫描任务。
//
// 只查询运营商状态回填,绝不重复发起复机调用;只有最终确认失败才按通知契约投递通知。
type RecoveryTaskHandler struct {
service *assetAutoRenewalApp.Service
}
// NewRecoveryTaskHandler 创建自动续费复机结果恢复任务处理器。
func NewRecoveryTaskHandler(service *assetAutoRenewalApp.Service) *RecoveryTaskHandler {
return &RecoveryTaskHandler{service: service}
}
// Handle 扫描未收敛的复机子结果并只查询状态回填。
func (h *RecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "自动续费复机结果恢复任务未配置")
}
taskType := constants.TaskTypeAssetAutoRenewalRecovery
if task != nil && task.Type() != "" {
taskType = task.Type()
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
ActorName: "资产钱包自动续费结果恢复计划任务", Source: constants.AuditSourceScheduler,
})
_, err := h.service.RecoverResumeResults(ctx)
return err
}

View File

@@ -0,0 +1,74 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// AssetAutoRenewalConfigAudit 是资产钱包自动续费配置保存的审计事实。
//
// 前后值快照由调用方在保存事务内组装Before 为保存前的开关、范围、集合、天数与版本,
// After 为保存后的对应值。审计写入与配置保存同事务未注册时整体失败fail-closed
type AssetAutoRenewalConfigAudit struct {
OperatorID uint
OperationType string
Description string
BeforeData map[string]any
AfterData map[string]any
Result string
ErrorCode string
ErrorSummary string
RequestID string
CorrelationID string
}
// WriteAssetAutoRenewalConfigChange 将自动续费配置变化转换为统一 Audit Event。
func (w *Writer) WriteAssetAutoRenewalConfigChange(ctx context.Context, tx *gorm.DB, change AssetAutoRenewalConfigAudit) error {
operationType := change.OperationType
if operationType != "" && operationType != constants.AuditOperationAssetAutoRenewalConfigUpdate {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
}
action, ok := w.registry.ActionByOperation(constants.AuditOperationAssetAutoRenewalConfigUpdate)
if !ok {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
}
if change.OperatorID == 0 {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "自动续费配置审计操作者缺失")
}
result := change.Result
if result == "" {
result = constants.AuditResultSuccess
}
summary := change.Description
if summary == "" {
summary = "保存资产钱包自动续费配置"
}
configID := strconv.FormatUint(uint64(constants.AssetAutoRenewalConfigSingletonID), 10)
return w.Append(ctx, tx, AppendInput{
ActionCode: action.Code, Summary: summary,
Actor: ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(change.OperatorID), 10),
Name: middleware.GetUsernameFromContext(ctx),
},
Source: action.Source, RequestPath: contextString(middleware.GetRequestPathFromContext(ctx)),
RequestMethod: contextString(middleware.GetRequestMethodFromContext(ctx)),
IPAddress: contextString(middleware.GetIPFromContext(ctx)), UserAgent: contextString(middleware.GetUserAgentFromContext(ctx)),
ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary,
RequestID: change.RequestID, CorrelationID: change.CorrelationID,
Resources: []ResourceInput{{
Type: constants.AuditResourceAssetAutoRenewalConfig, ID: &configID, Key: configID,
DisplayName: constants.AssetAutoRenewalConfigDisplayName,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleAssetAutoRenewalConfigTarget,
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
}

View File

@@ -436,9 +436,18 @@ func NewRegistry() *Registry {
constants.AuditActionCommissionWithdrawalAttemptRejected, "提现提交被拒绝", constants.AuditResourceShop)
qualificationSubmitRejected := distributionAction(
constants.AuditActionWithdrawalQualificationSubmitRejected, "资格提交被拒绝", constants.AuditResourceShop)
// 资产钱包自动续费:配置保存是运营账号行为,续费与失败是系统任务行为,三者主资源不同。
assetAutoRenewalConfigUpdated := packageConfigAction(
constants.AuditActionAssetAutoRenewalConfigUpdated, "保存资产钱包自动续费配置",
constants.AuditResourceAssetAutoRenewalConfig, constants.AuditRiskHigh)
assetAutoRenewalRenewed := assetAutoRenewalAction(
constants.AuditActionAssetAutoRenewalRenewed, "资产钱包自动续费完成", constants.AuditRiskHigh)
assetAutoRenewalFailed := assetAutoRenewalAction(
constants.AuditActionAssetAutoRenewalFailed, "资产钱包自动续费失败或跳过", constants.AuditRiskNormal)
return &Registry{
actionsByOperation: map[string]ActionDefinition{
constants.AuditOperationSystemConfigUpdate: systemConfigUpdated,
constants.AuditOperationAssetAutoRenewalConfigUpdate: assetAutoRenewalConfigUpdated,
constants.AuditOperationPaymentConfigCreate: paymentConfigCreated,
constants.AuditOperationPaymentConfigUpdate: paymentConfigUpdated,
constants.AuditOperationPaymentConfigDelete: paymentConfigDeleted,
@@ -748,6 +757,9 @@ func NewRegistry() *Registry {
constants.AuditActionWithdrawalQualificationInvalidated: qualificationInvalidated,
constants.AuditActionCommissionWithdrawalAttemptRejected: withdrawalAttemptRejected,
constants.AuditActionWithdrawalQualificationSubmitRejected: qualificationSubmitRejected,
constants.AuditActionAssetAutoRenewalConfigUpdated: assetAutoRenewalConfigUpdated,
constants.AuditActionAssetAutoRenewalRenewed: assetAutoRenewalRenewed,
constants.AuditActionAssetAutoRenewalFailed: assetAutoRenewalFailed,
},
resources: map[string]ResourceDefinition{
constants.AuditResourceAccount: {
@@ -822,6 +834,19 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceLogArchiveMonth, Name: "日志归档自然月",
IdentityFields: []string{"month", "timezone", "range_start", "range_end"},
},
constants.AuditResourceAssetAutoRenewalConfig: {
Type: constants.AuditResourceAssetAutoRenewalConfig, Name: "资产钱包自动续费配置",
IdentityFields: []string{"id", "enabled", "scope", "package_ids", "days_before_expiry", "config_version"},
},
constants.AuditResourceAssetAutoRenewalAttempt: {
Type: constants.AuditResourceAssetAutoRenewalAttempt, Name: "资产钱包自动续费尝试",
IdentityFields: []string{
"id", "asset_type", "asset_id", "trigger_date", "status", "failure_reason", "skip_reason",
"current_usage_id", "current_package_id", "renew_package_id", "renew_price",
"wallet_id", "wallet_transaction_id", "deduct_amount", "balance_before", "balance_after",
"order_id", "order_no", "resume_status", "resume_integration_id", "attempt_seq",
},
},
constants.AuditResourceDeviceBatchTask: {
Type: constants.AuditResourceDeviceBatchTask, Name: "设备批量分配任务",
IdentityFields: []string{"task_no", "operation_type"},
@@ -1168,6 +1193,23 @@ func customerAssetAdminAction(code, name string) ActionDefinition {
}
}
// assetAutoRenewalAction 是资产钱包自动续费系统动作的注册模板。
//
// 续费与失败都由计划任务或 Worker 系统任务产生,主资源是本表新登记的自动续费尝试记录,
// 因此不设 AllowedActor/Source 单一来源,改用 AllowedOrigins 同时接受两个系统入口。
func assetAutoRenewalAction(code, name, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: risk,
PrimaryResource: constants.AuditResourceAssetAutoRenewalAttempt, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
},
}
}
func packageConfigAction(code, name, primaryResource, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryConfiguration, Risk: risk,

View File

@@ -157,6 +157,25 @@ func NewRegistry() *Registry {
constants.NotificationRefTypePackageTrafficAlert: {},
},
},
// 资产钱包自动续费失败:类别沿用 expiry接收人含店铺业务员账号与个人客户
// 资源引用只指向失败资产,正文不含任何 URL 或前端路由。
constants.NotificationTypeAssetAutoRenewalFailed: {
Type: constants.NotificationTypeAssetAutoRenewalFailed, Category: constants.NotificationCategoryExpiry,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "套餐自动续费未完成",
BodyTemplate: "资产 {{.asset_identifier}} 的套餐 {{.package_name}} 自动续费未完成,原因:{{.failure_reason}},最终到期日期:{{.expiry_date}}。",
TemplateFields: map[string]struct{}{
"asset_identifier": {}, "package_name": {}, "failure_reason": {}, "expiry_date": {},
},
RecipientKinds: map[string]struct{}{
constants.NotificationRecipientKindAccount: {},
constants.NotificationRecipientKindPersonalCustomer: {},
},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeIotCard: {},
constants.NotificationRefTypeDevice: {},
},
},
}}
}