package roomrps import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "os" "sort" "strconv" "strings" "sync" "time" "google.golang.org/protobuf/proto" gamev1 "hyapp.local/api/proto/game/v1" userv1 "hyapp.local/api/proto/user/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" ) // UserClient 是 room-rps 唯一需要的 user-service 能力;这里只取展示资料,不把用户领域模型引入游戏用例层。 type UserClient interface { GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, 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 pub Publisher mu sync.Mutex configs map[string]*gamev1.RoomRPSConfig challenges map[string]*gamev1.RoomRPSChallenge now func() time.Time } // New 创建房内猜拳用例层。 // 当前阶段先把真实 HTTP、后台和 IM 事件跑通;挑战事实用进程内状态承接,后续接 MySQL 时保持同一套状态机和 RPC 契约即可。 func New(config Config, user UserClient, publisher Publisher) *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 } return &Service{ config: config, user: user, pub: publisher, configs: make(map[string]*gamev1.RoomRPSConfig), challenges: make(map[string]*gamev1.RoomRPSChallenge), now: time.Now, } } // NewTencentIMPublisherFromEnv 按环境变量创建真实腾讯 IM publisher。 // 密钥、账号和地域入口都属于敏感或部署差异配置,因此这里只读取环境,不在 game-service 默认配置里伪造。 func NewTencentIMPublisherFromEnv() (Publisher, error) { sdkAppID, _ := strconv.ParseInt(strings.TrimSpace(os.Getenv("TENCENT_IM_SDK_APP_ID")), 10, 64) secretKey := strings.TrimSpace(os.Getenv("TENCENT_IM_SECRET_KEY")) adminIdentifier := strings.TrimSpace(os.Getenv("TENCENT_IM_ADMIN_IDENTIFIER")) if sdkAppID <= 0 || secretKey == "" || adminIdentifier == "" { // 未配置 IM 时不阻断本地服务启动;真实 IM smoke 会显式带环境变量,并要求事件投递成功。 return nil, nil } ttl := time.Hour if raw := strings.TrimSpace(os.Getenv("TENCENT_IM_ADMIN_USERSIG_TTL")); raw != "" { parsed, err := time.ParseDuration(raw) if err != nil || parsed <= 0 { return nil, fmt.Errorf("TENCENT_IM_ADMIN_USERSIG_TTL is invalid") } ttl = parsed } return tencentim.NewRESTClient(tencentim.RESTConfig{ SDKAppID: sdkAppID, SecretKey: secretKey, AdminIdentifier: adminIdentifier, AdminUserSigTTL: ttl, Endpoint: envOr("TENCENT_IM_ENDPOINT", tencentim.DefaultEndpoint), GroupType: tencentim.DefaultGroupType, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }) } 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) s.mu.Lock() defer s.mu.Unlock() config := s.configForAppLocked(app) return cloneConfig(config), 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(app, config) if err != nil { return nil, 0, err } 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 { 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()) if req.GetUserId() <= 0 || roomID == "" || req.GetGiftId() <= 0 || !ok { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge command is incomplete") } s.mu.Lock() config := s.configForAppLocked(app) if config.GetStatus() != "active" { s.mu.Unlock() return nil, 0, xerr.New(xerr.Conflict, "room rps config is disabled") } gift, ok := stakeGiftByID(config, req.GetGiftId()) if !ok || !gift.GetEnabled() { s.mu.Unlock() return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled") } now := s.now() nowMS := now.UnixMilli() challenge := &gamev1.RoomRPSChallenge{ AppCode: app, ChallengeId: newChallengeID(app), RoomId: roomID, RegionId: req.GetRegionId(), Status: StatusPending, StakeGiftId: gift.GetGiftId(), StakeCoin: gift.GetGiftPriceCoin(), Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, 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() _ = 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") } challenge.Status = StatusFinished challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, 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() 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() if err := s.publishChallengeEvent(ctx, eventExpired, cloned); err != nil { return nil, 0, err } return cloned, nowMS, 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(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[int64]struct{}, 4) seenSort := make(map[int32]struct{}, 4) for index, gift := range config.GetStakeGifts() { if gift.GetGiftId() <= 0 { return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is invalid") } if _, ok := seenGift[gift.GetGiftId()]; 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[gift.GetGiftId()] = struct{}{} seenSort[sortOrder] = struct{}{} normalized := &gamev1.RoomRPSStakeGift{ GiftId: gift.GetGiftId(), GiftName: strings.TrimSpace(gift.GetGiftName()), GiftIconUrl: strings.TrimSpace(gift.GetGiftIconUrl()), GiftPriceCoin: gift.GetGiftPriceCoin(), Enabled: gift.GetEnabled(), SortOrder: sortOrder, } if fallback := defaultsByID[gift.GetGiftId()]; 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(sortOrder) * 100 } gifts = append(gifts, normalized) } 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) 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": strconv.FormatInt(challenge.GetStakeGiftId(), 10), "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, GiftName: "RPS 100", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-100.png", GiftPriceCoin: 100, Enabled: true, SortOrder: 1}, {GiftId: 10002, GiftName: "RPS 300", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-300.png", GiftPriceCoin: 300, Enabled: true, SortOrder: 2}, {GiftId: 10003, GiftName: "RPS 500", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-500.png", GiftPriceCoin: 500, Enabled: true, SortOrder: 3}, {GiftId: 10004, GiftName: "RPS 1000", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-1000.png", GiftPriceCoin: 1000, Enabled: true, SortOrder: 4}, } } func defaultGiftMap() map[int64]*gamev1.RoomRPSStakeGift { out := make(map[int64]*gamev1.RoomRPSStakeGift, 4) for _, gift := range defaultGifts() { out[gift.GetGiftId()] = gift } return out } func stakeGiftByID(config *gamev1.RoomRPSConfig, giftID int64) (*gamev1.RoomRPSStakeGift, bool) { for _, gift := range config.GetStakeGifts() { if gift.GetGiftId() == giftID { return gift, true } } return nil, false } 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[:])) } func envOr(key string, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } return value }