Files
geo/server/internal/ops/transport/desktop_client_release_handler.go
T
root 62d0b22988
Desktop Client Build / Resolve Build Metadata (push) Successful in 52s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Successful in 16m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m22s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 31s
feat: Implement direct upload functionality for desktop client releases
- Added new endpoints for initiating and completing direct uploads of desktop client packages.
- Introduced request and response structures for direct upload operations.
- Enhanced the upload process to support SHA256 and Content-MD5 validation.
- Updated the frontend to reflect changes in upload button states and progress indicators.
- Modified object storage clients to support presigned PUT URLs with content type and MD5 headers.
- Added tests for direct upload functionality and ensured existing upload processes remain intact.
2026-06-20 12:44:28 +08:00

485 lines
16 KiB
Go

package transport
import (
"fmt"
"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"`
}
type desktopClientReleaseUploadInitRequest struct {
Platform string `json:"platform" binding:"required"`
Arch string `json:"arch"`
Channel string `json:"channel"`
Version string `json:"version" binding:"required"`
Kind string `json:"kind"`
FileName string `json:"file_name" binding:"required"`
FileSizeBytes int64 `json:"file_size_bytes" binding:"required"`
ChunkSizeBytes int64 `json:"chunk_size_bytes"`
}
type desktopClientReleaseDirectUploadInitRequest struct {
Platform string `json:"platform" binding:"required"`
Arch string `json:"arch"`
Channel string `json:"channel"`
Version string `json:"version" binding:"required"`
Kind string `json:"kind"`
FileName string `json:"file_name" binding:"required"`
FileSizeBytes int64 `json:"file_size_bytes" binding:"required"`
SHA256 string `json:"sha256" binding:"required"`
ContentMD5 string `json:"content_md5"`
}
type desktopClientReleaseDirectUploadCompleteRequest struct {
Platform string `json:"platform" binding:"required"`
Arch string `json:"arch"`
Channel string `json:"channel"`
Version string `json:"version" binding:"required"`
Kind string `json:"kind"`
OSSObjectKey string `json:"oss_object_key" binding:"required"`
FileName string `json:"file_name" binding:"required"`
FileSizeBytes int64 `json:"file_size_bytes" binding:"required"`
SHA256 string `json:"sha256" binding:"required"`
}
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) {
if c.Request.ContentLength > app.MaxDesktopClientReleaseMultipartBytes() {
response.Error(c, response.ErrBadRequest(40072, "release_file_too_large", "客户端安装包不能超过 1GB"))
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, app.MaxDesktopClientReleaseMultipartBytes())
fileHeader, err := c.FormFile("file")
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
response.Error(c, response.ErrBadRequest(40072, "release_file_too_large", "客户端安装包不能超过 1GB"))
return
}
response.Error(c, response.ErrBadRequest(40071, "release_file_required", "请选择要上传的客户端安装包"))
return
}
if c.Request.MultipartForm != nil {
defer c.Request.MultipartForm.RemoveAll()
}
if fileHeader.Size > app.MaxDesktopClientReleaseUploadBytes {
response.Error(c, response.ErrBadRequest(40072, "release_file_too_large", "客户端安装包不能超过 1GB"))
return
}
file, err := fileHeader.Open()
if err != nil {
response.Error(c, response.ErrBadRequest(40071, "release_file_open_failed", "客户端安装包读取失败"))
return
}
defer file.Close()
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,
ContentReader: file,
ContentSizeBytes: fileHeader.Size,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func initiateDesktopClientReleaseDirectUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
return func(c *gin.Context) {
var body desktopClientReleaseDirectUploadInitRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
result, err := svc.InitiateDirectUpload(c.Request.Context(), app.DesktopClientReleaseDirectUploadInitInput{
Platform: body.Platform,
Arch: body.Arch,
Channel: body.Channel,
Version: body.Version,
Kind: body.Kind,
FileName: body.FileName,
FileSizeBytes: body.FileSizeBytes,
SHA256: body.SHA256,
ContentMD5: body.ContentMD5,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func completeDesktopClientReleaseDirectUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
return func(c *gin.Context) {
var body desktopClientReleaseDirectUploadCompleteRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
result, err := svc.CompleteDirectUpload(c.Request.Context(), app.DesktopClientReleaseDirectUploadCompleteInput{
Platform: body.Platform,
Arch: body.Arch,
Channel: body.Channel,
Version: body.Version,
Kind: body.Kind,
OSSObjectKey: body.OSSObjectKey,
FileName: body.FileName,
FileSizeBytes: body.FileSizeBytes,
SHA256: body.SHA256,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func initiateDesktopClientReleaseUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
return func(c *gin.Context) {
var body desktopClientReleaseUploadInitRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
result, err := svc.InitiateChunkedUpload(c.Request.Context(), app.DesktopClientReleaseChunkedUploadInitInput{
Platform: body.Platform,
Arch: body.Arch,
Channel: body.Channel,
Version: body.Version,
Kind: body.Kind,
FileName: body.FileName,
FileSizeBytes: body.FileSizeBytes,
ChunkSizeBytes: body.ChunkSizeBytes,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func uploadDesktopClientReleaseChunkHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.ContentLength > app.MaxDesktopClientReleaseChunkMultipartBytes() {
response.Error(c, response.ErrBadRequest(40076, "invalid_release_upload_chunk", "上传分片不能超过 64MB"))
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, app.MaxDesktopClientReleaseChunkMultipartBytes())
chunkIndex, err := strconv.Atoi(c.Param("chunk_index"))
if err != nil {
response.Error(c, response.ErrBadRequest(40076, "invalid_release_upload_chunk", "上传分片序号无效"))
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
response.Error(c, response.ErrBadRequest(40076, "invalid_release_upload_chunk", "上传分片不能超过 64MB"))
return
}
response.Error(c, response.ErrBadRequest(40071, "release_file_required", "请选择要上传的客户端安装包"))
return
}
if c.Request.MultipartForm != nil {
defer c.Request.MultipartForm.RemoveAll()
}
if fileHeader.Size > app.MaxDesktopClientReleaseChunkSizeBytes {
response.Error(c, response.ErrBadRequest(40076, "invalid_release_upload_chunk", "上传分片不能超过 64MB"))
return
}
file, err := fileHeader.Open()
if err != nil {
response.Error(c, response.ErrBadRequest(40071, "release_file_open_failed", "客户端安装包读取失败"))
return
}
defer file.Close()
result, err := svc.UploadChunk(c.Request.Context(), app.DesktopClientReleaseChunkUploadInput{
UploadID: c.Param("upload_id"),
ChunkIndex: chunkIndex,
ContentReader: file,
ContentSizeBytes: fileHeader.Size,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func completeDesktopClientReleaseUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
return func(c *gin.Context) {
result, err := svc.CompleteChunkedUpload(c.Request.Context(), c.Param("upload_id"))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
}
func abortDesktopClientReleaseUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.AbortChunkedUpload(c.Param("upload_id")); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"upload_id": c.Param("upload_id")})
}
}
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)
}