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:
226
internal/application/assetautorenewal/config.go
Normal file
226
internal/application/assetautorenewal/config.go
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user