feat: 新增单次购买道具的聚合功能

This commit is contained in:
zerosaturation 2026-05-22 11:57:12 +08:00
parent 36a366e7ba
commit c2d58d4924
3 changed files with 132 additions and 29 deletions

View File

@ -33,6 +33,10 @@ var (
dbPassword = flag.String("db-password", getEnv("DB_PASSWORD", ""), "Database password") dbPassword = flag.String("db-password", getEnv("DB_PASSWORD", ""), "Database password")
dbName = flag.String("db-name", getEnv("DB_NAME", "top-fans"), "Database name") dbName = flag.String("db-name", getEnv("DB_NAME", "top-fans"), "Database name")
userServiceURL = flag.String("user-service-url", getEnv("USER_SERVICE_URL", "tri://localhost:20000"), "User service URL") userServiceURL = flag.String("user-service-url", getEnv("USER_SERVICE_URL", "tri://localhost:20000"), "User service URL")
redisHost = flag.String("redis-host", getEnv("REDIS_HOST", "localhost"), "Redis host")
redisPort = flag.Int("redis-port", getEnvInt("REDIS_PORT", 6379), "Redis port")
redisPassword = flag.String("redis-password", getEnv("REDIS_PASSWORD", ""), "Redis password")
redisDB = flag.Int("redis-db", getEnvInt("REDIS_DB", 0), "Redis database")
healthHandler *health.Handler healthHandler *health.Handler
) )
@ -77,6 +81,16 @@ func main() {
logger.Sugar.Fatalf("Failed to initialize database: %v", err) logger.Sugar.Fatalf("Failed to initialize database: %v", err)
} }
// 初始化 Redis
if err := database.InitRedis(database.RedisConfig{
Host: *redisHost,
Port: *redisPort,
Password: *redisPassword,
DB: *redisDB,
}); err != nil {
logger.Sugar.Fatalf("Failed to initialize Redis: %v", err)
}
// 自动迁移数据库表 // 自动迁移数据库表
if err := autoMigrate(); err != nil { if err := autoMigrate(); err != nil {
logger.Sugar.Fatalf("Failed to migrate database: %v", err) logger.Sugar.Fatalf("Failed to migrate database: %v", err)
@ -93,7 +107,7 @@ func main() {
} }
// 初始化 Service // 初始化 Service
activityService := service.NewActivityService(activityRepo, mintingActivityRepo, userRPCClient) activityService := service.NewActivityService(activityRepo, mintingActivityRepo, userRPCClient, database.GetRedis())
// 初始化 Provider // 初始化 Provider
activityProvider := provider.NewActivityProvider(activityService) activityProvider := provider.NewActivityProvider(activityService)

View File

@ -2,7 +2,7 @@ package service
import ( import (
"context" "context"
"errors" "fmt"
"time" "time"
appErrors "github.com/topfans/backend/pkg/errors" appErrors "github.com/topfans/backend/pkg/errors"
@ -12,6 +12,7 @@ import (
pbCommon "github.com/topfans/backend/pkg/proto/common" pbCommon "github.com/topfans/backend/pkg/proto/common"
"github.com/topfans/backend/services/activityService/client" "github.com/topfans/backend/services/activityService/client"
"github.com/topfans/backend/services/activityService/repository" "github.com/topfans/backend/services/activityService/repository"
"github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -50,17 +51,55 @@ type activityService struct {
activityRepo repository.ActivityRepository activityRepo repository.ActivityRepository
mintingActivityRepo repository.MintingActivityRepository mintingActivityRepo repository.MintingActivityRepository
userRPCClient client.UserRPCClient userRPCClient client.UserRPCClient
redisClient *redis.Client
} }
// NewActivityService 创建活动Service实例 // NewActivityService 创建活动Service实例
func NewActivityService(activityRepo repository.ActivityRepository, mintingActivityRepo repository.MintingActivityRepository, userRPCClient client.UserRPCClient) ActivityService { func NewActivityService(activityRepo repository.ActivityRepository, mintingActivityRepo repository.MintingActivityRepository, userRPCClient client.UserRPCClient, redisClient *redis.Client) ActivityService {
return &activityService{ return &activityService{
activityRepo: activityRepo, activityRepo: activityRepo,
mintingActivityRepo: mintingActivityRepo, mintingActivityRepo: mintingActivityRepo,
userRPCClient: userRPCClient, userRPCClient: userRPCClient,
redisClient: redisClient,
} }
} }
// comboKey 生成连击计数器 Redis Key
func (s *activityService) comboKey(userID int64, itemType string) string {
return fmt.Sprintf("combo:%d:%s", userID, itemType)
}
// incrementComboCount 增加连击计数
// 用户每次购买道具时调用3秒内同用户同道具的多次购买累加显示
func (s *activityService) incrementComboCount(ctx context.Context, userID int64, itemType string) error {
if s.redisClient == nil {
return nil
}
key := s.comboKey(userID, itemType)
if err := s.redisClient.Incr(ctx, key).Err(); err != nil {
logger.Logger.Warn("Incr combo count failed", zap.Error(err))
return err
}
if err := s.redisClient.Expire(ctx, key, 3*time.Second).Err(); err != nil {
logger.Logger.Warn("Expire combo count failed", zap.Error(err))
return err
}
return nil
}
// getComboCount 获取连击计数
func (s *activityService) getComboCount(ctx context.Context, userID int64, itemType string) int64 {
if s.redisClient == nil {
return 1
}
key := s.comboKey(userID, itemType)
count, err := s.redisClient.Get(ctx, key).Int64()
if err != nil || count == 0 {
return 1
}
return count
}
// GetActivityList 获取活动列表 // GetActivityList 获取活动列表
func (s *activityService) GetActivityList(ctx context.Context, req *pb.GetActivityListRequest) (*pb.GetActivityListResponse, error) { func (s *activityService) GetActivityList(ctx context.Context, req *pb.GetActivityListRequest) (*pb.GetActivityListResponse, error) {
logger.Logger.Info("GetActivityList request", logger.Logger.Info("GetActivityList request",
@ -111,7 +150,7 @@ func (s *activityService) GetActivity(ctx context.Context, req *pb.GetProgressRe
logger.Logger.Info("GetActivity request", zap.Int64("activity_id", req.ActivityId)) logger.Logger.Info("GetActivity request", zap.Int64("activity_id", req.ActivityId))
if req.ActivityId <= 0 { if req.ActivityId <= 0 {
return nil, errors.New("activity_id is required") return nil, fmt.Errorf("activity_id is required")
} }
activity, err := s.activityRepo.GetActivityByID(req.ActivityId) activity, err := s.activityRepo.GetActivityByID(req.ActivityId)
@ -132,7 +171,7 @@ func (s *activityService) GetActivityItems(ctx context.Context, req *pb.GetProgr
logger.Logger.Info("GetActivityItems request", zap.Int64("activity_id", req.ActivityId)) logger.Logger.Info("GetActivityItems request", zap.Int64("activity_id", req.ActivityId))
if req.ActivityId <= 0 { if req.ActivityId <= 0 {
return nil, errors.New("activity_id is required") return nil, fmt.Errorf("activity_id is required")
} }
items, err := s.activityRepo.GetActivityItems(req.ActivityId) items, err := s.activityRepo.GetActivityItems(req.ActivityId)
@ -162,7 +201,7 @@ func (s *activityService) GetProgress(ctx context.Context, req *pb.GetProgressRe
logger.Logger.Info("GetProgress request", zap.Int64("activity_id", req.ActivityId)) logger.Logger.Info("GetProgress request", zap.Int64("activity_id", req.ActivityId))
if req.ActivityId <= 0 { if req.ActivityId <= 0 {
return nil, errors.New("activity_id is required") return nil, fmt.Errorf("activity_id is required")
} }
activity, err := s.activityRepo.GetActivityByID(req.ActivityId) activity, err := s.activityRepo.GetActivityByID(req.ActivityId)
@ -368,6 +407,9 @@ func (s *activityService) PurchaseItem(ctx context.Context, req *pb.PurchaseItem
logger.Logger.Error("CreateContribution failed", zap.Error(err)) logger.Logger.Error("CreateContribution failed", zap.Error(err))
} }
// 更新 Redis 连击计数器3秒TTL
s.incrementComboCount(ctx, userID, req.ItemType)
// 更新用户统计 // 更新用户统计
stats, _ := s.activityRepo.GetUserStats(req.ActivityId, userID, req.StarId) stats, _ := s.activityRepo.GetUserStats(req.ActivityId, userID, req.StarId)
if stats == nil { if stats == nil {
@ -507,13 +549,19 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
}, nil }, nil
} }
// 计算总消费水晶和贡献点(先计算,用于校验余额 // 计算总消费水晶和贡献点并验证所有items预校验
var totalCost int64 var totalCost int64
var totalContribution int64 var totalContribution int64
itemInfoMap := make(map[string]*models.ActivityItem) itemInfoMap := make(map[string]*models.ActivityItem)
validItems := make([]*pb.PurchaseItem, 0, len(req.Items))
var fails []*pb.PurchaseFailItem
for _, item := range req.Items { for _, item := range req.Items {
if item.Quantity <= 0 { if item.Quantity <= 0 {
fails = append(fails, &pb.PurchaseFailItem{
ItemType: item.ItemType,
Reason: "quantity must be > 0",
})
continue continue
} }
@ -523,12 +571,30 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
zap.String("item_type", item.ItemType), zap.String("item_type", item.ItemType),
zap.Error(err), zap.Error(err),
) )
fails = append(fails, &pb.PurchaseFailItem{
ItemType: item.ItemType,
Reason: "item not found",
})
continue continue
} }
itemInfoMap[item.ItemType] = activityItem itemInfoMap[item.ItemType] = activityItem
totalCost += int64(activityItem.CrystalCost) * int64(item.Quantity) totalCost += int64(activityItem.CrystalCost) * int64(item.Quantity)
totalContribution += int64(activityItem.ContributionPoints) * int64(item.Quantity) totalContribution += int64(activityItem.ContributionPoints) * int64(item.Quantity)
validItems = append(validItems, item)
}
// 如果所有items都失败直接返回
if len(validItems) == 0 {
return &pb.BatchPurchaseItemResponse{
Base: &pbCommon.BaseResponse{
Code: 400,
Message: "no valid items",
},
SuccessCount: 0,
FailCount: int32(len(fails)),
Fails: fails,
}, nil
} }
// 检查水晶余额是否足够 // 检查水晶余额是否足够
@ -538,6 +604,9 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
Code: 400, Code: 400,
Message: "水晶余额不足", Message: "水晶余额不足",
}, },
SuccessCount: 0,
FailCount: int32(len(req.Items)),
Fails: fails,
}, nil }, nil
} }
@ -550,6 +619,9 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
Code: 500, Code: 500,
Message: "扣减水晶失败: " + err.Error(), Message: "扣减水晶失败: " + err.Error(),
}, },
SuccessCount: 0,
FailCount: int32(len(validItems)),
Fails: fails,
}, nil }, nil
} }
@ -564,15 +636,22 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
logger.Logger.Error("UpdateActivityProgress failed", zap.Error(err)) logger.Logger.Error("UpdateActivityProgress failed", zap.Error(err))
} }
// 创建贡献记录(合并为一个记录) // 为每个成功的item创建贡献记录并更新连击计数
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
for _, item := range validItems {
activityItem := itemInfoMap[item.ItemType]
itemCost := int64(activityItem.CrystalCost) * int64(item.Quantity)
itemContribution := int64(activityItem.ContributionPoints) * int64(item.Quantity)
contribution := &models.ActivityContribution{ contribution := &models.ActivityContribution{
ActivityID: req.ActivityId, ActivityID: req.ActivityId,
UserID: userID, UserID: userID,
StarID: req.StarId, StarID: req.StarId,
Quantity: 0, // 批量购买不记录单数量 ItemID: activityItem.ID,
CrystalSpent: totalCost, ItemType: item.ItemType,
ContributionPoints: totalContribution, Quantity: int(item.Quantity),
CrystalSpent: itemCost,
ContributionPoints: itemContribution,
CreatedAt: now, CreatedAt: now,
} }
@ -581,6 +660,10 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
logger.Logger.Error("CreateContribution failed", zap.Error(err)) logger.Logger.Error("CreateContribution failed", zap.Error(err))
} }
// 更新 Redis 连击计数器3秒TTL
s.incrementComboCount(ctx, userID, item.ItemType)
}
// 更新用户统计 // 更新用户统计
stats, _ := s.activityRepo.GetUserStats(req.ActivityId, userID, req.StarId) stats, _ := s.activityRepo.GetUserStats(req.ActivityId, userID, req.StarId)
if stats == nil { if stats == nil {
@ -599,7 +682,7 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
stats.TotalContribution += totalContribution stats.TotalContribution += totalContribution
stats.TotalCrystalSpent += totalCost stats.TotalCrystalSpent += totalCost
stats.TotalItems += len(req.Items) stats.TotalItems += len(validItems)
stats.LastContributeAt = now stats.LastContributeAt = now
stats.UpdatedAt = now stats.UpdatedAt = now
@ -613,6 +696,8 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
zap.Int64("total_cost", totalCost), zap.Int64("total_cost", totalCost),
zap.Int64("total_contribution", totalContribution), zap.Int64("total_contribution", totalContribution),
zap.Int64("new_progress", newProgress), zap.Int64("new_progress", newProgress),
zap.Int("success_count", len(validItems)),
zap.Int("fail_count", len(fails)),
) )
return &pb.BatchPurchaseItemResponse{ return &pb.BatchPurchaseItemResponse{
@ -624,9 +709,9 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
TotalContribution: totalContribution, TotalContribution: totalContribution,
CurrentProgress: newProgress, CurrentProgress: newProgress,
RemainingBalance: newBalance, RemainingBalance: newBalance,
SuccessCount: int32(len(req.Items)), SuccessCount: int32(len(validItems)),
FailCount: 0, FailCount: int32(len(fails)),
Fails: []*pb.PurchaseFailItem{}, Fails: fails,
}, nil }, nil
} }
@ -897,6 +982,9 @@ func (s *activityService) GetLatestContributions(ctx context.Context, req *pb.Ge
} }
} }
// 获取连击计数
comboCount := int32(s.getComboCount(ctx, c.UserID, c.ItemType))
records[i] = &pb.ContributionRecord{ records[i] = &pb.ContributionRecord{
Id: c.ID, Id: c.ID,
UserId: c.UserID, UserId: c.UserID,
@ -908,6 +996,7 @@ func (s *activityService) GetLatestContributions(ctx context.Context, req *pb.Ge
ItemName: itemName, ItemName: itemName,
ItemIcon: itemIcon, ItemIcon: itemIcon,
Quantity: int32(c.Quantity), Quantity: int32(c.Quantity),
ComboCount: comboCount,
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,
} }
} }

View File

@ -79,7 +79,7 @@ const emit = defineEmits(['cardClick', 'scroll'])
// ========== ========== // ========== ==========
const rpx2px = (rpx) => Math.round(uni.getSystemInfoSync().windowWidth / 750 * rpx) const rpx2px = (rpx) => Math.round(uni.getSystemInfoSync().windowWidth / 750 * rpx)
const GAP = rpx2px(16) const GAP = rpx2px(24)
const BORDER_W = rpx2px(2) const BORDER_W = rpx2px(2)
const SCALE = 0.9 const SCALE = 0.9
const ROWS = 4 const ROWS = 4
@ -356,13 +356,13 @@ class WaterfallLayout {
this.gap = gap this.gap = gap
this.rowH = Math.floor((containerH - gap * (ROWS - 1)) / ROWS) this.rowH = Math.floor((containerH - gap * (ROWS - 1)) / ROWS)
// = rowH × 9/16 9:16span // = rowH × 9/16 9:16span
this.colW = Math.round(this.rowH * 9 / 16) this.colW = Math.round(this.rowH * 4 / 3)
this.curX = 0 this.curX = 0
} }
_cardSize(span) { _cardSize(span) {
const h = Math.round((span * this.rowH + (span - 1) * this.gap) * SCALE) const h = Math.round((span * this.rowH + (span - 1) * this.gap) * SCALE)
const w = Math.round(h * 9 / 16) const w = Math.round(h * 4 / 3)
return { w, h } return { w, h }
} }