📁 分享模块 · 从混沌到最优标准架构

一次长达数十小时的全链路调试与重构纪实
📅 2026-03-13 👤 核心开发者 ⚙️ 最终版本 v3.0 (零代理、直传、短链)

📌 概述

本文档完整记录了 smart-upload-backend-v2 项目分享模块从完全不可用到最终实现最优标准架构的全部历程。我们经历了编译错误、守卫失效、MinIO 签名崩溃、路由冲突、前端 403、HTTPS 强制、下载播放而非保存等二十余个坑,最终构建出一个应用服务器仅负责权限验证、对象存储直传文件、短链优雅、体验完美的高性能分享系统。

🔥 初始状态

创建分享 → 403/500
获取列表 → 500
公开访问 → 403/500
下载 → 404/500

🔧 最终状态

短链 `/s/:code` 访问
302 重定向到预签名 MinIO URL
强制下载而非播放
支持密码、有效期、权限精细控制

🚀 标准架构

应用服务器只做“签发官”
MinIO 做“搬运工”
无状态令牌 + 预签名URL
极致性能与可扩展性

🔍 调试问题复盘

以下是整个调试过程中遇到的主要问题及其解决方案,可作为避坑手册。

问题现象根本原因修复方案
创建分享 403JWT 有效但返回 403JwtStrategy 返回的用户对象缺少 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_emailrequire_https
下载权限不足NO_DOWNLOAD_PERMISSIONaccess_levels 中未包含 downloadUPDATE shares SET access_levels = array_append(access_levels, 'download')
文件详情 404获取文件信息接口 404控制器要求文件所有者,但分享访问者不是所有者增加 FileControllergetFile 方法同时支持 JWT 和分享令牌,并添加 @Public()
下载返回空响应curl 收到空响应MinIO 客户端 presignedGetObject 调用参数不匹配导致崩溃修正为三个参数(bucket, object, expires),并处理 responseHeaders
文件软删除导致 404下载提示文件不存在文件已被软删除(isDeleted=true恢复文件或修改查询条件,创建分享时检查文件状态
预签名 URL 过期访问预签名 URL 返回 AccessDenied有效期 60 秒太短,操作不及时延长至 3600 秒
短链路由 404/s/:code 无法访问控制器前缀干扰或未在模块注册创建 ShortShareController,无前缀,注册到模块,并在 main.ts 中配置全局前缀排除
前端获取文件信息 401/404getPublicFileInfo 返回 401请求路径缺少 /api 前缀,且 FileController 未正确处理分享令牌添加 /api 前缀,修改 FileController 支持双认证
点击下载直接播放浏览器播放音频/视频预签名 URL 未带 response-content-disposition=attachment在服务层调用 getPresignedDownloadUrl 时传递该参数
require_https 永远为 true分享始终要求 HTTPSCreateShareDto 中默认值为 true,且实体 createNewShare 方法中也有后备默认值 true将 DTO 默认值改为 false,同时修改实体中 options.requireHttps ?? false

🏗️ 最优标准实施指南

基于调试经验,我们提炼出以下“逻辑与数据分离”的标准架构,该架构已在实际项目中验证可行。

核心思想

应用服务器 (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);
}

前端适配要点

环境变量示例

# 后端 .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

✅ 验证步骤

  1. 登录获取 JWT,创建公开分享(包含下载权限)→ 记录 shareCode
  2. 访问前端短链 http://www.fox360.cn:5173/s/{shareCode} → 应显示文件列表
  3. 点击下载按钮 → 应弹出下载对话框(而非直接播放)
  4. 检查数据库 require_https 字段应为 false(新分享)
  5. 使用不同浏览器或隐私模式测试无登录访问 → 同样可下载
💡 最终成功下载时,控制台输出的喜悦:
curl -L -o "下载的文件.mov" "预签名URL"
% Total % Received % Xferd Average Speed Time Time Time Current
100 85.0M 0 85.0M 0 0 75.2M 0 --:--:-- 0:00:01 --:--:-- 75.2M

📋 调试经验总结

🔮 未来扩展建议