feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
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:
56
internal/infrastructure/assetautorenewal/commander.go
Normal file
56
internal/infrastructure/assetautorenewal/commander.go
Normal 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, "自动续费复机执行能力未配置")
|
||||
}
|
||||
70
internal/infrastructure/assetautorenewal/task.go
Normal file
70
internal/infrastructure/assetautorenewal/task.go
Normal 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
|
||||
}
|
||||
74
internal/infrastructure/audit/asset_auto_renewal.go
Normal file
74
internal/infrastructure/audit/asset_auto_renewal.go
Normal 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,
|
||||
}},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user