From febfd65b4545b92159eb92baa984a196721fe689 Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 25 Jun 2026 00:40:08 +0800 Subject: [PATCH] fix: delete desktop release oss objects --- .../ops/app/desktop_client_release.go | 85 ++++++++- .../ops/app/desktop_client_release_test.go | 174 +++++++++++++++++- .../ops/repository/desktop_client_release.go | 22 +++ 3 files changed, 279 insertions(+), 2 deletions(-) diff --git a/server/internal/ops/app/desktop_client_release.go b/server/internal/ops/app/desktop_client_release.go index 3136f83..e099825 100644 --- a/server/internal/ops/app/desktop_client_release.go +++ b/server/internal/ops/app/desktop_client_release.go @@ -61,7 +61,7 @@ const ( ) type DesktopClientReleaseService struct { - releases *repository.DesktopClientReleaseRepository + releases desktopClientReleaseStore storage objectstorage.Client audits *AuditService releaseUploadDir string @@ -70,6 +70,18 @@ type DesktopClientReleaseService struct { releaseUploadMu sync.Mutex } +type desktopClientReleaseStore interface { + List(ctx context.Context, f repository.DesktopClientReleaseFilter) ([]domain.DesktopClientRelease, int64, error) + ListEnabledByChannel(ctx context.Context, channel string) ([]domain.DesktopClientRelease, error) + Create(ctx context.Context, in repository.UpsertDesktopClientReleaseInput) (*domain.DesktopClientRelease, error) + Update(ctx context.Context, id int64, in repository.UpsertDesktopClientReleaseInput) (*domain.DesktopClientRelease, error) + SetEnabled(ctx context.Context, id int64, enabled bool, actorID *int64) (*domain.DesktopClientRelease, error) + GetByID(ctx context.Context, id int64) (*domain.DesktopClientRelease, error) + ListEnabledByTarget(ctx context.Context, target repository.DesktopClientReleaseTarget) ([]domain.DesktopClientRelease, error) + HasObjectReference(ctx context.Context, objectKey string, excludeID int64) (bool, error) + Delete(ctx context.Context, id int64) error +} + func NewDesktopClientReleaseService( releases *repository.DesktopClientReleaseRepository, storage objectstorage.Client, @@ -536,6 +548,9 @@ func (s *DesktopClientReleaseService) Delete(ctx context.Context, actor *Actor, } return err } + if err := s.deleteReleaseObjects(ctx, item); err != nil { + return err + } if err := s.releases.Delete(ctx, id); err != nil { if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { return response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在") @@ -546,6 +561,46 @@ func (s *DesktopClientReleaseService) Delete(ctx context.Context, actor *Actor, return nil } +func (s *DesktopClientReleaseService) deleteReleaseObjects(ctx context.Context, item *domain.DesktopClientRelease) error { + objectKeys := desktopClientReleaseOSSObjectKeys(item) + if len(objectKeys) == 0 { + return nil + } + if s.storage == nil { + return response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") + } + if err := s.storage.Validate(); err != nil { + appErr := response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") + appErr.Cause = err + return appErr + } + + for _, objectKey := range objectKeys { + referenced, err := s.releases.HasObjectReference(ctx, objectKey, item.ID) + if err != nil { + return err + } + if referenced { + continue + } + exists, err := s.storage.Exists(ctx, objectKey) + if err != nil { + appErr := response.ErrInternal(50097, "desktop_client_release_object_check_failed", "检查客户端版本 OSS 对象失败") + appErr.Cause = err + return appErr + } + if !exists { + continue + } + if err := s.storage.Delete(ctx, objectKey); err != nil { + appErr := response.ErrInternal(50098, "desktop_client_release_object_delete_failed", "删除客户端版本 OSS 对象失败") + appErr.Cause = err + return appErr + } + } + return nil +} + func (s *DesktopClientReleaseService) UploadOSS(ctx context.Context, in DesktopClientReleaseUploadInput) (*DesktopClientReleaseUploadResult, error) { if s.storage == nil { return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") @@ -1441,6 +1496,34 @@ func itemUpdaterAsset(item *domain.DesktopClientRelease) desktopClientReleaseAss return itemInstallerAsset(item) } +func desktopClientReleaseOSSObjectKeys(item *domain.DesktopClientRelease) []string { + if item == nil { + return nil + } + candidates := []desktopClientReleaseAsset{itemInstallerAsset(item)} + if item.UpdaterDownloadSource != nil { + candidates = append(candidates, itemUpdaterAsset(item)) + } + + seen := map[string]struct{}{} + keys := make([]string, 0, len(candidates)) + for _, asset := range candidates { + if stringPointerValue(asset.DownloadSource) != DesktopClientDownloadSourceOSS { + continue + } + objectKey := strings.TrimSpace(stringPointerValue(asset.OSSObjectKey)) + if objectKey == "" { + continue + } + if _, ok := seen[objectKey]; ok { + continue + } + seen[objectKey] = struct{}{} + keys = append(keys, objectKey) + } + return keys +} + func (s *DesktopClientReleaseService) resolveDownloadURLResult(item *domain.DesktopClientRelease) (*DesktopClientReleaseDownloadURLResult, error) { downloadURL, err := s.resolveInstallerDownloadURL(item) if err != nil { diff --git a/server/internal/ops/app/desktop_client_release_test.go b/server/internal/ops/app/desktop_client_release_test.go index 43ec6da..5b0567d 100644 --- a/server/internal/ops/app/desktop_client_release_test.go +++ b/server/internal/ops/app/desktop_client_release_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/geo-platform/tenant-api/internal/ops/domain" + "github.com/geo-platform/tenant-api/internal/ops/repository" "github.com/geo-platform/tenant-api/internal/shared/objectstorage" "github.com/stretchr/testify/require" ) @@ -247,6 +248,110 @@ func TestBuildDesktopClientReleaseObjectKeyReusesContentAddress(t *testing.T) { require.Equal(t, "desktop-client/releases/blobs/"+sum+".exe", first) } +func TestDesktopClientReleaseOSSObjectKeysIncludesInstallerAndUpdater(t *testing.T) { + t.Parallel() + + installerKey := "desktop-client/releases/blobs/installer.dmg" + updaterKey := "desktop-client/releases/blobs/updater.zip" + ossSource := DesktopClientDownloadSourceOSS + + require.Equal(t, []string{installerKey, updaterKey}, desktopClientReleaseOSSObjectKeys(&domain.DesktopClientRelease{ + DownloadSource: DesktopClientDownloadSourceOSS, + OSSObjectKey: &installerKey, + UpdaterDownloadSource: &ossSource, + UpdaterOSSObjectKey: &updaterKey, + })) +} + +func TestDesktopClientReleaseOSSObjectKeysDedupesSharedInstallerUpdater(t *testing.T) { + t.Parallel() + + objectKey := "desktop-client/releases/blobs/shared.zip" + ossSource := DesktopClientDownloadSourceOSS + + require.Equal(t, []string{objectKey}, desktopClientReleaseOSSObjectKeys(&domain.DesktopClientRelease{ + DownloadSource: DesktopClientDownloadSourceOSS, + OSSObjectKey: &objectKey, + UpdaterDownloadSource: &ossSource, + UpdaterOSSObjectKey: &objectKey, + })) +} + +func TestDesktopClientReleaseDeleteRemovesUnreferencedOSSObjects(t *testing.T) { + t.Parallel() + + installerKey := "desktop-client/releases/blobs/installer.dmg" + updaterKey := "desktop-client/releases/blobs/updater.zip" + ossSource := DesktopClientDownloadSourceOSS + store := &desktopClientReleaseStoreFake{ + items: map[int64]*domain.DesktopClientRelease{ + 42: { + ID: 42, + DownloadSource: DesktopClientDownloadSourceOSS, + OSSObjectKey: &installerKey, + UpdaterDownloadSource: &ossSource, + UpdaterOSSObjectKey: &updaterKey, + }, + }, + } + storage := &desktopReleaseDownloadURLStorage{ + existsByKey: map[string]bool{ + installerKey: true, + updaterKey: true, + }, + objects: map[string][]byte{ + installerKey: []byte("installer"), + updaterKey: []byte("updater"), + }, + } + svc := NewDesktopClientReleaseService(nil, storage, nil) + svc.releases = store + + err := svc.Delete(context.Background(), nil, 42) + + require.NoError(t, err) + require.Equal(t, []string{installerKey, updaterKey}, storage.deleteCalls) + require.Equal(t, []int64{42}, store.deletedIDs) +} + +func TestDesktopClientReleaseDeleteKeepsOSSObjectReferencedByAnotherRelease(t *testing.T) { + t.Parallel() + + installerKey := "desktop-client/releases/blobs/installer.dmg" + updaterKey := "desktop-client/releases/blobs/updater.zip" + ossSource := DesktopClientDownloadSourceOSS + store := &desktopClientReleaseStoreFake{ + items: map[int64]*domain.DesktopClientRelease{ + 42: { + ID: 42, + DownloadSource: DesktopClientDownloadSourceOSS, + OSSObjectKey: &installerKey, + UpdaterDownloadSource: &ossSource, + UpdaterOSSObjectKey: &updaterKey, + }, + }, + references: map[string]bool{installerKey: true}, + } + storage := &desktopReleaseDownloadURLStorage{ + existsByKey: map[string]bool{ + installerKey: true, + updaterKey: true, + }, + objects: map[string][]byte{ + installerKey: []byte("installer"), + updaterKey: []byte("updater"), + }, + } + svc := NewDesktopClientReleaseService(nil, storage, nil) + svc.releases = store + + err := svc.Delete(context.Background(), nil, 42) + + require.NoError(t, err) + require.Equal(t, []string{updaterKey}, storage.deleteCalls) + require.Equal(t, []int64{42}, store.deletedIDs) +} + func TestResolveDownloadURLResultUsesSignedDownloadURL(t *testing.T) { t.Parallel() @@ -462,6 +567,7 @@ func TestDesktopClientReleaseDirectUploadCompleteJobReportsStatus(t *testing.T) type desktopReleaseDownloadURLStorage struct { publicURLCalls []string downloadURLCalls []string + deleteCalls []string existsByKey map[string]bool objects map[string][]byte objectSizes map[string]int64 @@ -544,7 +650,17 @@ func (s *desktopReleaseDownloadURLStorage) PresignedPutURL( }, nil } -func (s *desktopReleaseDownloadURLStorage) Delete(context.Context, string) error { +func (s *desktopReleaseDownloadURLStorage) Delete(_ context.Context, objectKey string) error { + s.deleteCalls = append(s.deleteCalls, objectKey) + if s.objects != nil { + delete(s.objects, objectKey) + } + if s.existsByKey != nil { + s.existsByKey[objectKey] = false + } + if s.objectSizes != nil { + delete(s.objectSizes, objectKey) + } return nil } @@ -579,3 +695,59 @@ func (s *desktopReleaseDownloadURLStorage) Stat(_ context.Context, objectKey str func (s *desktopReleaseDownloadURLStorage) Copy(context.Context, string, string) error { return nil } + +type desktopClientReleaseStoreFake struct { + items map[int64]*domain.DesktopClientRelease + references map[string]bool + deletedIDs []int64 +} + +func (s *desktopClientReleaseStoreFake) List(context.Context, repository.DesktopClientReleaseFilter) ([]domain.DesktopClientRelease, int64, error) { + items := make([]domain.DesktopClientRelease, 0, len(s.items)) + for _, item := range s.items { + items = append(items, *item) + } + return items, int64(len(items)), nil +} + +func (s *desktopClientReleaseStoreFake) ListEnabledByChannel(context.Context, string) ([]domain.DesktopClientRelease, error) { + return nil, nil +} + +func (s *desktopClientReleaseStoreFake) Create(context.Context, repository.UpsertDesktopClientReleaseInput) (*domain.DesktopClientRelease, error) { + return nil, errors.New("unexpected create call") +} + +func (s *desktopClientReleaseStoreFake) Update(context.Context, int64, repository.UpsertDesktopClientReleaseInput) (*domain.DesktopClientRelease, error) { + return nil, errors.New("unexpected update call") +} + +func (s *desktopClientReleaseStoreFake) SetEnabled(context.Context, int64, bool, *int64) (*domain.DesktopClientRelease, error) { + return nil, errors.New("unexpected set enabled call") +} + +func (s *desktopClientReleaseStoreFake) GetByID(_ context.Context, id int64) (*domain.DesktopClientRelease, error) { + item, ok := s.items[id] + if !ok { + return nil, repository.ErrDesktopClientReleaseNotFound + } + copied := *item + return &copied, nil +} + +func (s *desktopClientReleaseStoreFake) ListEnabledByTarget(context.Context, repository.DesktopClientReleaseTarget) ([]domain.DesktopClientRelease, error) { + return nil, nil +} + +func (s *desktopClientReleaseStoreFake) HasObjectReference(_ context.Context, objectKey string, _ int64) (bool, error) { + return s.references[objectKey], nil +} + +func (s *desktopClientReleaseStoreFake) Delete(_ context.Context, id int64) error { + if _, ok := s.items[id]; !ok { + return repository.ErrDesktopClientReleaseNotFound + } + s.deletedIDs = append(s.deletedIDs, id) + delete(s.items, id) + return nil +} diff --git a/server/internal/ops/repository/desktop_client_release.go b/server/internal/ops/repository/desktop_client_release.go index 95e18f4..392440d 100644 --- a/server/internal/ops/repository/desktop_client_release.go +++ b/server/internal/ops/repository/desktop_client_release.go @@ -328,6 +328,28 @@ func (r *DesktopClientReleaseRepository) SetEnabled(ctx context.Context, id int6 RETURNING `+desktopClientReleaseColumns, enabled, actorID, id)) } +func (r *DesktopClientReleaseRepository) HasObjectReference(ctx context.Context, objectKey string, excludeID int64) (bool, error) { + if objectKey == "" { + return false, nil + } + var exists bool + err := r.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 + FROM ops.desktop_client_releases + WHERE id <> $1 + AND ( + (download_source = 'oss' AND oss_object_key = $2) + OR + (updater_download_source = 'oss' AND updater_oss_object_key = $2) + ) + )`, excludeID, objectKey).Scan(&exists) + if err != nil { + return false, err + } + return exists, nil +} + func (r *DesktopClientReleaseRepository) Delete(ctx context.Context, id int64) error { cmd, err := r.pool.Exec(ctx, `DELETE FROM ops.desktop_client_releases WHERE id = $1`, id) if err != nil {