fix: allow desktop release version history
Frontend CI / Frontend (push) Successful in 2m12s
Backend CI / Backend (push) Successful in 14m12s

This commit is contained in:
2026-06-10 13:13:06 +08:00
parent b646dfdc11
commit 6a4eaf2827
6 changed files with 202 additions and 31 deletions
@@ -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{}
@@ -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()
@@ -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 (