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
+5
View File
@@ -21,6 +21,7 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/ipregion"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/observability"
"github.com/geo-platform/tenant-api/internal/shared/repository/postgres"
"github.com/geo-platform/tenant-api/internal/shared/repository/redis"
@@ -83,6 +84,7 @@ func main() {
kolSubscriptionsRepo := repository.NewKolSubscriptionRepository(pool)
auditsRepo := repository.NewAuditRepository(pool)
siteDomainMappingsRepo := repository.NewSiteDomainMappingRepository(monitoringPool)
desktopClientReleasesRepo := repository.NewDesktopClientReleaseRepository(pool)
schedulerRepo := repository.NewSchedulerRepository(pool)
ipRegionResolver, err := ipregion.NewResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger)
@@ -118,6 +120,8 @@ func main() {
jobSvc := app.NewJobService(jobsRepo, auditSvc, rabbitMQ)
kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache)
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
objectStorageClient := objectstorage.New(cfg.ObjectStorage, logger)
desktopClientReleaseSvc := app.NewDesktopClientReleaseService(desktopClientReleasesRepo, objectStorageClient, auditSvc)
complianceSvc := app.NewComplianceService(pool, auditSvc, logger)
schedulerSvc := app.NewSchedulerService(schedulerRepo, auditSvc)
@@ -166,6 +170,7 @@ func main() {
KolSubs: kolSubscriptionSvc,
Audits: auditSvc,
SiteDomains: siteDomainMappingSvc,
Releases: desktopClientReleaseSvc,
Compliance: complianceSvc,
Scheduler: schedulerSvc,
})
+3
View File
@@ -163,6 +163,9 @@ object_storage:
bucket: shengxintui
use_ssl: true
public_base_url: ""
bucket_acl: private
object_acl: default
signed_url_ttl: 30m
region: cn-shanghai
cache:
+13
View File
@@ -40,6 +40,19 @@ redis:
cache:
driver: redis
object_storage:
provider: aliyun
endpoint: https://oss-cn-shanghai.aliyuncs.com
access_key: LTAI5tAEj8euR8B1gXzMoG84
secret_key: lJPrKo9ViyHJE8UWIMmMCY0B6Je8v2
bucket: shengxintui
use_ssl: true
public_base_url: ""
bucket_acl: private
object_acl: default
signed_url_ttl: 30m
region: cn-shanghai
log:
level: info
format: json
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))
+20 -8
View File
@@ -285,14 +285,17 @@ type QdrantConfig struct {
}
type ObjectStorageConfig struct {
Provider string `mapstructure:"provider"`
Endpoint string `mapstructure:"endpoint"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
Bucket string `mapstructure:"bucket"`
UseSSL bool `mapstructure:"use_ssl"`
PublicBaseURL string `mapstructure:"public_base_url"`
Region string `mapstructure:"region"`
Provider string `mapstructure:"provider"`
Endpoint string `mapstructure:"endpoint"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
Bucket string `mapstructure:"bucket"`
UseSSL bool `mapstructure:"use_ssl"`
PublicBaseURL string `mapstructure:"public_base_url"`
BucketACL string `mapstructure:"bucket_acl"`
ObjectACL string `mapstructure:"object_acl"`
SignedURLTTL time.Duration `mapstructure:"signed_url_ttl"`
Region string `mapstructure:"region"`
}
type JWTConfig struct {
@@ -956,6 +959,15 @@ func applyEnvOverrides(cfg *Config) {
if publicBaseURL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_PUBLIC_BASE_URL"); ok {
cfg.ObjectStorage.PublicBaseURL = publicBaseURL
}
if bucketACL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_BUCKET_ACL"); ok {
cfg.ObjectStorage.BucketACL = bucketACL
}
if objectACL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_OBJECT_ACL"); ok {
cfg.ObjectStorage.ObjectACL = objectACL
}
if ttl, ok := lookupDurationEnv("OBJECT_STORAGE_SIGNED_URL_TTL"); ok {
cfg.ObjectStorage.SignedURLTTL = ttl
}
if region, ok := lookupNonEmptyEnv("OBJECT_STORAGE_REGION"); ok {
cfg.ObjectStorage.Region = region
}
@@ -108,6 +108,34 @@ retrieval:
}
}
func TestLoadAppliesObjectStorageEnvOverrides(t *testing.T) {
t.Setenv("OBJECT_STORAGE_BUCKET_ACL", "private")
t.Setenv("OBJECT_STORAGE_OBJECT_ACL", "public-read")
t.Setenv("OBJECT_STORAGE_SIGNED_URL_TTL", "45m")
configPath := writeTestConfig(t, `
object_storage:
bucket_acl: public-read
object_acl: private
signed_url_ttl: 10m
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.ObjectStorage.BucketACL != "private" {
t.Fatalf("expected env bucket acl, got %q", cfg.ObjectStorage.BucketACL)
}
if cfg.ObjectStorage.ObjectACL != "public-read" {
t.Fatalf("expected env object acl, got %q", cfg.ObjectStorage.ObjectACL)
}
if cfg.ObjectStorage.SignedURLTTL != 45*time.Minute {
t.Fatalf("expected env signed url ttl, got %s", cfg.ObjectStorage.SignedURLTTL)
}
}
func TestLoadAppliesBrandLibraryDefaults(t *testing.T) {
configPath := writeTestConfig(t, `
brand_library: {}
+105 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"strings"
"time"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"go.uber.org/zap"
@@ -106,11 +107,20 @@ func (c *aliyunClient) PutBytes(ctx context.Context, objectKey string, content [
return fmt.Errorf("get aliyun bucket: %w", err)
}
if err := bucket.PutObject(objectKey, bytes.NewReader(content), oss.ContentType(contentType)); err != nil {
options, err := aliyunPutObjectOptions(c.cfg, contentType)
if err != nil {
return err
}
if err := bucket.PutObject(objectKey, bytes.NewReader(content), options...); err != nil {
c.logError(ctx, "aliyun put object failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Int("content_size", len(content)),
zap.String("object_acl", strings.TrimSpace(c.cfg.ObjectACL)),
zap.String("content_type", contentType),
zap.String("aliyun_error_code", aliyunErrorCode(err)),
zap.String("aliyun_request_id", aliyunRequestID(err)),
zap.Int("aliyun_status_code", aliyunStatusCode(err)),
zap.Error(err),
)
return fmt.Errorf("put aliyun object: %w", err)
@@ -119,6 +129,47 @@ func (c *aliyunClient) PutBytes(ctx context.Context, objectKey string, content [
return nil
}
func aliyunPutObjectOptions(cfg config.ObjectStorageConfig, contentType string) ([]oss.Option, error) {
options := []oss.Option{oss.ContentType(contentType)}
acl := strings.ToLower(strings.TrimSpace(cfg.ObjectACL))
switch acl {
case "", "default":
return options, nil
case "private":
return append(options, oss.ObjectACL(oss.ACLPrivate)), nil
case "public-read":
return append(options, oss.ObjectACL(oss.ACLPublicRead)), nil
case "public-read-write":
return append(options, oss.ObjectACL(oss.ACLPublicReadWrite)), nil
default:
return nil, fmt.Errorf("unsupported aliyun oss object_acl %q", cfg.ObjectACL)
}
}
func aliyunErrorCode(err error) string {
serviceErr, ok := err.(oss.ServiceError)
if !ok {
return ""
}
return serviceErr.Code
}
func aliyunRequestID(err error) string {
serviceErr, ok := err.(oss.ServiceError)
if !ok {
return ""
}
return serviceErr.RequestID
}
func aliyunStatusCode(err error) int {
serviceErr, ok := err.(oss.ServiceError)
if !ok {
return 0
}
return serviceErr.StatusCode
}
func (c *aliyunClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) {
if err := c.Validate(); err != nil {
return nil, err
@@ -235,9 +286,62 @@ func (c *aliyunClient) PublicURL(objectKey string) (string, error) {
if err := c.Validate(); err != nil {
return "", err
}
publicRead, err := c.isPublicReadable()
if err != nil {
return "", err
}
if !publicRead {
return c.signedURL(objectKey)
}
return buildPublicURL(c.cfg, objectKey, true)
}
func (c *aliyunClient) isPublicReadable() (bool, error) {
objectACL := strings.ToLower(strings.TrimSpace(c.cfg.ObjectACL))
if isPublicReadACL(objectACL) {
return true, nil
}
if objectACL != "" && objectACL != "default" {
return false, nil
}
bucketACL := strings.ToLower(strings.TrimSpace(c.cfg.BucketACL))
if bucketACL != "" {
return isPublicReadACL(bucketACL), nil
}
result, err := c.client.GetBucketACL(c.bucket)
if err != nil {
return false, fmt.Errorf("get aliyun bucket acl: %w", err)
}
return isPublicReadACL(result.ACL), nil
}
func (c *aliyunClient) signedURL(objectKey string) (string, error) {
objectKey = normalizeObjectKeyPath(objectKey)
if objectKey == "" {
return "", fmt.Errorf("object key is required")
}
bucket, err := c.client.Bucket(c.bucket)
if err != nil {
return "", fmt.Errorf("get aliyun bucket: %w", err)
}
ttl := c.cfg.SignedURLTTL
if ttl <= 0 {
ttl = 30 * time.Minute
}
return bucket.SignURL(objectKey, oss.HTTPGet, int64(ttl.Seconds()))
}
func isPublicReadACL(acl string) bool {
switch strings.ToLower(strings.TrimSpace(acl)) {
case "public-read", "public-read-write":
return true
default:
return false
}
}
func (c *aliyunClient) logError(ctx context.Context, msg string, fields ...zap.Field) {
if c == nil || c.logger == nil {
return
@@ -0,0 +1,56 @@
package objectstorage
import (
"testing"
"github.com/geo-platform/tenant-api/internal/shared/config"
)
func TestAliyunPutObjectOptionsAcceptsSupportedObjectACL(t *testing.T) {
t.Parallel()
for _, acl := range []string{"", "default", "private", "public-read", "public-read-write"} {
acl := acl
t.Run(acl, func(t *testing.T) {
t.Parallel()
options, err := aliyunPutObjectOptions(config.ObjectStorageConfig{ObjectACL: acl}, "application/zip")
if err != nil {
t.Fatalf("aliyunPutObjectOptions(%q) error = %v", acl, err)
}
if len(options) == 0 {
t.Fatalf("expected at least content type option")
}
})
}
}
func TestAliyunPutObjectOptionsRejectsUnsupportedObjectACL(t *testing.T) {
t.Parallel()
_, err := aliyunPutObjectOptions(config.ObjectStorageConfig{ObjectACL: "authenticated-read"}, "application/zip")
if err == nil {
t.Fatal("expected unsupported object acl error")
}
}
func TestIsPublicReadACL(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
acl string
want bool
}{
{acl: "", want: false},
{acl: "default", want: false},
{acl: "private", want: false},
{acl: "public-read", want: true},
{acl: "public-read-write", want: true},
{acl: " PUBLIC-READ ", want: true},
} {
got := isPublicReadACL(tc.acl)
if got != tc.want {
t.Fatalf("isPublicReadACL(%q) = %v, want %v", tc.acl, got, tc.want)
}
}
}
+24 -6
View File
@@ -95,12 +95,9 @@ func joinEscapedPath(basePath string, extra ...string) string {
return
}
for _, segment := range strings.Split(value, "/") {
segment = strings.TrimSpace(segment)
if segment == "" {
continue
}
segments = append(segments, url.PathEscape(segment))
normalized := normalizeObjectKeyPath(value)
if normalized != "" {
segments = append(segments, strings.Split(normalized, "/")...)
}
}
@@ -114,3 +111,24 @@ func joinEscapedPath(basePath string, extra ...string) string {
}
return "/" + strings.Join(segments, "/")
}
func normalizeObjectKeyPath(value string) string {
segments := make([]string, 0)
value = strings.TrimSpace(value)
value = strings.Trim(value, "/")
if value == "" {
return ""
}
for _, segment := range strings.Split(value, "/") {
segment = strings.TrimSpace(segment)
if segment == "" {
continue
}
if decoded, err := url.PathUnescape(segment); err == nil {
segment = decoded
}
segments = append(segments, segment)
}
return strings.Join(segments, "/")
}
@@ -0,0 +1,53 @@
package objectstorage
import (
"testing"
"github.com/geo-platform/tenant-api/internal/shared/config"
)
func TestBuildPublicURLDoesNotDoubleEscapeEncodedObjectKey(t *testing.T) {
t.Parallel()
got, err := buildPublicURL(config.ObjectStorageConfig{
Endpoint: "https://oss-cn-shanghai.aliyuncs.com",
Bucket: "shengxintui",
UseSSL: true,
}, "desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip", true)
if err != nil {
t.Fatal(err)
}
want := "https://shengxintui.oss-cn-shanghai.aliyuncs.com/desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip"
if got != want {
t.Fatalf("url = %q, want %q", got, want)
}
}
func TestBuildPublicURLEscapesRawUnicodeObjectKeyOnce(t *testing.T) {
t.Parallel()
got, err := buildPublicURL(config.ObjectStorageConfig{
Endpoint: "https://oss-cn-shanghai.aliyuncs.com",
Bucket: "shengxintui",
UseSSL: true,
}, "desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-省心推-mac-0.1.19.zip", true)
if err != nil {
t.Fatal(err)
}
want := "https://shengxintui.oss-cn-shanghai.aliyuncs.com/desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip"
if got != want {
t.Fatalf("url = %q, want %q", got, want)
}
}
func TestNormalizeObjectKeyPathDecodesEncodedSegments(t *testing.T) {
t.Parallel()
got := normalizeObjectKeyPath("/desktop-client/releases/%E7%9C%81%E5%BF%83%E6%8E%A8.zip")
want := "desktop-client/releases/省心推.zip"
if got != want {
t.Fatalf("object key = %q, want %q", got, want)
}
}
+11 -6
View File
@@ -30,12 +30,17 @@ var routeDocs = map[string]routeDoc{
"GET /api/public/assets/:token": {"对外访问签名资源", "通过签名 token 直接访问对象存储中的图片/附件,无需登录。"},
// --- Desktop 客户端:注册 / 心跳 / 鉴权 ---
"POST /api/desktop/clients/register": {"桌面客户端注册", "由租户用户登录后调用,为本机桌面客户端申请客户端凭证。"},
"POST /api/desktop/clients/rotate": {"轮换客户端密钥", "客户端使用旧凭证调用,换取新的客户端密钥。"},
"POST /api/desktop/clients/heartbeat": {"客户端心跳上报", "桌面客户端定时上报存活与版本,服务端用于在线状态展示。"},
"POST /api/desktop/clients/offline": {"客户端主动离线", "桌面客户端退出/休眠时上报,立即将本机标记为离线。"},
"POST /api/desktop/clients/revoke": {"吊销客户端", "由桌面端调用,立即注销当前客户端凭证。"},
"GET /api/desktop/releases/latest": {"查询最新客户端版本", "桌面客户端未登录时查询最新版本配置,不提前生成签名下载地址。"},
"GET /api/desktop/releases/download-url": {"解析客户端下载地址", "用户开始下载时按最新版本配置即时生成下载地址,私有 OSS 会返回签名 URL。"},
"GET /api/desktop/releases/updater/:platform/:arch/:channel/:version": {"自动更新 Feed", "桌面客户端开始自动更新时读取 Electron updater feed,私有 OSS 会在 feed 中写入短时签名下载地址。"},
"GET /api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed": {"自动更新 Feed 文件", "兼容 Electron updater 对 latest*.yml 的二次请求,返回动态生成的 updater YAML。"},
"POST /api/desktop/clients/register": {"桌面客户端注册", "由租户用户登录后调用,为本机桌面客户端申请客户端凭证。"},
"POST /api/desktop/clients/rotate": {"轮换客户端密钥", "客户端使用旧凭证调用,换取新的客户端密钥。"},
"POST /api/desktop/clients/heartbeat": {"客户端心跳上报", "桌面客户端定时上报存活与版本,服务端用于在线状态展示。"},
"POST /api/desktop/clients/offline": {"客户端主动离线", "桌面客户端退出/休眠时上报,立即将本机标记为离线。"},
"POST /api/desktop/clients/revoke": {"吊销客户端", "由桌面端调用,立即注销当前客户端凭证。"},
"GET /api/desktop/clients/releases/latest": {"查询最新客户端版本(桌面鉴权)", "桌面客户端带 client token 查询最新版本,默认沿用本机注册渠道。"},
"GET /api/desktop/clients/releases/download-url": {"解析客户端下载地址(桌面鉴权)", "桌面客户端开始更新下载时即时获取下载地址,私有 OSS 会返回签名 URL。"},
// --- Desktop:派单与拉取 ---
"GET /api/desktop/dispatch": {"派单 WebSocket", "桌面客户端通过 WebSocket 长连接订阅派单事件,断线后自动重连。"},
"GET /api/desktop/accounts": {"拉取账号列表(桌面)", "桌面客户端拉取本机绑定/可用的媒体账号,支持增量同步。"},
@@ -500,6 +500,10 @@ func errorResponse(description string) map[string]any {
func securityForPath(path string) []map[string][]string {
if path == "/api/auth/password-key" ||
path == "/api/auth/login" || path == "/api/auth/refresh" ||
path == "/api/desktop/releases/latest" ||
path == "/api/desktop/releases/download-url" ||
path == "/api/desktop/releases/updater/:platform/:arch/:channel/:version" ||
path == "/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed" ||
strings.HasPrefix(path, "/api/health/") ||
strings.HasPrefix(path, "/api/public/") {
return nil
@@ -0,0 +1,136 @@
package transport
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
opsapp "github.com/geo-platform/tenant-api/internal/ops/app"
opsrepo "github.com/geo-platform/tenant-api/internal/ops/repository"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type DesktopReleaseHandler struct {
svc *opsapp.DesktopClientReleaseService
}
func NewDesktopReleaseHandler(a *bootstrap.App) *DesktopReleaseHandler {
return &DesktopReleaseHandler{
svc: opsapp.NewDesktopClientReleaseService(
opsrepo.NewDesktopClientReleaseRepository(a.DB),
a.ObjectStorage,
nil,
),
}
}
func (h *DesktopReleaseHandler) Latest(c *gin.Context) {
client := MustDesktopClient(c.Request.Context())
data, err := h.svc.CheckLatest(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
Platform: c.Query("platform"),
Arch: c.Query("arch"),
Channel: defaultDesktopReleaseChannel(c.Query("channel"), client.Channel),
CurrentVersion: c.Query("version"),
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopReleaseHandler) DownloadURL(c *gin.Context) {
client := MustDesktopClient(c.Request.Context())
data, err := h.svc.ResolveLatestDownloadURL(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{
Platform: c.Query("platform"),
Arch: c.Query("arch"),
Channel: defaultDesktopReleaseChannel(c.Query("channel"), client.Channel),
CurrentVersion: c.Query("version"),
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *DesktopReleaseHandler) LatestPublic(c *gin.Context) {
data, err := h.svc.CheckLatest(c.Request.Context(), opsapp.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, data)
}
func (h *DesktopReleaseHandler) DownloadURLPublic(c *gin.Context) {
data, err := h.svc.ResolveLatestDownloadURL(c.Request.Context(), opsapp.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, data)
}
func (h *DesktopReleaseHandler) UpdaterFeedPublic(c *gin.Context) {
feed, err := h.svc.ResolveLatestUpdaterFeed(c.Request.Context(), opsapp.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 firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func defaultDesktopReleaseChannel(queryValue string, clientChannel *string) string {
if queryValue != "" {
return queryValue
}
if clientChannel != nil && *clientChannel != "" {
return *clientChannel
}
return "stable"
}
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)
}
@@ -33,6 +33,7 @@ func RegisterRoutes(app *bootstrap.App) {
protected.POST("/auth/logout", authHandler.Logout)
desktopClientHandler := NewDesktopClientHandler(app)
desktopReleaseHandler := NewDesktopReleaseHandler(app)
desktopAccountHandler := NewDesktopAccountHandler(app)
desktopTaskHandler := NewDesktopTaskHandler(app)
desktopDispatchHandler := NewDesktopDispatchHandler(app)
@@ -40,6 +41,10 @@ func RegisterRoutes(app *bootstrap.App) {
desktopContentHandler := NewDesktopContentHandler(app)
publishJobHandler := NewPublishJobHandler(app)
protected.POST("/desktop/clients/register", desktopClientHandler.Register)
app.Engine.GET("/api/desktop/releases/latest", desktopReleaseHandler.LatestPublic)
app.Engine.GET("/api/desktop/releases/download-url", desktopReleaseHandler.DownloadURLPublic)
app.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed", desktopReleaseHandler.UpdaterFeedPublic)
app.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", desktopReleaseHandler.UpdaterFeedPublic)
desktopAuth := app.Engine.Group("/api/desktop")
desktopAuth.Use(DesktopClientMiddleware(repository.NewDesktopClientRepository(app.DB)))
@@ -47,6 +52,8 @@ func RegisterRoutes(app *bootstrap.App) {
desktopAuth.POST("/clients/heartbeat", desktopClientHandler.Heartbeat)
desktopAuth.POST("/clients/offline", desktopClientHandler.Offline)
desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke)
desktopAuth.GET("/clients/releases/latest", desktopReleaseHandler.Latest)
desktopAuth.GET("/clients/releases/download-url", desktopReleaseHandler.DownloadURL)
desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch)
desktopAuth.GET("/accounts", desktopAccountHandler.List)
desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article)
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ops.desktop_client_releases;
@@ -0,0 +1,64 @@
CREATE TABLE IF NOT EXISTS ops.desktop_client_releases (
id BIGSERIAL PRIMARY KEY,
platform VARCHAR(32) NOT NULL,
arch VARCHAR(32) NOT NULL DEFAULT 'universal',
channel VARCHAR(32) NOT NULL DEFAULT 'stable',
version VARCHAR(64) NOT NULL,
min_supported_version VARCHAR(64),
download_source VARCHAR(16) NOT NULL DEFAULT 'oss',
oss_object_key TEXT,
custom_download_url TEXT,
file_name TEXT,
file_size_bytes BIGINT,
sha256 VARCHAR(64),
updater_download_source VARCHAR(16),
updater_oss_object_key TEXT,
updater_custom_download_url TEXT,
updater_file_name TEXT,
updater_file_size_bytes BIGINT,
updater_sha256 VARCHAR(64),
release_notes TEXT,
force_update BOOLEAN NOT NULL DEFAULT FALSE,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
published_at TIMESTAMPTZ,
created_by BIGINT,
updated_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ck_desktop_client_release_file_size
CHECK (file_size_bytes IS NULL OR file_size_bytes >= 0),
CONSTRAINT ck_desktop_client_release_sha256
CHECK (sha256 IS NULL OR sha256 ~ '^[0-9a-f]{64}$'),
CONSTRAINT ck_desktop_client_release_updater_file_size
CHECK (updater_file_size_bytes IS NULL OR updater_file_size_bytes >= 0),
CONSTRAINT ck_desktop_client_release_updater_sha256
CHECK (updater_sha256 IS NULL OR updater_sha256 ~ '^[0-9a-f]{64}$'),
CONSTRAINT ck_desktop_client_release_source
CHECK (download_source IN ('oss', 'custom')),
CONSTRAINT ck_desktop_client_release_updater_source
CHECK (updater_download_source IS NULL OR updater_download_source IN ('oss', 'custom')),
CONSTRAINT ck_desktop_client_release_download_target
CHECK (
(download_source = 'oss' AND NULLIF(BTRIM(COALESCE(oss_object_key, '')), '') IS NOT NULL)
OR
(download_source = 'custom' AND NULLIF(BTRIM(COALESCE(custom_download_url, '')), '') IS NOT NULL)
),
CONSTRAINT ck_desktop_client_release_updater_target
CHECK (
updater_download_source IS NULL
OR
(updater_download_source = 'oss' AND NULLIF(BTRIM(COALESCE(updater_oss_object_key, '')), '') IS NOT NULL)
OR
(updater_download_source = 'custom' AND NULLIF(BTRIM(COALESCE(updater_custom_download_url, '')), '') IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_desktop_client_releases_target
ON ops.desktop_client_releases(platform, arch, channel);
CREATE INDEX IF NOT EXISTS idx_desktop_client_releases_lookup
ON ops.desktop_client_releases(channel, platform, arch)
WHERE enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_desktop_client_releases_updated_at
ON ops.desktop_client_releases(updated_at DESC);
@@ -0,0 +1,11 @@
ALTER TABLE ops.desktop_client_releases
DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_target,
DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_source,
DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_sha256,
DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_file_size,
DROP COLUMN IF EXISTS updater_sha256,
DROP COLUMN IF EXISTS updater_file_size_bytes,
DROP COLUMN IF EXISTS updater_file_name,
DROP COLUMN IF EXISTS updater_custom_download_url,
DROP COLUMN IF EXISTS updater_oss_object_key,
DROP COLUMN IF EXISTS updater_download_source;
@@ -0,0 +1,60 @@
ALTER TABLE ops.desktop_client_releases
ADD COLUMN IF NOT EXISTS updater_download_source VARCHAR(16),
ADD COLUMN IF NOT EXISTS updater_oss_object_key TEXT,
ADD COLUMN IF NOT EXISTS updater_custom_download_url TEXT,
ADD COLUMN IF NOT EXISTS updater_file_name TEXT,
ADD COLUMN IF NOT EXISTS updater_file_size_bytes BIGINT,
ADD COLUMN IF NOT EXISTS updater_sha256 VARCHAR(64);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'ck_desktop_client_release_updater_file_size'
AND conrelid = 'ops.desktop_client_releases'::regclass
) THEN
ALTER TABLE ops.desktop_client_releases
ADD CONSTRAINT ck_desktop_client_release_updater_file_size
CHECK (updater_file_size_bytes IS NULL OR updater_file_size_bytes >= 0);
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'ck_desktop_client_release_updater_sha256'
AND conrelid = 'ops.desktop_client_releases'::regclass
) THEN
ALTER TABLE ops.desktop_client_releases
ADD CONSTRAINT ck_desktop_client_release_updater_sha256
CHECK (updater_sha256 IS NULL OR updater_sha256 ~ '^[0-9a-f]{64}$');
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'ck_desktop_client_release_updater_source'
AND conrelid = 'ops.desktop_client_releases'::regclass
) THEN
ALTER TABLE ops.desktop_client_releases
ADD CONSTRAINT ck_desktop_client_release_updater_source
CHECK (updater_download_source IS NULL OR updater_download_source IN ('oss', 'custom'));
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'ck_desktop_client_release_updater_target'
AND conrelid = 'ops.desktop_client_releases'::regclass
) THEN
ALTER TABLE ops.desktop_client_releases
ADD CONSTRAINT ck_desktop_client_release_updater_target
CHECK (
updater_download_source IS NULL
OR
(updater_download_source = 'oss' AND NULLIF(BTRIM(COALESCE(updater_oss_object_key, '')), '') IS NOT NULL)
OR
(updater_download_source = 'custom' AND NULLIF(BTRIM(COALESCE(updater_custom_download_url, '')), '') IS NOT NULL)
);
END IF;
END $$;