package app import ( "bytes" "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "sort" "strings" "sync" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "gopkg.in/yaml.v3" "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/geo-platform/tenant-api/internal/shared/response" ) const ( ActionDesktopClientReleaseCreate = "desktop_client_release.create" ActionDesktopClientReleaseUpdate = "desktop_client_release.update" ActionDesktopClientReleaseEnable = "desktop_client_release.enable" ActionDesktopClientReleaseDisable = "desktop_client_release.disable" ActionDesktopClientReleaseDelete = "desktop_client_release.delete" ) const ( DesktopClientDownloadSourceOSS = "oss" DesktopClientDownloadSourceCustom = "custom" ) const ( DesktopClientAssetKindInstaller = "installer" DesktopClientAssetKindUpdater = "updater" ) const ( MaxDesktopClientReleaseUploadBytes int64 = 1 * 1024 * 1024 * 1024 desktopClientReleaseMultipartOverheadBytes = 4 * 1024 * 1024 DefaultDesktopClientReleaseChunkSizeBytes = 16 * 1024 * 1024 MinDesktopClientReleaseChunkSizeBytes = 1 * 1024 * 1024 MaxDesktopClientReleaseChunkSizeBytes = 64 * 1024 * 1024 desktopClientReleaseContentMD5Bytes = 16 desktopClientReleaseUploadSessionTTL = 24 * time.Hour desktopClientReleaseDirectUploadTTL = 2 * time.Hour ) type DesktopClientReleaseService struct { releases desktopClientReleaseStore storage objectstorage.Client audits *AuditService releaseUploadDir string releaseUploadLocks map[string]*sync.Mutex completeJobs map[string]*desktopClientReleaseCompleteJob 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, audits *AuditService, ) *DesktopClientReleaseService { return &DesktopClientReleaseService{ releases: releases, storage: storage, audits: audits, releaseUploadDir: defaultDesktopClientReleaseUploadDir(), releaseUploadLocks: make(map[string]*sync.Mutex), completeJobs: make(map[string]*desktopClientReleaseCompleteJob), } } func (s *DesktopClientReleaseService) WithReleaseUploadDir(dir string) *DesktopClientReleaseService { if s == nil { return s } s.releaseUploadDir = strings.TrimSpace(dir) return s } type DesktopClientReleaseView struct { ID int64 `json:"id"` Platform string `json:"platform"` Arch string `json:"arch"` Channel string `json:"channel"` Version string `json:"version"` MinSupportedVersion *string `json:"min_supported_version"` DownloadSource string `json:"download_source"` OSSObjectKey *string `json:"oss_object_key"` CustomDownloadURL *string `json:"custom_download_url"` DownloadURL *string `json:"download_url"` FileName *string `json:"file_name"` FileSizeBytes *int64 `json:"file_size_bytes"` SHA256 *string `json:"sha256"` UpdaterDownloadSource *string `json:"updater_download_source"` UpdaterOSSObjectKey *string `json:"updater_oss_object_key"` UpdaterCustomDownloadURL *string `json:"updater_custom_download_url"` UpdaterDownloadURL *string `json:"updater_download_url"` UpdaterFileName *string `json:"updater_file_name"` UpdaterFileSizeBytes *int64 `json:"updater_file_size_bytes"` UpdaterSHA256 *string `json:"updater_sha256"` ReleaseNotes *string `json:"release_notes"` ForceUpdate bool `json:"force_update"` Enabled bool `json:"enabled"` PublishedAt *time.Time `json:"published_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type DesktopClientReleaseListInput struct { Keyword string Platform string Channel string Enabled *bool Page int Size int } type DesktopClientReleaseListResult struct { Items []DesktopClientReleaseView `json:"items"` Total int64 `json:"total"` Page int `json:"page"` 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 Channel string Version string MinSupportedVersion *string DownloadSource string OSSObjectKey *string CustomDownloadURL *string FileName *string FileSizeBytes *int64 SHA256 *string UpdaterDownloadSource *string UpdaterOSSObjectKey *string UpdaterCustomDownloadURL *string UpdaterFileName *string UpdaterFileSizeBytes *int64 UpdaterSHA256 *string ReleaseNotes *string ForceUpdate bool Enabled bool } type DesktopClientReleaseUploadInput struct { Platform string Arch string Channel string Version string Kind string FileName string Content []byte ContentReader io.ReadSeeker ContentSizeBytes int64 } type DesktopClientReleaseUploadResult struct { OSSObjectKey string `json:"oss_object_key"` FileName string `json:"file_name"` FileSizeBytes int64 `json:"file_size_bytes"` SHA256 string `json:"sha256"` } type DesktopClientReleaseCompleteJobResult struct { JobID string `json:"job_id"` Status string `json:"status"` Stage string `json:"stage"` Message string `json:"message"` Percent int `json:"percent"` Result *DesktopClientReleaseUploadResult `json:"result,omitempty"` ErrorCode string `json:"error_code,omitempty"` Error string `json:"error,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type DesktopClientReleaseChunkedUploadInitInput struct { Platform string Arch string Channel string Version string Kind string FileName string FileSizeBytes int64 ChunkSizeBytes int64 } type DesktopClientReleaseChunkedUploadInitResult struct { UploadID string `json:"upload_id"` ChunkSizeBytes int64 `json:"chunk_size_bytes"` ChunkCount int `json:"chunk_count"` UploadedChunks []int `json:"uploaded_chunks"` ExpiresAt time.Time `json:"expires_at"` } type DesktopClientReleaseChunkUploadInput struct { UploadID string ChunkIndex int ContentReader io.Reader ContentSizeBytes int64 } type DesktopClientReleaseChunkUploadResult struct { UploadID string `json:"upload_id"` ChunkIndex int `json:"chunk_index"` ReceivedBytes int64 `json:"received_bytes"` UploadedChunks []int `json:"uploaded_chunks"` } type DesktopClientReleaseDirectUploadInitInput struct { Platform string Arch string Channel string Version string Kind string FileName string FileSizeBytes int64 SHA256 string ContentMD5 string } type DesktopClientReleaseDirectUploadInitResult struct { UploadURL string `json:"upload_url,omitempty"` Method string `json:"method"` Headers map[string]string `json:"headers"` OSSObjectKey string `json:"oss_object_key"` FileName string `json:"file_name"` FileSizeBytes int64 `json:"file_size_bytes"` SHA256 string `json:"sha256"` ObjectExists bool `json:"object_exists"` ExpiresAt time.Time `json:"expires_at"` } type DesktopClientReleaseDirectUploadCompleteInput struct { Platform string Arch string Channel string Version string Kind string OSSObjectKey string FileName string FileSizeBytes int64 SHA256 string } type desktopClientReleaseCompleteJob struct { mu sync.RWMutex id string status string stage string message string percent int result *DesktopClientReleaseUploadResult errorCode string err string createdAt time.Time updatedAt time.Time } type desktopClientReleaseUploadManifest struct { UploadID string `json:"upload_id"` Platform string `json:"platform"` Arch string `json:"arch"` Channel string `json:"channel"` Version string `json:"version"` Kind string `json:"kind"` FileName string `json:"file_name"` FileSizeBytes int64 `json:"file_size_bytes"` ChunkSizeBytes int64 `json:"chunk_size_bytes"` ChunkCount int `json:"chunk_count"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ExpiresAt time.Time `json:"expires_at"` Chunks map[int]desktopClientReleaseUploadChunk `json:"chunks"` } type desktopClientReleaseUploadChunk struct { Index int `json:"index"` SizeBytes int64 `json:"size_bytes"` SHA256 string `json:"sha256"` UploadedAt time.Time `json:"uploaded_at"` } type DesktopClientReleaseCheckInput struct { Platform string Arch string Channel string CurrentVersion string } 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"` OSSObjectKey *string `json:"oss_object_key"` FileName *string `json:"file_name"` FileSizeBytes *int64 `json:"file_size_bytes"` SHA256 *string `json:"sha256"` UpdaterAvailable bool `json:"updater_available"` UpdaterDownloadSource *string `json:"updater_download_source"` UpdaterOSSObjectKey *string `json:"updater_oss_object_key"` UpdaterFileName *string `json:"updater_file_name"` UpdaterFileSizeBytes *int64 `json:"updater_file_size_bytes"` UpdaterSHA256 *string `json:"updater_sha256"` ReleaseNotes *string `json:"release_notes"` PublishedAt *time.Time `json:"published_at"` } type DesktopClientReleaseDownloadURLResult struct { DownloadURL string `json:"download_url"` ExpiresIn *int64 `json:"expires_in,omitempty"` } type DesktopClientUpdaterFeedResult struct { Content []byte FileName string } type desktopClientUpdaterFeed struct { Version string `yaml:"version"` Files []desktopClientUpdaterFile `yaml:"files"` Path string `yaml:"path,omitempty"` SHA2 string `yaml:"sha2,omitempty"` ReleaseDate string `yaml:"releaseDate,omitempty"` ReleaseName string `yaml:"releaseName,omitempty"` ReleaseNotes string `yaml:"releaseNotes,omitempty"` } type desktopClientUpdaterFile struct { URL string `yaml:"url"` SHA2 string `yaml:"sha2,omitempty"` Size *int64 `yaml:"size,omitempty"` } type desktopClientReleaseAsset struct { DownloadSource *string OSSObjectKey *string CustomDownloadURL *string FileName *string FileSizeBytes *int64 SHA256 *string } func (a desktopClientReleaseAsset) HasTarget() bool { switch stringPointerValue(a.DownloadSource) { case DesktopClientDownloadSourceCustom: return strings.TrimSpace(stringPointerValue(a.CustomDownloadURL)) != "" default: return strings.TrimSpace(stringPointerValue(a.OSSObjectKey)) != "" } } func (s *DesktopClientReleaseService) List(ctx context.Context, in DesktopClientReleaseListInput) (*DesktopClientReleaseListResult, error) { page := in.Page if page <= 0 { page = 1 } size := in.Size if size <= 0 || size > 200 { size = 20 } items, total, err := s.releases.List(ctx, repository.DesktopClientReleaseFilter{ Keyword: strings.TrimSpace(in.Keyword), Platform: normalizeDesktopReleaseEnum(in.Platform), Channel: normalizeDesktopReleaseEnum(in.Channel), Enabled: in.Enabled, Limit: size, Offset: (page - 1) * size, }) if err != nil { return nil, err } views := make([]DesktopClientReleaseView, 0, len(items)) for i := range items { views = append(views, s.toView(&items[i])) } return &DesktopClientReleaseListResult{Items: views, Total: total, Page: page, Size: size}, nil } func (s *DesktopClientReleaseService) ListDownloadable(ctx context.Context, channel string) (*DesktopClientDownloadableReleaseListResult, error) { normalizedChannel := normalizeDesktopReleaseEnum(channel) if normalizedChannel == "" { normalizedChannel = "stable" } if !isValidDesktopReleaseToken(normalizedChannel) { return nil, response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效") } items, err := s.releases.ListEnabledByChannel(ctx, normalizedChannel) if err != nil { return nil, err } latestItems := latestDesktopClientReleasesByTarget(items) views := make([]DesktopClientDownloadableRelease, 0, len(latestItems)) for i := range latestItems { var downloadURL *string if latestItems[i].DownloadSource == DesktopClientDownloadSourceCustom { if value := strings.TrimSpace(stringPointerValue(latestItems[i].CustomDownloadURL)); value != "" { downloadURL = &value } } views = append(views, DesktopClientDownloadableRelease{ ID: latestItems[i].ID, Platform: latestItems[i].Platform, Arch: latestItems[i].Arch, Channel: latestItems[i].Channel, Version: latestItems[i].Version, DownloadURL: downloadURL, 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 } func (s *DesktopClientReleaseService) Create(ctx context.Context, actor *Actor, in DesktopClientReleaseInput) (*DesktopClientReleaseView, error) { normalized, err := normalizeDesktopClientReleaseInput(in) if err != nil { return nil, err } 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, err } s.audit(ctx, actor, ActionDesktopClientReleaseCreate, item) view := s.toView(item) return &view, nil } func (s *DesktopClientReleaseService) Update(ctx context.Context, actor *Actor, id int64, in DesktopClientReleaseInput) (*DesktopClientReleaseView, error) { normalized, err := normalizeDesktopClientReleaseInput(in) if err != nil { return nil, err } item, err := s.releases.Update(ctx, id, normalized.toRepositoryInput(actorID(actor))) if err != nil { if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { return nil, response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在") } if isDuplicateDesktopClientRelease(err) { return nil, response.ErrConflict(40930, "duplicate_desktop_client_release", "该平台、架构、渠道和版本的客户端版本配置已存在") } return nil, err } s.audit(ctx, actor, ActionDesktopClientReleaseUpdate, item) view := s.toView(item) return &view, nil } func (s *DesktopClientReleaseService) SetEnabled(ctx context.Context, actor *Actor, id int64, enabled bool) (*DesktopClientReleaseView, error) { item, err := s.releases.SetEnabled(ctx, id, enabled, actorID(actor)) if err != nil { if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { return nil, response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在") } return nil, err } action := ActionDesktopClientReleaseEnable if !enabled { action = ActionDesktopClientReleaseDisable } s.audit(ctx, actor, action, item) view := s.toView(item) return &view, nil } func (s *DesktopClientReleaseService) Delete(ctx context.Context, actor *Actor, id int64) error { item, err := s.releases.GetByID(ctx, id) if err != nil { if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { return response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在") } 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", "客户端版本配置不存在") } return err } s.audit(ctx, actor, ActionDesktopClientReleaseDelete, item) 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") } if err := s.storage.Validate(); err != nil { return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") } target, fileName, err := normalizeDesktopClientReleaseUploadInput(in) if err != nil { return nil, err } reader, fileSizeBytes, err := desktopClientReleaseUploadReader(in) if err != nil { return nil, response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败") } kind := normalizeDesktopClientAssetKind(in.Kind) if kind == DesktopClientAssetKindUpdater { if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil { return nil, err } } sha256Hex, err := hashDesktopClientReleaseUpload(reader) if err != nil { return nil, response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败") } objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName) contentType := detectDesktopReleaseContentType(fileName, nil) exists, err := s.storage.Exists(ctx, objectKey) if err != nil { appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) appErr.Cause = err return nil, appErr } if !exists { if err := putDesktopClientReleaseUpload(ctx, s.storage, objectKey, reader, fileSizeBytes, contentType); err != nil { appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) appErr.Cause = err return nil, appErr } } return &DesktopClientReleaseUploadResult{ OSSObjectKey: objectKey, FileName: fileName, FileSizeBytes: fileSizeBytes, SHA256: sha256Hex, }, nil } func (s *DesktopClientReleaseService) InitiateDirectUpload( ctx context.Context, in DesktopClientReleaseDirectUploadInitInput, ) (*DesktopClientReleaseDirectUploadInitResult, error) { if s.storage == nil { return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") } if err := s.storage.Validate(); err != nil { return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") } uploadInput := DesktopClientReleaseUploadInput{ Platform: in.Platform, Arch: in.Arch, Channel: in.Channel, Version: in.Version, Kind: in.Kind, FileName: in.FileName, ContentSizeBytes: in.FileSizeBytes, } target, fileName, err := normalizeDesktopClientReleaseUploadInput(uploadInput) if err != nil { return nil, err } sha256Hex, err := normalizeRequiredDesktopReleaseSHA256(in.SHA256) if err != nil { return nil, err } kind := normalizeDesktopClientAssetKind(in.Kind) if kind == DesktopClientAssetKindUpdater { if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil { return nil, err } } if _, err := normalizeDesktopReleaseContentMD5(in.ContentMD5); err != nil { return nil, err } objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName) exists, err := s.storage.Exists(ctx, objectKey) if err != nil { appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) appErr.Cause = err return nil, appErr } if exists { return &DesktopClientReleaseDirectUploadInitResult{ Method: http.MethodPut, Headers: map[string]string{}, OSSObjectKey: objectKey, FileName: fileName, FileSizeBytes: in.FileSizeBytes, SHA256: sha256Hex, ObjectExists: true, ExpiresAt: time.Now().UTC(), }, nil } presigner, ok := s.storage.(objectstorage.PresignedPutClient) if !ok { return nil, response.ErrInternal(50091, "object_storage_direct_upload_unsupported", "object storage direct upload is unsupported") } presigned, err := presigner.PresignedPutURL( ctx, objectKey, detectDesktopReleaseContentType(fileName, nil), "", desktopClientReleaseDirectUploadTTL, ) if err != nil { appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) appErr.Cause = err return nil, appErr } return &DesktopClientReleaseDirectUploadInitResult{ UploadURL: presigned.URL, Method: http.MethodPut, Headers: flattenHTTPHeaders(presigned.Headers), OSSObjectKey: objectKey, FileName: fileName, FileSizeBytes: in.FileSizeBytes, SHA256: sha256Hex, ObjectExists: false, ExpiresAt: presigned.ExpiresAt, }, nil } func (s *DesktopClientReleaseService) CompleteDirectUpload( ctx context.Context, in DesktopClientReleaseDirectUploadCompleteInput, ) (*DesktopClientReleaseUploadResult, error) { return s.completeDirectUpload(ctx, in, nil) } func (s *DesktopClientReleaseService) completeDirectUpload( ctx context.Context, in DesktopClientReleaseDirectUploadCompleteInput, onProgress func(stage string, percent int, message string), ) (*DesktopClientReleaseUploadResult, error) { if s.storage == nil { return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") } if err := s.storage.Validate(); err != nil { return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") } uploadInput := DesktopClientReleaseUploadInput{ Platform: in.Platform, Arch: in.Arch, Channel: in.Channel, Version: in.Version, Kind: in.Kind, FileName: in.FileName, ContentSizeBytes: in.FileSizeBytes, } target, fileName, err := normalizeDesktopClientReleaseUploadInput(uploadInput) if err != nil { return nil, err } sha256Hex, err := normalizeRequiredDesktopReleaseSHA256(in.SHA256) if err != nil { return nil, err } kind := normalizeDesktopClientAssetKind(in.Kind) if kind == DesktopClientAssetKindUpdater { if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil { return nil, err } } objectKey := strings.TrimSpace(in.OSSObjectKey) expectedObjectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName) if objectKey == "" { objectKey = expectedObjectKey } if objectKey != expectedObjectKey || !isValidDesktopReleaseObjectKey(objectKey) { return nil, response.ErrBadRequest(40066, "invalid_oss_object_key", "OSS Object Key 与上传文件校验信息不匹配") } reportDesktopReleaseUploadProgress(onProgress, "checking_object", 20, "正在检查 OSS 对象") if management, ok := s.storage.(objectstorage.ManagementClient); ok { info, err := management.Stat(ctx, objectKey) if err != nil { if errors.Is(err, objectstorage.ErrObjectNotFound) { return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成") } appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) appErr.Cause = err return nil, appErr } if info.Size != in.FileSizeBytes { return nil, response.ErrBadRequest(40079, "direct_upload_size_mismatch", "OSS 直传文件大小不匹配,请重新上传") } } else { exists, err := s.storage.Exists(ctx, objectKey) if err != nil { appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) appErr.Cause = err return nil, appErr } if !exists { return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成") } } reportDesktopReleaseUploadProgress(onProgress, "hashing_object", 55, "正在计算 OSS 文件 SHA256") actualSHA, actualSize, err := hashDesktopClientReleaseStorageObject(ctx, s.storage, objectKey) if err != nil { if errors.Is(err, objectstorage.ErrObjectNotFound) { return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成") } appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) appErr.Cause = err return nil, appErr } if actualSize != in.FileSizeBytes { return nil, response.ErrBadRequest(40079, "direct_upload_size_mismatch", "OSS 直传文件大小不匹配,请重新上传") } if actualSHA != sha256Hex { return nil, response.ErrBadRequest(40070, "invalid_sha256", "OSS 直传文件 SHA256 不匹配,请重新上传") } reportDesktopReleaseUploadProgress(onProgress, "finalizing", 95, "正在确认上传结果") return &DesktopClientReleaseUploadResult{ OSSObjectKey: objectKey, FileName: fileName, FileSizeBytes: in.FileSizeBytes, SHA256: sha256Hex, }, nil } func hashDesktopClientReleaseStorageObject( ctx context.Context, storage objectstorage.Client, objectKey string, ) (string, int64, error) { if readerClient, ok := storage.(objectstorage.ObjectReaderClient); ok { reader, err := readerClient.GetReader(ctx, objectKey) if err != nil { return "", 0, err } defer reader.Close() hasher := sha256.New() size, err := io.Copy(hasher, reader) if err != nil { return "", 0, err } return hex.EncodeToString(hasher.Sum(nil)), size, nil } content, err := storage.GetBytes(ctx, objectKey) if err != nil { return "", 0, err } actualSHA := sha256.Sum256(content) return hex.EncodeToString(actualSHA[:]), int64(len(content)), nil } func (s *DesktopClientReleaseService) StartDirectUploadCompleteJob( in DesktopClientReleaseDirectUploadCompleteInput, ) *DesktopClientReleaseCompleteJobResult { job := newDesktopReleaseCompleteJob() s.storeDesktopReleaseCompleteJob(job) go func() { job.setProgress("checking_object", 10, "正在检查 OSS 对象") result, err := s.completeDirectUpload(context.Background(), in, job.setProgress) if err != nil { job.fail(err) return } job.succeed(result) }() return job.snapshot() } func (s *DesktopClientReleaseService) StartChunkedUploadCompleteJob(uploadID string) (*DesktopClientReleaseCompleteJobResult, error) { if _, err := normalizeDesktopClientReleaseUploadID(uploadID); err != nil { return nil, err } job := newDesktopReleaseCompleteJob() s.storeDesktopReleaseCompleteJob(job) go func() { job.setProgress("validating_chunks", 10, "正在校验上传分片") result, err := s.completeChunkedUpload(context.Background(), uploadID, job.setProgress) if err != nil { job.fail(err) return } job.succeed(result) }() return job.snapshot(), nil } func (s *DesktopClientReleaseService) GetCompleteJob(jobID string) (*DesktopClientReleaseCompleteJobResult, error) { jobID = strings.TrimSpace(jobID) if jobID == "" { return nil, response.ErrBadRequest(40081, "invalid_upload_job_id", "上传任务 ID 无效") } s.releaseUploadMu.Lock() defer s.releaseUploadMu.Unlock() job, ok := s.completeJobs[jobID] if !ok { return nil, response.ErrNotFound(40481, "upload_job_not_found", "上传任务不存在或已过期") } return job.snapshot(), nil } func (s *DesktopClientReleaseService) storeDesktopReleaseCompleteJob(job *desktopClientReleaseCompleteJob) { s.releaseUploadMu.Lock() defer s.releaseUploadMu.Unlock() if s.completeJobs == nil { s.completeJobs = map[string]*desktopClientReleaseCompleteJob{} } cutoff := time.Now().UTC().Add(-desktopClientReleaseUploadSessionTTL) for id, existing := range s.completeJobs { if existing.snapshot().UpdatedAt.Before(cutoff) { delete(s.completeJobs, id) } } s.completeJobs[job.id] = job } func newDesktopReleaseCompleteJob() *desktopClientReleaseCompleteJob { now := time.Now().UTC() return &desktopClientReleaseCompleteJob{ id: uuid.NewString(), status: "running", stage: "queued", message: "上传确认任务已创建", percent: 1, createdAt: now, updatedAt: now, } } func (j *desktopClientReleaseCompleteJob) setProgress(stage string, percent int, message string) { if j == nil { return } j.mu.Lock() defer j.mu.Unlock() if j.status == "succeeded" || j.status == "failed" { return } j.status = "running" j.stage = strings.TrimSpace(stage) j.message = strings.TrimSpace(message) if percent < 1 { percent = 1 } if percent > 99 { percent = 99 } j.percent = percent j.updatedAt = time.Now().UTC() } func (j *desktopClientReleaseCompleteJob) succeed(result *DesktopClientReleaseUploadResult) { j.mu.Lock() defer j.mu.Unlock() j.status = "succeeded" j.stage = "complete" j.message = "上传完成" j.percent = 100 j.result = result j.updatedAt = time.Now().UTC() } func (j *desktopClientReleaseCompleteJob) fail(err error) { appErr := response.Normalize(err) j.mu.Lock() defer j.mu.Unlock() j.status = "failed" j.stage = "failed" j.message = appErr.Detail if j.message == "" { j.message = appErr.Message } j.errorCode = appErr.Message j.err = j.message if j.percent < 1 { j.percent = 1 } j.updatedAt = time.Now().UTC() } func (j *desktopClientReleaseCompleteJob) snapshot() *DesktopClientReleaseCompleteJobResult { j.mu.RLock() defer j.mu.RUnlock() return &DesktopClientReleaseCompleteJobResult{ JobID: j.id, Status: j.status, Stage: j.stage, Message: j.message, Percent: j.percent, Result: j.result, ErrorCode: j.errorCode, Error: j.err, CreatedAt: j.createdAt, UpdatedAt: j.updatedAt, } } func reportDesktopReleaseUploadProgress(onProgress func(stage string, percent int, message string), stage string, percent int, message string) { if onProgress != nil { onProgress(stage, percent, message) } } func MaxDesktopClientReleaseMultipartBytes() int64 { return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes } func MaxDesktopClientReleaseChunkMultipartBytes() int64 { return MaxDesktopClientReleaseChunkSizeBytes + desktopClientReleaseMultipartOverheadBytes } func (s *DesktopClientReleaseService) InitiateChunkedUpload( ctx context.Context, in DesktopClientReleaseChunkedUploadInitInput, ) (*DesktopClientReleaseChunkedUploadInitResult, error) { if s.storage == nil { return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") } if err := s.storage.Validate(); err != nil { return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") } uploadInput := DesktopClientReleaseUploadInput{ Platform: in.Platform, Arch: in.Arch, Channel: in.Channel, Version: in.Version, Kind: in.Kind, FileName: in.FileName, ContentSizeBytes: in.FileSizeBytes, } target, fileName, err := normalizeDesktopClientReleaseUploadInput(uploadInput) if err != nil { return nil, err } chunkSize := normalizeDesktopClientReleaseChunkSize(in.ChunkSizeBytes) chunkCount := desktopClientReleaseChunkCount(in.FileSizeBytes, chunkSize) if chunkCount <= 0 { return nil, response.ErrBadRequest(40071, "empty_release_file", "上传文件不能为空") } _ = s.cleanupExpiredDesktopReleaseUploads(time.Now()) uploadID := uuid.NewString() now := time.Now().UTC() manifest := desktopClientReleaseUploadManifest{ UploadID: uploadID, Platform: target.Platform, Arch: target.Arch, Channel: target.Channel, Version: strings.TrimSpace(in.Version), Kind: normalizeDesktopClientAssetKind(in.Kind), FileName: fileName, FileSizeBytes: in.FileSizeBytes, ChunkSizeBytes: chunkSize, ChunkCount: chunkCount, CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(desktopClientReleaseUploadSessionTTL), Chunks: map[int]desktopClientReleaseUploadChunk{}, } lock := s.desktopReleaseUploadLock(uploadID) lock.Lock() defer lock.Unlock() if err := os.MkdirAll(s.desktopReleaseUploadChunkDir(uploadID), 0o700); err != nil { return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to create upload session") } if err := s.saveDesktopReleaseUploadManifest(manifest); err != nil { return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to save upload session") } return &DesktopClientReleaseChunkedUploadInitResult{ UploadID: uploadID, ChunkSizeBytes: chunkSize, ChunkCount: chunkCount, UploadedChunks: []int{}, ExpiresAt: manifest.ExpiresAt, }, nil } func (s *DesktopClientReleaseService) UploadChunk( ctx context.Context, in DesktopClientReleaseChunkUploadInput, ) (*DesktopClientReleaseChunkUploadResult, error) { uploadID, err := normalizeDesktopClientReleaseUploadID(in.UploadID) if err != nil { return nil, err } if in.ContentReader == nil { return nil, response.ErrBadRequest(40071, "release_file_required", "请选择要上传的客户端安装包") } lock := s.desktopReleaseUploadLock(uploadID) lock.Lock() defer lock.Unlock() manifest, err := s.loadDesktopReleaseUploadManifest(uploadID) if err != nil { return nil, err } if time.Now().UTC().After(manifest.ExpiresAt) { return nil, response.ErrBadRequest(40075, "release_upload_expired", "上传会话已过期,请重新选择文件上传") } if in.ChunkIndex < 0 || in.ChunkIndex >= manifest.ChunkCount { return nil, response.ErrBadRequest(40076, "invalid_release_upload_chunk", "上传分片序号无效") } expectedSize := manifest.expectedChunkSize(in.ChunkIndex) if in.ContentSizeBytes > 0 && in.ContentSizeBytes != expectedSize { return nil, response.ErrBadRequest(40076, "invalid_release_upload_chunk", "上传分片大小无效") } chunkDir := s.desktopReleaseUploadChunkDir(uploadID) if err := os.MkdirAll(chunkDir, 0o700); err != nil { return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to create upload chunk directory") } chunkPath := desktopReleaseUploadChunkPath(chunkDir, in.ChunkIndex) tmpPath := chunkPath + ".tmp" chunkFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to write upload chunk") } hasher := sha256.New() written, copyErr := io.Copy(io.MultiWriter(chunkFile, hasher), io.LimitReader(in.ContentReader, expectedSize+1)) closeErr := chunkFile.Close() if copyErr != nil { _ = os.Remove(tmpPath) return nil, response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败") } if closeErr != nil { _ = os.Remove(tmpPath) return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to write upload chunk") } if written != expectedSize { _ = os.Remove(tmpPath) return nil, response.ErrBadRequest(40076, "invalid_release_upload_chunk", "上传分片大小无效") } if err := os.Rename(tmpPath, chunkPath); err != nil { _ = os.Remove(tmpPath) return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to save upload chunk") } now := time.Now().UTC() manifest.UpdatedAt = now manifest.Chunks[in.ChunkIndex] = desktopClientReleaseUploadChunk{ Index: in.ChunkIndex, SizeBytes: written, SHA256: hex.EncodeToString(hasher.Sum(nil)), UploadedAt: now, } if err := s.saveDesktopReleaseUploadManifest(manifest); err != nil { return nil, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to update upload session") } return &DesktopClientReleaseChunkUploadResult{ UploadID: uploadID, ChunkIndex: in.ChunkIndex, ReceivedBytes: written, UploadedChunks: manifest.uploadedChunkIndexes(), }, nil } func (s *DesktopClientReleaseService) CompleteChunkedUpload( ctx context.Context, uploadIDValue string, ) (*DesktopClientReleaseUploadResult, error) { return s.completeChunkedUpload(ctx, uploadIDValue, nil) } func (s *DesktopClientReleaseService) completeChunkedUpload( ctx context.Context, uploadIDValue string, onProgress func(stage string, percent int, message string), ) (*DesktopClientReleaseUploadResult, error) { uploadID, err := normalizeDesktopClientReleaseUploadID(uploadIDValue) if err != nil { return nil, err } lock := s.desktopReleaseUploadLock(uploadID) lock.Lock() defer lock.Unlock() manifest, err := s.loadDesktopReleaseUploadManifest(uploadID) if err != nil { return nil, err } if time.Now().UTC().After(manifest.ExpiresAt) { return nil, response.ErrBadRequest(40075, "release_upload_expired", "上传会话已过期,请重新选择文件上传") } reportDesktopReleaseUploadProgress(onProgress, "validating_chunks", 20, "正在校验上传分片") if err := manifest.validateComplete(); err != nil { return nil, err } reportDesktopReleaseUploadProgress(onProgress, "hashing_chunks", 45, "正在计算完整文件 SHA256") sha256Hex, err := s.hashDesktopReleaseUploadChunks(manifest) if err != nil { return nil, err } objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, manifest.FileName) contentType := detectDesktopReleaseContentType(manifest.FileName, nil) reportDesktopReleaseUploadProgress(onProgress, "checking_object", 65, "正在检查 OSS 对象") exists, err := s.storage.Exists(ctx, objectKey) if err != nil { appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) appErr.Cause = err return nil, appErr } if !exists { reportDesktopReleaseUploadProgress(onProgress, "uploading_object", 75, "正在合并并上传到 OSS") reader, err := s.openDesktopReleaseUploadChunks(manifest) if err != nil { return nil, err } defer reader.Close() if err := putDesktopClientReleaseReader(ctx, s.storage, objectKey, reader, manifest.FileSizeBytes, contentType); err != nil { appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) appErr.Cause = err return nil, appErr } } reportDesktopReleaseUploadProgress(onProgress, "finalizing", 95, "正在确认上传结果") result := &DesktopClientReleaseUploadResult{ OSSObjectKey: objectKey, FileName: manifest.FileName, FileSizeBytes: manifest.FileSizeBytes, SHA256: sha256Hex, } _ = os.RemoveAll(s.desktopReleaseUploadSessionDir(uploadID)) s.forgetDesktopReleaseUploadLock(uploadID) return result, nil } func (s *DesktopClientReleaseService) AbortChunkedUpload(uploadIDValue string) error { uploadID, err := normalizeDesktopClientReleaseUploadID(uploadIDValue) if err != nil { return err } lock := s.desktopReleaseUploadLock(uploadID) lock.Lock() defer lock.Unlock() _ = os.RemoveAll(s.desktopReleaseUploadSessionDir(uploadID)) s.forgetDesktopReleaseUploadLock(uploadID) return nil } func (s *DesktopClientReleaseService) CheckLatest(ctx context.Context, in DesktopClientReleaseCheckInput) (*DesktopClientReleaseCheckResult, error) { target, currentVersion, err := normalizeDesktopClientReleaseCheckInput(in) if err != nil { return nil, err } 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", "未配置该平台客户端版本") } return nil, err } return &DesktopClientReleaseCheckResult{ 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, OSSObjectKey: item.OSSObjectKey, FileName: item.FileName, FileSizeBytes: item.FileSizeBytes, SHA256: item.SHA256, UpdaterAvailable: itemUpdaterAsset(item).HasTarget(), UpdaterDownloadSource: itemUpdaterAsset(item).DownloadSource, UpdaterOSSObjectKey: itemUpdaterAsset(item).OSSObjectKey, UpdaterFileName: itemUpdaterAsset(item).FileName, UpdaterFileSizeBytes: itemUpdaterAsset(item).FileSizeBytes, UpdaterSHA256: itemUpdaterAsset(item).SHA256, ReleaseNotes: item.ReleaseNotes, PublishedAt: item.PublishedAt, }, nil } func (s *DesktopClientReleaseService) toView(item *domain.DesktopClientRelease) DesktopClientReleaseView { return DesktopClientReleaseView{ ID: item.ID, Platform: item.Platform, Arch: item.Arch, Channel: item.Channel, Version: item.Version, MinSupportedVersion: item.MinSupportedVersion, DownloadSource: item.DownloadSource, OSSObjectKey: item.OSSObjectKey, CustomDownloadURL: item.CustomDownloadURL, DownloadURL: nil, FileName: item.FileName, FileSizeBytes: item.FileSizeBytes, SHA256: item.SHA256, UpdaterDownloadSource: item.UpdaterDownloadSource, UpdaterOSSObjectKey: item.UpdaterOSSObjectKey, UpdaterCustomDownloadURL: item.UpdaterCustomDownloadURL, UpdaterDownloadURL: nil, UpdaterFileName: item.UpdaterFileName, UpdaterFileSizeBytes: item.UpdaterFileSizeBytes, UpdaterSHA256: item.UpdaterSHA256, ReleaseNotes: item.ReleaseNotes, ForceUpdate: item.ForceUpdate, Enabled: item.Enabled, PublishedAt: item.PublishedAt, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } func (s *DesktopClientReleaseService) ResolveDownloadURLByID(ctx context.Context, id int64) (*DesktopClientReleaseDownloadURLResult, error) { item, err := s.releases.GetByID(ctx, id) if err != nil { if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { return nil, response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在") } return nil, err } return s.resolveDownloadURLResult(item) } func (s *DesktopClientReleaseService) ResolveLatestDownloadURL(ctx context.Context, in DesktopClientReleaseCheckInput) (*DesktopClientReleaseDownloadURLResult, error) { target, _, err := normalizeDesktopClientReleaseCheckInput(in) if err != nil { return nil, err } 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", "未配置该平台客户端版本") } return nil, err } return s.resolveDownloadURLResult(item) } func (s *DesktopClientReleaseService) ResolveLatestUpdaterFeed(ctx context.Context, in DesktopClientReleaseCheckInput, feedFile string) (*DesktopClientUpdaterFeedResult, error) { target, _, err := normalizeDesktopClientReleaseCheckInput(in) if err != nil { return nil, err } 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", "未配置该平台客户端版本") } return nil, err } updaterAsset := itemUpdaterAsset(item) if !updaterAsset.HasTarget() { return nil, response.ErrInternal(50096, "desktop_client_update_package_not_configured", "未配置当前平台自动更新包") } downloadURL, err := s.resolveAssetDownloadURL(updaterAsset) if err != nil { return nil, err } if err := validateDesktopClientUpdaterPackage(item.Platform, updaterAsset.FileName, downloadURL); err != nil { return nil, err } content, err := buildDesktopClientUpdaterFeedYAML(item, updaterAsset, downloadURL) if err != nil { return nil, err } return &DesktopClientUpdaterFeedResult{ Content: content, FileName: sanitizeDesktopUpdaterFeedFile(feedFile), }, 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{} } source := item.DownloadSource return desktopClientReleaseAsset{ DownloadSource: &source, OSSObjectKey: item.OSSObjectKey, CustomDownloadURL: item.CustomDownloadURL, FileName: item.FileName, FileSizeBytes: item.FileSizeBytes, SHA256: item.SHA256, } } func itemUpdaterAsset(item *domain.DesktopClientRelease) desktopClientReleaseAsset { if item == nil { return desktopClientReleaseAsset{} } if item.UpdaterDownloadSource != nil { return desktopClientReleaseAsset{ DownloadSource: item.UpdaterDownloadSource, OSSObjectKey: item.UpdaterOSSObjectKey, CustomDownloadURL: item.UpdaterCustomDownloadURL, FileName: item.UpdaterFileName, FileSizeBytes: item.UpdaterFileSizeBytes, SHA256: item.UpdaterSHA256, } } 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 { 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 } 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.PublicURL(objectKey) if err != nil { return "", response.ErrInternal(50091, "object_storage_url_failed", "failed to build object storage download url") } return downloadURL, nil } func buildDesktopClientUpdaterFeedYAML(item *domain.DesktopClientRelease, asset desktopClientReleaseAsset, downloadURL string) ([]byte, error) { if item == nil { return nil, response.ErrInternal(50093, "desktop_client_release_missing", "客户端版本配置不存在") } sha2 := strings.ToLower(strings.TrimSpace(stringPointerValue(asset.SHA256))) if sha2 == "" { return nil, response.ErrInternal(50094, "desktop_client_release_missing_sha256", "自动更新需要配置安装包 SHA256") } if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(sha2) { return nil, response.ErrInternal(50095, "desktop_client_release_invalid_sha256", "自动更新安装包 SHA256 格式无效") } file := desktopClientUpdaterFile{ URL: downloadURL, SHA2: sha2, Size: asset.FileSizeBytes, } releaseDate := item.UpdatedAt.UTC().Format(time.RFC3339) if item.PublishedAt != nil { releaseDate = item.PublishedAt.UTC().Format(time.RFC3339) } feed := desktopClientUpdaterFeed{ Version: item.Version, Files: []desktopClientUpdaterFile{file}, Path: downloadURL, SHA2: sha2, ReleaseDate: releaseDate, ReleaseName: item.Version, } if notes := strings.TrimSpace(stringPointerValue(item.ReleaseNotes)); notes != "" { feed.ReleaseNotes = notes } content, err := yaml.Marshal(feed) if err != nil { return nil, fmt.Errorf("marshal desktop client updater feed: %w", err) } return content, nil } func validateDesktopClientUpdaterPackage(platform string, fileName *string, downloadURL string) error { name := strings.ToLower(strings.TrimSpace(stringPointerValue(fileName))) if name == "" { name = strings.ToLower(resolveFileNameFromURL(downloadURL)) } switch platform { case "darwin": if strings.HasSuffix(name, ".zip") { return nil } return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "macOS 自动更新必须上传 electron-builder 生成的 zip 包") case "win32": if strings.HasSuffix(name, ".exe") { return nil } return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "Windows 自动更新必须上传 electron-builder 生成的 NSIS exe 包") case "linux": if strings.HasSuffix(name, ".appimage") { return nil } return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "Linux 自动更新必须上传 electron-builder 生成的 AppImage 包") default: return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "当前平台暂不支持自动更新") } } func resolveFileNameFromURL(rawURL string) string { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { return "" } name := path.Base(parsed.EscapedPath()) if decoded, err := url.PathUnescape(name); err == nil { return decoded } return name } func sanitizeDesktopUpdaterFeedFile(value string) string { value = strings.TrimPrefix(strings.TrimSpace(value), "/") if value == "" { return "latest.yml" } value = path.Base(value) if !strings.HasSuffix(strings.ToLower(value), ".yml") { return "latest.yml" } return value } func (s *DesktopClientReleaseService) audit(ctx context.Context, actor *Actor, action string, item *domain.DesktopClientRelease) { if actor == nil || s.audits == nil || item == nil { return } _ = s.audits.Append(ctx, actor.audit(action, "desktop_client_release", item.ID, desktopClientReleaseAuditMetadata(item))) } func (in DesktopClientReleaseInput) toRepositoryInput(actorID *int64) repository.UpsertDesktopClientReleaseInput { return repository.UpsertDesktopClientReleaseInput{ Platform: in.Platform, Arch: in.Arch, Channel: in.Channel, Version: in.Version, MinSupportedVersion: in.MinSupportedVersion, DownloadSource: in.DownloadSource, OSSObjectKey: in.OSSObjectKey, CustomDownloadURL: in.CustomDownloadURL, FileName: in.FileName, FileSizeBytes: in.FileSizeBytes, SHA256: in.SHA256, UpdaterDownloadSource: in.UpdaterDownloadSource, UpdaterOSSObjectKey: in.UpdaterOSSObjectKey, UpdaterCustomDownloadURL: in.UpdaterCustomDownloadURL, UpdaterFileName: in.UpdaterFileName, UpdaterFileSizeBytes: in.UpdaterFileSizeBytes, UpdaterSHA256: in.UpdaterSHA256, ReleaseNotes: in.ReleaseNotes, ForceUpdate: in.ForceUpdate, Enabled: in.Enabled, PublishedAtNow: in.Enabled, ActorID: actorID, } } func normalizeDesktopClientReleaseInput(in DesktopClientReleaseInput) (DesktopClientReleaseInput, error) { platform := normalizeDesktopReleaseEnum(in.Platform) if !isAllowedDesktopReleasePlatform(platform) { return DesktopClientReleaseInput{}, response.ErrBadRequest(40060, "invalid_desktop_release_platform", "客户端平台必须是 darwin、win32 或 linux") } arch := normalizeDesktopReleaseEnum(in.Arch) if arch == "" { arch = "universal" } if !isAllowedDesktopReleaseArch(arch) { return DesktopClientReleaseInput{}, response.ErrBadRequest(40061, "invalid_desktop_release_arch", "客户端架构必须是 universal、x64、arm64 或 ia32") } channel := normalizeDesktopReleaseEnum(in.Channel) if channel == "" { channel = "stable" } if !isValidDesktopReleaseToken(channel) { return DesktopClientReleaseInput{}, response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效") } version := strings.TrimSpace(in.Version) if !isValidDesktopReleaseVersion(version) { return DesktopClientReleaseInput{}, response.ErrBadRequest(40063, "invalid_desktop_release_version", "版本号格式无效") } minSupportedVersion := trimOptionalReleaseString(in.MinSupportedVersion) if minSupportedVersion != nil && !isValidDesktopReleaseVersion(*minSupportedVersion) { return DesktopClientReleaseInput{}, response.ErrBadRequest(40064, "invalid_min_supported_version", "最低支持版本格式无效") } source := normalizeDesktopReleaseEnum(in.DownloadSource) if source == "" { source = DesktopClientDownloadSourceOSS } if source != DesktopClientDownloadSourceOSS && source != DesktopClientDownloadSourceCustom { return DesktopClientReleaseInput{}, response.ErrBadRequest(40065, "invalid_download_source", "下载来源必须是 oss 或 custom") } installerAsset, err := normalizeDesktopClientReleaseAsset( source, in.OSSObjectKey, in.CustomDownloadURL, in.FileName, in.FileSizeBytes, in.SHA256, true, false, platform, ) if err != nil { return DesktopClientReleaseInput{}, err } updaterSource := normalizeOptionalDesktopDownloadSource(in.UpdaterDownloadSource) if updaterSource == nil && hasDesktopClientUpdaterAssetInput(in) { defaultSource := DesktopClientDownloadSourceOSS updaterSource = &defaultSource } var updaterAsset desktopClientReleaseAsset if updaterSource != nil { updaterAsset, err = normalizeDesktopClientReleaseAsset( *updaterSource, in.UpdaterOSSObjectKey, in.UpdaterCustomDownloadURL, in.UpdaterFileName, in.UpdaterFileSizeBytes, in.UpdaterSHA256, true, true, platform, ) if err != nil { return DesktopClientReleaseInput{}, err } } releaseNotes := trimOptionalReleaseString(in.ReleaseNotes) return DesktopClientReleaseInput{ Platform: platform, Arch: arch, Channel: channel, Version: version, MinSupportedVersion: minSupportedVersion, DownloadSource: source, OSSObjectKey: installerAsset.OSSObjectKey, CustomDownloadURL: installerAsset.CustomDownloadURL, FileName: installerAsset.FileName, FileSizeBytes: installerAsset.FileSizeBytes, SHA256: installerAsset.SHA256, UpdaterDownloadSource: updaterSource, UpdaterOSSObjectKey: updaterAsset.OSSObjectKey, UpdaterCustomDownloadURL: updaterAsset.CustomDownloadURL, UpdaterFileName: updaterAsset.FileName, UpdaterFileSizeBytes: updaterAsset.FileSizeBytes, UpdaterSHA256: updaterAsset.SHA256, ReleaseNotes: releaseNotes, ForceUpdate: in.ForceUpdate, Enabled: in.Enabled, }, nil } func normalizeDesktopClientReleaseCheckInput(in DesktopClientReleaseCheckInput) (repository.DesktopClientReleaseTarget, string, error) { platform := normalizeDesktopReleaseEnum(in.Platform) if !isAllowedDesktopReleasePlatform(platform) { return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40060, "invalid_desktop_release_platform", "客户端平台必须是 darwin、win32 或 linux") } arch := normalizeDesktopReleaseEnum(in.Arch) if arch == "" { arch = "universal" } if !isAllowedDesktopReleaseArch(arch) { return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40061, "invalid_desktop_release_arch", "客户端架构必须是 universal、x64、arm64 或 ia32") } channel := normalizeDesktopReleaseEnum(in.Channel) if channel == "" { channel = "stable" } if !isValidDesktopReleaseToken(channel) { return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效") } currentVersion := strings.TrimSpace(in.CurrentVersion) if currentVersion != "" && !isValidDesktopReleaseVersion(currentVersion) { return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40063, "invalid_desktop_release_version", "版本号格式无效") } return repository.DesktopClientReleaseTarget{Platform: platform, Arch: arch, Channel: channel}, currentVersion, nil } func normalizeOptionalDesktopDownloadSource(value *string) *string { if value == nil { return nil } source := normalizeDesktopReleaseEnum(*value) if source == "" { return nil } return &source } func hasDesktopClientUpdaterAssetInput(in DesktopClientReleaseInput) bool { return trimOptionalReleaseString(in.UpdaterOSSObjectKey) != nil || trimOptionalReleaseString(in.UpdaterCustomDownloadURL) != nil || trimOptionalReleaseString(in.UpdaterFileName) != nil || in.UpdaterFileSizeBytes != nil || trimOptionalReleaseString(in.UpdaterSHA256) != nil } func normalizeDesktopClientReleaseAsset( source string, ossObjectKeyInput *string, customDownloadURLInput *string, fileNameInput *string, fileSizeBytes *int64, sha256Input *string, required bool, updater bool, platform string, ) (desktopClientReleaseAsset, error) { source = normalizeDesktopReleaseEnum(source) if source == "" { source = DesktopClientDownloadSourceOSS } if source != DesktopClientDownloadSourceOSS && source != DesktopClientDownloadSourceCustom { return desktopClientReleaseAsset{}, response.ErrBadRequest(40065, "invalid_download_source", "下载来源必须是 oss 或 custom") } ossObjectKey := trimOptionalReleaseString(ossObjectKeyInput) customDownloadURL := trimOptionalReleaseString(customDownloadURLInput) if source == DesktopClientDownloadSourceOSS { if required && (ossObjectKey == nil || !isValidDesktopReleaseObjectKey(*ossObjectKey)) { return desktopClientReleaseAsset{}, response.ErrBadRequest(40066, "invalid_oss_object_key", "OSS Object Key 不能为空,且不能以 / 开头或包含 ..") } if ossObjectKey != nil && !isValidDesktopReleaseObjectKey(*ossObjectKey) { return desktopClientReleaseAsset{}, response.ErrBadRequest(40066, "invalid_oss_object_key", "OSS Object Key 不能为空,且不能以 / 开头或包含 ..") } customDownloadURL = nil } else { if required && (customDownloadURL == nil || !isSafeHTTPURL(*customDownloadURL)) { return desktopClientReleaseAsset{}, response.ErrBadRequest(40067, "invalid_custom_download_url", "自定义下载地址必须是 http(s) URL") } if customDownloadURL != nil && !isSafeHTTPURL(*customDownloadURL) { return desktopClientReleaseAsset{}, response.ErrBadRequest(40067, "invalid_custom_download_url", "自定义下载地址必须是 http(s) URL") } ossObjectKey = nil } fileName := trimOptionalReleaseString(fileNameInput) if fileName != nil && (len(*fileName) > 255 || strings.ContainsAny(*fileName, `/\`)) { return desktopClientReleaseAsset{}, response.ErrBadRequest(40068, "invalid_file_name", "文件名不能包含路径且不能超过 255 字符") } if fileSizeBytes != nil && *fileSizeBytes < 0 { return desktopClientReleaseAsset{}, response.ErrBadRequest(40069, "invalid_file_size", "文件大小不能为负数") } sha256, err := normalizeDesktopReleaseSHA256(sha256Input) if err != nil { return desktopClientReleaseAsset{}, err } if updater { if sha256 == nil { return desktopClientReleaseAsset{}, response.ErrBadRequest(40070, "invalid_sha256", "自动更新包必须配置 SHA256") } fileNameSource := stringPointerValue(customDownloadURL) if fileNameSource == "" { fileNameSource = stringPointerValue(ossObjectKey) } if err := validateDesktopClientUpdaterPackage(platform, fileName, fileNameSource); err != nil { return desktopClientReleaseAsset{}, err } } sourcePtr := source return desktopClientReleaseAsset{ DownloadSource: &sourcePtr, OSSObjectKey: ossObjectKey, CustomDownloadURL: customDownloadURL, FileName: fileName, FileSizeBytes: fileSizeBytes, SHA256: sha256, }, nil } func normalizeDesktopReleaseSHA256(value *string) (*string, error) { sha256 := trimOptionalReleaseString(value) if sha256 == nil { return nil, nil } normalized := strings.ToLower(*sha256) if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(normalized) { return nil, response.ErrBadRequest(40070, "invalid_sha256", "SHA256 必须是 64 位十六进制") } return &normalized, nil } func normalizeRequiredDesktopReleaseSHA256(value string) (string, error) { sha256, err := normalizeDesktopReleaseSHA256(&value) if err != nil { return "", err } if sha256 == nil { return "", response.ErrBadRequest(40070, "invalid_sha256", "SHA256 必须是 64 位十六进制") } return *sha256, nil } func normalizeDesktopReleaseContentMD5(value string) (string, error) { normalized := strings.TrimSpace(value) if normalized == "" { return "", nil } content, err := base64.StdEncoding.DecodeString(normalized) if err != nil || len(content) != desktopClientReleaseContentMD5Bytes { return "", response.ErrBadRequest(40080, "invalid_content_md5", "Content-MD5 必须是标准 base64 编码的 16 字节 MD5") } return normalized, nil } func normalizeDesktopClientReleaseUploadInput(in DesktopClientReleaseUploadInput) (repository.DesktopClientReleaseTarget, string, error) { size, err := desktopClientReleaseUploadSize(in) if err != nil { return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败") } if size == 0 { return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40071, "empty_release_file", "上传文件不能为空") } if size > MaxDesktopClientReleaseUploadBytes { return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40072, "release_file_too_large", "客户端安装包不能超过 1GB") } target, _, err := normalizeDesktopClientReleaseCheckInput(DesktopClientReleaseCheckInput{ Platform: in.Platform, Arch: in.Arch, Channel: in.Channel, CurrentVersion: in.Version, }) if err != nil { return repository.DesktopClientReleaseTarget{}, "", err } fileName := sanitizeDesktopReleaseFileName(in.FileName) if fileName == "" { return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40068, "invalid_file_name", "文件名不能为空") } if !isAllowedDesktopReleasePackageFile(fileName) { return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40073, "invalid_release_file_type", "安装包格式必须是 dmg、pkg、exe、msi、zip、AppImage、deb、rpm 或 tar.gz") } if normalizeDesktopClientAssetKind(in.Kind) == DesktopClientAssetKindUpdater { if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil { return repository.DesktopClientReleaseTarget{}, "", err } } return target, fileName, nil } func desktopClientReleaseUploadSize(in DesktopClientReleaseUploadInput) (int64, error) { if in.ContentReader == nil { if in.ContentSizeBytes > 0 { return in.ContentSizeBytes, nil } return int64(len(in.Content)), nil } if in.ContentSizeBytes > 0 { return in.ContentSizeBytes, nil } current, err := in.ContentReader.Seek(0, io.SeekCurrent) if err != nil { return 0, err } end, err := in.ContentReader.Seek(0, io.SeekEnd) if restoreErr := seekDesktopClientReleaseUpload(in.ContentReader, current); err == nil { err = restoreErr } if err != nil { return 0, err } return end, nil } func desktopClientReleaseUploadReader(in DesktopClientReleaseUploadInput) (io.ReadSeeker, int64, error) { size, err := desktopClientReleaseUploadSize(in) if err != nil { return nil, 0, err } if in.ContentReader != nil { if err := seekDesktopClientReleaseUpload(in.ContentReader, 0); err != nil { return nil, 0, err } return in.ContentReader, size, nil } return bytes.NewReader(in.Content), size, nil } func hashDesktopClientReleaseUpload(reader io.ReadSeeker) (string, error) { if err := seekDesktopClientReleaseUpload(reader, 0); err != nil { return "", err } hasher := sha256.New() if _, err := io.Copy(hasher, reader); err != nil { return "", err } return hex.EncodeToString(hasher.Sum(nil)), nil } func putDesktopClientReleaseUpload( ctx context.Context, storage objectstorage.Client, objectKey string, reader io.ReadSeeker, size int64, contentType string, ) error { if err := seekDesktopClientReleaseUpload(reader, 0); err != nil { return err } return putDesktopClientReleaseReader(ctx, storage, objectKey, reader, size, contentType) } func putDesktopClientReleaseReader( ctx context.Context, storage objectstorage.Client, objectKey string, reader io.Reader, size int64, contentType string, ) error { if readerClient, ok := storage.(objectstorage.ReaderClient); ok { return readerClient.PutReader(ctx, objectKey, reader, size, contentType) } content, err := io.ReadAll(io.LimitReader(reader, MaxDesktopClientReleaseUploadBytes+1)) if err != nil { return err } if int64(len(content)) > MaxDesktopClientReleaseUploadBytes { return response.ErrBadRequest(40072, "release_file_too_large", "客户端安装包不能超过 1GB") } return storage.PutBytes(ctx, objectKey, content, contentType) } func flattenHTTPHeaders(headers http.Header) map[string]string { if len(headers) == 0 { return map[string]string{} } out := make(map[string]string, len(headers)) for key, values := range headers { key = strings.TrimSpace(key) if key == "" || len(values) == 0 { continue } value := strings.TrimSpace(values[0]) if value == "" { continue } out[key] = value } return out } func defaultDesktopClientReleaseUploadDir() string { return filepath.Join(os.TempDir(), "geo-rankly-desktop-release-uploads") } func normalizeDesktopClientReleaseChunkSize(value int64) int64 { switch { case value <= 0: return DefaultDesktopClientReleaseChunkSizeBytes case value < MinDesktopClientReleaseChunkSizeBytes: return MinDesktopClientReleaseChunkSizeBytes case value > MaxDesktopClientReleaseChunkSizeBytes: return MaxDesktopClientReleaseChunkSizeBytes default: return value } } func desktopClientReleaseChunkCount(fileSizeBytes int64, chunkSizeBytes int64) int { if fileSizeBytes <= 0 || chunkSizeBytes <= 0 { return 0 } return int((fileSizeBytes + chunkSizeBytes - 1) / chunkSizeBytes) } func normalizeDesktopClientReleaseUploadID(value string) (string, error) { uploadID := strings.TrimSpace(value) if _, err := uuid.Parse(uploadID); err != nil { return "", response.ErrBadRequest(40074, "invalid_release_upload_id", "上传会话不存在或已失效") } return uploadID, nil } func (s *DesktopClientReleaseService) desktopReleaseUploadBaseDir() string { dir := strings.TrimSpace(s.releaseUploadDir) if dir == "" { return defaultDesktopClientReleaseUploadDir() } return dir } func (s *DesktopClientReleaseService) desktopReleaseUploadSessionDir(uploadID string) string { return filepath.Join(s.desktopReleaseUploadBaseDir(), uploadID) } func (s *DesktopClientReleaseService) desktopReleaseUploadChunkDir(uploadID string) string { return filepath.Join(s.desktopReleaseUploadSessionDir(uploadID), "chunks") } func (s *DesktopClientReleaseService) desktopReleaseUploadManifestPath(uploadID string) string { return filepath.Join(s.desktopReleaseUploadSessionDir(uploadID), "manifest.json") } func desktopReleaseUploadChunkPath(chunkDir string, index int) string { return filepath.Join(chunkDir, fmt.Sprintf("%06d.part", index)) } func (s *DesktopClientReleaseService) desktopReleaseUploadLock(uploadID string) *sync.Mutex { s.releaseUploadMu.Lock() defer s.releaseUploadMu.Unlock() if s.releaseUploadLocks == nil { s.releaseUploadLocks = make(map[string]*sync.Mutex) } if lock := s.releaseUploadLocks[uploadID]; lock != nil { return lock } lock := &sync.Mutex{} s.releaseUploadLocks[uploadID] = lock return lock } func (s *DesktopClientReleaseService) forgetDesktopReleaseUploadLock(uploadID string) { s.releaseUploadMu.Lock() defer s.releaseUploadMu.Unlock() delete(s.releaseUploadLocks, uploadID) } func (s *DesktopClientReleaseService) saveDesktopReleaseUploadManifest(manifest desktopClientReleaseUploadManifest) error { if manifest.Chunks == nil { manifest.Chunks = map[int]desktopClientReleaseUploadChunk{} } sessionDir := s.desktopReleaseUploadSessionDir(manifest.UploadID) if err := os.MkdirAll(sessionDir, 0o700); err != nil { return err } data, err := json.MarshalIndent(manifest, "", " ") if err != nil { return err } manifestPath := s.desktopReleaseUploadManifestPath(manifest.UploadID) tmpPath := manifestPath + ".tmp" if err := os.WriteFile(tmpPath, data, 0o600); err != nil { return err } return os.Rename(tmpPath, manifestPath) } func (s *DesktopClientReleaseService) loadDesktopReleaseUploadManifest(uploadID string) (desktopClientReleaseUploadManifest, error) { data, err := os.ReadFile(s.desktopReleaseUploadManifestPath(uploadID)) if err != nil { if errors.Is(err, os.ErrNotExist) { return desktopClientReleaseUploadManifest{}, response.ErrBadRequest(40074, "invalid_release_upload_id", "上传会话不存在或已失效") } return desktopClientReleaseUploadManifest{}, response.ErrInternal(50092, "desktop_client_release_upload_failed", "failed to read upload session") } var manifest desktopClientReleaseUploadManifest if err := json.Unmarshal(data, &manifest); err != nil { return desktopClientReleaseUploadManifest{}, response.ErrBadRequest(40074, "invalid_release_upload_id", "上传会话不存在或已失效") } if manifest.UploadID != uploadID || manifest.Chunks == nil { return desktopClientReleaseUploadManifest{}, response.ErrBadRequest(40074, "invalid_release_upload_id", "上传会话不存在或已失效") } return manifest, nil } func (s *DesktopClientReleaseService) cleanupExpiredDesktopReleaseUploads(now time.Time) error { baseDir := s.desktopReleaseUploadBaseDir() entries, err := os.ReadDir(baseDir) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return err } for _, entry := range entries { if !entry.IsDir() { continue } uploadID := entry.Name() if _, err := normalizeDesktopClientReleaseUploadID(uploadID); err != nil { continue } manifest, err := s.loadDesktopReleaseUploadManifest(uploadID) if err != nil || now.UTC().After(manifest.ExpiresAt) { _ = os.RemoveAll(s.desktopReleaseUploadSessionDir(uploadID)) s.forgetDesktopReleaseUploadLock(uploadID) } } return nil } func (m desktopClientReleaseUploadManifest) expectedChunkSize(index int) int64 { if index < 0 || index >= m.ChunkCount || m.ChunkSizeBytes <= 0 { return 0 } if index == m.ChunkCount-1 { return m.FileSizeBytes - int64(index)*m.ChunkSizeBytes } return m.ChunkSizeBytes } func (m desktopClientReleaseUploadManifest) uploadedChunkIndexes() []int { indexes := make([]int, 0, len(m.Chunks)) for index := range m.Chunks { indexes = append(indexes, index) } sort.Ints(indexes) return indexes } func (m desktopClientReleaseUploadManifest) validateComplete() error { if m.ChunkCount <= 0 || len(m.Chunks) != m.ChunkCount { return response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成") } for index := 0; index < m.ChunkCount; index++ { chunk, ok := m.Chunks[index] if !ok || chunk.SizeBytes != m.expectedChunkSize(index) { return response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成") } } return nil } func (s *DesktopClientReleaseService) hashDesktopReleaseUploadChunks( manifest desktopClientReleaseUploadManifest, ) (string, error) { hasher := sha256.New() chunkDir := s.desktopReleaseUploadChunkDir(manifest.UploadID) for index := 0; index < manifest.ChunkCount; index++ { chunk := manifest.Chunks[index] if err := copyDesktopReleaseChunkToWriter(hasher, desktopReleaseUploadChunkPath(chunkDir, index), manifest.expectedChunkSize(index), chunk.SHA256); err != nil { return "", err } } return hex.EncodeToString(hasher.Sum(nil)), nil } func copyDesktopReleaseChunkToWriter(dst io.Writer, chunkPath string, expectedSize int64, expectedSHA256 string) error { file, err := os.Open(chunkPath) if err != nil { return response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成") } defer file.Close() hasher := sha256.New() written, err := io.Copy(io.MultiWriter(dst, hasher), file) if err != nil { return response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败") } if written != expectedSize { return response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成") } if sha256Hex := hex.EncodeToString(hasher.Sum(nil)); expectedSHA256 != "" && sha256Hex != expectedSHA256 { return response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片校验失败,请重新上传") } return nil } func (s *DesktopClientReleaseService) openDesktopReleaseUploadChunks( manifest desktopClientReleaseUploadManifest, ) (*desktopClientReleaseChunkReadCloser, error) { chunkDir := s.desktopReleaseUploadChunkDir(manifest.UploadID) closers := make([]io.Closer, 0, manifest.ChunkCount) readers := make([]io.Reader, 0, manifest.ChunkCount) for index := 0; index < manifest.ChunkCount; index++ { chunkPath := desktopReleaseUploadChunkPath(chunkDir, index) info, err := os.Stat(chunkPath) if err != nil || info.Size() != manifest.expectedChunkSize(index) { for _, closer := range closers { _ = closer.Close() } return nil, response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成") } file, err := os.Open(chunkPath) if err != nil { for _, closer := range closers { _ = closer.Close() } return nil, response.ErrBadRequest(40077, "release_upload_incomplete", "上传分片尚未全部完成") } closers = append(closers, file) readers = append(readers, file) } return &desktopClientReleaseChunkReadCloser{ reader: io.MultiReader(readers...), closers: closers, }, nil } type desktopClientReleaseChunkReadCloser struct { reader io.Reader closers []io.Closer } func (r *desktopClientReleaseChunkReadCloser) Read(p []byte) (int, error) { return r.reader.Read(p) } func (r *desktopClientReleaseChunkReadCloser) Close() error { var closeErr error for _, closer := range r.closers { if err := closer.Close(); err != nil && closeErr == nil { closeErr = err } } return closeErr } func seekDesktopClientReleaseUpload(reader io.ReadSeeker, offset int64) error { if reader == nil { return fmt.Errorf("release upload reader is required") } _, err := reader.Seek(offset, io.SeekStart) return err } func normalizeDesktopClientAssetKind(value string) string { switch normalizeDesktopReleaseEnum(value) { case DesktopClientAssetKindUpdater: return DesktopClientAssetKindUpdater default: return DesktopClientAssetKindInstaller } } func normalizeDesktopReleaseEnum(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func isAllowedDesktopReleasePlatform(value string) bool { switch value { case "darwin", "win32", "linux": return true default: return false } } func isAllowedDesktopReleaseArch(value string) bool { switch value { case "universal", "x64", "arm64", "ia32": return true default: return false } } func isValidDesktopReleaseToken(value string) bool { if value == "" || len(value) > 32 { return false } return regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`).MatchString(value) } func isValidDesktopReleaseVersion(value string) bool { if value == "" || len(value) > 64 || strings.ContainsAny(value, " \t\r\n/\\") { return false } return regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+_-]*$`).MatchString(value) } func isValidDesktopReleaseObjectKey(value string) bool { if value == "" || len(value) > 1024 || strings.HasPrefix(value, "/") || strings.Contains(value, "\\") { return false } for _, part := range strings.Split(value, "/") { if part == "" || part == "." || part == ".." { return false } } return true } func isSafeHTTPURL(value string) bool { parsed, err := url.Parse(value) return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" } func sanitizeDesktopReleaseFileName(value string) string { value = strings.TrimSpace(filepath.Base(value)) value = strings.ReplaceAll(value, "\\", "") value = strings.TrimSpace(value) if decoded, err := url.PathUnescape(value); err == nil { value = strings.TrimSpace(decoded) } if value == "." || value == "/" || value == "\\" { return "" } if len(value) > 255 { return "" } return value } func isAllowedDesktopReleasePackageFile(fileName string) bool { lower := strings.ToLower(strings.TrimSpace(fileName)) for _, suffix := range []string{ ".dmg", ".pkg", ".exe", ".msi", ".zip", ".appimage", ".deb", ".rpm", ".tar.gz", ".tgz", } { if strings.HasSuffix(lower, suffix) { return true } } return false } func buildDesktopClientReleaseObjectKey(sha256Hex string, fileName string) string { sum := strings.TrimSpace(strings.ToLower(sha256Hex)) if sum == "" { sum = "unknown" } return fmt.Sprintf("desktop-client/releases/blobs/%s%s", sum, desktopReleaseFileExtension(fileName)) } func desktopReleaseFileExtension(fileName string) string { lower := strings.ToLower(strings.TrimSpace(fileName)) switch { case strings.HasSuffix(lower, ".tar.gz"): return ".tar.gz" case strings.HasSuffix(lower, ".appimage"): return ".AppImage" default: return strings.ToLower(filepath.Ext(fileName)) } } func detectDesktopReleaseContentType(fileName string, content []byte) string { lower := strings.ToLower(fileName) switch { case strings.HasSuffix(lower, ".dmg"): return "application/x-apple-diskimage" case strings.HasSuffix(lower, ".pkg"): return "application/octet-stream" case strings.HasSuffix(lower, ".exe"): return "application/vnd.microsoft.portable-executable" case strings.HasSuffix(lower, ".msi"): return "application/x-msi" case strings.HasSuffix(lower, ".zip"): return "application/zip" case strings.HasSuffix(lower, ".deb"): return "application/vnd.debian.binary-package" case strings.HasSuffix(lower, ".rpm"): return "application/x-rpm" case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"): return "application/gzip" default: if len(content) > 0 { return http.DetectContentType(content) } return "application/octet-stream" } } func trimOptionalReleaseString(value *string) *string { if value == nil { return nil } trimmed := strings.TrimSpace(*value) if trimmed == "" { return nil } return &trimmed } func stringPointerValue(value *string) string { if value == nil { return "" } return *value } func isDuplicateDesktopClientRelease(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" } func desktopClientReleaseAuditMetadata(item *domain.DesktopClientRelease) map[string]any { return map[string]any{ "platform": item.Platform, "arch": item.Arch, "channel": item.Channel, "version": item.Version, "download_source": item.DownloadSource, "oss_object_key": stringPointerValue(item.OSSObjectKey), "custom_download_url": stringPointerValue(item.CustomDownloadURL), "force_update": item.ForceUpdate, "enabled": item.Enabled, } } func minSupportedVersionRequiresUpdate(minVersion *string, currentVersion string) bool { if minVersion == nil || strings.TrimSpace(currentVersion) == "" { return false } return compareVersionStrings(currentVersion, *minVersion) < 0 } func compareVersionStrings(left string, right string) int { leftParts := splitVersionParts(left) rightParts := splitVersionParts(right) maxLen := len(leftParts) if len(rightParts) > maxLen { maxLen = len(rightParts) } for i := 0; i < maxLen; i++ { l, r := 0, 0 if i < len(leftParts) { l = leftParts[i] } if i < len(rightParts) { r = rightParts[i] } if l > r { return 1 } if l < r { return -1 } } return strings.Compare(left, right) } func splitVersionParts(value string) []int { value = strings.TrimPrefix(strings.TrimSpace(value), "v") fields := regexp.MustCompile(`[^0-9]+`).Split(value, -1) parts := make([]int, 0, len(fields)) for _, field := range fields { if field == "" { continue } n := 0 for _, ch := range field { if ch < '0' || ch > '9' { break } n = n*10 + int(ch-'0') } parts = append(parts, n) } return parts }