Files
nl-game-api/internal/handler/game.go
2026-08-14 13:17:03 +08:00

501 lines
17 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 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})
}