994 lines
31 KiB
Go
Executable File
994 lines
31 KiB
Go
Executable File
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)
|
||
}
|