邹方成 c9a83a232a
Some checks failed
Build docker and publish / linux (1.24.5) (push) Failing after 48s
feat: minesweeper dynamic config and granular rewards
2025-12-24 17:33:13 +08:00

381 lines
11 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
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)
}