Files
junhong_cmp_fiber/internal/application/wallet/change_credit.go
break 5e552d99bc 收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展
Confidence: medium
Scope-risk: broad
Directive: 后续修改需保持审计事件与业务事务边界一致
Tested: git diff --cached --check
Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
2026-08-05 14:30:54 +08:00

135 lines
5.9 KiB
Go

// Package wallet 提供代理主钱包复杂写用例。
package wallet
import (
"context"
stderrors "errors"
"strconv"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// CreditChangeAudit 描述一次代理主钱包实际信用额度变化。
type CreditChangeAudit struct {
Wallet *model.AgentWallet
BeforeData map[string]any
AfterData map[string]any
Result string
ErrorCode string
ErrorSummary string
}
// CreditChangeAuditWriter 在信用额度业务事务内追加统一 Audit Event。
type CreditChangeAuditWriter interface {
WriteAgentWalletCreditChange(context.Context, *gorm.DB, CreditChangeAudit) error
}
// ChangeCreditService 调整既有店铺主钱包实际信用额度。
type ChangeCreditService struct {
db *gorm.DB
audit CreditChangeAuditWriter
}
// NewChangeCreditService 创建实际信用额度调整服务。
func NewChangeCreditService(db *gorm.DB, audit CreditChangeAuditWriter) *ChangeCreditService {
return &ChangeCreditService{db: db, audit: audit}
}
// Execute 使用服务端读取的版本条件更新,不修改余额、冻结金额或钱包流水。
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64) (*dto.ShopCreditLimitResponse, error) {
var result *dto.ShopCreditLimitResponse
var stored model.AgentWallet
var beforeData map[string]any
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&stored).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
beforeData = creditAuditData(&stored)
aggregate := domainwallet.AgentWallet{ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit, Status: stored.Status, Version: stored.Version}
if err := aggregate.ChangeCredit(enabled, limit); err != nil {
return err
}
update := tx.Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, stored.Version, aggregate.EffectiveCredit()).
Updates(map[string]any{"credit_enabled": enabled, "credit_limit": limit, "version": gorm.Expr("version + 1")})
if update.Error != nil {
return errors.Wrap(errors.CodeInternalError, update.Error, "更新店铺信用额度失败")
}
if update.RowsAffected != 1 {
var current model.AgentWallet
if err := tx.Where("id = ? AND wallet_type = ?", stored.ID, constants.AgentWalletTypeMain).First(&current).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
if current.Version != stored.Version {
return errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
return errors.New(errors.CodeInsufficientQuota, "当前资金占用无法降低或关闭信用额度")
}
available, _ := aggregate.AvailableBalance()
after := stored
after.CreditEnabled = enabled
after.CreditLimit = limit
after.Version++
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: after.Version}
if s.audit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包信用额度审计接缝未配置")
}
return s.audit.WriteAgentWalletCreditChange(ctx, tx, CreditChangeAudit{
Wallet: &after, BeforeData: beforeData, AfterData: creditAuditData(&after), Result: constants.AuditResultSuccess,
})
})
if err != nil && stored.ID != 0 {
s.recordCreditChangeFailure(ctx, &stored, beforeData, err)
}
return result, err
}
func creditAuditData(wallet *model.AgentWallet) map[string]any {
return map[string]any{
"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance,
"credit_enabled": wallet.CreditEnabled, "credit_limit": wallet.CreditLimit, "version": wallet.Version,
}
}
func (s *ChangeCreditService) recordCreditChangeFailure(ctx context.Context, wallet *model.AgentWallet, beforeData map[string]any, originalErr error) {
result, code, summary := creditChangeError(originalErr)
if s.audit == nil || s.db == nil {
recordCreditChangeSecondaryFailure(ctx, wallet.ID, code, errors.New(errors.CodeInvalidStatus, "代理主钱包信用额度审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteAgentWalletCreditChange(ctx, tx, CreditChangeAudit{
Wallet: wallet, BeforeData: beforeData, Result: result, ErrorCode: code, ErrorSummary: summary,
})
}); err != nil {
recordCreditChangeSecondaryFailure(ctx, wallet.ID, code, err)
}
}
func recordCreditChangeSecondaryFailure(ctx context.Context, walletID uint, errorCode string, err error) {
linkage := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(
constants.AuditActionAgentWalletCreditChanged, strconv.FormatUint(uint64(walletID), 10),
linkage.RequestID, linkage.CorrelationID, errorCode, err,
)
}
func creditChangeError(err error) (string, string, string) {
var appErr *errors.AppError
if !stderrors.As(err, &appErr) {
return constants.AuditResultFailed, strconv.Itoa(errors.CodeInternalError), "更新代理主钱包信用额度失败"
}
result := constants.AuditResultDenied
if appErr.Code == errors.CodeDatabaseError || appErr.Code == errors.CodeInternalError {
result = constants.AuditResultFailed
}
return result, strconv.Itoa(appErr.Code), appErr.Message
}