feat(prize): freeze value snapshots across grant redeem refund and reports
This commit is contained in:
parent
70e45b09ab
commit
8b7af03400
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 (作废)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
111
internal/service/activity/reward_snapshot_test.go
Normal file
111
internal/service/activity/reward_snapshot_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
// 作废记录
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user