文件分片上传

This commit is contained in:
xh
2025-07-20 01:49:06 +08:00
parent 5f15d0eb81
commit e787c5cb01
7 changed files with 423 additions and 1 deletions

View File

@@ -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",

View 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);
}
});
*/

View 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>

View 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)
}

View File

@@ -16,6 +16,7 @@ func RegisterRoute(rg *gin.RouterGroup) {
// 所有子路由需要加上前缀 /api/admin
commonController.UploadRoute(rg)
commonController.UploadChunkRoute(rg)
commonController.AlbumRoute(rg)
commonController.IndexRoute(rg)

View 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) // 清理临时分片
}

View File

@@ -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)
}