Compare commits
3 Commits
152bdf232e
...
a7fb47579d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7fb47579d | ||
|
|
0f767a0f22 | ||
|
|
9a9cccd194 |
@ -2046,6 +2046,7 @@ type RoomRPSStakeGift struct {
|
|||||||
Enabled bool `protobuf:"varint,5,opt,name=enabled,proto3" json:"enabled,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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -2129,6 +2130,13 @@ func (x *RoomRPSStakeGift) GetGiftIdText() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *RoomRPSStakeGift) GetGiftAnimationUrl() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.GiftAnimationUrl
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type RoomRPSConfig struct {
|
type RoomRPSConfig struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
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" +
|
"\agame_id\x18\x02 \x01(\tR\x06gameId\"m\n" +
|
||||||
"\x12DiceConfigResponse\x121\n" +
|
"\x12DiceConfigResponse\x121\n" +
|
||||||
"\x06config\x18\x01 \x01(\v2\x19.hyapp.game.v1.DiceConfigR\x06config\x12$\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" +
|
"\x10RoomRPSStakeGift\x12\x17\n" +
|
||||||
"\agift_id\x18\x01 \x01(\x03R\x06giftId\x12\x1b\n" +
|
"\agift_id\x18\x01 \x01(\x03R\x06giftId\x12\x1b\n" +
|
||||||
"\tgift_name\x18\x02 \x01(\tR\bgiftName\x12\"\n" +
|
"\tgift_name\x18\x02 \x01(\tR\bgiftName\x12\"\n" +
|
||||||
@ -6540,7 +6548,8 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
|
|||||||
"\n" +
|
"\n" +
|
||||||
"sort_order\x18\x06 \x01(\x05R\tsortOrder\x12 \n" +
|
"sort_order\x18\x06 \x01(\x05R\tsortOrder\x12 \n" +
|
||||||
"\fgift_id_text\x18\a \x01(\tR\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" +
|
"\rRoomRPSConfig\x12\x19\n" +
|
||||||
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
|
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
|
||||||
"\agame_id\x18\x02 \x01(\tR\x06gameId\x12\x16\n" +
|
"\agame_id\x18\x02 \x01(\tR\x06gameId\x12\x16\n" +
|
||||||
|
|||||||
@ -238,6 +238,7 @@ message RoomRPSStakeGift {
|
|||||||
bool enabled = 5;
|
bool enabled = 5;
|
||||||
int32 sort_order = 6;
|
int32 sort_order = 6;
|
||||||
string gift_id_text = 7;
|
string gift_id_text = 7;
|
||||||
|
string gift_animation_url = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RoomRPSConfig {
|
message RoomRPSConfig {
|
||||||
|
|||||||
@ -151,6 +151,7 @@ type roomRPSStakeGiftDTO struct {
|
|||||||
GiftIDNumber int64 `json:"giftIdNumber"`
|
GiftIDNumber int64 `json:"giftIdNumber"`
|
||||||
GiftName string `json:"giftName"`
|
GiftName string `json:"giftName"`
|
||||||
GiftIconURL string `json:"giftIconUrl"`
|
GiftIconURL string `json:"giftIconUrl"`
|
||||||
|
GiftAnimationURL string `json:"giftAnimationUrl"`
|
||||||
GiftPriceCoin int64 `json:"giftPriceCoin"`
|
GiftPriceCoin int64 `json:"giftPriceCoin"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
SortOrder int32 `json:"sortOrder"`
|
SortOrder int32 `json:"sortOrder"`
|
||||||
@ -357,6 +358,7 @@ func roomRPSConfigFromProto(item *gamev1.RoomRPSConfig) roomRPSConfigDTO {
|
|||||||
GiftIDNumber: gift.GetGiftId(),
|
GiftIDNumber: gift.GetGiftId(),
|
||||||
GiftName: gift.GetGiftName(),
|
GiftName: gift.GetGiftName(),
|
||||||
GiftIconURL: gift.GetGiftIconUrl(),
|
GiftIconURL: gift.GetGiftIconUrl(),
|
||||||
|
GiftAnimationURL: gift.GetGiftAnimationUrl(),
|
||||||
GiftPriceCoin: gift.GetGiftPriceCoin(),
|
GiftPriceCoin: gift.GetGiftPriceCoin(),
|
||||||
Enabled: gift.GetEnabled(),
|
Enabled: gift.GetEnabled(),
|
||||||
SortOrder: gift.GetSortOrder(),
|
SortOrder: gift.GetSortOrder(),
|
||||||
|
|||||||
@ -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) {
|
func TestRoomRPSChallengeFromProtoKeepsLargeIDsAsStrings(t *testing.T) {
|
||||||
got := roomRPSChallengeFromProto(&gamev1.RoomRPSChallenge{
|
got := roomRPSChallengeFromProto(&gamev1.RoomRPSChallenge{
|
||||||
ChallengeId: "rps-1",
|
ChallengeId: "rps-1",
|
||||||
|
|||||||
@ -287,6 +287,18 @@ CREATE TABLE IF NOT EXISTS game_self_game_configs (
|
|||||||
PRIMARY KEY(app_code, game_id)
|
PRIMARY KEY(app_code, game_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏配置表';
|
) 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 (
|
CREATE TABLE IF NOT EXISTS game_self_game_pools (
|
||||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||||
game_id VARCHAR(96) NOT NULL COMMENT '自研游戏 ID',
|
game_id VARCHAR(96) NOT NULL COMMENT '自研游戏 ID',
|
||||||
|
|||||||
@ -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='房内猜拳配置表';
|
||||||
@ -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"))
|
logx.Warn(context.Background(), "room_rps_im_disabled", slog.String("reason", "tencent_im.enabled=false"))
|
||||||
}
|
}
|
||||||
// 房内猜拳独立于骰子和独立猜拳:配置、挑战状态机和 IM 事件全部由 room-rps service 承接,transport 只做 RPC 适配。
|
// 房内猜拳独立于骰子和独立猜拳:配置、挑战状态机和 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)
|
transport := grpcserver.NewServer(svc, diceSvc, roomRPSSvc)
|
||||||
gamev1.RegisterGameAppServiceServer(server, transport)
|
gamev1.RegisterGameAppServiceServer(server, transport)
|
||||||
gamev1.RegisterGameCallbackServiceServer(server, transport)
|
gamev1.RegisterGameCallbackServiceServer(server, transport)
|
||||||
|
|||||||
@ -62,6 +62,12 @@ type GiftCatalogClient interface {
|
|||||||
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
|
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。
|
// Publisher 是房间 IM 投递边界;生产用腾讯 IM REST,测试可以注入 fake,service 不感知具体 SDK。
|
||||||
type Publisher interface {
|
type Publisher interface {
|
||||||
EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error
|
EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error
|
||||||
@ -78,6 +84,7 @@ type Service struct {
|
|||||||
user UserClient
|
user UserClient
|
||||||
gifts GiftCatalogClient
|
gifts GiftCatalogClient
|
||||||
pub Publisher
|
pub Publisher
|
||||||
|
store ConfigStore
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
configs map[string]*gamev1.RoomRPSConfig
|
configs map[string]*gamev1.RoomRPSConfig
|
||||||
@ -86,8 +93,8 @@ type Service struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New 创建房内猜拳用例层。
|
// New 创建房内猜拳用例层。
|
||||||
// 当前阶段先把真实 HTTP、后台和 IM 事件跑通;挑战事实用进程内状态承接,后续接 MySQL 时保持同一套状态机和 RPC 契约即可。
|
// 配置已经接入 MySQL 持久化;挑战事实仍是进程内状态,后续独立迁移时继续沿用当前状态机和 RPC 契约。
|
||||||
func New(config Config, user UserClient, publisher Publisher, giftCatalog ...GiftCatalogClient) *Service {
|
func New(config Config, user UserClient, publisher Publisher, extras ...any) *Service {
|
||||||
if config.ChallengeTimeout <= 0 {
|
if config.ChallengeTimeout <= 0 {
|
||||||
// 房内猜拳产品规则是 10 分钟无人应战自动超时;默认打开时必须带稳定超时,避免 pending 永久悬挂。
|
// 房内猜拳产品规则是 10 分钟无人应战自动超时;默认打开时必须带稳定超时,避免 pending 永久悬挂。
|
||||||
config.ChallengeTimeout = 10 * time.Minute
|
config.ChallengeTimeout = 10 * time.Minute
|
||||||
@ -97,15 +104,23 @@ func New(config Config, user UserClient, publisher Publisher, giftCatalog ...Gif
|
|||||||
config.RevealCountdown = 3 * time.Second
|
config.RevealCountdown = 3 * time.Second
|
||||||
}
|
}
|
||||||
var gifts GiftCatalogClient
|
var gifts GiftCatalogClient
|
||||||
if len(giftCatalog) > 0 {
|
var store ConfigStore
|
||||||
|
for _, extra := range extras {
|
||||||
|
switch typed := extra.(type) {
|
||||||
|
case GiftCatalogClient:
|
||||||
// room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。
|
// room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。
|
||||||
gifts = giftCatalog[0]
|
gifts = typed
|
||||||
|
case ConfigStore:
|
||||||
|
// MySQL 是配置事实源;没有仓储的测试场景才落回进程内 map,避免把运行态差异写成前端兜底。
|
||||||
|
store = typed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return &Service{
|
return &Service{
|
||||||
config: config,
|
config: config,
|
||||||
user: user,
|
user: user,
|
||||||
gifts: gifts,
|
gifts: gifts,
|
||||||
pub: publisher,
|
pub: publisher,
|
||||||
|
store: store,
|
||||||
configs: make(map[string]*gamev1.RoomRPSConfig),
|
configs: make(map[string]*gamev1.RoomRPSConfig),
|
||||||
challenges: make(map[string]*gamev1.RoomRPSChallenge),
|
challenges: make(map[string]*gamev1.RoomRPSChallenge),
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
@ -125,6 +140,10 @@ func (s *Service) GetConfig(ctx context.Context, app string) (*gamev1.RoomRPSCon
|
|||||||
app = appcode.Normalize(app)
|
app = appcode.Normalize(app)
|
||||||
ctx = appcode.WithContext(ctx, app)
|
ctx = appcode.WithContext(ctx, app)
|
||||||
|
|
||||||
|
if s.store != nil {
|
||||||
|
return s.getPersistentConfig(ctx, app)
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
config := s.configForAppLocked(app)
|
config := s.configForAppLocked(app)
|
||||||
cloned := cloneConfig(config)
|
cloned := cloneConfig(config)
|
||||||
@ -157,11 +176,25 @@ func (s *Service) UpdateConfig(ctx context.Context, app string, config *gamev1.R
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
nowMS := s.now().UnixMilli()
|
|
||||||
if existing := s.configs[app]; existing != nil && existing.GetCreatedAtMs() > 0 {
|
if existing := s.configs[app]; existing != nil && existing.GetCreatedAtMs() > 0 {
|
||||||
normalized.CreatedAtMs = existing.GetCreatedAtMs()
|
normalized.CreatedAtMs = existing.GetCreatedAtMs()
|
||||||
} else {
|
} 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")
|
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge command is incomplete")
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
config, err := s.runtimeConfig(ctx, app)
|
||||||
config := s.configForAppLocked(app)
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
if config.GetStatus() != "active" {
|
if config.GetStatus() != "active" {
|
||||||
s.mu.Unlock()
|
|
||||||
return nil, 0, xerr.New(xerr.Conflict, "room rps config is disabled")
|
return nil, 0, xerr.New(xerr.Conflict, "room rps config is disabled")
|
||||||
}
|
}
|
||||||
gift, ok := stakeGiftByID(config, giftID)
|
gift, ok := stakeGiftByID(config, giftID)
|
||||||
if !ok || !gift.GetEnabled() {
|
if !ok || !gift.GetEnabled() {
|
||||||
s.mu.Unlock()
|
|
||||||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled")
|
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
now := s.now()
|
now := s.now()
|
||||||
nowMS := now.UnixMilli()
|
nowMS := now.UnixMilli()
|
||||||
challenge := &gamev1.RoomRPSChallenge{
|
challenge := &gamev1.RoomRPSChallenge{
|
||||||
@ -451,6 +486,45 @@ func (s *Service) ExpireChallenge(ctx context.Context, app string, challengeID s
|
|||||||
return cloned, nowMS, nil
|
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 {
|
func (s *Service) configForAppLocked(app string) *gamev1.RoomRPSConfig {
|
||||||
if config := s.configs[app]; config != nil {
|
if config := s.configs[app]; config != nil {
|
||||||
return config
|
return config
|
||||||
@ -512,6 +586,7 @@ func (s *Service) normalizeConfig(ctx context.Context, app string, config *gamev
|
|||||||
GiftIdText: giftID,
|
GiftIdText: giftID,
|
||||||
GiftName: strings.TrimSpace(gift.GetGiftName()),
|
GiftName: strings.TrimSpace(gift.GetGiftName()),
|
||||||
GiftIconUrl: strings.TrimSpace(gift.GetGiftIconUrl()),
|
GiftIconUrl: strings.TrimSpace(gift.GetGiftIconUrl()),
|
||||||
|
GiftAnimationUrl: strings.TrimSpace(gift.GetGiftAnimationUrl()),
|
||||||
GiftPriceCoin: gift.GetGiftPriceCoin(),
|
GiftPriceCoin: gift.GetGiftPriceCoin(),
|
||||||
Enabled: gift.GetEnabled(),
|
Enabled: gift.GetEnabled(),
|
||||||
SortOrder: sortOrder,
|
SortOrder: sortOrder,
|
||||||
@ -571,7 +646,7 @@ func (s *Service) enrichStakeGifts(ctx context.Context, app string, gifts []*gam
|
|||||||
if giftID == "" {
|
if giftID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if gift.GetGiftName() != "" && gift.GetGiftIconUrl() != "" && gift.GetGiftPriceCoin() > 0 {
|
if gift.GetGiftName() != "" && gift.GetGiftIconUrl() != "" && gift.GetGiftAnimationUrl() != "" && gift.GetGiftPriceCoin() > 0 && !roomRPSURLLooksAnimated(gift.GetGiftIconUrl()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := seen[giftID]; ok {
|
if _, ok := seen[giftID]; ok {
|
||||||
@ -604,13 +679,20 @@ func (s *Service) enrichStakeGifts(ctx context.Context, app string, gifts []*gam
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if gift.GiftIconUrl == "" {
|
if gift.GiftIconUrl == "" || roomRPSURLLooksAnimated(gift.GiftIconUrl) {
|
||||||
if iconURL := roomRPSGiftIconURL(item); iconURL != "" {
|
if iconURL := roomRPSGiftIconURL(item); iconURL != "" {
|
||||||
// Flutter 的 PK 面板只消费 gift_icon_url;这里把礼物资源主图折叠成该字段,保持接口契约稳定。
|
// gift_icon_url 只给静态封面;历史配置曾把 animation_url 写进这里,读配置时要用 wallet 资源重新纠正。
|
||||||
gift.GiftIconUrl = iconURL
|
gift.GiftIconUrl = iconURL
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if gift.GiftAnimationUrl == "" {
|
||||||
|
if animationURL := roomRPSGiftAnimationURL(item); animationURL != "" {
|
||||||
|
// 动效单独返回给客户端按需播放,不能再混入 icon 字段,否则列表和选择面板会把 mp4 当图片加载。
|
||||||
|
gift.GiftAnimationUrl = animationURL
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
if gift.GiftPriceCoin <= 0 && item.GetCoinPrice() > 0 {
|
if gift.GiftPriceCoin <= 0 && item.GetCoinPrice() > 0 {
|
||||||
// 创建挑战会把 stake_coin 固化到对局事实,缺币价时必须使用 wallet-service 当前生效礼物价格补齐。
|
// 创建挑战会把 stake_coin 固化到对局事实,缺币价时必须使用 wallet-service 当前生效礼物价格补齐。
|
||||||
gift.GiftPriceCoin = item.GetCoinPrice()
|
gift.GiftPriceCoin = item.GetCoinPrice()
|
||||||
@ -680,16 +762,35 @@ func roomRPSGiftIconURL(gift *walletv1.GiftConfig) string {
|
|||||||
candidates := []string{
|
candidates := []string{
|
||||||
gift.GetResource().GetAssetUrl(),
|
gift.GetResource().GetAssetUrl(),
|
||||||
gift.GetResource().GetPreviewUrl(),
|
gift.GetResource().GetPreviewUrl(),
|
||||||
gift.GetResource().GetAnimationUrl(),
|
|
||||||
}
|
}
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if value := strings.TrimSpace(candidate); value != "" {
|
if value := strings.TrimSpace(candidate); value != "" && !roomRPSURLLooksAnimated(value) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
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 {
|
func (s *Service) publishChallengeEvent(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) error {
|
||||||
if s.pub == nil || challenge == nil {
|
if s.pub == nil || challenge == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -7,12 +7,13 @@ import (
|
|||||||
|
|
||||||
gamev1 "hyapp.local/api/proto/game/v1"
|
gamev1 "hyapp.local/api/proto/game/v1"
|
||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) {
|
func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) {
|
||||||
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
|
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
|
||||||
"2001": roomRPSWalletGift("2001", "Rose", "https://cdn.example.test/rose.png", "", "", 100),
|
"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", "", 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),
|
"2003": roomRPSWalletGift("2003", "Crown", "", "", "https://cdn.example.test/crown.svga", 500),
|
||||||
"2004": roomRPSWalletGift("2004", "Cup", "https://cdn.example.test/cup.png", "", "", 1000),
|
"2004": roomRPSWalletGift("2004", "Cup", "https://cdn.example.test/cup.png", "", "", 1000),
|
||||||
})
|
})
|
||||||
@ -34,14 +35,14 @@ func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) {
|
|||||||
t.Fatalf("UpdateConfig() error = %v", err)
|
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)
|
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" {
|
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 when asset_url is empty: %+v", got)
|
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" {
|
if got := config.GetStakeGifts()[2]; got.GetGiftIconUrl() != "" || got.GetGiftAnimationUrl() != "https://cdn.example.test/crown.svga" {
|
||||||
t.Fatalf("third gift should use animation_url as last display fallback: %+v", got)
|
t.Fatalf("third gift should not use animation_url as display icon: %+v", got)
|
||||||
}
|
}
|
||||||
if len(catalog.requests) != 4 {
|
if len(catalog.requests) != 4 {
|
||||||
t.Fatalf("expected one exact catalog lookup per missing gift, got %d", len(catalog.requests))
|
t.Fatalf("expected one exact catalog lookup per missing gift, got %d", len(catalog.requests))
|
||||||
@ -89,6 +90,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) {
|
func TestCreateChallengeMatchesStringGiftID(t *testing.T) {
|
||||||
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
|
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
|
||||||
"Gifi-3": roomRPSWalletGift("Gifi-3", "Tiger", "https://cdn.example.test/tiger.png", "", "", 1),
|
"Gifi-3": roomRPSWalletGift("Gifi-3", "Tiger", "https://cdn.example.test/tiger.png", "", "", 1),
|
||||||
@ -130,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 {
|
type fakeGiftCatalog struct {
|
||||||
gifts map[string]*walletv1.GiftConfig
|
gifts map[string]*walletv1.GiftConfig
|
||||||
requests []*walletv1.ListGiftConfigsRequest
|
requests []*walletv1.ListGiftConfigsRequest
|
||||||
@ -165,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
|
||||||
|
}
|
||||||
|
|||||||
@ -236,6 +236,17 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY(app_code, game_id)
|
PRIMARY KEY(app_code, game_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) 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 (
|
`CREATE TABLE IF NOT EXISTS game_self_game_pools (
|
||||||
app_code VARCHAR(32) NOT NULL,
|
app_code VARCHAR(32) NOT NULL,
|
||||||
game_id VARCHAR(96) NOT NULL,
|
game_id VARCHAR(96) NOT NULL,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
gamev1 "hyapp.local/api/proto/game/v1"
|
||||||
"hyapp/internal/testutil/mysqlschema"
|
"hyapp/internal/testutil/mysqlschema"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
@ -16,6 +17,64 @@ import (
|
|||||||
gameservice "hyapp/services/game-service/internal/service/game"
|
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) {
|
func TestSelfGameStakePoolStrategyLogAndProtectionState(t *testing.T) {
|
||||||
caller := mysqlschema.CallerFile(t, 1)
|
caller := mysqlschema.CallerFile(t, 1)
|
||||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -129,6 +129,7 @@ type roomRPSStakeGiftData struct {
|
|||||||
GiftIDNumber int64 `json:"gift_id_number"`
|
GiftIDNumber int64 `json:"gift_id_number"`
|
||||||
GiftName string `json:"gift_name"`
|
GiftName string `json:"gift_name"`
|
||||||
GiftIconURL string `json:"gift_icon_url"`
|
GiftIconURL string `json:"gift_icon_url"`
|
||||||
|
GiftAnimationURL string `json:"gift_animation_url"`
|
||||||
GiftPriceCoin int64 `json:"gift_price_coin"`
|
GiftPriceCoin int64 `json:"gift_price_coin"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
SortOrder int32 `json:"sort_order"`
|
SortOrder int32 `json:"sort_order"`
|
||||||
@ -337,6 +338,7 @@ func roomRPSConfigItemDataFromProto(config *gamev1.RoomRPSConfig) roomRPSConfigI
|
|||||||
GiftIDNumber: gift.GetGiftId(),
|
GiftIDNumber: gift.GetGiftId(),
|
||||||
GiftName: gift.GetGiftName(),
|
GiftName: gift.GetGiftName(),
|
||||||
GiftIconURL: gift.GetGiftIconUrl(),
|
GiftIconURL: gift.GetGiftIconUrl(),
|
||||||
|
GiftAnimationURL: gift.GetGiftAnimationUrl(),
|
||||||
GiftPriceCoin: gift.GetGiftPriceCoin(),
|
GiftPriceCoin: gift.GetGiftPriceCoin(),
|
||||||
Enabled: gift.GetEnabled(),
|
Enabled: gift.GetEnabled(),
|
||||||
SortOrder: gift.GetSortOrder(),
|
SortOrder: gift.GetSortOrder(),
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user