2025-12-26 12:22:32 +08:00

472 lines
14 KiB
Go
Raw 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"
"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: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: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: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})
}
}
// 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)
}