首页 关于 文章 作品集 工具 联系
返回文章列表

Flutter + Node.js:图片上传与文件管理接口设计

图片上传是几乎所有移动应用的标配功能:用户头像、聊天图片、商品图片、朋友圈动态……这些场景都离不开一个稳定可靠的文件上传接口。这篇文章记录了我在 Flutter + Node.js 项目中实现图片上传功能的完整过程,从后端的 Multer 中间件配置到 Flutter 端的图片选择与上传封装。

一、后端:Multer 文件上传中间件

Node.js 生态中处理 multipart/form-data 最成熟的方案是 multer。它支持内存存储和磁盘存储,可以灵活配置文件大小限制、文件类型过滤和存储路径规则。

首先安装依赖:

npm install multer sharp

其中 sharp 是一个高性能的图片处理库,用于在上传时自动压缩图片、生成缩略图。

配置 Multer 存储策略:

const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');

// 确保上传目录存在
const uploadDir = path.join(__dirname, '../../uploads');
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}

// 按日期分目录存储
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    const dateDir = new Date()
      .toISOString()
      .split('T')[0]
      .replace(/-/g, '/');
    const dir = path.join(uploadDir, dateDir);
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
    cb(null, dir);
  },
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase();
    const name = crypto.randomBytes(16).toString('hex');
    cb(null, `${name}${ext}`);
  }
});

// 文件过滤器:只允许图片
const fileFilter = (req, file, cb) => {
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
  if (allowedTypes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new Error('仅支持 JPG/PNG/GIF/WEBP 格式的图片'), false);
  }
};

const upload = multer({
  storage,
  fileFilter,
  limits: {
    fileSize: 10 * 1024 * 1024  // 单文件最大 10MB
  }
});

module.exports = upload;

二、图片处理:自动压缩与缩略图

用户上传的图片往往体积很大,直接存储和传输会浪费大量带宽。使用 sharp 在上传时自动处理图片:

const sharp = require('sharp');
const path = require('path');
const fs = require('fs');

const processImage = async (filePath) => {
  const dir = path.dirname(filePath);
  const ext = path.extname(filePath);
  const baseName = path.basename(filePath, ext);
  const optimizedPath = path.join(dir, `${baseName}_opt${ext}`);
  const thumbPath = path.join(dir, `${baseName}_thumb${ext}`);

  // 压缩原图(保持比例,最大宽度 1200px)
  await sharp(filePath)
    .resize(1200, null, { withoutEnlargement: true })
    .jpeg({ quality: 80, progressive: true })
    .toFile(optimizedPath);

  // 生成缩略图(200x200)
  await sharp(filePath)
    .resize(200, 200, { fit: 'cover' })
    .jpeg({ quality: 70 })
    .toFile(thumbPath);

  // 删除原始文件,保留处理后的版本
  fs.unlinkSync(filePath);

  return {
    original: optimizedPath,
    thumbnail: thumbPath
  };
};

三、上传接口实现

定义上传路由,支持单图和多图上传:

const express = require('express');
const router = express.Router();
const upload = require('../middleware/upload');
const { protect } = require('../middleware/auth');
const { processImage } = require('../utils/imageProcessor');
const { successResponse, errorResponse } = require('../utils/response');

// 单图上传
router.post('/single', protect, upload.single('image'),
  async (req, res) => {
    try {
      if (!req.file) {
        return errorResponse(res, '请选择要上传的图片', 400);
      }

      const processed = await processImage(req.file.path);

      // 返回可访问的 URL
      const fileUrl = processed.original.replace(/\\/g, '/');

      successResponse(res, {
        url: `/uploads/${fileUrl}`,
        thumbnail: `/uploads/${processed.thumbnail.replace(/\\/g, '/')}`,
        size: fs.statSync(processed.original).size,
        mimetype: req.file.mimetype
      });
    } catch (error) {
      errorResponse(res, '图片处理失败: ' + error.message, 500);
    }
  }
);

// 多图上传(最多 9 张)
router.post('/multiple', protect,
  upload.array('images', 9),
  async (req, res) => {
    try {
      if (!req.files || req.files.length === 0) {
        return errorResponse(res, '请选择要上传的图片', 400);
      }

      const results = await Promise.all(
        req.files.map(async (file) => {
          const processed = await processImage(file.path);
          return {
            url: `/uploads/${processed.original.replace(/\\/g, '/')}`,
            thumbnail: `/uploads/${processed.thumbnail.replace(/\\/g, '/')}`
          };
        })
      );

      successResponse(res, { images: results });
    } catch (error) {
      errorResponse(res, '图片处理失败: ' + error.message, 500);
    }
  }
);

module.exports = router;

不要忘记在 Express 入口中设置静态文件服务,让上传的图片可以通过 URL 直接访问:

const path = require('path');
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));

四、Flutter 端:图片选择与上传

Flutter 端使用 image_picker 选择图片,用 dio 发送 multipart 请求:

import 'package:image_picker/image_picker.dart';
import 'package:dio/dio.dart';

class ImageUploadService {
  final Dio _dio;
  final ImagePicker _picker = ImagePicker();

  ImageUploadService(this._dio);

  // 选择并上传单张图片
  Future<String?> pickAndUploadImage() async {
    try {
      // 选择图片
      final XFile? image = await _picker.pickImage(
        source: ImageSource.gallery,
        maxWidth: 1920,
        maxHeight: 1920,
        imageQuality: 90,
      );

      if (image == null) return null;

      // 构建 FormData
      final formData = FormData.fromMap({
        'image': await MultipartFile.fromFile(
          image.path,
          filename: image.name,
        ),
      });

      // 发送上传请求
      final response = await _dio.post(
        '/upload/single',
        data: formData,
        options: Options(contentType: 'multipart/form-data'),
      );

      if (response.data['success']) {
        return response.data['data']['url'];
      }
      return null;
    } catch (e) {
      print('图片上传失败: $e');
      return null;
    }
  }

  // 选择并上传多张图片
  Future<List<String>> pickAndUploadMultiple() async {
    try {
      final List<XFile> images = await _picker.pickMultiImage(
        maxWidth: 1920,
        maxHeight: 1920,
        imageQuality: 90,
      );

      if (images.isEmpty) return [];

      final List<MultipartFile> files = images.map((img) =>
        MultipartFile.fromFileSync(img.path, filename: img.name)
      ).toList();

      // 最多上传9张
      final uploadFiles = files.take(9).toList();

      final formData = FormData.fromMap({
        'images': uploadFiles,
      });

      final response = await _dio.post(
        '/upload/multiple',
        data: formData,
        options: Options(contentType: 'multipart/form-data'),
      );

      if (response.data['success']) {
        return (response.data['data']['images'] as List)
            .map((img) => img['url'] as String)
            .toList();
      }
      return [];
    } catch (e) {
      print('多图上传失败: $e');
      return [];
    }
  }
}

五、上传进度监听

大文件上传时,进度反馈对用户体验至关重要。Dio 原生支持 onSendProgress 回调:

Future<void> uploadWithProgress(
  String filePath,
  Function(int sent, int total) onProgress,
) async {
  final formData = FormData.fromMap({
    'image': await MultipartFile.fromFile(filePath),
  });

  await _dio.post('/upload/single', data: formData,
    onSendProgress: (sent, total) {
      final percent = (sent / total * 100).toStringAsFixed(1);
      onProgress(sent, total);
      print('上传进度: $percent%');
    },
  );
}

总结

一个完整的图片上传方案需要考虑的远不止「接收文件」这么简单。从后端的文件类型校验、大小限制、自动压缩、缩略图生成,到 Flutter 端的图片选择、multipart 构建和上传进度监听,每一个环节都直接影响用户的实际体验。

这个方案的亮点在于:按日期自动分目录存储、文件名随机生成避免冲突、上传时自动生成优化版本和缩略图、以及 Flutter 端统一的上传服务封装。生产环境中还可以进一步接入云存储(如阿里云 OSS、腾讯云 COS)来替代本地存储,大幅提升可靠性和访问速度。