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")
|
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)
|
||||||
|
|||||||
@ -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,21 +636,32 @@ 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()
|
||||||
contribution := &models.ActivityContribution{
|
for _, item := range validItems {
|
||||||
ActivityID: req.ActivityId,
|
activityItem := itemInfoMap[item.ItemType]
|
||||||
UserID: userID,
|
itemCost := int64(activityItem.CrystalCost) * int64(item.Quantity)
|
||||||
StarID: req.StarId,
|
itemContribution := int64(activityItem.ContributionPoints) * int64(item.Quantity)
|
||||||
Quantity: 0, // 批量购买不记录单数量
|
|
||||||
CrystalSpent: totalCost,
|
|
||||||
ContributionPoints: totalContribution,
|
|
||||||
CreatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.activityRepo.CreateContribution(contribution)
|
contribution := &models.ActivityContribution{
|
||||||
if err != nil {
|
ActivityID: req.ActivityId,
|
||||||
logger.Logger.Error("CreateContribution failed", zap.Error(err))
|
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.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{
|
||||||
@ -621,12 +706,12 @@ func (s *activityService) BatchPurchaseItem(ctx context.Context, req *pb.BatchPu
|
|||||||
Message: "ok",
|
Message: "ok",
|
||||||
},
|
},
|
||||||
TotalCrystalSpent: totalCost,
|
TotalCrystalSpent: totalCost,
|
||||||
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,
|
||||||
@ -907,7 +995,8 @@ func (s *activityService) GetLatestContributions(ctx context.Context, req *pb.Ge
|
|||||||
ItemType: c.ItemType,
|
ItemType: c.ItemType,
|
||||||
ItemName: itemName,
|
ItemName: itemName,
|
||||||
ItemIcon: itemIcon,
|
ItemIcon: itemIcon,
|
||||||
Quantity: int32(c.Quantity),
|
Quantity: int32(c.Quantity),
|
||||||
|
ComboCount: comboCount,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:16,span 只影响高度
|
// 列宽固定 = 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
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user