new
This commit is contained in:
@@ -23,7 +23,7 @@ type Wallet struct {
|
||||
// 钱包状态 - 钱包的基本状态信息
|
||||
IsActive bool `gorm:"default:true" json:"is_active" comment:"钱包是否激活"`
|
||||
Balance decimal.Decimal `gorm:"type:decimal(20,8);default:0" json:"balance" comment:"钱包余额(精确到8位小数)"`
|
||||
Version int64 `gorm:"version" json:"version" comment:"乐观锁版本号"`
|
||||
Version int64 `gorm:"default:0" json:"version" comment:"乐观锁版本号"`
|
||||
|
||||
// 时间戳字段
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"`
|
||||
|
||||
@@ -15,7 +15,7 @@ type WalletTransaction struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"交易记录唯一标识"`
|
||||
UserID string `gorm:"type:varchar(36);not null;index" json:"user_id" comment:"扣款用户ID"`
|
||||
ApiCallID string `gorm:"type:varchar(64);not null;uniqueIndex" json:"api_call_id" comment:"关联API调用ID"`
|
||||
TransactionID string `gorm:"type:varchar(64);not null;uniqueIndex" json:"transaction_id" comment:"交易ID"`
|
||||
TransactionID string `gorm:"type:varchar(36);not null;uniqueIndex" json:"transaction_id" comment:"交易ID"`
|
||||
ProductID string `gorm:"type:varchar(64);not null;index" json:"product_id" comment:"产品ID"`
|
||||
|
||||
// 扣款信息
|
||||
|
||||
@@ -2,6 +2,7 @@ package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"tyapi-server/internal/domains/finance/entities"
|
||||
"tyapi-server/internal/shared/interfaces"
|
||||
@@ -20,4 +21,16 @@ type RechargeRecordRepository interface {
|
||||
// 管理员查询方法
|
||||
List(ctx context.Context, options interfaces.ListOptions) ([]entities.RechargeRecord, error)
|
||||
Count(ctx context.Context, options interfaces.CountOptions) (int64, error)
|
||||
|
||||
// 统计相关方法
|
||||
GetTotalAmountByUserId(ctx context.Context, userId string) (float64, error)
|
||||
GetTotalAmountByUserIdAndDateRange(ctx context.Context, userId string, startDate, endDate time.Time) (float64, error)
|
||||
GetDailyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error)
|
||||
GetMonthlyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error)
|
||||
|
||||
// 系统级别统计方法
|
||||
GetSystemTotalAmount(ctx context.Context) (float64, error)
|
||||
GetSystemAmountByDateRange(ctx context.Context, startDate, endDate time.Time) (float64, error)
|
||||
GetSystemDailyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
|
||||
GetSystemMonthlyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"tyapi-server/internal/domains/finance/entities"
|
||||
"tyapi-server/internal/shared/interfaces"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// FinanceStats 财务统计信息
|
||||
@@ -25,7 +27,9 @@ type WalletRepository interface {
|
||||
GetByUserID(ctx context.Context, userID string) (*entities.Wallet, error)
|
||||
|
||||
// 乐观锁更新(自动重试)
|
||||
UpdateBalanceWithVersion(ctx context.Context, walletID string, newBalance string, oldVersion int64) (bool, error)
|
||||
UpdateBalanceWithVersion(ctx context.Context, walletID string, amount decimal.Decimal, operation string) (bool, error)
|
||||
// 乐观锁更新(通过用户ID直接更新,避免重复查询)
|
||||
UpdateBalanceByUserID(ctx context.Context, userID string, amount decimal.Decimal, operation string) (bool, error)
|
||||
|
||||
// 状态操作
|
||||
ActivateWallet(ctx context.Context, walletID string) error
|
||||
|
||||
@@ -2,6 +2,7 @@ package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"tyapi-server/internal/domains/finance/entities"
|
||||
"tyapi-server/internal/shared/interfaces"
|
||||
)
|
||||
@@ -26,6 +27,22 @@ type WalletTransactionRepository interface {
|
||||
// 新增:统计用户钱包交易次数
|
||||
CountByUserId(ctx context.Context, userId string) (int64, error)
|
||||
|
||||
// 统计相关方法
|
||||
CountByUserIdAndDateRange(ctx context.Context, userId string, startDate, endDate time.Time) (int64, error)
|
||||
GetTotalAmountByUserId(ctx context.Context, userId string) (float64, error)
|
||||
GetTotalAmountByUserIdAndDateRange(ctx context.Context, userId string, startDate, endDate time.Time) (float64, error)
|
||||
GetDailyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error)
|
||||
GetMonthlyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error)
|
||||
|
||||
// 管理端:根据条件筛选所有钱包交易记录(包含产品名称)
|
||||
ListWithFiltersAndProductName(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (map[string]string, []*entities.WalletTransaction, int64, error)
|
||||
|
||||
// 管理端:导出钱包交易记录(包含产品名称和企业信息)
|
||||
ExportWithFiltersAndProductName(ctx context.Context, filters map[string]interface{}) ([]*entities.WalletTransaction, error)
|
||||
|
||||
// 系统级别统计方法
|
||||
GetSystemTotalAmount(ctx context.Context) (float64, error)
|
||||
GetSystemAmountByDateRange(ctx context.Context, startDate, endDate time.Time) (float64, error)
|
||||
GetSystemDailyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
|
||||
GetSystemMonthlyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
|
||||
}
|
||||
186
internal/domains/finance/services/balance_alert_service.go
Normal file
186
internal/domains/finance/services/balance_alert_service.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"tyapi-server/internal/config"
|
||||
"tyapi-server/internal/domains/api/entities"
|
||||
api_repositories "tyapi-server/internal/domains/api/repositories"
|
||||
user_repositories "tyapi-server/internal/domains/user/repositories"
|
||||
"tyapi-server/internal/infrastructure/external/sms"
|
||||
)
|
||||
|
||||
// BalanceAlertService 余额预警服务接口
|
||||
type BalanceAlertService interface {
|
||||
CheckAndSendAlert(ctx context.Context, userID string, balance decimal.Decimal) error
|
||||
}
|
||||
|
||||
// BalanceAlertServiceImpl 余额预警服务实现
|
||||
type BalanceAlertServiceImpl struct {
|
||||
apiUserRepo api_repositories.ApiUserRepository
|
||||
userRepo user_repositories.UserRepository
|
||||
enterpriseInfoRepo user_repositories.EnterpriseInfoRepository
|
||||
smsService *sms.AliSMSService
|
||||
config *config.Config
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewBalanceAlertService 创建余额预警服务
|
||||
func NewBalanceAlertService(
|
||||
apiUserRepo api_repositories.ApiUserRepository,
|
||||
userRepo user_repositories.UserRepository,
|
||||
enterpriseInfoRepo user_repositories.EnterpriseInfoRepository,
|
||||
smsService *sms.AliSMSService,
|
||||
config *config.Config,
|
||||
logger *zap.Logger,
|
||||
) BalanceAlertService {
|
||||
return &BalanceAlertServiceImpl{
|
||||
apiUserRepo: apiUserRepo,
|
||||
userRepo: userRepo,
|
||||
enterpriseInfoRepo: enterpriseInfoRepo,
|
||||
smsService: smsService,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAndSendAlert 检查余额并发送预警
|
||||
func (s *BalanceAlertServiceImpl) CheckAndSendAlert(ctx context.Context, userID string, balance decimal.Decimal) error {
|
||||
// 1. 获取API用户信息
|
||||
apiUser, err := s.apiUserRepo.FindByUserId(ctx, userID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取API用户信息失败",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("获取API用户信息失败: %w", err)
|
||||
}
|
||||
|
||||
if apiUser == nil {
|
||||
s.logger.Debug("API用户不存在,跳过余额预警检查", zap.String("user_id", userID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. 兼容性处理:如果API用户没有配置预警信息,从用户表获取并更新
|
||||
needUpdate := false
|
||||
if apiUser.AlertPhone == "" {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取用户信息失败",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("获取用户信息失败: %w", err)
|
||||
}
|
||||
if user.Phone != "" {
|
||||
apiUser.AlertPhone = user.Phone
|
||||
needUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 兼容性处理:如果API用户没有配置预警阈值,使用默认值
|
||||
if apiUser.BalanceAlertThreshold == 0 {
|
||||
apiUser.BalanceAlertThreshold = s.config.Wallet.BalanceAlert.DefaultThreshold
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
// 4. 如果需要更新API用户信息,保存到数据库
|
||||
if needUpdate {
|
||||
if err := s.apiUserRepo.Update(ctx, apiUser); err != nil {
|
||||
s.logger.Error("更新API用户预警配置失败",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
// 不返回错误,继续执行预警检查
|
||||
}
|
||||
}
|
||||
|
||||
balanceFloat, _ := balance.Float64()
|
||||
|
||||
// 5. 检查是否需要发送欠费预警(不受冷却期限制)
|
||||
if apiUser.ShouldSendArrearsAlert(balanceFloat) {
|
||||
if err := s.sendArrearsAlert(ctx, apiUser, balanceFloat); err != nil {
|
||||
s.logger.Error("发送欠费预警失败",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
// 欠费预警不受冷却期限制,不需要更新LastArrearsAlert时间
|
||||
return nil
|
||||
}
|
||||
|
||||
// 6. 检查是否需要发送低余额预警
|
||||
if apiUser.ShouldSendLowBalanceAlert(balanceFloat) {
|
||||
if err := s.sendLowBalanceAlert(ctx, apiUser, balanceFloat); err != nil {
|
||||
s.logger.Error("发送低余额预警失败",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
// 标记预警已发送
|
||||
apiUser.MarkLowBalanceAlertSent()
|
||||
if err := s.apiUserRepo.Update(ctx, apiUser); err != nil {
|
||||
s.logger.Error("更新API用户预警时间失败",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendArrearsAlert 发送欠费预警
|
||||
func (s *BalanceAlertServiceImpl) sendArrearsAlert(ctx context.Context, apiUser *entities.ApiUser, balance float64) error {
|
||||
// 直接从企业信息表获取企业名称
|
||||
enterpriseInfo, err := s.enterpriseInfoRepo.GetByUserID(ctx, apiUser.UserId)
|
||||
if err != nil {
|
||||
s.logger.Error("获取企业信息失败",
|
||||
zap.String("user_id", apiUser.UserId),
|
||||
zap.Error(err))
|
||||
// 如果获取企业信息失败,使用默认名称
|
||||
return s.smsService.SendBalanceAlert(ctx, apiUser.AlertPhone, balance, 0, "arrears", "天远数据用户")
|
||||
}
|
||||
|
||||
// 获取企业名称,如果没有则使用默认名称
|
||||
enterpriseName := "天远数据用户"
|
||||
if enterpriseInfo != nil && enterpriseInfo.CompanyName != "" {
|
||||
enterpriseName = enterpriseInfo.CompanyName
|
||||
}
|
||||
|
||||
s.logger.Info("发送欠费预警短信",
|
||||
zap.String("user_id", apiUser.UserId),
|
||||
zap.String("phone", apiUser.AlertPhone),
|
||||
zap.Float64("balance", balance),
|
||||
zap.String("enterprise_name", enterpriseName))
|
||||
|
||||
return s.smsService.SendBalanceAlert(ctx, apiUser.AlertPhone, balance, 0, "arrears", enterpriseName)
|
||||
}
|
||||
|
||||
// sendLowBalanceAlert 发送低余额预警
|
||||
func (s *BalanceAlertServiceImpl) sendLowBalanceAlert(ctx context.Context, apiUser *entities.ApiUser, balance float64) error {
|
||||
// 直接从企业信息表获取企业名称
|
||||
enterpriseInfo, err := s.enterpriseInfoRepo.GetByUserID(ctx, apiUser.UserId)
|
||||
if err != nil {
|
||||
s.logger.Error("获取企业信息失败",
|
||||
zap.String("user_id", apiUser.UserId),
|
||||
zap.Error(err))
|
||||
// 如果获取企业信息失败,使用默认名称
|
||||
return s.smsService.SendBalanceAlert(ctx, apiUser.AlertPhone, balance, apiUser.BalanceAlertThreshold, "low_balance", "天远数据用户")
|
||||
}
|
||||
|
||||
// 获取企业名称,如果没有则使用默认名称
|
||||
enterpriseName := "天远数据用户"
|
||||
if enterpriseInfo != nil && enterpriseInfo.CompanyName != "" {
|
||||
enterpriseName = enterpriseInfo.CompanyName
|
||||
}
|
||||
|
||||
s.logger.Info("发送低余额预警短信",
|
||||
zap.String("user_id", apiUser.UserId),
|
||||
zap.String("phone", apiUser.AlertPhone),
|
||||
zap.Float64("balance", balance),
|
||||
zap.Float64("threshold", apiUser.BalanceAlertThreshold),
|
||||
zap.String("enterprise_name", enterpriseName))
|
||||
|
||||
return s.smsService.SendBalanceAlert(ctx, apiUser.AlertPhone, balance, apiUser.BalanceAlertThreshold, "low_balance", enterpriseName)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"tyapi-server/internal/config"
|
||||
"tyapi-server/internal/domains/finance/entities"
|
||||
@@ -25,21 +26,27 @@ type WalletAggregateService interface {
|
||||
|
||||
// WalletAggregateServiceImpl 钱包聚合服务实现
|
||||
type WalletAggregateServiceImpl struct {
|
||||
db *gorm.DB
|
||||
walletRepo repositories.WalletRepository
|
||||
transactionRepo repositories.WalletTransactionRepository
|
||||
balanceAlertSvc BalanceAlertService
|
||||
logger *zap.Logger
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewWalletAggregateService(
|
||||
walletRepo repositories.WalletRepository,
|
||||
transactionRepo repositories.WalletTransactionRepository,
|
||||
logger *zap.Logger,
|
||||
db *gorm.DB,
|
||||
walletRepo repositories.WalletRepository,
|
||||
transactionRepo repositories.WalletTransactionRepository,
|
||||
balanceAlertSvc BalanceAlertService,
|
||||
logger *zap.Logger,
|
||||
cfg *config.Config,
|
||||
) WalletAggregateService {
|
||||
return &WalletAggregateServiceImpl{
|
||||
db: db,
|
||||
walletRepo: walletRepo,
|
||||
transactionRepo: transactionRepo,
|
||||
balanceAlertSvc: balanceAlertSvc,
|
||||
logger: logger,
|
||||
cfg: cfg,
|
||||
}
|
||||
@@ -62,72 +69,59 @@ func (s *WalletAggregateServiceImpl) CreateWallet(ctx context.Context, userID st
|
||||
return &created, nil
|
||||
}
|
||||
|
||||
// Recharge 充值
|
||||
// Recharge 充值 - 使用事务确保一致性
|
||||
func (s *WalletAggregateServiceImpl) Recharge(ctx context.Context, userID string, amount decimal.Decimal) error {
|
||||
w, err := s.walletRepo.GetByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("钱包不存在")
|
||||
}
|
||||
// 使用数据库事务确保一致性
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
ok, err := s.walletRepo.UpdateBalanceByUserID(ctx, userID, amount, "add")
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新钱包余额失败: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("高并发下充值失败,请重试")
|
||||
}
|
||||
|
||||
// 更新钱包余额
|
||||
w.AddBalance(amount)
|
||||
ok, err := s.walletRepo.UpdateBalanceWithVersion(ctx, w.ID, w.Balance.String(), w.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("高并发下充值失败,请重试")
|
||||
}
|
||||
s.logger.Info("钱包充值成功",
|
||||
zap.String("user_id", userID),
|
||||
zap.String("amount", amount.String()))
|
||||
|
||||
s.logger.Info("钱包充值成功",
|
||||
zap.String("user_id", userID),
|
||||
zap.String("wallet_id", w.ID),
|
||||
zap.String("amount", amount.String()),
|
||||
zap.String("balance_after", w.Balance.String()))
|
||||
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
}
|
||||
// Deduct 扣款,含欠费规则
|
||||
|
||||
// Deduct 扣款,含欠费规则 - 使用事务确保一致性
|
||||
func (s *WalletAggregateServiceImpl) Deduct(ctx context.Context, userID string, amount decimal.Decimal, apiCallID, transactionID, productID string) error {
|
||||
w, err := s.walletRepo.GetByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("钱包不存在")
|
||||
}
|
||||
// 使用数据库事务确保一致性
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. 使用乐观锁更新余额(通过用户ID直接更新,避免重复查询)
|
||||
ok, err := s.walletRepo.UpdateBalanceByUserID(ctx, userID, amount, "subtract")
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新钱包余额失败: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("高并发下扣款失败,请重试")
|
||||
}
|
||||
|
||||
// 扣减余额
|
||||
if err := w.SubtractBalance(amount); err != nil {
|
||||
return err
|
||||
}
|
||||
// 2. 创建扣款记录(检查是否已存在)
|
||||
transaction := entities.NewWalletTransaction(userID, apiCallID, transactionID, productID, amount)
|
||||
|
||||
// 更新钱包余额
|
||||
ok, err := s.walletRepo.UpdateBalanceWithVersion(ctx, w.ID, w.Balance.String(), w.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("高并发下扣款失败,请重试")
|
||||
}
|
||||
if err := tx.Create(transaction).Error; err != nil {
|
||||
return fmt.Errorf("创建扣款记录失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建扣款记录
|
||||
transaction := entities.NewWalletTransaction(userID, apiCallID, transactionID, productID, amount)
|
||||
_, err = s.transactionRepo.Create(ctx, *transaction)
|
||||
if err != nil {
|
||||
s.logger.Error("创建扣款记录失败", zap.Error(err))
|
||||
// 不返回错误,因为钱包余额已经更新成功
|
||||
}
|
||||
s.logger.Info("钱包扣款成功",
|
||||
zap.String("user_id", userID),
|
||||
zap.String("amount", amount.String()),
|
||||
zap.String("api_call_id", apiCallID),
|
||||
zap.String("transaction_id", transactionID))
|
||||
|
||||
s.logger.Info("钱包扣款成功",
|
||||
zap.String("user_id", userID),
|
||||
zap.String("wallet_id", w.ID),
|
||||
zap.String("amount", amount.String()),
|
||||
zap.String("balance_after", w.Balance.String()),
|
||||
zap.String("api_call_id", apiCallID))
|
||||
// 3. 扣费成功后异步检查余额预警
|
||||
go s.checkBalanceAlertAsync(context.Background(), userID)
|
||||
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
// GetBalance 查询余额
|
||||
func (s *WalletAggregateServiceImpl) GetBalance(ctx context.Context, userID string) (decimal.Decimal, error) {
|
||||
w, err := s.walletRepo.GetByUserID(ctx, userID)
|
||||
@@ -140,3 +134,22 @@ func (s *WalletAggregateServiceImpl) GetBalance(ctx context.Context, userID stri
|
||||
func (s *WalletAggregateServiceImpl) LoadWalletByUserId(ctx context.Context, userID string) (*entities.Wallet, error) {
|
||||
return s.walletRepo.GetByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// checkBalanceAlertAsync 异步检查余额预警
|
||||
func (s *WalletAggregateServiceImpl) checkBalanceAlertAsync(ctx context.Context, userID string) {
|
||||
// 获取最新余额
|
||||
wallet, err := s.walletRepo.GetByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取钱包余额失败",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查并发送预警
|
||||
if err := s.balanceAlertSvc.CheckAndSendAlert(ctx, userID, wallet.Balance); err != nil {
|
||||
s.logger.Error("余额预警检查失败",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user