179 lines
5.0 KiB
Go
179 lines
5.0 KiB
Go
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
|
||
}
|