存档功能

This commit is contained in:
李琦
2026-09-24 16:53:46 +08:00
parent 81159196b1
commit f58eb7b276
5 changed files with 407 additions and 44 deletions

View File

@@ -2,6 +2,7 @@ package handler
import (
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -315,46 +316,209 @@ func GameProgressSave(c *gin.Context) {
resp.OK(c, gin.H{"level": gp.Level})
}
// ---- 游戏存档(支持存档的单机游戏:饥荒等) ----
// ---- 游戏存档(支持存档的单机游戏:饥荒等,一人可多份命名存档) ----
// GameSaveGet 读取我的存档:无档返回 exists=false,前端据此显示「继续/新开」
func GameSaveGet(c *gin.Context) {
const maxSavesPerGame = 12
func findGameByCode(c *gin.Context, requireOn bool) (model.Game, bool) {
var game model.Game
if err := database.DB.Where("code = ? AND status = 1", c.Param("code")).First(&game).Error; err != nil {
q := database.DB.Where("code = ?", c.Param("code"))
if requireOn {
q = q.Where("status = 1")
}
if err := q.First(&game).Error; err != nil {
resp.Fail(c, "游戏不存在或已下架")
return game, false
}
return game, true
}
// GameSaveList 列出我的全部存档(不含 data 大字段)
func GameSaveList(c *gin.Context) {
game, ok := findGameByCode(c, true)
if !ok {
return
}
var rows []model.GameSave
database.DB.Select("id, user_id, game_id, day, score, name, created_at, updated_at").
Where("user_id = ? AND game_id = ?", middleware.UID(c), game.ID).
Order("updated_at DESC").
Find(&rows)
list := make([]gin.H, 0, len(rows))
for _, s := range rows {
name := s.Name
if name == "" {
name = "冒险"
}
list = append(list, gin.H{
"id": s.ID, "name": name, "day": s.Day, "score": s.Score, "updated_at": s.UpdatedAt,
})
}
resp.OK(c, gin.H{"list": list, "max": maxSavesPerGame})
}
// GameSaveGetOne 读取指定存档全文
func GameSaveGetOne(c *gin.Context) {
game, ok := findGameByCode(c, true)
if !ok {
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})
if err := database.DB.Where("id = ? AND user_id = ? AND game_id = ?", c.Param("id"), middleware.UID(c), game.ID).
First(&save).Error; err != nil {
resp.Fail(c, "存档不存在")
return
}
resp.OK(c, gin.H{"exists": true, "day": save.Day, "score": save.Score, "data": save.Data, "updated_at": save.UpdatedAt})
name := save.Name
if name == "" {
name = "冒险"
}
resp.OK(c, gin.H{
"id": save.ID, "name": name, "day": save.Day, "score": save.Score,
"data": save.Data, "updated_at": save.UpdatedAt,
})
}
// saveGameReq 保存存档请求体
type saveGameReq struct {
ID int `json:"id"` // 有则更新,无则新建
Name string `json:"name"` // 新建时必填;更新时可改名
Day int `json:"day" binding:"min=1"` // 存档天数(展示用)
Score int `json:"score" binding:"min=0"` // 存档时分数(展示用)
Data string `json:"data" binding:"required"` // 游戏状态快照(JSON 字符串)
}
// GameSaveSet 覆盖保存存档(每天清晨自动存 + 手动存档按钮)
func trimSaveName(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "冒险"
}
runes := []rune(s)
if len(runes) > 32 {
s = string(runes[:32])
}
return s
}
// 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, "游戏不存在或已下架")
game, ok := findGameByCode(c, true)
if !ok {
return
}
uid := middleware.UID(c)
name := trimSaveName(req.Name)
if req.ID > 0 {
var save model.GameSave
if err := database.DB.Where("id = ? AND user_id = ? AND game_id = ?", req.ID, uid, game.ID).First(&save).Error; err != nil {
resp.Fail(c, "存档不存在")
return
}
up := map[string]any{"day": req.Day, "score": req.Score, "data": req.Data}
if strings.TrimSpace(req.Name) != "" {
up["name"] = name
}
if err := database.DB.Model(&save).Updates(up).Error; err != nil {
resp.Fail(c, "存档失败,请重试")
return
}
outName := name
if strings.TrimSpace(req.Name) == "" {
outName = save.Name
if outName == "" {
outName = "冒险"
}
}
resp.OK(c, gin.H{"id": save.ID, "name": outName, "day": req.Day})
return
}
var cnt int64
database.DB.Model(&model.GameSave{}).Where("user_id = ? AND game_id = ?", uid, game.ID).Count(&cnt)
if cnt >= maxSavesPerGame {
resp.Fail(c, "存档位已满,请先删除旧存档")
return
}
save := model.GameSave{UserID: uid, GameID: game.ID, Day: req.Day, Score: req.Score, Name: name, Data: req.Data}
if err := database.DB.Create(&save).Error; err != nil {
resp.Fail(c, "存档失败,请重试")
return
}
resp.OK(c, gin.H{"id": save.ID, "name": save.Name, "day": req.Day})
}
// GameSaveDeleteOne 删除指定存档
func GameSaveDeleteOne(c *gin.Context) {
game, ok := findGameByCode(c, false)
if !ok {
return
}
res := database.DB.Where("id = ? AND user_id = ? AND game_id = ?", c.Param("id"), middleware.UID(c), game.ID).
Delete(&model.GameSave{})
if res.RowsAffected == 0 {
resp.Fail(c, "存档不存在")
return
}
resp.OK(c, nil)
}
// ---- 兼容旧接口:单存档读写(取最近一份 / 覆盖最近一份) ----
// GameSaveGet 兼容:无档 exists=false;有档返回最近更新的一份全文
func GameSaveGet(c *gin.Context) {
game, ok := findGameByCode(c, true)
if !ok {
return
}
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.Where("user_id = ? AND game_id = ?", middleware.UID(c), game.ID).
Order("updated_at DESC").First(&save).Error; err != nil {
resp.OK(c, gin.H{"exists": false})
return
}
name := save.Name
if name == "" {
name = "冒险"
}
resp.OK(c, gin.H{
"exists": true, "id": save.ID, "name": name,
"day": save.Day, "score": save.Score, "data": save.Data, "updated_at": save.UpdatedAt,
})
}
// GameSaveDelete 兼容:删除该游戏下全部存档(旧「放弃存档」)
func GameSaveDelete(c *gin.Context) {
game, ok := findGameByCode(c, false)
if !ok {
return
}
database.DB.Where("user_id = ? AND game_id = ?", middleware.UID(c), game.ID).Delete(&model.GameSave{})
resp.OK(c, nil)
}
// GameSaveSetLegacy 兼容旧单档 POST:有档则覆盖最近一份,无则新建
func GameSaveSetLegacy(c *gin.Context) {
var req saveGameReq
if err := c.ShouldBindJSON(&req); err != nil || len(req.Data) > 256*1024 {
resp.Fail(c, "存档数据有误")
return
}
game, ok := findGameByCode(c, true)
if !ok {
return
}
uid := middleware.UID(c)
name := trimSaveName(req.Name)
var save model.GameSave
if err := database.DB.Where("user_id = ? AND game_id = ?", uid, game.ID).
Order("updated_at DESC").First(&save).Error; err != nil {
save = model.GameSave{UserID: uid, GameID: game.ID, Day: req.Day, Score: req.Score, Name: name, Data: req.Data}
if err := database.DB.Create(&save).Error; err != nil {
resp.Fail(c, "存档失败,请重试")
return
@@ -365,18 +529,7 @@ func GameSaveSet(c *gin.Context) {
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)
resp.OK(c, gin.H{"id": save.ID, "day": req.Day})
}
// ---- 游戏皮肤(更衣室:免费默认解锁,其余积分购买) ----

View File

@@ -1,6 +1,8 @@
package handler
import (
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
@@ -15,7 +17,9 @@ import (
"nl-game-api-gin/pkg/resp"
)
// 桌面安装包存放目录(相对进程工作目录);Nginx 可 alias 同目录或反代 /download/
// 桌面安装包存放目录(相对进程工作目录)
// 对外下载走 /api/download/xxx(走 Nginx 已有的 /api/ 反代,避免被 SPA fallback 吃掉)
// 同时保留 /download/xxx 兼容旧链接
const releaseDir = "data/releases"
// 最多保留的安装包数量(按语义化版本从新到旧)
@@ -33,7 +37,7 @@ type releaseItem struct {
Filename string `json:"filename"`
Version string `json:"version"`
Size int64 `json:"size"`
URL string `json:"url"` // 相对路径 /download/xxx,前端/客户端拼域名
URL string `json:"url"` // 相对路径 /api/download/xxx,前端/客户端拼域名
ModTime int64 `json:"mod_time"`
IsCurrent bool `json:"is_current"`
}
@@ -43,12 +47,57 @@ func ensureReleaseDir() error {
return os.MkdirAll(releaseDir, 0o755)
}
// ReleaseDir 对外暴露目录路径(路由挂静态文件用)
// ReleaseDir 对外暴露目录路径
func ReleaseDir() string {
_ = ensureReleaseDir()
return releaseDir
}
// releaseAPIPath 安装包的推荐对外相对路径(走 /api/ 反代)
func releaseAPIPath(name string) string {
return "/api/download/" + filepath.Base(name)
}
// isOurDownloadPath 是否为本服务托管的安装包路径(/download/ 或 /api/download/)
func isOurDownloadPath(p string) bool {
p = strings.ToLower(strings.ReplaceAll(p, "\\", "/"))
return strings.Contains(p, "/api/download/") || strings.HasPrefix(p, "/download/") || p == "/download"
}
// canonicalDownloadPath 把库里存的旧路径 /download/xxx 或绝对 URL 规范成 /api/download/xxx
// 外链 CDN(路径不含 /download/)原样返回,避免误改
func canonicalDownloadPath(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
pathPart := raw
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
u, err := url.Parse(raw)
if err != nil {
return raw
}
pathPart = u.Path
if !isOurDownloadPath(pathPart) {
return raw
}
}
if i := strings.IndexAny(pathPart, "?#"); i >= 0 {
pathPart = pathPart[:i]
}
if !isOurDownloadPath(pathPart) && !strings.HasPrefix(pathPart, "/api/download/") {
return raw
}
name := filepath.Base(strings.ReplaceAll(pathPart, "\\", "/"))
if name == "" || name == "." || name == ".." {
return raw
}
if !releaseExtOK[strings.ToLower(filepath.Ext(name))] {
return raw
}
return releaseAPIPath(name)
}
// parseVersionFromName 从安装包文件名提取语义化版本;失败返回空串
func parseVersionFromName(name string) string {
base := filepath.Base(name)
@@ -114,7 +163,7 @@ func listReleaseFiles() ([]releaseItem, error) {
Filename: name,
Version: ver,
Size: info.Size(),
URL: "/download/" + name,
URL: releaseAPIPath(name),
ModTime: info.ModTime().Unix(),
IsCurrent: ver != "" && ver == curVer,
})
@@ -166,7 +215,7 @@ func publicOrigin(c *gin.Context) string {
// absDownloadURL 相对路径 → 绝对 URL;已是 http(s) 则原样返回
func absDownloadURL(c *gin.Context, u string) string {
u = strings.TrimSpace(u)
u = canonicalDownloadPath(u)
if u == "" {
return ""
}
@@ -183,19 +232,78 @@ func absDownloadURL(c *gin.Context, u string) string {
return origin + u
}
// currentReleaseMeta 当前对外安装包的文件名与大小(供客户端校验下载完整性)
func currentReleaseMeta() (filename string, size int64) {
raw := canonicalDownloadPath(service.GetConfig(model.ConfKeyAppDownloadURL, ""))
name := filepath.Base(raw)
items, _ := listReleaseFiles()
for _, it := range items {
if it.IsCurrent || it.Filename == name {
return it.Filename, it.Size
}
}
if name != "" && name != "." && name != "/" {
return name, 0
}
return "", 0
}
// VersionLatest 公开的桌面端最新版本信息,无需登录
// 桌面端启动时与「检查更新」调用,返回 version / download_url / release_notes / force
func VersionLatest(c *gin.Context) {
rawURL := service.GetConfig(model.ConfKeyAppDownloadURL, "")
filename, size := currentReleaseMeta()
resp.OK(c, gin.H{
"version": service.GetConfig(model.ConfKeyAppVersion, "1.0.0"),
"download_url": absDownloadURL(c, rawURL),
"filename": filename,
"size": size,
"release_notes": service.GetConfig(model.ConfKeyAppReleaseNotes, ""),
// 1=强制更新,桌面端会拦截跳过按钮;其他值或空都视为不强制
"force": service.GetConfigInt(model.ConfKeyAppForceUpdate, 0),
})
}
// VersionDownload 公开下载安装包:强制 attachment,避免被浏览器当网页打开
func VersionDownload(c *gin.Context) {
name := filepath.Base(c.Param("filename"))
if name == "" || name == "." || name == ".." {
c.Status(http.StatusBadRequest)
return
}
ext := strings.ToLower(filepath.Ext(name))
if !releaseExtOK[ext] {
c.Status(http.StatusBadRequest)
return
}
if err := ensureReleaseDir(); err != nil {
c.Status(http.StatusInternalServerError)
return
}
fp := filepath.Join(releaseDir, name)
f, err := os.Open(fp)
if err != nil {
if os.IsNotExist(err) {
c.Status(http.StatusNotFound)
return
}
c.Status(http.StatusInternalServerError)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() {
c.Status(http.StatusNotFound)
return
}
safe := strings.ReplaceAll(name, `"`, "")
c.Header("Content-Disposition", `attachment; filename="`+safe+`"`)
c.Header("Content-Type", "application/octet-stream")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Cache-Control", "public, max-age=3600")
http.ServeContent(c.Writer, c.Request, name, st.ModTime(), f)
}
// AdminVersionGet 后台:当前版本配置 + 已上传安装包列表(最多 3 个)
func AdminVersionGet(c *gin.Context) {
items, err := listReleaseFiles()
@@ -267,7 +375,7 @@ func AdminVersionUpload(c *gin.Context) {
return
}
_ = service.SetConfig(model.ConfKeyAppVersion, ver)
_ = service.SetConfig(model.ConfKeyAppDownloadURL, "/download/"+name)
_ = service.SetConfig(model.ConfKeyAppDownloadURL, releaseAPIPath(name))
if notes := strings.TrimSpace(c.PostForm("release_notes")); notes != "" {
_ = service.SetConfig(model.ConfKeyAppReleaseNotes, notes)
}

View File

@@ -0,0 +1,95 @@
package handler
import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestParseVersionFromName(t *testing.T) {
cases := []struct {
name, want string
}{
{"PixelArcade-Setup-1.0.1.exe", "1.0.1"},
{"PixelArcade-Setup-2.10.0.msi", "2.10.0"},
{"foo.zip", ""},
{"Setup.exe", ""},
}
for _, c := range cases {
if g := parseVersionFromName(c.name); g != c.want {
t.Fatalf("parseVersionFromName(%q)=%q, want %q", c.name, g, c.want)
}
}
}
func TestCompareSemver(t *testing.T) {
if compareSemver("1.0.1", "1.0.0") <= 0 {
t.Fatal("1.0.1 should be newer than 1.0.0")
}
if compareSemver("1.0.0", "1.0.0") != 0 {
t.Fatal("equal versions")
}
if compareSemver("1.0.0", "1.2.0") >= 0 {
t.Fatal("1.0.0 should be older than 1.2.0")
}
}
func TestCanonicalDownloadPath(t *testing.T) {
cases := []struct{ in, want string }{
{"", ""},
{"/download/PixelArcade-Setup-1.0.1.exe", "/api/download/PixelArcade-Setup-1.0.1.exe"},
{"/api/download/foo.exe", "/api/download/foo.exe"},
{"https://game.nailaoyun.cn/download/foo.exe", "/api/download/foo.exe"},
{"http://127.0.0.1:8080/download/foo.exe", "/api/download/foo.exe"},
{"https://cdn.example.com/releases/foo.exe", "https://cdn.example.com/releases/foo.exe"},
}
for _, c := range cases {
if g := canonicalDownloadPath(c.in); g != c.want {
t.Fatalf("canonicalDownloadPath(%q)=%q, want %q", c.in, g, c.want)
}
}
}
func TestVersionDownloadAttachment(t *testing.T) {
gin.SetMode(gin.TestMode)
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(releaseDir, 0o755); err != nil {
t.Fatal(err)
}
name := "PixelArcade-Setup-1.2.3.exe"
body := append([]byte("MZ"), []byte(strings.Repeat("x", 80))...)
if err := os.WriteFile(filepath.Join(releaseDir, name), body, 0o644); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/download/"+name, nil)
c.Params = gin.Params{{Key: "filename", Value: name}}
VersionDownload(c)
if w.Code != 200 {
t.Fatalf("status %d", w.Code)
}
cd := w.Header().Get("Content-Disposition")
if !strings.Contains(cd, name) || !strings.Contains(cd, "attachment") {
t.Fatalf("Content-Disposition %q", cd)
}
if w.Header().Get("Content-Type") != "application/octet-stream" {
t.Fatalf("Content-Type %q", w.Header().Get("Content-Type"))
}
if !strings.HasPrefix(w.Body.String(), "MZ") {
t.Fatal("body should start with MZ")
}
}

View File

@@ -1,16 +1,17 @@
package model
// GameSave 游戏存档表:支持存档的单机游戏(如饥荒)一人一游戏一份快照
// GameSave 游戏存档表:支持存档的单机游戏(如饥荒)一人可有多份命名快照
// 快照内容由前端游戏自行序列化(JSON 字符串),后端只负责存取
type GameSave struct {
ID int `gorm:"primaryKey" json:"id"` // 存档ID
UserID int `gorm:"uniqueIndex:uk_user_game" json:"user_id"` // 玩家用户ID
GameID int `gorm:"uniqueIndex:uk_user_game" json:"game_id"` // 游戏ID
Day int `gorm:"default:1" json:"day"` // 存档进度天数(列表/继续面板展示用)
Score int `gorm:"default:0" json:"score"` // 存档时分数(展示用)
Data string `gorm:"type:mediumtext" json:"data"` // 游戏状态快照(JSON 字符串,前端序列化)
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 创建时间(int 时间戳)
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间(int 时间戳)
ID int `gorm:"primaryKey" json:"id"` // 存档ID
UserID int `gorm:"index:idx_user_game" json:"user_id"` // 玩家用户ID
GameID int `gorm:"index:idx_user_game" json:"game_id"` // 游戏ID
Day int `gorm:"default:1" json:"day"` // 存档进度天数(列表展示用)
Score int `gorm:"default:0" json:"score"` // 存档时分数(展示用)
Name string `gorm:"size:64;default:冒险" json:"name"` // 存档显示名
Data string `gorm:"type:mediumtext" json:"data"` // 游戏状态快照(JSON 字符串)
CreatedAt int64 `gorm:"autoCreateTime" json:"created_at"` // 创建时间
UpdatedAt int64 `gorm:"autoUpdateTime" json:"updated_at"` // 更新时间
}
// TableName 指定表名

View File

@@ -15,8 +15,8 @@ func Setup() *gin.Engine {
r.Use(middleware.CORS())
// 安装包可达 ~100MB+,提高 multipart 内存阈值(超出部分写临时文件)
r.MaxMultipartMemory = 256 << 20
// 桌面安装包静态下载:后台上传到 data/releases,对外路径 /download/xxx.exe
r.Static("/download", handler.ReleaseDir())
// 兼容旧下载路径;生产请优先用 /api/download/(已被 Nginx /api/ 反代,不会被 SPA 吃掉)
r.GET("/download/:filename", handler.VersionDownload)
// WebSocket 对战入口(?token= 鉴权,内部自行校验)
hub := room.NewHub()
r.GET("/ws", hub.HandleWS)
@@ -28,6 +28,8 @@ func Setup() *gin.Engine {
api.GET("/config", handler.SiteConfigPublic)
// 公开接口:桌面端检查最新版本(无需登录)
api.GET("/version/latest", handler.VersionLatest)
// 公开下载安装包(走 /api/ 反代,带 attachment 头)
api.GET("/download/:filename", handler.VersionDownload)
// 登录后接口
auth := api.Group("", middleware.Auth())
{
@@ -48,9 +50,13 @@ func Setup() *gin.Engine {
// 关卡进度(带关卡的游戏:查我的+好友进度 / 过关上报)
auth.GET("/games/:code/progress", handler.GameProgressGet)
auth.POST("/games/:code/progress", handler.GameProgressSave)
// 游戏存档(支持存档的游戏:读档/覆盖保存/删档)
// 游戏存档(多存档:列表/读一份/新建或更新/删一份;旧单档接口仍兼容)
auth.GET("/games/:code/saves", handler.GameSaveList)
auth.GET("/games/:code/saves/:id", handler.GameSaveGetOne)
auth.POST("/games/:code/saves", handler.GameSaveSet)
auth.DELETE("/games/:code/saves/:id", handler.GameSaveDeleteOne)
auth.GET("/games/:code/save", handler.GameSaveGet)
auth.POST("/games/:code/save", handler.GameSaveSet)
auth.POST("/games/:code/save", handler.GameSaveSetLegacy)
auth.DELETE("/games/:code/save", handler.GameSaveDelete)
// 游戏皮肤(更衣室:皮肤列表 / 积分购买)
auth.GET("/games/:code/skins", handler.GameSkins)