feat: 新增单次购买道具的聚合功能
This commit is contained in:
parent
36a366e7ba
commit
c2d58d4924
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:16,span 只影响高度
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user