mirror of
https://gitee.com/xiangheng/x_admin.git
synced 2025-10-20 14:55:18 +08:00
文件分片上传
This commit is contained in:
@@ -33,6 +33,7 @@
|
|||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"query-string": "^9.1.1",
|
"query-string": "^9.1.1",
|
||||||
"rolldown-vite": "^7.0.9",
|
"rolldown-vite": "^7.0.9",
|
||||||
|
"spark-md5": "^3.0.2",
|
||||||
"vform3-builds": "^3.0.10",
|
"vform3-builds": "^3.0.10",
|
||||||
"vue": "^3.5.16",
|
"vue": "^3.5.16",
|
||||||
"vue-clipboard3": "^2.0.0",
|
"vue-clipboard3": "^2.0.0",
|
||||||
|
191
admin/src/utils/FileUploader.ts
Normal file
191
admin/src/utils/FileUploader.ts
Normal file
@@ -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<ArrayBuffer> {
|
||||||
|
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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
*/
|
42
admin/src/views/error/uploadChunk.vue
Normal file
42
admin/src/views/error/uploadChunk.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<input type="file" ref="fileInput" @change="handleChange" />
|
||||||
|
<el-button @click="btn">上传</el-button>
|
||||||
|
<el-button @click="merge">合并</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import FileUploader from '@/utils/FileUploader'
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
let fileUploader: FileUploader | null = null
|
||||||
|
function handleChange(e) {
|
||||||
|
const files = (e.target as HTMLInputElement).files
|
||||||
|
// console.log('e.target', e.target)
|
||||||
|
console.log('files', files)
|
||||||
|
if (files) {
|
||||||
|
fileUploader = new FileUploader(files[0], { chunkSize: 1024 * 1024 * 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function btn() {
|
||||||
|
fileUploader.start()
|
||||||
|
}
|
||||||
|
function merge() {
|
||||||
|
fileUploader.mergeChunk()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
button {
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgb(150, 149, 149);
|
||||||
|
border: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
97
server/controller/admin_ctl/commonController/uploadChunk.go
Normal file
97
server/controller/admin_ctl/commonController/uploadChunk.go
Normal file
@@ -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)
|
||||||
|
}
|
@@ -16,6 +16,7 @@ func RegisterRoute(rg *gin.RouterGroup) {
|
|||||||
// 所有子路由需要加上前缀 /api/admin
|
// 所有子路由需要加上前缀 /api/admin
|
||||||
|
|
||||||
commonController.UploadRoute(rg)
|
commonController.UploadRoute(rg)
|
||||||
|
commonController.UploadChunkRoute(rg)
|
||||||
commonController.AlbumRoute(rg)
|
commonController.AlbumRoute(rg)
|
||||||
commonController.IndexRoute(rg)
|
commonController.IndexRoute(rg)
|
||||||
|
|
||||||
|
72
server/service/commonService/uploadChunkService.go
Normal file
72
server/service/commonService/uploadChunkService.go
Normal file
@@ -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) // 清理临时分片
|
||||||
|
}
|
@@ -92,6 +92,24 @@ func (tu toolsUtil) ObjToJson(data interface{}) (res string, err error) {
|
|||||||
|
|
||||||
// IsFileExist 判断文件或目录是否存在
|
// IsFileExist 判断文件或目录是否存在
|
||||||
func (tu toolsUtil) IsFileExist(path string) bool {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user