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")
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")
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
)
@ -77,6 +81,16 @@ func main() {
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 {
logger.Sugar.Fatalf("Failed to migrate database: %v", err)
@ -93,7 +107,7 @@ func main() {
}
// 初始化 Service
activityService := service.NewActivityService(activityRepo, mintingActivityRepo, userRPCClient)
activityService := service.NewActivityService(activityRepo, mintingActivityRepo, userRPCClient, database.GetRedis())
// 初始化 Provider
activityProvider := provider.NewActivityProvider(activityService)

View File

@ -2,7 +2,7 @@ package service
import (
"context"
"errors"
"fmt"
"time"
appErrors "github.com/topfans/backend/pkg/errors"
@ -12,6 +12,7 @@ import (
pbCommon "github.com/topfans/backend/pkg/proto/common"
"github.com/topfans/backend/services/activityService/client"
"github.com/topfans/backend/services/activityService/repository"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
@ -50,17 +51,55 @@ type activityService struct {
activityRepo repository.ActivityRepository
mintingActivityRepo repository.MintingActivityRepository
userRPCClient client.UserRPCClient
redisClient *redis.Client
}
// 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{
activityRepo: activityRepo,
mintingActivityRepo: mintingActivityRepo,
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 获取活动列表
func (s *activityService) GetActivityList(ctx context.Context, req *pb.GetActivityListRequest) (*pb.GetActivityListResponse, error) {
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))
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)
@ -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))
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)
@ -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))
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)
@ -368,6 +407,9 @@ func (s *activityService) PurchaseItem(ctx context.Context, req *pb.PurchaseItem
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)
if stats == nil {
@ -507,13 +549,19 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
}, nil
}
// 计算总消费水晶和贡献点(先计算,用于校验余额
// 计算总消费水晶和贡献点并验证所有items预校验
var totalCost int64
var totalContribution int64
itemInfoMap := make(map[string]*models.ActivityItem)
validItems := make([]*pb.PurchaseItem, 0, len(req.Items))
var fails []*pb.PurchaseFailItem
for _, item := range req.Items {
if item.Quantity <= 0 {
fails = append(fails, &pb.PurchaseFailItem{
ItemType: item.ItemType,
Reason: "quantity must be > 0",
})
continue
}
@ -523,12 +571,30 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
zap.String("item_type", item.ItemType),
zap.Error(err),
)
fails = append(fails, &pb.PurchaseFailItem{
ItemType: item.ItemType,
Reason: "item not found",
})
continue
}
itemInfoMap[item.ItemType] = activityItem
totalCost += int64(activityItem.CrystalCost) * 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,
Message: "水晶余额不足",
},
SuccessCount: 0,
FailCount: int32(len(req.Items)),
Fails: fails,
}, nil
}
@ -550,6 +619,9 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
Code: 500,
Message: "扣减水晶失败: " + err.Error(),
},
SuccessCount: 0,
FailCount: int32(len(validItems)),
Fails: fails,
}, nil
}
@ -564,21 +636,32 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
logger.Logger.Error("UpdateActivityProgress failed", zap.Error(err))
}
// 创建贡献记录(合并为一个记录)
// 为每个成功的item创建贡献记录并更新连击计数
now := time.Now().UnixMilli()
contribution := &models.ActivityContribution{
ActivityID: req.ActivityId,
UserID: userID,
StarID: req.StarId,
Quantity: 0, // 批量购买不记录单数量
CrystalSpent: totalCost,
ContributionPoints: totalContribution,
CreatedAt: now,
}
for _, item := range validItems {
activityItem := itemInfoMap[item.ItemType]
itemCost := int64(activityItem.CrystalCost) * int64(item.Quantity)
itemContribution := int64(activityItem.ContributionPoints) * int64(item.Quantity)
err = s.activityRepo.CreateContribution(contribution)
if err != nil {
logger.Logger.Error("CreateContribution failed", zap.Error(err))
contribution := &models.ActivityContribution{
ActivityID: req.ActivityId,
UserID: userID,
StarID: req.StarId,
ItemID: activityItem.ID,
ItemType: item.ItemType,
Quantity: int(item.Quantity),
CrystalSpent: itemCost,
ContributionPoints: itemContribution,
CreatedAt: now,
}
err = s.activityRepo.CreateContribution(contribution)
if err != nil {
logger.Logger.Error("CreateContribution failed", zap.Error(err))
}
// 更新 Redis 连击计数器3秒TTL
s.incrementComboCount(ctx, userID, item.ItemType)
}
// 更新用户统计
@ -599,7 +682,7 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
stats.TotalContribution += totalContribution
stats.TotalCrystalSpent += totalCost
stats.TotalItems += len(req.Items)
stats.TotalItems += len(validItems)
stats.LastContributeAt = 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_contribution", totalContribution),
zap.Int64("new_progress", newProgress),
zap.Int("success_count", len(validItems)),
zap.Int("fail_count", len(fails)),
)
return &pb.BatchPurchaseItemResponse{
@ -621,12 +706,12 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
Message: "ok",
},
TotalCrystalSpent: totalCost,
TotalContribution: totalContribution,
TotalContribution: totalContribution,
CurrentProgress: newProgress,
RemainingBalance: newBalance,
SuccessCount: int32(len(req.Items)),
FailCount: 0,
Fails: []*pb.PurchaseFailItem{},
SuccessCount: int32(len(validItems)),
FailCount: int32(len(fails)),
Fails: fails,
}, 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{
Id: c.ID,
UserId: c.UserID,
@ -907,7 +995,8 @@ func (s *activityService) GetLatestContributions(ctx context.Context, req *pb.Ge
ItemType: c.ItemType,
ItemName: itemName,
ItemIcon: itemIcon,
Quantity: int32(c.Quantity),
Quantity: int32(c.Quantity),
ComboCount: comboCount,
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 GAP = rpx2px(16)
const GAP = rpx2px(24)
const BORDER_W = rpx2px(2)
const SCALE = 0.9
const ROWS = 4
@ -356,13 +356,13 @@ class WaterfallLayout {
this.gap = gap
this.rowH = Math.floor((containerH - gap * (ROWS - 1)) / ROWS)
// = rowH × 9/16 9:16span
this.colW = Math.round(this.rowH * 9 / 16)
this.colW = Math.round(this.rowH * 4 / 3)
this.curX = 0
}
_cardSize(span) {
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 }
}