后端
package mainimport ("encoding/json""fmt""log""net/http""os""path/filepath""github.com/gin-contrib/cors""github.com/gin-gonic/gin"
)// 前端传来的文件元数据
type FileMetaRequest struct {FileName string `json:"fileName" binding:"required"`FileSize int64 `json:"fileSize" binding:"required"`FileType string `json:"fileType" binding:"required"`FileMD5 string `json:"fileMD5" binding:"required"`
}// 返回给前端的响应结构
type UploadResponse struct {OriginalName string `json:"originalName"`SavedPath string `json:"savedPath"`ReceivedMD5 string `json:"receivedMD5"`IsVerified bool `json:"isVerified"` // 是否通过验证
}func main() {r := gin.Default()// 配置CORSr.Use(cors.New(cors.Config{AllowOrigins: []string{"*"},AllowMethods: []string{"POST"},}))// 上传目录uploadDir := "uploads"if _, err := os.Stat(uploadDir); os.IsNotExist(err) {os.Mkdir(uploadDir, 0755)}r.POST("/upload", func(c *gin.Context) {// 1. 获取元数据JSONmetaJson := c.PostForm("metadata")var fileMetas []FileMetaRequestif err := json.Unmarshal([]byte(metaJson), &fileMetas); err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": "元数据解析失败"})return}// 2. 获取文件form, err := c.MultipartForm()if err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": "文件获取失败"})return}files := form.File["files"]// 3. 验证文件数量匹配if len(files) != len(fileMetas) {c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("元数据与文件数量不匹配(元数据:%d 文件:%d)",len(fileMetas), len(files)),})return}var results []UploadResponsefor i, file := range files {meta := fileMetas[i]// 4. 验证基本元数据if file.Filename != meta.FileName ||file.Size != meta.FileSize {results = append(results, UploadResponse{OriginalName: file.Filename,IsVerified: false,})continue}// 5. 保存文件savedName := fmt.Sprintf("%s%s", meta.FileMD5, filepath.Ext(file.Filename))savePath := filepath.Join(uploadDir, savedName)if err := c.SaveUploadedFile(file, savePath); err != nil {results = append(results, UploadResponse{OriginalName: file.Filename,IsVerified: false,})continue}// 6. 记录结果(实际项目中这里应该做MD5校验)results = append(results, UploadResponse{OriginalName: file.Filename,SavedPath: savePath,ReceivedMD5: meta.FileMD5,IsVerified: true,})}c.JSON(http.StatusOK, gin.H{"success": true,"results": results,})})log.Println("服务启动在 :8080")r.Run(":8080")
}
前端
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>文件上传系统</title><script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-md5/2.19.0/js/md5.min.js"></script><style>body {font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;max-width: 800px;margin: 0 auto;padding: 20px;background-color: }h1 {color: text-align: center;margin-bottom: 30px;}.upload-container {background-color: white;padding: 25px;border-radius: 8px;box-shadow: 0 2px 10px rgba(0,0,0,0.1);}.file-drop-area {border: 2px dashed border-radius: 5px;padding: 30px;text-align: center;margin-bottom: 20px;transition: all 0.3s;}.file-drop-area.highlight {background-color: border-color: }display: none;}.file-label {display: inline-block;padding: 10px 20px;background-color: color: white;border-radius: 5px;cursor: pointer;transition: background-color 0.3s;}.file-label:hover {background-color: }.file-list {margin-top: 20px;}.file-item {display: flex;justify-content: space-between;align-items: center;padding: 10px;border-bottom: 1px solid }.file-info {flex: 1;}.file-name {font-weight: bold;}.file-meta {font-size: 0.8em;color: }.file-type {display: inline-block;padding: 2px 8px;border-radius: 4px;font-size: 0.8em;margin-left: 10px;}.type-body {background-color: color: white;}.type-attachment {background-color: color: white;}.progress-container {margin-top: 20px;}.progress-bar {height: 20px;background-color: border-radius: 4px;margin-bottom: 10px;overflow: hidden;}.progress {height: 100%;background-color: width: 0%;transition: width 0.3s;}.results {margin-top: 30px;}.result-item {padding: 10px;margin-bottom: 10px;border-radius: 4px;background-color: }.success {border-left: 4px solid }.error {border-left: 4px solid }button {padding: 10px 20px;background-color: color: white;border: none;border-radius: 4px;cursor: pointer;font-size: 16px;transition: background-color 0.3s;}button:hover {background-color: }button:disabled {background-color: cursor: not-allowed;}</style>
</head>
<body>
<h1>邮件文件上传系统</h1><div class="upload-container"><div class="file-drop-area" id="dropArea"><input type="file" id="fileInput" multiple><label for="fileInput" class="file-label">选择文件或拖放到此处</label><p>支持多文件上传,自动计算MD5校验值</p></div><div class="file-list" id="fileList"></div><div class="progress-container" id="progressContainer" style="display: none;"><h3>上传进度</h3><div class="progress-bar"><div class="progress" id="progressBar"></div></div><div id="progressText">准备上传...</div></div><button id="uploadBtn" disabled>开始上传</button><button id="clearBtn">清空列表</button>
</div><div class="results" id="results"></div><script>// 全局变量let files = [];const dropArea = document.getElementById('dropArea');const fileInput = document.getElementById('fileInput');const fileList = document.getElementById('fileList');const uploadBtn = document.getElementById('uploadBtn');const clearBtn = document.getElementById('clearBtn');const progressContainer = document.getElementById('progressContainer');const progressBar = document.getElementById('progressBar');const progressText = document.getElementById('progressText');const resultsContainer = document.getElementById('results');// 拖放功能dropArea.addEventListener('dragover', (e) => {e.preventDefault();dropArea.classList.add('highlight');});dropArea.addEventListener('dragleave', () => {dropArea.classList.remove('highlight');});dropArea.addEventListener('drop', (e) => {e.preventDefault();dropArea.classList.remove('highlight');if (e.dataTransfer.files.length) {fileInput.files = e.dataTransfer.files;handleFiles();}});// 文件选择处理fileInput.addEventListener('change', handleFiles);async function handleFiles() {const newFiles = Array.from(fileInput.files);if (newFiles.length === 0) return;// 为每个文件计算MD5并创建元数据for (const file of newFiles) {const fileMeta = {file: file,name: file.name,size: file.size,type: file.type,md5: await calculateMD5(file),};files.push(fileMeta);}renderFileList();uploadBtn.disabled = false;}// 计算MD5async function calculateMD5(file) {return new Promise((resolve) => {const reader = new FileReader();reader.onload = (e) => {const hash = md5(e.target.result);resolve(hash);};reader.readAsBinaryString(file); // 注意这里使用 readAsBinaryString});}// 渲染文件列表function renderFileList() {fileList.innerHTML = '';if (files.length === 0) {fileList.innerHTML = '<p>没有选择文件</p>';uploadBtn.disabled = true;return;}files.forEach((fileMeta, index) => {const fileItem = document.createElement('div');fileItem.className = 'file-item';fileItem.innerHTML = `<div class="file-info"><div class="file-name">${fileMeta.name}</div><div class="file-meta">大小: ${formatFileSize(fileMeta.size)} |MD5: ${fileMeta.md5.substring(0, 8)}... |类型: ${fileMeta.type || '未知'}</div></div><div><button onclick="toggleFileType(${index})" class="file-type ${fileMeta.isAttachment ? 'type-attachment' : 'type-body'}">${fileMeta.isAttachment ? '附件' : '正文'}</button></div>`;fileList.appendChild(fileItem);});}// 格式化文件大小function formatFileSize(bytes) {if (bytes === 0) return '0 Bytes';const k = 1024;const sizes = ['Bytes', 'KB', 'MB', 'GB'];const i = Math.floor(Math.log(bytes) / Math.log(k));return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];}// 上传文件uploadBtn.addEventListener('click', async () => {if (files.length === 0) return;uploadBtn.disabled = true;progressContainer.style.display = 'block';resultsContainer.innerHTML = '<h3>上传结果</h3>';try {const formData = new FormData();// 添加元数据const metadata = files.map(f => ({fileName: f.name,fileSize: f.size,fileType: f.type,fileMD5: f.md5,}));formData.append('metadata', JSON.stringify(metadata));// 添加文件files.forEach(f => formData.append('files', f.file));// 使用Fetch API上传const xhr = new XMLHttpRequest();xhr.open('POST', 'http://localhost:8080/upload', true);// 进度监听xhr.upload.onprogress = (e) => {if (e.lengthComputable) {const percent = Math.round((e.loaded / e.total) * 100);progressBar.style.width = percent + '%';progressText.textContent = `上传中: ${percent}% (${formatFileSize(e.loaded)}/${formatFileSize(e.total)})`;}};xhr.onload = () => {if (xhr.status === 200) {const response = JSON.parse(xhr.responseText);showResults(response);} else {showError('上传失败: ' + xhr.statusText);}};xhr.onerror = () => {showError('网络错误,上传失败');};xhr.send(formData);} catch (error) {showError('上传出错: ' + error.message);}});// 显示上传结果function showResults(response) {progressText.textContent = '上传完成!';if (response.success) {response.results.forEach(result => {const resultItem = document.createElement('div');resultItem.className = `result-item ${result.isVerified ? 'success' : 'error'}`;resultItem.innerHTML = `<div><strong>${result.originalName}</strong></div><div>保存路径: ${result.savedPath || '无'}</div><div>MD5校验: ${result.receivedMD5 || '无'} -<span style="color: ${result.isVerified ? '#2ecc71' : '#e74c3c'}">${result.isVerified ? '✓ 验证通过' : '× 验证失败'}</span></div>`;resultsContainer.appendChild(resultItem);});} else {showError(response.error || '上传失败');}}// 显示错误function showError(message) {const errorItem = document.createElement('div');errorItem.className = 'result-item error';errorItem.textContent = message;resultsContainer.appendChild(errorItem);}// 清空列表clearBtn.addEventListener('click', () => {files = [];fileInput.value = '';renderFileList();progressContainer.style.display = 'none';resultsContainer.innerHTML = '';uploadBtn.disabled = true;});
</script>
</body>
</html>
上传截图
