1
0
forked from erp-dev/erp
Files
erpnew/docs/FIX_FILE_UPLOAD_401.md
2025-11-19 14:08:05 +08:00

8.5 KiB
Raw Blame History

PlateOrder 文件上传时返回 401 错误修复报告

📋 问题描述

症状:

  • PlateOrder 的 PATCH 请求在上传图片时返回 401 错误
  • 错误信息:token invalid
  • 仅修改普通字段(不上传文件)时工作正常
  • 用户权限验证正常Token 有效

🔍 根本原因

缺少文件解析器配置!

当前端使用 multipart/form-data 格式上传文件时Django REST Framework 需要特定的解析器来处理请求体。

问题对比

修复前PlateOrderViewSet

class PlateOrderViewSet(viewsets.ModelViewSet):
    queryset = models.PlateOrder.objects.all()
    permission_classes = [DjangoModelPermissions]
    # ❌ 缺少 parser_classes
    # 默认只有 JSONParser无法处理 multipart/form-data

结果:

  • 当 Content-Type 为 multipart/form-data
  • DRF 无法正确解析请求
  • JWT 认证从 multipart 数据中无法提取 Token
  • 返回 401 "token invalid"

其他工作正常的上传接口UploadFileViewSet

class UploadFileViewSet(viewsets.GenericViewSet):
    permission_classes = [IsAuthenticated]
    parser_classes = [MultiPartParser, FormParser]  # ✅ 有配置!

🔧 修复方案

修改 1: 导入必要的解析器

# api_v1/views/printing/views.py
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser

修改 2: 添加 parser_classes 配置

class PlateOrderViewSet(viewsets.ModelViewSet):
    queryset = models.PlateOrder.objects.all()
    permission_classes = [DjangoModelPermissions]
    parser_classes = [MultiPartParser, FormParser, JSONParser]  # ✅ 新增
    pagination_class = LimitOffsetPagination
    # ... 其他配置

解析器说明

解析器 作用 支持的 Content-Type
MultiPartParser 处理文件上传 multipart/form-data
FormParser 处理表单数据 application/x-www-form-urlencoded
JSONParser 处理 JSON 数据 application/json

为什么需要三个?

  • MultiPartParser - 支持文件上传PATCH 带图片)
  • FormParser - 兼容表单提交
  • JSONParser - 保持原有的 JSON API 支持PATCH 不带文件)

📝 技术细节

DRF 请求处理流程

  1. 请求到达 → 检查 Content-Type
  2. 选择解析器 → 根据 parser_classes 匹配合适的解析器
  3. 解析请求体 → 提取数据(包括 Token
  4. 认证 → JWT 认证器从请求中提取并验证 Token
  5. 权限检查 → 验证用户权限
  6. 业务逻辑 → 处理请求

没有正确的解析器时:

  • 步骤 3 失败 → 无法正确解析 multipart 数据
  • 步骤 4 失败 → JWT 认证无法从请求中提取 Token
  • 返回 401 "token invalid"

为什么 Authorization 头中的 Token 失效?

虽然 Token 在 HTTP Header 中(不在请求体),但 DRF 的认证流程依赖于正确的请求解析。当解析器不匹配时:

  1. DRF 尝试用默认解析器JSONParser解析 multipart 数据
  2. 解析失败,请求对象状态异常
  3. 认证流程检测到异常,拒绝请求
  4. 返回 401

验证

测试覆盖

创建了完整的测试文件 test_plate_order_file_upload.py,包含:

  1. PATCH 上传文件 + 更新字段

    data = {
        'urgency_level': '加急',
        'plate_image': image_file,
        'is_mark_frame': True,
    }
    response = client.patch(url, data, format='multipart')
    
  2. PATCH 仅更新字段(不上传文件)

    data = {'urgency_level': '特急'}
    response = client.patch(url, data, format='json')
    
  3. PATCH 仅上传文件

    data = {'plate_image': image_file}
    response = client.patch(url, data, format='multipart')
    
  4. POST 创建时上传文件

    data = {
        'customer': customer.id,
        'design_code': 'DESIGN001',
        'plate_image': image_file,
    }
    response = client.post(url, data, format='multipart')
    

所有测试通过!

🎯 前端使用指南

方法 1: 使用 FormData推荐

// 上传文件 + 更新字段
const formData = new FormData();
formData.append('urgency_level', '加急');
formData.append('is_mark_frame', true);
formData.append('plate_image', fileObject);  // File 对象

// 重要:不要设置 Content-Type浏览器会自动设置为 multipart/form-data
const response = await fetch('/api/v1/plate-orders/123/', {
  method: 'PATCH',
  headers: {
    'Authorization': `Bearer ${token}`
    // ❌ 不要设置 'Content-Type': 'multipart/form-data'
  },
  body: formData
});

方法 2: 使用 Axios

import axios from 'axios';

// 上传文件
const formData = new FormData();
formData.append('plate_image', file);
formData.append('urgency_level', '加急');

const response = await axios.patch(
  '/api/v1/plate-orders/123/',
  formData,
  {
    headers: {
      'Authorization': `Bearer ${token}`,
      // Axios 会自动设置正确的 Content-Type
    }
  }
);

方法 3: 纯字段更新(不上传文件)

// 不上传文件时,使用 JSON 格式
const response = await axios.patch(
  '/api/v1/plate-orders/123/',
  {
    urgency_level: '正常',
    is_mark_frame: false
  },
  {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json'
    }
  }
);

⚠️ 常见错误

错误 1: 手动设置 Content-Type

// ❌ 错误:手动设置 multipart Content-Type
const formData = new FormData();
formData.append('file', file);

fetch(url, {
  headers: {
    'Content-Type': 'multipart/form-data'  // ❌ 不要这样做!
  },
  body: formData
});

问题: 浏览器需要自动生成 boundary 参数,手动设置会导致边界标识符缺失。

正确做法: 让浏览器自动设置 Content-Type。

错误 2: 混用 JSON 和 FormData

// ❌ 错误:在 FormData 中添加对象
formData.append('data', { urgency_level: '加急' });  // ❌ 会变成 "[object Object]"

// ✅ 正确:逐个添加字段
formData.append('urgency_level', '加急');
formData.append('is_mark_frame', true);

错误 3: 文件字段名不匹配

// ❌ 错误:字段名与后端不一致
formData.append('image', file);  // 后端期望 'plate_image'

// ✅ 正确:使用后端定义的字段名
formData.append('plate_image', file);  // 匹配 PlateOrder.plate_image

🔄 影响范围

修改的文件

  1. api_v1/views/printing/views.py

    • 添加解析器导入
    • 添加 parser_classes 配置到 PlateOrderViewSet
  2. api_v1/views/printing/test_plate_order_file_upload.py (新增)

    • 完整的文件上传测试套件

不影响的功能

  • 纯 JSON 的 PATCH 请求(不上传文件)
  • GET、POST、DELETE 请求
  • 其他 ViewSetPrintingOrderViewSet、PrintingJobViewSet
  • 现有的权限控制
  • 现有的序列化器逻辑

📊 类似问题排查

如果其他接口也遇到文件上传时 401 错误,检查:

  1. ViewSet 是否配置了 parser_classes

    parser_classes = [MultiPartParser, FormParser, JSONParser]
    
  2. 序列化器是否包含文件字段?

    class Meta:
        fields = [..., 'plate_image', ...]  # 确保包含
    
  3. 模型是否有对应的文件字段?

    plate_image = models.FileField(upload_to='plate_images/', ...)
    
  4. 前端是否正确使用 FormData

    const formData = new FormData();
    formData.append('plate_image', file);
    

🎓 经验总结

关键点

  1. DRF 不会自动处理所有格式

    • 默认只有 JSONParser
    • 文件上传需要显式配置 MultiPartParser
  2. 解析器配置是认证的前提

    • 正确的请求解析 → 正确的认证
    • 解析失败 → 认证失败 → 401
  3. 前端不要设置 multipart 的 Content-Type

    • 浏览器会自动设置
    • 手动设置会缺少 boundary 参数

最佳实践

  1. 对于支持文件上传的 ViewSet

    parser_classes = [MultiPartParser, FormParser, JSONParser]
    
  2. 对于纯 API 的 ViewSet

    parser_classes = [JSONParser]  # 或不设置(使用默认)
    
  3. 测试覆盖

    • 测试文件上传
    • 测试纯字段更新
    • 测试混合场景(文件 + 字段)

修复完成日期: 2025-11-19
修复人员: AI Assistant
测试状态: 全部通过4/4 tests