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" "encoding/json" "net/http" "strconv" "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 } // 查询剩余次数 ticket, _ := h.ticketSvc.GetUserTicketByGame(ctx.RequestContext(), userID, req.GameCode) remaining := 0 if ticket != nil { remaining = int(ticket.Available) } // 从系统配置读取Nakama服务器信息 nakamaServer := "wss://nakama.yourdomain.com" nakamaKey := "defaultkey" clientUrl := "https://game.1024tool.vip" 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 storedUserID, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result() if err != nil || 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 settleRequest struct { UserID string `json:"user_id"` Ticket string `json:"ticket"` MatchID string `json:"match_id"` Win bool `json:"win"` Score int `json:"score"` } type settleResponse struct { Success bool `json:"success"` Reward string `json:"reward,omitempty"` } // 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 } // 验证token(可选,如果游戏服务器传了ticket则验证,否则信任internal调用) if req.Ticket != "" { storedUserID, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result() if err != nil || storedUserID != req.UserID { h.logger.Warn("Ticket validation failed, but proceeding with internal trust", zap.String("ticket", req.Ticket), zap.String("user_id", req.UserID)) } else { // 删除token防止重复使用 h.redis.Del(ctx.RequestContext(), "game:token:ticket:"+req.Ticket) } } // 注意:即使ticket验证失败,作为internal API我们仍然信任游戏服务器传来的UserID // 奖品发放逻辑 var rewardMsg string var msConfig struct { WinnerRewardPoints int64 `json:"winner_reward_points"` ParticipationRewardPoints int64 `json:"participation_reward_points"` WinnerRewardProductID int64 `json:"winner_reward_product_id"` ParticipationRewardProductID int64 `json:"participation_reward_product_id"` } // 1. 读取配置 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), &msConfig) } uid, _ := strconv.ParseInt(req.UserID, 10, 64) // 2. 确定奖励内容 var targetProductID int64 var targetPoints int64 if req.Win { targetProductID = msConfig.WinnerRewardProductID targetPoints = msConfig.WinnerRewardPoints if targetPoints == 0 && targetProductID == 0 { targetPoints = 100 // 兜底 } } else { targetProductID = msConfig.ParticipationRewardProductID targetPoints = msConfig.ParticipationRewardPoints if targetPoints == 0 && targetProductID == 0 { targetPoints = 10 // 兜底 } } // 3. 发放奖励 if targetProductID > 0 { res, err := h.userSvc.GrantReward(ctx.RequestContext(), uid, usersvc.GrantRewardRequest{ ProductID: targetProductID, Quantity: 1, Remark: "扫雷游戏奖励", }) if err != nil || !res.Success { h.logger.Error("Failed to grant game product reward", zap.Error(err), zap.String("msg", res.Message)) rewardMsg = "奖励发放失败" } else { rewardMsg = "获得奖品" } } else if targetPoints > 0 { err := h.userSvc.AddPointsWithAction(ctx.RequestContext(), uid, targetPoints, "game_reward", "扫雷游戏奖励", "minesweeper_settle", nil, nil) if err != nil { h.logger.Error("Failed to grant game points", zap.Error(err)) } rewardMsg = strconv.FormatInt(targetPoints, 10) + "积分" } ctx.Payload(&settleResponse{Success: true, Reward: rewardMsg}) } } 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" } // 扣减游戏次数 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) } } // ========== 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) }