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);