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 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), 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 { TicketToken string `json:"ticket_token"` NakamaServer string `json:"nakama_server"` NakamaKey string `json:"nakama_key"` RemainingTimes int `json:"remaining_times"` } // 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) { userID := int64(ctx.SessionUserInfo().Id) req := new(enterGameRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } // 扣减资格 if err := h.ticketSvc.UseTicket(ctx.RequestContext(), userID, req.GameCode); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 180001, "游戏次数不足")) return } // 生成临时token并存入Redis ticketToken := generateTicketToken(userID) h.redis.Set(ctx.RequestContext(), "game:ticket:"+ticketToken, userID, 30*60*1000000000) // 30分钟 // 查询剩余次数 ticket, _ := h.ticketSvc.GetUserTicketByGame(ctx.RequestContext(), userID, req.GameCode) remaining := 0 if ticket != nil { remaining = int(ticket.Available) } // TODO: 从配置读取Nakama服务器信息 ctx.Payload(&enterGameResponse{ TicketToken: ticketToken, NakamaServer: "ws://localhost:7350", NakamaKey: "defaultkey", RemainingTimes: remaining, }) } } // ========== Internal API (Nakama调用) ========== 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: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 storedUserID, err := h.redis.Get(ctx.RequestContext(), "game:ticket:"+req.Ticket).Result() if err != nil || storedUserID != req.UserID { ctx.Payload(&settleResponse{Success: false}) return } // 删除token防止重复使用 h.redis.Del(ctx.RequestContext(), "game:ticket:"+req.Ticket) // 奖品发放逻辑 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}) } } // 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) }