Files
ljwx-chat/问题解决记录.md
2025-11-29 07:36:44 +08:00

9.3 KiB
Raw Permalink Blame History

文件上传问题解决记录

📝 问题时间线

第一次报错:网络错误

时间: 初次排查 错误消息: "文件上传失败,请确认你的网络是否正常,并检查文件存储服务跨域配置是否正确" 错误类型: Network Error (XHR status = 0)

诊断过程

  1. ✅ 检查 MinIO 状态 → 未运行
  2. ✅ 检查配置 → S3 环境变量已配置
  3. ✅ 分析代码 → 错误发生在 src/services/upload.ts:190

根本原因

MinIO 服务未启动,导致无法访问 S3 端点。

解决方案

  1. 创建 docker-compose.minio.yml - MinIO 服务配置
  2. 创建 setup-minio.sh - 自动配置脚本
  3. 创建 start-all.sh - 一键启动脚本
  4. 启动 MinIO 服务

结果

✅ MinIO 正常运行,但上传仍然失败,进入第二阶段排查。


第二次报错:403 Forbidden

时间: MinIO 启动后 错误消息: "Forbidden" 错误类型: HTTP 403

诊断过程

  1. ✅ MinIO 运行正常 → curl http://localhost:9002/minio/health/live 返回 200
  2. ✅ Bucket 存在 → mc ls local/lingjingwanxiang 显示 bucket
  3. ✅ 访问策略正确 → mc anonymous get 显示 public
  4. ✅ CORS 配置正确 → 测试 OPTIONS 请求通过
  5. ❌ 预签名 URL 问题 → 深入分析代码

第一次修复尝试(错误)

方案: 在生成预签名 URL 后替换域名

// ❌ 错误的修复
const url = await getSignedUrl(this.client, command);
if (fileEnv.S3_PUBLIC_DOMAIN && fileEnv.S3_ENDPOINT) {
  return url.replace(
    new URL(fileEnv.S3_ENDPOINT).origin,
    new URL(fileEnv.S3_PUBLIC_DOMAIN).origin
  );
}

问题: AWS Signature V4 签名基于完整 URL 计算,修改 URL 导致签名失效。

测试结果:

  • ✅ 预签名 URL 包含正确的公网地址 192.168.1.83:9002
  • ❌ 上传返回 403 Forbidden(签名失效)

根本原因分析

AWS 签名机制:

签名 = HMAC-SHA256(
  Secret Key,
  String to Sign
)

String to Sign 包括:
- HTTP 方法 (PUT)
- 完整 URL (包括 host) ← 关键
- Headers
- Payload hash

当生成签名后修改 URL,签名验证失败 → 403 Forbidden

第二次修复(正确)

方案: 创建两个独立的 S3Client

class S3 {
  private readonly client: S3Client; // S3_ENDPOINT - 内部操作
  private readonly presignClient: S3Client; // S3_PUBLIC_DOMAIN - 预签名 URL

  constructor() {
    this.client = new S3Client({
      ...config,
      endpoint: fileEnv.S3_ENDPOINT, // host.docker.internal:9002
    });

    this.presignClient = new S3Client({
      ...config,
      endpoint: fileEnv.S3_PUBLIC_DOMAIN || fileEnv.S3_ENDPOINT, // 192.168.1.83:9002
    });
  }

  async createPreSignedUrl(key: string) {
    // 使用 presignClient 生成 URL
    return getSignedUrl(this.presignClient, command);
  }
}

优势:

  • ✅ 签名基于正确的 endpoint(公网地址)
  • ✅ 内部操作仍使用容器可访问的地址
  • ✅ 不需要事后修改 URL

🔍 技术深度分析

为什么需要两个不同的地址?

Docker 网络架构

┌─────────────────────────────────────┐
│         Host Machine                 │
│                                      │
│  ┌────────────────┐  ┌────────────┐ │
│  │ MinIO Container│  │ App Container││
│  │ Port: 9000     │  │ Port: 3210   ││
│  │ → :9002        │  │              ││
│  └────────────────┘  └────────────┘ │
│         ↑                  ↑         │
│         │                  │         │
└─────────┼──────────────────┼─────────┘
          │                  │
    (host.docker.internal:9002)
          │                  │
┌─────────┴──────────────────┴─────────┐
│         Browser                       │
│    192.168.1.83:3210                 │
│    192.168.1.83:9002                 │
└───────────────────────────────────────┘

关键点:

  • 容器内:通过 host.docker.internal 访问 host 服务
  • 浏览器:通过 192.168.1.83 访问 host 服务
  • 预签名 URL: 由服务端生成,但由浏览器使用

AWS S3 预签名 URL 工作流程

  1. 客户端请求:

    Browser → LobeChat: "我要上传文件"
    
  2. 服务端生成预签名 URL:

    const command = new PutObjectCommand({...});
    const url = await getSignedUrl(presignClient, command);
    // url = http://192.168.1.83:9002/bucket/file?X-Amz-Signature=...
    
  3. 签名计算:

    Canonical Request =
      "PUT\n" +
      "/bucket/file\n" +
      "X-Amz-Algorithm=AWS4-HMAC-SHA256&...\n" +
      "host:192.168.1.83:9002\n" +    ← 包含在签名中
      ...
    
    Signature = HMAC-SHA256(secretKey, stringToSign)
    
  4. 客户端使用:

    Browser → PUT http://192.168.1.83:9002/bucket/file?X-Amz-Signature=...
    MinIO → 验证签名 → ✅ 通过
    

为什么简单替换 URL 会失败?

// 错误方案
const url = await getSignedUrl(client, command);
// url = http://host.docker.internal:9002/...?X-Amz-Signature=abc123
// 签名基于: host:host.docker.internal:9002

const fixedUrl = url.replace('host.docker.internal', '192.168.1.83');
// fixedUrl = http://192.168.1.83:9002/...?X-Amz-Signature=abc123
// 但签名仍然是基于 host.docker.internal 计算的!

Browser → PUT http://192.168.1.83:9002/...?X-Amz-Signature=abc123
MinIO → 验证签名:
  期望的 host: 192.168.1.83:9002
  签名中的 host: host.docker.internal:9002
  → ❌ 签名不匹配 → 403 Forbidden

双客户端方案的优雅之处

// 正确方案
const presignClient = new S3Client({
  endpoint: 'http://192.168.1.83:9002', // 直接使用公网地址
});

const url = await getSignedUrl(presignClient, command);
// url = http://192.168.1.83:9002/...?X-Amz-Signature=xyz789
// 签名基于: host:192.168.1.83:9002 ← 正确!

Browser → PUT http://192.168.1.83:9002/...?X-Amz-Signature=xyz789
MinIO → 验证签名:
  期望的 host: 192.168.1.83:9002
  签名中的 host: 192.168.1.83:9002
  → ✅ 匹配 → 200 OK

📊 修复总结

修改的文件

文件 修改内容 状态
src/server/modules/S3/index.ts 添加 presignClient,使用双客户端架构 ✅ 已完成
src/server/modules/S3/index.test.ts 更新测试用例 ✅ 已完成

创建的工具

文件 用途
docker-compose.minio.yml MinIO 服务配置
setup-minio.sh 自动配置 MinIO
start-all.sh 一键启动所有服务
stop-all.sh 停止所有服务
test-upload.sh 诊断工具
rebuild-and-restart.sh 重建和重启脚本

创建的文档

文件 说明
🚀立即修复上传问题.md 完整操作指南
修复403错误.md 技术详解
快速修复.md 快速参考
文件上传故障排查.md 故障排查指南
问题解决记录.md 本文档

🎓 经验教训

1. Docker 网络的复杂性

在 Docker 环境中,需要区分:

  • 容器间通信(内部网络)
  • 容器与 host 通信(host.docker.internal)
  • 外部客户端访问(公网 IP / 域名)

2. 云服务 API 的签名机制

修改已签名的 URL 通常会导致签名失效,应该:

  • 在生成签名时使用正确的 endpoint
  • 而不是事后修改 URL

3. 分层诊断的重要性

问题排查应该层层递进:

  1. 服务是否运行?
  2. 网络是否连通?
  3. 权限是否正确?
  4. 签名是否有效?

4. 测试的重要性

每次修复后都应该:

  • 运行自动化测试
  • 进行端到端测试
  • 验证边界情况

🚀 下一步行动

  1. 立即执行修复:

    ./rebuild-and-restart.sh
    
  2. 验证功能:

    • 上传图片
    • 上传文档
    • 在聊天中查看上传的文件
  3. 清理临时文件 (可选):

    rm -f 立即修复文件上传.md
    rm -f README-文件上传修复.md
    rm -f 文件上传修复说明.md
    
  4. 提交代码 (可选):

    git add src/server/modules/S3/
    git commit -m "🐛 fix: use dual S3Client for presigned URLs
    
    - Add presignClient for generating presigned URLs with public domain
    - Keep client for internal S3 operations
    - Fix 403 Forbidden error caused by signature mismatch
    - Update tests to reflect dual client architecture"
    

问题解决日期: 2025-11-28 总耗时: ~2 小时(诊断 + 修复) 状态: ✅ 已修复,待应用和测试