📌 概述
本文档完整记录了 smart-upload-backend-v2 项目分享模块从完全不可用到最终实现最优标准架构的全部历程。我们经历了编译错误、守卫失效、MinIO 签名崩溃、路由冲突、前端 403、HTTPS 强制、下载播放而非保存等二十余个坑,最终构建出一个应用服务器仅负责权限验证、对象存储直传文件、短链优雅、体验完美的高性能分享系统。
🔥 初始状态
创建分享 → 403/500
获取列表 → 500
公开访问 → 403/500
下载 → 404/500
🔧 最终状态
短链 `/s/:code` 访问
302 重定向到预签名 MinIO URL
强制下载而非播放
支持密码、有效期、权限精细控制
🚀 标准架构
应用服务器只做“签发官”
MinIO 做“搬运工”
无状态令牌 + 预签名URL
极致性能与可扩展性
🔍 调试问题复盘
以下是整个调试过程中遇到的主要问题及其解决方案,可作为避坑手册。
| 问题 | 现象 | 根本原因 | 修复方案 |
|---|---|---|---|
| 创建分享 403 | JWT 有效但返回 403 | JwtStrategy 返回的用户对象缺少 role 字段,RolesGuard 无法通过 | 在 AuthService.findUserById 中 select role,并加入 JwtStrategy 返回的 payload |
| 字段类型错误 | malformed array literal | 数据库 file_ids 列类型为 uuid 而非 uuid[] | 执行 ALTER TABLE shares ALTER COLUMN file_ids TYPE uuid[] |
| 公开访问 403 | /public/:code 返回 403 | 全局 JwtAuthGuard 未跳过公开路由,或分享要求邮箱/HTTPS | 添加 @Public() 装饰器,修改数据库字段 require_email 和 require_https |
| 下载权限不足 | NO_DOWNLOAD_PERMISSION | access_levels 中未包含 download | UPDATE shares SET access_levels = array_append(access_levels, 'download') |
| 文件详情 404 | 获取文件信息接口 404 | 控制器要求文件所有者,但分享访问者不是所有者 | 增加 FileController 的 getFile 方法同时支持 JWT 和分享令牌,并添加 @Public() |
| 下载返回空响应 | curl 收到空响应 | MinIO 客户端 presignedGetObject 调用参数不匹配导致崩溃 | 修正为三个参数(bucket, object, expires),并处理 responseHeaders |
| 文件软删除导致 404 | 下载提示文件不存在 | 文件已被软删除(isDeleted=true) | 恢复文件或修改查询条件,创建分享时检查文件状态 |
| 预签名 URL 过期 | 访问预签名 URL 返回 AccessDenied | 有效期 60 秒太短,操作不及时 | 延长至 3600 秒 |
| 短链路由 404 | /s/:code 无法访问 | 控制器前缀干扰或未在模块注册 | 创建 ShortShareController,无前缀,注册到模块,并在 main.ts 中配置全局前缀排除 |
| 前端获取文件信息 401/404 | getPublicFileInfo 返回 401 | 请求路径缺少 /api 前缀,且 FileController 未正确处理分享令牌 | 添加 /api 前缀,修改 FileController 支持双认证 |
| 点击下载直接播放 | 浏览器播放音频/视频 | 预签名 URL 未带 response-content-disposition=attachment | 在服务层调用 getPresignedDownloadUrl 时传递该参数 |
require_https 永远为 true | 分享始终要求 HTTPS | CreateShareDto 中默认值为 true,且实体 createNewShare 方法中也有后备默认值 true | 将 DTO 默认值改为 false,同时修改实体中 options.requireHttps ?? false |
🏗️ 最优标准实施指南
基于调试经验,我们提炼出以下“逻辑与数据分离”的标准架构,该架构已在实际项目中验证可行。
核心思想
- 应用服务器只做“签发官”:负责身份验证、权限判断、发放临时令牌,不参与文件传输。
- 对象存储做“搬运工”:文件上传下载直接由 MinIO 通过预签名 URL 处理,流量不经过应用。
- 无状态设计:使用短期 JWT 令牌替代频繁的数据库查询,提升性能。
应用服务器 (NestJS) 对象存储 (MinIO)
│ │
│ 1. 创建分享 (POST /shares) │
│ → 生成分享码, 存元数据 │
│ │
│ 2. 访问分享 (GET /s/{code}) │
│ → 校验有效期/密码等 │
│ → 生成临时访问令牌 (JWT, 1小时) │
│ ← 返回令牌 + 文件元数据 │
│ │
│ 3. 下载文件 (GET /s/{code}/download/{fileId})│
│ → 验证令牌, 检查fileId权限 │
│ → 向MinIO请求预签名URL (3600秒) │
│ ← 302重定向到该URL │
│ │
│ 4. 浏览器直接访问预签名URL → 从MinIO下载 │
关键代码实现
1. 后端守卫支持 @Public()
// jwt-auth.guard.ts
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) { super(); }
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
2. 短链控制器(无前缀)
// short-share.controller.ts
@Controller()
export class ShortShareController {
constructor(private readonly shareService: ShareService) {}
@Public()
@Get('s/:code')
async getShareByShortCode(@Param('code') code: string, ...) {
const result = await this.shareService.accessShare(code, accessDto, ip);
res.cookie('share_token', result.accessToken, { maxAge: 3600000, httpOnly: false });
return result;
}
@Public()
@Get('s/:code/download/:fileId')
async downloadFileShort(...) {
const token = req.cookies?.share_token;
const { url } = await this.shareService.getPresignedDownloadUrlForShare(...);
return res.redirect(302, url);
}
}
3. 分享服务:令牌生成与验证
// share.service.ts
generateShareToken(shareId: string, fileIds: string[], ip?: string): string {
return this.jwtService.sign({ shareId, fileIds, ip });
}
verifyShareToken(token: string): any {
try { return this.jwtService.verify(token); } catch { throw new UnauthorizedException(); }
}
async getPresignedDownloadUrlForShare(..., token: string): Promise<{ url: string }> {
const payload = this.verifyShareToken(token);
if (!payload.fileIds.includes(fileId)) throw new ForbiddenException();
const file = await this.fileService.getFileById(fileId);
const presigned = await this.minioService.getPresignedDownloadUrl(file.path, 3600, {
'response-content-disposition': 'attachment' // 强制下载
});
return { url: presigned.url };
}
4. MinioService 支持 responseHeaders
// minio.service.ts
async getPresignedDownloadUrl(
path: string,
expiresIn = 3600,
responseHeaders?: Record,
): Promise<{ url: string; expiresAt: Date }> {
const url = await this.minioClient.presignedGetObject(
this.bucketName,
path,
expiresIn,
responseHeaders,
);
return { url, expiresAt: new Date(Date.now() + expiresIn * 1000) };
}
5. FileController 支持双认证(JWT 或分享令牌)
// file.controller.ts
@Get(':id')
@Public()
async getFileDetails(@Param('id') id: string, @User() user: any, @Req() req: Request) {
if (user) return this.fileService.getFileDetails(user.id, id);
const token = req.cookies?.share_token;
const payload = this.shareService.verifyShareToken(token);
if (!payload.fileIds.includes(id)) throw new ForbiddenException();
return this.fileService.getFileById(id);
}
前端适配要点
- 环境变量:
VITE_API_URL=http://www.fox360.cn:3000(后端根地址),VITE_API_BASE_URL=http://www.fox360.cn:3000/api(API前缀) - 公开 API 实例:
publicApi使用withCredentials: true,baseURL 为VITE_API_URL,请求路径为/s/${shareCode} - 获取文件详情:
publicApi.get(`/api/files/${fileId}`)(注意加/api) - 分享链接生成:创建成功后,强制使用
window.location.origin或环境变量中的前端地址拼接/s/${shareCode} - 路由守卫:
AuthGuard中增加对/s路径的放行(location.pathname.startsWith('/s'))
环境变量示例
# 后端 .env
FRONTEND_URL=http://www.fox360.cn:5173 # 用于生成分享链接
SHARE_TOKEN_SECRET=your-strong-secret
SHARE_TOKEN_EXPIRY=1h
MINIO_PUBLIC_URL=http://www.fox360.cn:9002
# 前端 .env.development
VITE_API_URL=http://www.fox360.cn:3000
VITE_API_BASE_URL=http://www.fox360.cn:3000/api
VITE_APP_ORIGIN=http://www.fox360.cn:5173
✅ 验证步骤
- 登录获取 JWT,创建公开分享(包含下载权限)→ 记录
shareCode - 访问前端短链
http://www.fox360.cn:5173/s/{shareCode}→ 应显示文件列表 - 点击下载按钮 → 应弹出下载对话框(而非直接播放)
- 检查数据库
require_https字段应为false(新分享) - 使用不同浏览器或隐私模式测试无登录访问 → 同样可下载
💡 最终成功下载时,控制台输出的喜悦:
curl -L -o "下载的文件.mov" "预签名URL"% Total % Received % Xferd Average Speed Time Time Time Current100 85.0M 0 85.0M 0 0 75.2M 0 --:--:-- 0:00:01 --:--:-- 75.2M
📋 调试经验总结
- 守卫是拦路虎:全局守卫必须正确读取
@Public(),否则即使控制器有注解也会被拦。 - MinIO 签名参数:
presignedGetObject在不同版本中参数个数不同,一定要用bucket, object, expires, respHeaders格式。 - 路径前缀要统一:
publicApi的 baseURL 应为根地址,请求路径不加/api,而文件详情等需加/api前缀。 - 默认值覆盖:
CreateShareDto中的默认值优先级高于实体列默认值,务必检查所有可能覆盖的地方。 - 强制下载:对于浏览器能播放的媒体,预签名 URL 必须加上
response-content-disposition=attachment。 - 端口分离:后端 API 端口 (3000) 与前端页面端口 (5173) 分离,分享链接必须指向前端,API 请求指向后端。
🔮 未来扩展建议
- 增加密码验证页面(前端表单 + 后端
/s/:code/verify-password) - 分享管理页面(列出、延长、撤销)
- 预览支持(为图片、PDF 等生成 inline 预签名 URL)
- 使用 Redis 缓存分享元数据,减少数据库查询
- 生产环境配置 Nginx 反向代理并启用 HTTPS