Files
nl-game-api/internal/handler/version.go
2026-09-24 16:53:46 +08:00

424 lines
12 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 (
"net/http"
"net/url"
"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"
)
// 桌面安装包存放目录(相对进程工作目录)
// 对外下载走 /api/download/xxx(走 Nginx 已有的 /api/ 反代,避免被 SPA fallback 吃掉)
// 同时保留 /download/xxx 兼容旧链接
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"` // 相对路径 /api/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
}
// 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)
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: releaseAPIPath(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 = canonicalDownloadPath(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
}
// 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()
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, releaseAPIPath(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)
}