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

This commit is contained in:
2026-05-25 19:23:49 +08:00
parent 41b4a765ac
commit 5f6e9f11da
46 changed files with 5508 additions and 160 deletions
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)
}
+63 -12
View File
@@ -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":
+29
View File
@@ -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)
}
+13
View File
@@ -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))