文件上传服务 #

一、上传概述 #

1.1 上传类型 #

类型 说明
单文件上传 上传单个文件
多文件上传 同时上传多个文件
表单混合 文件和表单数据混合

1.2 安装依赖 #

bash
npm install @hapi/hapi @hapi/inert @hapi/joi @hapi/boom
npm install uuid mime-types

二、文件存储 #

2.1 src/services/fileService.js #

javascript
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const mime = require('mime-types');

const uploadDir = path.join(__dirname, '../../uploads');

if (!fs.existsSync(uploadDir)) {
    fs.mkdirSync(uploadDir, { recursive: true });
}

const saveFile = async (file) => {
    const ext = mime.extension(file.headers['content-type']);
    const filename = `${uuidv4()}.${ext}`;
    const filepath = path.join(uploadDir, filename);
    
    return new Promise((resolve, reject) => {
        const writeStream = fs.createWriteStream(filepath);
        
        file.pipe(writeStream);
        
        writeStream.on('finish', () => {
            resolve({
                filename,
                originalName: file.hapi.filename,
                mimetype: file.hapi.headers['content-type'],
                size: file.bytes,
                path: filepath
            });
        });
        
        writeStream.on('error', reject);
    });
};

const deleteFile = (filename) => {
    const filepath = path.join(uploadDir, filename);
    
    return new Promise((resolve, reject) => {
        fs.unlink(filepath, (err) => {
            if (err) {
                reject(err);
            } else {
                resolve(true);
            }
        });
    });
};

const fileExists = (filename) => {
    const filepath = path.join(uploadDir, filename);
    return fs.existsSync(filepath);
};

const getFileInfo = (filename) => {
    const filepath = path.join(uploadDir, filename);
    
    if (!fs.existsSync(filepath)) {
        return null;
    }
    
    const stats = fs.statSync(filepath);
    
    return {
        filename,
        size: stats.size,
        createdAt: stats.birthtime,
        modifiedAt: stats.mtime
    };
};

module.exports = {
    saveFile,
    deleteFile,
    fileExists,
    getFileInfo
};

三、上传处理函数 #

3.1 src/handlers/upload.js #

javascript
const Boom = require('@hapi/boom');
const fileService = require('../services/fileService');

const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
const maxFileSize = 10 * 1024 * 1024;

const uploadSingle = async (request, h) => {
    const file = request.payload.file;
    
    if (!file) {
        throw Boom.badRequest('No file uploaded');
    }
    
    const mimetype = file.hapi.headers['content-type'];
    
    if (!allowedTypes.includes(mimetype)) {
        throw Boom.badRequest('File type not allowed');
    }
    
    const fileInfo = await fileService.saveFile(file);
    
    return h.response({
        message: 'File uploaded successfully',
        file: fileInfo
    }).code(201);
};

const uploadMultiple = async (request, h) => {
    const files = request.payload.files;
    
    if (!files || files.length === 0) {
        throw Boom.badRequest('No files uploaded');
    }
    
    const results = [];
    
    for (const file of files) {
        const mimetype = file.hapi.headers['content-type'];
        
        if (!allowedTypes.includes(mimetype)) {
            continue;
        }
        
        const fileInfo = await fileService.saveFile(file);
        results.push(fileInfo);
    }
    
    return h.response({
        message: `${results.length} files uploaded successfully`,
        files: results
    }).code(201);
};

const download = async (request, h) => {
    const { filename } = request.params;
    
    if (!fileService.fileExists(filename)) {
        throw Boom.notFound('File not found');
    }
    
    return h.file(`uploads/${filename}`, {
        mode: 'attachment'
    });
};

const view = async (request, h) => {
    const { filename } = request.params;
    
    if (!fileService.fileExists(filename)) {
        throw Boom.notFound('File not found');
    }
    
    return h.file(`uploads/${filename}`);
};

const deleteFile = async (request, h) => {
    const { filename } = request.params;
    
    if (!fileService.fileExists(filename)) {
        throw Boom.notFound('File not found');
    }
    
    await fileService.deleteFile(filename);
    
    return h.response().code(204);
};

const getInfo = async (request, h) => {
    const { filename } = request.params;
    
    const info = fileService.getFileInfo(filename);
    
    if (!info) {
        throw Boom.notFound('File not found');
    }
    
    return info;
};

module.exports = {
    uploadSingle,
    uploadMultiple,
    download,
    view,
    deleteFile,
    getInfo
};

四、上传路由 #

4.1 src/routes/upload.js #

javascript
const Joi = require('joi');
const handlers = require('../handlers/upload');

module.exports = [
    {
        method: 'POST',
        path: '/upload',
        options: {
            description: 'Upload single file',
            tags: ['api'],
            payload: {
                output: 'stream',
                parse: true,
                maxBytes: 10 * 1024 * 1024,
                multipart: true
            }
        },
        handler: handlers.uploadSingle
    },
    {
        method: 'POST',
        path: '/upload/multiple',
        options: {
            description: 'Upload multiple files',
            tags: ['api'],
            payload: {
                output: 'stream',
                parse: true,
                maxBytes: 50 * 1024 * 1024,
                multipart: true
            }
        },
        handler: handlers.uploadMultiple
    },
    {
        method: 'GET',
        path: '/files/{filename}',
        options: {
            description: 'Download file',
            tags: ['api'],
            validate: {
                params: Joi.object({
                    filename: Joi.string().required()
                })
            }
        },
        handler: handlers.download
    },
    {
        method: 'GET',
        path: '/view/{filename}',
        options: {
            description: 'View file',
            tags: ['api'],
            validate: {
                params: Joi.object({
                    filename: Joi.string().required()
                })
            }
        },
        handler: handlers.view
    },
    {
        method: 'DELETE',
        path: '/files/{filename}',
        options: {
            description: 'Delete file',
            tags: ['api'],
            validate: {
                params: Joi.object({
                    filename: Joi.string().required()
                })
            }
        },
        handler: handlers.deleteFile
    },
    {
        method: 'GET',
        path: '/files/{filename}/info',
        options: {
            description: 'Get file info',
            tags: ['api'],
            validate: {
                params: Joi.object({
                    filename: Joi.string().required()
                })
            }
        },
        handler: handlers.getInfo
    }
];

五、静态文件服务 #

5.1 配置Inert #

javascript
const Inert = require('@hapi/inert');

await server.register(Inert);

server.route({
    method: 'GET',
    path: '/uploads/{param*}',
    handler: {
        directory: {
            path: './uploads',
            listing: true
        }
    }
});

六、图片处理 #

6.1 安装sharp #

bash
npm install sharp

6.2 图片缩略图 #

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

const createThumbnail = async (inputPath, outputPath, width = 200) => {
    await sharp(inputPath)
        .resize(width, null, {
            fit: 'inside',
            withoutEnlargement: true
        })
        .toFile(outputPath);
};

const getImageMetadata = async (filepath) => {
    return await sharp(filepath).metadata();
};

module.exports = {
    createThumbnail,
    getImageMetadata
};

6.3 上传并生成缩略图 #

javascript
const uploadImage = async (request, h) => {
    const file = request.payload.file;
    
    const ext = path.extname(file.hapi.filename);
    const filename = `${uuidv4()}${ext}`;
    const filepath = path.join(uploadDir, filename);
    const thumbPath = path.join(uploadDir, 'thumbs', filename);
    
    await saveFile(file, filepath);
    
    await createThumbnail(filepath, thumbPath);
    
    return {
        filename,
        thumbnail: `/uploads/thumbs/${filename}`
    };
};

七、文件验证 #

7.1 文件类型验证 #

javascript
const validateFileType = (file, allowedTypes) => {
    const mimetype = file.hapi.headers['content-type'];
    return allowedTypes.includes(mimetype);
};

const validateFileSize = (file, maxSize) => {
    return file.bytes <= maxSize;
};

const validateFile = (file, options = {}) => {
    const {
        allowedTypes = ['image/jpeg', 'image/png'],
        maxSize = 10 * 1024 * 1024
    } = options;
    
    if (!validateFileType(file, allowedTypes)) {
        throw Boom.badRequest('File type not allowed');
    }
    
    if (!validateFileSize(file, maxSize)) {
        throw Boom.badRequest('File size exceeds limit');
    }
    
    return true;
};

八、完整入口文件 #

8.1 src/index.js #

javascript
require('dotenv').config();
const Hapi = require('@hapi/hapi');
const Inert = require('@hapi/inert');
const config = require('./config');
const errorHandler = require('./plugins/errorHandler');
const uploadRoutes = require('./routes/upload');

const init = async () => {
    const server = Hapi.server({
        port: config.port,
        host: config.host
    });

    await server.register(Inert);
    await server.register(errorHandler);

    server.route([
        {
            method: 'GET',
            path: '/',
            handler: (request, h) => {
                return { message: 'File Upload Service' };
            }
        },
        {
            method: 'GET',
            path: '/uploads/{param*}',
            handler: {
                directory: {
                    path: './uploads',
                    listing: true
                }
            }
        },
        ...uploadRoutes
    ]);

    await server.start();
    console.log('Server running on %s', server.info.uri);
};

init();

九、API测试 #

9.1 上传单文件 #

bash
curl -X POST http://localhost:3000/upload \
  -F "file=@/path/to/file.jpg"

9.2 上传多文件 #

bash
curl -X POST http://localhost:3000/upload/multiple \
  -F "files=@/path/to/file1.jpg" \
  -F "files=@/path/to/file2.jpg"

9.3 下载文件 #

bash
curl http://localhost:3000/files/filename.jpg -O

9.4 获取文件信息 #

bash
curl http://localhost:3000/files/filename.jpg/info

十、总结 #

文件上传要点:

功能 路径 方法
单文件上传 /upload POST
多文件上传 /upload/multiple POST
下载文件 /files/ GET
预览文件 /view/ GET
删除文件 /files/ DELETE

下一步,让我们学习部署上线!

最后更新:2026-03-28