package app import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "net/http" "net/url" "path" "path/filepath" "regexp" "strings" "time" "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" ) type DesktopClientReleaseService struct { releases *repository.DesktopClientReleaseRepository storage objectstorage.Client audits *AuditService } func NewDesktopClientReleaseService( releases *repository.DesktopClientReleaseRepository, storage objectstorage.Client, audits *AuditService, ) *DesktopClientReleaseService { return &DesktopClientReleaseService{releases: releases, storage: storage, audits: audits} } 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 } 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 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) { enabled := true normalizedChannel := normalizeDesktopReleaseEnum(channel) if normalizedChannel == "" { normalizedChannel = "stable" } if !isValidDesktopReleaseToken(normalizedChannel) { return nil, response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效") } items, _, err := s.releases.List(ctx, repository.DesktopClientReleaseFilter{ Channel: normalizedChannel, Enabled: &enabled, Limit: 200, }) if err != nil { return nil, err } views := make([]DesktopClientDownloadableRelease, 0, len(items)) for i := range items { var downloadURL *string if items[i].DownloadSource == DesktopClientDownloadSourceCustom { if value := strings.TrimSpace(stringPointerValue(items[i].CustomDownloadURL)); value != "" { downloadURL = &value } } views = append(views, DesktopClientDownloadableRelease{ ID: items[i].ID, Platform: items[i].Platform, Arch: items[i].Arch, Channel: items[i].Channel, Version: items[i].Version, DownloadURL: downloadURL, FileName: items[i].FileName, FileSizeBytes: items[i].FileSizeBytes, SHA256: items[i].SHA256, ReleaseNotes: items[i].ReleaseNotes, PublishedAt: items[i].PublishedAt, UpdatedAt: items[i].UpdatedAt, ForceUpdate: items[i].ForceUpdate, MinSupportedVersion: items[i].MinSupportedVersion, DownloadSource: items[i].DownloadSource, OSSObjectKey: items[i].OSSObjectKey, }) } 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.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) 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 } kind := normalizeDesktopClientAssetKind(in.Kind) if kind == DesktopClientAssetKindUpdater { if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil { return nil, err } } sum := sha256.Sum256(in.Content) sha256Hex := hex.EncodeToString(sum[:]) objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName) contentType := detectDesktopReleaseContentType(fileName, in.Content) 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 := s.storage.PutBytes(ctx, objectKey, in.Content, 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: int64(len(in.Content)), SHA256: sha256Hex, }, 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.releases.FindEnabled(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.releases.FindEnabled(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.releases.FindEnabled(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 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 (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 normalizeDesktopClientReleaseUploadInput(in DesktopClientReleaseUploadInput) (repository.DesktopClientReleaseTarget, string, error) { if len(in.Content) == 0 { return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40071, "empty_release_file", "上传文件不能为空") } if len(in.Content) > 1024*1024*1024 { 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 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 }