316 lines
8.9 KiB
Go
316 lines
8.9 KiB
Go
|
|
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<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)
|
|||
|
|
}
|