初始化

This commit is contained in:
李琦
2026-08-14 13:17:03 +08:00
commit 2f5ce02608
72 changed files with 11132 additions and 0 deletions

372
internal/handler/admin.go Normal file
View File

@@ -0,0 +1,372 @@
package handler
import (
"fmt"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"nl-game-api-gin/internal/database"
"nl-game-api-gin/internal/model"
"nl-game-api-gin/internal/service"
"nl-game-api-gin/pkg/resp"
)
// AdminStats 数据概览:用户/游戏/订单总量与今日动态
func AdminStats(c *gin.Context) {
var userCount, gameCount, orderCount, todayPlays, todaySigns int64
var todayPoints int
todayStart := time.Now().Truncate(24 * time.Hour).Unix()
// 各类总量统计
database.DB.Model(&model.User{}).Count(&userCount)
database.DB.Model(&model.Game{}).Where("status = 1").Count(&gameCount)
database.DB.Model(&model.Order{}).Count(&orderCount)
database.DB.Model(&model.GameRecord{}).Where("created_at >= ?", todayStart).Count(&todayPlays)
database.DB.Model(&model.SignIn{}).Where("created_at >= ?", todayStart).Count(&todaySigns)
// 今日发放积分(只统计正向变动)
database.DB.Model(&model.PointRecord{}).
Where("created_at >= ? AND change_points > 0", todayStart).
Select("COALESCE(SUM(change_points),0)").Scan(&todayPoints)
resp.OK(c, gin.H{
"user_count": userCount, // 用户总数
"game_count": gameCount, // 上架游戏数
"order_count": orderCount, // 订单总数
"today_plays": todayPlays, // 今日游玩次数
"today_signs": todaySigns, // 今日签到人数
"today_points": todayPoints, // 今日发放积分
})
}
// AdminThemes 主题列表 + 当前启用主题
func AdminThemes(c *gin.Context) {
var themes []model.Theme
database.DB.Where("status = 1").Order("sort").Find(&themes)
resp.OK(c, gin.H{
"list": themes,
"active": service.GetConfig(model.ConfKeyActiveTheme, "arcade"),
})
}
// switchThemeReq 切换主题请求
type switchThemeReq struct {
Code string `json:"code" binding:"required"` // 目标主题编码
}
// AdminSwitchTheme 切换全站主题(写 site_configs.active_theme,前台下次拉取生效)
func AdminSwitchTheme(c *gin.Context) {
var req switchThemeReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数错误")
return
}
var theme model.Theme
if err := database.DB.Where("code = ? AND status = 1", req.Code).First(&theme).Error; err != nil {
resp.Fail(c, "主题不存在")
return
}
if err := service.SetConfig(model.ConfKeyActiveTheme, theme.Code); err != nil {
resp.Fail(c, "切换失败")
return
}
resp.OK(c, gin.H{"active": theme.Code})
}
// updateSiteReq 站点信息更新请求
type updateSiteReq struct {
SiteName string `json:"site_name"` // 站点名称(空=不修改)
Announcement string `json:"announcement"` // 公告内容(空=不修改)
CarouselStyle string `json:"carousel_style"` // 大厅轮播效果:slide/fade/coverflow/cards(空=不修改)
CarouselInterval int `json:"carousel_interval"` // 自动轮播间隔秒(0=不修改,限 2~30)
}
// 允许的轮播效果集合(与前端 GameCarousel 组件实现一一对应)
var carouselStyles = map[string]bool{"slide": true, "fade": true, "coverflow": true, "cards": true}
// AdminUpdateSite 更新站点配置
func AdminUpdateSite(c *gin.Context) {
var req updateSiteReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数错误")
return
}
if req.SiteName != "" {
service.SetConfig(model.ConfKeySiteName, req.SiteName)
}
if req.Announcement != "" {
service.SetConfig(model.ConfKeyAnnouncement, req.Announcement)
}
if req.CarouselStyle != "" {
if !carouselStyles[req.CarouselStyle] {
resp.Fail(c, "不支持的轮播效果")
return
}
service.SetConfig(model.ConfKeyCarouselStyle, req.CarouselStyle)
}
if req.CarouselInterval > 0 {
if req.CarouselInterval < 2 || req.CarouselInterval > 30 {
resp.Fail(c, "轮播间隔需在 2~30 秒之间")
return
}
service.SetConfig(model.ConfKeyCarouselInterval, strconv.Itoa(req.CarouselInterval))
}
resp.OK(c, nil)
}
// AdminUsers 用户列表(支持用户名/昵称关键词搜索、账号状态筛选,分页)
func AdminUsers(c *gin.Context) {
page, size, offset := pageParams(c)
keyword := c.Query("keyword")
status, _ := strconv.Atoi(c.Query("status")) // 0=全部 1=正常 2=封禁
query := database.DB.Model(&model.User{})
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("username LIKE ? OR nickname LIKE ?", like, like)
}
if status == model.UserStatusNormal || status == model.UserStatusBanned {
query = query.Where("status = ?", status)
}
var total int64
list := make([]model.User, 0)
query.Count(&total)
query.Order("id DESC").Limit(size).Offset(offset).Find(&list)
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
// AdminUserPoints 指定用户的积分流水(后台调积分弹窗展示,分页)
func AdminUserPoints(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
page, size, offset := pageParams(c)
var total int64
list := make([]model.PointRecord, 0)
database.DB.Model(&model.PointRecord{}).Where("user_id = ?", id).Count(&total)
database.DB.Where("user_id = ?", id).Order("id DESC").Limit(size).Offset(offset).Find(&list)
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
// adminUpdateUserReq 用户更新请求
type adminUpdateUserReq struct {
Status int `json:"status"` // 账号状态:1正常 2封禁(0=不修改)
PointsOp string `json:"points_op"` // 积分操作:recharge充值 / deduct扣除 / set调整为(空=不操作)
PointsValue int `json:"points_value"` // 操作数值:充值/扣除为变动量(>0),调整为目标余额(>=0)
PointsAdjust int `json:"points_adjust"` // 兼容旧版:正加负减(0=不操作,新版前端不再使用)
Remark string `json:"remark"` // 流水备注
VipLevel *int `json:"vip_level"` // 目标VIP等级(0=取消VIP,1~5;nil=不修改)
VipAddDays int `json:"vip_add_days"` // VIP有效期增减天数(0=不修改)
}
// AdminUpdateUser 封禁/解封、积分充值/扣除/调整、调整VIP
func AdminUpdateUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var req adminUpdateUserReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数错误")
return
}
var user model.User
if err := database.DB.First(&user, id).Error; err != nil {
resp.Fail(c, "用户不存在")
return
}
// 修改账号状态
if req.Status == model.UserStatusNormal || req.Status == model.UserStatusBanned {
if user.Role == model.RoleAdmin && req.Status == model.UserStatusBanned {
resp.Fail(c, "不能封禁管理员")
return
}
database.DB.Model(&user).Update("status", req.Status)
}
// 调整VIP等级/有效期
if req.VipLevel != nil || req.VipAddDays != 0 {
now := time.Now().Unix()
newLevel := user.VipLevel
newExpire := user.VipExpire
if req.VipLevel != nil {
if *req.VipLevel < 0 || *req.VipLevel > 5 {
resp.Fail(c, "VIP等级需在 0~5 之间")
return
}
newLevel = *req.VipLevel
if newLevel == 0 {
// 取消VIP:到期时间一并清零
newExpire = 0
} else if newExpire <= now {
// 从无到有或已过期:默认给 30 天
newExpire = now + 30*86400
}
}
if req.VipAddDays != 0 {
if newLevel == 0 {
resp.Fail(c, "请先设置VIP等级再调整天数")
return
}
base := newExpire
if base < now {
base = now
}
newExpire = base + int64(req.VipAddDays)*86400
if newExpire <= now {
// 扣到过期以前:直接取消VIP
newLevel, newExpire = 0, 0
}
}
database.DB.Model(&user).Updates(map[string]any{"vip_level": newLevel, "vip_expire": newExpire})
}
// 积分操作:充值 / 扣除 / 调整为目标值(走统一积分服务,自动写流水)
if req.PointsOp != "" {
change := 0
defRemark := ""
switch req.PointsOp {
case "recharge": // 充值:加分
if req.PointsValue <= 0 {
resp.Fail(c, "充值数量需大于 0")
return
}
change = req.PointsValue
defRemark = "后台充值"
case "deduct": // 扣除:减分(余额不足会被积分服务拦下)
if req.PointsValue <= 0 {
resp.Fail(c, "扣除数量需大于 0")
return
}
change = -req.PointsValue
defRemark = "后台扣除"
case "set": // 调整为:直接设定目标余额,按差额记流水
if req.PointsValue < 0 {
resp.Fail(c, "目标积分不能为负数")
return
}
change = req.PointsValue - user.Points
defRemark = fmt.Sprintf("后台调整为 %d", req.PointsValue)
default:
resp.Fail(c, "未知的积分操作类型")
return
}
if change != 0 {
remark := req.Remark
if remark == "" {
remark = defRemark
}
if _, err := service.ChangePoints(nil, user.ID, change, model.PointTypeAdmin, 0, remark); err != nil {
resp.Fail(c, err.Error())
return
}
}
} else if req.PointsAdjust != 0 {
// 兼容旧版协议:正加负减
remark := req.Remark
if remark == "" {
remark = "后台手动调整"
}
if _, err := service.ChangePoints(nil, user.ID, req.PointsAdjust, model.PointTypeAdmin, 0, remark); err != nil {
resp.Fail(c, err.Error())
return
}
}
resp.OK(c, nil)
}
// AdminGames 游戏列表(含下架游戏)
func AdminGames(c *gin.Context) {
list := make([]model.Game, 0)
database.DB.Order("sort").Find(&list)
resp.OK(c, list)
}
// adminUpdateGameReq 游戏更新请求
type adminUpdateGameReq struct {
Price *int `json:"price"` // 价格(nil=不修改)
Status *int `json:"status"` // 状态:1上架 2下架(nil=不修改)
Sort *int `json:"sort"` // 排序值(nil=不修改)
ScorePerPoint *int `json:"score_per_point"` // 得分换算:多少分换1积分(0=不产积分;nil=不修改)
MaxPointsPerPlay *int `json:"max_points_per_play"` // 单局最多可得积分(nil=不修改)
Carousel *int `json:"carousel"` // 大厅轮播:0=不参与,>0=参与并按值升序(nil=不修改)
}
// AdminUpdateGame 修改游戏价格/上下架/排序/积分规则
func AdminUpdateGame(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var req adminUpdateGameReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数错误")
return
}
updates := map[string]any{}
if req.Price != nil && *req.Price >= 0 {
updates["price"] = *req.Price
}
if req.Status != nil && (*req.Status == 1 || *req.Status == 2) {
updates["status"] = *req.Status
}
if req.Sort != nil {
updates["sort"] = *req.Sort
}
if req.ScorePerPoint != nil && *req.ScorePerPoint >= 0 {
updates["score_per_point"] = *req.ScorePerPoint
}
if req.MaxPointsPerPlay != nil && *req.MaxPointsPerPlay >= 0 {
updates["max_points_per_play"] = *req.MaxPointsPerPlay
}
if req.Carousel != nil && *req.Carousel >= 0 {
updates["carousel"] = *req.Carousel
}
if len(updates) == 0 {
resp.Fail(c, "没有可更新的字段")
return
}
if err := database.DB.Model(&model.Game{}).Where("id = ?", id).Updates(updates).Error; err != nil {
resp.Fail(c, "保存失败,请重试")
return
}
resp.OK(c, nil)
}
// adminOrderItem 订单列表项(附下单用户信息)
type adminOrderItem struct {
model.Order
Username string `json:"username"` // 用户账号
Nickname string `json:"nickname"` // 用户昵称
}
// adminOrderQuery 构造订单查询(列表、计数与合计共用同一组筛选条件)
// rangeKey 时间范围:today=今天 / 7d=近7天 / 30d=近30天 / 空=全部
func adminOrderQuery(orderNo, userKw, rangeKey string) *gorm.DB {
q := database.DB.Table("orders o").
Joins("LEFT JOIN users u ON u.id = o.user_id").
Where("o.deleted_at = 0")
if orderNo != "" {
q = q.Where("o.order_no LIKE ?", "%"+orderNo+"%")
}
if userKw != "" {
q = q.Where("u.username LIKE ?", "%"+userKw+"%")
}
now := time.Now()
switch rangeKey {
case "today":
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
q = q.Where("o.created_at >= ?", day.Unix())
case "7d":
q = q.Where("o.created_at >= ?", now.AddDate(0, 0, -7).Unix())
case "30d":
q = q.Where("o.created_at >= ?", now.AddDate(0, 0, -30).Unix())
}
return q
}
// AdminOrders 全站订单列表(支持按订单号、用户账号搜索与时间范围筛选,分页,返回筛选结果的积分合计)
func AdminOrders(c *gin.Context) {
page, size, offset := pageParams(c)
orderNo := c.Query("order_no")
userKw := c.Query("user")
rangeKey := c.Query("range")
var total int64
var sum int64
list := make([]adminOrderItem, 0)
adminOrderQuery(orderNo, userKw, rangeKey).Count(&total)
adminOrderQuery(orderNo, userKw, rangeKey).Select("COALESCE(SUM(o.total_points),0)").Scan(&sum)
adminOrderQuery(orderNo, userKw, rangeKey).
Select("o.*, u.username, u.nickname").
Order("o.id DESC").Limit(size).Offset(offset).Scan(&list)
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size, "sum_points": sum})
}

147
internal/handler/aiconf.go Normal file
View File

@@ -0,0 +1,147 @@
package handler
import (
"context"
"time"
"github.com/gin-gonic/gin"
"nl-game-api-gin/internal/ai"
"nl-game-api-gin/internal/config"
"nl-game-api-gin/internal/model"
"nl-game-api-gin/internal/service"
"nl-game-api-gin/pkg/resp"
)
// aiProviderConf 单个提供方的当前生效配置(后台展示用)
type aiProviderConf struct {
APIKey string `json:"api_key"` // 生效中的 API Key(仅超管可见)
BaseURL string `json:"base_url"` // 生效中的接口地址
Model string `json:"model"` // 生效中的模型名
KeySource string `json:"key_source"` // Key 来源:后台配置/配置文件/未配置
}
// effectiveAIConf 计算某提供方的生效配置:数据库非空项覆盖 config.yaml
func effectiveAIConf(provider string) aiProviderConf {
var base config.LLMConf
var kKey, kBase, kModel string
if provider == ai.ProviderSpark {
base, kKey, kBase, kModel = config.C.AI.Spark, model.ConfKeyAISparkKey, model.ConfKeyAISparkBase, model.ConfKeyAISparkModel
} else {
base, kKey, kBase, kModel = config.C.AI.DeepSeek, model.ConfKeyAIDeepSeekKey, model.ConfKeyAIDeepSeekBase, model.ConfKeyAIDeepSeekModel
}
out := aiProviderConf{APIKey: base.APIKey, BaseURL: base.BaseURL, Model: base.Model, KeySource: "配置文件"}
if v := service.GetConfig(kKey, ""); v != "" {
out.APIKey, out.KeySource = v, "后台配置"
}
if v := service.GetConfig(kBase, ""); v != "" {
out.BaseURL = v
}
if v := service.GetConfig(kModel, ""); v != "" {
out.Model = v
}
if out.APIKey == "" {
out.KeySource = "未配置"
}
return out
}
// AdminAIConfig 后台:查看当前全站AI模型选择、两家大模型的生效配置与获取指引
func AdminAIConfig(c *gin.Context) {
resp.OK(c, gin.H{
"provider": ai.GlobalProvider(), // 全站统一的对战AI提供方(玩家前台只选难度)
"spark": effectiveAIConf(ai.ProviderSpark),
"deepseek": effectiveAIConf(ai.ProviderDeepSeek),
// 申请指引(前端展示)
"guide": gin.H{
"spark": "讯飞开放平台 console.xfyun.cn 创建应用后,在「Spark Lite → HTTP 服务接口认证信息」复制 APIPassword 填入即可,Lite 模型免费",
"deepseek": "DeepSeek 开放平台 platform.deepseek.com 注册后创建 API Key(sk- 开头),按 token 计费",
},
})
}
// aiSaveReq 保存AI配置请求体(保存后立即生效,无需重启)
type aiSaveReq struct {
Provider *string `json:"provider"` // 全站AI提供方:rule/spark/deepseek(nil=不修改)
Spark *struct {
APIKey *string `json:"api_key"` // 留空=清除后台配置回退到配置文件
BaseURL *string `json:"base_url"` // 接口地址
Model *string `json:"model"` // 模型名
} `json:"spark"`
DeepSeek *struct {
APIKey *string `json:"api_key"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
} `json:"deepseek"`
}
// AdminSaveAIConfig 后台:保存大模型 API Key 等配置到数据库(覆盖 config.yaml)
func AdminSaveAIConfig(c *gin.Context) {
var req aiSaveReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数有误")
return
}
// 切换全站AI模型(新建房间立即生效,已开局房间不受影响)
if req.Provider != nil {
p := *req.Provider
if p != ai.ProviderRule && p != ai.ProviderSpark && p != ai.ProviderDeepSeek {
resp.Fail(c, "AI提供方只能是 rule / spark / deepseek")
return
}
service.SetConfig(model.ConfKeyAIProvider, p)
}
if req.Spark != nil {
saveIfSet(model.ConfKeyAISparkKey, req.Spark.APIKey)
saveIfSet(model.ConfKeyAISparkBase, req.Spark.BaseURL)
saveIfSet(model.ConfKeyAISparkModel, req.Spark.Model)
}
if req.DeepSeek != nil {
saveIfSet(model.ConfKeyAIDeepSeekKey, req.DeepSeek.APIKey)
saveIfSet(model.ConfKeyAIDeepSeekBase, req.DeepSeek.BaseURL)
saveIfSet(model.ConfKeyAIDeepSeekModel, req.DeepSeek.Model)
}
resp.OK(c, gin.H{
"provider": ai.GlobalProvider(),
"spark": effectiveAIConf(ai.ProviderSpark),
"deepseek": effectiveAIConf(ai.ProviderDeepSeek),
})
}
// saveIfSet 请求里带了该字段才写库(nil=不动,空串=清除让其回退配置文件)
func saveIfSet(key string, val *string) {
if val != nil {
service.SetConfig(key, *val)
}
}
// aiTestReq 连通性测试请求体
type aiTestReq struct {
Provider string `json:"provider" binding:"required,oneof=spark deepseek"` // 要测试的提供方
}
// AdminTestAI 后台:向大模型发一条测试消息,验证 Key 是否可用并测量延迟
func AdminTestAI(c *gin.Context) {
var req aiTestReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数有误")
return
}
client := ai.ClientForTest(req.Provider)
if client == nil {
resp.Fail(c, "该提供方还没有配置 API Key")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
start := time.Now()
reply, err := client.Chat(ctx, "你是连通性测试助手。", "请只回复两个字:在线", 0.1)
if err != nil {
resp.Fail(c, "连接失败:"+err.Error())
return
}
resp.OK(c, gin.H{
"reply": reply, // 模型回复内容
"latency_ms": time.Since(start).Milliseconds(), // 往返延迟(毫秒)
})
}

View File

@@ -0,0 +1,129 @@
package handler
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
"nl-game-api-gin/internal/database"
"nl-game-api-gin/internal/model"
"nl-game-api-gin/internal/room"
"nl-game-api-gin/pkg/resp"
)
// dayStart 返回某时刻所在自然日的零点时间戳(本地时区)
func dayStart(t time.Time) int64 {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).Unix()
}
// AdminAnalytics 运营数据分析:日活趋势、游玩时长、平均时长、最火游戏、当前在线
// 日活口径:当天有登录 / 游玩 / 签到 / 对战任一行为的去重用户数
func AdminAnalytics(c *gin.Context) {
now := time.Now()
todayStart := dayStart(now)
const trendDays = 14
since := todayStart - int64(trendDays-1)*86400
// ---- 日活趋势:四张行为表按天去重合并 ----
type dayRow struct {
D string `gorm:"column:d"`
N int `gorm:"column:n"`
}
var rows []dayRow
database.DB.Raw(`SELECT d, COUNT(DISTINCT uid) AS n FROM (
SELECT user_id AS uid, FROM_UNIXTIME(created_at, '%Y-%m-%d') AS d FROM login_logs WHERE created_at >= ?
UNION ALL SELECT user_id, FROM_UNIXTIME(created_at, '%Y-%m-%d') FROM game_records WHERE created_at >= ?
UNION ALL SELECT user_id, FROM_UNIXTIME(created_at, '%Y-%m-%d') FROM sign_ins WHERE created_at >= ?
UNION ALL SELECT user_id, FROM_UNIXTIME(created_at, '%Y-%m-%d') FROM battle_records WHERE created_at >= ?
) t GROUP BY d ORDER BY d`, since, since, since, since).Scan(&rows)
dauMap := map[string]int{}
for _, r := range rows {
dauMap[r.D] = r.N
}
// 补齐没有数据的日期(趋势图连续)
trend := make([]gin.H, 0, trendDays)
for i := 0; i < trendDays; i++ {
day := time.Unix(todayStart-int64(trendDays-1-i)*86400, 0)
key := day.Format("2006-01-02")
trend = append(trend, gin.H{"date": day.Format("01-02"), "full": key, "count": dauMap[key]})
}
dauToday := dauMap[now.Format("2006-01-02")]
dauYesterday := dauMap[time.Unix(todayStart-86400, 0).Format("2006-01-02")]
// ---- 游玩时长与局数(单机 + 联机对战合并) ----
type sumRow struct {
Dur int64 `gorm:"column:dur"`
Cnt int64 `gorm:"column:cnt"`
}
sumOf := func(table string, from int64) sumRow {
var r sumRow
database.DB.Raw(fmt.Sprintf(
`SELECT COALESCE(SUM(duration),0) AS dur, COUNT(*) AS cnt FROM %s WHERE created_at >= ?`, table), from).Scan(&r)
return r
}
gToday, gTotal := sumOf("game_records", todayStart), sumOf("game_records", 0)
bToday, bTotal := sumOf("battle_records", todayStart), sumOf("battle_records", 0)
secToday := gToday.Dur + bToday.Dur
secTotal := gTotal.Dur + bTotal.Dur
playsToday := gToday.Cnt + bToday.Cnt
playsTotal := gTotal.Cnt + bTotal.Cnt
avgSec := int64(0)
if playsTotal > 0 {
avgSec = secTotal / playsTotal
}
avgSecToday := int64(0)
if playsToday > 0 {
avgSecToday = secToday / playsToday
}
// ---- 最火游戏:近 7 天局数排序(单机+对战合并),附平均时长与历史总局数 ----
weekAgo := now.Unix() - 7*86400
type hotRow struct {
GameID int `gorm:"column:game_id"`
Plays int `gorm:"column:plays"`
Dur int64 `gorm:"column:dur"`
}
var hot []hotRow
database.DB.Raw(`SELECT game_id, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS dur FROM (
SELECT game_id, duration FROM game_records WHERE created_at >= ?
UNION ALL SELECT game_id, duration FROM battle_records WHERE created_at >= ?
) t GROUP BY game_id ORDER BY plays DESC LIMIT 8`, weekAgo, weekAgo).Scan(&hot)
ids := make([]int, 0, len(hot))
for _, h := range hot {
ids = append(ids, h.GameID)
}
gameMap := map[int]model.Game{}
if len(ids) > 0 {
var games []model.Game
database.DB.Where("id IN ?", ids).Find(&games)
for _, g := range games {
gameMap[g.ID] = g
}
}
hotGames := make([]gin.H, 0, len(hot))
for _, h := range hot {
g, ok := gameMap[h.GameID]
if !ok {
continue
}
avg := int64(0)
if h.Plays > 0 {
avg = h.Dur / int64(h.Plays)
}
hotGames = append(hotGames, gin.H{
"game_id": g.ID, "code": g.Code, "name": g.Name, "icon": g.Icon, "category": g.Category,
"plays_7d": h.Plays, "avg_seconds": avg, "play_count": g.PlayCount,
})
}
resp.OK(c, gin.H{
"dau_today": dauToday, // 今日日活
"dau_yesterday": dauYesterday, // 昨日日活(对比)
"dau_trend": trend, // 近 14 天日活趋势
"online_now": room.OnlineCount(), // 当前在线人数(WebSocket 连接数)
"play_seconds_today": secToday, // 今日游玩总时长(秒)
"play_seconds_total": secTotal, // 累计游玩总时长(秒)
"plays_today": playsToday, // 今日总局数
"plays_total": playsTotal, // 累计总局数
"avg_seconds": avgSec, // 平均单局时长(秒,历史)
"avg_seconds_today": avgSecToday, // 平均单局时长(秒,今日)
"hot_games": hotGames, // 近 7 天最火游戏 Top8
})
}

107
internal/handler/auth.go Normal file
View File

@@ -0,0 +1,107 @@
// Package handler 存放全部 HTTP 接口处理函数
package handler
import (
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"nl-game-api-gin/internal/database"
"nl-game-api-gin/internal/model"
"nl-game-api-gin/internal/service"
"nl-game-api-gin/pkg/jwtutil"
"nl-game-api-gin/pkg/resp"
)
// registerReq 注册请求体
type registerReq struct {
Username string `json:"username" binding:"required,min=3,max=20"` // 用户名:3-20位
Password string `json:"password" binding:"required,min=6,max=32"` // 密码:6-32位
Nickname string `json:"nickname" binding:"max=20"` // 昵称:可选,默认同用户名
Avatar string `json:"avatar"` // emoji 头像:可选
}
// Register 用户注册:校验重名、加密密码、发注册礼积分
func Register(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数有误:用户名3-20位、密码6-32位")
return
}
req.Username = strings.TrimSpace(req.Username)
// 用户名查重
var count int64
database.DB.Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
if count > 0 {
resp.Fail(c, "用户名已被占用")
return
}
// bcrypt 加密密码
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
resp.Fail(c, "密码加密失败")
return
}
if req.Nickname == "" {
req.Nickname = req.Username
}
if req.Avatar == "" {
req.Avatar = "🙂"
}
user := model.User{
Username: req.Username, Password: string(hash),
Nickname: req.Nickname, Avatar: req.Avatar, Role: model.RoleNormal,
}
if err := database.DB.Create(&user).Error; err != nil {
resp.Fail(c, "注册失败,请稍后重试")
return
}
// 注册礼积分(金额可在后台配置)
gift := service.GetConfigInt(model.ConfKeyRegisterGift, 200)
if gift > 0 {
service.ChangePoints(nil, user.ID, gift, model.PointTypeRegister, 0, "新用户注册礼")
user.Points = gift
}
token, _ := jwtutil.Generate(user.ID, user.Role)
resp.OK(c, gin.H{"token": token, "user": user})
}
// loginReq 登录请求体
type loginReq struct {
Username string `json:"username" binding:"required"` // 用户名
Password string `json:"password" binding:"required"` // 密码
}
// Login 用户登录:校验密码、记录登录日志、签发 Token
func Login(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "请输入用户名和密码")
return
}
var user model.User
if err := database.DB.Where("username = ?", strings.TrimSpace(req.Username)).First(&user).Error; err != nil {
resp.Fail(c, "用户名或密码错误")
return
}
// bcrypt 校验密码
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) != nil {
resp.Fail(c, "用户名或密码错误")
return
}
if user.Status == model.UserStatusBanned {
resp.Fail(c, "账号已被封禁,请联系管理员")
return
}
// 写登录日志(IP + 浏览器UA)
database.DB.Create(&model.LoginLog{
UserID: user.ID, IP: c.ClientIP(), UserAgent: c.GetHeader("User-Agent"),
})
token, err := jwtutil.Generate(user.ID, user.Role)
if err != nil {
resp.Fail(c, "登录凭证生成失败")
return
}
resp.OK(c, gin.H{"token": token, "user": user})
}

181
internal/handler/chat.go Normal file
View File

@@ -0,0 +1,181 @@
package handler
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"nl-game-api-gin/internal/database"
"nl-game-api-gin/internal/middleware"
"nl-game-api-gin/internal/model"
"nl-game-api-gin/internal/room"
"nl-game-api-gin/pkg/resp"
)
// ChatConversations 会话列表:每个聊过天的好友一条,带最后一条消息与未读数
func ChatConversations(c *gin.Context) {
uid := middleware.UID(c)
// 每个对端的最后一条消息ID
type peerRow struct {
Peer int `gorm:"column:peer"`
LastID int `gorm:"column:last_id"`
}
var peers []peerRow
database.DB.Raw(`SELECT IF(from_id = ?, to_id, from_id) AS peer, MAX(id) AS last_id
FROM chat_messages WHERE from_id = ? OR to_id = ?
GROUP BY peer ORDER BY last_id DESC LIMIT 100`, uid, uid, uid).Scan(&peers)
if len(peers) == 0 {
resp.OK(c, gin.H{"list": []any{}, "total_unread": 0})
return
}
lastIDs := make([]int, 0, len(peers))
peerIDs := make([]int, 0, len(peers))
for _, p := range peers {
lastIDs = append(lastIDs, p.LastID)
peerIDs = append(peerIDs, p.Peer)
}
// 最后一条消息内容
var lastMsgs []model.ChatMessage
database.DB.Where("id IN ?", lastIDs).Find(&lastMsgs)
msgMap := map[int]model.ChatMessage{}
for _, m := range lastMsgs {
msgMap[m.ID] = m
}
// 各对端发给我的未读数
type unreadRow struct {
FromID int `gorm:"column:from_id"`
Cnt int `gorm:"column:cnt"`
}
var unreads []unreadRow
database.DB.Raw(`SELECT from_id, COUNT(*) AS cnt FROM chat_messages
WHERE to_id = ? AND is_read = 0 GROUP BY from_id`, uid).Scan(&unreads)
unreadMap := map[int]int{}
total := 0
for _, u := range unreads {
unreadMap[u.FromID] = u.Cnt
total += u.Cnt
}
briefs := loadBriefs(peerIDs)
type convItem struct {
Peer userBrief `json:"peer"` // 对方信息
LastMsg string `json:"last_msg"` // 最后一条消息内容
LastAt int64 `json:"last_at"` // 最后一条消息时间
FromMe bool `json:"from_me"` // 最后一条是否我发的
Unread int `json:"unread"` // 未读数
}
list := make([]convItem, 0, len(peers))
for _, p := range peers {
b, ok := briefs[p.Peer]
if !ok {
continue
}
m := msgMap[p.LastID]
list = append(list, convItem{
Peer: b, LastMsg: m.Content, LastAt: m.CreatedAt,
FromMe: m.FromID == uid, Unread: unreadMap[p.Peer],
})
}
resp.OK(c, gin.H{"list": list, "total_unread": total})
}
// ChatMessages 与某人的聊天记录(倒序分页,返回升序;拉取即把对方发来的标记已读)
func ChatMessages(c *gin.Context) {
uid := middleware.UID(c)
peer, _ := strconv.Atoi(c.Query("peer_id"))
if peer <= 0 {
resp.Fail(c, "参数有误")
return
}
beforeID, _ := strconv.Atoi(c.Query("before_id"))
size, _ := strconv.Atoi(c.Query("size"))
if size <= 0 || size > 100 {
size = 50
}
q := database.DB.Where("(from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?)", uid, peer, peer, uid)
if beforeID > 0 {
q = q.Where("id < ?", beforeID)
}
var msgs []model.ChatMessage
q.Order("id DESC").Limit(size + 1).Find(&msgs)
hasMore := len(msgs) > size
if hasMore {
msgs = msgs[:size]
}
// 反转为时间升序(前端从上到下渲染)
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
// 拉取历史即视为已读
database.DB.Model(&model.ChatMessage{}).
Where("from_id = ? AND to_id = ? AND is_read = 0", peer, uid).
Update("is_read", 1)
briefs := loadBriefs([]int{peer})
resp.OK(c, gin.H{"list": msgs, "has_more": hasMore, "peer": briefs[peer]})
}
// chatSendBody 发送私聊请求体
type chatSendBody struct {
ToID int `json:"to_id" binding:"required"` // 接收方用户ID
Content string `json:"content" binding:"required"` // 消息内容
}
// ChatSend 发送私聊:仅好友之间可发;对方在线时通过 WebSocket 实时推送
func ChatSend(c *gin.Context) {
uid := middleware.UID(c)
var req chatSendBody
if err := c.ShouldBindJSON(&req); err != nil || req.ToID == uid {
resp.Fail(c, "参数有误")
return
}
content := strings.TrimSpace(req.Content)
if content == "" {
resp.Fail(c, "消息内容不能为空")
return
}
if len([]rune(content)) > 500 {
resp.Fail(c, "消息太长(最多 500 字)")
return
}
if !isFriend(uid, req.ToID) {
resp.Fail(c, "你们还不是好友,先添加对方为好友吧")
return
}
msg := model.ChatMessage{FromID: uid, ToID: req.ToID, Content: content}
if err := database.DB.Create(&msg).Error; err != nil {
resp.Fail(c, "发送失败,请稍后重试")
return
}
// 对方在线:实时推送消息(含发送者摘要,便于会话列表直接更新)
var me model.User
database.DB.First(&me, uid)
room.PushToUser(req.ToID, "chat_msg", gin.H{"msg": msg, "from": briefOf(&me, true)})
resp.OK(c, msg)
}
// chatReadBody 标记已读请求体
type chatReadBody struct {
PeerID int `json:"peer_id" binding:"required"` // 对端用户ID
}
// ChatRead 把某人发给我的消息全部标记已读(聊天窗口打开时收到新消息调用)
func ChatRead(c *gin.Context) {
uid := middleware.UID(c)
var req chatReadBody
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数有误")
return
}
database.DB.Model(&model.ChatMessage{}).
Where("from_id = ? AND to_id = ? AND is_read = 0", req.PeerID, uid).
Update("is_read", 1)
resp.OK(c, nil)
}
// ChatUnread 我的未读私聊总数(导航角标)
func ChatUnread(c *gin.Context) {
uid := middleware.UID(c)
var cnt int64
database.DB.Model(&model.ChatMessage{}).Where("to_id = ? AND is_read = 0", uid).Count(&cnt)
resp.OK(c, gin.H{"unread": cnt})
}

View File

@@ -0,0 +1,38 @@
package handler
import (
"encoding/json"
"github.com/gin-gonic/gin"
"nl-game-api-gin/internal/database"
"nl-game-api-gin/internal/model"
"nl-game-api-gin/internal/service"
"nl-game-api-gin/pkg/resp"
)
// SiteConfigPublic 公开的站点配置:站点名、公告、当前主题(含 CSS 变量),无需登录
func SiteConfigPublic(c *gin.Context) {
// 读取当前启用的主题(后台可切换)
activeCode := service.GetConfig(model.ConfKeyActiveTheme, "arcade")
var theme model.Theme
if err := database.DB.Where("code = ? AND status = 1", activeCode).First(&theme).Error; err != nil {
// 配置的主题不存在时回退到第一个可用主题
database.DB.Where("status = 1").Order("sort").First(&theme)
}
// 把 CSS 变量 JSON 解析成对象,前端直接注入 :root
vars := map[string]string{}
json.Unmarshal([]byte(theme.CSSVars), &vars)
resp.OK(c, gin.H{
"site_name": service.GetConfig(model.ConfKeySiteName, "像素游戏厅"),
"announcement": service.GetConfig(model.ConfKeyAnnouncement, ""),
// 大厅轮播配置:效果样式 + 自动播放间隔(参与轮播的游戏由 games.carousel 决定)
"carousel_style": service.GetConfig(model.ConfKeyCarouselStyle, "slide"),
"carousel_interval": service.GetConfigInt(model.ConfKeyCarouselInterval, 5),
"theme": gin.H{
"code": theme.Code,
"name": theme.Name,
"css_vars": vars,
},
})
}

275
internal/handler/friend.go Normal file
View File

@@ -0,0 +1,275 @@
package handler
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"nl-game-api-gin/internal/database"
"nl-game-api-gin/internal/middleware"
"nl-game-api-gin/internal/model"
"nl-game-api-gin/internal/room"
"nl-game-api-gin/pkg/resp"
)
// userBrief 用户摘要(好友列表/会话列表/搜索结果通用)
type userBrief struct {
UserID int `json:"user_id"` // 用户ID
Username string `json:"username"` // 用户名
Nickname string `json:"nickname"` // 昵称
Avatar string `json:"avatar"` // emoji 头像
Online bool `json:"online"` // 是否在线(有活跃 WebSocket 连接)
}
// briefOf 把用户模型转为摘要
func briefOf(u *model.User, online bool) userBrief {
return userBrief{UserID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar, Online: online}
}
// loadBriefs 批量加载用户摘要(自动附带在线状态)
func loadBriefs(ids []int) map[int]userBrief {
out := map[int]userBrief{}
if len(ids) == 0 {
return out
}
var users []model.User
database.DB.Where("id IN ?", ids).Find(&users)
online := room.OnlineMap(ids)
for i := range users {
u := &users[i]
out[u.ID] = briefOf(u, online[u.ID])
}
return out
}
// isFriend 两人是否已互为好友(任一方向的已通过记录)
func isFriend(a, b int) bool {
var cnt int64
database.DB.Model(&model.Friend{}).
Where("status = ? AND ((user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?))",
model.FriendStatusAccepted, a, b, b, a).
Count(&cnt)
return cnt > 0
}
// FriendList 我的好友列表(在线的排在前面)
func FriendList(c *gin.Context) {
uid := middleware.UID(c)
var rels []model.Friend
database.DB.Where("status = ? AND (user_id = ? OR friend_id = ?)", model.FriendStatusAccepted, uid, uid).
Order("updated_at DESC").Find(&rels)
ids := make([]int, 0, len(rels))
sinceMap := map[int]int64{}
for _, r := range rels {
peer := r.UserID
if peer == uid {
peer = r.FriendID
}
ids = append(ids, peer)
sinceMap[peer] = r.UpdatedAt
}
briefs := loadBriefs(ids)
type friendItem struct {
userBrief
Since int64 `json:"since"` // 成为好友时间
}
list := make([]friendItem, 0, len(ids))
for _, id := range ids {
if b, ok := briefs[id]; ok {
list = append(list, friendItem{userBrief: b, Since: sinceMap[id]})
}
}
// 在线的排前面(保持各自原有相对顺序)
sorted := make([]friendItem, 0, len(list))
for _, it := range list {
if it.Online {
sorted = append(sorted, it)
}
}
for _, it := range list {
if !it.Online {
sorted = append(sorted, it)
}
}
resp.OK(c, sorted)
}
// FriendRequests 好友申请:received=发给我的待处理申请,sent=我发出的待验证申请
func FriendRequests(c *gin.Context) {
uid := middleware.UID(c)
var in, out []model.Friend
database.DB.Where("friend_id = ? AND status = ?", uid, model.FriendStatusPending).Order("id DESC").Find(&in)
database.DB.Where("user_id = ? AND status = ?", uid, model.FriendStatusPending).Order("id DESC").Find(&out)
ids := make([]int, 0, len(in)+len(out))
for _, r := range in {
ids = append(ids, r.UserID)
}
for _, r := range out {
ids = append(ids, r.FriendID)
}
briefs := loadBriefs(ids)
type reqItem struct {
ID int `json:"id"` // 关系ID(同意/拒绝时用)
User userBrief `json:"user"` // 对方信息
At int64 `json:"at"` // 申请时间
}
received := make([]reqItem, 0, len(in))
for _, r := range in {
received = append(received, reqItem{ID: r.ID, User: briefs[r.UserID], At: r.CreatedAt})
}
sent := make([]reqItem, 0, len(out))
for _, r := range out {
sent = append(sent, reqItem{ID: r.ID, User: briefs[r.FriendID], At: r.CreatedAt})
}
resp.OK(c, gin.H{"received": received, "sent": sent})
}
// FriendSearch 按用户名/昵称搜索用户(加好友入口),附带与我的关系状态
func FriendSearch(c *gin.Context) {
uid := middleware.UID(c)
kw := strings.TrimSpace(c.Query("keyword"))
if kw == "" {
resp.OK(c, []any{})
return
}
like := "%" + kw + "%"
var users []model.User
database.DB.Where("id != ? AND status = ? AND (username LIKE ? OR nickname LIKE ?)",
uid, model.UserStatusNormal, like, like).Limit(10).Find(&users)
ids := make([]int, 0, len(users))
for _, u := range users {
ids = append(ids, u.ID)
}
online := room.OnlineMap(ids)
// 一次取出我与这批用户的全部关系,标注状态
var rels []model.Friend
if len(ids) > 0 {
database.DB.Where("(user_id = ? AND friend_id IN ?) OR (friend_id = ? AND user_id IN ?)", uid, ids, uid, ids).Find(&rels)
}
// relation: none=无关系 friend=已是好友 pending_out=我已申请 pending_in=对方已申请我
relMap := map[int]string{}
for _, r := range rels {
peer := r.UserID
dir := "pending_in"
if r.UserID == uid {
peer = r.FriendID
dir = "pending_out"
}
if r.Status == model.FriendStatusAccepted {
relMap[peer] = "friend"
} else if relMap[peer] != "friend" {
relMap[peer] = dir
}
}
type searchItem struct {
userBrief
Relation string `json:"relation"` // none/friend/pending_out/pending_in
}
list := make([]searchItem, 0, len(users))
for i := range users {
u := &users[i]
rel := relMap[u.ID]
if rel == "" {
rel = "none"
}
list = append(list, searchItem{userBrief: briefOf(u, online[u.ID]), Relation: rel})
}
resp.OK(c, list)
}
// friendReqBody 发起好友申请请求体
type friendReqBody struct {
UserID int `json:"user_id" binding:"required"` // 要添加的用户ID
}
// FriendRequest 发起好友申请:对方在线时实时推送;若对方已申请过我则直接成为好友
func FriendRequest(c *gin.Context) {
uid := middleware.UID(c)
var req friendReqBody
if err := c.ShouldBindJSON(&req); err != nil || req.UserID == uid {
resp.Fail(c, "参数有误")
return
}
var target model.User
if err := database.DB.First(&target, req.UserID).Error; err != nil || target.Status != model.UserStatusNormal {
resp.Fail(c, "用户不存在")
return
}
var me model.User
database.DB.First(&me, uid)
// 已有关系判断(两个方向)
var exist model.Friend
if err := database.DB.Where("user_id = ? AND friend_id = ?", uid, req.UserID).First(&exist).Error; err == nil {
if exist.Status == model.FriendStatusAccepted {
resp.Fail(c, "你们已经是好友了")
} else {
resp.Fail(c, "已发送过申请,等待对方同意")
}
return
}
var reverse model.Friend
if err := database.DB.Where("user_id = ? AND friend_id = ?", req.UserID, uid).First(&reverse).Error; err == nil {
if reverse.Status == model.FriendStatusAccepted {
resp.Fail(c, "你们已经是好友了")
return
}
// 对方已申请过我:视为互相想加,直接通过
database.DB.Model(&reverse).Update("status", model.FriendStatusAccepted)
room.PushToUser(req.UserID, "friend_event", gin.H{"kind": "accept", "user": briefOf(&me, true)})
resp.OK(c, gin.H{"relation": "friend", "msg": "对方也申请过你,已直接成为好友"})
return
}
rel := model.Friend{UserID: uid, FriendID: req.UserID, Status: model.FriendStatusPending}
if err := database.DB.Create(&rel).Error; err != nil {
resp.Fail(c, "申请失败,请稍后重试")
return
}
// 对方在线则实时提醒
room.PushToUser(req.UserID, "friend_event", gin.H{"kind": "request", "user": briefOf(&me, true)})
resp.OK(c, gin.H{"relation": "pending_out"})
}
// friendRespondBody 处理好友申请请求体
type friendRespondBody struct {
ID int `json:"id" binding:"required"` // 关系ID(来自申请列表)
Accept bool `json:"accept"` // true=同意 false=拒绝
}
// FriendRespond 同意/拒绝好友申请(同意后通知申请人)
func FriendRespond(c *gin.Context) {
uid := middleware.UID(c)
var req friendRespondBody
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数有误")
return
}
var rel model.Friend
if err := database.DB.First(&rel, req.ID).Error; err != nil || rel.FriendID != uid || rel.Status != model.FriendStatusPending {
resp.Fail(c, "申请不存在或已处理")
return
}
if !req.Accept {
database.DB.Delete(&rel)
resp.OK(c, gin.H{"relation": "none"})
return
}
database.DB.Model(&rel).Update("status", model.FriendStatusAccepted)
var me model.User
database.DB.First(&me, uid)
room.PushToUser(rel.UserID, "friend_event", gin.H{"kind": "accept", "user": briefOf(&me, true)})
resp.OK(c, gin.H{"relation": "friend"})
}
// FriendDelete 删除好友(清除双方全部关系记录,聊天记录保留)
func FriendDelete(c *gin.Context) {
uid := middleware.UID(c)
peer, _ := strconv.Atoi(c.Param("uid"))
if peer <= 0 {
resp.Fail(c, "参数有误")
return
}
database.DB.Where("(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)", uid, peer, peer, uid).
Delete(&model.Friend{})
resp.OK(c, nil)
}

500
internal/handler/game.go Normal file
View File

@@ -0,0 +1,500 @@
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})
}

64
internal/handler/rank.go Normal file
View File

@@ -0,0 +1,64 @@
package handler
import (
"github.com/gin-gonic/gin"
"nl-game-api-gin/internal/database"
"nl-game-api-gin/internal/middleware"
"nl-game-api-gin/internal/model"
"nl-game-api-gin/pkg/resp"
)
// rankUser 排行榜条目
type rankUser struct {
UserID int `json:"user_id"` // 用户ID
Nickname string `json:"nickname"` // 昵称
Avatar string `json:"avatar"` // 头像
TotalPoints int `json:"total_points"` // 累计积分
}
// PointsRank 积分排行榜(前50) + 我的名次
func PointsRank(c *gin.Context) {
list := make([]rankUser, 0)
database.DB.Table("users").
Select("id AS user_id, nickname, avatar, total_points").
Where("status = 1 AND deleted_at = 0").
Order("total_points DESC, id ASC").Limit(50).Scan(&list)
// 我的名次 = 比我累计积分高的人数 + 1
uid := middleware.UID(c)
var me model.User
database.DB.First(&me, uid)
var higher int64
database.DB.Model(&model.User{}).
Where("status = 1 AND (total_points > ? OR (total_points = ? AND id < ?))", me.TotalPoints, me.TotalPoints, uid).
Count(&higher)
resp.OK(c, gin.H{"list": list, "my_rank": higher + 1, "my_points": me.TotalPoints})
}
// gameRankItem 单游戏排行条目
type gameRankItem struct {
UserID int `json:"user_id"` // 用户ID
Nickname string `json:"nickname"` // 昵称
Avatar string `json:"avatar"` // 头像
Best int `json:"best"` // 该游戏最高分
}
// GameRank 指定游戏的最高分排行(每人取最高一条,前50)
func GameRank(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
}
list := make([]gameRankItem, 0)
database.DB.Raw(`SELECT t.user_id, u.nickname, u.avatar, t.best
FROM (SELECT user_id, MAX(score) AS best FROM game_records WHERE game_id = ? GROUP BY user_id) t
JOIN users u ON u.id = t.user_id AND u.status = 1 AND u.deleted_at = 0
ORDER BY t.best DESC, t.user_id ASC LIMIT 50`, game.ID).Scan(&list)
// 我的最高分
var myBest int
database.DB.Model(&model.GameRecord{}).
Where("user_id = ? AND game_id = ?", middleware.UID(c), game.ID).
Select("COALESCE(MAX(score),0)").Scan(&myBest)
resp.OK(c, gin.H{"game": game, "list": list, "my_best": myBest})
}

274
internal/handler/shop.go Normal file
View File

@@ -0,0 +1,274 @@
package handler
import (
"errors"
"fmt"
"math/rand"
"strconv"
"strings"
"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"
)
// PropList 道具商城列表(上架中)
func PropList(c *gin.Context) {
props := make([]model.Prop, 0)
database.DB.Where("status = 1").Order("sort").Find(&props)
resp.OK(c, props)
}
// cartItem 购物车条目(附商品名称与价格)
type cartItem struct {
ID int `json:"id"` // 购物车ID
ItemType int `json:"item_type"` // 1游戏 2道具
ItemID int `json:"item_id"` // 商品ID
Quantity int `json:"quantity"` // 数量
Name string `json:"name"` // 商品名称
Icon string `json:"icon"` // 商品图标
Price int `json:"price"` // 单价(积分)
}
// loadCartItems 加载用户购物车明细(游戏与道具分别联表取信息)
func loadCartItems(uid int) []cartItem {
list := make([]cartItem, 0)
// 游戏类条目
database.DB.Table("carts ct").
Select("ct.id, ct.item_type, ct.item_id, ct.quantity, g.name, g.icon, g.price").
Joins("JOIN games g ON g.id = ct.item_id").
Where("ct.user_id = ? AND ct.item_type = ?", uid, model.ItemTypeGame).Scan(&list)
// 道具类条目
var propItems []cartItem
database.DB.Table("carts ct").
Select("ct.id, ct.item_type, ct.item_id, ct.quantity, p.name, p.icon, p.price").
Joins("JOIN props p ON p.id = ct.item_id").
Where("ct.user_id = ? AND ct.item_type = ?", uid, model.ItemTypeProp).Scan(&propItems)
return append(list, propItems...)
}
// CartList 我的购物车明细
func CartList(c *gin.Context) {
list := loadCartItems(middleware.UID(c))
// 计算合计积分
total := 0
for _, it := range list {
total += it.Price * it.Quantity
}
resp.OK(c, gin.H{"list": list, "total": total})
}
// addCartReq 加入购物车请求体
type addCartReq struct {
ItemType int `json:"item_type" binding:"required,oneof=1 2"` // 1游戏 2道具
ItemID int `json:"item_id" binding:"required"` // 商品ID
Quantity int `json:"quantity"` // 数量(默认为1)
}
// AddCart 加入购物车:游戏查重不可重复购买,道具可累加数量
func AddCart(c *gin.Context) {
var req addCartReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数有误")
return
}
if req.Quantity < 1 {
req.Quantity = 1
}
uid := middleware.UID(c)
if req.ItemType == model.ItemTypeGame {
// 游戏:校验存在、非免费、未拥有
var game model.Game
if err := database.DB.Where("id = ? AND status = 1", req.ItemID).First(&game).Error; err != nil {
resp.Fail(c, "游戏不存在")
return
}
if game.Price == 0 {
resp.Fail(c, "免费游戏无需购买")
return
}
var owned int64
database.DB.Model(&model.UserGame{}).Where("user_id = ? AND game_id = ?", uid, game.ID).Count(&owned)
if owned > 0 {
resp.Fail(c, "已拥有该游戏")
return
}
req.Quantity = 1 // 游戏固定数量为1
} else {
var prop model.Prop
if err := database.DB.Where("id = ? AND status = 1", req.ItemID).First(&prop).Error; err != nil {
resp.Fail(c, "道具不存在")
return
}
}
// 已在购物车:道具累加数量,游戏保持1份不动
var exist model.Cart
err := database.DB.Where("user_id = ? AND item_type = ? AND item_id = ?", uid, req.ItemType, req.ItemID).First(&exist).Error
if err == nil {
if req.ItemType == model.ItemTypeProp {
database.DB.Model(&exist).Update("quantity", exist.Quantity+req.Quantity)
}
resp.OK(c, nil)
return
}
database.DB.Create(&model.Cart{UserID: uid, ItemType: req.ItemType, ItemID: req.ItemID, Quantity: req.Quantity})
resp.OK(c, nil)
}
// RemoveCart 移除购物车条目(只能删自己的)
func RemoveCart(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
database.DB.Where("id = ? AND user_id = ?", id, middleware.UID(c)).Delete(&model.Cart{})
resp.OK(c, nil)
}
// genOrderNo 生成订单号(时间戳 + 4位随机)
func genOrderNo() string {
return fmt.Sprintf("PO%s%04d", time.Now().Format("20060102150405"), rand.Intn(10000))
}
// purchaseItems 核心购买逻辑:事务内扣积分、写订单、发放游戏/道具
// items 由调用方保证已校验(存在且可购买)
func purchaseItems(uid int, items []cartItem) (*model.Order, error) {
if len(items) == 0 {
return nil, errors.New("没有可购买的商品")
}
total := 0
names := make([]string, 0, len(items))
for _, it := range items {
total += it.Price * it.Quantity
names = append(names, it.Name)
}
var order model.Order
err := database.DB.Transaction(func(tx *gorm.DB) error {
// 创建订单主体
order = model.Order{
OrderNo: genOrderNo(), UserID: uid, TotalPoints: total,
Status: model.OrderStatusDone, Remark: strings.Join(names, "、"),
}
if err := tx.Create(&order).Error; err != nil {
return err
}
// 扣积分(余额不足时事务整体回滚)
if _, err := service.ChangePoints(tx, uid, -total, model.PointTypeShop, order.ID,
fmt.Sprintf("订单 %s 消费", order.OrderNo)); err != nil {
return err
}
for _, it := range items {
// 写订单明细(价格快照)
if err := tx.Create(&model.OrderItem{
OrderID: order.ID, ItemType: it.ItemType, ItemID: it.ItemID,
ItemName: it.Name, Price: it.Price, Quantity: it.Quantity,
}).Error; err != nil {
return err
}
if it.ItemType == model.ItemTypeGame {
// 发放游戏(唯一索引防重复购买)
if err := tx.Create(&model.UserGame{UserID: uid, GameID: it.ItemID, OrderID: order.ID}).Error; err != nil {
return errors.New("已拥有该游戏,请勿重复购买")
}
} else {
// 发放道具:已有记录累加,否则新建
res := tx.Exec(`UPDATE user_props SET quantity = quantity + ? WHERE user_id = ? AND prop_id = ?`,
it.Quantity, uid, it.ItemID)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
if err := tx.Create(&model.UserProp{UserID: uid, PropID: it.ItemID, Quantity: it.Quantity}).Error; err != nil {
return err
}
}
}
}
return nil
})
if err != nil {
return nil, err
}
return &order, nil
}
// Checkout 购物车结算下单
func Checkout(c *gin.Context) {
uid := middleware.UID(c)
items := loadCartItems(uid)
order, err := purchaseItems(uid, items)
if err != nil {
resp.Fail(c, err.Error())
return
}
// 下单成功后清空购物车
database.DB.Where("user_id = ?", uid).Delete(&model.Cart{})
resp.OK(c, order)
}
// buyReq 立即购买请求体(跳过购物车直接下单)
type buyReq struct {
ItemType int `json:"item_type" binding:"required,oneof=1 2"` // 1游戏 2道具
ItemID int `json:"item_id" binding:"required"` // 商品ID
Quantity int `json:"quantity"` // 数量(仅道具)
}
// BuyNow 立即购买单件商品
func BuyNow(c *gin.Context) {
var req buyReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数有误")
return
}
if req.Quantity < 1 {
req.Quantity = 1
}
uid := middleware.UID(c)
var item cartItem
if req.ItemType == model.ItemTypeGame {
var game model.Game
if err := database.DB.Where("id = ? AND status = 1", req.ItemID).First(&game).Error; err != nil {
resp.Fail(c, "游戏不存在")
return
}
if game.Price == 0 {
resp.Fail(c, "免费游戏无需购买")
return
}
var owned int64
database.DB.Model(&model.UserGame{}).Where("user_id = ? AND game_id = ?", uid, game.ID).Count(&owned)
if owned > 0 {
resp.Fail(c, "已拥有该游戏")
return
}
item = cartItem{ItemType: 1, ItemID: game.ID, Quantity: 1, Name: game.Name, Price: game.Price}
} else {
var prop model.Prop
if err := database.DB.Where("id = ? AND status = 1", req.ItemID).First(&prop).Error; err != nil {
resp.Fail(c, "道具不存在")
return
}
item = cartItem{ItemType: 2, ItemID: prop.ID, Quantity: req.Quantity, Name: prop.Name, Price: prop.Price}
}
order, err := purchaseItems(uid, []cartItem{item})
if err != nil {
resp.Fail(c, err.Error())
return
}
resp.OK(c, order)
}
// OrderList 我的订单列表(含明细,分页)
func OrderList(c *gin.Context) {
page, size, offset := pageParams(c)
uid := middleware.UID(c)
var total int64
orders := make([]model.Order, 0)
database.DB.Model(&model.Order{}).Where("user_id = ?", uid).Count(&total)
database.DB.Preload("Items").Where("user_id = ?", uid).
Order("id DESC").Limit(size).Offset(offset).Find(&orders)
resp.OK(c, gin.H{"list": orders, "total": total, "page": page, "size": size})
}

143
internal/handler/signin.go Normal file
View File

@@ -0,0 +1,143 @@
package handler
import (
"errors"
"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"
)
// dayInt 把日期转成 yyyymmdd 整数(与 sign_ins.sign_day 对应)
func dayInt(t time.Time) int {
return t.Year()*10000 + int(t.Month())*100 + t.Day()
}
// calcSignPoints 计算签到基础积分:基础 + 连签加成(有上限),不含VIP加成
func calcSignPoints(streak int) int {
base := service.GetConfigInt(model.ConfKeySignBasePoints, 10)
step := service.GetConfigInt(model.ConfKeySignStreakStep, 5)
capDays := service.GetConfigInt(model.ConfKeySignStreakCap, 7)
if streak > capDays {
streak = capDays
}
return base + step*(streak-1)
}
// vipSigninBonus 查用户当前生效VIP等级与对应的签到加成积分(非VIP返回 0,0)
func vipSigninBonus(uid int, now time.Time) (int, int) {
var user model.User
if err := database.DB.First(&user, uid).Error; err != nil {
return 0, 0
}
level := service.UserVipLevel(&user, now)
if level <= 0 {
return 0, 0
}
var vl model.VipLevel
if err := database.DB.Where("level = ?", level).First(&vl).Error; err != nil {
return level, 0
}
return level, vl.SigninBonus
}
// SignInStatus 签到页数据:本月已签日期、今日是否已签、连签天数与奖励预告(含VIP加成)
func SignInStatus(c *gin.Context) {
uid := middleware.UID(c)
now := time.Now()
// 本月第一天的 yyyymmdd(用于筛选本月记录)
monthStart := now.Year()*10000 + int(now.Month())*100 + 1
var monthSigns []model.SignIn
database.DB.Where("user_id = ? AND sign_day >= ?", uid, monthStart).Order("sign_day").Find(&monthSigns)
signedDays := make([]int, 0, len(monthSigns))
for _, s := range monthSigns {
signedDays = append(signedDays, s.SignDay%100)
}
// 最近一次签到记录(用于计算今日/昨日连签)
var last model.SignIn
hasLast := database.DB.Where("user_id = ?", uid).Order("sign_day DESC").First(&last).Error == nil
today := dayInt(now)
yesterday := dayInt(now.AddDate(0, 0, -1))
todaySigned := hasLast && last.SignDay == today
// 计算"今天签到能连几天"(已签则取当前值)
nextStreak := 1
if hasLast {
if last.SignDay == today {
nextStreak = last.Streak
} else if last.SignDay == yesterday {
nextStreak = last.Streak + 1
}
}
// VIP签到加成(随等级配置,非VIP为 0)
vipLevel, vipBonus := vipSigninBonus(uid, now)
resp.OK(c, gin.H{
"year": now.Year(),
"month": int(now.Month()),
"today": now.Day(),
"signed_days": signedDays, // 本月已签的"日"列表
"today_signed": todaySigned, // 今日是否已签
"streak": nextStreak, // 连签天数(今日签到后的值)
"vip_level": vipLevel, // 当前生效VIP等级(0=非VIP)
"vip_bonus": vipBonus, // VIP签到加成积分
"today_points": calcSignPoints(nextStreak) + vipBonus, // 今日签到可得/已得积分(含加成)
"next_points": calcSignPoints(nextStreak+1) + vipBonus, // 明日签到可得积分(预告,含加成)
})
}
// DoSignIn 执行签到:防重复、算连签、发积分(基础 + 连签 + VIP加成)
func DoSignIn(c *gin.Context) {
uid := middleware.UID(c)
now := time.Now()
today := dayInt(now)
yesterday := dayInt(now.AddDate(0, 0, -1))
// 今日是否已签到
var cnt int64
database.DB.Model(&model.SignIn{}).Where("user_id = ? AND sign_day = ?", uid, today).Count(&cnt)
if cnt > 0 {
resp.Fail(c, "今天已经签过啦")
return
}
// 昨天有签则连签 +1,否则从 1 重新计
streak := 1
var last model.SignIn
if database.DB.Where("user_id = ?", uid).Order("sign_day DESC").First(&last).Error == nil {
if last.SignDay == yesterday {
streak = last.Streak + 1
}
}
// 总积分 = 基础 + 连签加成 + VIP加成
vipLevel, vipBonus := vipSigninBonus(uid, now)
points := calcSignPoints(streak) + vipBonus
remark := fmt.Sprintf("每日签到(连签 %d 天)", streak)
if vipBonus > 0 {
remark = fmt.Sprintf("每日签到(连签 %d 天,VIP%d 加成 +%d)", streak, vipLevel, vipBonus)
}
// 签到落库与积分发放在同一事务:任一步失败整体回滚,
// 避免"签到行已写入(当天唯一约束挡住重签)但积分没到账"的中间态
var balance int
err := database.DB.Transaction(func(tx *gorm.DB) error {
sign := model.SignIn{UserID: uid, SignDay: today, Streak: streak, Points: points}
if err := tx.Create(&sign).Error; err != nil {
// 唯一索引兜底(并发下重复签到会触发此分支)
return errors.New("签到失败,请稍后重试")
}
b, err := service.ChangePoints(tx, uid, points, model.PointTypeSignIn, sign.ID, remark)
if err != nil {
return errors.New("积分发放失败")
}
balance = b
return nil
})
if err != nil {
resp.Fail(c, err.Error())
return
}
resp.OK(c, gin.H{"points": points, "streak": streak, "balance": balance, "vip_bonus": vipBonus})
}

277
internal/handler/user.go Normal file
View File

@@ -0,0 +1,277 @@
package handler
import (
"sort"
"strconv"
"time"
"github.com/gin-gonic/gin"
"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"
)
// pageParams 解析分页参数(页码默认1、每页默认10、上限50)
func pageParams(c *gin.Context) (page, size, offset int) {
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ = strconv.Atoi(c.DefaultQuery("size", "10"))
if page < 1 {
page = 1
}
// 上限 500:支持前端「批量拉取 + 虚拟滚动」模式一次取一大页(查询始终带 LIMIT,无全表风险)
if size < 1 || size > 500 {
size = 10
}
return page, size, (page - 1) * size
}
// Profile 获取当前用户资料
func Profile(c *gin.Context) {
var user model.User
if err := database.DB.First(&user, middleware.UID(c)).Error; err != nil {
resp.Unauthorized(c, "用户不存在")
return
}
resp.OK(c, user)
}
// updateProfileReq 修改资料请求体
type updateProfileReq struct {
Nickname string `json:"nickname" binding:"required,max=20"` // 新昵称
Avatar string `json:"avatar" binding:"required,max=8"` // 新 emoji 头像
}
// UpdateProfile 修改昵称与头像
func UpdateProfile(c *gin.Context) {
var req updateProfileReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数有误")
return
}
database.DB.Model(&model.User{}).Where("id = ?", middleware.UID(c)).
Updates(map[string]any{"nickname": req.Nickname, "avatar": req.Avatar})
resp.OK(c, nil)
}
// PointRecords 我的积分流水(分页)
func PointRecords(c *gin.Context) {
page, size, offset := pageParams(c)
uid := middleware.UID(c)
var total int64
list := make([]model.PointRecord, 0)
database.DB.Model(&model.PointRecord{}).Where("user_id = ?", uid).Count(&total)
database.DB.Where("user_id = ?", uid).Order("id DESC").Limit(size).Offset(offset).Find(&list)
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
// LoginLogs 我的登录记录(分页)
func LoginLogs(c *gin.Context) {
page, size, offset := pageParams(c)
uid := middleware.UID(c)
var total int64
list := make([]model.LoginLog, 0)
database.DB.Model(&model.LoginLog{}).Where("user_id = ?", uid).Count(&total)
database.DB.Where("user_id = ?", uid).Order("id DESC").Limit(size).Offset(offset).Find(&list)
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
// gameRecordItem 游戏记录列表项(附游戏名称与图标)
type gameRecordItem struct {
model.GameRecord
GameName string `json:"game_name"` // 游戏名称
GameIcon string `json:"game_icon"` // 游戏图标
}
// GameRecords 我的单机游戏记录(分页,联表补游戏信息)
func GameRecords(c *gin.Context) {
page, size, offset := pageParams(c)
uid := middleware.UID(c)
var total int64
list := make([]gameRecordItem, 0)
database.DB.Model(&model.GameRecord{}).Where("user_id = ?", uid).Count(&total)
database.DB.Table("game_records gr").
Select("gr.*, g.name AS game_name, g.icon AS game_icon").
Joins("LEFT JOIN games g ON g.id = gr.game_id").
Where("gr.user_id = ?", uid).
Order("gr.id DESC").Limit(size).Offset(offset).Scan(&list)
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
// battleRecordItem 对战记录列表项(附游戏名称与图标)
type battleRecordItem struct {
model.BattleRecord
GameName string `json:"game_name"` // 游戏名称
GameIcon string `json:"game_icon"` // 游戏图标
}
// BattleRecords 我的联机对战记录(分页)
func BattleRecords(c *gin.Context) {
page, size, offset := pageParams(c)
uid := middleware.UID(c)
var total int64
list := make([]battleRecordItem, 0)
database.DB.Model(&model.BattleRecord{}).Where("user_id = ?", uid).Count(&total)
database.DB.Table("battle_records br").
Select("br.*, g.name AS game_name, g.icon AS game_icon").
Joins("LEFT JOIN games g ON g.id = br.game_id").
Where("br.user_id = ?", uid).
Order("br.id DESC").Limit(size).Offset(offset).Scan(&list)
resp.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
}
// userPropItem 用户道具列表项(附道具详情)
type userPropItem struct {
PropID int `json:"prop_id"` // 道具ID
Code string `json:"code"` // 道具编码
Name string `json:"name"` // 道具名称
Icon string `json:"icon"` // 图标
Description string `json:"description"` // 用途说明
Quantity int `json:"quantity"` // 持有数量
}
// UserProps 我的道具背包(只返回数量大于0的)
func UserProps(c *gin.Context) {
list := make([]userPropItem, 0)
database.DB.Table("user_props up").
Select("up.prop_id, p.code, p.name, p.icon, p.description, up.quantity").
Joins("JOIN props p ON p.id = up.prop_id").
Where("up.user_id = ? AND up.quantity > 0", middleware.UID(c)).
Order("p.sort").Scan(&list)
resp.OK(c, list)
}
// UserGames 我已购买的游戏ID列表(判断解锁状态用)
func UserGames(c *gin.Context) {
ids := make([]int, 0)
database.DB.Model(&model.UserGame{}).Where("user_id = ?", middleware.UID(c)).Pluck("game_id", &ids)
resp.OK(c, ids)
}
// myGameItem 「我的游戏」列表项:游戏信息 + 来源 + 我的游玩统计
type myGameItem struct {
model.Game
Source string `json:"source"` // 来源:bought=已购 weekly=本周周免 played=玩过的免费游戏
GotAt int64 `json:"got_at"` // 获得时间(已购=购买时间,其余=0)
Plays int `json:"plays"` // 我的总局数(单机+对战)
Best int `json:"best"` // 我的最高分(单机游戏)
LastPlayed int64 `json:"last_played"` // 最近一次游玩时间(0=没玩过)
}
// UserMyGames 个人中心「我的游戏」:已购游戏 + 本周VIP周免 + 玩过的免费游戏,附游玩统计
// 排序:已购按购买时间倒序 → 周免 → 玩过的按最近游玩倒序
func UserMyGames(c *gin.Context) {
uid := middleware.UID(c)
// 我的游玩统计(单机记录:局数/最高分/最近时间,按游戏聚合)
type statRow struct {
GameID int `gorm:"column:game_id"`
Plays int `gorm:"column:plays"`
Best int `gorm:"column:best"`
Last int64 `gorm:"column:last"`
}
statMap := map[int]statRow{}
var stats []statRow
database.DB.Raw(`SELECT game_id, COUNT(*) AS plays, MAX(score) AS best, MAX(created_at) AS last
FROM game_records WHERE user_id = ? GROUP BY game_id`, uid).Scan(&stats)
for _, s := range stats {
statMap[s.GameID] = s
}
// 对战局数并入统计(联机游戏没有单机得分,best 保持 0)
var bstats []statRow
database.DB.Raw(`SELECT game_id, COUNT(*) AS plays, 0 AS best, MAX(created_at) AS last
FROM battle_records WHERE user_id = ? GROUP BY game_id`, uid).Scan(&bstats)
for _, s := range bstats {
m := statMap[s.GameID]
m.GameID = s.GameID
m.Plays += s.Plays
if s.Last > m.Last {
m.Last = s.Last
}
statMap[s.GameID] = m
}
// 已购游戏(购买时间倒序)
var ugs []model.UserGame
database.DB.Where("user_id = ?", uid).Order("id DESC").Find(&ugs)
boughtAt := map[int]int64{}
boughtOrder := make([]int, 0, len(ugs))
for _, ug := range ugs {
if _, ok := boughtAt[ug.GameID]; !ok {
boughtAt[ug.GameID] = ug.CreatedAt
boughtOrder = append(boughtOrder, ug.GameID)
}
}
// 本周周免集合(非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())
}
// 汇总需要展示的游戏ID:已购 + 周免 + 玩过的
idSet := map[int]bool{}
for _, id := range boughtOrder {
idSet[id] = true
}
for id := range weeklySet {
idSet[id] = true
}
for id := range statMap {
idSet[id] = true
}
if len(idSet) == 0 {
resp.OK(c, []myGameItem{})
return
}
ids := make([]int, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
var games []model.Game
database.DB.Where("id IN ? AND status = 1", ids).Find(&games)
gameMap := map[int]model.Game{}
for _, g := range games {
gameMap[g.ID] = g
}
build := func(id int, source string) (myGameItem, bool) {
g, ok := gameMap[id]
if !ok {
return myGameItem{}, false
}
s := statMap[id]
return myGameItem{
Game: g, Source: source, GotAt: boughtAt[id],
Plays: s.Plays, Best: s.Best, LastPlayed: s.Last,
}, true
}
list := make([]myGameItem, 0, len(idSet))
// 1. 已购(购买时间倒序;已下架的会被 build 过滤)
for _, id := range boughtOrder {
if it, ok := build(id, "bought"); ok {
list = append(list, it)
}
}
boughtCount := len(list)
// 2. 本周周免(未购买的才标周免),按游戏排序权重稳定展示
for id := range weeklySet {
if boughtAt[id] == 0 {
if it, ok := build(id, "weekly"); ok {
list = append(list, it)
}
}
}
weeklyPart := list[boughtCount:]
sort.SliceStable(weeklyPart, func(i, j int) bool { return weeklyPart[i].Sort < weeklyPart[j].Sort })
// 3. 玩过的其它游戏(免费游戏等,按最近游玩倒序)
played := make([]myGameItem, 0)
for id := range statMap {
if boughtAt[id] == 0 && !weeklySet[id] {
if it, ok := build(id, "played"); ok {
played = append(played, it)
}
}
}
sort.SliceStable(played, func(i, j int) bool { return played[i].LastPlayed > played[j].LastPlayed })
list = append(list, played...)
resp.OK(c, list)
}

174
internal/handler/vip.go Normal file
View File

@@ -0,0 +1,174 @@
package handler
import (
"fmt"
"strconv"
"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"
)
// vipGameBrief 周免游戏的精简信息(VIP页展示用)
type vipGameBrief struct {
ID int `json:"id"` // 游戏ID
Code string `json:"code"` // 游戏编码
Name string `json:"name"` // 游戏名称
Icon string `json:"icon"` // emoji 图标
Price int `json:"price"` // 原价(积分)
}
// vipLevelItem VIP等级 + 本周周免批次
type vipLevelItem struct {
model.VipLevel
WeeklyGames []vipGameBrief `json:"weekly_games"` // 该等级本周的周免游戏
}
// VipInfo VIP中心数据:我的VIP状态、5个等级的权益与本周周免批次、下次刷新时间
func VipInfo(c *gin.Context) {
uid := middleware.UID(c)
now := time.Now()
var user model.User
if err := database.DB.First(&user, uid).Error; err != nil {
resp.Fail(c, "用户不存在")
return
}
var levels []model.VipLevel
database.DB.Order("level").Find(&levels)
pool := service.VipWeeklyPool(now)
myLevel := service.UserVipLevel(&user, now)
list := make([]vipLevelItem, 0, len(levels))
myName := ""
for _, l := range levels {
batch := service.VipWeeklyBatch(pool, levels, l.Level)
games := make([]vipGameBrief, 0, len(batch))
for _, g := range batch {
games = append(games, vipGameBrief{ID: g.ID, Code: g.Code, Name: g.Name, Icon: g.Icon, Price: g.Price})
}
list = append(list, vipLevelItem{VipLevel: l, WeeklyGames: games})
if l.Level == myLevel {
myName = l.Name
}
}
resp.OK(c, gin.H{
"my": gin.H{
"level": myLevel, // 当前生效等级(过期后为 0)
"level_name": myName, // 等级名称
"expire": user.VipExpire, // 到期时间戳(秒)
"active": myLevel > 0, // 是否生效中
},
"refresh_at": service.VipNextRefresh(now).Unix(), // 下次周免刷新时间(周五12:00)
"levels": list,
})
}
// vipBuyReq 购买VIP请求体
type vipBuyReq struct {
Level int `json:"level" binding:"required,min=1,max=5"` // 目标等级(1~5)
}
// VipBuy 开通/续费/升级VIP:扣积分并更新等级与到期时间(30天/次)
// 规则:同等级续费在原到期时间上顺延;升级立即生效、按30天重新计时;不允许降级购买
func VipBuy(c *gin.Context) {
var req vipBuyReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数有误")
return
}
uid := middleware.UID(c)
var target model.VipLevel
if err := database.DB.Where("level = ?", req.Level).First(&target).Error; err != nil {
resp.Fail(c, "VIP等级不存在")
return
}
now := time.Now()
var newExpire int64
var balance int
err := database.DB.Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&user, uid).Error; err != nil {
return fmt.Errorf("用户不存在")
}
curLevel := service.UserVipLevel(&user, now)
if curLevel > req.Level {
return fmt.Errorf("当前已是更高等级(VIP%d),无需降级购买", curLevel)
}
// 计算新到期时间:同级续费顺延,升级/新开从现在起算30天
if curLevel == req.Level {
newExpire = user.VipExpire + 30*86400
} else {
newExpire = now.Unix() + 30*86400
}
// 扣积分并记流水(复用事务,余额不足会报错回滚)
b, err := service.ChangePoints(tx, uid, -target.PricePoints, model.PointTypeVIP, target.ID,
fmt.Sprintf("开通VIP%d·%s(30天)", target.Level, target.Name))
if err != nil {
return err
}
balance = b
return tx.Model(&model.User{}).Where("id = ?", uid).
Updates(map[string]any{"vip_level": req.Level, "vip_expire": newExpire}).Error
})
if err != nil {
resp.Fail(c, err.Error())
return
}
resp.OK(c, gin.H{"level": req.Level, "expire": newExpire, "balance": balance})
}
// AdminVipLevels 后台:VIP等级配置列表
func AdminVipLevels(c *gin.Context) {
var levels []model.VipLevel
database.DB.Order("level").Find(&levels)
resp.OK(c, levels)
}
// adminUpdateVipReq 后台修改VIP等级配置请求体(nil=不修改)
type adminUpdateVipReq struct {
Name *string `json:"name"` // 等级名称
PricePoints *int `json:"price_points"` // 30天价格(积分)
WeeklyFreeQuota *int `json:"weekly_free_quota"` // 周免游戏数量
SigninBonus *int `json:"signin_bonus"` // 签到加成积分
Description *string `json:"description"` // 权益描述
}
// AdminUpdateVipLevel 后台:修改某一VIP等级的价格与权益
func AdminUpdateVipLevel(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var req adminUpdateVipReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数有误")
return
}
updates := map[string]any{}
if req.Name != nil && *req.Name != "" {
updates["name"] = *req.Name
}
if req.PricePoints != nil && *req.PricePoints >= 0 {
updates["price_points"] = *req.PricePoints
}
if req.WeeklyFreeQuota != nil && *req.WeeklyFreeQuota >= 0 {
updates["weekly_free_quota"] = *req.WeeklyFreeQuota
}
if req.SigninBonus != nil && *req.SigninBonus >= 0 {
updates["signin_bonus"] = *req.SigninBonus
}
if req.Description != nil {
updates["description"] = *req.Description
}
if len(updates) == 0 {
resp.Fail(c, "没有需要修改的内容")
return
}
if err := database.DB.Model(&model.VipLevel{}).Where("id = ?", id).Updates(updates).Error; err != nil {
resp.Fail(c, "保存失败")
return
}
resp.OK(c, nil)
}