From 65574e376277d67c0c1b3fc7c2f069c7f1de3485 Mon Sep 17 00:00:00 2001 From: whm <973418690@qq.com> Date: Tue, 14 Apr 2026 09:30:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(upload):=20=E5=88=86=E7=89=87=E7=94=A8=20mu?= =?UTF-8?q?ltipart=20=E5=AD=97=E6=AE=B5=20chunk=E3=80=81=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E4=B8=8E=E4=B8=B2=E8=A1=8C=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端 FormData+chunk,避免 raw body 被中间层断连 - Gin 分片路由置于 POST .../assets 之前 - 分片并发降为 1 Made-with: Cursor --- admin/src/api/admin.js | 9 ++++-- admin/src/utils/siteAssetResumableUpload.js | 3 +- server/handlers/multipart_upload.go | 35 +++++++++++++++++++-- server/main.go | 4 +-- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/admin/src/api/admin.js b/admin/src/api/admin.js index 6cc6eb2..026d8f1 100644 --- a/admin/src/api/admin.js +++ b/admin/src/api/admin.js @@ -103,11 +103,14 @@ export const initMultipartUpload = (siteId, body) => export const getMultipartUploadStatus = (siteId, uploadId) => request.get(`/admin/sites/${siteId}/assets/multipart/${uploadId}/status`, { timeout: 60000 }) -export const putMultipartChunk = (siteId, uploadId, chunkIndex, blob) => - request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/chunk/${chunkIndex}`, blob, { - headers: { 'Content-Type': 'application/octet-stream' }, +export const putMultipartChunk = (siteId, uploadId, chunkIndex, blob) => { + const fd = new FormData() + fd.append('chunk', blob, 'part.bin') + // 不传 Content-Type,由浏览器带 boundary;与整文件 multipart 一致,减少中间层断连 + return request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/chunk/${chunkIndex}`, fd, { timeout: 180000 }) +} export const completeMultipartUpload = (siteId, uploadId) => request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/complete`, {}, { timeout: 600000 }) diff --git a/admin/src/utils/siteAssetResumableUpload.js b/admin/src/utils/siteAssetResumableUpload.js index 3612244..7b37e26 100644 --- a/admin/src/utils/siteAssetResumableUpload.js +++ b/admin/src/utils/siteAssetResumableUpload.js @@ -9,7 +9,8 @@ import { const CHUNK_THRESHOLD = 8 * 1024 * 1024 const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024 -const UPLOAD_CONCURRENCY = 3 +// 串行上传分片,避免 HTTP/2 多路复用 + 大 body 在部分反代上不稳定 +const UPLOAD_CONCURRENCY = 1 function fileFingerprint(file) { return `${file.name}\t${file.size}\t${file.lastModified}` diff --git a/server/handlers/multipart_upload.go b/server/handlers/multipart_upload.go index beb9d62..24450e0 100644 --- a/server/handlers/multipart_upload.go +++ b/server/handlers/multipart_upload.go @@ -215,7 +215,7 @@ func MultipartUploadStatus(c *gin.Context) { }) } -// PutMultipartChunk 上传单个分片(二进制 body,长度须与分片大小一致)。路由同时注册 POST 与 PUT,建议客户端用 POST。 +// PutMultipartChunk 上传单个分片。支持 multipart 字段 chunk(推荐)或 application/octet-stream 原始 body;路由同时注册 POST 与 PUT。 func PutMultipartChunk(c *gin.Context) { siteID := c.Param("site_id") uploadID := c.Param("upload_id") @@ -252,7 +252,38 @@ func PutMultipartChunk(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时文件失败"}) return } - n, err := io.Copy(f, io.LimitReader(c.Request.Body, expected+1)) + + ct := strings.ToLower(c.GetHeader("Content-Type")) + var src io.Reader + if strings.HasPrefix(ct, "multipart/form-data") { + // 与整文件上传一致走 multipart,避免部分网关对 raw POST body 断连 + fh, err := c.FormFile("chunk") + if err != nil { + _ = f.Close() + _ = os.Remove(tmp) + c.JSON(http.StatusBadRequest, gin.H{"error": "请使用表单字段 chunk 上传分片"}) + return + } + if fh.Size > 0 && fh.Size != expected { + _ = f.Close() + _ = os.Remove(tmp) + c.JSON(http.StatusBadRequest, gin.H{"error": "分片大小不符"}) + return + } + part, err := fh.Open() + if err != nil { + _ = f.Close() + _ = os.Remove(tmp) + c.JSON(http.StatusBadRequest, gin.H{"error": "打开分片失败"}) + return + } + defer part.Close() + src = part + } else { + src = c.Request.Body + } + + n, err := io.Copy(f, io.LimitReader(src, expected+1)) _ = f.Close() if err != nil { _ = os.Remove(tmp) diff --git a/server/main.go b/server/main.go index 26ac940..7c31670 100644 --- a/server/main.go +++ b/server/main.go @@ -171,14 +171,14 @@ func main() { admin.PUT("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.UpdateHomepage) admin.GET("/sites/:site_id/assets/downloadable", handlers.RequirePermission(models.PermHomepageEdit), handlers.ListDownloadableAssets) admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets) - admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset) + // 分片路由须在 POST .../assets 整文件上传之前注册,避免被更泛的路由误匹配 admin.POST("/sites/:site_id/assets/init-multipart", handlers.RequirePermission(models.PermModuleUpload), handlers.InitMultipartUpload) admin.GET("/sites/:site_id/assets/multipart/:upload_id/status", handlers.RequirePermission(models.PermModuleUpload), handlers.MultipartUploadStatus) - // 分片用 POST:部分反向代理对 PUT + 大 body 会断连,浏览器表现为 Network Error admin.POST("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk) admin.PUT("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk) admin.POST("/sites/:site_id/assets/multipart/:upload_id/complete", handlers.RequirePermission(models.PermModuleUpload), handlers.CompleteMultipartUpload) admin.DELETE("/sites/:site_id/assets/multipart/:upload_id", handlers.RequirePermission(models.PermModuleUpload), handlers.AbortMultipartUpload) + admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset) admin.POST("/sites/:site_id/folders", handlers.RequirePermission(models.PermModuleUpload), handlers.CreateSiteFolder) admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset) admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)