fix(upload): 分片用 multipart 字段 chunk、路由顺序与串行上传
- 前端 FormData+chunk,避免 raw body 被中间层断连 - Gin 分片路由置于 POST .../assets 之前 - 分片并发降为 1 Made-with: Cursor
This commit is contained in:
@@ -103,11 +103,14 @@ export const initMultipartUpload = (siteId, body) =>
|
|||||||
export const getMultipartUploadStatus = (siteId, uploadId) =>
|
export const getMultipartUploadStatus = (siteId, uploadId) =>
|
||||||
request.get(`/admin/sites/${siteId}/assets/multipart/${uploadId}/status`, { timeout: 60000 })
|
request.get(`/admin/sites/${siteId}/assets/multipart/${uploadId}/status`, { timeout: 60000 })
|
||||||
|
|
||||||
export const putMultipartChunk = (siteId, uploadId, chunkIndex, blob) =>
|
export const putMultipartChunk = (siteId, uploadId, chunkIndex, blob) => {
|
||||||
request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/chunk/${chunkIndex}`, blob, {
|
const fd = new FormData()
|
||||||
headers: { 'Content-Type': 'application/octet-stream' },
|
fd.append('chunk', blob, 'part.bin')
|
||||||
|
// 不传 Content-Type,由浏览器带 boundary;与整文件 multipart 一致,减少中间层断连
|
||||||
|
return request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/chunk/${chunkIndex}`, fd, {
|
||||||
timeout: 180000
|
timeout: 180000
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const completeMultipartUpload = (siteId, uploadId) =>
|
export const completeMultipartUpload = (siteId, uploadId) =>
|
||||||
request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/complete`, {}, { timeout: 600000 })
|
request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/complete`, {}, { timeout: 600000 })
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
|
|
||||||
const CHUNK_THRESHOLD = 8 * 1024 * 1024
|
const CHUNK_THRESHOLD = 8 * 1024 * 1024
|
||||||
const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
|
const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
|
||||||
const UPLOAD_CONCURRENCY = 3
|
// 串行上传分片,避免 HTTP/2 多路复用 + 大 body 在部分反代上不稳定
|
||||||
|
const UPLOAD_CONCURRENCY = 1
|
||||||
|
|
||||||
function fileFingerprint(file) {
|
function fileFingerprint(file) {
|
||||||
return `${file.name}\t${file.size}\t${file.lastModified}`
|
return `${file.name}\t${file.size}\t${file.lastModified}`
|
||||||
|
|||||||
@@ -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) {
|
func PutMultipartChunk(c *gin.Context) {
|
||||||
siteID := c.Param("site_id")
|
siteID := c.Param("site_id")
|
||||||
uploadID := c.Param("upload_id")
|
uploadID := c.Param("upload_id")
|
||||||
@@ -252,7 +252,38 @@ func PutMultipartChunk(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时文件失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时文件失败"})
|
||||||
return
|
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()
|
_ = f.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(tmp)
|
_ = os.Remove(tmp)
|
||||||
|
|||||||
@@ -171,14 +171,14 @@ func main() {
|
|||||||
admin.PUT("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.UpdateHomepage)
|
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/downloadable", handlers.RequirePermission(models.PermHomepageEdit), handlers.ListDownloadableAssets)
|
||||||
admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets)
|
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.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)
|
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.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.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.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.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.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.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
|
||||||
admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)
|
admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)
|
||||||
|
|||||||
Reference in New Issue
Block a user