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 var costStats []costStat
db.Table(model.TableNameUserInventory). db.Table(model.TableNameUserInventory).
Select("user_inventory.activity_id, SUM(products.price) as total_cost"). Select("user_inventory.activity_id, SUM(user_inventory.value_cents) as total_cost").
Joins("JOIN products ON products.id = user_inventory.product_id").
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id"). Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Where("user_inventory.activity_id IN ?", activityIDs). Where("user_inventory.activity_id IN ?", activityIDs).
Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本 Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本
@ -429,7 +428,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
activity_reward_settings.product_id, activity_reward_settings.product_id,
COALESCE(products.name, '') as product_name, COALESCE(products.name, '') as product_name,
COALESCE(products.images_json, '[]') as images_json, 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.actual_amount, 0) as order_amount,
COALESCE(orders.discount_amount, 0) as discount_amount, COALESCE(orders.discount_amount, 0) as discount_amount,
COALESCE(orders.points_amount, 0) as points_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) // Join with Products, Activities, and Orders (for livestream detection)
query := db.Table(model.TableNameUserInventory). 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 activities ON activities.id = user_inventory.activity_id").
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id"). Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Where("user_inventory.user_id IN ?", userIDs) Where("user_inventory.user_id IN ?", userIDs)
@ -231,10 +230,10 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
err := query.Select(` err := query.Select(`
user_inventory.user_id, user_inventory.user_id,
SUM(products.price) as total_value, SUM(user_inventory.value_cents) 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 = 1 THEN user_inventory.value_cents 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 = 2 THEN user_inventory.value_cents ELSE 0 END) as infinite_prize,
SUM(CASE WHEN activities.activity_category_id = 3 THEN products.price ELSE 0 END) as matching_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"). Group("user_inventory.user_id").
Scan(&invStats).Error Scan(&invStats).Error

View File

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

View File

@ -177,6 +177,14 @@ func (h *handler) CreateRefund() core.HandlerFunc {
// 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产) // 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产)
svc := usersvc.New(h.logger, h.repo) 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 清理格位 // 直接使用已初始化的 activity service 清理格位
_ = h.activity.ClearIchibanPositionsByOrderID(ctx.RequestContext(), order.ID) _ = h.activity.ClearIchibanPositionsByOrderID(ctx.RequestContext(), order.ID)
@ -191,18 +199,25 @@ func (h *handler) CreateRefund() core.HandlerFunc {
} }
} else if inv.Status == 3 { } else if inv.Status == 3 {
// 状态3已兑换扣除积分并作废 // 状态3已兑换扣除积分并作废
deductPoints := int64(0)
matches := rePoints.FindStringSubmatch(inv.Remark) matches := rePoints.FindStringSubmatch(inv.Remark)
if len(matches) > 1 { if len(matches) > 1 {
p, _ := strconv.ParseInt(matches[1], 10, 64) p, _ := strconv.ParseInt(matches[1], 10, 64)
if p > 0 { if p > 0 {
// 扣除积分(记录流水)- 使用柔性扣减 deductPoints = p
_, 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 deductPoints <= 0 && inv.ValueCents > 0 {
} deductPoints = inv.ValueCents * rate
if consumed < p { }
pointsShortage = true 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 (作废) // 更新状态为2 (作废)

View File

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

View File

@ -344,7 +344,7 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
var invRes []invResult var invRes []invResult
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB(). h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)). 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.UserID.In(userIDs...)).
Where(h.readDB.UserInventory.Status.Eq(1)). // 1=持有 Where(h.readDB.UserInventory.Status.Eq(1)). // 1=持有
Group(h.readDB.UserInventory.UserID). Group(h.readDB.UserInventory.UserID).
@ -564,9 +564,8 @@ func (h *handler) ListUserInvites() core.HandlerFunc {
`, userID).Scan(&summaryConsume).Error `, userID).Scan(&summaryConsume).Error
// 资产价值汇总(不包含已兑换的商品) // 资产价值汇总(不包含已兑换的商品)
_ = h.repo.GetDbR().Raw(` _ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(p.price), 0) SELECT COALESCE(SUM(ui.value_cents), 0)
FROM user_inventory ui 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 = ?) WHERE ui.user_id IN (SELECT invitee_id FROM user_invites WHERE inviter_id = ?)
AND ui.status != 2 AND ui.status != 2
`, userID).Scan(&summaryAsset).Error `, userID).Scan(&summaryAsset).Error
@ -763,7 +762,7 @@ func (h *handler) ListUserInventory() core.HandlerFunc {
sql := ` sql := `
SELECT ui.id, ui.user_id, ui.product_id, ui.order_id, ui.activity_id, ui.reward_id, 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, 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 FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id = ? WHERE ui.user_id = ?
@ -2158,9 +2157,9 @@ func (h *handler) AdminSearchUsers() core.HandlerFunc {
// 按手机号或昵称模糊匹配 // 按手机号或昵称模糊匹配
rows, _ := q.Where( rows, _ := q.Where(
h.readDB.Users.Mobile.Like("%"+req.Keyword+"%"), h.readDB.Users.Mobile.Like("%" + req.Keyword + "%"),
).Or( ).Or(
h.readDB.Users.Nickname.Like("%"+req.Keyword+"%"), h.readDB.Users.Nickname.Like("%" + req.Keyword + "%"),
).Limit(10).Find() ).Limit(10).Find()
items := make([]userItem, 0, len(rows)) 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 ( import (
"context" "context"
"time"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
@ -12,17 +13,40 @@ import (
// 返回: 错误信息 // 返回: 错误信息
func (s *service) CreateIssueRewards(ctx context.Context, issueID int64, rewards []CreateRewardInput) error { func (s *service) CreateIssueRewards(ctx context.Context, issueID int64, rewards []CreateRewardInput) error {
return s.writeDB.Transaction(func(tx *dao.Query) 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 { for _, r := range rewards {
item := &model.ActivityRewardSettings{ item := &model.ActivityRewardSettings{
IssueID: issueID, IssueID: issueID,
ProductID: r.ProductID, ProductID: r.ProductID,
Weight: r.Weight, PriceSnapshotCents: productPriceMap[r.ProductID],
Quantity: r.Quantity, PriceSnapshotAt: time.Now(),
OriginalQty: r.OriginalQty, Weight: r.Weight,
Level: r.Level, Quantity: r.Quantity,
Sort: r.Sort, OriginalQty: r.OriginalQty,
IsBoss: r.IsBoss, Level: r.Level,
MinScore: r.MinScore, Sort: r.Sort,
IsBoss: r.IsBoss,
MinScore: r.MinScore,
} }
if err := tx.ActivityRewardSettings.WithContext(ctx).Create(item); err != nil { if err := tx.ActivityRewardSettings.WithContext(ctx).Create(item); err != nil {
return err return err

View File

@ -17,6 +17,16 @@ func (s *service) ModifyIssueReward(ctx context.Context, rewardID int64, in Modi
} }
if in.ProductID != nil { if in.ProductID != nil {
item.ProductID = *in.ProductID 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 { if in.Weight != nil {
item.Weight = int32(*in.Weight) item.Weight = int32(*in.Weight)

View File

@ -319,6 +319,15 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
} }
// 2. 回收资产 // 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 { for _, inv := range inventories {
if inv.Status == 1 { if inv.Status == 1 {
// 状态1持有作废 // 状态1持有作废
@ -332,25 +341,28 @@ func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.Livest
) )
} else if inv.Status == 3 { } else if inv.Status == 3 {
// 状态3已兑换/发货):扣除积分 // 状态3已兑换/发货):扣除积分
// 查找商品价格作为积分扣除依据 pointsToDeduct := inv.ValueCents * rate
var product model.Products if pointsToDeduct <= 0 {
if err := s.repo.GetDbR().Where("id = ?", inv.ProductID).First(&product).Error; err == nil { // 兼容历史数据,兜底回退商品价格
pointsToDeduct := product.Price / 100 // 分转换为积分(假设 1积分=1分钱 var product model.Products
if pointsToDeduct > 0 { if err := s.repo.GetDbR().Where("id = ?", inv.ProductID).First(&product).Error; err == nil {
_, consumed, err := s.userSvc.ConsumePointsForRefund(ctx, inv.UserID, pointsToDeduct, "user_inventory", fmt.Sprintf("%d", inv.ID), "直播退款回收已兑换资产") pointsToDeduct = product.Price * rate
if err != nil { }
s.logger.Error("[资产回收] 扣除积分失败", zap.Error(err), zap.Int64("user_id", inv.UserID)) }
} if pointsToDeduct > 0 {
if consumed < pointsToDeduct { _, consumed, err := s.userSvc.ConsumePointsForRefund(ctx, inv.UserID, pointsToDeduct, "user_inventory", fmt.Sprintf("%d", inv.ID), "直播退款回收已兑换资产")
// 积分不足,标记用户 if err != nil {
s.logger.Warn("[资产回收] 用户积分不足", s.logger.Error("[资产回收] 扣除积分失败", zap.Error(err), zap.Int64("user_id", inv.UserID))
zap.Int64("user_id", inv.UserID), }
zap.Int64("needed", pointsToDeduct), if consumed < pointsToDeduct {
zap.Int64("consumed", consumed), // 积分不足,标记用户
) s.logger.Warn("[资产回收] 用户积分不足",
// 可选:加入黑名单 zap.Int64("user_id", inv.UserID),
// db.Exec("UPDATE users SET status = 3 WHERE 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 { if err != nil {
return 0, err return 0, err
} }
p, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(inv.ProductID)).First() valueCents := inv.ValueCents
if err != nil { valueSource := inv.ValueSource
return 0, err 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() cfg, _ := s.readDB.SystemConfigs.WithContext(ctx).Where(s.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First()
rate := int64(1) rate := int64(1)
@ -513,7 +524,7 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
rate = r 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 { 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 return 0, err
} }
@ -569,39 +580,63 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
return 0, fmt.Errorf("no_valid_inventory") return 0, fmt.Errorf("no_valid_inventory")
} }
// 构建inventory映射和收集productID // 4. 按资产快照计算总积分,缺失快照时回退商品价格并回写
invMap := make(map[int64]*model.UserInventory, len(invList))
productIDs := make([]int64, 0, len(invList)) productIDs := make([]int64, 0, len(invList))
productIDSet := make(map[int64]struct{}) productIDSet := make(map[int64]struct{})
for _, inv := range invList { for _, inv := range invList {
invMap[inv.ID] = inv if inv.ValueCents <= 0 {
if _, ok := productIDSet[inv.ProductID]; !ok { if _, ok := productIDSet[inv.ProductID]; !ok {
productIDSet[inv.ProductID] = struct{}{} productIDSet[inv.ProductID] = struct{}{}
productIDs = append(productIDs, inv.ProductID) 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次 // 5. 计算总积分和准备批量更新
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. 计算总积分和准备批量更新数据
var totalPoints int64 var totalPoints int64
validIDs := make([]int64, 0, len(invList)) 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 { for _, inv := range invList {
p := productMap[inv.ProductID] valueCents := inv.ValueCents
if p == nil { 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 continue
} }
points := p.Price * rate points := valueCents * rate
totalPoints += points totalPoints += points
validIDs = append(validIDs, inv.ID) validIDs = append(validIDs, inv.ID)
} }
@ -639,6 +674,14 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
} }
// 批量更新inventory状态一次UPDATE替代N次 // 批量更新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( 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", "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, validIDs, userID,

View File

@ -91,10 +91,12 @@ func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, pa
p := products[r.ProductID] p := products[r.ProductID]
name := "" name := ""
images := "" images := ""
var price int64 price := r.ValueCents
if p != nil { if p != nil {
name = p.Name name = p.Name
images = p.ImagesJSON images = p.ImagesJSON
}
if price <= 0 && p != nil {
price = p.Price price = p.Price
} }
sh := shipMap[r.ID] sh := shipMap[r.ID]
@ -177,10 +179,12 @@ func (s *service) ListInventoryWithProductActive(ctx context.Context, userID int
p := products[r.ProductID] p := products[r.ProductID]
name := "" name := ""
images := "" images := ""
var price int64 price := r.ValueCents
if p != nil { if p != nil {
name = p.Name name = p.Name
images = p.ImagesJSON images = p.ImagesJSON
}
if price <= 0 && p != nil {
price = p.Price price = p.Price
} }
sh := shipMap[r.ID] 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) // 1. 获取聚合后的商品ID列表 (GROUP BY product_id, status)
var groupResults []struct { var groupResults []struct {
ProductID int64 `gorm:"column:product_id"` ProductID int64 `gorm:"column:product_id"`
Status int32 `gorm:"column:status"` Status int32 `gorm:"column:status"`
Count int64 `gorm:"column:count"` Count int64 `gorm:"column:count"`
UpdatedAt time.Time `gorm:"column:updated_at"` ValueCents int64 `gorm:"column:value_cents"`
UpdatedAt time.Time `gorm:"column:updated_at"`
} }
q := s.readDB.UserInventory.WithContext(ctx).ReadDB(). 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.ProductID,
s.readDB.UserInventory.Status, s.readDB.UserInventory.Status,
s.readDB.UserInventory.ID.Count().As("count"), s.readDB.UserInventory.ID.Count().As("count"),
s.readDB.UserInventory.ValueCents.Max().As("value_cents"),
s.readDB.UserInventory.UpdatedAt.Max().As("updated_at"), s.readDB.UserInventory.UpdatedAt.Max().As("updated_at"),
). ).
Where(s.readDB.UserInventory.UserID.Eq(userID)) 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() p, _ := s.readDB.Products.WithContext(ctx).ReadDB().Where(s.readDB.Products.ID.Eq(g.ProductID)).First()
name := "未知商品" name := "未知商品"
images := "" images := ""
var price int64 price := g.ValueCents
if p != nil { if p != nil {
name = p.Name name = p.Name
images = p.ImagesJSON images = p.ImagesJSON
}
if price <= 0 && p != nil {
price = p.Price 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 { err := s.writeDB.Transaction(func(tx *dao.Query) error {
logger.Info("开始事务处理") logger.Info("开始事务处理")
var rewardSetting *model.ActivityRewardSettings
var err error
// 1. 检查奖励配置库存如果提供了reward_id // 1. 检查奖励配置库存如果提供了reward_id
if req.RewardID != nil { if req.RewardID != nil {
logger.Info("检查奖励配置", zap.Int64("reward_id", *req.RewardID)) 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), tx.ActivityRewardSettings.ID.Eq(*req.RewardID),
).First() ).First()
if err != nil { if err != nil {
@ -109,7 +111,7 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
} }
logger.Info("创建订单", zap.Any("order", order)) logger.Info("创建订单", zap.Any("order", order))
err := tx.Orders.WithContext(ctx).Create(order) err = tx.Orders.WithContext(ctx).Create(order)
if err != nil { if err != nil {
logger.Error("创建订单失败", zap.Error(err)) logger.Error("创建订单失败", zap.Error(err))
return fmt.Errorf("创建订单失败: %w", err) return fmt.Errorf("创建订单失败: %w", err)
@ -163,7 +165,25 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
inventory := &model.UserInventory{ inventory := &model.UserInventory{
UserID: userID, UserID: userID,
ProductID: req.ProductID, 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 { ActivityID: func() int64 {
if req.ActivityID != nil { if req.ActivityID != nil {
return *req.ActivityID 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 { err := s.writeDB.Transaction(func(tx *dao.Query) error {
logger.Info("开始事务处理") logger.Info("开始事务处理")
var rewardSetting *model.ActivityRewardSettings
// 1. 验证订单存在且属于该用户 // 1. 验证订单存在且属于该用户
order, err := tx.Orders.WithContext(ctx).Where( order, err := tx.Orders.WithContext(ctx).Where(
@ -322,6 +343,13 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
logger.Error("奖励库存不足或不存在") logger.Error("奖励库存不足或不存在")
return fmt.Errorf("奖励库存不足或不存在") 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("奖励库存扣减成功(乐观锁)") logger.Info("奖励库存扣减成功(乐观锁)")
} }
@ -355,7 +383,25 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
inventory := &model.UserInventory{ inventory := &model.UserInventory{
UserID: userID, UserID: userID,
ProductID: req.ProductID, 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 { ActivityID: func() int64 {
if req.ActivityID != nil { if req.ActivityID != nil {
return *req.ActivityID return *req.ActivityID

View File

@ -3,6 +3,7 @@ package user
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
@ -60,6 +61,26 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
for _, p := range products { for _, p := range products {
productMap[p.ID] = p 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. 批量创建订单项和库存记录 // 3. 批量创建订单项和库存记录
var orderItems []*model.OrderItems var orderItems []*model.OrderItems
@ -83,8 +104,32 @@ func (s *service) BatchGrantRewardsToOrder(ctx context.Context, userID int64, or
}) })
inventories = append(inventories, &model.UserInventory{ inventories = append(inventories, &model.UserInventory{
UserID: userID, UserID: userID,
ProductID: item.ProductID, 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, OrderID: orderID,
ActivityID: item.ActivityID, ActivityID: item.ActivityID,
RewardID: func() int64 { RewardID: func() int64 {