机器人和礼物问题

This commit is contained in:
zhx 2026-06-18 18:48:47 +08:00
parent c8fc1ede01
commit 3b2e059964
6 changed files with 82 additions and 18 deletions

View File

@ -18,6 +18,11 @@ const (
robotMaxDisplayLevel = 30 robotMaxDisplayLevel = 30
) )
var excludedRobotAvatarFrameResourceIDs = map[int64]struct{}{
165: {},
175: {},
}
type robotAppearanceCatalog struct { type robotAppearanceCatalog struct {
avatarFrames []*walletv1.Resource avatarFrames []*walletv1.Resource
vehicles []*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())) resources := make([]*walletv1.Resource, 0, len(resp.GetResources()))
for _, item := range resp.GetResources() { for _, item := range resp.GetResources() {
if item != nil && item.GetResourceId() > 0 && item.GetStatus() == "active" { if item == nil || item.GetResourceId() <= 0 || item.GetStatus() != "active" {
resources = append(resources, item) continue
} }
if isExcludedRobotAppearanceResource(resourceType, item.GetResourceId()) {
// 机器人装扮随机池只过滤明确不适合机器人批量露出的资源;资源仍保留在 wallet owner 中,
// 真人发放、商城和其他后台场景不受这个创建机器人专用策略影响。
continue
}
resources = append(resources, item)
} }
return resources, nil 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 { 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{ grant, err := h.wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
CommandId: fmt.Sprintf("game_robot_init:%s:%d:%s:%d", requestID, userID, slot, resourceID), CommandId: fmt.Sprintf("game_robot_init:%s:%d:%s:%d", requestID, userID, slot, resourceID),

View File

@ -66,7 +66,7 @@ func (c *fakeRobotGameClient) RegisterDiceRobots(_ context.Context, req *gamev1.
type fakeRobotWalletClient struct { type fakeRobotWalletClient struct {
walletclient.Client walletclient.Client
listRequests []*walletv1.ListResourcesRequest listRequests []*walletv1.ListResourcesRequest
grants []string grants []int64
equips []int64 equips []int64
} }
@ -74,7 +74,11 @@ func (c *fakeRobotWalletClient) ListResources(_ context.Context, req *walletv1.L
c.listRequests = append(c.listRequests, req) c.listRequests = append(c.listRequests, req)
switch req.GetResourceType() { switch req.GetResourceType() {
case "avatar_frame": 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": case "vehicle":
return &walletv1.ListResourcesResponse{Resources: []*walletv1.Resource{{ResourceId: 201, ResourceType: "vehicle", Status: "active", ManagerGrantEnabled: false}}}, nil return &walletv1.ListResourcesResponse{Resources: []*walletv1.Resource{{ResourceId: 201, ResourceType: "vehicle", Status: "active", ManagerGrantEnabled: false}}}, nil
case "badge": 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) { 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{{ return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{Items: []*walletv1.ResourceGrantItem{{
ResourceId: req.GetResourceId(), ResourceId: req.GetResourceId(),
EntitlementId: "entitlement", EntitlementId: "entitlement",
@ -173,6 +177,12 @@ func TestGenerateDiceRobotsUsesLikeiProfiles(t *testing.T) {
t.Fatalf("robot level out of range: %+v", level) 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) { func TestGenerateDiceRobotsFallsBackToRandomProfilesWhenLikeiMissing(t *testing.T) {

View File

@ -22,7 +22,7 @@ import (
const defaultWheelID = "default" const defaultWheelID = "default"
var wheelTierCounts = map[string]int{ var wheelTierCounts = map[string]int{
"classic": 9, "classic": 8,
"luxury": 8, "luxury": 8,
"advanced": 12, "advanced": 12,
} }
@ -210,7 +210,7 @@ func validateConfigRequest(req configRequest) error {
return fmt.Errorf("wheel_id 不能为空") return fmt.Errorf("wheel_id 不能为空")
} }
expectedCount := expectedWheelTierCount(req.WheelID) expectedCount := expectedWheelTierCount(req.WheelID)
// 转盘前端布局是固定格子数classic=9、luxury=8、advanced=12。这里按池子强校验 // 转盘前端布局是固定格子数classic=8、luxury=8、advanced=12。这里按池子强校验
// 避免运营导入资源组后少格或多格,导致 H5 动画序列和后端奖品配置无法一一对应。 // 避免运营导入资源组后少格或多格,导致 H5 动画序列和后端奖品配置无法一一对应。
if len(req.Tiers) != expectedCount { if len(req.Tiers) != expectedCount {
return fmt.Errorf("%s 转盘必须配置 %d 个奖品档位", normalizeWheelID(req.WheelID), expectedCount) return fmt.Errorf("%s 转盘必须配置 %d 个奖品档位", normalizeWheelID(req.WheelID), expectedCount)

View File

@ -9,9 +9,9 @@ import (
func TestConfigToProtoSupportsPoolTierCountAndForcesPropRTPZero(t *testing.T) { func TestConfigToProtoSupportsPoolTierCountAndForcesPropRTPZero(t *testing.T) {
tiers := validClassicTiers() tiers := validClassicTiers()
tiers[0] = tierDTO{DisplayName: "金币", RewardType: " coin ", RewardCount: 1, RewardCoins: 1000, ProbabilityPercent: defaultProbabilityPercent(0, 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, 9), 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, 9), Enabled: true} tiers[2] = tierDTO{DisplayName: "装扮", RewardType: "dress_up", RewardID: "dress_1", RewardCount: 1, RTPValueCoins: 999999, ProbabilityPercent: defaultProbabilityPercent(2, 8), Enabled: true}
req := configRequest{ req := configRequest{
WheelID: " classic ", WheelID: " classic ",
Enabled: true, Enabled: true,
@ -32,8 +32,8 @@ func TestConfigToProtoSupportsPoolTierCountAndForcesPropRTPZero(t *testing.T) {
t.Fatalf("ppm mismatch: target=%d pool=%d", config.GetTargetRtpPpm(), config.GetPoolRatePpm()) t.Fatalf("ppm mismatch: target=%d pool=%d", config.GetTargetRtpPpm(), config.GetPoolRatePpm())
} }
protoTiers := config.GetTiers() protoTiers := config.GetTiers()
if len(protoTiers) != 9 { if len(protoTiers) != 8 {
t.Fatalf("tier count = %d, want 9", len(protoTiers)) t.Fatalf("tier count = %d, want 8", len(protoTiers))
} }
if protoTiers[0].GetTierId() != "coin-01" || protoTiers[0].GetRtpValueCoins() != 1000 { if protoTiers[0].GetTierId() != "coin-01" || protoTiers[0].GetRtpValueCoins() != 1000 {
t.Fatalf("coin tier mismatch: %+v", protoTiers[0]) t.Fatalf("coin tier mismatch: %+v", protoTiers[0])
@ -52,11 +52,11 @@ func TestValidateConfigRequiresPoolTierCountAndProbabilitySum(t *testing.T) {
{RewardType: "gift", RewardID: "gift_2", ProbabilityPercent: 20}, {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) t.Fatalf("classic tier count should be rejected, got %v", err)
} }
tiers := validClassicTiers() tiers := validClassicTiers()
tiers[8].ProbabilityPercent = 1 tiers[7].ProbabilityPercent = 1
err = validateConfigRequest(configRequest{ err = validateConfigRequest(configRequest{
WheelID: "classic", WheelID: "classic",
Tiers: tiers, Tiers: tiers,
@ -99,14 +99,14 @@ func TestConfigFromProtoConvertsPPMToPercent(t *testing.T) {
} }
func validClassicTiers() []tierDTO { func validClassicTiers() []tierDTO {
tiers := make([]tierDTO, 0, 9) tiers := make([]tierDTO, 0, 8)
for index := 0; index < 9; index++ { for index := 0; index < 8; index++ {
tiers = append(tiers, tierDTO{ tiers = append(tiers, tierDTO{
DisplayName: "金币", DisplayName: "金币",
RewardType: "coin", RewardType: "coin",
RewardCount: 1, RewardCount: 1,
RewardCoins: 100, RewardCoins: 100,
ProbabilityPercent: defaultProbabilityPercent(index, 9), ProbabilityPercent: defaultProbabilityPercent(index, 8),
Enabled: true, Enabled: true,
}) })
} }

View File

@ -11,6 +11,12 @@ import (
const defaultWheelID = "default" const defaultWheelID = "default"
var wheelTierCounts = map[string]int{
"classic": 8,
"luxury": 8,
"advanced": 12,
}
func NormalizeWheelID(wheelID string) string { func NormalizeWheelID(wheelID string) string {
wheelID = strings.TrimSpace(wheelID) wheelID = strings.TrimSpace(wheelID)
if wheelID == "" { if wheelID == "" {
@ -65,6 +71,11 @@ func ValidateRuleConfig(config domain.RuleConfig) error {
if len(config.Tiers) == 0 { if len(config.Tiers) == 0 {
return xerr.New(xerr.InvalidArgument, "wheel tiers are required") 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{} seen := map[string]bool{}
var enabled bool var enabled bool
for _, tier := range config.Tiers { for _, tier := range config.Tiers {

View File

@ -1,6 +1,8 @@
package wheel package wheel
import ( import (
"fmt"
"strings"
"testing" "testing"
domain "hyapp/services/activity-service/internal/domain/wheel" domain "hyapp/services/activity-service/internal/domain/wheel"
@ -27,7 +29,7 @@ func TestNormalizeRuleConfigForcesPropRTPValueToZero(t *testing.T) {
func TestValidateRuleConfigRejectsPropRTPValueAfterNormalizationByCallerMutation(t *testing.T) { func TestValidateRuleConfigRejectsPropRTPValueAfterNormalizationByCallerMutation(t *testing.T) {
config := NormalizeRuleConfig(domain.RuleConfig{ config := NormalizeRuleConfig(domain.RuleConfig{
WheelID: "classic", WheelID: "custom",
DrawPriceCoins: 100, DrawPriceCoins: 100,
SettlementWindowDraws: 1000, SettlementWindowDraws: 1000,
Tiers: []domain.Tier{ Tiers: []domain.Tier{
@ -39,3 +41,25 @@ func TestValidateRuleConfigRejectsPropRTPValueAfterNormalizationByCallerMutation
t.Fatalf("ValidateRuleConfig should normalize prop rtp value before validation: %v", err) 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)
}
}