feat(recharge): 新增充值服务和 DTO
- 实现 RechargeService 完整充值业务逻辑 - 创建充值订单、预检强充要求 - 支付回调处理、幂等性检查 - 累计充值更新、一次性佣金触发 - 新增 RechargeDTO 请求/响应结构 - CreateRechargeRequest、RechargeResponse - RechargeListRequest/Response、RechargeCheckRequest/Response - 完整的单元测试覆盖(1488 行) - 强充要求检查、支付回调、佣金发放等场景 - 事务处理、幂等性验证 Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
107
internal/model/dto/recharge.go
Normal file
107
internal/model/dto/recharge.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// CreateRechargeRequest 创建充值订单请求
|
||||
type CreateRechargeRequest struct {
|
||||
// 资源类型: iot_card-物联网卡 device-设备
|
||||
ResourceType string `json:"resource_type" validate:"required,oneof=iot_card device" description:"资源类型"`
|
||||
// 资源ID(卡ID或设备ID)
|
||||
ResourceID uint `json:"resource_id" validate:"required,min=1" description:"资源ID"`
|
||||
// 充值金额(分)
|
||||
Amount int64 `json:"amount" validate:"required,min=100,max=10000000" description:"充值金额(分)"`
|
||||
// 支付方式: wechat-微信 alipay-支付宝
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" description:"支付方式"`
|
||||
}
|
||||
|
||||
// RechargeResponse 充值订单响应
|
||||
type RechargeResponse struct {
|
||||
// 充值订单ID
|
||||
ID uint `json:"id" description:"充值订单ID"`
|
||||
// 充值订单号
|
||||
RechargeNo string `json:"recharge_no" description:"充值订单号"`
|
||||
// 用户ID
|
||||
UserID uint `json:"user_id" description:"用户ID"`
|
||||
// 钱包ID
|
||||
WalletID uint `json:"wallet_id" description:"钱包ID"`
|
||||
// 充值金额(分)
|
||||
Amount int64 `json:"amount" description:"充值金额(分)"`
|
||||
// 支付方式
|
||||
PaymentMethod string `json:"payment_method" description:"支付方式"`
|
||||
// 支付渠道
|
||||
PaymentChannel *string `json:"payment_channel,omitempty" description:"支付渠道"`
|
||||
// 第三方支付交易号
|
||||
PaymentTransactionID *string `json:"payment_transaction_id,omitempty" description:"第三方支付交易号"`
|
||||
// 充值状态: 1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款
|
||||
Status int `json:"status" description:"充值状态"`
|
||||
// 状态文本
|
||||
StatusText string `json:"status_text" description:"状态文本"`
|
||||
// 支付时间
|
||||
PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"`
|
||||
// 完成时间
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
|
||||
// 创建时间
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
// 更新时间
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// RechargeListRequest 充值订单列表请求
|
||||
type RechargeListRequest struct {
|
||||
// 页码(从1开始)
|
||||
Page int `json:"page" form:"page" description:"页码"`
|
||||
// 每页数量
|
||||
PageSize int `json:"page_size" form:"page_size" description:"每页数量"`
|
||||
// 钱包ID筛选
|
||||
WalletID *uint `json:"wallet_id" form:"wallet_id" description:"钱包ID"`
|
||||
// 状态筛选
|
||||
Status *int `json:"status" form:"status" description:"状态"`
|
||||
// 开始时间
|
||||
StartTime *time.Time `json:"start_time" form:"start_time" description:"开始时间"`
|
||||
// 结束时间
|
||||
EndTime *time.Time `json:"end_time" form:"end_time" description:"结束时间"`
|
||||
}
|
||||
|
||||
// RechargeListResponse 充值订单列表响应
|
||||
type RechargeListResponse struct {
|
||||
// 列表数据
|
||||
List []*RechargeResponse `json:"list" description:"列表数据"`
|
||||
// 总记录数
|
||||
Total int64 `json:"total" description:"总记录数"`
|
||||
// 当前页码
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
// 每页数量
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
// 总页数
|
||||
TotalPages int `json:"total_pages" description:"总页数"`
|
||||
}
|
||||
|
||||
// RechargeCheckRequest 充值预检请求
|
||||
type RechargeCheckRequest struct {
|
||||
// 资源类型: iot_card-物联网卡 device-设备
|
||||
ResourceType string `json:"resource_type" query:"resource_type" validate:"required,oneof=iot_card device" description:"资源类型"`
|
||||
// 资源ID(卡ID或设备ID)
|
||||
ResourceID uint `json:"resource_id" query:"resource_id" validate:"required,min=1" description:"资源ID"`
|
||||
}
|
||||
|
||||
// RechargeCheckResponse 充值预检响应
|
||||
type RechargeCheckResponse struct {
|
||||
// 是否需要强充
|
||||
NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强充"`
|
||||
// 强充金额(分)
|
||||
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强充金额(分)"`
|
||||
// 触发类型: single_recharge-单次充值 accumulated_recharge-累计充值
|
||||
TriggerType string `json:"trigger_type" description:"触发类型"`
|
||||
// 最小充值金额(分)
|
||||
MinAmount int64 `json:"min_amount" description:"最小充值金额(分)"`
|
||||
// 最大充值金额(分)
|
||||
MaxAmount int64 `json:"max_amount" description:"最大充值金额(分)"`
|
||||
// 当前累计充值金额(分)
|
||||
CurrentAccumulated int64 `json:"current_accumulated" description:"当前累计充值金额(分)"`
|
||||
// 佣金触发阈值(分)
|
||||
Threshold int64 `json:"threshold" description:"佣金触发阈值(分)"`
|
||||
// 提示信息
|
||||
Message string `json:"message" description:"提示信息"`
|
||||
// 一次性佣金是否已发放
|
||||
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
|
||||
}
|
||||
724
internal/service/recharge/service.go
Normal file
724
internal/service/recharge/service.go
Normal file
@@ -0,0 +1,724 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ForceRechargeRequirement 强充要求信息
|
||||
type ForceRechargeRequirement struct {
|
||||
NeedForceRecharge bool `json:"need_force_recharge"` // 是否需要强充
|
||||
ForceRechargeAmount int64 `json:"force_recharge_amount"` // 强充金额(分)
|
||||
TriggerType string `json:"trigger_type"` // 触发类型: single_recharge/accumulated_recharge
|
||||
MinAmount int64 `json:"min_amount"` // 最小充值金额
|
||||
MaxAmount int64 `json:"max_amount"` // 最大充值金额
|
||||
CurrentAccumulated int64 `json:"current_accumulated"` // 当前累计充值
|
||||
Threshold int64 `json:"threshold"` // 佣金触发阈值
|
||||
Message string `json:"message"` // 提示信息
|
||||
FirstCommissionPaid bool `json:"first_commission_paid"` // 一次性佣金是否已发放
|
||||
}
|
||||
|
||||
// Service 充值服务
|
||||
// 负责充值订单的创建、预检、支付回调处理等业务逻辑
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
rechargeStore *postgres.RechargeStore
|
||||
walletStore *postgres.WalletStore
|
||||
walletTransactionStore *postgres.WalletTransactionStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
commissionRecordStore *postgres.CommissionRecordStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// New 创建充值服务实例
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
rechargeStore *postgres.RechargeStore,
|
||||
walletStore *postgres.WalletStore,
|
||||
walletTransactionStore *postgres.WalletTransactionStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
commissionRecordStore *postgres.CommissionRecordStore,
|
||||
logger *zap.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
rechargeStore: rechargeStore,
|
||||
walletStore: walletStore,
|
||||
walletTransactionStore: walletTransactionStore,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceStore: deviceStore,
|
||||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||||
commissionRecordStore: commissionRecordStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建充值订单
|
||||
// 验证资源、金额范围、强充要求,生成订单号
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateRechargeRequest, userID uint) (*dto.RechargeResponse, error) {
|
||||
// 1. 验证金额范围
|
||||
if req.Amount < constants.RechargeMinAmount {
|
||||
return nil, errors.New(errors.CodeRechargeAmountInvalid, "充值金额不能低于1元")
|
||||
}
|
||||
if req.Amount > constants.RechargeMaxAmount {
|
||||
return nil, errors.New(errors.CodeRechargeAmountInvalid, "充值金额不能超过100000元")
|
||||
}
|
||||
|
||||
// 2. 获取资源(卡或设备)
|
||||
var wallet *model.Wallet
|
||||
var err error
|
||||
|
||||
if req.ResourceType == "iot_card" {
|
||||
wallet, err = s.walletStore.GetByResourceTypeAndID(ctx, "iot_card", req.ResourceID, "main")
|
||||
} else if req.ResourceType == "device" {
|
||||
wallet, err = s.walletStore.GetByResourceTypeAndID(ctx, "device", req.ResourceID, "main")
|
||||
} else {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "无效的资源类型")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeWalletNotFound, "钱包不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||||
}
|
||||
|
||||
// 3. 验证强充要求
|
||||
forceReq, err := s.checkForceRechargeRequirement(ctx, req.ResourceType, req.ResourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if forceReq.NeedForceRecharge && req.Amount != forceReq.ForceRechargeAmount {
|
||||
return nil, errors.New(errors.CodeForceRechargeAmountMismatch,
|
||||
fmt.Sprintf("必须充值%d分才能满足强充要求", forceReq.ForceRechargeAmount))
|
||||
}
|
||||
|
||||
// 4. 生成充值订单号
|
||||
rechargeNo := s.generateRechargeNo()
|
||||
|
||||
// 5. 创建充值订单
|
||||
recharge := &model.RechargeRecord{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: userID,
|
||||
Updater: userID,
|
||||
},
|
||||
UserID: userID,
|
||||
WalletID: wallet.ID,
|
||||
RechargeNo: rechargeNo,
|
||||
Amount: req.Amount,
|
||||
PaymentMethod: req.PaymentMethod,
|
||||
Status: constants.RechargeStatusPending,
|
||||
}
|
||||
|
||||
if err := s.rechargeStore.Create(ctx, recharge); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
|
||||
}
|
||||
|
||||
s.logger.Info("创建充值订单成功",
|
||||
zap.Uint("recharge_id", recharge.ID),
|
||||
zap.String("recharge_no", rechargeNo),
|
||||
zap.Int64("amount", req.Amount),
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
return s.buildRechargeResponse(recharge), nil
|
||||
}
|
||||
|
||||
// GetRechargeCheck 充值预检
|
||||
// 返回强充要求、金额限制等信息
|
||||
func (s *Service) GetRechargeCheck(ctx context.Context, resourceType string, resourceID uint) (*ForceRechargeRequirement, error) {
|
||||
// 验证资源类型
|
||||
if resourceType != "iot_card" && resourceType != "device" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "无效的资源类型")
|
||||
}
|
||||
|
||||
// 验证资源是否存在
|
||||
if resourceType == "iot_card" {
|
||||
if _, err := s.iotCardStore.GetByID(ctx, resourceID); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeIotCardNotFound, "IoT卡不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||
}
|
||||
} else {
|
||||
if _, err := s.deviceStore.GetByID(ctx, resourceID); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||
}
|
||||
}
|
||||
|
||||
return s.checkForceRechargeRequirement(ctx, resourceType, resourceID)
|
||||
}
|
||||
|
||||
// GetByID 根据ID查询充值订单详情
|
||||
// 支持数据权限过滤
|
||||
func (s *Service) GetByID(ctx context.Context, id uint, userID uint) (*dto.RechargeResponse, error) {
|
||||
recharge, err := s.rechargeStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeRechargeNotFound, "充值订单不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
|
||||
}
|
||||
|
||||
// 数据权限检查:只能查看自己的充值订单
|
||||
if recharge.UserID != userID {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权查看此充值订单")
|
||||
}
|
||||
|
||||
return s.buildRechargeResponse(recharge), nil
|
||||
}
|
||||
|
||||
// List 查询充值订单列表
|
||||
// 支持分页、筛选、数据权限
|
||||
func (s *Service) List(ctx context.Context, req *dto.RechargeListRequest, userID uint) (*dto.RechargeListResponse, error) {
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize == 0 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
|
||||
params := &postgres.ListRechargeParams{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
UserID: &userID, // 数据权限:只能查看自己的
|
||||
}
|
||||
|
||||
if req.Status != nil {
|
||||
params.Status = req.Status
|
||||
}
|
||||
if req.WalletID != nil {
|
||||
params.WalletID = req.WalletID
|
||||
}
|
||||
if req.StartTime != nil {
|
||||
params.StartTime = req.StartTime
|
||||
}
|
||||
if req.EndTime != nil {
|
||||
params.EndTime = req.EndTime
|
||||
}
|
||||
|
||||
recharges, total, err := s.rechargeStore.List(ctx, params)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单列表失败")
|
||||
}
|
||||
|
||||
var list []*dto.RechargeResponse
|
||||
for _, r := range recharges {
|
||||
list = append(list, s.buildRechargeResponse(r))
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return &dto.RechargeListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandlePaymentCallback 支付回调处理
|
||||
// 支持幂等性检查、事务处理、更新余额、触发佣金
|
||||
func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error {
|
||||
// 1. 查询充值订单
|
||||
recharge, err := s.rechargeStore.GetByRechargeNo(ctx, rechargeNo)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
|
||||
}
|
||||
if recharge == nil {
|
||||
return errors.New(errors.CodeRechargeNotFound, "充值订单不存在")
|
||||
}
|
||||
|
||||
// 2. 幂等性检查:已支付则直接返回成功
|
||||
if recharge.Status == constants.RechargeStatusPaid || recharge.Status == constants.RechargeStatusCompleted {
|
||||
s.logger.Info("充值订单已支付,跳过处理",
|
||||
zap.String("recharge_no", rechargeNo),
|
||||
zap.Int("status", recharge.Status),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 检查订单状态是否允许支付
|
||||
if recharge.Status != constants.RechargeStatusPending {
|
||||
return errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
|
||||
}
|
||||
|
||||
// 4. 获取钱包信息
|
||||
wallet, err := s.walletStore.GetByID(ctx, recharge.WalletID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||||
}
|
||||
|
||||
// 5. 获取钱包对应的资源类型和ID
|
||||
resourceType := wallet.ResourceType
|
||||
resourceID := wallet.ResourceID
|
||||
|
||||
// 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金
|
||||
now := time.Now()
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 6.1 更新充值订单状态(带状态检查,实现乐观锁)
|
||||
oldStatus := constants.RechargeStatusPending
|
||||
if err := s.rechargeStore.UpdateStatus(ctx, recharge.ID, &oldStatus, constants.RechargeStatusPaid, &now, nil); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 状态已变更,幂等处理
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单状态失败")
|
||||
}
|
||||
|
||||
// 6.2 更新支付信息
|
||||
if err := s.rechargeStore.UpdatePaymentInfo(ctx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败")
|
||||
}
|
||||
|
||||
// 6.3 增加钱包余额(使用乐观锁)
|
||||
balanceBefore := wallet.Balance
|
||||
result := tx.Model(&model.Wallet{}).
|
||||
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
|
||||
Updates(map[string]any{
|
||||
"balance": gorm.Expr("balance + ?", recharge.Amount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新钱包余额失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInternalError, "钱包版本冲突,请重试")
|
||||
}
|
||||
|
||||
// 6.4 创建钱包交易记录
|
||||
remark := "钱包充值"
|
||||
refType := "recharge"
|
||||
transaction := &model.WalletTransaction{
|
||||
WalletID: wallet.ID,
|
||||
UserID: recharge.UserID,
|
||||
TransactionType: "recharge",
|
||||
Amount: recharge.Amount,
|
||||
BalanceBefore: balanceBefore,
|
||||
BalanceAfter: balanceBefore + recharge.Amount,
|
||||
Status: 1,
|
||||
ReferenceType: &refType,
|
||||
ReferenceID: &recharge.ID,
|
||||
Remark: &remark,
|
||||
Creator: recharge.UserID,
|
||||
}
|
||||
if err := tx.Create(transaction).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
|
||||
}
|
||||
|
||||
// 6.5 更新累计充值
|
||||
if err := s.updateAccumulatedRechargeInTx(ctx, tx, resourceType, resourceID, recharge.Amount); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 6.6 触发一次性佣金判断
|
||||
if err := s.triggerOneTimeCommissionIfNeededInTx(ctx, tx, resourceType, resourceID, recharge.Amount, recharge.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 6.7 更新充值订单状态为已完成
|
||||
if err := tx.Model(&model.RechargeRecord{}).
|
||||
Where("id = ?", recharge.ID).
|
||||
Update("status", constants.RechargeStatusCompleted).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单完成状态失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("充值支付回调处理成功",
|
||||
zap.String("recharge_no", rechargeNo),
|
||||
zap.Int64("amount", recharge.Amount),
|
||||
zap.String("resource_type", resourceType),
|
||||
zap.Uint("resource_id", resourceID),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkForceRechargeRequirement 检查强充要求
|
||||
// 根据资源类型和ID检查是否需要强制充值指定金额
|
||||
func (s *Service) checkForceRechargeRequirement(ctx context.Context, resourceType string, resourceID uint) (*ForceRechargeRequirement, error) {
|
||||
result := &ForceRechargeRequirement{
|
||||
NeedForceRecharge: false,
|
||||
MinAmount: constants.RechargeMinAmount,
|
||||
MaxAmount: constants.RechargeMaxAmount,
|
||||
Message: "无强充要求,可自由充值",
|
||||
}
|
||||
|
||||
var seriesAllocationID *uint
|
||||
var accumulatedRecharge int64
|
||||
var firstCommissionPaid bool
|
||||
|
||||
// 1. 查询资源信息
|
||||
if resourceType == "iot_card" {
|
||||
card, err := s.iotCardStore.GetByID(ctx, resourceID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeIotCardNotFound, "IoT卡不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||
}
|
||||
seriesAllocationID = card.SeriesAllocationID
|
||||
accumulatedRecharge = card.AccumulatedRecharge
|
||||
firstCommissionPaid = card.FirstCommissionPaid
|
||||
} else if resourceType == "device" {
|
||||
device, err := s.deviceStore.GetByID(ctx, resourceID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||
}
|
||||
seriesAllocationID = device.SeriesAllocationID
|
||||
accumulatedRecharge = device.AccumulatedRecharge
|
||||
firstCommissionPaid = device.FirstCommissionPaid
|
||||
}
|
||||
|
||||
result.CurrentAccumulated = accumulatedRecharge
|
||||
result.FirstCommissionPaid = firstCommissionPaid
|
||||
|
||||
// 2. 如果没有系列分配,无强充要求
|
||||
if seriesAllocationID == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 3. 查询系列分配配置
|
||||
allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, *seriesAllocationID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return result, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询系列分配失败")
|
||||
}
|
||||
|
||||
// 4. 如果未启用一次性佣金,无强充要求
|
||||
if !allocation.EnableOneTimeCommission {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Threshold = allocation.OneTimeCommissionThreshold
|
||||
result.TriggerType = allocation.OneTimeCommissionTrigger
|
||||
|
||||
// 5. 如果一次性佣金已发放,无强充要求
|
||||
if firstCommissionPaid {
|
||||
result.Message = "一次性佣金已发放,无强充要求"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 6. 根据触发类型判断强充要求
|
||||
if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerSingleRecharge {
|
||||
// 首次充值触发:必须充值阈值金额
|
||||
result.NeedForceRecharge = true
|
||||
result.ForceRechargeAmount = allocation.OneTimeCommissionThreshold
|
||||
result.Message = fmt.Sprintf("首次充值必须充值%d分", allocation.OneTimeCommissionThreshold)
|
||||
} else if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge {
|
||||
// 累计充值触发:检查是否启用强充
|
||||
if allocation.EnableForceRecharge {
|
||||
result.NeedForceRecharge = true
|
||||
// 强充金额优先使用配置值,否则使用阈值
|
||||
if allocation.ForceRechargeAmount > 0 {
|
||||
result.ForceRechargeAmount = allocation.ForceRechargeAmount
|
||||
} else {
|
||||
result.ForceRechargeAmount = allocation.OneTimeCommissionThreshold
|
||||
}
|
||||
result.Message = fmt.Sprintf("每次充值必须充值%d分", result.ForceRechargeAmount)
|
||||
} else {
|
||||
result.Message = "累计充值模式,可自由充值"
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// updateAccumulatedRechargeInTx 更新累计充值(事务内使用)
|
||||
// 原子操作更新卡或设备的累计充值金额
|
||||
func (s *Service) updateAccumulatedRechargeInTx(ctx context.Context, tx *gorm.DB, resourceType string, resourceID uint, amount int64) error {
|
||||
if resourceType == "iot_card" {
|
||||
result := tx.Model(&model.IotCard{}).
|
||||
Where("id = ?", resourceID).
|
||||
Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", amount))
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡累计充值失败")
|
||||
}
|
||||
} else if resourceType == "device" {
|
||||
result := tx.Model(&model.Device{}).
|
||||
Where("id = ?", resourceID).
|
||||
Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", amount))
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新设备累计充值失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// triggerOneTimeCommissionIfNeededInTx 触发一次性佣金(事务内使用)
|
||||
// 检查是否满足一次性佣金触发条件,满足则创建佣金记录并入账
|
||||
func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx *gorm.DB, resourceType string, resourceID uint, rechargeAmount int64, userID uint) error {
|
||||
var seriesAllocationID *uint
|
||||
var accumulatedRecharge int64
|
||||
var firstCommissionPaid bool
|
||||
var shopID *uint
|
||||
|
||||
// 1. 查询资源当前状态(需要从数据库重新查询以获取更新后的累计充值)
|
||||
if resourceType == "iot_card" {
|
||||
var card model.IotCard
|
||||
if err := tx.First(&card, resourceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||
}
|
||||
seriesAllocationID = card.SeriesAllocationID
|
||||
accumulatedRecharge = card.AccumulatedRecharge
|
||||
firstCommissionPaid = card.FirstCommissionPaid
|
||||
shopID = card.ShopID
|
||||
} else if resourceType == "device" {
|
||||
var device model.Device
|
||||
if err := tx.First(&device, resourceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||
}
|
||||
seriesAllocationID = device.SeriesAllocationID
|
||||
accumulatedRecharge = device.AccumulatedRecharge
|
||||
firstCommissionPaid = device.FirstCommissionPaid
|
||||
shopID = device.ShopID
|
||||
}
|
||||
|
||||
// 2. 如果没有系列分配或已发放佣金,跳过
|
||||
if seriesAllocationID == nil || firstCommissionPaid {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 如果没有归属店铺,无法发放佣金
|
||||
if shopID == nil {
|
||||
s.logger.Warn("资源未归属店铺,无法发放一次性佣金",
|
||||
zap.String("resource_type", resourceType),
|
||||
zap.Uint("resource_id", resourceID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 查询系列分配配置
|
||||
allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, *seriesAllocationID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询系列分配失败")
|
||||
}
|
||||
|
||||
// 5. 如果未启用一次性佣金,跳过
|
||||
if !allocation.EnableOneTimeCommission {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 6. 根据触发类型判断是否满足条件
|
||||
var rechargeAmountToCheck int64
|
||||
switch allocation.OneTimeCommissionTrigger {
|
||||
case model.OneTimeCommissionTriggerSingleRecharge:
|
||||
rechargeAmountToCheck = rechargeAmount
|
||||
case model.OneTimeCommissionTriggerAccumulatedRecharge:
|
||||
rechargeAmountToCheck = accumulatedRecharge
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
// 7. 检查是否达到阈值
|
||||
if rechargeAmountToCheck < allocation.OneTimeCommissionThreshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 8. 计算佣金金额
|
||||
commissionAmount := s.calculateOneTimeCommission(allocation, rechargeAmount)
|
||||
if commissionAmount <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 9. 查询店铺的佣金钱包
|
||||
var commissionWallet model.Wallet
|
||||
if err := tx.Where("resource_type = ? AND resource_id = ? AND wallet_type = ?", "shop", *shopID, "commission").
|
||||
First(&commissionWallet).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
s.logger.Warn("店铺佣金钱包不存在,跳过佣金发放",
|
||||
zap.Uint("shop_id", *shopID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺佣金钱包失败")
|
||||
}
|
||||
|
||||
// 10. 创建佣金记录
|
||||
var iotCardID, deviceID *uint
|
||||
if resourceType == "iot_card" {
|
||||
iotCardID = &resourceID
|
||||
} else {
|
||||
deviceID = &resourceID
|
||||
}
|
||||
|
||||
commissionRecord := &model.CommissionRecord{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: userID,
|
||||
Updater: userID,
|
||||
},
|
||||
ShopID: *shopID,
|
||||
IotCardID: iotCardID,
|
||||
DeviceID: deviceID,
|
||||
CommissionSource: model.CommissionSourceOneTime,
|
||||
Amount: commissionAmount,
|
||||
Status: model.CommissionStatusReleased,
|
||||
Remark: "钱包充值触发一次性佣金",
|
||||
}
|
||||
|
||||
if err := tx.Create(commissionRecord).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建佣金记录失败")
|
||||
}
|
||||
|
||||
// 11. 佣金入账到店铺佣金钱包
|
||||
balanceBefore := commissionWallet.Balance
|
||||
result := tx.Model(&model.Wallet{}).
|
||||
Where("id = ? AND version = ?", commissionWallet.ID, commissionWallet.Version).
|
||||
Updates(map[string]any{
|
||||
"balance": gorm.Expr("balance + ?", commissionAmount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新佣金钱包余额失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInternalError, "佣金钱包版本冲突,请重试")
|
||||
}
|
||||
|
||||
// 12. 更新佣金记录的入账后余额
|
||||
now := time.Now()
|
||||
if err := tx.Model(commissionRecord).Updates(map[string]any{
|
||||
"balance_after": balanceBefore + commissionAmount,
|
||||
"released_at": now,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败")
|
||||
}
|
||||
|
||||
// 13. 创建佣金钱包交易记录
|
||||
remark := "一次性佣金入账(充值触发)"
|
||||
refType := "commission"
|
||||
commissionTransaction := &model.WalletTransaction{
|
||||
WalletID: commissionWallet.ID,
|
||||
UserID: userID,
|
||||
TransactionType: "commission",
|
||||
Amount: commissionAmount,
|
||||
BalanceBefore: balanceBefore,
|
||||
BalanceAfter: balanceBefore + commissionAmount,
|
||||
Status: 1,
|
||||
ReferenceType: &refType,
|
||||
ReferenceID: &commissionRecord.ID,
|
||||
Remark: &remark,
|
||||
Creator: userID,
|
||||
}
|
||||
if err := tx.Create(commissionTransaction).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建佣金钱包交易记录失败")
|
||||
}
|
||||
|
||||
// 14. 标记一次性佣金已发放
|
||||
if resourceType == "iot_card" {
|
||||
if err := tx.Model(&model.IotCard{}).Where("id = ?", resourceID).
|
||||
Update("first_commission_paid", true).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡佣金发放状态失败")
|
||||
}
|
||||
} else {
|
||||
if err := tx.Model(&model.Device{}).Where("id = ?", resourceID).
|
||||
Update("first_commission_paid", true).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备佣金发放状态失败")
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("一次性佣金发放成功",
|
||||
zap.String("resource_type", resourceType),
|
||||
zap.Uint("resource_id", resourceID),
|
||||
zap.Uint("shop_id", *shopID),
|
||||
zap.Int64("commission_amount", commissionAmount),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculateOneTimeCommission 计算一次性佣金金额
|
||||
func (s *Service) calculateOneTimeCommission(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 {
|
||||
if allocation.OneTimeCommissionType == model.OneTimeCommissionTypeFixed {
|
||||
// 固定佣金
|
||||
if allocation.OneTimeCommissionMode == model.CommissionModeFixed {
|
||||
return allocation.OneTimeCommissionValue
|
||||
} else if allocation.OneTimeCommissionMode == model.CommissionModePercent {
|
||||
// 百分比佣金(千分比)
|
||||
return orderAmount * allocation.OneTimeCommissionValue / 1000
|
||||
}
|
||||
}
|
||||
// 梯度佣金在此不处理,由 commission_calculation 服务处理
|
||||
return 0
|
||||
}
|
||||
|
||||
// generateRechargeNo 生成充值订单号
|
||||
// 格式: RCH + 14位时间戳 + 6位随机数
|
||||
func (s *Service) generateRechargeNo() string {
|
||||
now := time.Now()
|
||||
timestamp := now.Format("20060102150405")
|
||||
randomNum := rand.Intn(1000000)
|
||||
return fmt.Sprintf("RCH%s%06d", timestamp, randomNum)
|
||||
}
|
||||
|
||||
// buildRechargeResponse 构建充值订单响应
|
||||
func (s *Service) buildRechargeResponse(recharge *model.RechargeRecord) *dto.RechargeResponse {
|
||||
statusText := ""
|
||||
switch recharge.Status {
|
||||
case constants.RechargeStatusPending:
|
||||
statusText = "待支付"
|
||||
case constants.RechargeStatusPaid:
|
||||
statusText = "已支付"
|
||||
case constants.RechargeStatusCompleted:
|
||||
statusText = "已完成"
|
||||
case constants.RechargeStatusClosed:
|
||||
statusText = "已关闭"
|
||||
case constants.RechargeStatusRefunded:
|
||||
statusText = "已退款"
|
||||
}
|
||||
|
||||
return &dto.RechargeResponse{
|
||||
ID: recharge.ID,
|
||||
RechargeNo: recharge.RechargeNo,
|
||||
UserID: recharge.UserID,
|
||||
WalletID: recharge.WalletID,
|
||||
Amount: recharge.Amount,
|
||||
PaymentMethod: recharge.PaymentMethod,
|
||||
PaymentChannel: recharge.PaymentChannel,
|
||||
PaymentTransactionID: recharge.PaymentTransactionID,
|
||||
Status: recharge.Status,
|
||||
StatusText: statusText,
|
||||
PaidAt: recharge.PaidAt,
|
||||
CompletedAt: recharge.CompletedAt,
|
||||
CreatedAt: recharge.CreatedAt,
|
||||
UpdatedAt: recharge.UpdatedAt,
|
||||
}
|
||||
}
|
||||
1488
internal/service/recharge/service_test.go
Normal file
1488
internal/service/recharge/service_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user