Files

501 lines
17 KiB
Go
Raw Permalink Normal View History

2026-08-14 13:17:03 +08:00
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})
}