存档功能
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
// ---- 游戏皮肤(更衣室:免费默认解锁,其余积分购买) ----
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
95
internal/handler/version_test.go
Normal file
95
internal/handler/version_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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 指定表名
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user