feat(desktop): add client release updater
Desktop Client Build / Resolve Build Metadata (push) Successful in 19s
Frontend CI / Frontend (push) Successful in 3m20s
Backend CI / Backend (push) Successful in 16m48s
Desktop Client Build / Build Desktop Client (push) Successful in 24m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 37s
Desktop Client Build / Resolve Build Metadata (push) Successful in 19s
Frontend CI / Frontend (push) Successful in 3m20s
Backend CI / Backend (push) Successful in 16m48s
Desktop Client Build / Build Desktop Client (push) Successful in 24m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 37s
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeDesktopClientReleaseInputOSS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
customURL := "https://download.example.com/client.dmg"
|
||||
ossKey := "desktop/stable/client.dmg"
|
||||
got, err := normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{
|
||||
Platform: " Darwin ",
|
||||
Arch: "",
|
||||
Channel: "",
|
||||
Version: "1.2.3",
|
||||
DownloadSource: "oss",
|
||||
OSSObjectKey: &ossKey,
|
||||
CustomDownloadURL: &customURL,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "darwin", got.Platform)
|
||||
require.Equal(t, "universal", got.Arch)
|
||||
require.Equal(t, "stable", got.Channel)
|
||||
require.Equal(t, DesktopClientDownloadSourceOSS, got.DownloadSource)
|
||||
require.NotNil(t, got.OSSObjectKey)
|
||||
require.Equal(t, "desktop/stable/client.dmg", *got.OSSObjectKey)
|
||||
require.Nil(t, got.CustomDownloadURL)
|
||||
}
|
||||
|
||||
func TestNormalizeDesktopClientReleaseInputCustom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
customURL := "https://download.example.com/client.exe"
|
||||
ossKey := "desktop/stable/client.exe"
|
||||
got, err := normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{
|
||||
Platform: "win32",
|
||||
Arch: "x64",
|
||||
Channel: "stable",
|
||||
Version: "1.2.3",
|
||||
DownloadSource: "custom",
|
||||
OSSObjectKey: &ossKey,
|
||||
CustomDownloadURL: &customURL,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DesktopClientDownloadSourceCustom, got.DownloadSource)
|
||||
require.Nil(t, got.OSSObjectKey)
|
||||
require.NotNil(t, got.CustomDownloadURL)
|
||||
require.Equal(t, customURL, *got.CustomDownloadURL)
|
||||
}
|
||||
|
||||
func TestNormalizeDesktopClientReleaseInputRejectsInvalidDownloadTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
invalidOSSKey := "../client.dmg"
|
||||
_, err := normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{
|
||||
Platform: "darwin",
|
||||
Version: "1.2.3",
|
||||
DownloadSource: "oss",
|
||||
OSSObjectKey: &invalidOSSKey,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
invalidURL := "javascript:alert(1)"
|
||||
_, err = normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{
|
||||
Platform: "win32",
|
||||
Version: "1.2.3",
|
||||
DownloadSource: "custom",
|
||||
CustomDownloadURL: &invalidURL,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDesktopClientReleaseVersionPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Positive(t, compareVersionStrings("1.2.10", "1.2.9"))
|
||||
require.Negative(t, compareVersionStrings("1.2.9", "1.2.10"))
|
||||
|
||||
minVersion := "1.2.0"
|
||||
require.True(t, minSupportedVersionRequiresUpdate(&minVersion, "1.1.9"))
|
||||
require.False(t, minSupportedVersionRequiresUpdate(&minVersion, "1.2.0"))
|
||||
}
|
||||
|
||||
func TestNormalizeDesktopClientReleaseUploadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target, fileName, err := normalizeDesktopClientReleaseUploadInput(DesktopClientReleaseUploadInput{
|
||||
Platform: "darwin",
|
||||
Arch: "arm64",
|
||||
Channel: "stable",
|
||||
Version: "1.2.3",
|
||||
FileName: "省心推-1.2.3-arm64.dmg",
|
||||
Content: []byte("package"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "darwin", target.Platform)
|
||||
require.Equal(t, "arm64", target.Arch)
|
||||
require.Equal(t, "stable", target.Channel)
|
||||
require.Equal(t, "省心推-1.2.3-arm64.dmg", fileName)
|
||||
}
|
||||
|
||||
func TestNormalizeDesktopClientReleaseUploadInputDecodesFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, fileName, err := normalizeDesktopClientReleaseUploadInput(DesktopClientReleaseUploadInput{
|
||||
Platform: "darwin",
|
||||
Arch: "arm64",
|
||||
Channel: "stable",
|
||||
Version: "1.2.3",
|
||||
FileName: "%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip",
|
||||
Content: []byte("package"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "省心推-mac-0.1.19.zip", fileName)
|
||||
}
|
||||
|
||||
func TestNormalizeDesktopClientReleaseUploadInputRejectsInvalidFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := normalizeDesktopClientReleaseUploadInput(DesktopClientReleaseUploadInput{
|
||||
Platform: "darwin",
|
||||
Arch: "arm64",
|
||||
Channel: "stable",
|
||||
Version: "1.2.3",
|
||||
FileName: "client.txt",
|
||||
Content: []byte("package"),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBuildDesktopClientUpdaterFeedYAMLUsesSignedURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fileName := "省心推-mac-1.2.3.zip"
|
||||
sha256 := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
size := int64(1024)
|
||||
publishedAt := time.Date(2026, 5, 25, 10, 30, 0, 0, time.UTC)
|
||||
content, err := buildDesktopClientUpdaterFeedYAML(&domain.DesktopClientRelease{
|
||||
Platform: "darwin",
|
||||
Version: "1.2.3",
|
||||
PublishedAt: &publishedAt,
|
||||
UpdatedAt: publishedAt,
|
||||
}, desktopClientReleaseAsset{
|
||||
FileName: &fileName,
|
||||
FileSizeBytes: &size,
|
||||
SHA256: &sha256,
|
||||
}, "https://oss.example.com/desktop/%E7%9C%81%E5%BF%83%E6%8E%A8.zip?Expires=123&Signature=abc")
|
||||
|
||||
require.NoError(t, err)
|
||||
text := string(content)
|
||||
require.Contains(t, text, "version: 1.2.3")
|
||||
require.Contains(t, text, "sha2: "+sha256)
|
||||
require.Contains(t, text, "size: 1024")
|
||||
require.Contains(t, text, "Expires=123")
|
||||
require.Contains(t, text, "Signature=abc")
|
||||
require.NotContains(t, text, "%25E7")
|
||||
}
|
||||
|
||||
func TestValidateDesktopClientUpdaterPackage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
zipName := "省心推-mac-1.2.3.zip"
|
||||
require.NoError(t, validateDesktopClientUpdaterPackage("darwin", &zipName, ""))
|
||||
|
||||
dmgName := "省心推-mac-1.2.3.dmg"
|
||||
require.Error(t, validateDesktopClientUpdaterPackage("darwin", &dmgName, ""))
|
||||
|
||||
exeURL := "https://download.example.com/%E7%9C%81%E5%BF%83%E6%8E%A8%20Setup%201.2.3.exe"
|
||||
require.NoError(t, validateDesktopClientUpdaterPackage("win32", nil, exeURL))
|
||||
}
|
||||
|
||||
func TestBuildDesktopClientReleaseObjectKeyReusesContentAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sum := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
first := buildDesktopClientReleaseObjectKey(sum, "省心推 Setup 1.2.3.exe")
|
||||
second := buildDesktopClientReleaseObjectKey(sum, "renamed.exe")
|
||||
|
||||
require.Equal(t, first, second)
|
||||
require.Equal(t, "desktop-client/releases/blobs/"+sum+".exe", first)
|
||||
}
|
||||
@@ -20,18 +20,19 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server sharedconfig.ServerConfig `mapstructure:"server"`
|
||||
Database sharedconfig.DatabaseConfig `mapstructure:"database"`
|
||||
MonitoringDatabase sharedconfig.DatabaseConfig `mapstructure:"monitoring_database"`
|
||||
Redis sharedconfig.RedisConfig `mapstructure:"redis"`
|
||||
Cache sharedconfig.CacheConfig `mapstructure:"cache"`
|
||||
RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"`
|
||||
Log sharedconfig.LogConfig `mapstructure:"log"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Auth sharedconfig.AuthConfig `mapstructure:"auth"`
|
||||
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
||||
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
|
||||
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
||||
Server sharedconfig.ServerConfig `mapstructure:"server"`
|
||||
Database sharedconfig.DatabaseConfig `mapstructure:"database"`
|
||||
MonitoringDatabase sharedconfig.DatabaseConfig `mapstructure:"monitoring_database"`
|
||||
Redis sharedconfig.RedisConfig `mapstructure:"redis"`
|
||||
Cache sharedconfig.CacheConfig `mapstructure:"cache"`
|
||||
RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"`
|
||||
ObjectStorage sharedconfig.ObjectStorageConfig `mapstructure:"object_storage"`
|
||||
Log sharedconfig.LogConfig `mapstructure:"log"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Auth sharedconfig.AuthConfig `mapstructure:"auth"`
|
||||
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
||||
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
|
||||
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
@@ -237,6 +238,9 @@ func Diff(previous, current *Config) []FieldChange {
|
||||
if previous.RabbitMQ != current.RabbitMQ {
|
||||
add("rabbitmq", false)
|
||||
}
|
||||
if previous.ObjectStorage != current.ObjectStorage {
|
||||
add("object_storage", false)
|
||||
}
|
||||
if previous.Log != current.Log {
|
||||
add("log", false)
|
||||
}
|
||||
@@ -466,9 +470,56 @@ func applyEnvOverrides(cfg *Config) error {
|
||||
if v := os.Getenv("OPS_RABBITMQ_URL"); v != "" {
|
||||
cfg.RabbitMQ.URL = v
|
||||
}
|
||||
applyOpsObjectStorageEnvOverrides(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyOpsObjectStorageEnvOverrides(cfg *Config) {
|
||||
if endpoint := firstNonEmptyEnv("OPS_OBJECT_STORAGE_ENDPOINT", "OBJECT_STORAGE_ENDPOINT"); endpoint != "" {
|
||||
cfg.ObjectStorage.Endpoint = endpoint
|
||||
}
|
||||
if accessKey := firstNonEmptyEnv("OPS_OBJECT_STORAGE_ACCESS_KEY", "OBJECT_STORAGE_ACCESS_KEY"); accessKey != "" {
|
||||
cfg.ObjectStorage.AccessKey = accessKey
|
||||
}
|
||||
if secretKey := firstNonEmptyEnv("OPS_OBJECT_STORAGE_SECRET_KEY", "OBJECT_STORAGE_SECRET_KEY"); secretKey != "" {
|
||||
cfg.ObjectStorage.SecretKey = secretKey
|
||||
}
|
||||
if bucket := firstNonEmptyEnv("OPS_OBJECT_STORAGE_BUCKET", "OBJECT_STORAGE_BUCKET"); bucket != "" {
|
||||
cfg.ObjectStorage.Bucket = bucket
|
||||
}
|
||||
if provider := firstNonEmptyEnv("OPS_OBJECT_STORAGE_PROVIDER", "OBJECT_STORAGE_PROVIDER"); provider != "" {
|
||||
cfg.ObjectStorage.Provider = provider
|
||||
}
|
||||
if publicBaseURL := firstNonEmptyEnv("OPS_OBJECT_STORAGE_PUBLIC_BASE_URL", "OBJECT_STORAGE_PUBLIC_BASE_URL"); publicBaseURL != "" {
|
||||
cfg.ObjectStorage.PublicBaseURL = publicBaseURL
|
||||
}
|
||||
if objectACL := firstNonEmptyEnv("OPS_OBJECT_STORAGE_OBJECT_ACL", "OBJECT_STORAGE_OBJECT_ACL"); objectACL != "" {
|
||||
cfg.ObjectStorage.ObjectACL = objectACL
|
||||
}
|
||||
if v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_SIGNED_URL_TTL", "OBJECT_STORAGE_SIGNED_URL_TTL"); v != "" {
|
||||
if ttl, err := time.ParseDuration(v); err == nil {
|
||||
cfg.ObjectStorage.SignedURLTTL = ttl
|
||||
}
|
||||
}
|
||||
if region := firstNonEmptyEnv("OPS_OBJECT_STORAGE_REGION", "OBJECT_STORAGE_REGION"); region != "" {
|
||||
cfg.ObjectStorage.Region = region
|
||||
}
|
||||
if v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_USE_SSL", "OBJECT_STORAGE_USE_SSL"); v != "" {
|
||||
if useSSL, err := parseBoolEnv("OPS_OBJECT_STORAGE_USE_SSL", v); err == nil {
|
||||
cfg.ObjectStorage.UseSSL = useSSL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyEnv(names ...string) string {
|
||||
for _, name := range names {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseBoolEnv(name, value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
|
||||
@@ -73,3 +73,32 @@ type SiteDomainMapping struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type DesktopClientRelease struct {
|
||||
ID int64
|
||||
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
|
||||
PublishedAt *time.Time
|
||||
CreatedBy *int64
|
||||
UpdatedBy *int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
)
|
||||
|
||||
var ErrDesktopClientReleaseNotFound = errors.New("desktop client release not found")
|
||||
|
||||
type DesktopClientReleaseRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewDesktopClientReleaseRepository(pool *pgxpool.Pool) *DesktopClientReleaseRepository {
|
||||
return &DesktopClientReleaseRepository{pool: pool}
|
||||
}
|
||||
|
||||
const desktopClientReleaseColumns = `
|
||||
id, platform, arch, channel, version, min_supported_version, download_source,
|
||||
oss_object_key, custom_download_url, file_name, file_size_bytes, sha256,
|
||||
updater_download_source, updater_oss_object_key, updater_custom_download_url,
|
||||
updater_file_name, updater_file_size_bytes, updater_sha256,
|
||||
release_notes, force_update, enabled, published_at, created_by, updated_by,
|
||||
created_at, updated_at`
|
||||
|
||||
type DesktopClientReleaseFilter struct {
|
||||
Keyword string
|
||||
Platform string
|
||||
Channel string
|
||||
Enabled *bool
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type DesktopClientReleaseTarget struct {
|
||||
Platform string
|
||||
Arch string
|
||||
Channel string
|
||||
}
|
||||
|
||||
type UpsertDesktopClientReleaseInput 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
|
||||
PublishedAtNow bool
|
||||
ActorID *int64
|
||||
}
|
||||
|
||||
func scanDesktopClientRelease(row pgx.Row) (*domain.DesktopClientRelease, error) {
|
||||
var item domain.DesktopClientRelease
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.Platform,
|
||||
&item.Arch,
|
||||
&item.Channel,
|
||||
&item.Version,
|
||||
&item.MinSupportedVersion,
|
||||
&item.DownloadSource,
|
||||
&item.OSSObjectKey,
|
||||
&item.CustomDownloadURL,
|
||||
&item.FileName,
|
||||
&item.FileSizeBytes,
|
||||
&item.SHA256,
|
||||
&item.UpdaterDownloadSource,
|
||||
&item.UpdaterOSSObjectKey,
|
||||
&item.UpdaterCustomDownloadURL,
|
||||
&item.UpdaterFileName,
|
||||
&item.UpdaterFileSizeBytes,
|
||||
&item.UpdaterSHA256,
|
||||
&item.ReleaseNotes,
|
||||
&item.ForceUpdate,
|
||||
&item.Enabled,
|
||||
&item.PublishedAt,
|
||||
&item.CreatedBy,
|
||||
&item.UpdatedBy,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrDesktopClientReleaseNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) List(ctx context.Context, f DesktopClientReleaseFilter) ([]domain.DesktopClientRelease, int64, error) {
|
||||
args := []any{}
|
||||
where := "1=1"
|
||||
|
||||
if f.Keyword != "" {
|
||||
args = append(args, "%"+f.Keyword+"%")
|
||||
where += fmt.Sprintf(
|
||||
" AND (version ILIKE $%d OR platform ILIKE $%d OR arch ILIKE $%d OR channel ILIKE $%d OR COALESCE(file_name, '') ILIKE $%d OR COALESCE(oss_object_key, '') ILIKE $%d OR COALESCE(custom_download_url, '') ILIKE $%d OR COALESCE(updater_file_name, '') ILIKE $%d OR COALESCE(updater_oss_object_key, '') ILIKE $%d OR COALESCE(updater_custom_download_url, '') ILIKE $%d)",
|
||||
len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args),
|
||||
)
|
||||
}
|
||||
if f.Platform != "" {
|
||||
args = append(args, f.Platform)
|
||||
where += fmt.Sprintf(" AND platform = $%d", len(args))
|
||||
}
|
||||
if f.Channel != "" {
|
||||
args = append(args, f.Channel)
|
||||
where += fmt.Sprintf(" AND channel = $%d", len(args))
|
||||
}
|
||||
if f.Enabled != nil {
|
||||
args = append(args, *f.Enabled)
|
||||
where += fmt.Sprintf(" AND enabled = $%d", len(args))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM ops.desktop_client_releases WHERE "+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 20
|
||||
}
|
||||
offset := f.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
limitArg := len(args) - 1
|
||||
offsetArg := len(args)
|
||||
|
||||
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
|
||||
SELECT %s
|
||||
FROM ops.desktop_client_releases
|
||||
WHERE %s
|
||||
ORDER BY enabled DESC, channel ASC, platform ASC, arch ASC, updated_at DESC, id DESC
|
||||
LIMIT $%d OFFSET $%d`, desktopClientReleaseColumns, where, limitArg, offsetArg), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.DesktopClientRelease, 0, limit)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanDesktopClientRelease(rows)
|
||||
if scanErr != nil {
|
||||
return nil, 0, scanErr
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) GetByID(ctx context.Context, id int64) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
SELECT `+desktopClientReleaseColumns+`
|
||||
FROM ops.desktop_client_releases
|
||||
WHERE id = $1`, id))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) GetByTarget(ctx context.Context, target DesktopClientReleaseTarget) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
SELECT `+desktopClientReleaseColumns+`
|
||||
FROM ops.desktop_client_releases
|
||||
WHERE platform = $1 AND arch = $2 AND channel = $3`,
|
||||
target.Platform, target.Arch, target.Channel))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) FindEnabled(ctx context.Context, target DesktopClientReleaseTarget) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
SELECT `+desktopClientReleaseColumns+`
|
||||
FROM ops.desktop_client_releases
|
||||
WHERE platform = $1
|
||||
AND channel = $2
|
||||
AND enabled = TRUE
|
||||
AND (arch = $3 OR arch = 'universal')
|
||||
ORDER BY CASE WHEN arch = $3 THEN 0 ELSE 1 END, updated_at DESC, id DESC
|
||||
LIMIT 1`,
|
||||
target.Platform, target.Channel, target.Arch))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) Create(ctx context.Context, in UpsertDesktopClientReleaseInput) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
INSERT INTO ops.desktop_client_releases (
|
||||
platform, arch, channel, version, min_supported_version, download_source,
|
||||
oss_object_key, custom_download_url, file_name, file_size_bytes, sha256,
|
||||
updater_download_source, updater_oss_object_key, updater_custom_download_url,
|
||||
updater_file_name, updater_file_size_bytes, updater_sha256,
|
||||
release_notes, force_update, enabled, published_at, created_by, updated_by
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16, $17,
|
||||
$18, $19, $20,
|
||||
CASE WHEN $21 THEN NOW() ELSE NULL END,
|
||||
$22, $22
|
||||
)
|
||||
RETURNING `+desktopClientReleaseColumns,
|
||||
in.Platform, in.Arch, in.Channel, in.Version, in.MinSupportedVersion, in.DownloadSource,
|
||||
in.OSSObjectKey, in.CustomDownloadURL, in.FileName, in.FileSizeBytes, in.SHA256,
|
||||
in.UpdaterDownloadSource, in.UpdaterOSSObjectKey, in.UpdaterCustomDownloadURL,
|
||||
in.UpdaterFileName, in.UpdaterFileSizeBytes, in.UpdaterSHA256,
|
||||
in.ReleaseNotes, in.ForceUpdate, in.Enabled, in.PublishedAtNow, in.ActorID))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) Update(ctx context.Context, id int64, in UpsertDesktopClientReleaseInput) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
UPDATE ops.desktop_client_releases
|
||||
SET platform = $1,
|
||||
arch = $2,
|
||||
channel = $3,
|
||||
version = $4,
|
||||
min_supported_version = $5,
|
||||
download_source = $6,
|
||||
oss_object_key = $7,
|
||||
custom_download_url = $8,
|
||||
file_name = $9,
|
||||
file_size_bytes = $10,
|
||||
sha256 = $11,
|
||||
updater_download_source = $12,
|
||||
updater_oss_object_key = $13,
|
||||
updater_custom_download_url = $14,
|
||||
updater_file_name = $15,
|
||||
updater_file_size_bytes = $16,
|
||||
updater_sha256 = $17,
|
||||
release_notes = $18,
|
||||
force_update = $19,
|
||||
enabled = $20,
|
||||
published_at = CASE
|
||||
WHEN $20 = TRUE AND published_at IS NULL THEN NOW()
|
||||
WHEN $20 = FALSE THEN NULL
|
||||
ELSE published_at
|
||||
END,
|
||||
updated_by = $21,
|
||||
updated_at = NOW()
|
||||
WHERE id = $22
|
||||
RETURNING `+desktopClientReleaseColumns,
|
||||
in.Platform, in.Arch, in.Channel, in.Version, in.MinSupportedVersion, in.DownloadSource,
|
||||
in.OSSObjectKey, in.CustomDownloadURL, in.FileName, in.FileSizeBytes, in.SHA256,
|
||||
in.UpdaterDownloadSource, in.UpdaterOSSObjectKey, in.UpdaterCustomDownloadURL,
|
||||
in.UpdaterFileName, in.UpdaterFileSizeBytes, in.UpdaterSHA256,
|
||||
in.ReleaseNotes, in.ForceUpdate, in.Enabled, in.ActorID, id))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) SetEnabled(ctx context.Context, id int64, enabled bool, actorID *int64) (*domain.DesktopClientRelease, error) {
|
||||
return scanDesktopClientRelease(r.pool.QueryRow(ctx, `
|
||||
UPDATE ops.desktop_client_releases
|
||||
SET enabled = $1,
|
||||
published_at = CASE
|
||||
WHEN $1 = TRUE AND published_at IS NULL THEN NOW()
|
||||
WHEN $1 = FALSE THEN NULL
|
||||
ELSE published_at
|
||||
END,
|
||||
updated_by = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING `+desktopClientReleaseColumns, enabled, actorID, id))
|
||||
}
|
||||
|
||||
func (r *DesktopClientReleaseRepository) Delete(ctx context.Context, id int64) error {
|
||||
cmd, err := r.pool.Exec(ctx, `DELETE FROM ops.desktop_client_releases WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.RowsAffected() == 0 {
|
||||
return ErrDesktopClientReleaseNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type desktopClientReleaseRequest struct {
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Arch string `json:"arch"`
|
||||
Channel string `json:"channel"`
|
||||
Version string `json:"version" binding:"required"`
|
||||
MinSupportedVersion *string `json:"min_supported_version"`
|
||||
DownloadSource string `json:"download_source"`
|
||||
OSSObjectKey *string `json:"oss_object_key"`
|
||||
CustomDownloadURL *string `json:"custom_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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type setDesktopClientReleaseEnabledRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func listDesktopClientReleasesHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
result, err := svc.List(c.Request.Context(), app.DesktopClientReleaseListInput{
|
||||
Keyword: c.Query("keyword"),
|
||||
Platform: c.Query("platform"),
|
||||
Channel: c.Query("channel"),
|
||||
Enabled: parseOptionalBoolQuery(c.Query("enabled")),
|
||||
Page: page,
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func createDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body desktopClientReleaseRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := svc.Create(c.Request.Context(), actorFromGin(c), body.toInput())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func updateDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body desktopClientReleaseRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := svc.Update(c.Request.Context(), actorFromGin(c), id, body.toInput())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func setDesktopClientReleaseEnabledHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body setDesktopClientReleaseEnabledRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := svc.SetEnabled(c.Request.Context(), actorFromGin(c), id, body.Enabled)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.Delete(c.Request.Context(), actorFromGin(c), id); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDesktopClientReleaseDownloadURLHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.ResolveDownloadURLByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func uploadDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40071, "release_file_required", "请选择要上传的客户端安装包"))
|
||||
return
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40071, "release_file_open_failed", "客户端安装包读取失败"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := svc.UploadOSS(c.Request.Context(), app.DesktopClientReleaseUploadInput{
|
||||
Platform: c.PostForm("platform"),
|
||||
Arch: c.PostForm("arch"),
|
||||
Channel: c.PostForm("channel"),
|
||||
Version: c.PostForm("version"),
|
||||
Kind: c.DefaultPostForm("kind", app.DesktopClientAssetKindInstaller),
|
||||
FileName: fileHeader.Filename,
|
||||
Content: content,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func checkDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := svc.CheckLatest(c.Request.Context(), app.DesktopClientReleaseCheckInput{
|
||||
Platform: c.Query("platform"),
|
||||
Arch: c.Query("arch"),
|
||||
Channel: c.DefaultQuery("channel", "stable"),
|
||||
CurrentVersion: c.Query("version"),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveLatestDesktopClientReleaseDownloadURLHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := svc.ResolveLatestDownloadURL(c.Request.Context(), app.DesktopClientReleaseCheckInput{
|
||||
Platform: c.Query("platform"),
|
||||
Arch: c.Query("arch"),
|
||||
Channel: c.DefaultQuery("channel", "stable"),
|
||||
CurrentVersion: c.Query("version"),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func desktopClientReleaseUpdaterFeedHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
feed, err := svc.ResolveLatestUpdaterFeed(c.Request.Context(), app.DesktopClientReleaseCheckInput{
|
||||
Platform: firstNonEmpty(c.Param("platform"), c.Query("platform")),
|
||||
Arch: firstNonEmpty(c.Param("arch"), c.Query("arch")),
|
||||
Channel: firstNonEmpty(c.Param("channel"), c.DefaultQuery("channel", "stable")),
|
||||
CurrentVersion: firstNonEmpty(c.Param("version"), c.Query("version")),
|
||||
}, c.Param("feed"))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/x-yaml; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, sanitizeHeaderFileName(feed.FileName)))
|
||||
c.Data(http.StatusOK, "application/x-yaml; charset=utf-8", feed.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func (r desktopClientReleaseRequest) toInput() app.DesktopClientReleaseInput {
|
||||
enabled := true
|
||||
if r.Enabled != nil {
|
||||
enabled = *r.Enabled
|
||||
}
|
||||
return app.DesktopClientReleaseInput{
|
||||
Platform: r.Platform,
|
||||
Arch: r.Arch,
|
||||
Channel: r.Channel,
|
||||
Version: r.Version,
|
||||
MinSupportedVersion: r.MinSupportedVersion,
|
||||
DownloadSource: r.DownloadSource,
|
||||
OSSObjectKey: r.OSSObjectKey,
|
||||
CustomDownloadURL: r.CustomDownloadURL,
|
||||
FileName: r.FileName,
|
||||
FileSizeBytes: r.FileSizeBytes,
|
||||
SHA256: r.SHA256,
|
||||
UpdaterDownloadSource: r.UpdaterDownloadSource,
|
||||
UpdaterOSSObjectKey: r.UpdaterOSSObjectKey,
|
||||
UpdaterCustomDownloadURL: r.UpdaterCustomDownloadURL,
|
||||
UpdaterFileName: r.UpdaterFileName,
|
||||
UpdaterFileSizeBytes: r.UpdaterFileSizeBytes,
|
||||
UpdaterSHA256: r.UpdaterSHA256,
|
||||
ReleaseNotes: r.ReleaseNotes,
|
||||
ForceUpdate: r.ForceUpdate,
|
||||
Enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeHeaderFileName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "latest.yml"
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '"' || r == '\\' || r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type Deps struct {
|
||||
KolSubs *app.KolSubscriptionService
|
||||
Audits *app.AuditService
|
||||
SiteDomains *app.SiteDomainMappingService
|
||||
Releases *app.DesktopClientReleaseService
|
||||
Compliance *app.ComplianceService
|
||||
Scheduler *app.SchedulerService
|
||||
}
|
||||
@@ -67,6 +68,10 @@ func RegisterRoutes(d Deps) {
|
||||
}
|
||||
response.Success(c, gin.H{"status": "ready"})
|
||||
})
|
||||
d.Engine.GET("/api/desktop/releases/latest", checkDesktopClientReleaseHandler(d.Releases))
|
||||
d.Engine.GET("/api/desktop/releases/download-url", resolveLatestDesktopClientReleaseDownloadURLHandler(d.Releases))
|
||||
d.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed", desktopClientReleaseUpdaterFeedHandler(d.Releases))
|
||||
d.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", desktopClientReleaseUpdaterFeedHandler(d.Releases))
|
||||
|
||||
api := d.Engine.Group("/api/ops")
|
||||
{
|
||||
@@ -132,6 +137,14 @@ func RegisterRoutes(d Deps) {
|
||||
authed.POST("/site-domain-mappings/:id/active", setSiteDomainMappingActiveHandler(d.SiteDomains))
|
||||
authed.DELETE("/site-domain-mappings/:id", deleteSiteDomainMappingHandler(d.SiteDomains))
|
||||
|
||||
authed.GET("/desktop-client/releases", listDesktopClientReleasesHandler(d.Releases))
|
||||
authed.POST("/desktop-client/releases/upload", uploadDesktopClientReleaseHandler(d.Releases))
|
||||
authed.POST("/desktop-client/releases", createDesktopClientReleaseHandler(d.Releases))
|
||||
authed.PATCH("/desktop-client/releases/:id", updateDesktopClientReleaseHandler(d.Releases))
|
||||
authed.GET("/desktop-client/releases/:id/download-url", resolveDesktopClientReleaseDownloadURLHandler(d.Releases))
|
||||
authed.POST("/desktop-client/releases/:id/enabled", setDesktopClientReleaseEnabledHandler(d.Releases))
|
||||
authed.DELETE("/desktop-client/releases/:id", deleteDesktopClientReleaseHandler(d.Releases))
|
||||
|
||||
compliance := authed.Group("/compliance")
|
||||
compliance.GET("/dictionaries", listComplianceDictionariesHandler(d.Compliance))
|
||||
compliance.POST("/dictionaries", createComplianceDictionaryHandler(d.Compliance))
|
||||
|
||||
Reference in New Issue
Block a user