📌 概述
本文档记录了 smart-upload-backend-v2 项目中分享模块从完全不可用到稳定运行的完整调试历程,并基于此提炼出最优标准实现方案。调试过程中解决了认证缺失、数据库字段错误、路由冲突、权限校验缺失、HTTPS强制、文件信息获取404等一系列问题。最终实现了创建分享、公开访问、下载文件等核心功能。以下内容可作为后续重构或新项目实现的权威参考。
🔥 初始状态
创建分享 → 403/500
获取列表 → 500
公开访问 → 403/500
下载 → 404/500
🔧 修复后状态
创建分享成功 (201)
公开访问成功 (200)
下载直接返回文件流 (200)
支持密码、有效期、权限控制
🚀 标准架构目标
无状态临时令牌
预签名URL直传
302重定向下载
极致性能与可扩展性
🔍 调试问题复盘
本次调试遇到的典型问题及解决方案汇总如下,为后续避免类似错误提供参考。
| 问题 | 现象 | 根本原因 | 修复方案 |
|---|---|---|---|
| 创建分享403 | JWT有效但返回403 | JwtStrategy返回的用户对象缺少role字段,RolesGuard无法通过 | 在AuthService.findUserById中select role,并在JwtStrategy中添加role |
| 路由冲突 | /shares/user被当作:id捕获 | 动态路由在前,静态路由在后 | 调整路由顺序,删除不必要的/user路径,改用查询参数 |
| 字段类型错误 | malformed array literal | 数据库file_ids列类型为uuid而非uuid[] | 执行SQL:ALTER TABLE shares ALTER COLUMN file_ids TYPE uuid[] |
| 手动格式化fileIds | 返回的fileId为"{",fileCount为字符串长度 | 服务层手动将数组格式化为字符串 | 移除手动格式化,让TypeORM自动处理数组 |
| IP拼接错误 | 数据库inet类型插入非法字符串 | 控制器将ip、userAgent等拼接后传给数据库 | 拆分参数,只传递纯IP给服务层 |
| 公开访问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') |
| 下载接口500 | Cannot read properties of undefined (reading 'shareCode') | 控制器中result.share为undefined | 在share.service.ts的downloadFile中返回完整的file对象 |
| 文件详情404 | 获取文件信息接口404 | 控制器要求文件所有者,但分享访问者不是所有者 | 临时前端用分享标题替代,根本方案需改用临时令牌绕过所有权检查 |
🏗️ 最优标准实施指南
基于调试经验,我们提炼出以下“逻辑与数据分离”的标准架构。该架构可应对高并发、易于扩展,且符合业界最佳实践。
核心思想
- 应用服务器只做“签发官”:负责身份验证、权限判断、发放临时令牌,不参与文件传输。
- 对象存储做“搬运工”:文件上传下载直接由MinIO/S3通过预签名URL处理,流量不经过应用。
- 无状态设计:使用短期JWT令牌替代频繁的数据库查询,提升性能。
应用服务器 (NestJS) 对象存储 (MinIO)
│ │
│ 1. 创建分享 (POST /shares) │
│ → 生成分享码, 存元数据 │
│ │
│ 2. 访问分享 (GET /s/{code}) │
│ → 校验有效期/密码等 │
│ → 生成临时访问令牌 (JWT, 1小时) │
│ ← 返回令牌 + 文件元数据 │
│ │
│ 3. 下载文件 (GET /s/{code}/download/{fileId})│
│ → 验证令牌, 检查fileId权限 │
│ → 向MinIO请求预签名URL (60秒) │
│ ← 302重定向到该URL │
│ │
│ 4. 浏览器直接访问预签名URL → 从MinIO下载 │
实施步骤详解
以下按阶段列出需修改的代码文件及核心逻辑,不包含具体代码,仅描述职责和交互。
阶段一:引入临时令牌
- 后端依赖:安装
jsonwebtoken,配置JWT_SECRET(用于分享令牌,与应用登录令牌分开)。 share.service.ts修改accessShare方法:在成功验证后,生成一个包含{ shareId, fileIds, ip }的JWT,有效期建议1小时。share.controller.ts在getShareByCode中返回新增的accessToken字段(可放在响应体或Set-Cookie中)。- 前端
SharePublic.jsx:接收令牌并存储(内存或cookie),后续下载请求在Authorization头中携带。
阶段二:下载改为302重定向
minio.service.ts新增方法getPresignedDownloadUrl(fileKey: string, expires = 60): Promise<string>,内部调用minioClient.presignedGetObject。share.service.ts新增verifyShareToken(token: string)方法,用于解析和验证JWT。share.controller.ts重构downloadFile:- 从请求头获取令牌,验证有效性。
- 从令牌中取出
fileIds,检查当前fileId是否在列表中。 - 根据
fileId查询文件路径(可从令牌预存或查数据库,建议预存以减负)。 - 调用
minioService.getPresignedDownloadUrl获取预签名URL。 - 使用
res.redirect(302, url)返回重定向。
- 前端
handleDownload只需使用window.open或动态<a>标签,并确保请求头携带令牌(若使用window.open无法添加自定义头,可将令牌放在cookie中或使用fetch预检后跳转,简单场景推荐cookie)。
阶段三:优化路径与前端体验
- 添加简短路由
/s/:code替代/share/:code,并在控制器中复用逻辑(保持向后兼容)。 - 前端路由配置同时支持
/share/:code和/s/:code,指向同一组件。 - 创建分享成功后,返回的分享链接改为
https://yourdomain.com/s/{shareCode}。 AuthGuard中将/s加入公开路径白名单。
阶段四:增强功能(密码/邮箱/IP限制)
- 密码验证:前端先展示密码表单,提交到
POST /s/:code/verify-password,后端验证正确后返回临时令牌,后续操作同前。 - 邮箱验证:类似密码,提交邮箱到后端,验证通过后返回令牌。
- IP限制:在生成令牌前检查访客IP是否在允许范围内,若不符合直接拒绝。
阶段五:可观测性(可选)
- 在生成令牌前异步记录访问日志(IP、时间、文件ID等),可使用队列或后台任务,不阻塞主流程。
- 在重定向下载前可记录下载事件。
关键文件修改清单
| 文件路径 | 主要修改点 |
|---|---|
src/modules/share/share.service.ts | 添加 generateShareToken, verifyShareToken;accessShare 返回令牌;downloadFile 改为权限验证(不查分享) |
src/modules/share/share.controller.ts | 公开路由返回令牌;下载接口改为302重定向;新增密码验证端点 |
src/modules/minio/minio.service.ts | 新增 getPresignedDownloadUrl 方法 |
src/modules/share/dto/share-response.dto.ts | 添加 accessToken 字段 |
src/router/index.js / App.jsx | 添加路由 /s/:code |
src/pages/SharePublic.jsx | 存储令牌,请求时携带;下载函数简化;支持密码表单 |
src/components/auth/AuthGuard.jsx | 将 /s 加入公开路径白名单 |
.env | 添加 SHARE_TOKEN_SECRET 配置 |
环境配置示例
# .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
📋 调试经验总结
- 调试步骤:从健康检查端点开始,逐步扩展;善用
curl直接测试后端接口,排除前端干扰。 - 数据库一致性:TypeORM的数组类型要求数据库列正确,迁移后务必验证。
- 角色与权限:JWT返回的用户对象必须包含角色字段,否则守卫无法通过。
- 路由顺序:动态路由应放在静态路由之后,避免冲突。
- 公开路由:必须显式标记
@Public()并确保全局守卫正确处理。 - 文件信息获取:公开分享下,不应要求用户登录或文件所有权;可通过临时令牌机制替代。
- 下载性能:永远不要通过应用服务器转发文件流,必须使用预签名URL+302重定向。
🔮 未来扩展建议
- 增加分享管理页面(列出用户创建的分享,支持取消、延长有效期)。
- 支持多文件下载打包为zip(可在预签名URL之外增加打包服务)。
- 增加预览功能,对图片、PDF、视频生成预览URL(通过
presignedGetObject设置response-content-disposition=inline)。 - 使用Redis缓存分享元数据,减少数据库压力。
- 记录详细访问日志,用于统计和审计。