初始化
This commit is contained in:
372
internal/handler/admin.go
Normal file
372
internal/handler/admin.go
Normal 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
147
internal/handler/aiconf.go
Normal 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(), // 往返延迟(毫秒)
|
||||
})
|
||||
}
|
||||
129
internal/handler/analytics.go
Normal file
129
internal/handler/analytics.go
Normal 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
107
internal/handler/auth.go
Normal 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
181
internal/handler/chat.go
Normal 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})
|
||||
}
|
||||
38
internal/handler/config.go
Normal file
38
internal/handler/config.go
Normal 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
275
internal/handler/friend.go
Normal 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
500
internal/handler/game.go
Normal 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
64
internal/handler/rank.go
Normal 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
274
internal/handler/shop.go
Normal 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
143
internal/handler/signin.go
Normal 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
277
internal/handler/user.go
Normal 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
174
internal/handler/vip.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user