package handler import ( "fmt" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "nl-game-api-gin/internal/database" "nl-game-api-gin/internal/middleware" "nl-game-api-gin/internal/model" "nl-game-api-gin/internal/service" "nl-game-api-gin/pkg/resp" ) // gameListItem 游戏列表项(附当前用户的拥有/周免/可玩状态) type gameListItem struct { model.Game Owned bool `json:"owned"` // 是否永久拥有(免费或已购买) WeeklyFree bool `json:"weekly_free"` // 是否在我的VIP本周周免批次内 Playable bool `json:"playable"` // 是否可玩(拥有 或 周免) } // GameList 大厅游戏列表:全部上架游戏 + 我的拥有/周免状态 func GameList(c *gin.Context) { uid := middleware.UID(c) var games []model.Game database.DB.Where("status = 1").Order("sort").Find(&games) // 我已购买的游戏ID集合 ownedSet := map[int]bool{} var ownedIDs []int database.DB.Model(&model.UserGame{}).Where("user_id = ?", uid).Pluck("game_id", &ownedIDs) for _, id := range ownedIDs { ownedSet[id] = true } // 我的VIP本周周免游戏ID集合(非VIP为空集合) weeklySet := map[int]bool{} var user model.User if err := database.DB.First(&user, uid).Error; err == nil { weeklySet = service.VipWeeklyFreeSet(&user, time.Now()) } list := make([]gameListItem, 0, len(games)) for _, g := range games { owned := g.Price == 0 || ownedSet[g.ID] weekly := !owned && weeklySet[g.ID] list = append(list, gameListItem{Game: g, Owned: owned, WeeklyFree: weekly, Playable: owned || weekly}) } resp.OK(c, list) } // GameDetail 游戏详情:基础信息、拥有/周免状态、我的最高分 func GameDetail(c *gin.Context) { var game model.Game if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil { resp.Fail(c, "游戏不存在或已下架") return } uid := middleware.UID(c) owned := game.Price == 0 if !owned { var cnt int64 database.DB.Model(&model.UserGame{}).Where("user_id = ? AND game_id = ?", uid, game.ID).Count(&cnt) owned = cnt > 0 } // 未拥有时看是否在我的VIP周免批次内(周免也可进入游玩) weekly := false if !owned { var user model.User if err := database.DB.First(&user, uid).Error; err == nil { weekly = service.VipWeeklyFreeSet(&user, time.Now())[game.ID] } } // 我的历史最高分 var best int database.DB.Model(&model.GameRecord{}).Where("user_id = ? AND game_id = ?", uid, game.ID). Select("COALESCE(MAX(score),0)").Scan(&best) resp.OK(c, gin.H{ "game": game, "owned": owned, "best_score": best, "weekly_free": weekly, "playable": owned || weekly, }) } // submitScoreReq 提交成绩请求体 type submitScoreReq struct { Score int `json:"score" binding:"min=0"` // 本局得分 Duration int `json:"duration"` // 游玩时长(秒) UseDouble bool `json:"use_double"` // 是否使用双倍积分卡 } // SubmitScore 提交单机游戏成绩:分数 → 平台积分换算并入账,记录本局 func SubmitScore(c *gin.Context) { var req submitScoreReq if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, "参数有误") return } var game model.Game if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil { resp.Fail(c, "游戏不存在或已下架") return } if game.Engine == model.EngineOnline { resp.Fail(c, "联机游戏不支持提交成绩") return } uid := middleware.UID(c) // 付费游戏必须已购买或在我的VIP周免批次内才能提交成绩(防接口刷分) if game.Price > 0 { var cnt int64 database.DB.Model(&model.UserGame{}).Where("user_id = ? AND game_id = ?", uid, game.ID).Count(&cnt) if cnt == 0 { weekly := false var user model.User if err := database.DB.First(&user, uid).Error; err == nil { weekly = service.VipWeeklyFreeSet(&user, time.Now())[game.ID] } if !weekly { resp.Fail(c, "请先购买该游戏") return } } } now := time.Now() // 防刷 1:同一游戏两次交分之间的最小间隔(站点配置可调,默认 15 秒) if minGap := service.GetConfigInt("score_min_interval_sec", 15); minGap > 0 { var lastAt int64 database.DB.Model(&model.GameRecord{}).Where("user_id = ? AND game_id = ?", uid, game.ID). Select("COALESCE(MAX(created_at),0)").Scan(&lastAt) if lastAt > 0 && now.Unix()-lastAt < int64(minGap) { resp.Fail(c, "提交太频繁,请稍后再试") return } } // 防刷 2:单游戏每日积分封顶 = 单局上限 × 倍数(站点配置可调,默认 10 倍) dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix() var todaySum int database.DB.Model(&model.GameRecord{}). Where("user_id = ? AND game_id = ? AND created_at >= ?", uid, game.ID, dayStart). Select("COALESCE(SUM(points_gained),0)").Scan(&todaySum) dailyCap := game.MaxPointsPerPlay * service.GetConfigInt("daily_points_cap_multiple", 10) // 换算可得积分:分数/换算比,封顶单局上限(后台可配) points := 0 if game.ScorePerPoint > 0 && req.Score > 0 { points = req.Score / game.ScorePerPoint if points > game.MaxPointsPerPlay { points = game.MaxPointsPerPlay } } // 达到当日封顶后本局不再发积分(分数仍记录、仍可刷新最高分) capped := false clampDaily := func() { remaining := dailyCap - todaySum if remaining < 0 { remaining = 0 } if points > remaining { points = remaining capped = true } } clampDaily() // 事务前先夹一次:已封顶时连双倍卡都不消耗 doubled := false var newBalance int var record model.GameRecord err := database.DB.Transaction(func(tx *gorm.DB) error { // 使用双倍积分卡:原子扣减 1 张,扣到了才翻倍 if req.UseDouble && points > 0 { res := tx.Exec(`UPDATE user_props up JOIN props p ON p.id = up.prop_id SET up.quantity = up.quantity - 1 WHERE up.user_id = ? AND p.code = 'double_points' AND up.quantity > 0`, uid) if res.Error != nil { return res.Error } if res.RowsAffected > 0 { points *= 2 doubled = true } } // 翻倍后再夹一次,确保当日总量严格不超封顶 clampDaily() // 写游戏记录 record = model.GameRecord{UserID: uid, GameID: game.ID, Score: req.Score, PointsGained: points, Duration: req.Duration} if err := tx.Create(&record).Error; err != nil { return err } // 游玩次数自增 if err := tx.Model(&model.Game{}).Where("id = ?", game.ID). UpdateColumn("play_count", gorm.Expr("play_count + 1")).Error; err != nil { return err } // 积分入账并写流水 if points > 0 { b, err := service.ChangePoints(tx, uid, points, model.PointTypeGame, record.ID, fmt.Sprintf("游玩「%s」获得 %d 分", game.Name, req.Score)) if err != nil { return err } newBalance = b } return nil }) if err != nil { resp.Fail(c, "提交失败:"+err.Error()) return } // 查询之前的最高分,判断是否刷新纪录 var prevBest int database.DB.Model(&model.GameRecord{}). Where("user_id = ? AND game_id = ? AND id != ?", uid, game.ID, record.ID). Select("COALESCE(MAX(score),0)").Scan(&prevBest) if newBalance == 0 { // 本局没有积分入账时查一次当前余额(保证返回值正确) var user model.User database.DB.First(&user, uid) newBalance = user.Points } resp.OK(c, gin.H{ "points_gained": points, // 本局获得积分(含翻倍与当日封顶) "doubled": doubled, // 是否成功使用双倍卡 "capped": capped, // 是否触发单游戏每日积分封顶 "balance": newBalance, // 最新积分余额 "is_new_high": req.Score > prevBest, // 是否刷新个人纪录 }) } // friendProgressItem 好友在某游戏的关卡进度 type friendProgressItem struct { UserID int `json:"user_id"` // 好友用户ID Nickname string `json:"nickname"` // 好友昵称 Avatar string `json:"avatar"` // 好友头像(emoji) Level int `json:"level"` // 好友解锁到的关卡 } // friendIDs 我的全部好友ID(已通过的关系,双向取对端) func friendIDs(uid int) []int { var rels []model.Friend database.DB.Where("status = ? AND (user_id = ? OR friend_id = ?)", model.FriendStatusAccepted, uid, uid).Find(&rels) ids := make([]int, 0, len(rels)) for _, r := range rels { peer := r.UserID if peer == uid { peer = r.FriendID } ids = append(ids, peer) } return ids } // GameProgressGet 我的关卡进度 + 好友进度(关卡选择页展示「哪个好友在哪一关」) func GameProgressGet(c *gin.Context) { var game model.Game if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil { resp.Fail(c, "游戏不存在或已下架") return } uid := middleware.UID(c) // 我的进度(无记录视为第 1 关) myLevel := 1 var gp model.GameProgress if err := database.DB.Where("user_id = ? AND game_id = ?", uid, game.ID).First(&gp).Error; err == nil { myLevel = gp.Level } // 好友进度:只列出玩过本游戏(有进度记录)的好友 friends := make([]friendProgressItem, 0) if ids := friendIDs(uid); len(ids) > 0 { database.DB.Table("game_progress gp"). Select("gp.user_id, u.nickname, u.avatar, gp.level"). Joins("JOIN users u ON u.id = gp.user_id"). Where("gp.game_id = ? AND gp.user_id IN ?", game.ID, ids). Order("gp.level DESC").Scan(&friends) } resp.OK(c, gin.H{"level": myLevel, "friends": friends}) } // saveProgressReq 保存关卡进度请求体 type saveProgressReq struct { Level int `json:"level" binding:"required,min=1,max=999"` // 解锁到的关卡 } // GameProgressSave 保存关卡进度(只增不减:过关后前端上报解锁的新关卡) // 关卡均为逐级解锁(过第 N 关解锁 N+1),服务端按"当前进度 +1"夹紧上报值,防接口跳关作弊 func GameProgressSave(c *gin.Context) { var req saveProgressReq if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, "参数有误") return } var game model.Game if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil { resp.Fail(c, "游戏不存在或已下架") return } uid := middleware.UID(c) var gp model.GameProgress if err := database.DB.Where("user_id = ? AND game_id = ?", uid, game.ID).First(&gp).Error; err != nil { // 首次记录:初始进度为第 1 关,最多解锁到第 2 关 level := req.Level if level > 2 { level = 2 } gp = model.GameProgress{UserID: uid, GameID: game.ID, Level: level} if err := database.DB.Create(&gp).Error; err != nil { resp.Fail(c, "保存失败,请重试") return } } else if req.Level > gp.Level { level := req.Level if level > gp.Level+1 { level = gp.Level + 1 } database.DB.Model(&gp).Update("level", level) gp.Level = level } resp.OK(c, gin.H{"level": gp.Level}) } // ---- 游戏存档(支持存档的单机游戏:饥荒等) ---- // GameSaveGet 读取我的存档:无档返回 exists=false,前端据此显示「继续/新开」 func GameSaveGet(c *gin.Context) { var game model.Game if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil { resp.Fail(c, "游戏不存在或已下架") return } var save model.GameSave if err := database.DB.Where("user_id = ? AND game_id = ?", middleware.UID(c), game.ID).First(&save).Error; err != nil { resp.OK(c, gin.H{"exists": false}) return } resp.OK(c, gin.H{"exists": true, "day": save.Day, "score": save.Score, "data": save.Data, "updated_at": save.UpdatedAt}) } // saveGameReq 保存存档请求体 type saveGameReq struct { Day int `json:"day" binding:"min=1"` // 存档天数(展示用) Score int `json:"score" binding:"min=0"` // 存档时分数(展示用) Data string `json:"data" binding:"required"` // 游戏状态快照(JSON 字符串) } // GameSaveSet 覆盖保存存档(每天清晨自动存 + 手动存档按钮) func GameSaveSet(c *gin.Context) { var req saveGameReq if err := c.ShouldBindJSON(&req); err != nil || len(req.Data) > 256*1024 { resp.Fail(c, "存档数据有误") return } var game model.Game if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil { resp.Fail(c, "游戏不存在或已下架") return } uid := middleware.UID(c) var save model.GameSave if err := database.DB.Where("user_id = ? AND game_id = ?", uid, game.ID).First(&save).Error; err != nil { save = model.GameSave{UserID: uid, GameID: game.ID, Day: req.Day, Score: req.Score, Data: req.Data} if err := database.DB.Create(&save).Error; err != nil { resp.Fail(c, "存档失败,请重试") return } } else if err := database.DB.Model(&save).Updates(map[string]any{ "day": req.Day, "score": req.Score, "data": req.Data, }).Error; err != nil { resp.Fail(c, "存档失败,请重试") return } resp.OK(c, gin.H{"day": req.Day}) } // GameSaveDelete 删除存档(死亡永久死亡删档 / 玩家主动重新开始) func GameSaveDelete(c *gin.Context) { var game model.Game if err := database.DB.Where("code = ?", c.Param("code")).First(&game).Error; err != nil { resp.Fail(c, "游戏不存在") return } database.DB.Where("user_id = ? AND game_id = ?", middleware.UID(c), game.ID).Delete(&model.GameSave{}) resp.OK(c, nil) } // ---- 游戏皮肤(更衣室:免费默认解锁,其余积分购买) ---- // skinListItem 皮肤列表项(附当前用户是否已拥有) type skinListItem struct { Code string `json:"code"` // 皮肤编码(对应前端绘制参数表) Name string `json:"name"` // 展示名称 Price int `json:"price"` // 售价(0=免费) Owned bool `json:"owned"` // 是否已拥有(免费皮肤恒为 true) } // GameSkins 皮肤列表:全部皮肤 + 我的拥有状态(price=0 视为人人拥有) func GameSkins(c *gin.Context) { code := c.Param("code") var skins []model.GameSkin database.DB.Where("game_code = ?", code).Order("sort").Find(&skins) // 我已购皮肤ID集合 uid := middleware.UID(c) ownedSet := map[int]bool{} var ownedIDs []int database.DB.Table("user_skins us"). Joins("JOIN game_skins gs ON gs.id = us.skin_id"). Where("us.user_id = ? AND gs.game_code = ?", uid, code). Pluck("us.skin_id", &ownedIDs) for _, id := range ownedIDs { ownedSet[id] = true } list := make([]skinListItem, 0, len(skins)) for _, s := range skins { list = append(list, skinListItem{Code: s.Code, Name: s.Name, Price: s.Price, Owned: s.Price == 0 || ownedSet[s.ID]}) } resp.OK(c, list) } // buySkinReq 购买皮肤请求体 type buySkinReq struct { SkinCode string `json:"skin_code" binding:"required"` // 皮肤编码 } // GameSkinBuy 积分购买皮肤:校验未拥有 → 事务内扣积分 + 写流水 + 发放 func GameSkinBuy(c *gin.Context) { var req buySkinReq if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, "参数有误") return } var skin model.GameSkin if err := database.DB.Where("game_code = ? AND code = ?", c.Param("code"), req.SkinCode).First(&skin).Error; err != nil { resp.Fail(c, "皮肤不存在") return } if skin.Price <= 0 { resp.Fail(c, "免费皮肤无需购买") return } uid := middleware.UID(c) var owned int64 database.DB.Model(&model.UserSkin{}).Where("user_id = ? AND skin_id = ?", uid, skin.ID).Count(&owned) if owned > 0 { resp.Fail(c, "已拥有该皮肤") return } var balance int err := database.DB.Transaction(func(tx *gorm.DB) error { // 扣积分并写流水(余额不足时事务整体回滚) b, err := service.ChangePoints(tx, uid, -skin.Price, model.PointTypeShop, skin.ID, fmt.Sprintf("购买皮肤「%s」", skin.Name)) if err != nil { return err } balance = b // 发放皮肤(唯一索引防并发重复购买) return tx.Create(&model.UserSkin{UserID: uid, SkinID: skin.ID}).Error }) if err != nil { resp.Fail(c, err.Error()) return } resp.OK(c, gin.H{"balance": balance, "code": skin.Code}) } // usePropReq 使用道具请求体 type usePropReq struct { PropCode string `json:"prop_code" binding:"required"` // 道具编码 GameCode string `json:"game_code"` // 在哪个游戏中使用(记录用) } // UseProp 游戏内使用道具:原子扣减库存,效果由前端游戏内生效 func UseProp(c *gin.Context) { var req usePropReq if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, "参数有误") return } uid := middleware.UID(c) var prop model.Prop if err := database.DB.Where("code = ?", req.PropCode).First(&prop).Error; err != nil { resp.Fail(c, "道具不存在") return } // 原子扣减:数量不足时影响行数为 0 res := database.DB.Exec(`UPDATE user_props SET quantity = quantity - 1 WHERE user_id = ? AND prop_id = ? AND quantity > 0`, uid, prop.ID) if res.Error != nil || res.RowsAffected == 0 { resp.Fail(c, "道具数量不足") return } // 写一条使用流水(积分变动为 0,仅作记录) var user model.User database.DB.First(&user, uid) database.DB.Create(&model.PointRecord{ UserID: uid, ChangePoints: 0, Balance: user.Points, Type: model.PointTypeProp, Remark: fmt.Sprintf("在「%s」中使用道具「%s」", req.GameCode, prop.Name), }) // 返回剩余数量 var remain int database.DB.Model(&model.UserProp{}).Where("user_id = ? AND prop_id = ?", uid, prop.ID). Select("COALESCE(quantity,0)").Scan(&remain) resp.OK(c, gin.H{"remain": remain}) }