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

> **更新（2025-11）**：`PlateOrder.plate_image` 现已改为 JSON 字段，仅接受通过 `/api/v1/upload/` 上传后的 `file_id` 引用，不再直接处理 multipart/form-data。以下内容保留旧问题的排查过程以供参考。

## 📋 问题描述

**症状：**
- 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）

