diff --git a/apps/ops-web/src/views/DesktopClientReleasesView.vue b/apps/ops-web/src/views/DesktopClientReleasesView.vue
index 2b576c9..2b0a7dd 100644
--- a/apps/ops-web/src/views/DesktopClientReleasesView.vue
+++ b/apps/ops-web/src/views/DesktopClientReleasesView.vue
@@ -175,7 +175,7 @@
-
+
diff --git a/server/internal/ops/app/desktop_client_release.go b/server/internal/ops/app/desktop_client_release.go
index 8af0b45..6770185 100644
--- a/server/internal/ops/app/desktop_client_release.go
+++ b/server/internal/ops/app/desktop_client_release.go
@@ -360,7 +360,6 @@ func (s *DesktopClientReleaseService) List(ctx context.Context, in DesktopClient
}
func (s *DesktopClientReleaseService) ListDownloadable(ctx context.Context, channel string) (*DesktopClientDownloadableReleaseListResult, error) {
- enabled := true
normalizedChannel := normalizeDesktopReleaseEnum(channel)
if normalizedChannel == "" {
normalizedChannel = "stable"
@@ -369,40 +368,37 @@ func (s *DesktopClientReleaseService) ListDownloadable(ctx context.Context, chan
return nil, response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效")
}
- items, _, err := s.releases.List(ctx, repository.DesktopClientReleaseFilter{
- Channel: normalizedChannel,
- Enabled: &enabled,
- Limit: 200,
- })
+ items, err := s.releases.ListEnabledByChannel(ctx, normalizedChannel)
if err != nil {
return nil, err
}
- views := make([]DesktopClientDownloadableRelease, 0, len(items))
- for i := range items {
+ latestItems := latestDesktopClientReleasesByTarget(items)
+ views := make([]DesktopClientDownloadableRelease, 0, len(latestItems))
+ for i := range latestItems {
var downloadURL *string
- if items[i].DownloadSource == DesktopClientDownloadSourceCustom {
- if value := strings.TrimSpace(stringPointerValue(items[i].CustomDownloadURL)); value != "" {
+ if latestItems[i].DownloadSource == DesktopClientDownloadSourceCustom {
+ if value := strings.TrimSpace(stringPointerValue(latestItems[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,
+ ID: latestItems[i].ID,
+ Platform: latestItems[i].Platform,
+ Arch: latestItems[i].Arch,
+ Channel: latestItems[i].Channel,
+ Version: latestItems[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,
+ FileName: latestItems[i].FileName,
+ FileSizeBytes: latestItems[i].FileSizeBytes,
+ SHA256: latestItems[i].SHA256,
+ ReleaseNotes: latestItems[i].ReleaseNotes,
+ PublishedAt: latestItems[i].PublishedAt,
+ UpdatedAt: latestItems[i].UpdatedAt,
+ ForceUpdate: latestItems[i].ForceUpdate,
+ MinSupportedVersion: latestItems[i].MinSupportedVersion,
+ DownloadSource: latestItems[i].DownloadSource,
+ OSSObjectKey: latestItems[i].OSSObjectKey,
})
}
return &DesktopClientDownloadableReleaseListResult{Items: views}, nil
@@ -417,7 +413,7 @@ func (s *DesktopClientReleaseService) Create(ctx context.Context, actor *Actor,
item, err := s.releases.Create(ctx, normalized.toRepositoryInput(actorID(actor)))
if err != nil {
if isDuplicateDesktopClientRelease(err) {
- return nil, response.ErrConflict(40930, "duplicate_desktop_client_release", "该平台、架构和渠道的客户端版本配置已存在")
+ return nil, response.ErrConflict(40930, "duplicate_desktop_client_release", "该平台、架构、渠道和版本的客户端版本配置已存在")
}
return nil, err
}
@@ -438,7 +434,7 @@ func (s *DesktopClientReleaseService) Update(ctx context.Context, actor *Actor,
return nil, response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在")
}
if isDuplicateDesktopClientRelease(err) {
- return nil, response.ErrConflict(40930, "duplicate_desktop_client_release", "该平台、架构和渠道的客户端版本配置已存在")
+ return nil, response.ErrConflict(40930, "duplicate_desktop_client_release", "该平台、架构、渠道和版本的客户端版本配置已存在")
}
return nil, err
}
@@ -767,7 +763,7 @@ func (s *DesktopClientReleaseService) CheckLatest(ctx context.Context, in Deskto
return nil, err
}
- item, err := s.releases.FindEnabled(ctx, target)
+ item, err := s.findLatestEnabledRelease(ctx, target)
if err != nil {
if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) {
return nil, response.ErrNotFound(40431, "desktop_client_release_not_configured", "未配置该平台客户端版本")
@@ -847,7 +843,7 @@ func (s *DesktopClientReleaseService) ResolveLatestDownloadURL(ctx context.Conte
if err != nil {
return nil, err
}
- item, err := s.releases.FindEnabled(ctx, target)
+ item, err := s.findLatestEnabledRelease(ctx, target)
if err != nil {
if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) {
return nil, response.ErrNotFound(40431, "desktop_client_release_not_configured", "未配置该平台客户端版本")
@@ -862,7 +858,7 @@ func (s *DesktopClientReleaseService) ResolveLatestUpdaterFeed(ctx context.Conte
if err != nil {
return nil, err
}
- item, err := s.releases.FindEnabled(ctx, target)
+ item, err := s.findLatestEnabledRelease(ctx, target)
if err != nil {
if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) {
return nil, response.ErrNotFound(40431, "desktop_client_release_not_configured", "未配置该平台客户端版本")
@@ -890,6 +886,79 @@ func (s *DesktopClientReleaseService) ResolveLatestUpdaterFeed(ctx context.Conte
}, nil
}
+func (s *DesktopClientReleaseService) findLatestEnabledRelease(ctx context.Context, target repository.DesktopClientReleaseTarget) (*domain.DesktopClientRelease, error) {
+ items, err := s.releases.ListEnabledByTarget(ctx, target)
+ if err != nil {
+ return nil, err
+ }
+ if len(items) == 0 {
+ return nil, repository.ErrDesktopClientReleaseNotFound
+ }
+ return latestDesktopClientRelease(items), nil
+}
+
+func latestDesktopClientReleasesByTarget(items []domain.DesktopClientRelease) []domain.DesktopClientRelease {
+ byTarget := map[string]domain.DesktopClientRelease{}
+ for _, item := range items {
+ key := item.Platform + "\x00" + item.Arch + "\x00" + item.Channel
+ current, ok := byTarget[key]
+ if !ok || isDesktopClientReleaseNewer(item, current) {
+ byTarget[key] = item
+ }
+ }
+
+ latest := make([]domain.DesktopClientRelease, 0, len(byTarget))
+ for _, item := range byTarget {
+ latest = append(latest, item)
+ }
+ sort.SliceStable(latest, func(i, j int) bool {
+ if latest[i].Platform != latest[j].Platform {
+ return latest[i].Platform < latest[j].Platform
+ }
+ if latest[i].Arch != latest[j].Arch {
+ return latest[i].Arch < latest[j].Arch
+ }
+ if latest[i].Channel != latest[j].Channel {
+ return latest[i].Channel < latest[j].Channel
+ }
+ return latest[i].ID > latest[j].ID
+ })
+ return latest
+}
+
+func latestDesktopClientRelease(items []domain.DesktopClientRelease) *domain.DesktopClientRelease {
+ if len(items) == 0 {
+ return nil
+ }
+ latest := items[0]
+ for _, item := range items[1:] {
+ if isDesktopClientReleaseNewer(item, latest) {
+ latest = item
+ }
+ }
+ return &latest
+}
+
+func isDesktopClientReleaseNewer(candidate, current domain.DesktopClientRelease) bool {
+ if archPriority(candidate.Arch) != archPriority(current.Arch) {
+ return archPriority(candidate.Arch) < archPriority(current.Arch)
+ }
+ if compared := compareVersionStrings(candidate.Version, current.Version); compared != 0 {
+ return compared > 0
+ }
+ if !candidate.UpdatedAt.Equal(current.UpdatedAt) {
+ return candidate.UpdatedAt.After(current.UpdatedAt)
+ }
+ return candidate.ID > current.ID
+}
+
+func archPriority(arch string) int {
+ if arch == "universal" {
+ return 1
+ }
+ return 0
+}
+
func itemInstallerAsset(item *domain.DesktopClientRelease) desktopClientReleaseAsset {
if item == nil {
return desktopClientReleaseAsset{}
diff --git a/server/internal/ops/app/desktop_client_release_test.go b/server/internal/ops/app/desktop_client_release_test.go
index 748c191..f5c8f68 100644
--- a/server/internal/ops/app/desktop_client_release_test.go
+++ b/server/internal/ops/app/desktop_client_release_test.go
@@ -97,6 +97,50 @@ func TestDesktopClientReleaseVersionPolicy(t *testing.T) {
require.False(t, minSupportedVersionRequiresUpdate(&minVersion, "1.2.0"))
}
+func TestLatestDesktopClientReleasePrefersVersionThenArch(t *testing.T) {
+ t.Parallel()
+
+ oldX64 := domain.DesktopClientRelease{
+ ID: 1,
+ Platform: "win32",
+ Arch: "x64",
+ Channel: "stable",
+ Version: "0.1.0",
+ UpdatedAt: time.Date(2026, 6, 10, 10, 0, 0, 0, time.UTC),
+ }
+ newX64 := domain.DesktopClientRelease{
+ ID: 2,
+ Platform: "win32",
+ Arch: "x64",
+ Channel: "stable",
+ Version: "0.1.1",
+ UpdatedAt: time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC),
+ }
+ newUniversal := domain.DesktopClientRelease{
+ ID: 3,
+ Platform: "win32",
+ Arch: "universal",
+ Channel: "stable",
+ Version: "0.2.0",
+ UpdatedAt: time.Date(2026, 6, 10, 11, 0, 0, 0, time.UTC),
+ }
+
+ require.Equal(t, newX64.ID, latestDesktopClientRelease([]domain.DesktopClientRelease{
+ newUniversal,
+ oldX64,
+ newX64,
+ }).ID)
+
+ latestByTarget := latestDesktopClientReleasesByTarget([]domain.DesktopClientRelease{
+ oldX64,
+ newX64,
+ newUniversal,
+ })
+ require.Len(t, latestByTarget, 2)
+ require.Equal(t, newUniversal.ID, latestByTarget[0].ID)
+ require.Equal(t, newX64.ID, latestByTarget[1].ID)
+}
+
func TestNormalizeDesktopClientReleaseUploadInput(t *testing.T) {
t.Parallel()
diff --git a/server/internal/ops/repository/desktop_client_release.go b/server/internal/ops/repository/desktop_client_release.go
index 90c15ee..95e18f4 100644
--- a/server/internal/ops/repository/desktop_client_release.go
+++ b/server/internal/ops/repository/desktop_client_release.go
@@ -199,6 +199,56 @@ func (r *DesktopClientReleaseRepository) FindEnabled(ctx context.Context, target
target.Platform, target.Channel, target.Arch))
}
+func (r *DesktopClientReleaseRepository) ListEnabledByTarget(ctx context.Context, target DesktopClientReleaseTarget) ([]domain.DesktopClientRelease, error) {
+ rows, err := r.pool.Query(ctx, `
+ SELECT `+desktopClientReleaseColumns+`
+ FROM ops.desktop_client_releases
+ WHERE platform = $1
+ AND channel = $2
+ AND enabled = TRUE
+ AND (arch = $3 OR arch = 'universal')
+ ORDER BY CASE WHEN arch = $3 THEN 0 ELSE 1 END, updated_at DESC, id DESC`,
+ target.Platform, target.Channel, target.Arch)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ items := []domain.DesktopClientRelease{}
+ for rows.Next() {
+ item, scanErr := scanDesktopClientRelease(rows)
+ if scanErr != nil {
+ return nil, scanErr
+ }
+ items = append(items, *item)
+ }
+ return items, rows.Err()
+}
+
+func (r *DesktopClientReleaseRepository) ListEnabledByChannel(ctx context.Context, channel string) ([]domain.DesktopClientRelease, error) {
+ rows, err := r.pool.Query(ctx, `
+ SELECT `+desktopClientReleaseColumns+`
+ FROM ops.desktop_client_releases
+ WHERE channel = $1
+ AND enabled = TRUE
+ ORDER BY channel ASC, platform ASC, arch ASC, updated_at DESC, id DESC`,
+ channel)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ items := []domain.DesktopClientRelease{}
+ for rows.Next() {
+ item, scanErr := scanDesktopClientRelease(rows)
+ if scanErr != nil {
+ return nil, scanErr
+ }
+ items = append(items, *item)
+ }
+ return items, rows.Err()
+}
+
func (r *DesktopClientReleaseRepository) Create(ctx context.Context, in UpsertDesktopClientReleaseInput) (*domain.DesktopClientRelease, error) {
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
INSERT INTO ops.desktop_client_releases (
diff --git a/server/migrations_ops/20260610120000_desktop_client_release_version_history.down.sql b/server/migrations_ops/20260610120000_desktop_client_release_version_history.down.sql
new file mode 100644
index 0000000..09a41d9
--- /dev/null
+++ b/server/migrations_ops/20260610120000_desktop_client_release_version_history.down.sql
@@ -0,0 +1,4 @@
+DROP INDEX IF EXISTS ops.uk_desktop_client_releases_target_version;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uk_desktop_client_releases_target
+ ON ops.desktop_client_releases(platform, arch, channel);
diff --git a/server/migrations_ops/20260610120000_desktop_client_release_version_history.up.sql b/server/migrations_ops/20260610120000_desktop_client_release_version_history.up.sql
new file mode 100644
index 0000000..8505c93
--- /dev/null
+++ b/server/migrations_ops/20260610120000_desktop_client_release_version_history.up.sql
@@ -0,0 +1,4 @@
+DROP INDEX IF EXISTS ops.uk_desktop_client_releases_target;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uk_desktop_client_releases_target_version
+ ON ops.desktop_client_releases(platform, arch, channel, version);