feat(fileupload): 并入通用大文件分片上传服务端核心

- 六接口(init/chunk/merge/check/task/abort)+ 分片幂等 + 断点续传 +
  .part 原子改名 + 合并整文件 MD5 校验 + 秒传
- 零宿主耦合:Repository 接口注入存储、Envelope 注入响应封装、
  OnMerged/OnInstantHit 回调衔接业务(仅依赖既有 gin/uuid)
- 自 FileUpload 项目 restful/upload 抽出;maintain-service 云端文件为其首个宿主
This commit is contained in:
2026-09-25 15:11:30 +08:00
parent 40c9f62c9f
commit 33574d4ac2
5 changed files with 711 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
// Package fileupload 通用大文件分片上传(Go/Gin 服务端核心)。
//
// 职责:init/chunk/merge/check/task/abort 六接口的路由挂载与业务逻辑——
// 分片幂等、断点续传、.part 原子改名、合并时整文件 MD5 校验、秒传。
// 宿主差异全部通过 Options 与 Repository 注入,本包不依赖任何宿主的
// 响应封装、ORM 生成代码或业务表:
//
// fileupload.Mount(api.Group("/upload"), opts, repo)
//
// 与业务层的衔接:
// - merge 成功 → OnMerged(TaskInfo)(宿主写自己的业务表/云盘记录);
// 回调失败则 merge 整体失败、任务置 error
// - 秒传命中 → OnInstantHit(TaskInfo)(宿主确保业务侧可见行,可空回调)
// - 响应封装 → Options.Envelope(必填,如 httpx.HandleResult / gin.H{code:200,...})
package fileupload
import (
"errors"
"math"
"path"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// TaskInfo 上传任务的库级视图(与宿主的表结构解耦)。
type TaskInfo struct {
Id int64
UploadId string
FileName string
FileSize int64
FileMd5 string
MimeType string
ChunkSize int64
TotalChunk int32
Status string // uploading / merging / done / error
StoragePath string // 相对路径(如 cloud/xxx),由宿主的目录约定决定
Operator string
TenantId int64
}
// ChunkInfo 分片记录的库级视图。
type ChunkInfo struct {
UploadId string
ChunkIndex int32
ChunkSize int64
ChunkMd5 string
}
// Status 常量。
const (
StatusUploading = "uploading"
StatusMerging = "merging"
StatusDone = "done"
StatusError = "error"
)
// DefaultMaxChunkBytes 单分片硬上限(防滥用,与常见 multipart 内存缓冲同量级)。
const DefaultMaxChunkBytes int64 = 64 << 20
// Envelope 宿主响应封装:resp 非 nil 时写成功响应,err 非 nil 时写失败响应。
type Envelope func(c *gin.Context, resp any, err error)
// Options 宿主注入配置。
type Options struct {
// ChunkRoot 分片临时根目录;库在其下建 <uploadId>/ 子目录。
// 建议置于宿主静态根之外,避免分片被静态服务暴露。
ChunkRoot string
// CloudDir 合并产物目录(宿主的云盘/文件目录约定)。
CloudDir string
// WebPathPrefix storagePath 的 Web 相对前缀(如 "cloud"),空则无前缀。
// 最终 storagePath = WebPathPrefix + "/" + 产物名(存库、回调、BS 拼接均用该值),
// 需与 CloudDir 相对宿主静态根的子目录一致。
WebPathPrefix string
// URLPrefix 拼接 fileUrl 的静态前缀,默认 "/uploads";
// 最终 fileUrl = URLPrefix + "/" + storagePath。
URLPrefix string
// MaxTotalSizeMB 文件总大小上限(MB),0=不限制。
MaxTotalSizeMB int64
// MaxChunkBytes 单分片字节上限,0=DefaultMaxChunkBytes(64MB)。
MaxChunkBytes int64
// OnMerged 合并成功回调(宿主写业务表等)。返回 err 则 merge 失败、任务置 error。
OnMerged func(t TaskInfo) error
// OnInstantHit 秒传命中回调(宿主确保业务侧可见行)。可空。
OnInstantHit func(t TaskInfo) error
// Envelope 响应封装(必填)。
Envelope Envelope
}
func (o *Options) validate() error {
if o.ChunkRoot == "" || o.CloudDir == "" {
return errors.New("fileupload: Options.ChunkRoot / CloudDir 必填")
}
if o.Envelope == nil {
return errors.New("fileupload: Options.Envelope 必填")
}
if o.MaxChunkBytes <= 0 {
o.MaxChunkBytes = DefaultMaxChunkBytes
}
if o.URLPrefix == "" {
o.URLPrefix = "/uploads"
}
return nil
}
func (o *Options) maxChunkBytes() int64 {
if o.MaxChunkBytes > 0 {
return o.MaxChunkBytes
}
return DefaultMaxChunkBytes
}
// expectChunks 期望分片数:ceil(fileSize/chunkSize),空文件视为 1 个空分片。
func expectChunks(fileSize, chunkSize int64) int {
if chunkSize <= 0 {
return 0
}
n := int(math.Ceil(float64(fileSize) / float64(chunkSize)))
if n == 0 {
n = 1
}
return n
}
// sanitizeFileName 取路径基名并剥离路径穿越/非法字符。
func sanitizeFileName(name string) string {
name = path.Base(strings.TrimSpace(name))
name = strings.ReplaceAll(name, "\\", "")
if name == "." || name == ".." || name == "/" || name == "" {
return ""
}
return name
}
// newUploadID 生成上传任务 ID(uuid v4,URL 安全)。
func newUploadID() string { return uuid.NewString() }