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,226 @@
package assetautorenewal
import (
"context"
"sort"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// ConfigView 是自动续费配置的读取视图。
type ConfigView struct {
Enabled int `json:"enabled"`
Scope string `json:"scope"`
PackageIDs []uint `json:"package_ids"`
DaysBeforeExpiry int `json:"days_before_expiry"`
ConfigVersion int64 `json:"config_version"`
Updater uint `json:"updater"`
UpdatedAt time.Time `json:"updated_at"`
}
// ConfigRequest 是保存自动续费配置的请求。
type ConfigRequest struct {
Enabled int `json:"enabled"`
Scope string `json:"scope"`
PackageIDs []uint `json:"package_ids"`
DaysBeforeExpiry int `json:"days_before_expiry"`
}
// GetConfig 读取唯一的自动续费配置;仅超级管理员与平台账号可见。
func (s *Service) GetConfig(ctx context.Context) (*ConfigView, error) {
if _, err := requirePlatformOperator(ctx); err != nil {
return nil, err
}
config, err := s.configStore.Get(ctx)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "自动续费配置不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败")
}
return toConfigView(config), nil
}
// SaveConfig 保存自动续费配置:单行事务锁串行化、事务内自增配置版本,并与审计同事务写入。
//
// 保存只影响后续扫描:已产生的尝试记录保留触发时的配置版本快照,不重算。
func (s *Service) SaveConfig(ctx context.Context, request ConfigRequest) (*ConfigView, error) {
operatorID, err := requirePlatformOperator(ctx)
if err != nil {
return nil, err
}
packageIDs, err := normalizeConfigRequest(&request)
if err != nil {
return nil, err
}
if len(packageIDs) > 0 {
if err := s.validateSellableMainPackages(ctx, packageIDs); err != nil {
return nil, err
}
}
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "自动续费配置审计接缝未配置")
}
saved := &model.AssetAutoRenewalConfig{}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
current, lockErr := s.configStore.LockInTx(ctx, tx)
if lockErr != nil {
if lockErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "自动续费配置不存在")
}
return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定自动续费配置失败")
}
before := configSnapshot(current)
saved.Enabled = request.Enabled
saved.Scope = request.Scope
saved.PackageIDs = model.UintJSONBArray(packageIDs)
saved.DaysBeforeExpiry = request.DaysBeforeExpiry
saved.ConfigVersion = current.ConfigVersion + 1
saved.Creator = current.Creator
saved.Updater = operatorID
if saveErr := s.configStore.SaveInTx(ctx, tx, saved, operatorID); saveErr != nil {
return errors.Wrap(errors.CodeDatabaseError, saveErr, "保存自动续费配置失败")
}
if auditErr := s.auditWriter.WriteAssetAutoRenewalConfigChange(ctx, tx, audit.AssetAutoRenewalConfigAudit{
OperatorID: operatorID,
OperationType: constants.AuditOperationAssetAutoRenewalConfigUpdate,
Description: "保存资产钱包自动续费配置",
BeforeData: before,
AfterData: configSnapshot(saved),
RequestID: derefString(middleware.GetRequestIDFromContext(ctx)),
CorrelationID: derefString(middleware.GetRequestIDFromContext(ctx)),
}); auditErr != nil {
return auditErr
}
return nil
})
if err != nil {
return nil, err
}
view := &ConfigView{
Enabled: saved.Enabled, Scope: saved.Scope, PackageIDs: packageIDs,
DaysBeforeExpiry: saved.DaysBeforeExpiry, ConfigVersion: saved.ConfigVersion,
Updater: saved.Updater, UpdatedAt: s.now(),
}
s.logger.Info("资产钱包自动续费配置已保存",
zap.Int("enabled", view.Enabled), zap.String("scope", view.Scope),
zap.Int("days_before_expiry", view.DaysBeforeExpiry), zap.Int64("config_version", view.ConfigVersion))
return view, nil
}
// derefString 安全解引用可空字符串,供审计上下文可选字段复用。
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
// requirePlatformOperator 复核调用者仅限超级管理员与平台账号,并返回其账号 ID。
//
// 路由组已做粗粒度门禁这里在业务边界再复核一次账号类型ENG-AUTHZ-001
// 代理、企业与个人客户一律按「无权限或不存在」统一拒绝,不形成可枚举差异。
func requirePlatformOperator(ctx context.Context) (uint, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return operatorID, nil
}
// normalizeConfigRequest 归一化并校验保存请求,返回去重升序的指定套餐集合。
func normalizeConfigRequest(request *ConfigRequest) ([]uint, error) {
if request.Enabled != constants.AssetAutoRenewalConfigEnabledOff &&
request.Enabled != constants.AssetAutoRenewalConfigEnabledOn {
return nil, errors.New(errors.CodeInvalidParam, "自动续费总开关取值非法")
}
if request.Scope != constants.AssetAutoRenewalScopeAll && request.Scope != constants.AssetAutoRenewalScopeSpecified {
return nil, errors.New(errors.CodeInvalidParam, "自动续费适用范围取值非法")
}
if request.DaysBeforeExpiry < constants.AssetAutoRenewalMinDaysBeforeExpiry ||
request.DaysBeforeExpiry > constants.AssetAutoRenewalMaxDaysBeforeExpiry {
return nil, errors.New(errors.CodeInvalidParam, "自动续费到期前天数必须在 1 至 90 之间")
}
seen := make(map[uint]struct{}, len(request.PackageIDs))
packageIDs := make([]uint, 0, len(request.PackageIDs))
for _, packageID := range request.PackageIDs {
if packageID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "自动续费指定套餐包含无效 ID")
}
if _, exists := seen[packageID]; exists {
continue
}
seen[packageID] = struct{}{}
packageIDs = append(packageIDs, packageID)
}
sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] })
if request.Scope == constants.AssetAutoRenewalScopeSpecified && len(packageIDs) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "指定范围必须至少选择一个主套餐")
}
if request.Scope == constants.AssetAutoRenewalScopeAll {
packageIDs = nil
}
return packageIDs, nil
}
// validateSellableMainPackages 校验指定集合只能选择当前可售主套餐。
//
// 可售口径与购买校验的平台分支一致:套餐为正式套餐、全局启用且上架。
// 运行时不因后来下架而拒绝(交由续费豁免判定),因此下架只在此处拦截配置保存。
func (s *Service) validateSellableMainPackages(ctx context.Context, packageIDs []uint) error {
packages, err := s.loadPackagesByIDs(ctx, packageIDs)
if err != nil {
return err
}
for _, packageID := range packageIDs {
pkg, exists := packages[packageID]
if !exists {
return errors.New(errors.CodeInvalidParam, "指定套餐不存在")
}
if pkg.PackageType != constants.PackageTypeFormal {
return errors.New(errors.CodeInvalidParam, "指定范围只能选择主套餐")
}
if pkg.Status != constants.StatusEnabled {
return errors.New(errors.CodeInvalidParam, "指定套餐已禁用")
}
if pkg.ShelfStatus != constants.ShelfStatusOn {
return errors.New(errors.CodeInvalidParam, "指定套餐已下架")
}
}
return nil
}
// configSnapshot 生成配置前后值快照,字段口径固定,便于审计比对。
func configSnapshot(config *model.AssetAutoRenewalConfig) map[string]any {
return map[string]any{
"enabled": config.Enabled,
"scope": config.Scope,
"package_ids": []uint(config.PackageIDs),
"days_before_expiry": config.DaysBeforeExpiry,
"config_version": config.ConfigVersion,
}
}
func toConfigView(config *model.AssetAutoRenewalConfig) *ConfigView {
return &ConfigView{
Enabled: config.Enabled,
Scope: config.Scope,
PackageIDs: []uint(config.PackageIDs),
DaysBeforeExpiry: config.DaysBeforeExpiry,
ConfigVersion: config.ConfigVersion,
Updater: config.Updater,
UpdatedAt: config.UpdatedAt,
}
}