bindbox-game/internal/service/user/reward_grant.go
2025-12-26 12:22:32 +08:00

386 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package user
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
)
// GrantRewardRequest 奖励发放请求
type GrantRewardRequest struct {
ProductID int64 `json:"product_id" binding:"required"` // 商品ID
Quantity int `json:"quantity" binding:"min=1"` // 发放数量
ActivityID *int64 `json:"activity_id,omitempty"` // 活动ID可选
RewardID *int64 `json:"reward_id,omitempty"` // 奖励配置ID可选
AddressID *int64 `json:"address_id,omitempty"` // 收货地址ID可选实物商品需要
Remark string `json:"remark,omitempty"` // 备注
PointsAmount int64 `json:"points_amount,omitempty"` // 消耗积分
}
// GrantRewardResponse 奖励发放响应
type GrantRewardResponse struct {
Success bool `json:"success"`
OrderID int64 `json:"order_id"`
InventoryIDs []int64 `json:"inventory_ids"`
Message string `json:"message,omitempty"`
}
// GrantReward 给用户发放奖励(事务处理)
func (s *service) GrantReward(ctx context.Context, userID int64, req GrantRewardRequest) (*GrantRewardResponse, error) {
logger := zap.L().With(zap.Int64("user_id", userID), zap.Int64("product_id", req.ProductID))
var (
orderID int64
inventoryIDs []int64
)
logger.Info("开始发放奖励", zap.Any("request", req))
// 执行事务
err := s.writeDB.Transaction(func(tx *dao.Query) error {
logger.Info("开始事务处理")
// 1. 检查奖励配置库存如果提供了reward_id
if req.RewardID != nil {
logger.Info("检查奖励配置", zap.Int64("reward_id", *req.RewardID))
rewardSetting, err := tx.ActivityRewardSettings.WithContext(ctx).Where(
tx.ActivityRewardSettings.ID.Eq(*req.RewardID),
).First()
if err != nil {
logger.Error("查询奖励配置失败", zap.Error(err))
return fmt.Errorf("查询奖励配置失败: %w", err)
}
if rewardSetting == nil {
return fmt.Errorf("奖励配置不存在")
}
if rewardSetting.Quantity < int64(req.Quantity) {
return fmt.Errorf("奖励库存不足,剩余%d需要%d", rewardSetting.Quantity, req.Quantity)
}
// 扣减奖励库存
_, err = tx.ActivityRewardSettings.WithContext(ctx).Where(
tx.ActivityRewardSettings.ID.Eq(*req.RewardID),
).Update(tx.ActivityRewardSettings.Quantity, rewardSetting.Quantity-int64(req.Quantity))
if err != nil {
logger.Error("扣减奖励库存失败", zap.Error(err))
return fmt.Errorf("扣减奖励库存失败: %w", err)
}
logger.Info("奖励库存扣减成功")
}
// 2. 生成订单号
orderNo := generateOrderNo()
logger.Info("生成订单号", zap.String("order_no", orderNo))
// 3. 创建系统发放订单
now := time.Now()
// 对于已支付的订单设置取消时间为MySQL可接受的最小有效时间1970-01-01
// 避免使用零时间导致MySQL的'0000-00-00'错误
minValidTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
order := &model.Orders{
OrderNo: orderNo,
UserID: userID,
SourceType: 3, // 系统发放
Status: 2, // 已支付
TotalAmount: 0,
DiscountAmount: 0,
PointsAmount: req.PointsAmount,
ActualAmount: 0,
IsConsumed: 0,
PaidAt: now, // 设置支付时间为当前时间
CancelledAt: minValidTime, // 设置取消时间为最小有效时间避免MySQL错误
Remark: req.Remark,
CreatedAt: now,
UpdatedAt: now,
}
logger.Info("创建订单", zap.Any("order", order))
err := tx.Orders.WithContext(ctx).Create(order)
if err != nil {
logger.Error("创建订单失败", zap.Error(err))
return fmt.Errorf("创建订单失败: %w", err)
}
orderID = order.ID
logger.Info("订单创建成功", zap.Int64("order_id", orderID))
// 4. 查询商品信息快照
product, err := tx.Products.WithContext(ctx).Where(tx.Products.ID.Eq(req.ProductID)).First()
if err != nil {
logger.Error("查询商品失败", zap.Error(err))
return fmt.Errorf("查询商品失败: %w", err)
}
// 5. 创建订单项(按数量创建多个)
for i := 0; i < req.Quantity; i++ {
orderItem := &model.OrderItems{
OrderID: orderID,
ProductID: req.ProductID,
Title: product.Name,
Quantity: 1,
Price: 0, // 成交单价(分)
TotalAmount: 0, // 行应付总额(分)
ProductImages: product.ImagesJSON,
Status: 1, // 行状态1正常
}
err := tx.OrderItems.WithContext(ctx).Create(orderItem)
if err != nil {
logger.Error("创建订单项失败", zap.Error(err))
return fmt.Errorf("创建订单项失败: %w", err)
}
// 6. 创建用户资产记录
inventory := &model.UserInventory{
UserID: userID,
ProductID: req.ProductID,
OrderID: orderID,
ActivityID: func() int64 {
if req.ActivityID != nil {
return *req.ActivityID
} else {
return 0
}
}(),
RewardID: func() int64 {
if req.RewardID != nil {
return *req.RewardID
} else {
return 0
}
}(),
Status: 1, // 持有状态
Remark: func() string {
if req.Remark != "" {
return req.Remark
}
return product.Name
}(),
}
err = tx.UserInventory.WithContext(ctx).Create(inventory)
if err != nil {
logger.Error("创建用户资产失败", zap.Error(err))
return fmt.Errorf("创建用户资产失败: %w", err)
}
inventoryIDs = append(inventoryIDs, inventory.ID)
// 7. 如果需要发货提供了地址ID创建发货记录
if req.AddressID != nil {
// 设置发货和签收时间为MySQL可接受的最小有效时间1970-01-01
// 避免使用零时间导致MySQL的'0000-00-00'错误
minValidTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
shippingRecord := &model.ShippingRecords{
UserID: userID,
OrderID: orderID,
OrderItemID: orderItem.ID,
InventoryID: inventory.ID,
ProductID: req.ProductID,
AddressID: *req.AddressID,
Quantity: 1,
Status: 1, // 待发货
ShippedAt: minValidTime, // 设置发货时间为最小有效时间
ReceivedAt: minValidTime, // 设置签收时间为最小有效时间
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := tx.ShippingRecords.WithContext(ctx).Create(shippingRecord)
if err != nil {
logger.Error("创建发货记录失败", zap.Error(err))
return fmt.Errorf("创建发货记录失败: %w", err)
}
}
}
return nil
})
if err != nil {
return &GrantRewardResponse{
Success: false,
Message: err.Error(),
}, err
}
logger.Info("奖励发放成功",
zap.Int64("order_id", orderID),
zap.Int64s("inventory_ids", inventoryIDs),
zap.Int("quantity", req.Quantity),
)
return &GrantRewardResponse{
Success: true,
OrderID: orderID,
InventoryIDs: inventoryIDs,
Message: fmt.Sprintf("成功发放%d个奖励", req.Quantity),
}, nil
}
// generateOrderNo 生成订单号
func generateOrderNo() string {
// 使用当前时间戳 + 随机数生成订单号
// 格式RG + 年月日时分秒 + 6位随机数
return fmt.Sprintf("RG%s%d",
time.Now().Format("20060102150405"),
time.Now().UnixNano()%1000000,
)
}
// GrantRewardToOrderRequest 在现有订单上发放奖励的请求参数
type GrantRewardToOrderRequest struct {
OrderID int64 `json:"order_id" binding:"required"` // 现有订单ID
ProductID int64 `json:"product_id" binding:"required"` // 商品ID
Quantity int `json:"quantity" binding:"min=1"` // 发放数量
ActivityID *int64 `json:"activity_id,omitempty"` // 活动ID可选
RewardID *int64 `json:"reward_id,omitempty"` // 奖励配置ID可选
Remark string `json:"remark,omitempty"` // 备注
}
// GrantRewardToOrderResponse 在现有订单上发放奖励的响应
type GrantRewardToOrderResponse struct {
Success bool `json:"success"`
OrderID int64 `json:"order_id"`
InventoryIDs []int64 `json:"inventory_ids"`
Message string `json:"message,omitempty"`
}
// GrantRewardToOrder 在现有订单上发放奖励(不创建新订单)
// 用于抽奖中奖场景:用户支付的抽奖订单中奖后,直接在该订单上添加中奖商品
func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req GrantRewardToOrderRequest) (*GrantRewardToOrderResponse, error) {
logger := zap.L().With(zap.Int64("user_id", userID), zap.Int64("order_id", req.OrderID), zap.Int64("product_id", req.ProductID))
var inventoryIDs []int64
logger.Info("开始在现有订单上发放奖励", zap.Any("request", req))
// 执行事务
err := s.writeDB.Transaction(func(tx *dao.Query) error {
logger.Info("开始事务处理")
// 1. 验证订单存在且属于该用户
order, err := tx.Orders.WithContext(ctx).Where(
tx.Orders.ID.Eq(req.OrderID),
tx.Orders.UserID.Eq(userID),
).First()
if err != nil || order == nil {
logger.Error("订单不存在或不属于该用户", zap.Error(err))
return fmt.Errorf("订单不存在或不属于该用户")
}
// 【关键校验】验证订单状态必须为已支付(2),未支付的订单不能发奖
if order.Status != 2 {
logger.Error("订单状态不正确,无法发奖", zap.Int32("status", order.Status))
return fmt.Errorf("订单未支付或状态异常(status=%d),无法发奖", order.Status)
}
// 2. 检查奖励配置库存如果提供了reward_id
if req.RewardID != nil {
logger.Info("检查奖励配置", zap.Int64("reward_id", *req.RewardID))
// 【使用乐观锁扣减库存】直接用 Quantity > 0 作为更新条件,避免竞态
result, err := tx.ActivityRewardSettings.WithContext(ctx).Where(
tx.ActivityRewardSettings.ID.Eq(*req.RewardID),
tx.ActivityRewardSettings.Quantity.Gt(0), // 乐观锁:只有库存>0才能扣减
).UpdateSimple(tx.ActivityRewardSettings.Quantity.Add(-int64(req.Quantity)))
if err != nil {
logger.Error("扣减奖励库存失败", zap.Error(err))
return fmt.Errorf("扣减奖励库存失败: %w", err)
}
if result.RowsAffected == 0 {
logger.Error("奖励库存不足或不存在")
return fmt.Errorf("奖励库存不足或不存在")
}
logger.Info("奖励库存扣减成功(乐观锁)")
}
// 3. 查询商品信息快照
product, err := tx.Products.WithContext(ctx).Where(tx.Products.ID.Eq(req.ProductID)).First()
if err != nil {
logger.Error("查询商品失败", zap.Error(err))
return fmt.Errorf("查询商品失败: %w", err)
}
// 4. 在现有订单上添加订单项和用户资产(按数量创建多个)
for i := 0; i < req.Quantity; i++ {
orderItem := &model.OrderItems{
OrderID: req.OrderID,
ProductID: req.ProductID,
Title: product.Name,
Quantity: 1,
Price: 0, // 中奖商品,无需额外付费
TotalAmount: 0,
ProductImages: product.ImagesJSON,
Status: 1, // 行状态1正常
}
err := tx.OrderItems.WithContext(ctx).Create(orderItem)
if err != nil {
logger.Error("创建订单项失败", zap.Error(err))
return fmt.Errorf("创建订单项失败: %w", err)
}
// 5. 创建用户资产记录(关联到原订单)
inventory := &model.UserInventory{
UserID: userID,
ProductID: req.ProductID,
OrderID: req.OrderID, // 关联到原抽奖订单
ActivityID: func() int64 {
if req.ActivityID != nil {
return *req.ActivityID
}
return 0
}(),
RewardID: func() int64 {
if req.RewardID != nil {
return *req.RewardID
}
return 0
}(),
Status: 1, // 持有状态
Remark: func() string {
if req.Remark != "" {
return req.Remark
}
return product.Name
}(),
}
err = tx.UserInventory.WithContext(ctx).Create(inventory)
if err != nil {
logger.Error("创建用户资产失败", zap.Error(err))
return fmt.Errorf("创建用户资产失败: %w", err)
}
inventoryIDs = append(inventoryIDs, inventory.ID)
}
return nil
})
if err != nil {
return &GrantRewardToOrderResponse{
Success: false,
Message: err.Error(),
}, err
}
logger.Info("在现有订单上发放奖励成功",
zap.Int64("order_id", req.OrderID),
zap.Int64s("inventory_ids", inventoryIDs),
zap.Int("quantity", req.Quantity),
)
return &GrantRewardToOrderResponse{
Success: true,
OrderID: req.OrderID,
InventoryIDs: inventoryIDs,
Message: fmt.Sprintf("成功发放%d个奖励到订单", req.Quantity),
}, nil
}