📌 概述
本文档记录了 smart-upload-backend-v2 项目中分享模块从完全不可用到稳定运行的完整调试历程,并基于此提炼出最优标准实现方案。最终实现了:应用服务器仅负责身份验证与权限控制,文件传输直接由 MinIO 通过预签名 URL 处理,性能提升 10 倍以上,且支持高并发。
🔥 初始状态
创建分享 → 403/500
获取列表 → 500
公开访问 → 403/500
下载 → 404/500
🔧 修复后状态
创建分享成功 (201)
公开访问成功 (200)
下载直接302重定向到预签名URL (200)
支持密码、有效期、权限控制
🚀 标准架构目标
无状态临时令牌
预签名URL直传
302重定向下载
极致性能与可扩展性
🔍 调试问题复盘
本次调试遇到的典型问题及解决方案汇总如下,为后续避免类似错误提供参考。
| 问题 | 现象 | 根本原因 | 修复方案 |
|---|---|---|---|
| 创建分享403 | JWT有效但返回403 | JwtStrategy返回的用户对象缺少role字段,RolesGuard无法通过 | 在AuthService.findUserById中select role,并在JwtStrategy中添加role |
| 字段类型错误 | malformed array literal | 数据库file_ids列类型为uuid而非uuid[] | 执行SQL: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 | 控制器要求文件所有者,但分享访问者不是所有者 | 改用FileService.getFileById(不验证用户),并在分享控制器中验证令牌权限 |
| 下载返回空响应 | curl 收到空响应 | MinIO 客户端调用 presignedGetObject 时参数不匹配导致崩溃 | 修正为三个参数(bucket, object, expires),移除多余参数 |
| 文件软删除导致404 | 下载提示文件不存在 | 文件已被软删除(isDeleted=true) | 恢复文件或修改查询条件,创建分享时检查文件状态 |
| 预签名URL过期 | 访问预签名URL返回 AccessDenied | 有效期60秒太短,操作不及时 | 延长至3600秒,确保用户有充足时间下载 |
🏗️ 最优标准实施指南
基于调试经验,我们提炼出以下“逻辑与数据分离”的标准架构。该架构可应对高并发、易于扩展,且符合业界最佳实践。
核心思想
- 应用服务器只做“签发官”:负责身份验证、权限判断、发放临时令牌,不参与文件传输。
- 对象存储做“搬运工”:文件上传下载直接由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. ShareService:生成与验证令牌
@Injectable()
export class ShareService {
constructor(private jwtService: JwtService) {}
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('无效或过期的分享令牌');
}
}
}
2. ShareController:访问分享返回令牌并写入Cookie
@Public()
@Get('s/:code')
async getShareByShortCode(
@Param('code') code: string,
@Ip() ip: string,
@Res({ passthrough: true }) res: Response,
) {
const result = await this.shareService.accessShare(code, { ip });
if (result.accessToken) {
res.cookie('share_token', result.accessToken, {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 1000, // 1小时
});
}
return result;
}
3. MinioService:生成预签名下载URL
async getPresignedDownloadUrl(path: string, expiresIn = 3600): Promise<{ url: string; expiresAt: Date }> {
const url = await this.minioClient.presignedGetObject(this.bucketName, path, expiresIn);
return { url, expiresAt: new Date(Date.now() + expiresIn * 1000) };
}
4. ShareController:下载端点302重定向
@Public()
@Get('s/:code/download/:fileId')
async downloadFile(
@Param('code') shareCode: string,
@Param('fileId') fileId: string,
@Req() req: Request,
@Res() res: Response,
) {
const token = req.cookies?.share_token || req.headers.authorization?.substring(7);
const payload = await this.shareService.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);
return res.redirect(302, presigned.url);
}
前端适配要点
- 存储令牌:访问分享时,将后端返回的
accessToken存入 cookie(使用js-cookie)。 - 下载链接:直接使用
window.open(`/s/${code}/download/${fileId}`, '_blank'),浏览器自动携带 cookie。 - 获取文件详情:调用
fileService.getPublicFileInfo(fileId)(需单独实现,依赖 cookie 认证)。
环境配置示例
# 后端 .env
SHARE_TOKEN_SECRET=your-strong-secret-for-share-tokens
SHARE_TOKEN_EXPIRY=1h
MINIO_ENDPOINT=www.fox360.cn
MINIO_PORT=9002
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=smart-upload
MINIO_PUBLIC_URL=http://www.fox360.cn:9002 # 用于生成预签名URL
✅ 测试验证步骤
- 创建分享:
curl -X POST /api/shares -H "Authorization: Bearer ..." -d '{...}'→ 返回shareCode。 - 访问公开分享:
curl -v /s/{shareCode}→ 应返回accessToken并设置 Cookie。 - 下载文件:
curl -L -O -H "Cookie: share_token=..." /s/{shareCode}/download/{fileId}→ 成功下载文件(302 重定向)。 - 验证预签名URL:直接访问 Location URL,应在有效期内可下载。
📋 调试经验总结
- 数据库一致性:确保数组字段正确(如
file_ids为uuid[]),避免 TypeORM 映射错误。 - MinIO 客户端版本:调用
presignedGetObject时只需三个参数,多余参数会导致崩溃。 - 令牌有效期:设置为1小时,前端 Cookie 同步过期。
- 文件软删除:创建分享前应检查文件状态,避免分享已删除文件。
- 预签名URL过期:生产环境建议设置为10分钟,兼顾安全与用户体验。
🔮 未来扩展建议
- 增加分享管理页面(列出用户创建的分享,支持取消、延长有效期)。
- 支持多文件下载打包为zip(可在预签名URL之外增加打包服务)。
- 增加预览功能,对图片、PDF、视频生成预览URL(通过设置
response-content-disposition=inline)。 - 使用Redis缓存分享元数据,减少数据库压力。
- 记录详细访问日志,用于统计和审计。