Files
nl-game-api/internal/handler/version.go
2026-08-14 17:50:37 +08:00

316 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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、ba>b → 1a==b → 0a<b → -1
func compareSemver(a, b string) int {
pa := strings.Split(strings.Split(a, "-")[0], ".")
pb := strings.Split(strings.Split(b, "-")[0], ".")
n := len(pa)
if len(pb) > 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)
}