feat: add downloadable release management for desktop client
- Extend DesktopClientReleaseCheckResponse with platform, arch, and channel fields. - Introduce DesktopClientDownloadableRelease and DesktopClientDownloadableReleaseListResponse interfaces for managing downloadable releases. - Implement ListDownloadable method in DesktopClientReleaseService to retrieve downloadable releases based on the specified channel. - Update DesktopClientReleaseCheckResult to include platform, arch, and channel information. - Add resolveInstallerDownloadURL method to handle download URL resolution for releases. - Create new endpoint in DesktopReleaseHandler for listing downloadable releases. - Update router to include the new downloadable releases route. - Enhance tests to cover the new functionality for resolving download URLs and listing downloadable releases.
This commit is contained in:
@@ -100,6 +100,29 @@ type DesktopClientReleaseListResult struct {
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type DesktopClientDownloadableRelease struct {
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
Channel string `json:"channel"`
|
||||
Version string `json:"version"`
|
||||
DownloadURL *string `json:"download_url"`
|
||||
FileName *string `json:"file_name"`
|
||||
FileSizeBytes *int64 `json:"file_size_bytes"`
|
||||
SHA256 *string `json:"sha256"`
|
||||
ReleaseNotes *string `json:"release_notes"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ForceUpdate bool `json:"force_update"`
|
||||
MinSupportedVersion *string `json:"min_supported_version"`
|
||||
DownloadSource string `json:"download_source"`
|
||||
OSSObjectKey *string `json:"oss_object_key"`
|
||||
}
|
||||
|
||||
type DesktopClientDownloadableReleaseListResult struct {
|
||||
Items []DesktopClientDownloadableRelease `json:"items"`
|
||||
}
|
||||
|
||||
type DesktopClientReleaseInput struct {
|
||||
Platform string
|
||||
Arch string
|
||||
@@ -151,6 +174,9 @@ type DesktopClientReleaseCheckResult struct {
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
Channel string `json:"channel"`
|
||||
MinSupportedVersion *string `json:"min_supported_version"`
|
||||
ForceUpdate bool `json:"force_update"`
|
||||
DownloadSource string `json:"download_source"`
|
||||
@@ -241,6 +267,55 @@ func (s *DesktopClientReleaseService) List(ctx context.Context, in DesktopClient
|
||||
return &DesktopClientReleaseListResult{Items: views, Total: total, Page: page, Size: size}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopClientReleaseService) ListDownloadable(ctx context.Context, channel string) (*DesktopClientDownloadableReleaseListResult, error) {
|
||||
enabled := true
|
||||
normalizedChannel := normalizeDesktopReleaseEnum(channel)
|
||||
if normalizedChannel == "" {
|
||||
normalizedChannel = "stable"
|
||||
}
|
||||
if !isValidDesktopReleaseToken(normalizedChannel) {
|
||||
return nil, response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效")
|
||||
}
|
||||
|
||||
items, _, err := s.releases.List(ctx, repository.DesktopClientReleaseFilter{
|
||||
Channel: normalizedChannel,
|
||||
Enabled: &enabled,
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
views := make([]DesktopClientDownloadableRelease, 0, len(items))
|
||||
for i := range items {
|
||||
var downloadURL *string
|
||||
if items[i].DownloadSource == DesktopClientDownloadSourceCustom {
|
||||
if value := strings.TrimSpace(stringPointerValue(items[i].CustomDownloadURL)); value != "" {
|
||||
downloadURL = &value
|
||||
}
|
||||
}
|
||||
views = append(views, DesktopClientDownloadableRelease{
|
||||
ID: items[i].ID,
|
||||
Platform: items[i].Platform,
|
||||
Arch: items[i].Arch,
|
||||
Channel: items[i].Channel,
|
||||
Version: items[i].Version,
|
||||
DownloadURL: downloadURL,
|
||||
FileName: items[i].FileName,
|
||||
FileSizeBytes: items[i].FileSizeBytes,
|
||||
SHA256: items[i].SHA256,
|
||||
ReleaseNotes: items[i].ReleaseNotes,
|
||||
PublishedAt: items[i].PublishedAt,
|
||||
UpdatedAt: items[i].UpdatedAt,
|
||||
ForceUpdate: items[i].ForceUpdate,
|
||||
MinSupportedVersion: items[i].MinSupportedVersion,
|
||||
DownloadSource: items[i].DownloadSource,
|
||||
OSSObjectKey: items[i].OSSObjectKey,
|
||||
})
|
||||
}
|
||||
return &DesktopClientDownloadableReleaseListResult{Items: views}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopClientReleaseService) Create(ctx context.Context, actor *Actor, in DesktopClientReleaseInput) (*DesktopClientReleaseView, error) {
|
||||
normalized, err := normalizeDesktopClientReleaseInput(in)
|
||||
if err != nil {
|
||||
@@ -378,6 +453,9 @@ func (s *DesktopClientReleaseService) CheckLatest(ctx context.Context, in Deskto
|
||||
UpdateAvailable: compareVersionStrings(item.Version, currentVersion) > 0,
|
||||
LatestVersion: item.Version,
|
||||
CurrentVersion: currentVersion,
|
||||
Platform: item.Platform,
|
||||
Arch: item.Arch,
|
||||
Channel: item.Channel,
|
||||
MinSupportedVersion: item.MinSupportedVersion,
|
||||
ForceUpdate: item.ForceUpdate || minSupportedVersionRequiresUpdate(item.MinSupportedVersion, currentVersion),
|
||||
DownloadSource: item.DownloadSource,
|
||||
@@ -519,13 +597,38 @@ func itemUpdaterAsset(item *domain.DesktopClientRelease) desktopClientReleaseAss
|
||||
}
|
||||
|
||||
func (s *DesktopClientReleaseService) resolveDownloadURLResult(item *domain.DesktopClientRelease) (*DesktopClientReleaseDownloadURLResult, error) {
|
||||
downloadURL, err := s.resolveAssetDownloadURL(itemInstallerAsset(item))
|
||||
downloadURL, err := s.resolveInstallerDownloadURL(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DesktopClientReleaseDownloadURLResult{DownloadURL: downloadURL}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopClientReleaseService) resolveInstallerDownloadURL(item *domain.DesktopClientRelease) (string, error) {
|
||||
asset := itemInstallerAsset(item)
|
||||
if !asset.HasTarget() {
|
||||
return "", nil
|
||||
}
|
||||
if stringPointerValue(asset.DownloadSource) == DesktopClientDownloadSourceCustom {
|
||||
return strings.TrimSpace(stringPointerValue(asset.CustomDownloadURL)), nil
|
||||
}
|
||||
|
||||
objectKey := strings.TrimSpace(stringPointerValue(asset.OSSObjectKey))
|
||||
if objectKey == "" {
|
||||
return "", response.ErrInternal(50090, "desktop_client_release_missing_object_key", "OSS object key is missing")
|
||||
}
|
||||
if s.storage == nil {
|
||||
return "", response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable")
|
||||
}
|
||||
downloadURL, err := s.storage.DownloadURL(objectKey)
|
||||
if err != nil {
|
||||
appErr := response.ErrInternal(50091, "object_storage_url_failed", err.Error())
|
||||
appErr.Cause = err
|
||||
return "", appErr
|
||||
}
|
||||
return downloadURL, nil
|
||||
}
|
||||
|
||||
func (s *DesktopClientReleaseService) resolveAssetDownloadURL(asset desktopClientReleaseAsset) (string, error) {
|
||||
if !asset.HasTarget() {
|
||||
return "", nil
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -192,3 +194,56 @@ func TestBuildDesktopClientReleaseObjectKeyReusesContentAddress(t *testing.T) {
|
||||
require.Equal(t, first, second)
|
||||
require.Equal(t, "desktop-client/releases/blobs/"+sum+".exe", first)
|
||||
}
|
||||
|
||||
func TestResolveDownloadURLResultUsesSignedDownloadURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
objectKey := "desktop-client/releases/blobs/client.zip"
|
||||
storage := &desktopReleaseDownloadURLStorage{}
|
||||
svc := &DesktopClientReleaseService{storage: storage}
|
||||
|
||||
result, err := svc.resolveDownloadURLResult(&domain.DesktopClientRelease{
|
||||
DownloadSource: DesktopClientDownloadSourceOSS,
|
||||
OSSObjectKey: &objectKey,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://oss.example.test/signed/client.zip?Expires=123", result.DownloadURL)
|
||||
require.Equal(t, []string{objectKey}, storage.downloadURLCalls)
|
||||
require.Empty(t, storage.publicURLCalls)
|
||||
}
|
||||
|
||||
type desktopReleaseDownloadURLStorage struct {
|
||||
publicURLCalls []string
|
||||
downloadURLCalls []string
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) PutBytes(context.Context, string, []byte, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) GetBytes(context.Context, string) ([]byte, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) Exists(context.Context, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) Delete(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) PublicURL(objectKey string) (string, error) {
|
||||
s.publicURLCalls = append(s.publicURLCalls, objectKey)
|
||||
return "", errors.New("public url should not be used for private release downloads")
|
||||
}
|
||||
|
||||
func (s *desktopReleaseDownloadURLStorage) DownloadURL(objectKey string) (string, error) {
|
||||
s.downloadURLCalls = append(s.downloadURLCalls, objectKey)
|
||||
return "https://oss.example.test/signed/client.zip?Expires=123", nil
|
||||
}
|
||||
|
||||
@@ -85,6 +85,15 @@ func (h *DesktopReleaseHandler) DownloadURLPublic(c *gin.Context) {
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *DesktopReleaseHandler) ListDownloadable(c *gin.Context) {
|
||||
data, err := h.svc.ListDownloadable(c.Request.Context(), c.DefaultQuery("channel", "stable"))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *DesktopReleaseHandler) UpdaterFeedPublic(c *gin.Context) {
|
||||
feed, err := h.svc.ResolveLatestUpdaterFeed(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
|
||||
Platform: firstNonEmpty(c.Param("platform"), c.Query("platform")),
|
||||
|
||||
@@ -98,6 +98,9 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
media := tenantProtected.Group("/media")
|
||||
media.GET("/platforms", mediaHandler.ListPlatforms)
|
||||
|
||||
desktopClient := tenantProtected.Group("/desktop-client")
|
||||
desktopClient.GET("/releases/downloadable", desktopReleaseHandler.ListDownloadable)
|
||||
|
||||
mediaSupplyHandler := NewMediaSupplyHandler(app)
|
||||
mediaSupply := tenantProtected.Group("/media-supply")
|
||||
mediaSupply.GET("/resources", mediaSupplyHandler.ListResources)
|
||||
|
||||
Reference in New Issue
Block a user