Some checks failed
Build docker and publish / linux (1.24.5) (push) Failing after 15s
472 lines
14 KiB
Go
472 lines
14 KiB
Go
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)
|
||
}
|