From 29b580f0ce5c268e9f9f8c56d60c1acd89df2759 Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 16 Jun 2026 14:11:07 +0800 Subject: [PATCH 1/3] fix: split room rps gift media urls --- api/proto/game/v1/game.pb.go | 33 ++++++++----- api/proto/game/v1/game.proto | 1 + .../internal/modules/gamemanagement/dto.go | 30 ++++++------ .../modules/gamemanagement/dto_test.go | 26 ++++++++++ .../internal/service/roomrps/service.go | 49 ++++++++++++++----- .../internal/service/roomrps/service_test.go | 46 ++++++++++++++--- .../transport/http/gameapi/game_view.go | 30 ++++++------ .../transport/http/gameapi/game_view_test.go | 37 ++++++++++++++ 8 files changed, 194 insertions(+), 58 deletions(-) create mode 100644 services/gateway-service/internal/transport/http/gameapi/game_view_test.go diff --git a/api/proto/game/v1/game.pb.go b/api/proto/game/v1/game.pb.go index db50f6e9..4f6e35e4 100644 --- a/api/proto/game/v1/game.pb.go +++ b/api/proto/game/v1/game.pb.go @@ -2038,16 +2038,17 @@ func (x *DiceConfigResponse) GetServerTimeMs() int64 { } type RoomRPSStakeGift struct { - state protoimpl.MessageState `protogen:"open.v1"` - GiftId int64 `protobuf:"varint,1,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"` - GiftName string `protobuf:"bytes,2,opt,name=gift_name,json=giftName,proto3" json:"gift_name,omitempty"` - GiftIconUrl string `protobuf:"bytes,3,opt,name=gift_icon_url,json=giftIconUrl,proto3" json:"gift_icon_url,omitempty"` - GiftPriceCoin int64 `protobuf:"varint,4,opt,name=gift_price_coin,json=giftPriceCoin,proto3" json:"gift_price_coin,omitempty"` - Enabled bool `protobuf:"varint,5,opt,name=enabled,proto3" json:"enabled,omitempty"` - SortOrder int32 `protobuf:"varint,6,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` - GiftIdText string `protobuf:"bytes,7,opt,name=gift_id_text,json=giftIdText,proto3" json:"gift_id_text,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + GiftId int64 `protobuf:"varint,1,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"` + GiftName string `protobuf:"bytes,2,opt,name=gift_name,json=giftName,proto3" json:"gift_name,omitempty"` + GiftIconUrl string `protobuf:"bytes,3,opt,name=gift_icon_url,json=giftIconUrl,proto3" json:"gift_icon_url,omitempty"` + GiftPriceCoin int64 `protobuf:"varint,4,opt,name=gift_price_coin,json=giftPriceCoin,proto3" json:"gift_price_coin,omitempty"` + Enabled bool `protobuf:"varint,5,opt,name=enabled,proto3" json:"enabled,omitempty"` + SortOrder int32 `protobuf:"varint,6,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` + GiftIdText string `protobuf:"bytes,7,opt,name=gift_id_text,json=giftIdText,proto3" json:"gift_id_text,omitempty"` + GiftAnimationUrl string `protobuf:"bytes,8,opt,name=gift_animation_url,json=giftAnimationUrl,proto3" json:"gift_animation_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomRPSStakeGift) Reset() { @@ -2129,6 +2130,13 @@ func (x *RoomRPSStakeGift) GetGiftIdText() string { return "" } +func (x *RoomRPSStakeGift) GetGiftAnimationUrl() string { + if x != nil { + return x.GiftAnimationUrl + } + return "" +} + type RoomRPSConfig struct { state protoimpl.MessageState `protogen:"open.v1"` AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` @@ -6530,7 +6538,7 @@ const file_proto_game_v1_game_proto_rawDesc = "" + "\agame_id\x18\x02 \x01(\tR\x06gameId\"m\n" + "\x12DiceConfigResponse\x121\n" + "\x06config\x18\x01 \x01(\v2\x19.hyapp.game.v1.DiceConfigR\x06config\x12$\n" + - "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xef\x01\n" + + "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\x9d\x02\n" + "\x10RoomRPSStakeGift\x12\x17\n" + "\agift_id\x18\x01 \x01(\x03R\x06giftId\x12\x1b\n" + "\tgift_name\x18\x02 \x01(\tR\bgiftName\x12\"\n" + @@ -6540,7 +6548,8 @@ const file_proto_game_v1_game_proto_rawDesc = "" + "\n" + "sort_order\x18\x06 \x01(\x05R\tsortOrder\x12 \n" + "\fgift_id_text\x18\a \x01(\tR\n" + - "giftIdText\"\xc7\x02\n" + + "giftIdText\x12,\n" + + "\x12gift_animation_url\x18\b \x01(\tR\x10giftAnimationUrl\"\xc7\x02\n" + "\rRoomRPSConfig\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" + "\agame_id\x18\x02 \x01(\tR\x06gameId\x12\x16\n" + diff --git a/api/proto/game/v1/game.proto b/api/proto/game/v1/game.proto index a83797a0..bf39c9d4 100644 --- a/api/proto/game/v1/game.proto +++ b/api/proto/game/v1/game.proto @@ -238,6 +238,7 @@ message RoomRPSStakeGift { bool enabled = 5; int32 sort_order = 6; string gift_id_text = 7; + string gift_animation_url = 8; } message RoomRPSConfig { diff --git a/server/admin/internal/modules/gamemanagement/dto.go b/server/admin/internal/modules/gamemanagement/dto.go index e2cfd444..372c38d0 100644 --- a/server/admin/internal/modules/gamemanagement/dto.go +++ b/server/admin/internal/modules/gamemanagement/dto.go @@ -147,13 +147,14 @@ type roomRPSConfigDTO struct { } type roomRPSStakeGiftDTO struct { - GiftID string `json:"giftId"` - GiftIDNumber int64 `json:"giftIdNumber"` - GiftName string `json:"giftName"` - GiftIconURL string `json:"giftIconUrl"` - GiftPriceCoin int64 `json:"giftPriceCoin"` - Enabled bool `json:"enabled"` - SortOrder int32 `json:"sortOrder"` + GiftID string `json:"giftId"` + GiftIDNumber int64 `json:"giftIdNumber"` + GiftName string `json:"giftName"` + GiftIconURL string `json:"giftIconUrl"` + GiftAnimationURL string `json:"giftAnimationUrl"` + GiftPriceCoin int64 `json:"giftPriceCoin"` + Enabled bool `json:"enabled"` + SortOrder int32 `json:"sortOrder"` } type roomRPSChallengeDTO struct { @@ -353,13 +354,14 @@ func roomRPSConfigFromProto(item *gamev1.RoomRPSConfig) roomRPSConfigDTO { for _, gift := range item.GetStakeGifts() { // 礼物详情由 game-service 结合礼物配置补齐;admin-server 只保持展示字段和 ID 双写,不重新查礼物表。 gifts = append(gifts, roomRPSStakeGiftDTO{ - GiftID: roomRPSStakeGiftIDFromProto(gift), - GiftIDNumber: gift.GetGiftId(), - GiftName: gift.GetGiftName(), - GiftIconURL: gift.GetGiftIconUrl(), - GiftPriceCoin: gift.GetGiftPriceCoin(), - Enabled: gift.GetEnabled(), - SortOrder: gift.GetSortOrder(), + GiftID: roomRPSStakeGiftIDFromProto(gift), + GiftIDNumber: gift.GetGiftId(), + GiftName: gift.GetGiftName(), + GiftIconURL: gift.GetGiftIconUrl(), + GiftAnimationURL: gift.GetGiftAnimationUrl(), + GiftPriceCoin: gift.GetGiftPriceCoin(), + Enabled: gift.GetEnabled(), + SortOrder: gift.GetSortOrder(), }) } // DTO 只做 proto 到后台 JSON 的字段命名转换;配置默认值和合法性已经在 game-service/admin request 层处理。 diff --git a/server/admin/internal/modules/gamemanagement/dto_test.go b/server/admin/internal/modules/gamemanagement/dto_test.go index e069ae19..8965e522 100644 --- a/server/admin/internal/modules/gamemanagement/dto_test.go +++ b/server/admin/internal/modules/gamemanagement/dto_test.go @@ -113,6 +113,32 @@ func TestRoomRPSConfigRequestAcceptsStringGiftIDs(t *testing.T) { } } +func TestRoomRPSConfigFromProtoIncludesGiftAnimationURL(t *testing.T) { + got := roomRPSConfigFromProto(&gamev1.RoomRPSConfig{ + AppCode: "lalu", + GameId: "room_rps", + Status: "active", + StakeGifts: []*gamev1.RoomRPSStakeGift{ + { + GiftIdText: "87", + GiftName: "彩蛋兔", + GiftIconUrl: "https://cdn.example.test/gifts/egg-rabbit.png", + GiftAnimationUrl: "https://cdn.example.test/gifts/egg-rabbit.mp4", + GiftPriceCoin: 40000, + Enabled: true, + SortOrder: 1, + }, + }, + }) + + if len(got.StakeGifts) != 1 { + t.Fatalf("expected one stake gift, got %+v", got.StakeGifts) + } + if got.StakeGifts[0].GiftIconURL != "https://cdn.example.test/gifts/egg-rabbit.png" || got.StakeGifts[0].GiftAnimationURL != "https://cdn.example.test/gifts/egg-rabbit.mp4" { + t.Fatalf("room rps admin gift media fields mismatch: %+v", got.StakeGifts[0]) + } +} + func TestRoomRPSChallengeFromProtoKeepsLargeIDsAsStrings(t *testing.T) { got := roomRPSChallengeFromProto(&gamev1.RoomRPSChallenge{ ChallengeId: "rps-1", diff --git a/services/game-service/internal/service/roomrps/service.go b/services/game-service/internal/service/roomrps/service.go index 29feb04b..d7b83d65 100644 --- a/services/game-service/internal/service/roomrps/service.go +++ b/services/game-service/internal/service/roomrps/service.go @@ -508,13 +508,14 @@ func (s *Service) normalizeConfig(ctx context.Context, app string, config *gamev seenSort[sortOrder] = struct{}{} normalized := &gamev1.RoomRPSStakeGift{ - GiftId: legacyRoomRPSGiftID(giftID), - GiftIdText: giftID, - GiftName: strings.TrimSpace(gift.GetGiftName()), - GiftIconUrl: strings.TrimSpace(gift.GetGiftIconUrl()), - GiftPriceCoin: gift.GetGiftPriceCoin(), - Enabled: gift.GetEnabled(), - SortOrder: sortOrder, + GiftId: legacyRoomRPSGiftID(giftID), + GiftIdText: giftID, + GiftName: strings.TrimSpace(gift.GetGiftName()), + GiftIconUrl: strings.TrimSpace(gift.GetGiftIconUrl()), + GiftAnimationUrl: strings.TrimSpace(gift.GetGiftAnimationUrl()), + GiftPriceCoin: gift.GetGiftPriceCoin(), + Enabled: gift.GetEnabled(), + SortOrder: sortOrder, } gifts = append(gifts, normalized) } @@ -571,7 +572,7 @@ func (s *Service) enrichStakeGifts(ctx context.Context, app string, gifts []*gam if giftID == "" { continue } - if gift.GetGiftName() != "" && gift.GetGiftIconUrl() != "" && gift.GetGiftPriceCoin() > 0 { + if gift.GetGiftName() != "" && gift.GetGiftIconUrl() != "" && gift.GetGiftAnimationUrl() != "" && gift.GetGiftPriceCoin() > 0 && !roomRPSURLLooksAnimated(gift.GetGiftIconUrl()) { continue } if _, ok := seen[giftID]; ok { @@ -604,13 +605,20 @@ func (s *Service) enrichStakeGifts(ctx context.Context, app string, gifts []*gam changed = true } } - if gift.GiftIconUrl == "" { + if gift.GiftIconUrl == "" || roomRPSURLLooksAnimated(gift.GiftIconUrl) { if iconURL := roomRPSGiftIconURL(item); iconURL != "" { - // Flutter 的 PK 面板只消费 gift_icon_url;这里把礼物资源主图折叠成该字段,保持接口契约稳定。 + // gift_icon_url 只给静态封面;历史配置曾把 animation_url 写进这里,读配置时要用 wallet 资源重新纠正。 gift.GiftIconUrl = iconURL changed = true } } + if gift.GiftAnimationUrl == "" { + if animationURL := roomRPSGiftAnimationURL(item); animationURL != "" { + // 动效单独返回给客户端按需播放,不能再混入 icon 字段,否则列表和选择面板会把 mp4 当图片加载。 + gift.GiftAnimationUrl = animationURL + changed = true + } + } if gift.GiftPriceCoin <= 0 && item.GetCoinPrice() > 0 { // 创建挑战会把 stake_coin 固化到对局事实,缺币价时必须使用 wallet-service 当前生效礼物价格补齐。 gift.GiftPriceCoin = item.GetCoinPrice() @@ -680,7 +688,6 @@ func roomRPSGiftIconURL(gift *walletv1.GiftConfig) string { candidates := []string{ gift.GetResource().GetAssetUrl(), gift.GetResource().GetPreviewUrl(), - gift.GetResource().GetAnimationUrl(), } for _, candidate := range candidates { if value := strings.TrimSpace(candidate); value != "" { @@ -690,6 +697,26 @@ func roomRPSGiftIconURL(gift *walletv1.GiftConfig) string { return "" } +func roomRPSGiftAnimationURL(gift *walletv1.GiftConfig) string { + if gift == nil || gift.GetResource() == nil { + return "" + } + return strings.TrimSpace(gift.GetResource().GetAnimationUrl()) +} + +func roomRPSURLLooksAnimated(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return false + } + for _, suffix := range []string{".mp4", ".svga", ".pag", ".webm", ".mov"} { + if strings.Contains(value, suffix) { + return true + } + } + return false +} + func (s *Service) publishChallengeEvent(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) error { if s.pub == nil || challenge == nil { return nil diff --git a/services/game-service/internal/service/roomrps/service_test.go b/services/game-service/internal/service/roomrps/service_test.go index 59359755..6e48c67e 100644 --- a/services/game-service/internal/service/roomrps/service_test.go +++ b/services/game-service/internal/service/roomrps/service_test.go @@ -11,8 +11,8 @@ import ( func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) { catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{ - "2001": roomRPSWalletGift("2001", "Rose", "https://cdn.example.test/rose.png", "", "", 100), - "2002": roomRPSWalletGift("2002", "Bell", "", "https://cdn.example.test/bell-preview.png", "", 300), + "2001": roomRPSWalletGift("2001", "Rose", "https://cdn.example.test/rose.png", "", "https://cdn.example.test/rose.mp4", 100), + "2002": roomRPSWalletGift("2002", "Bell", "", "https://cdn.example.test/bell-preview.png", "https://cdn.example.test/bell.svga", 300), "2003": roomRPSWalletGift("2003", "Crown", "", "", "https://cdn.example.test/crown.svga", 500), "2004": roomRPSWalletGift("2004", "Cup", "https://cdn.example.test/cup.png", "", "", 1000), }) @@ -34,14 +34,14 @@ func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) { t.Fatalf("UpdateConfig() error = %v", err) } - if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Rose" || got.GetGiftIconUrl() != "https://cdn.example.test/rose.png" || got.GetGiftPriceCoin() != 100 { + if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Rose" || got.GetGiftIconUrl() != "https://cdn.example.test/rose.png" || got.GetGiftAnimationUrl() != "https://cdn.example.test/rose.mp4" || got.GetGiftPriceCoin() != 100 { t.Fatalf("first gift was not enriched from wallet catalog: %+v", got) } - if got := config.GetStakeGifts()[1]; got.GetGiftIconUrl() != "https://cdn.example.test/bell-preview.png" { - t.Fatalf("second gift should use preview_url when asset_url is empty: %+v", got) + if got := config.GetStakeGifts()[1]; got.GetGiftIconUrl() != "https://cdn.example.test/bell-preview.png" || got.GetGiftAnimationUrl() != "https://cdn.example.test/bell.svga" { + t.Fatalf("second gift should use preview_url as icon and animation_url as animation: %+v", got) } - if got := config.GetStakeGifts()[2]; got.GetGiftIconUrl() != "https://cdn.example.test/crown.svga" { - t.Fatalf("third gift should use animation_url as last display fallback: %+v", got) + if got := config.GetStakeGifts()[2]; got.GetGiftIconUrl() != "" || got.GetGiftAnimationUrl() != "https://cdn.example.test/crown.svga" { + t.Fatalf("third gift should not use animation_url as display icon: %+v", got) } if len(catalog.requests) != 4 { t.Fatalf("expected one exact catalog lookup per missing gift, got %d", len(catalog.requests)) @@ -89,6 +89,38 @@ func TestGetConfigBackfillsLegacyEmptyGiftSnapshot(t *testing.T) { } } +func TestGetConfigMovesLegacyAnimatedIconIntoAnimationURL(t *testing.T) { + catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{ + "Gifi-3": roomRPSWalletGift("Gifi-3", "Rocket", "https://cdn.example.test/rocket-cover.png", "", "https://cdn.example.test/rocket.mp4", 900), + }) + svc := New(Config{}, nil, nil, catalog) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + svc.configs["lalu"] = &gamev1.RoomRPSConfig{ + AppCode: "lalu", + GameId: GameID, + Status: "active", + ChallengeTimeoutMs: 600000, + RevealCountdownMs: 3000, + StakeGifts: []*gamev1.RoomRPSStakeGift{ + {GiftIdText: "Gifi-3", GiftName: "Rocket", GiftIconUrl: "https://cdn.example.test/rocket.mp4", GiftPriceCoin: 900, Enabled: true, SortOrder: 1}, + {GiftId: 10002, Enabled: true, SortOrder: 2}, + {GiftId: 10003, Enabled: true, SortOrder: 3}, + {GiftId: 10004, Enabled: true, SortOrder: 4}, + }, + CreatedAtMs: 1000, + UpdatedAtMs: 2000, + } + + config, _, err := svc.GetConfig(context.Background(), "lalu") + if err != nil { + t.Fatalf("GetConfig() error = %v", err) + } + + if got := config.GetStakeGifts()[0]; got.GetGiftIconUrl() != "https://cdn.example.test/rocket-cover.png" || got.GetGiftAnimationUrl() != "https://cdn.example.test/rocket.mp4" { + t.Fatalf("legacy animated icon was not split into cover and animation fields: %+v", got) + } +} + func TestCreateChallengeMatchesStringGiftID(t *testing.T) { catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{ "Gifi-3": roomRPSWalletGift("Gifi-3", "Tiger", "https://cdn.example.test/tiger.png", "", "", 1), diff --git a/services/gateway-service/internal/transport/http/gameapi/game_view.go b/services/gateway-service/internal/transport/http/gameapi/game_view.go index 6b993ee4..e5d9b92d 100644 --- a/services/gateway-service/internal/transport/http/gameapi/game_view.go +++ b/services/gateway-service/internal/transport/http/gameapi/game_view.go @@ -125,13 +125,14 @@ type roomRPSConfigItemData struct { } type roomRPSStakeGiftData struct { - GiftID string `json:"gift_id"` - GiftIDNumber int64 `json:"gift_id_number"` - GiftName string `json:"gift_name"` - GiftIconURL string `json:"gift_icon_url"` - GiftPriceCoin int64 `json:"gift_price_coin"` - Enabled bool `json:"enabled"` - SortOrder int32 `json:"sort_order"` + GiftID string `json:"gift_id"` + GiftIDNumber int64 `json:"gift_id_number"` + GiftName string `json:"gift_name"` + GiftIconURL string `json:"gift_icon_url"` + GiftAnimationURL string `json:"gift_animation_url"` + GiftPriceCoin int64 `json:"gift_price_coin"` + Enabled bool `json:"enabled"` + SortOrder int32 `json:"sort_order"` } type diceMatchItemData struct { @@ -333,13 +334,14 @@ func roomRPSConfigItemDataFromProto(config *gamev1.RoomRPSConfig) roomRPSConfigI for _, gift := range config.GetStakeGifts() { // 礼物 ID 以字符串为主,兼容数字字段;Flutter 展示、缓存和提交 body 都优先使用 gift_id。 gifts = append(gifts, roomRPSStakeGiftData{ - GiftID: roomRPSStakeGiftIDFromProto(gift), - GiftIDNumber: gift.GetGiftId(), - GiftName: gift.GetGiftName(), - GiftIconURL: gift.GetGiftIconUrl(), - GiftPriceCoin: gift.GetGiftPriceCoin(), - Enabled: gift.GetEnabled(), - SortOrder: gift.GetSortOrder(), + GiftID: roomRPSStakeGiftIDFromProto(gift), + GiftIDNumber: gift.GetGiftId(), + GiftName: gift.GetGiftName(), + GiftIconURL: gift.GetGiftIconUrl(), + GiftAnimationURL: gift.GetGiftAnimationUrl(), + GiftPriceCoin: gift.GetGiftPriceCoin(), + Enabled: gift.GetEnabled(), + SortOrder: gift.GetSortOrder(), }) } return roomRPSConfigItemData{ diff --git a/services/gateway-service/internal/transport/http/gameapi/game_view_test.go b/services/gateway-service/internal/transport/http/gameapi/game_view_test.go new file mode 100644 index 00000000..d0c114b6 --- /dev/null +++ b/services/gateway-service/internal/transport/http/gameapi/game_view_test.go @@ -0,0 +1,37 @@ +package gameapi + +import ( + "testing" + + gamev1 "hyapp.local/api/proto/game/v1" +) + +func TestRoomRPSConfigDataFromProtoIncludesGiftAnimationURL(t *testing.T) { + data := roomRPSConfigDataFromProto(&gamev1.RoomRPSConfigResponse{ + Config: &gamev1.RoomRPSConfig{ + AppCode: "lalu", + GameId: "room_rps", + Status: "active", + StakeGifts: []*gamev1.RoomRPSStakeGift{ + { + GiftIdText: "87", + GiftName: "彩蛋兔", + GiftIconUrl: "https://cdn.example.test/gifts/egg-rabbit.png", + GiftAnimationUrl: "https://cdn.example.test/gifts/egg-rabbit.mp4", + GiftPriceCoin: 40000, + Enabled: true, + SortOrder: 1, + }, + }, + }, + ServerTimeMs: 1770000000000, + }) + + if len(data.Config.StakeGifts) != 1 { + t.Fatalf("expected one stake gift, got %+v", data.Config.StakeGifts) + } + got := data.Config.StakeGifts[0] + if got.GiftIconURL != "https://cdn.example.test/gifts/egg-rabbit.png" || got.GiftAnimationURL != "https://cdn.example.test/gifts/egg-rabbit.mp4" { + t.Fatalf("room rps stake gift media fields mismatch: %+v", got) + } +} From cfe77431eb1e7cd07d4ff6af167f13002207910e Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 16 Jun 2026 14:15:35 +0800 Subject: [PATCH 2/3] fix: avoid animated room rps gift icons --- services/game-service/internal/service/roomrps/service.go | 2 +- services/game-service/internal/service/roomrps/service_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/game-service/internal/service/roomrps/service.go b/services/game-service/internal/service/roomrps/service.go index d7b83d65..4be32ec8 100644 --- a/services/game-service/internal/service/roomrps/service.go +++ b/services/game-service/internal/service/roomrps/service.go @@ -690,7 +690,7 @@ func roomRPSGiftIconURL(gift *walletv1.GiftConfig) string { gift.GetResource().GetPreviewUrl(), } for _, candidate := range candidates { - if value := strings.TrimSpace(candidate); value != "" { + if value := strings.TrimSpace(candidate); value != "" && !roomRPSURLLooksAnimated(value) { return value } } diff --git a/services/game-service/internal/service/roomrps/service_test.go b/services/game-service/internal/service/roomrps/service_test.go index 6e48c67e..05d749a7 100644 --- a/services/game-service/internal/service/roomrps/service_test.go +++ b/services/game-service/internal/service/roomrps/service_test.go @@ -12,7 +12,7 @@ import ( func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) { catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{ "2001": roomRPSWalletGift("2001", "Rose", "https://cdn.example.test/rose.png", "", "https://cdn.example.test/rose.mp4", 100), - "2002": roomRPSWalletGift("2002", "Bell", "", "https://cdn.example.test/bell-preview.png", "https://cdn.example.test/bell.svga", 300), + "2002": roomRPSWalletGift("2002", "Bell", "https://cdn.example.test/bell.mp4", "https://cdn.example.test/bell-preview.png", "https://cdn.example.test/bell.svga", 300), "2003": roomRPSWalletGift("2003", "Crown", "", "", "https://cdn.example.test/crown.svga", 500), "2004": roomRPSWalletGift("2004", "Cup", "https://cdn.example.test/cup.png", "", "", 1000), }) From d92509b5f24a6457c9b9953b11f0e14c98d1ec70 Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 16 Jun 2026 14:48:47 +0800 Subject: [PATCH 3/3] fix: persist room rps config --- .../deploy/mysql/initdb/001_game_service.sql | 12 ++ .../mysql/migrations/001_room_rps_configs.sql | 17 ++ services/game-service/internal/app/app.go | 2 +- .../internal/service/roomrps/service.go | 94 +++++++++-- .../internal/service/roomrps/service_test.go | 99 +++++++++++ .../internal/storage/mysql/repository.go | 11 ++ .../internal/storage/mysql/repository_test.go | 59 +++++++ .../mysql/room_rps_config_repository.go | 158 ++++++++++++++++++ 8 files changed, 441 insertions(+), 11 deletions(-) create mode 100644 services/game-service/deploy/mysql/migrations/001_room_rps_configs.sql create mode 100644 services/game-service/internal/storage/mysql/room_rps_config_repository.go diff --git a/services/game-service/deploy/mysql/initdb/001_game_service.sql b/services/game-service/deploy/mysql/initdb/001_game_service.sql index 5c8712e7..68026fe0 100644 --- a/services/game-service/deploy/mysql/initdb/001_game_service.sql +++ b/services/game-service/deploy/mysql/initdb/001_game_service.sql @@ -287,6 +287,18 @@ CREATE TABLE IF NOT EXISTS game_self_game_configs ( PRIMARY KEY(app_code, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏配置表'; +CREATE TABLE IF NOT EXISTS game_room_rps_configs ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', + game_id VARCHAR(96) NOT NULL COMMENT '房内猜拳游戏 ID', + status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '配置状态', + challenge_timeout_ms BIGINT NOT NULL DEFAULT 600000 COMMENT '挑战无人应战超时毫秒数', + reveal_countdown_ms BIGINT NOT NULL DEFAULT 3000 COMMENT '揭晓倒计时毫秒数', + stake_gifts_json JSON NOT NULL COMMENT '四档礼物配置快照', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY(app_code, game_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房内猜拳配置表'; + CREATE TABLE IF NOT EXISTS game_self_game_pools ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', game_id VARCHAR(96) NOT NULL COMMENT '自研游戏 ID', diff --git a/services/game-service/deploy/mysql/migrations/001_room_rps_configs.sql b/services/game-service/deploy/mysql/migrations/001_room_rps_configs.sql new file mode 100644 index 00000000..a0fad577 --- /dev/null +++ b/services/game-service/deploy/mysql/migrations/001_room_rps_configs.sql @@ -0,0 +1,17 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE DATABASE IF NOT EXISTS hyapp_game DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_game; + +CREATE TABLE IF NOT EXISTS game_room_rps_configs ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', + game_id VARCHAR(96) NOT NULL COMMENT '房内猜拳游戏 ID', + status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '配置状态', + challenge_timeout_ms BIGINT NOT NULL DEFAULT 600000 COMMENT '挑战无人应战超时毫秒数', + reveal_countdown_ms BIGINT NOT NULL DEFAULT 3000 COMMENT '揭晓倒计时毫秒数', + stake_gifts_json JSON NOT NULL COMMENT '四档礼物配置快照', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY(app_code, game_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房内猜拳配置表'; diff --git a/services/game-service/internal/app/app.go b/services/game-service/internal/app/app.go index 02c80954..09763978 100644 --- a/services/game-service/internal/app/app.go +++ b/services/game-service/internal/app/app.go @@ -127,7 +127,7 @@ func New(cfg config.Config) (*App, error) { logx.Warn(context.Background(), "room_rps_im_disabled", slog.String("reason", "tencent_im.enabled=false")) } // 房内猜拳独立于骰子和独立猜拳:配置、挑战状态机和 IM 事件全部由 room-rps service 承接,transport 只做 RPC 适配。 - roomRPSSvc := roomrpsservice.New(roomrpsservice.Config{}, userClient, roomRPSPublisher, walletClient) + roomRPSSvc := roomrpsservice.New(roomrpsservice.Config{}, userClient, roomRPSPublisher, walletClient, repo) transport := grpcserver.NewServer(svc, diceSvc, roomRPSSvc) gamev1.RegisterGameAppServiceServer(server, transport) gamev1.RegisterGameCallbackServiceServer(server, transport) diff --git a/services/game-service/internal/service/roomrps/service.go b/services/game-service/internal/service/roomrps/service.go index 4be32ec8..64a0e318 100644 --- a/services/game-service/internal/service/roomrps/service.go +++ b/services/game-service/internal/service/roomrps/service.go @@ -62,6 +62,12 @@ type GiftCatalogClient interface { ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) } +// ConfigStore 是 room-rps 配置的持久化边界;service 只关心完整配置快照,表结构和 JSON 编码留在 MySQL 仓储层。 +type ConfigStore interface { + GetRoomRPSConfig(ctx context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error) + UpsertRoomRPSConfig(ctx context.Context, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) +} + // Publisher 是房间 IM 投递边界;生产用腾讯 IM REST,测试可以注入 fake,service 不感知具体 SDK。 type Publisher interface { EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error @@ -78,6 +84,7 @@ type Service struct { user UserClient gifts GiftCatalogClient pub Publisher + store ConfigStore mu sync.Mutex configs map[string]*gamev1.RoomRPSConfig @@ -86,8 +93,8 @@ type Service struct { } // New 创建房内猜拳用例层。 -// 当前阶段先把真实 HTTP、后台和 IM 事件跑通;挑战事实用进程内状态承接,后续接 MySQL 时保持同一套状态机和 RPC 契约即可。 -func New(config Config, user UserClient, publisher Publisher, giftCatalog ...GiftCatalogClient) *Service { +// 配置已经接入 MySQL 持久化;挑战事实仍是进程内状态,后续独立迁移时继续沿用当前状态机和 RPC 契约。 +func New(config Config, user UserClient, publisher Publisher, extras ...any) *Service { if config.ChallengeTimeout <= 0 { // 房内猜拳产品规则是 10 分钟无人应战自动超时;默认打开时必须带稳定超时,避免 pending 永久悬挂。 config.ChallengeTimeout = 10 * time.Minute @@ -97,15 +104,23 @@ func New(config Config, user UserClient, publisher Publisher, giftCatalog ...Gif config.RevealCountdown = 3 * time.Second } var gifts GiftCatalogClient - if len(giftCatalog) > 0 { - // room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。 - gifts = giftCatalog[0] + var store ConfigStore + for _, extra := range extras { + switch typed := extra.(type) { + case GiftCatalogClient: + // room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。 + gifts = typed + case ConfigStore: + // MySQL 是配置事实源;没有仓储的测试场景才落回进程内 map,避免把运行态差异写成前端兜底。 + store = typed + } } return &Service{ config: config, user: user, gifts: gifts, pub: publisher, + store: store, configs: make(map[string]*gamev1.RoomRPSConfig), challenges: make(map[string]*gamev1.RoomRPSChallenge), now: time.Now, @@ -125,6 +140,10 @@ func (s *Service) GetConfig(ctx context.Context, app string) (*gamev1.RoomRPSCon app = appcode.Normalize(app) ctx = appcode.WithContext(ctx, app) + if s.store != nil { + return s.getPersistentConfig(ctx, app) + } + s.mu.Lock() config := s.configForAppLocked(app) cloned := cloneConfig(config) @@ -157,11 +176,25 @@ func (s *Service) UpdateConfig(ctx context.Context, app string, config *gamev1.R if err != nil { return nil, 0, err } + nowMS := s.now().UnixMilli() + + if s.store != nil { + // 数据库保存时由唯一键保持 created_at_ms;service 每次提交只表达“这个时刻产生了新配置版本”。 + normalized.CreatedAtMs = nowMS + normalized.UpdatedAtMs = nowMS + saved, err := s.store.UpsertRoomRPSConfig(ctx, normalized) + if err != nil { + return nil, 0, err + } + s.mu.Lock() + delete(s.configs, app) + s.mu.Unlock() + return cloneConfig(saved), nowMS, nil + } s.mu.Lock() defer s.mu.Unlock() - nowMS := s.now().UnixMilli() if existing := s.configs[app]; existing != nil && existing.GetCreatedAtMs() > 0 { normalized.CreatedAtMs = existing.GetCreatedAtMs() } else { @@ -257,17 +290,19 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge command is incomplete") } - s.mu.Lock() - config := s.configForAppLocked(app) + config, err := s.runtimeConfig(ctx, app) + if err != nil { + return nil, 0, err + } if config.GetStatus() != "active" { - s.mu.Unlock() return nil, 0, xerr.New(xerr.Conflict, "room rps config is disabled") } gift, ok := stakeGiftByID(config, giftID) if !ok || !gift.GetEnabled() { - s.mu.Unlock() return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled") } + + s.mu.Lock() now := s.now() nowMS := now.UnixMilli() challenge := &gamev1.RoomRPSChallenge{ @@ -451,6 +486,45 @@ func (s *Service) ExpireChallenge(ctx context.Context, app string, challengeID s return cloned, nowMS, nil } +func (s *Service) getPersistentConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, int64, error) { + nowMS := s.now().UnixMilli() + config, err := s.store.GetRoomRPSConfig(ctx, app, GameID) + persisted := true + if err != nil { + if !xerr.IsCode(err, xerr.NotFound) { + return nil, 0, err + } + // 首次部署或本地空库没有配置行时仍返回产品默认值;只有后台保存后才写入 MySQL,避免读接口偷偷覆盖运营数据。 + config = defaultConfig(app, s.config.ChallengeTimeout, s.config.RevealCountdown, nowMS) + persisted = false + } + + enriched, changed, err := s.enrichConfigGifts(ctx, app, cloneConfig(config)) + if err != nil { + return nil, 0, err + } + if changed && persisted { + // 历史行可能只保存 gift_id 或误把 mp4 写进 icon;读路径修正成功后回写,后续重启和发起挑战都使用同一份快照。 + enriched.UpdatedAtMs = nowMS + saved, err := s.store.UpsertRoomRPSConfig(ctx, enriched) + if err != nil { + return nil, 0, err + } + enriched = saved + } + return cloneConfig(enriched), nowMS, nil +} + +func (s *Service) runtimeConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, error) { + if s.store != nil { + config, _, err := s.GetConfig(ctx, app) + return config, err + } + s.mu.Lock() + defer s.mu.Unlock() + return cloneConfig(s.configForAppLocked(app)), nil +} + func (s *Service) configForAppLocked(app string) *gamev1.RoomRPSConfig { if config := s.configs[app]; config != nil { return config diff --git a/services/game-service/internal/service/roomrps/service_test.go b/services/game-service/internal/service/roomrps/service_test.go index 05d749a7..f628dab0 100644 --- a/services/game-service/internal/service/roomrps/service_test.go +++ b/services/game-service/internal/service/roomrps/service_test.go @@ -7,6 +7,7 @@ import ( gamev1 "hyapp.local/api/proto/game/v1" walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/xerr" ) func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) { @@ -162,6 +163,75 @@ func TestCreateChallengeMatchesStringGiftID(t *testing.T) { } } +func TestRoomRPSConfigPersistsAcrossServiceInstances(t *testing.T) { + store := newFakeRoomRPSConfigStore() + svc := New(Config{}, nil, nil, store) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + _, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{ + Status: "active", + ChallengeTimeoutMs: 600000, + RevealCountdownMs: 3000, + StakeGifts: []*gamev1.RoomRPSStakeGift{ + {GiftIdText: "persist-1", GiftName: "Persist One", GiftIconUrl: "https://cdn.example.test/one.png", GiftPriceCoin: 11, Enabled: true, SortOrder: 1}, + {GiftIdText: "persist-2", GiftName: "Persist Two", GiftIconUrl: "https://cdn.example.test/two.png", GiftPriceCoin: 22, Enabled: true, SortOrder: 2}, + {GiftIdText: "persist-3", GiftName: "Persist Three", GiftIconUrl: "https://cdn.example.test/three.png", GiftPriceCoin: 33, Enabled: true, SortOrder: 3}, + {GiftIdText: "persist-4", GiftName: "Persist Four", GiftIconUrl: "https://cdn.example.test/four.png", GiftPriceCoin: 44, Enabled: true, SortOrder: 4}, + }, + }) + if err != nil { + t.Fatalf("UpdateConfig() error = %v", err) + } + if store.upserts != 1 { + t.Fatalf("expected one persistent upsert, got %d", store.upserts) + } + + restarted := New(Config{}, nil, nil, store) + restarted.now = func() time.Time { return time.UnixMilli(1770000060000) } + config, _, err := restarted.GetConfig(context.Background(), "lalu") + if err != nil { + t.Fatalf("GetConfig() after restart error = %v", err) + } + if got := config.GetStakeGifts()[0]; got.GetGiftIdText() != "persist-1" || got.GetGiftPriceCoin() != 11 { + t.Fatalf("persistent config was not reused after restart: %+v", got) + } +} + +func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) { + store := newFakeRoomRPSConfigStore() + store.configs["lalu:"+GameID] = &gamev1.RoomRPSConfig{ + AppCode: "lalu", + GameId: GameID, + Status: "active", + ChallengeTimeoutMs: 600000, + RevealCountdownMs: 3000, + StakeGifts: []*gamev1.RoomRPSStakeGift{ + {GiftIdText: "db-gift", GiftName: "DB Gift", GiftIconUrl: "https://cdn.example.test/db.png", GiftPriceCoin: 222, Enabled: true, SortOrder: 1}, + {GiftIdText: "db-gift-2", GiftName: "DB Gift 2", GiftIconUrl: "https://cdn.example.test/db2.png", GiftPriceCoin: 333, Enabled: true, SortOrder: 2}, + {GiftIdText: "db-gift-3", GiftName: "DB Gift 3", GiftIconUrl: "https://cdn.example.test/db3.png", GiftPriceCoin: 444, Enabled: true, SortOrder: 3}, + {GiftIdText: "db-gift-4", GiftName: "DB Gift 4", GiftIconUrl: "https://cdn.example.test/db4.png", GiftPriceCoin: 555, Enabled: true, SortOrder: 4}, + }, + CreatedAtMs: 1000, + UpdatedAtMs: 2000, + } + svc := New(Config{}, nil, nil, store) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu"}, + UserId: 42, + RoomId: "room-1", + GiftIdText: "db-gift", + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + if challenge.GetStakeGiftIdText() != "db-gift" || challenge.GetStakeCoin() != 222 { + t.Fatalf("challenge did not use persistent config: %+v", challenge) + } +} + type fakeGiftCatalog struct { gifts map[string]*walletv1.GiftConfig requests []*walletv1.ListGiftConfigsRequest @@ -197,3 +267,32 @@ func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL s }, } } + +type fakeRoomRPSConfigStore struct { + configs map[string]*gamev1.RoomRPSConfig + upserts int +} + +func newFakeRoomRPSConfigStore() *fakeRoomRPSConfigStore { + return &fakeRoomRPSConfigStore{configs: make(map[string]*gamev1.RoomRPSConfig)} +} + +func (f *fakeRoomRPSConfigStore) GetRoomRPSConfig(_ context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error) { + if gameID == "" { + gameID = GameID + } + config := f.configs[appCode+":"+gameID] + if config == nil { + return nil, xerr.New(xerr.NotFound, "room rps config not found") + } + return cloneConfig(config), nil +} + +func (f *fakeRoomRPSConfigStore) UpsertRoomRPSConfig(_ context.Context, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) { + if config.GetGameId() == "" { + config.GameId = GameID + } + f.upserts++ + f.configs[config.GetAppCode()+":"+config.GetGameId()] = cloneConfig(config) + return cloneConfig(config), nil +} diff --git a/services/game-service/internal/storage/mysql/repository.go b/services/game-service/internal/storage/mysql/repository.go index 667be862..67124f13 100644 --- a/services/game-service/internal/storage/mysql/repository.go +++ b/services/game-service/internal/storage/mysql/repository.go @@ -236,6 +236,17 @@ func (r *Repository) Migrate(ctx context.Context) error { updated_at_ms BIGINT NOT NULL, PRIMARY KEY(app_code, game_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS game_room_rps_configs ( + app_code VARCHAR(32) NOT NULL, + game_id VARCHAR(96) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + challenge_timeout_ms BIGINT NOT NULL DEFAULT 600000, + reveal_countdown_ms BIGINT NOT NULL DEFAULT 3000, + stake_gifts_json JSON NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY(app_code, game_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS game_self_game_pools ( app_code VARCHAR(32) NOT NULL, game_id VARCHAR(96) NOT NULL, diff --git a/services/game-service/internal/storage/mysql/repository_test.go b/services/game-service/internal/storage/mysql/repository_test.go index 85d87991..3af80bd6 100644 --- a/services/game-service/internal/storage/mysql/repository_test.go +++ b/services/game-service/internal/storage/mysql/repository_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + gamev1 "hyapp.local/api/proto/game/v1" "hyapp/internal/testutil/mysqlschema" "hyapp/pkg/appcode" "hyapp/pkg/xerr" @@ -16,6 +17,64 @@ import ( gameservice "hyapp/services/game-service/internal/service/game" ) +func TestRoomRPSConfigRepositoryRoundTrip(t *testing.T) { + caller := mysqlschema.CallerFile(t, 1) + schema := mysqlschema.New(t, mysqlschema.Config{ + InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"), + DatabasePrefix: "hy_game_rps_test", + }) + + ctx := appcode.WithContext(context.Background(), "lalu") + repo, err := Open(ctx, schema.DSN) + if err != nil { + t.Fatalf("open repository failed: %v", err) + } + t.Cleanup(func() { _ = repo.Close() }) + + if _, err := repo.GetRoomRPSConfig(ctx, "lalu", "room_rps"); !xerr.IsCode(err, xerr.NotFound) { + t.Fatalf("empty config should return not found, got %v", err) + } + + saved, err := repo.UpsertRoomRPSConfig(ctx, &gamev1.RoomRPSConfig{ + AppCode: "lalu", + GameId: "room_rps", + Status: "active", + ChallengeTimeoutMs: 600000, + RevealCountdownMs: 3000, + StakeGifts: []*gamev1.RoomRPSStakeGift{ + {GiftIdText: "Gifi-3", GiftName: "Tiger", GiftIconUrl: "https://cdn.example.test/tiger.png", GiftAnimationUrl: "https://cdn.example.test/tiger.mp4", GiftPriceCoin: 100, Enabled: true, SortOrder: 1}, + {GiftIdText: "Gifi-2", GiftName: "Cowboy", GiftIconUrl: "https://cdn.example.test/cowboy.png", GiftPriceCoin: 200, Enabled: true, SortOrder: 2}, + {GiftIdText: "Gifi-1", GiftName: "Warrior", GiftIconUrl: "https://cdn.example.test/warrior.png", GiftPriceCoin: 300, Enabled: true, SortOrder: 3}, + {GiftIdText: "lw1", GiftName: "Wheel", GiftIconUrl: "https://cdn.example.test/wheel.png", GiftPriceCoin: 400, Enabled: false, SortOrder: 4}, + }, + CreatedAtMs: 1700000000000, + UpdatedAtMs: 1700000000000, + }) + if err != nil { + t.Fatalf("upsert room rps config failed: %v", err) + } + if saved.GetCreatedAtMs() != 1700000000000 || saved.GetStakeGifts()[0].GetGiftAnimationUrl() == "" { + t.Fatalf("saved config mismatch: %+v", saved) + } + + updated, err := repo.UpsertRoomRPSConfig(ctx, &gamev1.RoomRPSConfig{ + AppCode: "lalu", + GameId: "room_rps", + Status: "disabled", + ChallengeTimeoutMs: 300000, + RevealCountdownMs: 5000, + StakeGifts: saved.GetStakeGifts(), + CreatedAtMs: 1800000000000, + UpdatedAtMs: 1800000000000, + }) + if err != nil { + t.Fatalf("update room rps config failed: %v", err) + } + if updated.GetStatus() != "disabled" || updated.GetCreatedAtMs() != 1700000000000 || updated.GetUpdatedAtMs() != 1800000000000 { + t.Fatalf("updated config should preserve created_at and update payload: %+v", updated) + } +} + func TestSelfGameStakePoolStrategyLogAndProtectionState(t *testing.T) { caller := mysqlschema.CallerFile(t, 1) schema := mysqlschema.New(t, mysqlschema.Config{ diff --git a/services/game-service/internal/storage/mysql/room_rps_config_repository.go b/services/game-service/internal/storage/mysql/room_rps_config_repository.go new file mode 100644 index 00000000..ef0a40e4 --- /dev/null +++ b/services/game-service/internal/storage/mysql/room_rps_config_repository.go @@ -0,0 +1,158 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "time" + + gamev1 "hyapp.local/api/proto/game/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" +) + +const roomRPSGameID = "room_rps" + +type roomRPSStakeGiftSnapshot struct { + GiftID int64 `json:"gift_id"` + GiftIDText string `json:"gift_id_text"` + GiftName string `json:"gift_name"` + GiftIconURL string `json:"gift_icon_url"` + GiftAnimationURL string `json:"gift_animation_url"` + GiftPriceCoin int64 `json:"gift_price_coin"` + Enabled bool `json:"enabled"` + SortOrder int32 `json:"sort_order"` +} + +// GetRoomRPSConfig 返回房内猜拳配置事实;没有行时返回 NotFound,让 service 决定是否使用默认配置。 +func (r *Repository) GetRoomRPSConfig(ctx context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + app := appcode.Normalize(appCode) + gameID = normalizeRoomRPSGameID(gameID) + row := r.db.QueryRowContext(ctx, + `SELECT app_code, game_id, status, challenge_timeout_ms, reveal_countdown_ms, + COALESCE(CAST(stake_gifts_json AS CHAR), '[]'), created_at_ms, updated_at_ms + FROM game_room_rps_configs + WHERE app_code = ? AND game_id = ?`, + app, gameID, + ) + config := &gamev1.RoomRPSConfig{} + var giftsJSON string + if err := row.Scan( + &config.AppCode, &config.GameId, &config.Status, &config.ChallengeTimeoutMs, + &config.RevealCountdownMs, &giftsJSON, &config.CreatedAtMs, &config.UpdatedAtMs, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, xerr.New(xerr.NotFound, "room rps config not found") + } + return nil, err + } + gifts, err := roomRPSStakeGiftsFromJSON(giftsJSON) + if err != nil { + return nil, err + } + config.StakeGifts = gifts + return config, nil +} + +// UpsertRoomRPSConfig 持久化后台保存后的完整快照;重复保存只更新时间和可运营字段,不重写首次创建时间。 +func (r *Repository) UpsertRoomRPSConfig(ctx context.Context, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if config == nil { + return nil, xerr.New(xerr.InvalidArgument, "room rps config is required") + } + app := appcode.Normalize(config.GetAppCode()) + if app == "" { + return nil, xerr.New(xerr.InvalidArgument, "app_code is required") + } + gameID := normalizeRoomRPSGameID(config.GetGameId()) + nowMS := time.Now().UnixMilli() + createdAtMS := config.GetCreatedAtMs() + if createdAtMS <= 0 { + createdAtMS = nowMS + } + updatedAtMS := config.GetUpdatedAtMs() + if updatedAtMS <= 0 { + updatedAtMS = nowMS + } + rawGifts, err := json.Marshal(roomRPSStakeGiftSnapshots(config.GetStakeGifts())) + if err != nil { + return nil, err + } + if _, err := r.db.ExecContext(ctx, + `INSERT INTO game_room_rps_configs ( + app_code, game_id, status, challenge_timeout_ms, reveal_countdown_ms, + stake_gifts_json, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + challenge_timeout_ms = VALUES(challenge_timeout_ms), + reveal_countdown_ms = VALUES(reveal_countdown_ms), + stake_gifts_json = VALUES(stake_gifts_json), + updated_at_ms = VALUES(updated_at_ms)`, + app, gameID, strings.TrimSpace(config.GetStatus()), config.GetChallengeTimeoutMs(), config.GetRevealCountdownMs(), + string(rawGifts), createdAtMS, updatedAtMS, + ); err != nil { + return nil, err + } + return r.GetRoomRPSConfig(ctx, app, gameID) +} + +func normalizeRoomRPSGameID(gameID string) string { + gameID = strings.TrimSpace(gameID) + if gameID == "" { + return roomRPSGameID + } + return gameID +} + +func roomRPSStakeGiftSnapshots(gifts []*gamev1.RoomRPSStakeGift) []roomRPSStakeGiftSnapshot { + result := make([]roomRPSStakeGiftSnapshot, 0, len(gifts)) + for _, gift := range gifts { + if gift == nil { + continue + } + result = append(result, roomRPSStakeGiftSnapshot{ + GiftID: gift.GetGiftId(), + GiftIDText: strings.TrimSpace(gift.GetGiftIdText()), + GiftName: strings.TrimSpace(gift.GetGiftName()), + GiftIconURL: strings.TrimSpace(gift.GetGiftIconUrl()), + GiftAnimationURL: strings.TrimSpace(gift.GetGiftAnimationUrl()), + GiftPriceCoin: gift.GetGiftPriceCoin(), + Enabled: gift.GetEnabled(), + SortOrder: gift.GetSortOrder(), + }) + } + return result +} + +func roomRPSStakeGiftsFromJSON(value string) ([]*gamev1.RoomRPSStakeGift, error) { + value = strings.TrimSpace(value) + if value == "" || value == "null" { + return []*gamev1.RoomRPSStakeGift{}, nil + } + var snapshots []roomRPSStakeGiftSnapshot + if err := json.Unmarshal([]byte(value), &snapshots); err != nil { + return nil, err + } + result := make([]*gamev1.RoomRPSStakeGift, 0, len(snapshots)) + for _, snapshot := range snapshots { + result = append(result, &gamev1.RoomRPSStakeGift{ + GiftId: snapshot.GiftID, + GiftIdText: strings.TrimSpace(snapshot.GiftIDText), + GiftName: strings.TrimSpace(snapshot.GiftName), + GiftIconUrl: strings.TrimSpace(snapshot.GiftIconURL), + GiftAnimationUrl: strings.TrimSpace(snapshot.GiftAnimationURL), + GiftPriceCoin: snapshot.GiftPriceCoin, + Enabled: snapshot.Enabled, + SortOrder: snapshot.SortOrder, + }) + } + return result, nil +}