From c2d58d4924c37f65a89bd4c51ad5042cea33950e Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Fri, 22 May 2026 11:57:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8D=95=E6=AC=A1?= =?UTF-8?q?=E8=B4=AD=E4=B9=B0=E9=81=93=E5=85=B7=E7=9A=84=E8=81=9A=E5=90=88?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/activityService/main.go | 16 +- .../service/activity_service.go | 139 ++++++++++++++---- .../pages/square/components/WaterfallGrid.vue | 6 +- 3 files changed, 132 insertions(+), 29 deletions(-) diff --git a/backend/services/activityService/main.go b/backend/services/activityService/main.go index 774fad1..6206c30 100644 --- a/backend/services/activityService/main.go +++ b/backend/services/activityService/main.go @@ -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) diff --git a/backend/services/activityService/service/activity_service.go b/backend/services/activityService/service/activity_service.go index 70c61fc..efb5da4 100644 --- a/backend/services/activityService/service/activity_service.go +++ b/backend/services/activityService/service/activity_service.go @@ -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, } } diff --git a/frontend/pages/square/components/WaterfallGrid.vue b/frontend/pages/square/components/WaterfallGrid.vue index 859b41f..c5aee91 100644 --- a/frontend/pages/square/components/WaterfallGrid.vue +++ b/frontend/pages/square/components/WaterfallGrid.vue @@ -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 } }