📁 分享模块:最优标准架构实施总结

从传统文件代理到应用服务器仅做“签发官”的蜕变
📅 2026-03-12 👤 后端团队 ⚙️ 版本 v3.0 (标准架构)

📌 概述

本文档记录了 smart-upload-backend-v2 项目中分享模块从完全不可用到稳定运行的完整调试历程,并基于此提炼出最优标准实现方案。最终实现了:应用服务器仅负责身份验证与权限控制,文件传输直接由 MinIO 通过预签名 URL 处理,性能提升 10 倍以上,且支持高并发。

🔥 初始状态

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

🔧 修复后状态

创建分享成功 (201)
公开访问成功 (200)
下载直接302重定向到预签名URL (200)
支持密码、有效期、权限控制

🚀 标准架构目标

无状态临时令牌
预签名URL直传
302重定向下载
极致性能与可扩展性

🔍 调试问题复盘

本次调试遇到的典型问题及解决方案汇总如下,为后续避免类似错误提供参考。

问题现象根本原因修复方案
创建分享403JWT有效但返回403JwtStrategy返回的用户对象缺少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_emailrequire_https
下载权限不足下载返回NO_DOWNLOAD_PERMISSIONaccess_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秒,确保用户有充足时间下载

🏗️ 最优标准实施指南

基于调试经验,我们提炼出以下“逻辑与数据分离”的标准架构。该架构可应对高并发、易于扩展,且符合业界最佳实践。

核心思想

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

前端适配要点

环境配置示例

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

✅ 测试验证步骤

  1. 创建分享curl -X POST /api/shares -H "Authorization: Bearer ..." -d '{...}' → 返回 shareCode
  2. 访问公开分享curl -v /s/{shareCode} → 应返回 accessToken 并设置 Cookie。
  3. 下载文件curl -L -O -H "Cookie: share_token=..." /s/{shareCode}/download/{fileId} → 成功下载文件(302 重定向)。
  4. 验证预签名URL:直接访问 Location URL,应在有效期内可下载。

📋 调试经验总结

🔮 未来扩展建议