package handler import ( "os" "path/filepath" "regexp" "sort" "strconv" "strings" "github.com/gin-gonic/gin" "nl-game-api-gin/internal/model" "nl-game-api-gin/internal/service" "nl-game-api-gin/pkg/resp" ) // 桌面安装包存放目录(相对进程工作目录);Nginx 可 alias 同目录或反代 /download/ const releaseDir = "data/releases" // 最多保留的安装包数量(按语义化版本从新到旧) const maxReleaseKeep = 3 var ( // 从文件名解析版本号,如 PixelArcade-Setup-1.0.1.exe → 1.0.1 releaseVerRe = regexp.MustCompile(`(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?)`) // 允许的安装包后缀 releaseExtOK = map[string]bool{".exe": true, ".msi": true, ".dmg": true, ".zip": true, ".7z": true} ) // releaseItem 单个安装包在列表接口中的展示结构 type releaseItem struct { Filename string `json:"filename"` Version string `json:"version"` Size int64 `json:"size"` URL string `json:"url"` // 相对路径 /download/xxx,前端/客户端拼域名 ModTime int64 `json:"mod_time"` IsCurrent bool `json:"is_current"` } // ensureReleaseDir 确保安装包目录存在 func ensureReleaseDir() error { return os.MkdirAll(releaseDir, 0o755) } // ReleaseDir 对外暴露目录路径(路由挂静态文件用) func ReleaseDir() string { _ = ensureReleaseDir() return releaseDir } // parseVersionFromName 从安装包文件名提取语义化版本;失败返回空串 func parseVersionFromName(name string) string { base := filepath.Base(name) m := releaseVerRe.FindStringSubmatch(base) if len(m) < 2 { return "" } return m[1] } // compareSemver 比较 a、b:a>b → 1,a==b → 0,a n { n = len(pb) } for i := 0; i < n; i++ { var va, vb int if i < len(pa) { va, _ = strconv.Atoi(pa[i]) } if i < len(pb) { vb, _ = strconv.Atoi(pb[i]) } if va > vb { return 1 } if va < vb { return -1 } } return 0 } // listReleaseFiles 扫描目录内允许后缀的安装包,按版本降序 func listReleaseFiles() ([]releaseItem, error) { if err := ensureReleaseDir(); err != nil { return nil, err } entries, err := os.ReadDir(releaseDir) if err != nil { return nil, err } curVer := service.GetConfig(model.ConfKeyAppVersion, "") var items []releaseItem for _, e := range entries { if e.IsDir() { continue } name := e.Name() ext := strings.ToLower(filepath.Ext(name)) if !releaseExtOK[ext] { continue } info, err := e.Info() if err != nil { continue } ver := parseVersionFromName(name) items = append(items, releaseItem{ Filename: name, Version: ver, Size: info.Size(), URL: "/download/" + name, ModTime: info.ModTime().Unix(), IsCurrent: ver != "" && ver == curVer, }) } sort.Slice(items, func(i, j int) bool { c := compareSemver(items[i].Version, items[j].Version) if c != 0 { return c > 0 } return items[i].ModTime > items[j].ModTime }) return items, nil } // pruneReleases 只保留最新 maxReleaseKeep 个安装包,删除其余 func pruneReleases() error { items, err := listReleaseFiles() if err != nil { return err } if len(items) <= maxReleaseKeep { return nil } for _, it := range items[maxReleaseKeep:] { _ = os.Remove(filepath.Join(releaseDir, it.Filename)) } return nil } // publicOrigin 根据请求拼对外可访问的站点根(含协议),用于把相对下载路径补成绝对 URL func publicOrigin(c *gin.Context) string { scheme := c.GetHeader("X-Forwarded-Proto") if scheme == "" { if c.Request.TLS != nil { scheme = "https" } else { scheme = "http" } } host := c.GetHeader("X-Forwarded-Host") if host == "" { host = c.Request.Host } if host == "" { return "" } return scheme + "://" + host } // absDownloadURL 相对路径 → 绝对 URL;已是 http(s) 则原样返回 func absDownloadURL(c *gin.Context, u string) string { u = strings.TrimSpace(u) if u == "" { return "" } if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") { return u } origin := publicOrigin(c) if origin == "" { return u } if !strings.HasPrefix(u, "/") { u = "/" + u } return origin + u } // VersionLatest 公开的桌面端最新版本信息,无需登录 // 桌面端启动时与「检查更新」调用,返回 version / download_url / release_notes / force func VersionLatest(c *gin.Context) { rawURL := service.GetConfig(model.ConfKeyAppDownloadURL, "") resp.OK(c, gin.H{ "version": service.GetConfig(model.ConfKeyAppVersion, "1.0.0"), "download_url": absDownloadURL(c, rawURL), "release_notes": service.GetConfig(model.ConfKeyAppReleaseNotes, ""), // 1=强制更新,桌面端会拦截跳过按钮;其他值或空都视为不强制 "force": service.GetConfigInt(model.ConfKeyAppForceUpdate, 0), }) } // AdminVersionGet 后台:当前版本配置 + 已上传安装包列表(最多 3 个) func AdminVersionGet(c *gin.Context) { items, err := listReleaseFiles() if err != nil { resp.Fail(c, "读取安装包目录失败:"+err.Error()) return } rawURL := service.GetConfig(model.ConfKeyAppDownloadURL, "") resp.OK(c, gin.H{ "version": service.GetConfig(model.ConfKeyAppVersion, "1.0.0"), "download_url": absDownloadURL(c, rawURL), "release_notes": service.GetConfig(model.ConfKeyAppReleaseNotes, ""), "force": service.GetConfigInt(model.ConfKeyAppForceUpdate, 0), "keep": maxReleaseKeep, "releases": items, }) } // AdminVersionSave 后台:仅更新说明 / 强制更新开关(不改安装包) func AdminVersionSave(c *gin.Context) { var req struct { ReleaseNotes *string `json:"release_notes"` Force *int `json:"force"` } if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, "参数错误") return } if req.ReleaseNotes != nil { _ = service.SetConfig(model.ConfKeyAppReleaseNotes, *req.ReleaseNotes) } if req.Force != nil { v := "0" if *req.Force == 1 { v = "1" } _ = service.SetConfig(model.ConfKeyAppForceUpdate, v) } AdminVersionGet(c) } // AdminVersionUpload 后台:上传桌面安装包,从文件名解析版本,写 site_configs,并只保留最近 3 个 // 表单字段:file(必填)、release_notes(可选)、force(可选 0/1) func AdminVersionUpload(c *gin.Context) { fh, err := c.FormFile("file") if err != nil { resp.Fail(c, "请选择安装包文件") return } name := filepath.Base(fh.Filename) ext := strings.ToLower(filepath.Ext(name)) if !releaseExtOK[ext] { resp.Fail(c, "仅支持 .exe / .msi / .dmg / .zip / .7z") return } ver := parseVersionFromName(name) if ver == "" { resp.Fail(c, "无法从文件名解析版本号,请使用类似 PixelArcade-Setup-1.0.1.exe 的命名") return } if err := ensureReleaseDir(); err != nil { resp.Fail(c, "创建目录失败:"+err.Error()) return } // 同名覆盖;不同文件同版本也允许(后上传覆盖目录内旧文件靠 prune + 文件名) dst := filepath.Join(releaseDir, name) if err := c.SaveUploadedFile(fh, dst); err != nil { resp.Fail(c, "保存失败:"+err.Error()) return } _ = service.SetConfig(model.ConfKeyAppVersion, ver) _ = service.SetConfig(model.ConfKeyAppDownloadURL, "/download/"+name) if notes := strings.TrimSpace(c.PostForm("release_notes")); notes != "" { _ = service.SetConfig(model.ConfKeyAppReleaseNotes, notes) } if force := c.PostForm("force"); force == "1" || force == "true" { _ = service.SetConfig(model.ConfKeyAppForceUpdate, "1") } else if force == "0" || force == "false" { _ = service.SetConfig(model.ConfKeyAppForceUpdate, "0") } if err := pruneReleases(); err != nil { resp.Fail(c, "清理旧版本失败:"+err.Error()) return } // 若刚上传的文件被 prune 误删(不应发生,它是最新),再检查一下 if _, err := os.Stat(dst); err != nil { resp.Fail(c, "上传后文件丢失,请重试") return } AdminVersionGet(c) } // AdminVersionDelete 后台:删除指定安装包;若删的是当前版则回退到剩余最新版 func AdminVersionDelete(c *gin.Context) { name := filepath.Base(c.Param("filename")) if name == "" || name == "." || name == ".." { resp.Fail(c, "文件名无效") return } path := filepath.Join(releaseDir, name) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { resp.Fail(c, "删除失败:"+err.Error()) return } // 当前指向被删文件时,回退到仍存在的最新包 curURL := service.GetConfig(model.ConfKeyAppDownloadURL, "") if strings.HasSuffix(curURL, "/"+name) || strings.HasSuffix(curURL, name) { items, _ := listReleaseFiles() if len(items) > 0 { _ = service.SetConfig(model.ConfKeyAppVersion, items[0].Version) _ = service.SetConfig(model.ConfKeyAppDownloadURL, items[0].URL) } else { _ = service.SetConfig(model.ConfKeyAppDownloadURL, "") } } AdminVersionGet(c) }