feat: add downloadable release management for desktop client
Frontend CI / Frontend (push) Successful in 4m1s
Backend CI / Backend (push) Failing after 8m51s

- 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:
2026-05-31 19:28:35 +08:00
parent de9c52be6d
commit d6866cd85b
8 changed files with 1419 additions and 201 deletions
@@ -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)