Skip to content

文件静态服务使用文档

概述

文件静态服务模块提供了上传文件的访问能力,通过 ServeStaticModule 将本地文件系统映射为 HTTP 可访问的资源路径。该模块配置在 CommonModule 中,允许前端通过 URL 直接访问上传的图片、文档等静态资源。

架构设计

模块结构

src/modules/common/
├── common.module.ts      # 通用模块(包含静态服务配置)
├── common.controller.ts  # 通用控制器
├── common.service.ts     # 通用服务
└── captcha.service.ts    # 验证码服务

核心依赖

  • @nestjs/serve-static - NestJS 静态文件服务模块
  • express - 底层 HTTP 服务器框架
  • path - Node.js 路径处理模块

基础配置

ServeStaticModule 配置

typescript
import { Module } from '@nestjs/common'
import { ServeStaticModule, ServeStaticModuleOptions } from '@nestjs/serve-static'
import { isAbsolute, join } from 'path'

@Module({
  imports: [
    ServeStaticModule.forRootAsync({
      useFactory: (config) => {
        const fileUploadLocationConfig = 'upload'
        const rootPath = isAbsolute(fileUploadLocationConfig)
          ? `${fileUploadLocationConfig}`
          : join(process.cwd(), `${fileUploadLocationConfig}`)

        return [
          {
            rootPath,                    // 物理路径:项目根目录下的 upload 文件夹
            serveRoot: '/api/static',    // 虚拟路径:URL 前缀
            serveStaticOptions: {
              cacheControl: true,        // 启用缓存控制
            },
          },
        ] as ServeStaticModuleOptions[]
      },
    }),
  ],
})
export class CommonModule {}

配置参数说明

参数类型默认值说明
rootPathstring-静态文件的物理路径(绝对路径或相对路径)
serveRootstring''URL 访问前缀,必须以 / 开头
excludestring[][]需要排除的路径数组
serveStaticOptionsobject{}Express static 中间件选项

serveStaticOptions 常用选项

typescript
{
  cacheControl: true,           // 启用 Cache-Control 头
  maxAge: 86400000,             // 缓存时间(毫秒),默认 1 天
  etag: true,                   // 启用 ETag
  lastModified: true,           // 启用 Last-Modified 头
  dotfiles: 'ignore',           // 如何处理点文件 ('allow' | 'deny' | 'ignore')
  index: false,                 // 是否提供索引文件
  redirect: false,              // 是否在目录末尾添加斜杠并重定向
}

访问规则

URL 映射关系

配置:

  • 物理路径:/project/upload/
  • 虚拟路径:/api/static

访问示例:

物理文件:upload/avatar/2024-01-15/1705305600000-123456789.jpg
访问URL: http://localhost:3000/api/static/avatar/2024-01-15/1705305600000-123456789.jpg

物理文件:upload/article/2024-01-16/1705392000000-987654321.png
访问URL: http://localhost:3000/api/static/article/2024-01-16/1705392000000-987654321.png

路径转换逻辑

请求URL: /api/static/{relativePath}

去除前缀: {relativePath}

拼接路径: process.cwd() + '/upload/' + {relativePath}

返回文件: 如果文件存在则返回,否则 404

实际应用场景

1. 图片资源访问

用户头像显示

vue
<template>
  <img :src="avatarUrl" alt="用户头像" />
</template>

<script setup>
import { ref, onMounted } from 'vue'

const avatarUrl = ref('')

onMounted(async () => {
  // 从 API 获取用户信息
  const user = await getUserInfo()
  // 拼接静态资源 URL
  avatarUrl.value = `${import.meta.env.VITE_API_BASE_URL}/api/static${user.avatar}`
})
</script>

文章配图展示

vue
<template>
  <div v-for="article in articles" :key="article.id">
    <h3>{{ article.title }}</h3>
    <img 
      :src="getImageUrl(article.coverImage)" 
      :alt="article.title"
      @error="handleImageError"
    />
  </div>
</template>

<script setup>
const getImageUrl = (path) => {
  if (!path) return '/default-cover.png'
  // path 格式: /article/2024-01-15/xxx.jpg
  return `${import.meta.env.VITE_API_BASE_URL}/api/static${path}`
}

const handleImageError = (e) => {
  e.target.src = '/default-image.png'
}
</script>

2. 文件下载功能

vue
<template>
  <el-button @click="downloadFile(fileUrl, fileName)">
    下载文件
  </el-button>
</template>

<script setup>
const downloadFile = (url, filename) => {
  const link = document.createElement('a')
  link.href = url
  link.download = filename
  link.target = '_blank'
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)
}

// 使用示例
const fileUrl = `${import.meta.env.VITE_API_BASE_URL}/api/static/document/2024-01-15/report.pdf`
downloadFile(fileUrl, '年度报告.pdf')
</script>

3. 富文本编辑器图片渲染

vue
<template>
  <div v-html="processedContent"></div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  content: String
})

// 处理内容中的图片路径
const processedContent = computed(() => {
  if (!props.content) return ''
  
  // 将相对路径转换为完整 URL
  return props.content.replace(
    /src="(\/upload\/[^"]+)"/g,
    (match, path) => {
      // /upload/article/xxx.jpg -> /api/static/article/xxx.jpg
      const staticPath = path.replace('/upload/', '/api/static/')
      return `src="${import.meta.env.VITE_API_BASE_URL}${staticPath}"`
    }
  )
})
</script>

4. 批量图片加载优化

vue
<template>
  <div class="image-gallery">
    <img 
      v-for="img in images" 
      :key="img.id"
      :src="img.url"
      loading="lazy"
      :alt="img.alt"
    />
  </div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  imageList: Array
})

const images = computed(() => {
  return props.imageList.map(img => ({
    ...img,
    url: `${import.meta.env.VITE_API_BASE_URL}/api/static${img.path}`
  }))
})
</script>

<style scoped>
.image-gallery img {
  /* 懒加载样式 */
  opacity: 0;
  transition: opacity 0.3s;
}

.image-gallery img.loaded {
  opacity: 1;
}
</style>

高级配置

1. 多目录静态服务

如果需要同时服务多个目录:

typescript
ServeStaticModule.forRootAsync({
  useFactory: () => {
    return [
      {
        rootPath: join(process.cwd(), 'upload'),
        serveRoot: '/api/static',
        serveStaticOptions: {
          cacheControl: true,
          maxAge: 86400000, // 1天
        },
      },
      {
        rootPath: join(process.cwd(), 'public'),
        serveRoot: '/public',
        serveStaticOptions: {
          cacheControl: true,
          maxAge: 604800000, // 7天
        },
      },
      {
        rootPath: join(process.cwd(), 'assets'),
        serveRoot: '/assets',
        exclude: ['/api'],
      },
    ] as ServeStaticModuleOptions[]
  },
})

2. 环境差异化配置

根据开发/生产环境使用不同配置:

typescript
import { ConfigService } from '@nestjs/config'

ServeStaticModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (configService: ConfigService) => {
    const isProduction = configService.get('NODE_ENV') === 'production'
    
    return [
      {
        rootPath: join(process.cwd(), 'upload'),
        serveRoot: '/api/static',
        serveStaticOptions: {
          cacheControl: isProduction,
          maxAge: isProduction ? 86400000 : 0, // 生产环境缓存,开发环境不缓存
          etag: isProduction,
        },
      },
    ] as ServeStaticModuleOptions[]
  },
})

3. 自定义中间件实现

如果需要更灵活的控制,可以使用自定义中间件(代码中已注释的方案):

typescript
import { Injectable, NestMiddleware } from '@nestjs/common'
import { Request, Response, NextFunction } from 'express'
import { join } from 'path'
import { access, constants } from 'fs/promises'

@Injectable()
export class StaticFileMiddleware implements NestMiddleware {
  async use(req: Request, res: Response, next: NextFunction) {
    // 检查请求是否匹配静态资源路径
    if (req.url.startsWith('/static/') || req.url.startsWith('/api/static/')) {
      let filePath = req.url
      
      // 移除路径前缀
      if (filePath.startsWith('/api/static/')) {
        filePath = filePath.replace('/api/static/', '')
      } else if (filePath.startsWith('/static/')) {
        filePath = filePath.replace('/static/', '')
      }

      const fullPath = join(process.cwd(), 'upload', filePath)

      try {
        // 检查文件是否存在且可读
        await access(fullPath, constants.R_OK)
        res.sendFile(fullPath)
      } catch (err) {
        // 文件不存在,继续下一个中间件
        next()
      }
    } else {
      next()
    }
  }
}

// 在模块中注册
@Module({})
export class CommonModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(StaticFileMiddleware)
      .forRoutes(
        { path: 'static/*', method: RequestMethod.ALL },
        { path: 'api/static/*', method: RequestMethod.ALL }
      )
  }
}

4. 添加访问权限控制

为静态资源添加认证保护:

typescript
import { Injectable, NestMiddleware } from '@nestjs/common'
import { Request, Response, NextFunction } from 'express'
import { JwtService } from '@nestjs/jwt'

@Injectable()
export class AuthenticatedStaticMiddleware implements NestMiddleware {
  constructor(private jwtService: JwtService) {}

  async use(req: Request, res: Response, next: NextFunction) {
    // 公开资源直接放行
    const publicPaths = ['/api/static/public/']
    if (publicPaths.some(path => req.url.startsWith(path))) {
      return next()
    }

    // 检查认证令牌
    const token = req.headers.authorization?.replace('Bearer ', '')
    
    if (!token) {
      return res.status(401).json({
        code: 401,
        message: '未授权访问',
      })
    }

    try {
      await this.jwtService.verifyAsync(token)
      next()
    } catch (error) {
      res.status(403).json({
        code: 403,
        message: '令牌无效',
      })
    }
  }
}

5. 图片处理和优化

集成图片处理服务:

typescript
import { Controller, Get, Res, Query } from '@nestjs/common'
import { Response } from 'express'
import { join } from 'path'
import * as sharp from 'sharp'

@Controller('api/image')
export class ImageController {
  @Get('resize')
  async resizeImage(
    @Query('path') path: string,
    @Query('width') width: number,
    @Query('height') height: number,
    @Res() res: Response,
  ) {
    const fullPath = join(process.cwd(), 'upload', path)
    
    try {
      const image = sharp(fullPath)
      
      // 调整尺寸
      if (width && height) {
        image.resize(width, height, { fit: 'cover' })
      } else if (width) {
        image.resize(width, null)
      } else if (height) {
        image.resize(null, height)
      }
      
      // 优化图片质量
      image.jpeg({ quality: 80 })
      
      const buffer = await image.toBuffer()
      
      res.set({
        'Content-Type': 'image/jpeg',
        'Cache-Control': 'public, max-age=31536000', // 1年缓存
      })
      
      res.send(buffer)
    } catch (error) {
      res.status(404).json({ message: '图片不存在' })
    }
  }
}

前端调用:

vue
<img 
  :src="`${apiBaseUrl}/api/image/resize?path=${imagePath}&width=200&height=200`"
  alt="缩略图"
/>

性能优化

1. 启用缓存策略

typescript
ServeStaticModule.forRootAsync({
  useFactory: () => {
    return [
      {
        rootPath: join(process.cwd(), 'upload'),
        serveRoot: '/api/static',
        serveStaticOptions: {
          cacheControl: true,
          maxAge: 86400000,        // 1天
          immutable: true,          // 不变资源
          etag: true,               // 启用 ETag
          lastModified: true,       // 启用 Last-Modified
        },
      },
    ]
  },
})

2. CDN 加速配置

生产环境建议使用 CDN:

typescript
// 环境变量配置
const CDN_BASE_URL = process.env.CDN_BASE_URL || ''

// 在响应中添加 CDN 地址
@Controller('upload')
export class UploadController {
  @Post('image')
  async upload(@UploadedFile() file) {
    const cdnUrl = CDN_BASE_URL 
      ? `${CDN_BASE_URL}/api/static/${file.filename}`
      : `/api/static/${file.filename}`
    
    return {
      code: 200,
      data: {
        url: cdnUrl,
      },
    }
  }
}

3. Gzip 压缩

main.ts 中启用压缩:

typescript
import { NestFactory } from '@nestjs/core'
import compression from 'compression'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  
  // 启用 gzip 压缩
  app.use(compression())
  
  await app.listen(3000)
}

4. 浏览器缓存策略

根据不同文件类型设置不同的缓存时间:

typescript
import { Injectable, NestMiddleware } from '@nestjs/common'
import { Request, Response, NextFunction } from 'express'
import { extname } from 'path'

@Injectable()
export class CacheControlMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const ext = extname(req.url).toLowerCase()
    
    // 图片文件:长期缓存
    if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'].includes(ext)) {
      res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
    }
    // CSS/JS:中等时长缓存
    else if (['.css', '.js'].includes(ext)) {
      res.setHeader('Cache-Control', 'public, max-age=86400')
    }
    // 其他文件:短期缓存
    else {
      res.setHeader('Cache-Control', 'public, max-age=3600')
    }
    
    next()
  }
}

Nginx 部署方案

为什么生产环境推荐 Nginx?

虽然 ServeStaticModule 可以满足基本需求,但生产环境建议使用 Nginx 提供静态文件服务:

优势:

  • ✅ 更高的性能和并发处理能力
  • ✅ 更低的内存占用
  • ✅ 更好的缓存控制
  • ✅ 支持断点续传
  • ✅ 内置 Gzip 压缩
  • ✅ 负载均衡支持

Nginx 配置示例

nginx
server {
    listen 80;
    server_name example.com;

    # 静态文件服务
    location /api/static/ {
        alias /var/www/nest-admin/upload/;
        
        # 缓存配置
        expires 30d;
        add_header Cache-Control "public, immutable";
        
        # 跨域配置(如需要)
        add_header Access-Control-Allow-Origin "*";
        
        # 日志
        access_log /var/log/nginx/static-access.log;
        error_log /var/log/nginx/static-error.log;
    }

    # API 代理
    location /api/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    # 前端静态资源
    location / {
        root /var/www/nest-vue-admin/dist;
        try_files $uri $uri/ /index.html;
        expires 1d;
    }
}

Docker Compose 部署

yaml
version: '3.8'

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./upload:/var/www/nest-admin/upload
      - ./dist:/var/www/nest-vue-admin/dist
    depends_on:
      - api
  
  api:
    build: ./nest-admin
    environment:
      - NODE_ENV=production
    volumes:
      - ./upload:/app/upload

volumes:
  upload:

安全考虑

1. 防止目录遍历攻击

typescript
import { Injectable, NestMiddleware } from '@nestjs/common'
import { Request, Response, NextFunction } from 'express'
import { join, normalize } from 'path'

@Injectable()
export class SecurityStaticMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const basePath = join(process.cwd(), 'upload')
    const requestedPath = normalize(join(basePath, req.url.replace('/api/static/', '')))
    
    // 确保请求路径在允许的目录内
    if (!requestedPath.startsWith(basePath)) {
      return res.status(403).json({
        code: 403,
        message: '禁止访问',
      })
    }
    
    next()
  }
}

2. 限制文件类型访问

typescript
// 只允许访问特定类型的文件
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.doc', '.docx']

if (!allowedExtensions.some(ext => requestedPath.endsWith(ext))) {
  return res.status(403).json({
    code: 403,
    message: '不允许访问此文件类型',
  })
}

3. 防盗链配置

Nginx 防盗链:

nginx
location /api/static/ {
    valid_referers none blocked example.com *.example.com;
    
    if ($invalid_referer) {
        return 403;
    }
    
    alias /var/www/nest-admin/upload/;
}

4. 敏感文件保护

typescript
// 阻止访问隐藏文件和敏感文件
const blockedPatterns = ['/.', '/..', '.env', '.git', '.htaccess']

if (blockedPatterns.some(pattern => req.url.includes(pattern))) {
  return res.status(403).json({
    code: 403,
    message: '禁止访问',
  })
}

常见问题

Q1: 如何修改静态资源的访问路径?

修改 serveRoot 参数:

typescript
// 改为 /static
serveRoot: '/static'
// 访问:http://localhost:3000/static/avatar/xxx.jpg

// 改为根路径
serveRoot: ''
// 访问:http://localhost:3000/avatar/xxx.jpg

Q2: 如何解决跨域问题?

serveStaticOptions 中配置:

typescript
serveStaticOptions: {
  setHeaders: (res, path, stat) => {
    res.setHeader('Access-Control-Allow-Origin', '*')
    res.setHeader('Access-Control-Allow-Methods', 'GET')
  }
}

或在 Nginx 中配置(见上文)。

Q3: 上传的文件无法访问?

检查以下几点:

  1. 确认 upload 目录存在且有读权限
  2. 确认文件路径正确
  3. 检查应用是否重启
  4. 查看控制台错误日志
bash
# 检查目录权限
ls -la upload/

# 设置正确权限
chmod -R 755 upload/

Q4: 如何实现图片水印?

typescript
import * as sharp from 'sharp'

@Controller('api/watermark')
export class WatermarkController {
  @Get()
  async addWatermark(
    @Query('path') path: string,
    @Query('text') text: string,
    @Res() res: Response,
  ) {
    const imagePath = join(process.cwd(), 'upload', path)
    
    const image = await sharp(imagePath)
      .composite([
        {
          input: Buffer.from(
            `<svg><text x="50%" y="50%" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="40">${text}</text></svg>`
          ),
          gravity: 'center',
        },
      ])
      .toBuffer()
    
    res.set('Content-Type', 'image/jpeg')
    res.send(image)
  }
}

Q5: 如何统计文件下载次数?

typescript
@Controller('api/download')
export class DownloadController {
  constructor(private downloadLogService: DownloadLogService) {}

  @Get(':filename')
  async download(
    @Param('filename') filename: string,
    @Req() req: Request,
    @Res() res: Response,
  ) {
    const filePath = join(process.cwd(), 'upload', filename)
    
    // 记录下载日志
    await this.downloadLogService.log({
      filename,
      ip: req.ip,
      userAgent: req.headers['user-agent'],
      userId: req.user?.id,
      downloadedAt: new Date(),
    })
    
    res.download(filePath)
  }
}

Q6: 大文件下载优化?

使用流式传输:

typescript
import { createReadStream } from 'fs'

@Get('large-file/:filename')
async downloadLargeFile(
  @Param('filename') filename: string,
  @Res() res: Response,
) {
  const filePath = join(process.cwd(), 'upload', filename)
  const stat = await fs.promises.stat(filePath)
  
  res.writeHead(200, {
    'Content-Type': 'application/octet-stream',
    'Content-Length': stat.size,
    'Content-Disposition': `attachment; filename="${filename}"`,
  })
  
  const stream = createReadStream(filePath)
  stream.pipe(res)
}

总结

文件静态服务模块为 NestJS 应用提供了便捷的静态资源访问能力。

核心特性:

  • ✅ 简单易用的配置方式
  • ✅ 灵活的 URL 映射规则
  • ✅ 支持缓存控制和性能优化
  • ✅ 可扩展的中间件机制

最佳实践:

  • 📌 开发环境使用 ServeStaticModule 快速搭建
  • 📌 生产环境使用 Nginx 提供静态文件服务
  • 📌 合理设置缓存策略,提升访问速度
  • 📌 做好安全防护,防止目录遍历和盗链
  • 📌 对图片等资源进行压缩和优化
  • 📌 定期清理无用的静态文件

适用场景:

  • 用户上传的图片、文档访问
  • 系统生成的报表文件下载
  • 富文本编辑器中的图片展示
  • 头像、封面图等资源加载

通过合理配置和优化,可以为用户提供快速、安全的静态资源访问体验。