package game import ( "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/service/game" usersvc "bindbox-game/internal/service/user" "context" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/redis/go-redis/v9" "go.uber.org/zap" ) type handler struct { logger logger.CustomLogger db mysql.Repo redis *redis.Client ticketSvc game.TicketService gameTokenSvc game.GameTokenService userSvc usersvc.Service readDB *dao.Query } func New(l logger.CustomLogger, db mysql.Repo, rdb *redis.Client, userSvc usersvc.Service) *handler { return &handler{ logger: l, db: db, redis: rdb, ticketSvc: game.NewTicketService(l, db), gameTokenSvc: game.NewGameTokenService(l, db, rdb), userSvc: userSvc, readDB: dao.Use(db.GetDbR()), } } // ========== Admin API ========== type grantTicketRequest struct { GameCode string `json:"game_code" binding:"required"` Amount int `json:"amount" binding:"required,min=1"` Remark string `json:"remark"` } // GrantUserTicket Admin为用户发放游戏资格 // @Summary 发放游戏资格 // @Tags 管理端.游戏 // @Param user_id path int true "用户ID" // @Param RequestBody body grantTicketRequest true "请求参数" // @Success 200 {object} map[string]any // @Router /api/admin/users/{user_id}/game_tickets [post] func (h *handler) GrantUserTicket() core.HandlerFunc { return func(ctx core.Context) { userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil || userID <= 0 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid user_id")) return } req := new(grantTicketRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } err = h.ticketSvc.GrantTicket(ctx.RequestContext(), userID, req.GameCode, req.Amount, "admin", 0, req.Remark) if err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) return } ctx.Payload(map[string]any{"success": true}) } } // ListUserTickets Admin查询用户游戏资格日志 // @Summary 查询用户游戏资格日志 // @Tags 管理端.游戏 // @Param user_id path int true "用户ID" // @Param page query int false "页码" // @Param page_size query int false "每页数量" // @Success 200 {object} map[string]any // @Router /api/admin/users/{user_id}/game_tickets [get] func (h *handler) ListUserTickets() core.HandlerFunc { return func(ctx core.Context) { userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) if err != nil || userID <= 0 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid user_id")) return } var req struct { Page int `form:"page"` PageSize int `form:"page_size"` } _ = ctx.ShouldBindQuery(&req) if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = 20 } logs, total, err := h.ticketSvc.GetTicketLogs(ctx.RequestContext(), userID, req.Page, req.PageSize) if err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) return } ctx.Payload(map[string]any{ "list": logs, "total": total, "page": req.Page, "page_size": req.PageSize, }) } } // ========== App API ========== // GetMyTickets App获取我的游戏资格 // @Summary 获取我的游戏资格 // @Tags APP端.游戏 // @Param user_id path int true "用户ID" // @Success 200 {object} map[string]int // @Router /api/app/users/{user_id}/game_tickets [get] func (h *handler) GetMyTickets() core.HandlerFunc { return func(ctx core.Context) { userID := int64(ctx.SessionUserInfo().Id) tickets, err := h.ticketSvc.GetUserTickets(ctx.RequestContext(), userID) if err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) return } ctx.Payload(tickets) } } type enterGameRequest struct { GameCode string `json:"game_code" binding:"required"` } type enterGameResponse struct { GameToken string `json:"game_token"` ExpiresAt string `json:"expires_at"` NakamaServer string `json:"nakama_server"` NakamaKey string `json:"nakama_key"` RemainingTimes int `json:"remaining_times"` ClientUrl string `json:"client_url"` } // EnterGame App进入游戏(消耗资格) // @Summary 进入游戏 // @Tags APP端.游戏 // @Param RequestBody body enterGameRequest true "请求参数" // @Success 200 {object} enterGameResponse // @Router /api/app/games/enter [post] func (h *handler) EnterGame() core.HandlerFunc { return func(ctx core.Context) { sessionInfo := ctx.SessionUserInfo() userID := int64(sessionInfo.Id) username := sessionInfo.NickName avatar := "" // Avatar not in session, could be fetched from user profile if needed req := new(enterGameRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } // 生成安全的 GameToken (会自动扣减游戏次数) gameToken, _, expiresAt, err := h.gameTokenSvc.GenerateToken( ctx.RequestContext(), userID, username, avatar, req.GameCode, ) if err != nil { h.logger.Error("Failed to generate game token", zap.Error(err)) ctx.AbortWithError(core.Error(http.StatusBadRequest, 180001, "游戏次数不足或生成Token失败")) return } // 查询剩余次数 remaining := 0 if req.GameCode == "minesweeper_free" { remaining = 999999 // Represent infinite for free mode } else { ticket, _ := h.ticketSvc.GetUserTicketByGame(ctx.RequestContext(), userID, req.GameCode) if ticket != nil { remaining = int(ticket.Available) } } // 从系统配置读取Nakama服务器信息 nakamaServer := "ws://127.0.0.1:7350" nakamaKey := "defaultkey" clientUrl := "http://127.0.0.1:9991" // 指向当前后端地址作为默认 configKey := "game_" + req.GameCode + "_config" // map generic game code to specific config key if needed, or just use convention if req.GameCode == "minesweeper" { configKey = "game_minesweeper_config" } conf, _ := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq(configKey)).First() if conf != nil { var gameConfig struct { Server string `json:"server"` Key string `json:"key"` ClientUrl string `json:"client_url"` } if json.Unmarshal([]byte(conf.ConfigValue), &gameConfig) == nil { if gameConfig.Server != "" { nakamaServer = gameConfig.Server } if gameConfig.Key != "" { nakamaKey = gameConfig.Key } if gameConfig.ClientUrl != "" { clientUrl = gameConfig.ClientUrl } } } ctx.Payload(&enterGameResponse{ GameToken: gameToken, ExpiresAt: expiresAt.Format("2006-01-02T15:04:05Z07:00"), NakamaServer: nakamaServer, NakamaKey: nakamaKey, RemainingTimes: remaining, ClientUrl: clientUrl, }) } } // ========== Internal API (Nakama调用) ========== type validateTokenRequest struct { GameToken string `json:"game_token" binding:"required"` } type validateTokenResponse struct { Valid bool `json:"valid"` UserID int64 `json:"user_id,omitempty"` Username string `json:"username,omitempty"` Avatar string `json:"avatar,omitempty"` GameType string `json:"game_type,omitempty"` Ticket string `json:"ticket,omitempty"` Error string `json:"error,omitempty"` } // ValidateGameToken Internal验证GameToken // @Summary 验证GameToken // @Tags Internal.游戏 // @Param RequestBody body validateTokenRequest true "请求参数" // @Success 200 {object} validateTokenResponse // @Router /internal/game/validate-token [post] func (h *handler) ValidateGameToken() core.HandlerFunc { return func(ctx core.Context) { req := new(validateTokenRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.Payload(&validateTokenResponse{Valid: false, Error: "invalid request"}) return } claims, err := h.gameTokenSvc.ValidateToken(ctx.RequestContext(), req.GameToken) if err != nil { h.logger.Warn("GameToken validation failed", zap.Error(err)) ctx.Payload(&validateTokenResponse{Valid: false, Error: err.Error()}) return } ctx.Payload(&validateTokenResponse{ Valid: true, UserID: claims.UserID, Username: claims.Username, Avatar: claims.Avatar, GameType: claims.GameType, Ticket: claims.Ticket, }) } } type verifyRequest struct { UserID string `json:"user_id"` Ticket string `json:"ticket"` } type verifyResponse struct { Valid bool `json:"valid"` UserID string `json:"user_id"` GameConfig map[string]any `json:"game_config,omitempty"` } // VerifyTicket Internal验证游戏票据 // @Summary 验证票据 // @Tags Internal.游戏 // @Param RequestBody body verifyRequest true "请求参数" // @Success 200 {object} verifyResponse // @Router /internal/game/verify [post] func (h *handler) VerifyTicket() core.HandlerFunc { return func(ctx core.Context) { req := new(verifyRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } // 从Redis验证token storedValue, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result() if err != nil { ctx.Payload(&verifyResponse{Valid: false}) return } // Parse "userID:gameType" parts := strings.Split(storedValue, ":") if len(parts) < 2 { ctx.Payload(&verifyResponse{Valid: false}) return } storedUserID := parts[0] if storedUserID != req.UserID { ctx.Payload(&verifyResponse{Valid: false}) return } // 获取游戏配置 gameConfig := make(map[string]any) configKey := "game_minesweeper_config" conf, err := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq(configKey)).First() if err == nil && conf != nil { json.Unmarshal([]byte(conf.ConfigValue), &gameConfig) } ctx.Payload(&verifyResponse{Valid: true, UserID: req.UserID, GameConfig: gameConfig}) } } type settlePlayerRecord struct { UserID int64 `json:"user_id"` Ticket string `json:"ticket"` Win bool `json:"win"` Rank int `json:"rank"` Score int `json:"score"` DamageDealt int `json:"damage_dealt"` DamageTaken int `json:"damage_taken"` Kills int `json:"kills"` ChestsCollected int `json:"chests_collected"` RoundsSurvived int `json:"rounds_survived"` } type settleRequest struct { MatchID string `json:"match_id"` GameType string `json:"game_type"` TotalRounds int `json:"total_rounds"` Players []settlePlayerRecord `json:"players"` // 兼容旧版单人结算字段 UserID string `json:"user_id"` Ticket string `json:"ticket"` Win bool `json:"win"` Score int `json:"score"` } type settleResponse struct { Success bool `json:"success"` Reward string `json:"reward,omitempty"` } func calcRankPoints(rank int) int64 { switch rank { case 1: return 1000 case 2: return -900 case 3: return -1100 case 4: return -1300 default: return 0 } } // SettleGame Internal游戏结算(批量全员) // @Summary 游戏结算 // @Tags Internal.游戏 // @Param RequestBody body settleRequest true "请求参数" // @Success 200 {object} settleResponse // @Router /internal/game/settle [post] func (h *handler) SettleGame() core.HandlerFunc { return func(ctx core.Context) { req := new(settleRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } // 兼容旧版单人结算(Nakama 未升级时的过渡) if len(req.Players) == 0 && req.UserID != "" { uid, _ := strconv.ParseInt(req.UserID, 10, 64) if uid > 0 { rank := 2 if req.Win { rank = 1 } req.Players = []settlePlayerRecord{{ UserID: uid, Ticket: req.Ticket, Win: req.Win, Rank: rank, Score: req.Score, }} if req.GameType == "" { req.GameType = "minesweeper" } } } if len(req.Players) == 0 { ctx.Payload(&settleResponse{Success: true}) return } // 幂等检查:match_id 已结算则直接返回 if req.MatchID != "" { var count int64 h.db.GetDbR().Table("minesweeper_game_records").Where("match_id = ?", req.MatchID).Count(&count) if count > 0 { h.logger.Info("Game already settled, skip", zap.String("match_id", req.MatchID)) ctx.Payload(&settleResponse{Success: true}) return } } isFreeMode := req.GameType == "minesweeper_free" now := time.Now() // 读取奖励配置(付费场用) var msConfig struct { WinnerRewardPoints int64 `json:"winner_reward_points"` WinnerRewardProductID int64 `json:"winner_reward_product_id"` ParticipationRewardPoints int64 `json:"participation_reward_points"` } if !isFreeMode { conf, _ := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()). Where(h.readDB.SystemConfigs.ConfigKey.Eq("game_minesweeper_config")).First() if conf != nil { json.Unmarshal([]byte(conf.ConfigValue), &msConfig) } } for _, p := range req.Players { // 兜底:旧客户端漏传 Rank 时按 Win 推断 rank := p.Rank if rank == 0 { rank = 2 if p.Win { rank = 1 } } p.Rank = rank p.Win = p.Rank == 1 rankPoints := calcRankPoints(p.Rank) rawJSON, _ := json.Marshal(p) // 写入游戏记录(忽略 duplicate key 错误) h.db.GetDbW().Exec(` INSERT IGNORE INTO minesweeper_game_records (match_id, user_id, game_type, ticket, is_winner, rank_position, total_players, total_rounds, rounds_survived, score, damage_dealt, damage_taken, kills, chests_collected, rank_points, raw_summary, settled_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, req.MatchID, p.UserID, req.GameType, p.Ticket, p.Win, p.Rank, len(req.Players), req.TotalRounds, p.RoundsSurvived, p.Score, p.DamageDealt, p.DamageTaken, p.Kills, p.ChestsCollected, rankPoints, string(rawJSON), now, now, ) // UPSERT 聚合榜 wins, losses := 0, 1 if p.Win { wins, losses = 1, 0 } h.db.GetDbW().Exec(` INSERT INTO minesweeper_leaderboard (user_id, game_type, matches_played, wins, losses, win_rate, total_score, best_score, avg_score, total_damage_dealt, total_damage_taken, avg_damage_dealt, total_chests_collected, total_rounds_survived, total_rank_points, last_match_id, last_settled_at, created_at, updated_at) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE matches_played = matches_played + 1, wins = wins + VALUES(wins), losses = losses + VALUES(losses), win_rate = ROUND((wins + VALUES(wins)) / (matches_played + 1), 4), total_score = total_score + VALUES(total_score), best_score = GREATEST(best_score, VALUES(best_score)), avg_score = ROUND((total_score + VALUES(total_score)) / (matches_played + 1), 2), total_damage_dealt = total_damage_dealt + VALUES(total_damage_dealt), total_damage_taken = total_damage_taken + VALUES(total_damage_taken), avg_damage_dealt = ROUND((total_damage_dealt + VALUES(total_damage_dealt)) / (matches_played + 1), 2), total_chests_collected = total_chests_collected + VALUES(total_chests_collected), total_rounds_survived = total_rounds_survived + VALUES(total_rounds_survived), total_rank_points = total_rank_points + VALUES(total_rank_points), last_match_id = VALUES(last_match_id), last_settled_at = VALUES(last_settled_at), updated_at = VALUES(updated_at)`, p.UserID, req.GameType, wins, losses, float64(wins), p.Score, p.Score, float64(p.Score), p.DamageDealt, p.DamageTaken, float64(p.DamageDealt), p.ChestsCollected, p.RoundsSurvived, rankPoints, req.MatchID, now, now, now, ) // 清除 Redis ticket if p.Ticket != "" { h.redis.Del(ctx.RequestContext(), "game:token:ticket:"+p.Ticket) } // 付费场发奖励(仅赢家) if !isFreeMode && p.Win && p.UserID > 0 { if msConfig.WinnerRewardProductID > 0 { h.userSvc.GrantReward(ctx.RequestContext(), p.UserID, usersvc.GrantRewardRequest{ ProductID: msConfig.WinnerRewardProductID, Quantity: 1, Remark: "扫雷游戏奖励", }) } else { pts := msConfig.WinnerRewardPoints if pts == 0 { pts = 100 } h.userSvc.AddPointsWithAction(ctx.RequestContext(), p.UserID, pts, "game_reward", "扫雷游戏奖励", "minesweeper_settle", nil, nil) } } } // 异步刷新排行榜缓存 go h.refreshLeaderboardCache(req.GameType) ctx.Payload(&settleResponse{Success: true}) } } func (h *handler) refreshLeaderboardCache(gameType string) { type lbRow struct { UserID int64 `json:"user_id"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` TotalRankPoints int64 `json:"total_rank_points"` MatchesPlayed int `json:"matches_played"` Wins int `json:"wins"` Losses int `json:"losses"` WinRate float64 `json:"win_rate"` BestScore int `json:"best_score"` AvgScore float64 `json:"avg_score"` AvgDamageDealt float64 `json:"avg_damage_dealt"` TotalChests int64 `json:"total_chests_collected"` } var rows []lbRow h.db.GetDbR().Raw(` SELECT l.user_id, COALESCE(u.nick_name, '') AS nickname, COALESCE(u.avatar_url, '') AS avatar, l.total_rank_points, l.matches_played, l.wins, l.losses, CAST(l.win_rate AS DECIMAL(7,4)) AS win_rate, l.best_score, CAST(l.avg_score AS DECIMAL(12,2)) AS avg_score, CAST(l.avg_damage_dealt AS DECIMAL(12,2)) AS avg_damage_dealt, l.total_chests_collected FROM minesweeper_leaderboard l LEFT JOIN users u ON u.id = l.user_id WHERE l.game_type = ? ORDER BY l.total_rank_points DESC, l.wins DESC, l.best_score DESC, l.user_id ASC LIMIT 100`, gameType).Scan(&rows) if len(rows) == 0 { return } data, _ := json.Marshal(rows) cacheKey := fmt.Sprintf("ms:lb:v1:%s:top100", gameType) h.redis.Set(context.Background(), cacheKey, string(data), 5*time.Minute) } type consumeTicketRequest struct { UserID string `json:"user_id"` GameCode string `json:"game_code"` Ticket string `json:"ticket"` } type consumeTicketResponse struct { Success bool `json:"success"` Error string `json:"error,omitempty"` } // ConsumeTicket Internal扣减游戏次数(匹配成功后由Nakama调用) // @Summary 扣减游戏次数 // @Tags Internal.游戏 // @Param RequestBody body consumeTicketRequest true "请求参数" // @Success 200 {object} consumeTicketResponse // @Router /internal/game/consume-ticket [post] func (h *handler) ConsumeTicket() core.HandlerFunc { return func(ctx core.Context) { req := new(consumeTicketRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.Payload(&consumeTicketResponse{Success: false, Error: "参数错误"}) return } uid, _ := strconv.ParseInt(req.UserID, 10, 64) if uid <= 0 { ctx.Payload(&consumeTicketResponse{Success: false, Error: "无效的用户ID"}) return } gameCode := req.GameCode if gameCode == "" { gameCode = "minesweeper" } // 扣减游戏次数 if gameCode == "minesweeper_free" { // 免费场场不扣减次数,直接通过 h.logger.Info("Free mode consume ticket skipped deduction", zap.Int64("user_id", uid)) } else { err := h.ticketSvc.UseTicket(ctx.RequestContext(), uid, gameCode) if err != nil { h.logger.Error("Failed to consume ticket", zap.Int64("user_id", uid), zap.String("game_code", gameCode), zap.Error(err)) ctx.Payload(&consumeTicketResponse{Success: false, Error: err.Error()}) return } } // 使 ticket 失效(防止重复扣减) if req.Ticket != "" { h.gameTokenSvc.InvalidateTicket(ctx.RequestContext(), req.Ticket) } h.logger.Info("Ticket consumed on match success", zap.Int64("user_id", uid), zap.String("game_code", gameCode)) ctx.Payload(&consumeTicketResponse{Success: true}) } } // GetMinesweeperConfig Internal获取扫雷配置 // @Summary 获取扫雷配置 // @Tags Internal.游戏 // @Success 200 {object} map[string]interface{} // @Router /internal/game/minesweeper/config [get] func (h *handler) GetMinesweeperConfig() core.HandlerFunc { return func(ctx core.Context) { configKey := "game_minesweeper_config" conf, err := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq(configKey)).First() if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, "获取配置失败")) return } var gameConfig map[string]interface{} if err := json.Unmarshal([]byte(conf.ConfigValue), &gameConfig); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, "解析配置失败")) return } ctx.Payload(gameConfig) } } // GetLeaderboard App端排行榜查询 // @Summary 扫雷排行榜 // @Tags APP端.游戏 // @Param game_type query string false "游戏类型 minesweeper|minesweeper_free" // @Param page query int false "页码" // @Param page_size query int false "每页数量" // @Success 200 {object} map[string]any // @Router /api/app/games/leaderboard [get] func (h *handler) GetLeaderboard() core.HandlerFunc { return func(ctx core.Context) { var req struct { GameType string `form:"game_type"` Page int `form:"page"` PageSize int `form:"page_size"` } _ = ctx.ShouldBindQuery(&req) if req.GameType == "" { req.GameType = "minesweeper" } if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 || req.PageSize > 100 { req.PageSize = 20 } si := ctx.SessionUserInfo() myUserID := int64(si.Id) gameType := req.GameType page := req.Page pageSize := req.PageSize // 直接查 MySQL(实时数据,不走缓存) type lbRow struct { UserID int64 `json:"user_id"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` TotalRankPoints int64 `json:"total_rank_points"` MatchesPlayed int `json:"matches_played"` Wins int `json:"wins"` Losses int `json:"losses"` WinRate float64 `json:"win_rate"` BestScore int `json:"best_score"` AvgScore float64 `json:"avg_score"` } var rows []lbRow var total int64 offset := (page - 1) * pageSize h.db.GetDbR().Table("minesweeper_leaderboard").Where("game_type = ?", gameType).Count(&total) h.db.GetDbR().Raw(` SELECT l.user_id, COALESCE(u.nick_name, '') AS nickname, COALESCE(u.avatar_url, '') AS avatar, l.total_rank_points, l.matches_played, l.wins, l.losses, CAST(l.win_rate AS DECIMAL(7,4)) AS win_rate, l.best_score, CAST(l.avg_score AS DECIMAL(12,2)) AS avg_score FROM minesweeper_leaderboard l LEFT JOIN users u ON u.id = l.user_id WHERE l.game_type = ? ORDER BY l.total_rank_points DESC, l.wins DESC, l.best_score DESC, l.user_id ASC LIMIT ? OFFSET ?`, gameType, pageSize, offset).Scan(&rows) // 补名次 list := make([]map[string]any, 0, len(rows)) for i, r := range rows { m := map[string]any{ "rank": offset + i + 1, "user_id": r.UserID, "nickname": r.Nickname, "avatar": r.Avatar, "total_rank_points": r.TotalRankPoints, "matches_played": r.MatchesPlayed, "wins": r.Wins, "losses": r.Losses, "win_rate": r.WinRate, "best_score": r.BestScore, "avg_score": r.AvgScore, } list = append(list, m) } ctx.Payload(map[string]any{ "game_type": gameType, "page": page, "page_size": pageSize, "total": total, "list": list, "me": h.queryMyRank(ctx.RequestContext(), myUserID, gameType), }) } } // GetLeaderboardInternal 内部排行榜查询(供 Nakama RPC 代理) func (h *handler) GetLeaderboardInternal() core.HandlerFunc { return h.GetLeaderboard() } func (h *handler) queryMyRank(ctx context.Context, userID int64, gameType string) map[string]any { if userID <= 0 { return nil } var count int64 h.db.GetDbR().Table("minesweeper_leaderboard"). Where("user_id = ? AND game_type = ?", userID, gameType). Count(&count) if count == 0 { return nil } var myPoints int64 h.db.GetDbR().Table("minesweeper_leaderboard"). Select("total_rank_points"). Where("user_id = ? AND game_type = ?", userID, gameType). Scan(&myPoints) var myRank int64 h.db.GetDbR().Table("minesweeper_leaderboard"). Where("game_type = ? AND total_rank_points > ?", gameType, myPoints). Count(&myRank) var row struct { Wins int `json:"wins"` MatchesPlayed int `json:"matches_played"` WinRate float64 `json:"win_rate"` BestScore int `json:"best_score"` } h.db.GetDbR().Table("minesweeper_leaderboard"). Select("wins, matches_played, CAST(win_rate AS DECIMAL(7,4)) as win_rate, best_score"). Where("user_id = ? AND game_type = ?", userID, gameType). Scan(&row) return map[string]any{ "rank": myRank + 1, "user_id": userID, "total_rank_points": myPoints, "wins": row.Wins, "matches_played": row.MatchesPlayed, "win_rate": row.WinRate, "best_score": row.BestScore, } } // ========== Admin API: 排行榜 & 对战记录 ========== // GetAdminLeaderboard Admin查询扫雷排行榜 // @Summary 查询扫雷排行榜 // @Tags 管理端.游戏 // @Param page query int false "页码" // @Param page_size query int false "每页数量" // @Param nickname query string false "按玩家昵称模糊搜索" // @Success 200 {object} map[string]any // @Router /api/admin/games/leaderboard [get] func (h *handler) GetAdminLeaderboard() core.HandlerFunc { return func(ctx core.Context) { var req struct { Page int `form:"page"` PageSize int `form:"page_size"` Nickname string `form:"nickname"` } _ = ctx.ShouldBindQuery(&req) if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 || req.PageSize > 100 { req.PageSize = 20 } offset := (req.Page - 1) * req.PageSize type lbRow struct { UserID int64 `json:"user_id"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` TotalRankPoints int64 `json:"total_rank_points"` MatchesPlayed int `json:"matches_played"` Wins int `json:"wins"` Losses int `json:"losses"` WinRate float64 `json:"win_rate"` BestScore int `json:"best_score"` AvgScore float64 `json:"avg_score"` } query := h.db.GetDbR().Table("minesweeper_leaderboard l"). Select("l.user_id, COALESCE(u.nick_name,'') AS nickname, COALESCE(u.avatar_url,'') AS avatar, l.total_rank_points, l.matches_played, l.wins, l.losses, CAST(l.win_rate AS DECIMAL(7,4)) AS win_rate, l.best_score, CAST(l.avg_score AS DECIMAL(12,2)) AS avg_score"). Joins("LEFT JOIN users u ON u.id = l.user_id"). Where("l.game_type = ?", "minesweeper") if req.Nickname != "" { query = query.Where("u.nick_name LIKE ?", "%"+req.Nickname+"%") } var total int64 query.Count(&total) var rows []lbRow query.Order("l.total_rank_points DESC, l.wins DESC, l.best_score DESC"). Limit(req.PageSize).Offset(offset).Scan(&rows) list := make([]map[string]any, 0, len(rows)) for i, r := range rows { list = append(list, map[string]any{ "rank": offset + i + 1, "user_id": r.UserID, "nickname": r.Nickname, "avatar": r.Avatar, "total_rank_points": r.TotalRankPoints, "matches_played": r.MatchesPlayed, "wins": r.Wins, "losses": r.Losses, "win_rate": r.WinRate, "best_score": r.BestScore, "avg_score": r.AvgScore, }) } ctx.Payload(map[string]any{ "total": total, "page": req.Page, "page_size": req.PageSize, "list": list, }) } } // GetAdminGameRecords Admin查询扫雷对战记录 // @Summary 查询扫雷对战记录 // @Tags 管理端.游戏 // @Param page query int false "页码" // @Param page_size query int false "每页数量" // @Param user_id query int false "按用户ID筛选" // @Param match_id query string false "按局ID筛选" // @Success 200 {object} map[string]any // @Router /api/admin/games/records [get] func (h *handler) GetAdminGameRecords() core.HandlerFunc { return func(ctx core.Context) { var req struct { Page int `form:"page"` PageSize int `form:"page_size"` UserID int64 `form:"user_id"` MatchID string `form:"match_id"` } _ = ctx.ShouldBindQuery(&req) if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 || req.PageSize > 100 { req.PageSize = 20 } offset := (req.Page - 1) * req.PageSize type recRow struct { ID int64 `json:"id"` MatchID string `json:"match_id"` UserID int64 `json:"user_id"` Nickname string `json:"nickname"` IsWinner bool `json:"is_winner"` RankPosition int `json:"rank_position"` TotalPlayers int `json:"total_players"` Score int `json:"score"` DamageDealt int `json:"damage_dealt"` ChestsCollected int `json:"chests_collected"` RankPoints int `json:"rank_points"` SettledAt string `json:"settled_at"` } query := h.db.GetDbR().Table("minesweeper_game_records r"). Select("r.id, r.match_id, r.user_id, COALESCE(u.nick_name,'') AS nickname, r.is_winner, r.rank_position, r.total_players, r.score, r.damage_dealt, r.chests_collected, r.rank_points, r.settled_at"). Joins("LEFT JOIN users u ON u.id = r.user_id"). Where("r.game_type = ?", "minesweeper") if req.UserID > 0 { query = query.Where("r.user_id = ?", req.UserID) } if req.MatchID != "" { query = query.Where("r.match_id = ?", req.MatchID) } var total int64 query.Count(&total) var rows []recRow query.Order("r.settled_at DESC").Limit(req.PageSize).Offset(offset).Scan(&rows) ctx.Payload(map[string]any{ "total": total, "page": req.Page, "page_size": req.PageSize, "list": rows, }) } } // ========== Helpers ========== func generateTicketToken(userID int64) string { return "GT" + randomString(16) } func randomString(n int) string { const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, n) for i := range b { b[i] = letters[i%len(letters)] } return string(b) }