Files

179 lines
5.0 KiB
Go
Raw Permalink Normal View History

2026-08-15 17:04:47 +08:00
package service
import (
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/config"
"nl-pms-api/internal/model"
)
var versionRe = regexp.MustCompile(`^\d+\.\d+\.\d+([-.][A-Za-z0-9.]+)?$`)
// ReleaseService 客户端发版。
type ReleaseService struct {
DB *gorm.DB
Cfg *config.Config
}
type LatestRelease struct {
Version string `json:"version"`
Channel string `json:"channel"`
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"sizeBytes"`
Changelog string `json:"changelog"`
CreatedAt string `json:"createdAt"`
}
// List 发版列表(新→旧)。
func (s *ReleaseService) List(channel string) ([]model.AppRelease, error) {
channel = normalizeChannel(channel)
var rows []model.AppRelease
q := s.DB.Order("id DESC").Limit(50)
if channel != "" {
q = q.Where("channel = ?", channel)
}
if err := q.Find(&rows).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
if rows == nil {
rows = []model.AppRelease{}
}
return rows, nil
}
// Latest 当前最新版元数据。
func (s *ReleaseService) Latest(channel string) (*LatestRelease, error) {
channel = normalizeChannel(channel)
var row model.AppRelease
err := s.DB.Where("channel = ? AND is_latest = 1", channel).Order("id DESC").First(&row).Error
if err == gorm.ErrRecordNotFound {
return nil, commonservice.NotFound("NO_RELEASE")
}
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
return &LatestRelease{
Version: row.Version,
Channel: row.Channel,
SHA256: row.SHA256,
SizeBytes: row.SizeBytes,
Changelog: row.Changelog,
CreatedAt: row.CreatedAt,
}, nil
}
// Upload 保存安装包并写入元数据(默认不标 latest需 Publish
func (s *ReleaseService) Upload(version, channel, changelog string, r io.Reader, size int64) (*model.AppRelease, error) {
version = strings.TrimSpace(version)
channel = normalizeChannel(channel)
changelog = strings.TrimSpace(changelog)
if !versionRe.MatchString(version) {
return nil, commonservice.BadRequest("VERSION_INVALID")
}
if size <= 0 {
return nil, commonservice.BadRequest("EMPTY_FILE")
}
if size > s.Cfg.MaxReleaseBytes() {
return nil, commonservice.BadRequest("FILE_TOO_LARGE")
}
var n int64
s.DB.Model(&model.AppRelease{}).Where("version = ? AND channel = ?", version, channel).Count(&n)
if n > 0 {
return nil, commonservice.Conflict("VERSION_EXISTS")
}
dir := filepath.Join(s.Cfg.StorageDir, "releases", channel)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
relName := filepath.ToSlash(filepath.Join("releases", channel, version+"-installer.exe"))
abs := filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(relName))
f, err := os.Create(abs)
if err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
defer f.Close()
h := sha256.New()
written, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(r, s.Cfg.MaxReleaseBytes()+1))
if err != nil {
_ = os.Remove(abs)
return nil, commonservice.Internal("SAVE_FAILED")
}
if written > s.Cfg.MaxReleaseBytes() {
_ = os.Remove(abs)
return nil, commonservice.BadRequest("FILE_TOO_LARGE")
}
sum := hex.EncodeToString(h.Sum(nil))
row := model.AppRelease{
Version: version,
Channel: channel,
Filename: relName,
SHA256: sum,
SizeBytes: written,
Changelog: changelog,
CreatedAt: commonservice.NowRFC(),
IsLatest: 0,
}
if err := s.DB.Create(&row).Error; err != nil {
_ = os.Remove(abs)
return nil, commonservice.Internal("SAVE_FAILED")
}
return &row, nil
}
// Publish 将指定发版标为该渠道最新。
func (s *ReleaseService) Publish(id int64) (*model.AppRelease, error) {
var row model.AppRelease
if err := s.DB.First(&row, id).Error; err != nil {
return nil, commonservice.NotFound("NOT_FOUND")
}
tx := s.DB.Begin()
if err := tx.Model(&model.AppRelease{}).Where("channel = ?", row.Channel).Update("is_latest", 0).Error; err != nil {
tx.Rollback()
return nil, commonservice.Internal("SAVE_FAILED")
}
if err := tx.Model(&row).Update("is_latest", 1).Error; err != nil {
tx.Rollback()
return nil, commonservice.Internal("SAVE_FAILED")
}
if err := tx.Commit().Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
row.IsLatest = 1
return &row, nil
}
// OpenFile 打开发版文件供下载。
func (s *ReleaseService) OpenFile(version, channel string) (*model.AppRelease, *os.File, error) {
version = strings.TrimSpace(version)
channel = normalizeChannel(channel)
var row model.AppRelease
if err := s.DB.Where("version = ? AND channel = ?", version, channel).First(&row).Error; err != nil {
return nil, nil, commonservice.NotFound("NOT_FOUND")
}
abs := filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(row.Filename))
f, err := os.Open(abs)
if err != nil {
return nil, nil, commonservice.NotFound("NOT_FOUND")
}
return &row, f, nil
}
func normalizeChannel(ch string) string {
ch = strings.TrimSpace(strings.ToLower(ch))
if ch == "" {
return "stable"
}
return ch
}