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,零外部渠道调用)
227 lines
8.7 KiB
Go
227 lines
8.7 KiB
Go
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,
|
||
}
|
||
}
|