Files
nl-pms-api/internal/service/release.go
2026-08-15 17:04:47 +08:00

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