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 }