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
@@ -254,13 +254,11 @@ defineExpose({
<div v-if="variable.type !== 'checkbox' && variable.type !== 'select'" class="form-item">
<label>{{ t('kol.manage.variable.placeholder') }}</label>
<a-input
<a-textarea
:value="variable.placeholder"
size="small"
@input="
(e: Event) =>
updateVariable(index, { placeholder: (e.target as HTMLInputElement).value })
"
:rows="3"
@update:value="(val: string) => updateVariable(index, { placeholder: val })"
/>
</div>
+78
View File
@@ -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<DesktopClientDownloadableReleaseListResponse>(
'/api/tenant/desktop-client/releases/downloadable',
{ params: { channel } },
)
},
publicLatest(params: { platform: string; arch: string; channel?: string; version?: string }) {
return publicClient.get<DesktopClientReleaseCheckResponse>('/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<DesktopClientReleaseDownloadURLResponse>(
'/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<DesktopClientDownloadableReleaseListResponse> {
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<CreatePublishJobResponse, CreatePublishJobRequest>(
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -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'
@@ -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)