From f58eb7b2763983bc10800406b109408157c16402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Thu, 24 Sep 2026 16:53:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=98=E6=A1=A3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/game.go | 203 +++++++++++++++++++++++++++---- internal/handler/version.go | 120 +++++++++++++++++- internal/handler/version_test.go | 95 +++++++++++++++ internal/model/save.go | 19 +-- internal/router/router.go | 14 ++- 5 files changed, 407 insertions(+), 44 deletions(-) create mode 100644 internal/handler/version_test.go diff --git a/internal/handler/game.go b/internal/handler/game.go index dc6af54..271a9da 100644 --- a/internal/handler/game.go +++ b/internal/handler/game.go @@ -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}) } // ---- 游戏皮肤(更衣室:免费默认解锁,其余积分购买) ---- diff --git a/internal/handler/version.go b/internal/handler/version.go index ce84cc8..5fd2b13 100644 --- a/internal/handler/version.go +++ b/internal/handler/version.go @@ -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) } diff --git a/internal/handler/version_test.go b/internal/handler/version_test.go new file mode 100644 index 0000000..5864c50 --- /dev/null +++ b/internal/handler/version_test.go @@ -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") + } +} diff --git a/internal/model/save.go b/internal/model/save.go index 1b505f2..9dc0217 100644 --- a/internal/model/save.go +++ b/internal/model/save.go @@ -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 指定表名 diff --git a/internal/router/router.go b/internal/router/router.go index 883ce2b..f2e25d1 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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)