Merge branch 'test' of gitea.haiyihy.com:hy/hyapp-server into test

This commit is contained in:
zhx 2026-06-16 17:00:09 +08:00
commit 15312f498b
8 changed files with 441 additions and 11 deletions

View File

@ -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',

View File

@ -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='房内猜拳配置表';

View File

@ -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)

View File

@ -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测试可以注入 fakeservice 不感知具体 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_msservice 每次提交只表达“这个时刻产生了新配置版本”。
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

View File

@ -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
}

View File

@ -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,

View File

@ -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{

View File

@ -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
}