package gamemanagement import ( "fmt" "strings" "hyapp-admin-server/internal/response" gamev1 "hyapp.local/api/proto/game/v1" "github.com/gin-gonic/gin" ) func (h *Handler) GetRoomRPSConfig(c *gin.Context) { // 后台查询不读本地缓存,也不直接查 game 表;所有配置事实统一从 game-service 管理 RPC 返回,避免 admin-server 和运行时出现双写。 resp, err := h.game.GetRoomRPSConfig(c.Request.Context(), &gamev1.GetRoomRPSConfigRequest{Meta: requestMeta(c)}) if err != nil { response.ServerError(c, "获取房内猜拳配置失败") return } response.OK(c, gin.H{"config": roomRPSConfigFromProto(resp.GetConfig()), "serverTimeMs": resp.GetServerTimeMs()}) } func (h *Handler) UpdateRoomRPSConfig(c *gin.Context) { var req roomRPSConfigRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "房内猜拳配置参数不正确") return } // JSON 绑定只负责类型检查,业务边界统一放到 toProto:默认启用、默认倒计时、固定四档礼物和礼物 ID 去重。 config, err := req.toProto() if err != nil { response.BadRequest(c, err.Error()) return } // admin-server 只校验后台可见的配置边界:开关、超时和 4 个礼物档位。 // 礼物名称、价格、图标、扣款、托管和结算幂等由 game-service/wallet-service 统一处理,避免后台绕过运行时校验写入脏配置。 resp, err := h.game.UpdateRoomRPSConfig(c.Request.Context(), &gamev1.UpdateRoomRPSConfigRequest{ Meta: requestMeta(c), Config: config, }) if err != nil { response.BadRequest(c, err.Error()) return } item := roomRPSConfigFromProto(resp.GetConfig()) // 配置修改会影响所有房间入口和下注档位,必须写审计;审计目标使用 game_id,便于后续按玩法追踪变更。 h.auditLog(c, "update-room-rps-config", "game_room_rps_configs", item.GameID, item.Status) response.OK(c, gin.H{"config": item, "serverTimeMs": resp.GetServerTimeMs()}) } func (h *Handler) ListRoomRPSChallenges(c *gin.Context) { // 管理后台默认页大小比 App 大,但仍只允许正数;真正的上限和游标稳定性由 game-service 控制,防止 admin-server 复制分页规则。 pageSize := int32(parsePositiveInt(firstQuery(c, "pageSize", "page_size"), 50)) // 所有 ID 查询都兼容 camelCase 和 snake_case,前端生成代码和手工调试都能命中同一套过滤逻辑。 initiatorUserID, ok := optionalInt64Query(c, "initiatorUserId", "initiator_user_id") if !ok { response.BadRequest(c, "发起人用户 ID 参数不正确") return } challengerUserID, ok := optionalInt64Query(c, "challengerUserId", "challenger_user_id") if !ok { response.BadRequest(c, "应战人用户 ID 参数不正确") return } startTimeMS, ok := optionalInt64Query(c, "startTimeMs", "start_time_ms") if !ok { response.BadRequest(c, "开始时间参数不正确") return } endTimeMS, ok := optionalInt64Query(c, "endTimeMs", "end_time_ms") if !ok { response.BadRequest(c, "结束时间参数不正确") return } // 列表接口只把后台筛选条件透传给 game-service;订单可见性、游标语义、状态枚举和结算状态仍以 game-service 查询结果为准。 // admin-server 不做本地状态拼装,避免后续 game-service 增加 timeout/settlement_failed 补偿状态时后台漏同步。 resp, err := h.game.ListRoomRPSChallenges(c.Request.Context(), &gamev1.ListRoomRPSChallengesRequest{ Meta: requestMeta(c), RoomId: strings.TrimSpace(firstQuery(c, "roomId", "room_id")), Status: strings.TrimSpace(c.Query("status")), InitiatorUserId: initiatorUserID, ChallengerUserId: challengerUserID, StartTimeMs: startTimeMS, EndTimeMs: endTimeMS, PageSize: pageSize, Cursor: strings.TrimSpace(c.Query("cursor")), }) if err != nil { response.ServerError(c, "获取房内猜拳订单失败") return } items := make([]roomRPSChallengeDTO, 0, len(resp.GetChallenges())) for _, item := range resp.GetChallenges() { // DTO 转换只做字段命名和大整数保护,不在后台列表里重新计算胜负或结算结果。 items = append(items, roomRPSChallengeFromProto(item)) } response.OK(c, gin.H{"items": items, "nextCursor": resp.GetNextCursor(), "pageSize": pageSize, "serverTimeMs": resp.GetServerTimeMs()}) } func (h *Handler) GetRoomRPSChallenge(c *gin.Context) { challengeID := strings.TrimSpace(c.Param("challenge_id")) if challengeID == "" { response.BadRequest(c, "挑战单 ID 参数不正确") return } // 详情接口不接收 room_id 或用户 ID 作为辅助条件;challenge_id 是唯一事实入口,避免后台参数互相矛盾时误展示错误订单。 resp, err := h.game.GetRoomRPSChallenge(c.Request.Context(), &gamev1.GetRoomRPSChallengeRequest{ Meta: requestMeta(c), ChallengeId: challengeID, }) if err != nil { response.BadRequest(c, err.Error()) return } response.OK(c, gin.H{"challenge": roomRPSChallengeFromProto(resp.GetChallenge()), "serverTimeMs": resp.GetServerTimeMs()}) } func (h *Handler) RetryRoomRPSSettlement(c *gin.Context) { challengeID := strings.TrimSpace(c.Param("challenge_id")) if challengeID == "" { response.BadRequest(c, "挑战单 ID 参数不正确") return } // 重试入口只允许后台触发一次明确的运维命令。 // 是否处于 settlement_failed、是否已经结算过、钱包补偿是否可重放,都由 game-service 按订单幂等事实判断,admin-server 不能预读后再决定。 resp, err := h.game.RetryRoomRPSSettlement(c.Request.Context(), &gamev1.RetryRoomRPSSettlementRequest{ Meta: requestMeta(c), ChallengeId: challengeID, }) if err != nil { response.BadRequest(c, err.Error()) return } item := roomRPSChallengeFromProto(resp.GetChallenge()) // 审计记录写回 game-service 返回的新结算状态,方便排查“点击重试但实际已被其它 worker 修复”的幂等场景。 h.auditLog(c, "retry-room-rps-settlement", "game_room_rps_challenges", item.ChallengeID, item.SettlementStatus) response.OK(c, gin.H{"challenge": item, "serverTimeMs": resp.GetServerTimeMs()}) } func (h *Handler) ExpireRoomRPSChallenge(c *gin.Context) { challengeID := strings.TrimSpace(c.Param("challenge_id")) if challengeID == "" { response.BadRequest(c, "挑战单 ID 参数不正确") return } // 手动过期只是运维兜底命令。 // 只有 pending 且未被应战的挑战能过期;这个判断必须在 game-service 事务内完成,避免后台读到 pending 后并发被应战。 resp, err := h.game.ExpireRoomRPSChallenge(c.Request.Context(), &gamev1.ExpireRoomRPSChallengeRequest{ Meta: requestMeta(c), ChallengeId: challengeID, }) if err != nil { response.BadRequest(c, err.Error()) return } item := roomRPSChallengeFromProto(resp.GetChallenge()) // 审计记录写最终状态,而不是写“用户点击了过期”,这样能看出命令是否真的把订单推进到 timeout。 h.auditLog(c, "expire-room-rps-challenge", "game_room_rps_challenges", item.ChallengeID, item.Status) response.OK(c, gin.H{"challenge": item, "serverTimeMs": resp.GetServerTimeMs()}) } func optionalInt64Query(c *gin.Context, keys ...string) (int64, bool) { raw := strings.TrimSpace(firstQuery(c, keys...)) if raw == "" { // 空值表示不筛选,不是筛选用户 0;game-service 看到 0 时按“无该条件”处理。 return 0, true } var value int64 if _, err := fmt.Sscan(raw, &value); err != nil || value < 0 { return 0, false } return value, true }