From 3b2e059964de1ed59abe829b3e6e4a031933f0ae Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 18 Jun 2026 18:48:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BA=E5=92=8C=E7=A4=BC?= =?UTF-8?q?=E7=89=A9=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gamemanagement/robot_appearance.go | 23 ++++++++++++++-- .../robot_profile_source_test.go | 16 +++++++++--- .../admin/internal/modules/wheel/handler.go | 4 +-- .../internal/modules/wheel/handler_test.go | 20 +++++++------- .../internal/service/wheel/config.go | 11 ++++++++ .../internal/service/wheel/config_test.go | 26 ++++++++++++++++++- 6 files changed, 82 insertions(+), 18 deletions(-) diff --git a/server/admin/internal/modules/gamemanagement/robot_appearance.go b/server/admin/internal/modules/gamemanagement/robot_appearance.go index 301bf8ed..718f43e3 100644 --- a/server/admin/internal/modules/gamemanagement/robot_appearance.go +++ b/server/admin/internal/modules/gamemanagement/robot_appearance.go @@ -18,6 +18,11 @@ const ( robotMaxDisplayLevel = 30 ) +var excludedRobotAvatarFrameResourceIDs = map[int64]struct{}{ + 165: {}, + 175: {}, +} + type robotAppearanceCatalog struct { avatarFrames []*walletv1.Resource vehicles []*walletv1.Resource @@ -102,13 +107,27 @@ func (h *Handler) listRobotResources(ctx context.Context, requestID string, appC } resources := make([]*walletv1.Resource, 0, len(resp.GetResources())) for _, item := range resp.GetResources() { - if item != nil && item.GetResourceId() > 0 && item.GetStatus() == "active" { - resources = append(resources, item) + if item == nil || item.GetResourceId() <= 0 || item.GetStatus() != "active" { + continue } + if isExcludedRobotAppearanceResource(resourceType, item.GetResourceId()) { + // 机器人装扮随机池只过滤明确不适合机器人批量露出的资源;资源仍保留在 wallet owner 中, + // 真人发放、商城和其他后台场景不受这个创建机器人专用策略影响。 + continue + } + resources = append(resources, item) } return resources, nil } +func isExcludedRobotAppearanceResource(resourceType string, resourceID int64) bool { + if resourceType != "avatar_frame" { + return false + } + _, excluded := excludedRobotAvatarFrameResourceIDs[resourceID] + return excluded +} + func (h *Handler) grantAndEquipRobotResource(ctx context.Context, requestID string, appCode string, actorUserID int64, userID int64, slot string, resourceID int64, equip bool) error { grant, err := h.wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{ CommandId: fmt.Sprintf("game_robot_init:%s:%d:%s:%d", requestID, userID, slot, resourceID), diff --git a/server/admin/internal/modules/gamemanagement/robot_profile_source_test.go b/server/admin/internal/modules/gamemanagement/robot_profile_source_test.go index 97f3b694..a079c2cc 100644 --- a/server/admin/internal/modules/gamemanagement/robot_profile_source_test.go +++ b/server/admin/internal/modules/gamemanagement/robot_profile_source_test.go @@ -66,7 +66,7 @@ func (c *fakeRobotGameClient) RegisterDiceRobots(_ context.Context, req *gamev1. type fakeRobotWalletClient struct { walletclient.Client listRequests []*walletv1.ListResourcesRequest - grants []string + grants []int64 equips []int64 } @@ -74,7 +74,11 @@ func (c *fakeRobotWalletClient) ListResources(_ context.Context, req *walletv1.L c.listRequests = append(c.listRequests, req) switch req.GetResourceType() { case "avatar_frame": - return &walletv1.ListResourcesResponse{Resources: []*walletv1.Resource{{ResourceId: 101, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false}}}, nil + return &walletv1.ListResourcesResponse{Resources: []*walletv1.Resource{ + {ResourceId: 165, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false}, + {ResourceId: 175, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false}, + {ResourceId: 101, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false}, + }}, nil case "vehicle": return &walletv1.ListResourcesResponse{Resources: []*walletv1.Resource{{ResourceId: 201, ResourceType: "vehicle", Status: "active", ManagerGrantEnabled: false}}}, nil case "badge": @@ -88,7 +92,7 @@ func (c *fakeRobotWalletClient) ListResources(_ context.Context, req *walletv1.L } func (c *fakeRobotWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) { - c.grants = append(c.grants, req.GetCommandId()) + c.grants = append(c.grants, req.GetResourceId()) return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{Items: []*walletv1.ResourceGrantItem{{ ResourceId: req.GetResourceId(), EntitlementId: "entitlement", @@ -173,6 +177,12 @@ func TestGenerateDiceRobotsUsesLikeiProfiles(t *testing.T) { t.Fatalf("robot level out of range: %+v", level) } } + for _, resourceID := range append(wallets.grants, wallets.equips...) { + // 165 和 175 是资源库里的有效头像框,但创建机器人不能随机抽中,避免批量机器人展示这两款运营保留资源。 + if resourceID == 165 || resourceID == 175 { + t.Fatalf("robot appearance must exclude avatar frame %d from random grants/equips", resourceID) + } + } } func TestGenerateDiceRobotsFallsBackToRandomProfilesWhenLikeiMissing(t *testing.T) { diff --git a/server/admin/internal/modules/wheel/handler.go b/server/admin/internal/modules/wheel/handler.go index b65944d3..79b6642b 100644 --- a/server/admin/internal/modules/wheel/handler.go +++ b/server/admin/internal/modules/wheel/handler.go @@ -22,7 +22,7 @@ import ( const defaultWheelID = "default" var wheelTierCounts = map[string]int{ - "classic": 9, + "classic": 8, "luxury": 8, "advanced": 12, } @@ -210,7 +210,7 @@ func validateConfigRequest(req configRequest) error { return fmt.Errorf("wheel_id 不能为空") } expectedCount := expectedWheelTierCount(req.WheelID) - // 转盘前端布局是固定格子数:classic=9、luxury=8、advanced=12。这里按池子强校验, + // 转盘前端布局是固定格子数:classic=8、luxury=8、advanced=12。这里按池子强校验, // 避免运营导入资源组后少格或多格,导致 H5 动画序列和后端奖品配置无法一一对应。 if len(req.Tiers) != expectedCount { return fmt.Errorf("%s 转盘必须配置 %d 个奖品档位", normalizeWheelID(req.WheelID), expectedCount) diff --git a/server/admin/internal/modules/wheel/handler_test.go b/server/admin/internal/modules/wheel/handler_test.go index b88d380e..33240ac6 100644 --- a/server/admin/internal/modules/wheel/handler_test.go +++ b/server/admin/internal/modules/wheel/handler_test.go @@ -9,9 +9,9 @@ import ( func TestConfigToProtoSupportsPoolTierCountAndForcesPropRTPZero(t *testing.T) { tiers := validClassicTiers() - tiers[0] = tierDTO{DisplayName: "金币", RewardType: " coin ", RewardCount: 1, RewardCoins: 1000, ProbabilityPercent: defaultProbabilityPercent(0, 9), Enabled: true} - tiers[1] = tierDTO{DisplayName: "礼物", RewardType: "gift", RewardID: "gift_1", RewardCount: 1, RTPValueCoins: 5000, ProbabilityPercent: defaultProbabilityPercent(1, 9), Enabled: true} - tiers[2] = tierDTO{DisplayName: "装扮", RewardType: "dress_up", RewardID: "dress_1", RewardCount: 1, RTPValueCoins: 999999, ProbabilityPercent: defaultProbabilityPercent(2, 9), Enabled: true} + tiers[0] = tierDTO{DisplayName: "金币", RewardType: " coin ", RewardCount: 1, RewardCoins: 1000, ProbabilityPercent: defaultProbabilityPercent(0, 8), Enabled: true} + tiers[1] = tierDTO{DisplayName: "礼物", RewardType: "gift", RewardID: "gift_1", RewardCount: 1, RTPValueCoins: 5000, ProbabilityPercent: defaultProbabilityPercent(1, 8), Enabled: true} + tiers[2] = tierDTO{DisplayName: "装扮", RewardType: "dress_up", RewardID: "dress_1", RewardCount: 1, RTPValueCoins: 999999, ProbabilityPercent: defaultProbabilityPercent(2, 8), Enabled: true} req := configRequest{ WheelID: " classic ", Enabled: true, @@ -32,8 +32,8 @@ func TestConfigToProtoSupportsPoolTierCountAndForcesPropRTPZero(t *testing.T) { t.Fatalf("ppm mismatch: target=%d pool=%d", config.GetTargetRtpPpm(), config.GetPoolRatePpm()) } protoTiers := config.GetTiers() - if len(protoTiers) != 9 { - t.Fatalf("tier count = %d, want 9", len(protoTiers)) + if len(protoTiers) != 8 { + t.Fatalf("tier count = %d, want 8", len(protoTiers)) } if protoTiers[0].GetTierId() != "coin-01" || protoTiers[0].GetRtpValueCoins() != 1000 { t.Fatalf("coin tier mismatch: %+v", protoTiers[0]) @@ -52,11 +52,11 @@ func TestValidateConfigRequiresPoolTierCountAndProbabilitySum(t *testing.T) { {RewardType: "gift", RewardID: "gift_2", ProbabilityPercent: 20}, }, }) - if err == nil || !strings.Contains(err.Error(), "9 个奖品档位") { + if err == nil || !strings.Contains(err.Error(), "8 个奖品档位") { t.Fatalf("classic tier count should be rejected, got %v", err) } tiers := validClassicTiers() - tiers[8].ProbabilityPercent = 1 + tiers[7].ProbabilityPercent = 1 err = validateConfigRequest(configRequest{ WheelID: "classic", Tiers: tiers, @@ -99,14 +99,14 @@ func TestConfigFromProtoConvertsPPMToPercent(t *testing.T) { } func validClassicTiers() []tierDTO { - tiers := make([]tierDTO, 0, 9) - for index := 0; index < 9; index++ { + tiers := make([]tierDTO, 0, 8) + for index := 0; index < 8; index++ { tiers = append(tiers, tierDTO{ DisplayName: "金币", RewardType: "coin", RewardCount: 1, RewardCoins: 100, - ProbabilityPercent: defaultProbabilityPercent(index, 9), + ProbabilityPercent: defaultProbabilityPercent(index, 8), Enabled: true, }) } diff --git a/services/activity-service/internal/service/wheel/config.go b/services/activity-service/internal/service/wheel/config.go index d52e4d27..075f08c4 100644 --- a/services/activity-service/internal/service/wheel/config.go +++ b/services/activity-service/internal/service/wheel/config.go @@ -11,6 +11,12 @@ import ( const defaultWheelID = "default" +var wheelTierCounts = map[string]int{ + "classic": 8, + "luxury": 8, + "advanced": 12, +} + func NormalizeWheelID(wheelID string) string { wheelID = strings.TrimSpace(wheelID) if wheelID == "" { @@ -65,6 +71,11 @@ func ValidateRuleConfig(config domain.RuleConfig) error { if len(config.Tiers) == 0 { return xerr.New(xerr.InvalidArgument, "wheel tiers are required") } + if expectedCount, ok := wheelTierCounts[config.WheelID]; ok && len(config.Tiers) != expectedCount { + // H5 每个转盘的视觉格子数是固定契约;服务端发布前强校验数量,避免后台或脚本写入多余档位后, + // 抽奖结果命中一个 H5 没有位置展示的奖品,造成动画、中奖弹窗和实际发奖对不上。 + return xerr.New(xerr.InvalidArgument, fmt.Sprintf("wheel %s requires %d tiers", config.WheelID, expectedCount)) + } seen := map[string]bool{} var enabled bool for _, tier := range config.Tiers { diff --git a/services/activity-service/internal/service/wheel/config_test.go b/services/activity-service/internal/service/wheel/config_test.go index 27d7712d..c6e8b9de 100644 --- a/services/activity-service/internal/service/wheel/config_test.go +++ b/services/activity-service/internal/service/wheel/config_test.go @@ -1,6 +1,8 @@ package wheel import ( + "fmt" + "strings" "testing" domain "hyapp/services/activity-service/internal/domain/wheel" @@ -27,7 +29,7 @@ func TestNormalizeRuleConfigForcesPropRTPValueToZero(t *testing.T) { func TestValidateRuleConfigRejectsPropRTPValueAfterNormalizationByCallerMutation(t *testing.T) { config := NormalizeRuleConfig(domain.RuleConfig{ - WheelID: "classic", + WheelID: "custom", DrawPriceCoins: 100, SettlementWindowDraws: 1000, Tiers: []domain.Tier{ @@ -39,3 +41,25 @@ func TestValidateRuleConfigRejectsPropRTPValueAfterNormalizationByCallerMutation t.Fatalf("ValidateRuleConfig should normalize prop rtp value before validation: %v", err) } } + +func TestValidateRuleConfigRejectsClassicNineTiers(t *testing.T) { + tiers := make([]domain.Tier, 0, 9) + for index := 0; index < 9; index++ { + tiers = append(tiers, domain.Tier{ + TierID: fmt.Sprintf("classic-%d", index+1), + RewardType: "coin", + RewardCoins: 100, + WeightPPM: 1, + Enabled: true, + }) + } + err := ValidateRuleConfig(domain.RuleConfig{ + WheelID: "classic", + DrawPriceCoins: 100, + SettlementWindowDraws: 1000, + Tiers: tiers, + }) + if err == nil || !strings.Contains(err.Error(), "requires 8 tiers") { + t.Fatalf("classic should reject nine tiers, got %v", err) + } +}