文件静态服务使用文档
概述
文件静态服务模块提供了上传文件的访问能力,通过 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 {}配置参数说明
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
rootPath | string | - | 静态文件的物理路径(绝对路径或相对路径) |
serveRoot | string | '' | URL 访问前缀,必须以 / 开头 |
exclude | string[] | [] | 需要排除的路径数组 |
serveStaticOptions | object | {} | 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.jpgQ2: 如何解决跨域问题?
在 serveStaticOptions 中配置:
typescript
serveStaticOptions: {
setHeaders: (res, path, stat) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET')
}
}或在 Nginx 中配置(见上文)。
Q3: 上传的文件无法访问?
检查以下几点:
- 确认
upload目录存在且有读权限 - 确认文件路径正确
- 检查应用是否重启
- 查看控制台错误日志
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 提供静态文件服务
- 📌 合理设置缓存策略,提升访问速度
- 📌 做好安全防护,防止目录遍历和盗链
- 📌 对图片等资源进行压缩和优化
- 📌 定期清理无用的静态文件
适用场景:
- 用户上传的图片、文档访问
- 系统生成的报表文件下载
- 富文本编辑器中的图片展示
- 头像、封面图等资源加载
通过合理配置和优化,可以为用户提供快速、安全的静态资源访问体验。