后端
package main
import (
"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()
// 配置CORS
r.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. 获取元数据JSON
metaJson := c.PostForm("metadata")
var fileMetas []FileMetaRequest
if 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 []UploadResponse
for 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;
}
// 计算MD5
async 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>
上传截图
