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