forked from erp-dev/erp
330 lines
8.5 KiB
Markdown
330 lines
8.5 KiB
Markdown
# PlateOrder 文件上传时返回 401 错误修复报告
|
||
|
||
## 📋 问题描述
|
||
|
||
**症状:**
|
||
- PlateOrder 的 PATCH 请求在**上传图片**时返回 401 错误
|
||
- 错误信息:`token invalid`
|
||
- 仅修改普通字段(不上传文件)时工作正常
|
||
- 用户权限验证正常,Token 有效
|
||
|
||
## 🔍 根本原因
|
||
|
||
**缺少文件解析器配置!**
|
||
|
||
当前端使用 `multipart/form-data` 格式上传文件时,Django REST Framework 需要特定的解析器来处理请求体。
|
||
|
||
### 问题对比
|
||
|
||
#### ❌ 修复前(PlateOrderViewSet)
|
||
|
||
```python
|
||
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)
|
||
|
||
```python
|
||
class UploadFileViewSet(viewsets.GenericViewSet):
|
||
permission_classes = [IsAuthenticated]
|
||
parser_classes = [MultiPartParser, FormParser] # ✅ 有配置!
|
||
```
|
||
|
||
## 🔧 修复方案
|
||
|
||
### 修改 1: 导入必要的解析器
|
||
|
||
```python
|
||
# api_v1/views/printing/views.py
|
||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||
```
|
||
|
||
### 修改 2: 添加 parser_classes 配置
|
||
|
||
```python
|
||
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 上传文件 + 更新字段**
|
||
```python
|
||
data = {
|
||
'urgency_level': '加急',
|
||
'plate_image': image_file,
|
||
'is_mark_frame': True,
|
||
}
|
||
response = client.patch(url, data, format='multipart')
|
||
```
|
||
|
||
2. ✅ **PATCH 仅更新字段(不上传文件)**
|
||
```python
|
||
data = {'urgency_level': '特急'}
|
||
response = client.patch(url, data, format='json')
|
||
```
|
||
|
||
3. ✅ **PATCH 仅上传文件**
|
||
```python
|
||
data = {'plate_image': image_file}
|
||
response = client.patch(url, data, format='multipart')
|
||
```
|
||
|
||
4. ✅ **POST 创建时上传文件**
|
||
```python
|
||
data = {
|
||
'customer': customer.id,
|
||
'design_code': 'DESIGN001',
|
||
'plate_image': image_file,
|
||
}
|
||
response = client.post(url, data, format='multipart')
|
||
```
|
||
|
||
**所有测试通过!** ✅
|
||
|
||
## 🎯 前端使用指南
|
||
|
||
### 方法 1: 使用 FormData(推荐)
|
||
|
||
```javascript
|
||
// 上传文件 + 更新字段
|
||
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
|
||
|
||
```javascript
|
||
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: 纯字段更新(不上传文件)
|
||
|
||
```javascript
|
||
// 不上传文件时,使用 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
|
||
|
||
```javascript
|
||
// ❌ 错误:手动设置 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
|
||
|
||
```javascript
|
||
// ❌ 错误:在 FormData 中添加对象
|
||
formData.append('data', { urgency_level: '加急' }); // ❌ 会变成 "[object Object]"
|
||
|
||
// ✅ 正确:逐个添加字段
|
||
formData.append('urgency_level', '加急');
|
||
formData.append('is_mark_frame', true);
|
||
```
|
||
|
||
### 错误 3: 文件字段名不匹配
|
||
|
||
```javascript
|
||
// ❌ 错误:字段名与后端不一致
|
||
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 请求
|
||
- ✅ 其他 ViewSet(PrintingOrderViewSet、PrintingJobViewSet)
|
||
- ✅ 现有的权限控制
|
||
- ✅ 现有的序列化器逻辑
|
||
|
||
## 📊 类似问题排查
|
||
|
||
如果其他接口也遇到文件上传时 401 错误,检查:
|
||
|
||
1. **ViewSet 是否配置了 parser_classes?**
|
||
```python
|
||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||
```
|
||
|
||
2. **序列化器是否包含文件字段?**
|
||
```python
|
||
class Meta:
|
||
fields = [..., 'plate_image', ...] # 确保包含
|
||
```
|
||
|
||
3. **模型是否有对应的文件字段?**
|
||
```python
|
||
plate_image = models.FileField(upload_to='plate_images/', ...)
|
||
```
|
||
|
||
4. **前端是否正确使用 FormData?**
|
||
```javascript
|
||
const formData = new FormData();
|
||
formData.append('plate_image', file);
|
||
```
|
||
|
||
## 🎓 经验总结
|
||
|
||
### 关键点
|
||
|
||
1. **DRF 不会自动处理所有格式**
|
||
- 默认只有 JSONParser
|
||
- 文件上传需要显式配置 MultiPartParser
|
||
|
||
2. **解析器配置是认证的前提**
|
||
- 正确的请求解析 → 正确的认证
|
||
- 解析失败 → 认证失败 → 401
|
||
|
||
3. **前端不要设置 multipart 的 Content-Type**
|
||
- 浏览器会自动设置
|
||
- 手动设置会缺少 boundary 参数
|
||
|
||
### 最佳实践
|
||
|
||
1. **对于支持文件上传的 ViewSet**
|
||
```python
|
||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||
```
|
||
|
||
2. **对于纯 API 的 ViewSet**
|
||
```python
|
||
parser_classes = [JSONParser] # 或不设置(使用默认)
|
||
```
|
||
|
||
3. **测试覆盖**
|
||
- 测试文件上传
|
||
- 测试纯字段更新
|
||
- 测试混合场景(文件 + 字段)
|
||
|
||
---
|
||
|
||
**修复完成日期**: 2025-11-19
|
||
**修复人员**: AI Assistant
|
||
**测试状态**: ✅ 全部通过(4/4 tests)
|
||
|