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,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,