文件上传

1.文件上传模式

在实际开发中,文件上传通常有两种模式:分步提交(先传文件,再提交表单)和同步提交(文件与表单字段一起提交)。选择哪种模式,直接影响前后端架构、代码复杂度和后期维护成本。

✅ 现代项目推荐:分步提交

  • 流程:
    • 前端先将文件单独上传到服务端;
    • 服务端返回一个临时或永久的文件 URL;
    • 用户提交表单时,仅将该 URL 作为普通字符串字段,通过 JSON 发送给后端。
  • 优势:
    • 表单提交轻量、统一(纯 JSON),无需特殊处理;
    • 文件可预览、裁剪、重传,用户体验更好;
    • 便于对接 CDN、OSS 等云存储;
    • 适用于跨平台场景(如微信小程序 + H5 + App 共用一套逻辑)

传统项目常见:同步提交

  • 流程:将文件与其他表单字段(如标题、描述)打包进 FormData,一次性提交。
  • 本质:无论使用原生 form标签 -> enctype="multipart/form-data" 还是 JavaScript 的 new FormData(),底层都是 multipart 请求。
  • 缺点:
    • 每次提交都要区分“哪些是文件、哪些是普通字段”;
    • 不利于前后端分离(后端需解析 multipart 而非 JSON);
    • 在小程序等受限环境中兼容性差(例如不支持 FormData)。

🔑 关键结论

  • 无论哪种模式,第一步上传文件的本质都是 FormData(或等效的二进制流)。区别在于:
    • 分步提交:只在上传文件时用一次 FormData,后续全是 JSON;
    • 同步提交:每次提交都要构造 FormData,混合处理文件与字段。
  • 从长期维护角度看,分步提交更清晰、更易扩展,尤其适合人员流动大、业务变化快的企业后台系统。

2.前端代码

Web 端:Vue 2 + Element UI

<template>
  <el-upload
    ref="upload"
    class="upload-demo"
    drag
    action="http://127.0.0.1:8089/api/uploads"
    :limit="1"
    :on-success="handleFileUploadSuccess"
    :on-remove="handleFileRemove"
  >
    <i class="el-icon-upload"></i>
    <div class="el-upload__text">
      将文件拖到此处,或<em>点击上传</em>
    </div>
  </el-upload>
</template>

<script>
export default {
  data() {
    return {
      form: {
        fileName: '',
        filePath: ''
      }
    };
  },
  methods: {
    // 文件上传成功
    handleFileUploadSuccess(response) {
      // 假设后端返回:{ data: [{ fileName: 'xxx', filePath: '/uploads/xxx' }] }
      const file = response.data?.[0];
      if (file) {
        this.form.fileName = file.fileName;
        this.form.filePath = file.filePath;
        // 清除可能存在的表单验证错误(如有)
        this.$refs.ruleForm?.clearValidate('filePath');
      }
    },

    // 文件被移除
    handleFileRemove() {
      this.form.fileName = '';
      this.form.filePath = '';
    }
  }
};
</script>

App端:uni-app

uni.chooseImage({
	success: (chooseImageRes) => {
		const tempFilePaths = chooseImageRes.tempFilePaths;
		uni.uploadFile({
			url: 'https://www.example.com/upload', //仅为示例,非真实的接口地址
			filePath: tempFilePaths[0],
			name: 'file',
			formData: {
				'user': 'test'
			},
			success: (uploadFileRes) => {
				console.log(uploadFileRes.data);
			}
		});
	}
});

📌 补充说明

无论是 Element UI 的 el-upload,还是 uni-app 的 uni.uploadFile(),它们在发起文件上传请求时,底层最终都会使用类似 FormData 的机制(或等效的 multipart/form-data 格式)将文件作为二进制数据提交到服务端。

  • 在 浏览器环境(如 Element UI)中,el-upload 内部确实直接使用了原生的 FormData API 构造请求体。
  • 在 小程序或 App 环境(如 uni-app)中,虽然 JavaScript 层没有暴露 FormData 对象(微信小程序等不支持),但 uni.uploadFile() 会在原生层自动构造一个符合 multipart/form-data 规范的 HTTP 请求,其效果与 FormData 等价

所有基于 HTTP 的文件上传,只要走的是标准表单上传方式,本质上都是 multipart/form-data 格式 —— 只是不同平台对开发者是否暴露 FormData 这个接口而已

这也解释了为什么后端(如 Express、Spring Boot、Go 等)可以用同一套逻辑处理来自 Web、H5、小程序的文件上传请求:它们收到的,都是标准的 multipart/form-data 请求

3.后端代码

接收文件,返回临时链接

const path = require('path');
const dayjs = require('dayjs');
// multer 是用于处理 multipart/form-data 请求的 Node.js 中间件,常用于文件上传
const multer = require('multer');
const fs = require('fs').promises;

// 获取项目根目录
const rootPath = getProjectRoot();
// 临时文件存储目录
const uploadDir = path.resolve(rootPath, 'public/tmp');

// 确保上传目录存在
ensureUploadsDirectory().catch(err => {
    console.error('无法创建上传目录:', err);
});

// 配置文件存储引擎
const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        cb(null, uploadDir);
    },
    filename: function (req, file, cb) {
        cb(null, dayjs().format('YYYYMMDDHHmmss') + '-' + file.originalname);
    }
});

// 初始化 multer 中间件
const upload = multer({ storage: storage });

// 注册路由
exports.install = function (router) {
    router.post(`/uploads`, upload.array('file', 9), async function (req, res) {
        try {
            if (!req.files || req.files.length === 0) {
                return res.status(400).send('没有接收到文件!');
            }
            // 构造返回的文件信息列表
            const files = req.files.map((file) => {
                const absoluteFilePath = file.path;
                const relativeFilePath = path.relative(rootPath, absoluteFilePath);
                return {
                    originalName: file.originalname,
                    fileName: file.filename,
                    filePath: relativeFilePath,
                    fileType: file.mimetype,
                    fileSize: file.size
                };
            });
            // 返回成功响应
            res.send({
                code: 1,
                data: files,
                msg: "上传成功!"
            });
        } catch (error) {
            res.send({
                code: 0,
                data: null,
                msg: error.message
            });
        }
    });
};

// 确保上传目录存在的工具方法
async function ensureUploadsDirectory() {
    try {
        await fs.access(uploadDir);
    } catch (error) {
        await fs.mkdir(uploadDir, { recursive: true });
    }
}

// 获取项目根目录的工具方法
function getProjectRoot() {
    let currentDir = __dirname;
    while (true) {
        if (fs.existsSync(path.join(currentDir, 'package.json'))) {
            return currentDir;
        }
        const parentDir = path.dirname(currentDir);
        if (parentDir === currentDir) {
            throw new Error("Could not find project root containing package.json");
        }
        currentDir = parentDir;
    }
}

“转正”接口设计

// 获取项目根目录
const rootPath = getProjectRoot();
// 正式文件存储目录
const targetDir = path.resolve(rootPath, 'public/AppPackages');

try {
    // 构造临时文件的绝对路径
    const tempFilePath = path.join(rootPath, req.body.filePath);
    // 使用原上传时生成的文件名
    const newFileName = path.basename(req.body.filePath);
    // 目标文件的绝对路径
    const targetFilePath = path.join(targetDir, newFileName);
    // 生成相对于项目根目录的路径(用于数据库存储或前端访问)
    const relativeFilePath = path.relative(rootPath, targetFilePath);

    // 将文件从临时目录移动到正式目录
    await fs.rename(tempFilePath, targetFilePath);

    // 组装业务数据
    const row = {
        appName: req.body.appName,
        version: req.body.version,
        fileName: req.body.fileName,
        filePath: relativeFilePath,
        content: req.body.content,
        status: req.body.status === undefined ? 1 : req.body.status,
    };

    // 此处执行数据库插入操作(例如:await AppUpdateModel.create(row))
    // 成功后返回新记录

    res.send({
        code: 1,
        data: row, // 或实际插入后的完整记录
        msg: "新增成功!"
    });
} catch (error) {
    res.send({
        code: 0,
        data: null,
        msg: error.message
    });
}

4.文件清理

方案一:手动梳理 + 脚本清理

  • 步骤:
    • 手动梳理数据表(识别文件引用字段)
    • 编写清理脚本

为避免未来再花人力梳理,建议在数据库设计时遵循以下规范:所有文件字段以 _file 或 _path 结尾,如 contract_file, id_card_front_path

方案二:成本对比 —— 硬盘 vs 人工

  • 硬盘成本(企业级 SATA HDD)
  • 人力成本(开发/运维工程师)

现实中,绝大多数普通企业内部管理系统(如 OA、HR、CRM 等)遇到上传文件存储快满的情况时,会选择直接增加硬盘(或云存储空间),而不是投入人力去梳理和清理文件

这一做法在中小企业中尤为普遍,原因包括:

  • 硬盘价格低廉(2025年,一块 8TB 企业级 SATA 硬盘市场价格约 ¥1200–¥1800);
  • 人力成本高(一名中级开发工程师日薪通常在 ¥1000–¥2000,含社保和管理成本后更高);
  • 清理逻辑复杂、易出错,而加硬盘操作简单、风险低;
  • 内部系统用户量有限,文件增长缓慢,一块大容量硬盘往往可支撑数年
posted @ 2019-12-04 17:00  ---空白---  阅读(1838)  评论(1)    收藏  举报