package roomrps import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "sort" "strconv" "strings" "sync" "time" "google.golang.org/protobuf/proto" gamev1 "hyapp.local/api/proto/game/v1" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/tencentim" "hyapp/pkg/xerr" ) const ( GameID = "room_rps" StatusPending = "pending" StatusMatched = "matched" StatusRevealing = "revealing" StatusFinished = "finished" StatusTimeout = "timeout" StatusSettlementFailed = "settlement_failed" StatusCancelled = "cancelled" SettlementPending = "pending" SettlementSettled = "settled" SettlementRefunded = "refunded" SettlementFailed = "failed" GestureRock = "rock" GesturePaper = "paper" GestureScissors = "scissors" ResultWin = "win" ResultLose = "lose" ResultDraw = "draw" eventChallengeCreated = "room_rps_challenge_created" eventChallengeAccepted = "room_rps_challenge_accepted" eventRevealCountdown = "room_rps_reveal_countdown" eventFinished = "room_rps_finished" eventExpired = "room_rps_expired" roomRPSWalletPlatform = "room_rps" roomRPSOpDebit = "debit" roomRPSOpRefund = "refund" ) // UserClient 是 room-rps 唯一需要的 user-service 能力;这里只取展示资料,不把用户领域模型引入游戏用例层。 type UserClient interface { GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) } // GiftCatalogClient 只暴露 room-rps 配置补齐需要的礼物配置查询能力;礼物名称、图标和币价仍由 wallet-service 作为 owner 输出。 type GiftCatalogClient interface { ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) } // WalletClient 只暴露 room-rps 需要的钱包能力;PK 状态仍由 game-service 管,金币事实和幂等改账由 wallet-service 管。 type WalletClient interface { ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, 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 PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error } type Config struct { ChallengeTimeout time.Duration RevealCountdown time.Duration } type Service struct { config Config user UserClient gifts GiftCatalogClient wallet WalletClient pub Publisher store ConfigStore mu sync.Mutex configs map[string]*gamev1.RoomRPSConfig challenges map[string]*gamev1.RoomRPSChallenge now func() time.Time } // New 创建房内猜拳用例层。 // 配置已经接入 MySQL 持久化;挑战事实仍是进程内状态,后续独立迁移时继续沿用当前状态机和 RPC 契约。 func New(config Config, user UserClient, publisher Publisher, extras ...any) *Service { if config.ChallengeTimeout <= 0 { // 房内猜拳产品规则是 10 分钟无人应战自动超时;默认打开时必须带稳定超时,避免 pending 永久悬挂。 config.ChallengeTimeout = 10 * time.Minute } if config.RevealCountdown <= 0 { // 揭晓动画固定 3 秒;IM 的 countdown 事件和 Flutter 动画以这个值做时间校准。 config.RevealCountdown = 3 * time.Second } var gifts GiftCatalogClient var wallet WalletClient var store ConfigStore for _, extra := range extras { if typed, ok := extra.(GiftCatalogClient); ok { // room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。 gifts = typed } if typed, ok := extra.(WalletClient); ok { // 发起和应战都必须进入 wallet-service 改账;只做余额查询会让用户看到 PK 成功但金币事实没有变化。 wallet = typed } if typed, ok := extra.(ConfigStore); ok { // MySQL 是配置事实源;没有仓储的测试场景才落回进程内 map,避免把运行态差异写成前端兜底。 store = typed } } return &Service{ config: config, user: user, gifts: gifts, wallet: wallet, pub: publisher, store: store, configs: make(map[string]*gamev1.RoomRPSConfig), challenges: make(map[string]*gamev1.RoomRPSChallenge), now: time.Now, } } // NewTencentIMPublisher 创建真实腾讯 IM publisher。 // game-service 的房内 PK 发起接口是同步返回给客户端的入口,因此 IM 配置必须来自已校验的 YAML,而不是进程环境变量的隐式副作用。 func NewTencentIMPublisher(config tencentim.RESTConfig) (Publisher, error) { return tencentim.NewRESTClient(config) } func (s *Service) GetConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, int64, error) { if s == nil { return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured") } 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) originalUpdatedAtMS := config.GetUpdatedAtMs() s.mu.Unlock() enriched, changed, err := s.enrichConfigGifts(ctx, app, cloned) if err != nil { return nil, 0, err } if changed { s.mu.Lock() // 线上已有旧配置可能只保存 gift_id;读接口补齐成功后同步回内存,后续发起挑战也能使用真实币价和展示快照。 if current := s.configs[app]; current != nil && current.GetUpdatedAtMs() == originalUpdatedAtMS { s.configs[app] = cloneConfig(enriched) } s.mu.Unlock() } return enriched, s.now().UnixMilli(), nil } func (s *Service) UpdateConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, int64, error) { if s == nil { return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured") } app = appcode.Normalize(app) ctx = appcode.WithContext(ctx, app) normalized, err := s.normalizeConfig(ctx, app, config) 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() if existing := s.configs[app]; existing != nil && existing.GetCreatedAtMs() > 0 { normalized.CreatedAtMs = existing.GetCreatedAtMs() } else { normalized.CreatedAtMs = nowMS } normalized.UpdatedAtMs = nowMS s.configs[app] = normalized return cloneConfig(normalized), nowMS, nil } func (s *Service) ListChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) ([]*gamev1.RoomRPSChallenge, string, int32, int64, error) { if s == nil { return nil, "", 0, 0, xerr.New(xerr.Unavailable, "room rps service is not configured") } app := appcode.Normalize(req.GetMeta().GetAppCode()) ctx = appcode.WithContext(ctx, app) pageSize := req.GetPageSize() if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { // 后台列表和 App 房间补拉都走同一接口;这里硬限制每页 100,防止一次请求把进程内状态全量复制出去。 pageSize = 100 } cursorOffset := 0 if raw := strings.TrimSpace(req.GetCursor()); raw != "" { parsed, err := strconv.Atoi(raw) if err != nil || parsed < 0 { return nil, "", 0, 0, xerr.New(xerr.InvalidArgument, "room rps cursor is invalid") } cursorOffset = parsed } s.mu.Lock() defer s.mu.Unlock() status := normalizeStatus(req.GetStatus()) items := make([]*gamev1.RoomRPSChallenge, 0, len(s.challenges)) for _, item := range s.challenges { if item.GetAppCode() != app { continue } if roomID := strings.TrimSpace(req.GetRoomId()); roomID != "" && item.GetRoomId() != roomID { continue } if status != "" && item.GetStatus() != status { continue } if req.GetInitiatorUserId() > 0 && item.GetInitiator().GetUserId() != req.GetInitiatorUserId() { continue } if req.GetChallengerUserId() > 0 && item.GetChallenger().GetUserId() != req.GetChallengerUserId() { continue } if req.GetStartTimeMs() > 0 && item.GetCreatedAtMs() < req.GetStartTimeMs() { continue } if req.GetEndTimeMs() > 0 && item.GetCreatedAtMs() > req.GetEndTimeMs() { continue } items = append(items, cloneChallenge(item)) } sort.Slice(items, func(i, j int) bool { if items[i].GetCreatedAtMs() == items[j].GetCreatedAtMs() { return items[i].GetChallengeId() > items[j].GetChallengeId() } return items[i].GetCreatedAtMs() > items[j].GetCreatedAtMs() }) if cursorOffset >= len(items) { return []*gamev1.RoomRPSChallenge{}, "", pageSize, s.now().UnixMilli(), nil } end := cursorOffset + int(pageSize) nextCursor := "" if end < len(items) { nextCursor = strconv.Itoa(end) } else { end = len(items) } return items[cursorOffset:end], nextCursor, pageSize, s.now().UnixMilli(), nil } func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallenge, int64, error) { if s == nil { return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured") } app := appcode.Normalize(req.GetMeta().GetAppCode()) ctx = appcode.WithContext(ctx, app) roomID := strings.TrimSpace(req.GetRoomId()) gesture, ok := normalizeGesture(req.GetGesture()) giftID := roomRPSRequestGiftID(req) if req.GetUserId() <= 0 || roomID == "" || giftID == "" || !ok { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge command is incomplete") } config, err := s.runtimeConfig(ctx, app) if err != nil { return nil, 0, err } if config.GetStatus() != "active" { return nil, 0, xerr.New(xerr.Conflict, "room rps config is disabled") } gift, ok := stakeGiftByID(config, giftID) if !ok || !gift.GetEnabled() { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled") } stakeCoin := gift.GetGiftPriceCoin() if stakeCoin <= 0 { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid") } challengeID := newChallengeID(app) initiatorBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit) if err != nil { return nil, 0, err } s.mu.Lock() now := s.now() nowMS := now.UnixMilli() challenge := &gamev1.RoomRPSChallenge{ AppCode: app, ChallengeId: challengeID, RoomId: roomID, RegionId: req.GetRegionId(), Status: StatusPending, StakeGiftId: legacyRoomRPSGiftID(giftID), StakeGiftIdText: giftID, StakeCoin: stakeCoin, Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: initiatorBalance, JoinedAtMs: nowMS}, SettlementStatus: SettlementPending, TimeoutAtMs: now.Add(s.config.ChallengeTimeout).UnixMilli(), CreatedAtMs: nowMS, UpdatedAtMs: nowMS, } s.challenges[challenge.GetChallengeId()] = challenge cloned := cloneChallenge(challenge) s.mu.Unlock() if err := s.publishChallengeEvent(ctx, eventChallengeCreated, cloned); err != nil { return nil, 0, err } return cloned, nowMS, nil } func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallenge, int64, error) { if s == nil { return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured") } app := appcode.Normalize(req.GetMeta().GetAppCode()) ctx = appcode.WithContext(ctx, app) challengeID := strings.TrimSpace(req.GetChallengeId()) gesture, ok := normalizeGesture(req.GetGesture()) if req.GetUserId() <= 0 || challengeID == "" || !ok { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps accept command is incomplete") } s.mu.Lock() challenge, ok := s.challenges[challengeID] if !ok || challenge.GetAppCode() != app { s.mu.Unlock() return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found") } now := s.now() nowMS := now.UnixMilli() if challenge.GetStatus() != StatusPending { // pending 是唯一可应战状态;matched/revealing/finished/timeout 都已经有明确归属,重复点击只能返回冲突。 s.mu.Unlock() return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending") } if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() { // 读到已超时但还未收敛的 pending 时,先在锁内改成 timeout,避免下一个用户还能应战同一单。 challenge.Status = StatusTimeout challenge.SettlementStatus = SettlementRefunded challenge.UpdatedAtMs = nowMS cloned := cloneChallenge(challenge) s.mu.Unlock() refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned) if refundErr != nil { s.markSettlementFailed(app, challengeID, refundErr) return nil, 0, refundErr } s.replaceChallengeSnapshot(app, challengeID, refunded) cloned = refunded _ = s.publishChallengeEvent(ctx, eventExpired, cloned) return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout") } if challenge.GetInitiator().GetUserId() == req.GetUserId() { // 发起人不能应战自己的单;这个判断必须在 service 锁内做,不能依赖前端按钮状态。 s.mu.Unlock() return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") } stakeCoin := challenge.GetStakeCoin() roomID := challenge.GetRoomId() s.mu.Unlock() challengerBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit) if err != nil { return nil, 0, err } s.mu.Lock() challenge, ok = s.challenges[challengeID] if !ok || challenge.GetAppCode() != app { s.mu.Unlock() _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found") } now = s.now() nowMS = now.UnixMilli() if challenge.GetStatus() != StatusPending { // 钱包扣款期间可能已有其他用户抢先应战;状态机必须以锁内二次校验为准,失败时立即退回刚扣的挑战方本金。 s.mu.Unlock() _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending") } if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() { // 二次校验也要收敛超时单,避免扣款耗时跨过 timeout 后仍然匹配成功。 challenge.Status = StatusTimeout challenge.SettlementStatus = SettlementRefunded challenge.UpdatedAtMs = nowMS cloned := cloneChallenge(challenge) s.mu.Unlock() _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned) if refundErr != nil { s.markSettlementFailed(app, challengeID, refundErr) return nil, 0, refundErr } s.replaceChallengeSnapshot(app, challengeID, refunded) cloned = refunded _ = s.publishChallengeEvent(ctx, eventExpired, cloned) return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout") } if challenge.GetInitiator().GetUserId() == req.GetUserId() { // 二次校验保留同一条权限边界,防止测试或未来接口在第一次检查后篡改挑战归属。 s.mu.Unlock() _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") } challenge.Status = StatusFinished challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: challengerBalance, JoinedAtMs: nowMS} challenge.MatchedAtMs = nowMS challenge.RevealAtMs = now.Add(s.config.RevealCountdown).UnixMilli() challenge.SettledAtMs = nowMS challenge.UpdatedAtMs = nowMS applyResult(challenge) cloned := cloneChallenge(challenge) s.mu.Unlock() settled, err := s.settleChallenge(ctx, req.GetMeta().GetRequestId(), cloned) if err != nil { s.mu.Lock() if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app { current.Status = StatusSettlementFailed current.SettlementStatus = SettlementFailed current.FailureReason = err.Error() current.UpdatedAtMs = s.now().UnixMilli() } s.mu.Unlock() return nil, 0, err } s.mu.Lock() if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app { current.Initiator.BalanceAfter = settled.GetInitiator().GetBalanceAfter() current.Challenger.BalanceAfter = settled.GetChallenger().GetBalanceAfter() current.SettlementStatus = settled.GetSettlementStatus() current.FailureReason = "" current.SettledAtMs = s.now().UnixMilli() current.UpdatedAtMs = current.GetSettledAtMs() cloned = cloneChallenge(current) } else { cloned = settled } s.mu.Unlock() if err := s.publishChallengeEvent(ctx, eventChallengeAccepted, cloned); err != nil { return nil, 0, err } if err := s.publishChallengeEvent(ctx, eventRevealCountdown, cloned); err != nil { return nil, 0, err } if err := s.publishChallengeEvent(ctx, eventFinished, cloned); err != nil { return nil, 0, err } return cloned, nowMS, nil } func (s *Service) GetChallenge(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) { if s == nil { return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured") } app = appcode.Normalize(app) ctx = appcode.WithContext(ctx, app) challengeID = strings.TrimSpace(challengeID) if challengeID == "" { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required") } s.mu.Lock() defer s.mu.Unlock() challenge, ok := s.challenges[challengeID] if !ok || challenge.GetAppCode() != app { return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found") } return cloneChallenge(challenge), s.now().UnixMilli(), nil } func (s *Service) RetrySettlement(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) { if s == nil { return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured") } app = appcode.Normalize(app) ctx = appcode.WithContext(ctx, app) challengeID = strings.TrimSpace(challengeID) if challengeID == "" { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required") } s.mu.Lock() challenge, ok := s.challenges[challengeID] if !ok || challenge.GetAppCode() != app { s.mu.Unlock() return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found") } if challenge.GetStatus() != StatusSettlementFailed && challenge.GetSettlementStatus() != SettlementFailed { // 只有结算失败单能进入重试;已结算、已退款、pending 或 timeout 都不能被后台按钮强制改结果。 s.mu.Unlock() return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not settlement_failed") } nowMS := s.now().UnixMilli() challenge.Status = StatusFinished challenge.SettlementStatus = SettlementSettled challenge.FailureReason = "" challenge.SettledAtMs = nowMS challenge.UpdatedAtMs = nowMS cloned := cloneChallenge(challenge) s.mu.Unlock() if err := s.publishChallengeEvent(ctx, eventFinished, cloned); err != nil { return nil, 0, err } return cloned, nowMS, nil } func (s *Service) ExpireChallenge(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) { if s == nil { return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured") } app = appcode.Normalize(app) ctx = appcode.WithContext(ctx, app) challengeID = strings.TrimSpace(challengeID) if challengeID == "" { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required") } s.mu.Lock() challenge, ok := s.challenges[challengeID] if !ok || challenge.GetAppCode() != app { s.mu.Unlock() return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found") } if challenge.GetStatus() != StatusPending || challenge.GetChallenger().GetUserId() > 0 { // 手动过期只允许无人应战的 pending 单;一旦有 challenger,就必须走揭晓/结算链路,不能后台撤销。 s.mu.Unlock() return nil, 0, xerr.New(xerr.Conflict, "room rps challenge can not expire") } nowMS := s.now().UnixMilli() challenge.Status = StatusTimeout challenge.SettlementStatus = SettlementRefunded challenge.UpdatedAtMs = nowMS cloned := cloneChallenge(challenge) s.mu.Unlock() refunded, err := s.refundPendingChallenge(ctx, "room-rps-expire:"+challengeID, cloned) if err != nil { s.markSettlementFailed(app, challengeID, err) return nil, 0, err } s.replaceChallengeSnapshot(app, challengeID, refunded) cloned = refunded if err := s.publishChallengeEvent(ctx, eventExpired, cloned); err != nil { return nil, 0, err } 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 } nowMS := s.now().UnixMilli() config := defaultConfig(app, s.config.ChallengeTimeout, s.config.RevealCountdown, nowMS) s.configs[app] = config return config } func (s *Service) normalizeConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) { if config == nil { return nil, xerr.New(xerr.InvalidArgument, "room rps config is required") } status := normalizeStatus(config.GetStatus()) if status == "" { // 逻辑功能配置默认打开;后台未传 status 时按 active 保存,不能静默写成关闭。 status = "active" } if status != "active" && status != "disabled" { return nil, xerr.New(xerr.InvalidArgument, "room rps status is invalid") } timeoutMS := config.GetChallengeTimeoutMs() if timeoutMS <= 0 { timeoutMS = int64(s.config.ChallengeTimeout / time.Millisecond) } revealMS := config.GetRevealCountdownMs() if revealMS <= 0 { revealMS = int64(s.config.RevealCountdown / time.Millisecond) } if len(config.GetStakeGifts()) != 4 { return nil, xerr.New(xerr.InvalidArgument, "room rps requires exactly 4 stake gifts") } defaultsByID := defaultGiftMap() gifts := make([]*gamev1.RoomRPSStakeGift, 0, 4) seenGift := make(map[string]struct{}, 4) seenSort := make(map[int32]struct{}, 4) for index, gift := range config.GetStakeGifts() { giftID := roomRPSStakeGiftID(gift) if giftID == "" { return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is invalid") } if _, ok := seenGift[giftID]; ok { return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is duplicated") } sortOrder := gift.GetSortOrder() if sortOrder <= 0 { sortOrder = int32(index + 1) } if _, ok := seenSort[sortOrder]; ok { return nil, xerr.New(xerr.InvalidArgument, "room rps gift sort_order is duplicated") } seenGift[giftID] = struct{}{} seenSort[sortOrder] = struct{}{} normalized := &gamev1.RoomRPSStakeGift{ 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) } if _, err := s.enrichStakeGifts(ctx, app, gifts); err != nil { return nil, err } for _, normalized := range gifts { if fallback := defaultsByID[roomRPSStakeGiftID(normalized)]; fallback != nil { if normalized.GiftName == "" { normalized.GiftName = fallback.GetGiftName() } if normalized.GiftIconUrl == "" { normalized.GiftIconUrl = fallback.GetGiftIconUrl() } if normalized.GiftPriceCoin <= 0 { normalized.GiftPriceCoin = fallback.GetGiftPriceCoin() } } if normalized.GiftPriceCoin <= 0 { // 运行时必须知道当前档位折算金币,后续接钱包托管也以这个金额作为订单事实。 normalized.GiftPriceCoin = int64(normalized.GetSortOrder()) * 100 } } sort.Slice(gifts, func(i, j int) bool { return gifts[i].GetSortOrder() < gifts[j].GetSortOrder() }) return &gamev1.RoomRPSConfig{ AppCode: app, GameId: GameID, Status: status, ChallengeTimeoutMs: timeoutMS, RevealCountdownMs: revealMS, StakeGifts: gifts, }, nil } func (s *Service) enrichConfigGifts(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, bool, error) { if config == nil { return nil, false, nil } changed, err := s.enrichStakeGifts(ctx, app, config.GetStakeGifts()) if err != nil { return nil, false, err } return config, changed, nil } func (s *Service) enrichStakeGifts(ctx context.Context, app string, gifts []*gamev1.RoomRPSStakeGift) (bool, error) { if len(gifts) == 0 { return false, nil } needGiftIDs := make([]string, 0, len(gifts)) seen := make(map[string]struct{}, len(gifts)) for _, gift := range gifts { giftID := roomRPSStakeGiftID(gift) if giftID == "" { continue } if gift.GetGiftName() != "" && gift.GetGiftIconUrl() != "" && gift.GetGiftAnimationUrl() != "" && gift.GetGiftPriceCoin() > 0 && !roomRPSURLLooksAnimated(gift.GetGiftIconUrl()) { continue } if _, ok := seen[giftID]; ok { continue } // 只查询缺字段的档位,避免每次读配置都把 wallet-service 当成全量礼物列表使用。 seen[giftID] = struct{}{} needGiftIDs = append(needGiftIDs, giftID) } if len(needGiftIDs) == 0 { return false, nil } catalog, err := s.roomRPSGiftCatalog(ctx, app, needGiftIDs) if err != nil { return false, err } changed := false for _, gift := range gifts { if gift == nil { continue } item := catalog[roomRPSStakeGiftID(gift)] if item == nil { continue } if gift.GiftName == "" { if name := roomRPSGiftDisplayName(item); name != "" { // 后台保存 room-rps 时只提交 gift_id;运行时展示名必须来自 wallet-service 礼物配置,不能让前端猜。 gift.GiftName = name changed = true } } if gift.GiftIconUrl == "" || roomRPSURLLooksAnimated(gift.GiftIconUrl) { if iconURL := roomRPSGiftIconURL(item); iconURL != "" { // 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() changed = true } } return changed, nil } func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs []string) (map[string]*walletv1.GiftConfig, error) { result := make(map[string]*walletv1.GiftConfig, len(giftIDs)) if s.gifts == nil { return result, nil } app = appcode.Normalize(app) for _, giftID := range giftIDs { giftID = strings.TrimSpace(giftID) if giftID == "" { continue } for page := int32(1); ; page++ { resp, err := s.gifts.ListGiftConfigs(ctx, &walletv1.ListGiftConfigsRequest{ RequestId: fmt.Sprintf("room-rps-config:%s:%s:%d", app, giftID, page), AppCode: app, Keyword: giftID, Page: page, PageSize: 100, }) if err != nil { return nil, xerr.New(xerr.Unavailable, "room rps gift config lookup failed") } for _, item := range resp.GetGifts() { if strings.TrimSpace(item.GetGiftId()) != giftID { continue } result[giftID] = item break } if result[giftID] != nil { break } if len(resp.GetGifts()) < 100 { break } if total := resp.GetTotal(); total > 0 && int64(page)*100 >= total { break } } } return result, nil } func (s *Service) settleChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) { if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetChallenger().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 { return nil, xerr.New(xerr.InvalidArgument, "room rps settlement challenge is incomplete") } settled := cloneChallenge(challenge) app := settled.GetAppCode() challengeID := settled.GetChallengeId() roomID := settled.GetRoomId() stakeCoin := settled.GetStakeCoin() if settled.GetInitiator().GetResult() == ResultDraw && settled.GetChallenger().GetResult() == ResultDraw { // 平局没有赢家,也不应产生任何礼物流;双方前置扣款都按原金额退款,客户端只展示 refunded 事实。 initiatorBalance, initiatorErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund) if initiatorErr == nil { settled.Initiator.BalanceAfter = initiatorBalance } challengerBalance, challengerErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund) if challengerErr == nil { settled.Challenger.BalanceAfter = challengerBalance } if initiatorErr != nil { return settled, initiatorErr } if challengerErr != nil { return settled, challengerErr } settled.SettlementStatus = SettlementRefunded return settled, nil } switch settled.GetWinnerUserId() { case settled.GetInitiator().GetUserId(): // 胜者本金必须退回;败者在点击 PK 时已经扣款,这笔扣款保留为败者成本,不再追加第二次扣费。 balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund) if err != nil { return settled, err } settled.Initiator.BalanceAfter = balanceAfter settled.SettlementStatus = SettlementSettled return settled, nil case settled.GetChallenger().GetUserId(): // 只退赢家本金;输家的余额保持前置扣款后的余额,避免“输了但金币没少”的账务错误。 balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund) if err != nil { return settled, err } settled.Challenger.BalanceAfter = balanceAfter settled.SettlementStatus = SettlementSettled return settled, nil default: return settled, xerr.New(xerr.InvalidArgument, "room rps winner is invalid") } } func (s *Service) refundPendingChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) { if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 { return nil, xerr.New(xerr.InvalidArgument, "room rps pending refund challenge is incomplete") } refunded := cloneChallenge(challenge) // 无人应战或已超时的 pending 单只存在发起人前置扣款;过期时退回发起人,不产生礼物或赢家收益。 balanceAfter, err := s.applyCoinChange(ctx, requestID, refunded.GetAppCode(), refunded.GetChallengeId(), refunded.GetRoomId(), refunded.GetInitiator().GetUserId(), refunded.GetStakeCoin(), roomRPSOpRefund) if err != nil { return refunded, err } refunded.Initiator.BalanceAfter = balanceAfter refunded.SettlementStatus = SettlementRefunded refunded.FailureReason = "" refunded.UpdatedAtMs = s.now().UnixMilli() return refunded, nil } func (s *Service) replaceChallengeSnapshot(app string, challengeID string, snapshot *gamev1.RoomRPSChallenge) { if s == nil || snapshot == nil { return } s.mu.Lock() defer s.mu.Unlock() current := s.challenges[strings.TrimSpace(challengeID)] if current == nil || current.GetAppCode() != appcode.Normalize(app) { return } s.challenges[strings.TrimSpace(challengeID)] = cloneChallenge(snapshot) } func (s *Service) markSettlementFailed(app string, challengeID string, cause error) { if s == nil || cause == nil { return } s.mu.Lock() defer s.mu.Unlock() current := s.challenges[strings.TrimSpace(challengeID)] if current == nil || current.GetAppCode() != appcode.Normalize(app) { return } current.Status = StatusSettlementFailed current.SettlementStatus = SettlementFailed current.FailureReason = cause.Error() current.UpdatedAtMs = s.now().UnixMilli() } func (s *Service) applyCoinChange(ctx context.Context, requestID string, app string, challengeID string, roomID string, userID int64, amount int64, opType string) (int64, error) { if amount <= 0 || userID <= 0 || strings.TrimSpace(challengeID) == "" { return 0, xerr.New(xerr.InvalidArgument, "room rps coin command is incomplete") } if s.wallet == nil { // 钱包不可用时必须拒绝进入 PK;否则房间状态会成功但 COIN 事实无法同步落账。 return 0, xerr.New(xerr.Unavailable, "room rps wallet client is not configured") } app = appcode.Normalize(app) opType = strings.TrimSpace(opType) providerOrderID := fmt.Sprintf("room_rps:%s:%d:%s", strings.TrimSpace(challengeID), userID, opType) requestHash := roomRPSStableHash(fmt.Sprintf("%s|%s|%s|%s|%d|%d", app, strings.TrimSpace(challengeID), strings.TrimSpace(roomID), opType, userID, amount)) resp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{ RequestId: strings.TrimSpace(requestID), AppCode: app, CommandId: "game:" + providerOrderID, UserId: userID, PlatformCode: roomRPSWalletPlatform, GameId: GameID, ProviderOrderId: providerOrderID, ProviderRoundId: strings.TrimSpace(challengeID), OpType: opType, CoinAmount: amount, RoomId: strings.TrimSpace(roomID), RequestHash: requestHash, }) if err != nil { return 0, err } return resp.GetBalanceAfter(), nil } func roomRPSStableHash(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) } func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string { if gift == nil { return "" } if name := strings.TrimSpace(gift.GetName()); name != "" { return name } return strings.TrimSpace(gift.GetResource().GetName()) } func roomRPSGiftIconURL(gift *walletv1.GiftConfig) string { if gift == nil || gift.GetResource() == nil { return "" } candidates := []string{ gift.GetResource().GetAssetUrl(), gift.GetResource().GetPreviewUrl(), } for _, candidate := range candidates { if value := strings.TrimSpace(candidate); value != "" && !roomRPSURLLooksAnimated(value) { return value } } 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 } // 每次发业务 IM 前都幂等建群;真实腾讯云已存在返回成功,临时房间或测试群也不需要额外预置。 if err := s.pub.EnsureGroup(ctx, tencentim.GroupSpec{ GroupID: challenge.GetRoomId(), Name: challenge.GetRoomId(), Type: tencentim.DefaultGroupType, ApplyJoinOption: "FreeAccess", }); err != nil { return xerr.New(xerr.Unavailable, "room rps im group ensure failed") } payload, err := s.challengeEventPayload(ctx, eventType, challenge) if err != nil { return err } eventID := fmt.Sprintf("%s:%s:%d", eventType, challenge.GetChallengeId(), s.now().UnixNano()) if err := s.pub.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{ GroupID: challenge.GetRoomId(), EventID: eventID, Desc: eventType, Ext: "room_rps", PayloadJSON: payload, }); err != nil { return xerr.New(xerr.Unavailable, "room rps im publish failed") } return nil } func (s *Service) challengeEventPayload(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) (json.RawMessage, error) { nowMS := s.now().UnixMilli() payload := map[string]any{ "event_id": fmt.Sprintf("%s:%s:%d", eventType, challenge.GetChallengeId(), nowMS), "event_type": eventType, "room_id": challenge.GetRoomId(), "challenge_id": challenge.GetChallengeId(), "server_time_ms": nowMS, "challenge": s.challengeMap(ctx, challenge), } raw, err := json.Marshal(payload) if err != nil { return nil, xerr.New(xerr.Internal, "room rps im payload marshal failed") } return raw, nil } func (s *Service) challengeMap(ctx context.Context, challenge *gamev1.RoomRPSChallenge) map[string]any { return map[string]any{ "app_code": challenge.GetAppCode(), "challenge_id": challenge.GetChallengeId(), "room_id": challenge.GetRoomId(), "region_id": challenge.GetRegionId(), "status": challenge.GetStatus(), "stake_gift_id": roomRPSChallengeGiftID(challenge), "stake_gift_id_number": challenge.GetStakeGiftId(), "stake_coin": challenge.GetStakeCoin(), "initiator": s.playerMap(ctx, challenge.GetAppCode(), challenge.GetInitiator()), "challenger": s.playerMap(ctx, challenge.GetAppCode(), challenge.GetChallenger()), "winner_user_id": strconv.FormatInt(challenge.GetWinnerUserId(), 10), "settlement_status": challenge.GetSettlementStatus(), "failure_reason": challenge.GetFailureReason(), "timeout_at_ms": challenge.GetTimeoutAtMs(), "reveal_at_ms": challenge.GetRevealAtMs(), "created_at_ms": challenge.GetCreatedAtMs(), "matched_at_ms": challenge.GetMatchedAtMs(), "settled_at_ms": challenge.GetSettledAtMs(), "updated_at_ms": challenge.GetUpdatedAtMs(), } } func (s *Service) playerMap(ctx context.Context, app string, player *gamev1.RoomRPSPlayer) map[string]any { if player == nil || player.GetUserId() <= 0 { return map[string]any{} } user := s.displayUser(ctx, app, player.GetUserId()) return map[string]any{ "user_id": strconv.FormatInt(player.GetUserId(), 10), "user_id_number": player.GetUserId(), "gesture": player.GetGesture(), "result": player.GetResult(), "balance_after": player.GetBalanceAfter(), "joined_at_ms": player.GetJoinedAtMs(), "user": user, } } func (s *Service) displayUser(ctx context.Context, app string, userID int64) map[string]any { user := map[string]any{ "user_id": strconv.FormatInt(userID, 10), } if s.user == nil || userID <= 0 { return user } resp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{ Meta: &userv1.RequestMeta{AppCode: appcode.Normalize(app), Caller: "game-service-room-rps", SentAtMs: s.now().UnixMilli()}, UserId: userID, }) if err != nil || resp.GetUser() == nil { // IM 资料补齐不能反向改挑战事实;失败时保留 user_id,HTTP 详情仍可由 gateway 再查一次资料。 return user } user["display_user_id"] = resp.GetUser().GetDisplayUserId() user["nickname"] = resp.GetUser().GetUsername() user["avatar"] = resp.GetUser().GetAvatar() return user } func defaultConfig(app string, timeout time.Duration, reveal time.Duration, nowMS int64) *gamev1.RoomRPSConfig { return &gamev1.RoomRPSConfig{ AppCode: app, GameId: GameID, Status: "active", ChallengeTimeoutMs: int64(timeout / time.Millisecond), RevealCountdownMs: int64(reveal / time.Millisecond), StakeGifts: defaultGifts(), CreatedAtMs: nowMS, UpdatedAtMs: nowMS, } } func defaultGifts() []*gamev1.RoomRPSStakeGift { return []*gamev1.RoomRPSStakeGift{ {GiftId: 10001, GiftIdText: "10001", GiftName: "RPS 100", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-100.png", GiftPriceCoin: 100, Enabled: true, SortOrder: 1}, {GiftId: 10002, GiftIdText: "10002", GiftName: "RPS 300", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-300.png", GiftPriceCoin: 300, Enabled: true, SortOrder: 2}, {GiftId: 10003, GiftIdText: "10003", GiftName: "RPS 500", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-500.png", GiftPriceCoin: 500, Enabled: true, SortOrder: 3}, {GiftId: 10004, GiftIdText: "10004", GiftName: "RPS 1000", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-1000.png", GiftPriceCoin: 1000, Enabled: true, SortOrder: 4}, } } func defaultGiftMap() map[string]*gamev1.RoomRPSStakeGift { out := make(map[string]*gamev1.RoomRPSStakeGift, 4) for _, gift := range defaultGifts() { out[roomRPSStakeGiftID(gift)] = gift } return out } func stakeGiftByID(config *gamev1.RoomRPSConfig, giftID string) (*gamev1.RoomRPSStakeGift, bool) { giftID = strings.TrimSpace(giftID) for _, gift := range config.GetStakeGifts() { if roomRPSStakeGiftID(gift) == giftID { return gift, true } } return nil, false } func roomRPSStakeGiftID(gift *gamev1.RoomRPSStakeGift) string { if gift == nil { return "" } if text := strings.TrimSpace(gift.GetGiftIdText()); text != "" { return text } if gift.GetGiftId() > 0 { return strconv.FormatInt(gift.GetGiftId(), 10) } return "" } func roomRPSRequestGiftID(req *gamev1.CreateRoomRPSChallengeRequest) string { if req == nil { return "" } if text := strings.TrimSpace(req.GetGiftIdText()); text != "" { return text } if req.GetGiftId() > 0 { return strconv.FormatInt(req.GetGiftId(), 10) } return "" } func roomRPSChallengeGiftID(challenge *gamev1.RoomRPSChallenge) string { if challenge == nil { return "" } if text := strings.TrimSpace(challenge.GetStakeGiftIdText()); text != "" { return text } if challenge.GetStakeGiftId() > 0 { return strconv.FormatInt(challenge.GetStakeGiftId(), 10) } return "" } func legacyRoomRPSGiftID(giftID string) int64 { value, err := strconv.ParseInt(strings.TrimSpace(giftID), 10, 64) if err != nil || value <= 0 { return 0 } return value } func applyResult(challenge *gamev1.RoomRPSChallenge) { initiatorGesture := challenge.GetInitiator().GetGesture() challengerGesture := challenge.GetChallenger().GetGesture() switch { case initiatorGesture == challengerGesture: challenge.Initiator.Result = ResultDraw challenge.Challenger.Result = ResultDraw challenge.WinnerUserId = 0 challenge.SettlementStatus = SettlementRefunded case beats(initiatorGesture, challengerGesture): challenge.Initiator.Result = ResultWin challenge.Challenger.Result = ResultLose challenge.WinnerUserId = challenge.GetInitiator().GetUserId() challenge.SettlementStatus = SettlementSettled default: challenge.Initiator.Result = ResultLose challenge.Challenger.Result = ResultWin challenge.WinnerUserId = challenge.GetChallenger().GetUserId() challenge.SettlementStatus = SettlementSettled } } func beats(left string, right string) bool { return (left == GestureRock && right == GestureScissors) || (left == GesturePaper && right == GestureRock) || (left == GestureScissors && right == GesturePaper) } func normalizeGesture(value string) (string, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case GestureRock: return GestureRock, true case GesturePaper: return GesturePaper, true case GestureScissors: return GestureScissors, true default: return "", false } } func normalizeStatus(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "", "all": return "" case StatusPending, StatusMatched, StatusRevealing, StatusFinished, StatusTimeout, StatusSettlementFailed, StatusCancelled, "active", "disabled": return strings.ToLower(strings.TrimSpace(value)) default: return strings.ToLower(strings.TrimSpace(value)) } } func cloneConfig(config *gamev1.RoomRPSConfig) *gamev1.RoomRPSConfig { if config == nil { return nil } // protobuf message 内部带锁状态,不能用结构体赋值浅拷贝;proto.Clone 会复制业务字段并重建 message state。 cloned, ok := proto.Clone(config).(*gamev1.RoomRPSConfig) if !ok { return nil } return cloned } func cloneChallenge(challenge *gamev1.RoomRPSChallenge) *gamev1.RoomRPSChallenge { if challenge == nil { return nil } // 对局快照同样必须走 protobuf 深拷贝,避免 vet copylocks 和嵌套玩家资料被调用方意外共享。 cloned, ok := proto.Clone(challenge).(*gamev1.RoomRPSChallenge) if !ok { return nil } return cloned } func newChallengeID(app string) string { var entropy [8]byte if _, err := rand.Read(entropy[:]); err != nil { return fmt.Sprintf("rps_%s_%d", appcode.Normalize(app), time.Now().UnixNano()) } return fmt.Sprintf("rps_%s_%d_%s", appcode.Normalize(app), time.Now().UnixMilli(), hex.EncodeToString(entropy[:])) }