2026-04-25 02:48:07 +08:00

994 lines
31 KiB
Go
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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