From d6866cd85bdc067531faaf197e7fedae97429b9c Mon Sep 17 00:00:00 2001 From: liangxu Date: Sun, 31 May 2026 19:28:35 +0800 Subject: [PATCH] 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. --- .../src/components/kol/KolVariableConfig.vue | 8 +- apps/admin-web/src/lib/api.ts | 78 + apps/admin-web/src/views/MediaView.vue | 1334 ++++++++++++++--- packages/shared-types/src/index.ts | 28 +- .../ops/app/desktop_client_release.go | 105 +- .../ops/app/desktop_client_release_test.go | 55 + .../transport/desktop_release_handler.go | 9 + server/internal/tenant/transport/router.go | 3 + 8 files changed, 1419 insertions(+), 201 deletions(-) diff --git a/apps/admin-web/src/components/kol/KolVariableConfig.vue b/apps/admin-web/src/components/kol/KolVariableConfig.vue index 18d6b77..c3ea233 100644 --- a/apps/admin-web/src/components/kol/KolVariableConfig.vue +++ b/apps/admin-web/src/components/kol/KolVariableConfig.vue @@ -254,13 +254,11 @@ defineExpose({
-
diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index fc01482..1d94f79 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -35,6 +35,10 @@ import type { CreatePublishJobRequest, CreatePublishJobResponse, DesktopAccountInfo, + DesktopClientDownloadableRelease, + DesktopClientDownloadableReleaseListResponse, + DesktopClientReleaseCheckResponse, + DesktopClientReleaseDownloadURLResponse, FolderDeletePreview, GenerateFromRuleRequest, GenerateFromRuleResponse, @@ -1285,6 +1289,80 @@ export const tenantAccountsApi = { }, } +export const desktopClientApi = { + downloadableReleases(channel = 'stable') { + return apiClient.get( + '/api/tenant/desktop-client/releases/downloadable', + { params: { channel } }, + ) + }, + publicLatest(params: { platform: string; arch: string; channel?: string; version?: string }) { + return publicClient.get('/api/desktop/releases/latest', { + params: { + platform: params.platform, + arch: params.arch, + channel: params.channel ?? 'stable', + version: params.version ?? '0.0.0', + }, + }) + }, + publicDownloadURL(params: { + platform: string + arch: string + channel?: string + version?: string + }) { + return publicClient.get( + '/api/desktop/releases/download-url', + { + params: { + platform: params.platform, + arch: params.arch, + channel: params.channel ?? 'stable', + version: params.version ?? '0.0.0', + }, + }, + ) + }, + async publicFallbackDownloadableReleases(): Promise { + const targets = [ + { platform: 'win32', arch: 'x64' }, + { platform: 'darwin', arch: 'arm64' }, + ] + const results = await Promise.allSettled( + targets.map((target) => this.publicLatest({ ...target, channel: 'stable' })), + ) + return { + items: results.flatMap((result, index) => { + if (result.status !== 'fulfilled') { + return [] + } + const release = result.value + const target = targets[index] + const item: DesktopClientDownloadableRelease = { + id: index + 1, + platform: release.platform ?? target.platform, + arch: release.arch ?? target.arch, + channel: release.channel ?? 'stable', + version: release.latest_version, + download_url: release.download_url ?? null, + oss_object_key: release.oss_object_key ?? null, + file_name: release.file_name ?? null, + file_size_bytes: release.file_size_bytes ?? null, + sha256: release.sha256 ?? null, + release_notes: release.release_notes ?? null, + published_at: release.published_at ?? null, + updated_at: release.published_at ?? new Date(0).toISOString(), + force_update: release.force_update, + min_supported_version: release.min_supported_version, + download_source: release.download_source, + } + return [item] + }), + } + }, +} + export const publishJobsApi = { create(payload: CreatePublishJobRequest) { return apiClient.post( diff --git a/apps/admin-web/src/views/MediaView.vue b/apps/admin-web/src/views/MediaView.vue index 14ab91b..5017bf1 100644 --- a/apps/admin-web/src/views/MediaView.vue +++ b/apps/admin-web/src/views/MediaView.vue @@ -1,13 +1,18 @@ diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 007e376..57e52e3 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -145,6 +145,9 @@ export interface DesktopClientReleaseCheckResponse { update_available: boolean latest_version: string current_version: string + platform?: 'darwin' | 'win32' | 'linux' | string + arch?: 'universal' | 'x64' | 'arm64' | 'ia32' | string + channel?: string min_supported_version: string | null force_update: boolean download_source: 'oss' | 'custom' @@ -164,10 +167,33 @@ export interface DesktopClientReleaseCheckResponse { } export interface DesktopClientReleaseDownloadURLResponse { - download_url: string + download_url?: string | null expires_in?: number | null } +export interface DesktopClientDownloadableRelease { + id: number + platform: 'darwin' | 'win32' | 'linux' | string + arch: 'universal' | 'x64' | 'arm64' | 'ia32' | string + channel: string + version: string + download_url?: string | null + file_name?: string | null + file_size_bytes?: number | null + sha256?: string | null + release_notes?: string | null + published_at?: string | null + updated_at: string + force_update: boolean + min_supported_version?: string | null + download_source: 'oss' | 'custom' | string + oss_object_key?: string | null +} + +export interface DesktopClientDownloadableReleaseListResponse { + items: DesktopClientDownloadableRelease[] +} + export type DesktopClientUpdateStage = | 'idle' | 'checking' diff --git a/server/internal/ops/app/desktop_client_release.go b/server/internal/ops/app/desktop_client_release.go index f323bf0..5c53a64 100644 --- a/server/internal/ops/app/desktop_client_release.go +++ b/server/internal/ops/app/desktop_client_release.go @@ -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 diff --git a/server/internal/ops/app/desktop_client_release_test.go b/server/internal/ops/app/desktop_client_release_test.go index 226a201..0500cde 100644 --- a/server/internal/ops/app/desktop_client_release_test.go +++ b/server/internal/ops/app/desktop_client_release_test.go @@ -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 +} diff --git a/server/internal/tenant/transport/desktop_release_handler.go b/server/internal/tenant/transport/desktop_release_handler.go index 81c72f1..94f19bd 100644 --- a/server/internal/tenant/transport/desktop_release_handler.go +++ b/server/internal/tenant/transport/desktop_release_handler.go @@ -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")), diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index a8521b6..477e542 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -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)