diff --git a/admin/package.json b/admin/package.json index c71df21..540d519 100644 --- a/admin/package.json +++ b/admin/package.json @@ -33,6 +33,7 @@ "pinia": "^3.0.3", "query-string": "^9.1.1", "rolldown-vite": "^7.0.9", + "spark-md5": "^3.0.2", "vform3-builds": "^3.0.10", "vue": "^3.5.16", "vue-clipboard3": "^2.0.0", diff --git a/admin/src/utils/FileUploader.ts b/admin/src/utils/FileUploader.ts new file mode 100644 index 0000000..e5981f7 --- /dev/null +++ b/admin/src/utils/FileUploader.ts @@ -0,0 +1,191 @@ +import SparkMD5 from 'spark-md5' +import axios from 'axios' + +export interface FileUploaderOptions { + chunkSize?: number + fileName?: string + onSuccess?: (filePath: string) => void + onError?: (error: Error) => void +} + +export default class FileUploader { + file: File + fileMd5: string + fileName: string + chunkSize: number = 1024 * 1024 // 1MB + chunkCount: number = 0 + onSuccess: FileUploaderOptions['onSuccess'] = () => {} + onError: FileUploaderOptions['onError'] = () => {} + onUploadProgress(chunkIndex, percent) { + console.log(`当前分片: ${chunkIndex},进度: ${percent}%`) + } + + constructor(file: File, options: FileUploaderOptions) { + this.file = file + this.fileName = file.name + + if (options?.chunkSize) { + this.chunkSize = options.chunkSize + } + this.chunkCount = Math.ceil(this.file.size / this.chunkSize) + if (options?.fileName) { + this.fileName = options.fileName + } + if (options?.onSuccess) { + this.onSuccess = options.onSuccess + } + if (options?.onError) { + this.onError = options.onError + } + } + + readerFile(file: File): Promise { + return new Promise((resolve, reject) => { + if (!file) { + return reject(new Error('文件不存在')) + } + const reader = new FileReader() + reader.onload = (e) => { + resolve(e.target?.result as ArrayBuffer) + } + reader.onerror = (e) => { + reject(e) + } + reader.readAsArrayBuffer(file) + }) + } + // 开始/恢复上传 + async start() { + try { + const arrayBuffer = await this.readerFile(this.file) + this.fileMd5 = this.getMd5(arrayBuffer) + const isExistFilePath = await this.checkFileExist() + if (isExistFilePath) { + this.complete(isExistFilePath) + return + } + this.splitChunks() + } catch (error) { + this.onError(error) + } + } + + // 上传完成 + complete(filePath) { + /* 合并分片 */ + console.log('complete:', filePath) + this.onSuccess(filePath) + } + // 检查上传状态 + getMd5(arrayBuffer: ArrayBuffer): string { + const spark = new SparkMD5.ArrayBuffer() + spark.append(arrayBuffer) + return spark.end() + } + // 检查文件是否存在,可实现秒传 + async checkFileExist(): Promise { + try { + /* 检查文件是否存在 */ + const res = await axios.get('/api/admin/common/uploadChunk/CheckFileExist', { + params: { + fileMd5: this.fileMd5, + fileName: this.file.name + } + }) + console.log('Init', res) + + if (res.data.code === 200 && res.data.data) { + return res.data.data + } + return '' + } catch (error) { + return '' + } + } + async splitChunks() { + for (let index = 0; index < this.chunkCount; index++) { + const chunkStart = index * this.chunkSize + const chunkEnd = Math.min(chunkStart + this.chunkSize, this.file.size) + const chunk = this.file.slice(chunkStart, chunkEnd) + await this.uploadChunk(this.fileMd5, index, chunk) + } + await this.mergeChunk() + } + async uploadChunk(fileMd5: string, index: number, chunk: Blob) { + try { + const checkResult = await axios.get('/api/admin/common/uploadChunk/CheckChunkExist', { + params: { + index, + fileMd5 + } + }) + console.log('checkResult', checkResult) + + if (checkResult.data.code === 200) { + console.log(`分片 ${index + 1}/${this.chunkCount} 已存在`) + return + } else if (checkResult.data.code === 500) { + const formData = new FormData() + formData.append('chunk', chunk) + formData.append('index', String(index)) + formData.append('fileMd5', fileMd5) + const result = await axios.post( + '/api/admin/common/uploadChunk/UploadChunk', + formData, + { + onUploadProgress: (progressEvent) => { + const percentCompleted = Math.round( + (progressEvent.loaded * 100) / progressEvent.total + ) + this.onUploadProgress(index, percentCompleted) + } + } + ) + console.log('result', result) + + if (result.data.code === 200) { + console.log(`分片 ${index + 1}/${this.chunkCount} 上传成功`) + } else { + console.error(`分片 ${index + 1}/${this.chunkCount} 上传失败: ${result}`) + // break + } + } + } catch (error) { + console.error(`分片 ${index + 1}/${this.chunkCount} 上传失败: ${error}`) + this.onError(error) + } + } + async mergeChunk() { + try { + const res = await axios.post('/api/admin/common/uploadChunk/MergeChunk', { + fileMd5: this.fileMd5, + fileName: this.file.name, + chunkCount: this.chunkCount + }) + if (res.data.code === 200) { + console.log('合并分片成功') + this.complete(res.data.data) + } else { + console.log('MergeChunk', res) + this.onError(new Error('合并分片失败')) + } + } catch (error) { + console.error(`合并分片失败: ${error}`) + this.onError(error) + } + } +} + +/** + * const uploader = new FileUploader(file, { + endpoint: "/api/upload", + concurrency: 4, + onProgress: (percent, chunkIndex) => { + console.log(`进度: ${percent}%,当前分片: ${chunkIndex}`); + }, + onComplete: (fileUrl) => { + console.log("文件地址:", fileUrl); + } +}); + + */ diff --git a/admin/src/views/error/uploadChunk.vue b/admin/src/views/error/uploadChunk.vue new file mode 100644 index 0000000..cc9a162 --- /dev/null +++ b/admin/src/views/error/uploadChunk.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/server/controller/admin_ctl/commonController/uploadChunk.go b/server/controller/admin_ctl/commonController/uploadChunk.go new file mode 100644 index 0000000..112653f --- /dev/null +++ b/server/controller/admin_ctl/commonController/uploadChunk.go @@ -0,0 +1,97 @@ +package commonController + +import ( + "fmt" + "net/url" + "os" + "regexp" + "x_admin/core/response" + "x_admin/service/commonService" + + "github.com/gin-gonic/gin" +) + +func UploadChunkRoute(rg *gin.RouterGroup) { + handle := uploadChunkHandler{ + uploadPath: "./uploads", + tmpPath: "./tmp", + } + os.MkdirAll(handle.uploadPath, 0755) + os.MkdirAll(handle.tmpPath, 0755) + + rg = rg.Group("/common") + rg.GET("/uploadChunk/CheckFileExist", handle.CheckFileExist) + rg.GET("/uploadChunk/CheckChunkExist", handle.CheckChunkExist) + rg.POST("/uploadChunk/UploadChunk", handle.UploadChunk) + rg.POST("/uploadChunk/MergeChunk", handle.MergeChunk) +} + +type uploadChunkHandler struct { + uploadPath string + tmpPath string +} + +func (uh uploadChunkHandler) CheckFileExist(c *gin.Context) { + var fileMd5 = c.Query("fileMd5") + var fileName = url.QueryEscape(c.Query("fileName")) + // 正则检查MD5 + reg := regexp.MustCompile(`^[a-fA-F0-9]{32}$`) + if !reg.MatchString(fileMd5) { + response.FailWithMsg(c, response.SystemError, "MD5格式错误") + return + } + var filePath = fmt.Sprintf("%s/%s_%s", uh.uploadPath, fileMd5, fileName) + // 检查文件是否存在 + if commonService.UploadChunkService.CheckFileExist(filePath) { + response.OkWithData(c, filePath) + return + } + response.OkWithData(c, nil) +} + +// 检查chunk是否存在 +func (uh uploadChunkHandler) CheckChunkExist(c *gin.Context) { + fileMd5 := c.Query("fileMd5") // 上传文件的md5 + index := c.Query("index") // 分片序号 + chunkPath := fmt.Sprintf("%s/%s/%s", uh.tmpPath, fileMd5, index) + if commonService.UploadChunkService.CheckFileExist(chunkPath) { + response.Ok(c) + return + } + response.FailWithMsg(c, response.SystemError, "分片不存在") +} + +// UploadChunk 上传分片 +func (uh uploadChunkHandler) UploadChunk(c *gin.Context) { + chunk, _ := c.FormFile("chunk") // 分片文件 + index := c.PostForm("index") // 分片序号 + fileMd5 := c.PostForm("fileMd5") // 上传文件的md5 + + chunkDir := fmt.Sprintf("%s/%s", uh.tmpPath, fileMd5) + chunkPath := fmt.Sprintf("%s/%s", chunkDir, index) + err := commonService.UploadChunkService.UploadChunk(chunkDir, chunkPath, chunk) + if err != nil { + response.FailWithMsg(c, response.SystemError, err.Error()) + return + } + response.Ok(c) +} +func (uh uploadChunkHandler) MergeChunk(c *gin.Context) { + var MergeChunk struct { + FileMd5 string `json:"fileMd5"` // 上传文件的md5 + FileName string `json:"fileName"` // 文件名 + ChunkCount int `json:"chunkCount"` // 分片数量 + } + bindErr := c.ShouldBindJSON(&MergeChunk) + if bindErr != nil { + response.FailWithMsg(c, response.SystemError, bindErr.Error()) + return + } + var filePath = fmt.Sprintf("%s/%s_%s", uh.uploadPath, MergeChunk.FileMd5, url.QueryEscape(MergeChunk.FileName)) + err := commonService.UploadChunkService.MergeChunk(MergeChunk.FileMd5, filePath, MergeChunk.ChunkCount) + if err != nil { + response.FailWithMsg(c, response.SystemError, err.Error()) + return + } + response.OkWithData(c, filePath) +} diff --git a/server/router/adminRoute/route.go b/server/router/adminRoute/route.go index 7494480..33e2de7 100644 --- a/server/router/adminRoute/route.go +++ b/server/router/adminRoute/route.go @@ -16,6 +16,7 @@ func RegisterRoute(rg *gin.RouterGroup) { // 所有子路由需要加上前缀 /api/admin commonController.UploadRoute(rg) + commonController.UploadChunkRoute(rg) commonController.AlbumRoute(rg) commonController.IndexRoute(rg) diff --git a/server/service/commonService/uploadChunkService.go b/server/service/commonService/uploadChunkService.go new file mode 100644 index 0000000..fbb88ea --- /dev/null +++ b/server/service/commonService/uploadChunkService.go @@ -0,0 +1,72 @@ +package commonService + +import ( + "errors" + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + "x_admin/util" +) + +var UploadChunkService = NewUploadChunkService() + +// NewUploadService 初始化 +func NewUploadChunkService() *uploadChunkService { + return &uploadChunkService{} +} + +// uploadService 上传服务实现类 +type uploadChunkService struct{} + +// CheckFileExist 检查文件是否存在 +func (upSrv uploadChunkService) CheckFileExist(filePath string) bool { + return util.ToolsUtil.IsFileExist(filePath) +} + +func (upSrv uploadChunkService) UploadChunk(chunkDir string, chunkPath string, chunk *multipart.FileHeader) error { + os.MkdirAll(chunkDir, 0755) + + chunkData, err := chunk.Open() + if err != nil { + return err + } + defer chunkData.Close() + + chunkBytes, err := io.ReadAll(chunkData) + if err != nil { + return err + } + return os.WriteFile(chunkPath, chunkBytes, 0644) +} +func (upSrv uploadChunkService) MergeChunk(fileMd5, filePath string, chunkCount int) error { + + chunkDir := fmt.Sprintf("./tmp/%s", fileMd5) + + chunks, err := os.ReadDir(chunkDir) + if err != nil { + return errors.New("分片不存在") + } + // 判断切片数量是否一致 + if len(chunks) != chunkCount { + return errors.New("分片未全部上传,请重试") + } + mergedFile, err := os.Create(filePath) + if err != nil { + return err + } + defer mergedFile.Close() + for index := range chunkCount { + chunkData, err := os.ReadFile(filepath.Join(chunkDir, fmt.Sprintf("%d", index))) + if err != nil { + return err + } + + _, err = mergedFile.Write(chunkData) + if err != nil { + return err + } + } + return os.RemoveAll(chunkDir) // 清理临时分片 +} diff --git a/server/util/tools.go b/server/util/tools.go index b57611f..5206014 100644 --- a/server/util/tools.go +++ b/server/util/tools.go @@ -92,6 +92,24 @@ func (tu toolsUtil) ObjToJson(data interface{}) (res string, err error) { // IsFileExist 判断文件或目录是否存在 func (tu toolsUtil) IsFileExist(path string) bool { - _, err := os.Stat(path) + var root, err = os.OpenRoot(".") + if err != nil { + return false + } + defer root.Close() + _, err = root.Stat(path) return err == nil || os.IsExist(err) } + +// 创建文件夹 +func (tu toolsUtil) CreateDir(path string) error { + var root, err = os.OpenRoot(".") + if err != nil { + return err + } + defer root.Close() + return root.Mkdir(path, 0755) +} +func (tu toolsUtil) WriteFile(path string, data []byte) error { + return os.WriteFile(path, data, 0644) +}