feat(prize): freeze value snapshots across grant redeem refund and reports

This commit is contained in:
win 2026-02-21 22:16:20 +08:00
parent 70e45b09ab
commit 8b7af03400
14 changed files with 418 additions and 104 deletions

View File

@ -216,8 +216,7 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
}
var costStats []costStat
db.Table(model.TableNameUserInventory).
Select("user_inventory.activity_id, SUM(products.price) as total_cost").
Joins("JOIN products ON products.id = user_inventory.product_id").
Select("user_inventory.activity_id, SUM(user_inventory.value_cents) as total_cost").
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Where("user_inventory.activity_id IN ?", activityIDs).
Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本
@ -429,7 +428,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
activity_reward_settings.product_id,
COALESCE(products.name, '') as product_name,
COALESCE(products.images_json, '[]') as images_json,
COALESCE(products.price, 0) as product_price,
COALESCE(activity_reward_settings.price_snapshot_cents, products.price, 0) as product_price,
COALESCE(orders.actual_amount, 0) as order_amount,
COALESCE(orders.discount_amount, 0) as discount_amount,
COALESCE(orders.points_amount, 0) as points_amount,

View File

@ -216,7 +216,6 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
// Join with Products, Activities, and Orders (for livestream detection)
query := db.Table(model.TableNameUserInventory).
Joins("JOIN products ON products.id = user_inventory.product_id").
Joins("LEFT JOIN activities ON activities.id = user_inventory.activity_id").
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Where("user_inventory.user_id IN ?", userIDs)
@ -231,10 +230,10 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
err := query.Select(`
user_inventory.user_id,
SUM(products.price) as total_value,
SUM(CASE WHEN activities.activity_category_id = 1 THEN products.price ELSE 0 END) as ichiban_prize,
SUM(CASE WHEN activities.activity_category_id = 2 THEN products.price ELSE 0 END) as infinite_prize,
SUM(CASE WHEN activities.activity_category_id = 3 THEN products.price ELSE 0 END) as matching_prize
SUM(user_inventory.value_cents) as total_value,
SUM(CASE WHEN activities.activity_category_id = 1 THEN user_inventory.value_cents ELSE 0 END) as ichiban_prize,
SUM(CASE WHEN activities.activity_category_id = 2 THEN user_inventory.value_cents ELSE 0 END) as infinite_prize,
SUM(CASE WHEN activities.activity_category_id = 3 THEN user_inventory.value_cents ELSE 0 END) as matching_prize
`).
Group("user_inventory.user_id").
Scan(&invStats).Error

View File

@ -120,7 +120,6 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
var prizeStats []prizeStat
prizeQuery := db.Table(model.TableNameUserInventory).
Joins("JOIN products ON products.id = user_inventory.product_id").
Where("user_inventory.user_id = ?", userID).
Where("user_inventory.status IN ?", []int{1, 3}).
Where("user_inventory.remark NOT LIKE ?", "%void%")
@ -131,7 +130,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
prizeQuery.Select(`
COALESCE(user_inventory.activity_id, 0) as activity_id,
SUM(products.price) as prize_value
SUM(user_inventory.value_cents) as prize_value
`).
Group("COALESCE(user_inventory.activity_id, 0)").
Scan(&prizeStats)

View File

@ -177,6 +177,14 @@ func (h *handler) CreateRefund() core.HandlerFunc {
// 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产)
svc := usersvc.New(h.logger, h.repo)
rate := int64(1)
if cfgRate, _ := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First(); cfgRate != nil {
var rv int64
_, _ = fmt.Sscanf(cfgRate.ConfigValue, "%d", &rv)
if rv > 0 {
rate = rv
}
}
// 直接使用已初始化的 activity service 清理格位
_ = h.activity.ClearIchibanPositionsByOrderID(ctx.RequestContext(), order.ID)
@ -191,18 +199,25 @@ func (h *handler) CreateRefund() core.HandlerFunc {
}
} else if inv.Status == 3 {
// 状态3已兑换扣除积分并作废
deductPoints := int64(0)
matches := rePoints.FindStringSubmatch(inv.Remark)
if len(matches) > 1 {
p, _ := strconv.ParseInt(matches[1], 10, 64)
if p > 0 {
// 扣除积分(记录流水)- 使用柔性扣减
_, consumed, err := svc.ConsumePointsForRefund(ctx.RequestContext(), order.UserID, p, "user_inventory", strconv.FormatInt(inv.ID, 10), "订单退款回收兑换积分")
if err != nil {
h.logger.Error(fmt.Sprintf("refund reclaim points failed: order=%s user=%d points=%d err=%v", order.OrderNo, order.UserID, p, err))
}
if consumed < p {
pointsShortage = true
}
deductPoints = p
}
}
if deductPoints <= 0 && inv.ValueCents > 0 {
deductPoints = inv.ValueCents * rate
}
if deductPoints > 0 {
// 扣除积分(记录流水)- 使用柔性扣减
_, consumed, err := svc.ConsumePointsForRefund(ctx.RequestContext(), order.UserID, deductPoints, "user_inventory", strconv.FormatInt(inv.ID, 10), "订单退款回收兑换积分")
if err != nil {
h.logger.Error(fmt.Sprintf("refund reclaim points failed: order=%s user=%d points=%d err=%v", order.OrderNo, order.UserID, deductPoints, err))
}
if consumed < deductPoints {
pointsShortage = true
}
}
// 更新状态为2 (作废)

View File

@ -12,18 +12,20 @@ import (
)
type rewardItem struct {
ID int64 `json:"id"`
ProductID int64 `json:"product_id"`
Weight float64 `json:"weight" binding:"required"`
Quantity int64 `json:"quantity" binding:"required"`
OriginalQty int64 `json:"original_qty" binding:"required"`
Level int32 `json:"level" binding:"required"`
Sort int32 `json:"sort"`
IsBoss int32 `json:"is_boss"`
MinScore int64 `json:"min_score"`
ProductName string `json:"product_name"`
ProductImageUrl string `json:"product_image_url"`
ProductPrice float64 `json:"product_price"`
ID int64 `json:"id"`
ProductID int64 `json:"product_id"`
Weight float64 `json:"weight" binding:"required"`
Quantity int64 `json:"quantity" binding:"required"`
OriginalQty int64 `json:"original_qty" binding:"required"`
Level int32 `json:"level" binding:"required"`
Sort int32 `json:"sort"`
IsBoss int32 `json:"is_boss"`
MinScore int64 `json:"min_score"`
ProductName string `json:"product_name"`
ProductImageUrl string `json:"product_image_url"`
ProductPrice float64 `json:"product_price"` // 兼容:返回配置快照价
ProductPriceSnapshot float64 `json:"product_price_snapshot"`
ProductPriceCurrent float64 `json:"product_price_current"`
}
type createRewardsRequest struct {
@ -151,9 +153,11 @@ func (h *handler) ListIssueRewards() core.HandlerFunc {
if p, ok := pm[v.ProductID]; ok {
it.ProductName = p.Name
it.ProductImageUrl = p.ImagesJSON
it.ProductPrice = float64(p.Price) / 100
it.ProductPriceCurrent = float64(p.Price) / 100
}
}
it.ProductPriceSnapshot = float64(v.PriceSnapshotCents) / 100
it.ProductPrice = it.ProductPriceSnapshot
res.List[i] = it
}
ctx.Payload(res)

View File

@ -344,7 +344,7 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
var invRes []invResult
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
Select(h.readDB.UserInventory.UserID, h.readDB.Products.Price.Sum().As("value")).
Select(h.readDB.UserInventory.UserID, h.readDB.UserInventory.ValueCents.Sum().As("value")).
Where(h.readDB.UserInventory.UserID.In(userIDs...)).
Where(h.readDB.UserInventory.Status.Eq(1)). // 1=持有
Group(h.readDB.UserInventory.UserID).
@ -564,9 +564,8 @@ func (h *handler) ListUserInvites() core.HandlerFunc {
`, userID).Scan(&summaryConsume).Error
// 资产价值汇总(不包含已兑换的商品)
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(p.price), 0)
SELECT COALESCE(SUM(ui.value_cents), 0)
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id IN (SELECT invitee_id FROM user_invites WHERE inviter_id = ?)
AND ui.status != 2
`, userID).Scan(&summaryAsset).Error
@ -763,7 +762,7 @@ func (h *handler) ListUserInventory() core.HandlerFunc {
sql := `
SELECT ui.id, ui.user_id, ui.product_id, ui.order_id, ui.activity_id, ui.reward_id,
ui.status, ui.remark, ui.created_at, ui.updated_at,
p.name as product_name, p.images_json as product_images, p.price as product_price
p.name as product_name, p.images_json as product_images, COALESCE(ui.value_cents, p.price, 0) as product_price
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id = ?
@ -2158,9 +2157,9 @@ func (h *handler) AdminSearchUsers() core.HandlerFunc {
// 按手机号或昵称模糊匹配
rows, _ := q.Where(
h.readDB.Users.Mobile.Like("%"+req.Keyword+"%"),
h.readDB.Users.Mobile.Like("%" + req.Keyword + "%"),
).Or(
h.readDB.Users.Nickname.Like("%"+req.Keyword+"%"),
h.readDB.Users.Nickname.Like("%" + req.Keyword + "%"),
).Limit(10).Find()
items := make([]userItem, 0, len(rows))

View File

@ -0,0 +1,111 @@
package activity
import (
"context"
"testing"
"bindbox-game/internal/repository/mysql/dao"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newRewardSnapshotTestService(t *testing.T) (*service, *dao.Query, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
if err := db.Exec(`CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
price INTEGER NOT NULL,
stock INTEGER NOT NULL,
images_json TEXT,
updated_at DATETIME,
deleted_at DATETIME
);`).Error; err != nil {
t.Fatalf("create products failed: %v", err)
}
if err := db.Exec(`CREATE TABLE activity_reward_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
issue_id INTEGER NOT NULL,
product_id INTEGER,
price_snapshot_cents INTEGER NOT NULL DEFAULT 0,
price_snapshot_at DATETIME,
weight INTEGER NOT NULL,
quantity INTEGER NOT NULL,
original_qty INTEGER NOT NULL,
level INTEGER NOT NULL,
sort INTEGER,
is_boss INTEGER,
min_score INTEGER NOT NULL DEFAULT 0,
deleted_at DATETIME
);`).Error; err != nil {
t.Fatalf("create activity_reward_settings failed: %v", err)
}
q := dao.Use(db)
svc := &service{readDB: q, writeDB: q}
return svc, q, db
}
func TestCreateIssueRewards_SnapshotFromProductPrice(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
if err := db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (101, 'A', 1000, 10, '[]')").Error; err != nil {
t.Fatalf("insert product failed: %v", err)
}
err := svc.CreateIssueRewards(ctx, 88, []CreateRewardInput{
{
ProductID: 101,
Weight: 1,
Quantity: 2,
OriginalQty: 2,
Level: 1,
Sort: 1,
IsBoss: 0,
MinScore: 0,
},
})
if err != nil {
t.Fatalf("CreateIssueRewards failed: %v", err)
}
row, err := q.ActivityRewardSettings.WithContext(ctx).Where(q.ActivityRewardSettings.IssueID.Eq(88)).First()
if err != nil {
t.Fatalf("query reward failed: %v", err)
}
if row.PriceSnapshotCents != 1000 {
t.Fatalf("expected snapshot=1000, got=%d", row.PriceSnapshotCents)
}
}
func TestModifyIssueReward_ProductChanged_RecomputeSnapshot(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
_ = db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (101, 'A', 1000, 10, '[]')").Error
_ = db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (102, 'B', 2300, 10, '[]')").Error
_ = db.Exec("INSERT INTO activity_reward_settings (id, issue_id, product_id, price_snapshot_cents, weight, quantity, original_qty, level, sort, is_boss, min_score) VALUES (1, 9, 101, 1000, 1, 1, 1, 1, 1, 0, 0)").Error
newProductID := int64(102)
if err := svc.ModifyIssueReward(ctx, 1, ModifyRewardInput{ProductID: &newProductID}); err != nil {
t.Fatalf("ModifyIssueReward failed: %v", err)
}
row, err := q.ActivityRewardSettings.WithContext(ctx).Where(q.ActivityRewardSettings.ID.Eq(1)).First()
if err != nil {
t.Fatalf("query reward failed: %v", err)
}
if row.ProductID != 102 {
t.Fatalf("expected product_id=102, got=%d", row.ProductID)
}
if row.PriceSnapshotCents != 2300 {
t.Fatalf("expected snapshot=2300, got=%d", row.PriceSnapshotCents)
}
}

View File

@ -2,6 +2,7 @@ package activity
import (
"context"
"time"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
@ -12,17 +13,40 @@ import (
// 返回: 错误信息
func (s *service) CreateIssueRewards(ctx context.Context, issueID int64, rewards []CreateRewardInput) error {
return s.writeDB.Transaction(func(tx *dao.Query) error {
productIDs := make(map[int64]struct{})
for _, r := range rewards {
if r.ProductID > 0 {
productIDs[r.ProductID] = struct{}{}
}
}
productPriceMap := make(map[int64]int64)
if len(productIDs) > 0 {
ids := make([]int64, 0, len(productIDs))
for id := range productIDs {
ids = append(ids, id)
}
products, err := tx.Products.WithContext(ctx).Where(tx.Products.ID.In(ids...)).Find()
if err != nil {
return err
}
for _, p := range products {
productPriceMap[p.ID] = p.Price
}
}
for _, r := range rewards {
item := &model.ActivityRewardSettings{
IssueID: issueID,
ProductID: r.ProductID,
Weight: r.Weight,
Quantity: r.Quantity,
OriginalQty: r.OriginalQty,
Level: r.Level,
Sort: r.Sort,
IsBoss: r.IsBoss,
MinScore: r.MinScore,
IssueID: issueID,
ProductID: r.ProductID,
PriceSnapshotCents: productPriceMap[r.ProductID],
PriceSnapshotAt: time.Now(),
Weight: r.Weight,
Quantity: r.Quantity,
OriginalQty: r.OriginalQty,
Level: r.Level,
Sort: r.Sort,
IsBoss: r.IsBoss,
MinScore: r.MinScore,
}
if err := tx.ActivityRewardSettings.WithContext(ctx).Create(item); err != nil {
return err

View File

@ -17,6 +17,16 @@ func (s *service) ModifyIssueReward(ctx context.Context, rewardID int64, in Modi
}
if in.ProductID != nil {
item.ProductID = *in.ProductID
priceSnapshot := int64(0)
if *in.ProductID > 0 {
product, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(*in.ProductID)).First()
if err != nil {
return err
}
priceSnapshot = product.Price
}
item.PriceSnapshotCents = priceSnapshot
item.PriceSnapshotAt = time.Now()
}
if in.Weight != nil {
item.Weight = int32(*in.Weight)

View File

@ -319,6 +319,15 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
}
// 2. 回收资产
rate := int64(1)
var cfg model.SystemConfigs
if err := s.repo.GetDbR().Where("config_key = ?", "points_exchange_per_cent").First(&cfg).Error; err == nil {
var rv int64
_, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &rv)
if rv > 0 {
rate = rv
}
}
for _, inv := range inventories {
if inv.Status == 1 {
// 状态1持有作废
@ -332,25 +341,28 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
)
} else if inv.Status == 3 {
// 状态3已兑换/发货):扣除积分
// 查找商品价格作为积分扣除依据
var product model.Products
if err := s.repo.GetDbR().Where("id = ?", inv.ProductID).First(&product).Error; err == nil {
pointsToDeduct := product.Price / 100 // 分转换为积分(假设 1积分=1分钱
if pointsToDeduct > 0 {
_, consumed, err := s.userSvc.ConsumePointsForRefund(ctx, inv.UserID, pointsToDeduct, "user_inventory", fmt.Sprintf("%d", inv.ID), "直播退款回收已兑换资产")
if err != nil {
s.logger.Error("[资产回收] 扣除积分失败", zap.Error(err), zap.Int64("user_id", inv.UserID))
}
if consumed < pointsToDeduct {
// 积分不足,标记用户
s.logger.Warn("[资产回收] 用户积分不足",
zap.Int64("user_id", inv.UserID),
zap.Int64("needed", pointsToDeduct),
zap.Int64("consumed", consumed),
)
// 可选:加入黑名单
// db.Exec("UPDATE users SET status = 3 WHERE id = ?", inv.UserID)
}
pointsToDeduct := inv.ValueCents * rate
if pointsToDeduct <= 0 {
// 兼容历史数据,兜底回退商品价格
var product model.Products
if err := s.repo.GetDbR().Where("id = ?", inv.ProductID).First(&product).Error; err == nil {
pointsToDeduct = product.Price * rate
}
}
if pointsToDeduct > 0 {
_, consumed, err := s.userSvc.ConsumePointsForRefund(ctx, inv.UserID, pointsToDeduct, "user_inventory", fmt.Sprintf("%d", inv.ID), "直播退款回收已兑换资产")
if err != nil {
s.logger.Error("[资产回收] 扣除积分失败", zap.Error(err), zap.Int64("user_id", inv.UserID))
}
if consumed < pointsToDeduct {
// 积分不足,标记用户
s.logger.Warn("[资产回收] 用户积分不足",
zap.Int64("user_id", inv.UserID),
zap.Int64("needed", pointsToDeduct),
zap.Int64("consumed", consumed),
)
// 可选:加入黑名单
// db.Exec("UPDATE users SET status = 3 WHERE id = ?", inv.UserID)
}
}
// 作废记录

View File

@ -500,9 +500,20 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
if err != nil {
return 0, err
}
p, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(inv.ProductID)).First()
if err != nil {
return 0, err
valueCents := inv.ValueCents
valueSource := inv.ValueSource
valueSnapshotAt := inv.ValueSnapshotAt
if valueCents <= 0 {
p, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(inv.ProductID)).First()
if err != nil {
return 0, err
}
valueCents = p.Price
valueSource = 2
valueSnapshotAt = time.Now()
if db := s.repo.GetDbW().Exec("UPDATE user_inventory SET value_cents=?, value_source=?, value_snapshot_at=? WHERE id=? AND user_id=?", valueCents, valueSource, valueSnapshotAt, inventoryID, userID); db.Error != nil {
return 0, db.Error
}
}
cfg, _ := s.readDB.SystemConfigs.WithContext(ctx).Where(s.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First()
rate := int64(1)
@ -513,7 +524,7 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
rate = r
}
}
points := p.Price * rate
points := valueCents * rate
if err = s.AddPoints(ctx, userID, points, "redeem_reward", fmt.Sprintf("inventory:%d product:%d", inventoryID, inv.ProductID), nil, nil); err != nil {
return 0, err
}
@ -569,39 +580,63 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
return 0, fmt.Errorf("no_valid_inventory")
}
// 构建inventory映射和收集productID
invMap := make(map[int64]*model.UserInventory, len(invList))
// 4. 按资产快照计算总积分,缺失快照时回退商品价格并回写
productIDs := make([]int64, 0, len(invList))
productIDSet := make(map[int64]struct{})
for _, inv := range invList {
invMap[inv.ID] = inv
if _, ok := productIDSet[inv.ProductID]; !ok {
productIDSet[inv.ProductID] = struct{}{}
productIDs = append(productIDs, inv.ProductID)
if inv.ValueCents <= 0 {
if _, ok := productIDSet[inv.ProductID]; !ok {
productIDSet[inv.ProductID] = struct{}{}
productIDs = append(productIDs, inv.ProductID)
}
}
}
productPriceMap := make(map[int64]int64)
if len(productIDs) > 0 {
products, err := s.readDB.Products.WithContext(ctx).
Where(s.readDB.Products.ID.In(productIDs...)).
Find()
if err != nil {
return 0, err
}
for _, p := range products {
productPriceMap[p.ID] = p.Price
}
}
// 4. 批量查询所有products一次查询替代N次
products, err := s.readDB.Products.WithContext(ctx).
Where(s.readDB.Products.ID.In(productIDs...)).
Find()
if err != nil {
return 0, err
}
productMap := make(map[int64]*model.Products, len(products))
for _, p := range products {
productMap[p.ID] = p
}
// 5. 计算总积分和准备批量更新数据
// 5. 计算总积分和准备批量更新
var totalPoints int64
validIDs := make([]int64, 0, len(invList))
type valueFix struct {
ID int64
ValueCents int64
ValueSource int32
ValueSnapAt time.Time
}
valueFixes := make([]valueFix, 0)
for _, inv := range invList {
p := productMap[inv.ProductID]
if p == nil {
valueCents := inv.ValueCents
valueSource := inv.ValueSource
valueSnapshotAt := inv.ValueSnapshotAt
if valueCents <= 0 {
price, ok := productPriceMap[inv.ProductID]
if !ok {
continue
}
valueCents = price
valueSource = 2
valueSnapshotAt = time.Now()
valueFixes = append(valueFixes, valueFix{
ID: inv.ID,
ValueCents: valueCents,
ValueSource: valueSource,
ValueSnapAt: valueSnapshotAt,
})
}
if valueCents <= 0 {
continue
}
points := p.Price * rate
points := valueCents * rate
totalPoints += points
validIDs = append(validIDs, inv.ID)
}
@ -639,6 +674,14 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
}
// 批量更新inventory状态一次UPDATE替代N次
for _, fix := range valueFixes {
if err := tx.Exec(
"UPDATE user_inventory SET value_cents=?, value_source=?, value_snapshot_at=? WHERE id=? AND user_id=?",
fix.ValueCents, fix.ValueSource, fix.ValueSnapAt, fix.ID, userID,
).Error; err != nil {
return err
}
}
if err := tx.Exec(
"UPDATE user_inventory SET status=3, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|batch_redeemed') WHERE id IN ? AND user_id=? AND status=1",
validIDs, userID,

View File

@ -91,10 +91,12 @@ func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, pa
p := products[r.ProductID]
name := ""
images := ""
var price int64
price := r.ValueCents
if p != nil {
name = p.Name
images = p.ImagesJSON
}
if price <= 0 && p != nil {
price = p.Price
}
sh := shipMap[r.ID]
@ -177,10 +179,12 @@ func (s *service) ListInventoryWithProductActive(ctx context.Context, userID int
p := products[r.ProductID]
name := ""
images := ""
var price int64
price := r.ValueCents
if p != nil {
name = p.Name
images = p.ImagesJSON
}
if price <= 0 && p != nil {
price = p.Price
}
sh := shipMap[r.ID]
@ -214,10 +218,11 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
// 1. 获取聚合后的商品ID列表 (GROUP BY product_id, status)
var groupResults []struct {
ProductID int64 `gorm:"column:product_id"`
Status int32 `gorm:"column:status"`
Count int64 `gorm:"column:count"`
UpdatedAt time.Time `gorm:"column:updated_at"`
ProductID int64 `gorm:"column:product_id"`
Status int32 `gorm:"column:status"`
Count int64 `gorm:"column:count"`
ValueCents int64 `gorm:"column:value_cents"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
q := s.readDB.UserInventory.WithContext(ctx).ReadDB().
@ -225,6 +230,7 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
s.readDB.UserInventory.ProductID,
s.readDB.UserInventory.Status,
s.readDB.UserInventory.ID.Count().As("count"),
s.readDB.UserInventory.ValueCents.Max().As("value_cents"),
s.readDB.UserInventory.UpdatedAt.Max().As("updated_at"),
).
Where(s.readDB.UserInventory.UserID.Eq(userID))
@ -272,10 +278,12 @@ func (s *service) ListInventoryAggregated(ctx context.Context, userID int64, pag
p, _ := s.readDB.Products.WithContext(ctx).ReadDB().Where(s.readDB.Products.ID.Eq(g.ProductID)).First()
name := "未知商品"
images := ""
var price int64
price := g.ValueCents
if p != nil {
name = p.Name
images = p.ImagesJSON
}
if price <= 0 && p != nil {
price = p.Price
}

View File

@ -47,11 +47,13 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
// 执行事务
err := s.writeDB.Transaction(func(tx *dao.Query) error {
logger.Info("开始事务处理")
var rewardSetting *model.ActivityRewardSettings
var err error
// 1. 检查奖励配置库存如果提供了reward_id
if req.RewardID != nil {
logger.Info("检查奖励配置", zap.Int64("reward_id", *req.RewardID))
rewardSetting, err := tx.ActivityRewardSettings.WithContext(ctx).Where(
rewardSetting, err = tx.ActivityRewardSettings.WithContext(ctx).Where(
tx.ActivityRewardSettings.ID.Eq(*req.RewardID),
).First()
if err != nil {
@ -109,7 +111,7 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
}
logger.Info("创建订单", zap.Any("order", order))
err := tx.Orders.WithContext(ctx).Create(order)
err = tx.Orders.WithContext(ctx).Create(order)
if err != nil {
logger.Error("创建订单失败", zap.Error(err))
return fmt.Errorf("创建订单失败: %w", err)
@ -163,7 +165,25 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
inventory := &model.UserInventory{
UserID: userID,
ProductID: req.ProductID,
OrderID: orderID,
ValueCents: func() int64 {
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
return rewardSetting.PriceSnapshotCents
}
return product.Price
}(),
ValueSource: func() int32 {
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
return 1
}
return 2
}(),
ValueSnapshotAt: func() time.Time {
if rewardSetting != nil && !rewardSetting.PriceSnapshotAt.IsZero() {
return rewardSetting.PriceSnapshotAt
}
return time.Now()
}(),
OrderID: orderID,
ActivityID: func() int64 {
if req.ActivityID != nil {
return *req.ActivityID
@ -288,6 +308,7 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
// 执行事务
err := s.writeDB.Transaction(func(tx *dao.Query) error {
logger.Info("开始事务处理")
var rewardSetting *model.ActivityRewardSettings
// 1. 验证订单存在且属于该用户
order, err := tx.Orders.WithContext(ctx).Where(
@ -322,6 +343,13 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
logger.Error("奖励库存不足或不存在")
return fmt.Errorf("奖励库存不足或不存在")
}
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)
}
logger.Info("奖励库存扣减成功(乐观锁)")
}
@ -355,7 +383,25 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
inventory := &model.UserInventory{
UserID: userID,
ProductID: req.ProductID,
OrderID: req.OrderID, // 关联到原抽奖订单
ValueCents: func() int64 {
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
return rewardSetting.PriceSnapshotCents
}
return product.Price
}(),
ValueSource: func() int32 {
if rewardSetting != nil && rewardSetting.PriceSnapshotCents > 0 {
return 1
}
return 2
}(),
ValueSnapshotAt: func() time.Time {
if rewardSetting != nil && !rewardSetting.PriceSnapshotAt.IsZero() {
return rewardSetting.PriceSnapshotAt
}
return time.Now()
}(),
OrderID: req.OrderID, // 关联到原抽奖订单
ActivityID: func() int64 {
if req.ActivityID != nil {
return *req.ActivityID

View File

@ -3,6 +3,7 @@ package user
import (
"context"
"fmt"
"time"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
@ -60,6 +61,26 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
for _, p := range products {
productMap[p.ID] = p
}
rewardSnapshotMap := make(map[int64]*model.ActivityRewardSettings)
rewardIDSet := make(map[int64]struct{})
for _, item := range items {
if item.RewardID != nil && *item.RewardID > 0 {
rewardIDSet[*item.RewardID] = struct{}{}
}
}
if len(rewardIDSet) > 0 {
rewardIDs := make([]int64, 0, len(rewardIDSet))
for id := range rewardIDSet {
rewardIDs = append(rewardIDs, id)
}
rewardRows, err := tx.ActivityRewardSettings.WithContext(ctx).Where(tx.ActivityRewardSettings.ID.In(rewardIDs...)).Find()
if err != nil {
return fmt.Errorf("查询奖励配置失败: %w", err)
}
for _, row := range rewardRows {
rewardSnapshotMap[row.ID] = row
}
}
// 3. 批量创建订单项和库存记录
var orderItems []*model.OrderItems
@ -83,8 +104,32 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
})
inventories = append(inventories, &model.UserInventory{
UserID: userID,
ProductID: item.ProductID,
UserID: userID,
ProductID: item.ProductID,
ValueCents: func() int64 {
if item.RewardID != nil {
if reward, ok := rewardSnapshotMap[*item.RewardID]; ok && reward.PriceSnapshotCents > 0 {
return reward.PriceSnapshotCents
}
}
return product.Price
}(),
ValueSource: func() int32 {
if item.RewardID != nil {
if reward, ok := rewardSnapshotMap[*item.RewardID]; ok && reward.PriceSnapshotCents > 0 {
return 1
}
}
return 2
}(),
ValueSnapshotAt: func() time.Time {
if item.RewardID != nil {
if reward, ok := rewardSnapshotMap[*item.RewardID]; ok && !reward.PriceSnapshotAt.IsZero() {
return reward.PriceSnapshotAt
}
}
return time.Now()
}(),
OrderID: orderID,
ActivityID: item.ActivityID,
RewardID: func() int64 {