1
0
forked from erp-dev/erp

feat: added pgadmin container

This commit is contained in:
2025-11-19 14:08:05 +08:00
parent 400c0db6db
commit ad0b6a84d2
7 changed files with 1263 additions and 0 deletions

329
docs/FIX_FILE_UPLOAD_401.md Normal file
View File

@@ -0,0 +1,329 @@
# 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 请求
- ✅ 其他 ViewSetPrintingOrderViewSet、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

View File

@@ -0,0 +1,558 @@
# PlateOrder PATCH 请求返回 401 错误排查指南
## 📋 问题概述
前端反馈 PlateOrder 接口的 PATCH 请求返回 401 未授权错误。
## 🔍 可能的原因与解决方案
### 原因 1⃣: JWT Token 未发送或格式错误 ⭐⭐⭐⭐⭐
**最常见原因!** 前端请求时缺少 Authorization 头或格式不正确。
#### 检查方法:
在浏览器开发者工具中:
1. 打开 **Network** 标签页
2. 找到失败的 PATCH 请求
3. 查看 **Request Headers**
#### 正确的格式:
```http
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
```
#### ❌ 常见错误:
```http
# 1: Authorization
Content-Type: application/json
# 2: 使
Authorization: Token eyJhbGciOiJIUzI1NiIs... Bearer
# 3: Token
Authorization: Bearer undefined token
```
#### ✅ 前端修复代码:
```javascript
// Axios 示例
import axios from 'axios';
// 方法1: 每次请求都带 token
const token = localStorage.getItem('access_token');
axios.patch('/api/v1/plate-orders/123/', {
urgency_level: '加急'
}, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
// 方法2: 配置全局拦截器(推荐)
axios.interceptors.request.use(config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}, error => {
return Promise.reject(error);
});
// 之后所有请求自动带 token
axios.patch('/api/v1/plate-orders/123/', {
urgency_level: '加急'
});
```
```javascript
// Fetch 示例
const token = localStorage.getItem('access_token');
fetch('/api/v1/plate-orders/123/', {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
urgency_level: '加急'
})
});
```
---
### 原因 2⃣: JWT Token 已过期 ⭐⭐⭐⭐
Token 有效期为 7 天(根据 `settings.py` 配置),过期后需要刷新。
#### 检查方法:
```bash
# 在后端查看 Token 是否有效
curl -X GET http://localhost:8000/api/v1/plate-orders/ \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
# 如果返回 401说明 Token 已过期
```
#### ✅ 前端修复代码:
```javascript
// 实现 Token 自动刷新
import axios from 'axios';
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 如果正在刷新,将请求加入队列
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers['Authorization'] = 'Bearer ' + token;
return axios(originalRequest);
}).catch(err => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = localStorage.getItem('refresh_token');
try {
const response = await axios.post('/api/token/refresh/', {
refresh: refreshToken
});
const { access } = response.data;
localStorage.setItem('access_token', access);
originalRequest.headers['Authorization'] = 'Bearer ' + access;
processQueue(null, access);
return axios(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
// Token 刷新失败,跳转到登录页
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
```
---
### 原因 3⃣: 用户缺少 `change_plateorder` 权限 ⭐⭐⭐
用户可能有 `view_plateorder` 权限(能 GET但缺少 `change_plateorder` 权限(不能 PATCH/PUT
#### 检查方法:
```bash
# 使用诊断脚本检查
cd /home/f/coding/flower
uv run python diagnose_permissions.py <username>
# 或在 Django shell 中检查
python manage.py shell
>>> from django.contrib.auth import get_user_model
>>> User = get_user_model()
>>> user = User.objects.get(username='前端用户名')
>>> user.has_perm('printing.change_plateorder')
False # ❌ 如果返回 False说明缺少权限
```
#### ✅ 后端修复方法:
**方法1: 在 Django Admin 中添加**
1. 登录 Admin: http://localhost:8000/admin/
2. 进入 **用户****组** 管理
3. 找到目标用户/组
4.**权限** 区域勾选:
- `printing | plate order | Can change plate order`
5. 保存
**方法2: 使用 Django Shell**
```python
python manage.py shell
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
User = get_user_model()
user = User.objects.get(username='前端用户名')
# 添加单个权限
change_perm = Permission.objects.get(codename='change_plateorder')
user.user_permissions.add(change_perm)
# 或批量添加所有 PlateOrder 权限
perms = Permission.objects.filter(
content_type__app_label='printing',
content_type__model='plateorder'
)
user.user_permissions.add(*perms)
print("✅ 权限已添加!")
```
**方法3: 通过用户组管理(推荐)**
```python
from django.contrib.auth.models import Group, Permission
# 创建或获取用户组
group, created = Group.objects.get_or_create(name='业务员')
# 为组添加权限
perms = Permission.objects.filter(
content_type__app_label='printing',
content_type__model='plateorder'
)
group.permissions.add(*perms)
# 将用户加入组
user.groups.add(group)
```
---
### 原因 4⃣: CORS 问题导致预检请求失败 ⭐⭐
如果前端和后端在不同域名PATCH 请求会先发送 OPTIONS 预检请求。
#### 检查方法:
在 Network 标签页中查看是否有:
1. 一个 OPTIONS 请求(预检)
2. 紧随其后的 PATCH 请求
如果 OPTIONS 请求失败PATCH 不会发送。
#### ✅ 后端修复方法:
检查 `settings.py` 中的 CORS 配置:
```python
# flower/settings.py
# 确保已安装 django-cors-headers
INSTALLED_APPS = [
...
'corsheaders',
...
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # 放在最前面
'django.middleware.common.CommonMiddleware',
...
]
# 开发环境可以允许所有域名
CORS_ALLOW_ALL_ORIGINS = True
# 生产环境应该指定具体域名
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
"http://localhost:8080",
"https://your-frontend-domain.com",
]
# 允许携带认证信息
CORS_ALLOW_CREDENTIALS = True
# 允许的 HTTP 方法
CORS_ALLOW_METHODS = [
'DELETE',
'GET',
'OPTIONS',
'PATCH',
'POST',
'PUT',
]
# 允许的请求头
CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
]
```
---
### 原因 5⃣: 前端发送了错误的 Content-Type ⭐
某些情况下Content-Type 不匹配会导致认证失败。
#### 检查方法:
Request Headers 中应该有:
```
Content-Type: application/json
```
#### ❌ 常见错误:
```
Content-Type: text/plain
Content-Type: application/x-www-form-urlencoded
```
#### ✅ 前端修复:
```javascript
// Axios 会自动设置,但可以强制指定
axios.patch('/api/v1/plate-orders/123/', data, {
headers: {
'Content-Type': 'application/json'
}
});
// Fetch 必须手动设置
fetch('/api/v1/plate-orders/123/', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(data)
});
```
---
## 🛠️ 完整的前端解决方案
### 推荐的 Axios 配置React/Vue
```javascript
// api.js
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:8000/api/v1',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器:自动添加 Token
api.interceptors.request.use(
config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
// 响应拦截器:处理 401 自动刷新 Token
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
api.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
}).catch(err => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
window.location.href = '/login';
return Promise.reject(error);
}
try {
const response = await axios.post(
'http://localhost:8000/api/token/refresh/',
{ refresh: refreshToken }
);
const { access } = response.data;
localStorage.setItem('access_token', access);
processQueue(null, access);
originalRequest.headers.Authorization = `Bearer ${access}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
localStorage.clear();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
// 使用示例
// import api from './api';
// api.patch('/plate-orders/123/', { urgency_level: '加急' });
```
---
## 🔬 诊断工具
### 1. 后端权限诊断
```bash
cd /home/f/coding/flower
uv run python diagnose_permissions.py <username>
```
### 2. 手动测试 API
```bash
# 获取 Token登录
curl -X POST http://localhost:8000/api/token/ \
-H "Content-Type: application/json" \
-d '{"username": "your_username", "password": "your_password"}'
# 使用 Token 测试 PATCH
curl -X PATCH http://localhost:8000/api/v1/plate-orders/1/ \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"urgency_level": "加急"}'
```
### 3. 前端调试代码
```javascript
// 在控制台运行,检查 Token 是否存在
console.log('Access Token:', localStorage.getItem('access_token'));
console.log('Refresh Token:', localStorage.getItem('refresh_token'));
// 手动测试 PATCH 请求
fetch('/api/v1/plate-orders/1/', {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ urgency_level: '加急' })
})
.then(res => res.json())
.then(data => console.log('Success:', data))
.catch(err => console.error('Error:', err));
```
---
## 📊 错误代码对照表
| 状态码 | 含义 | 可能原因 |
|--------|------|----------|
| 401 | Unauthorized | Token 缺失、格式错误、已过期 |
| 403 | Forbidden | Token 有效但用户缺少权限 |
| 400 | Bad Request | 请求数据格式错误 |
| 405 | Method Not Allowed | 接口不支持该 HTTP 方法 |
---
## ✅ 检查清单
在联系后端开发人员之前,前端请确认:
- [ ] 请求头包含 `Authorization: Bearer <token>`
- [ ] Token 格式正确(不是 `undefined``null`
- [ ] Content-Type 设置为 `application/json`
- [ ] 请求 URL 正确(末尾有 `/`
- [ ] Token 未过期(可以用同样的 Token 测试 GET 请求)
- [ ] 浏览器控制台无 CORS 错误
- [ ] Network 标签显示请求实际发送了
如果以上都确认无误但仍然 401请提供
1. Network 标签中完整的 Request Headers
2. 完整的 Response包括状态码和响应体
3. 用户名(用于后端检查权限配置)
---
## 📞 获取帮助
如果问题仍未解决:
1. 运行诊断脚本并提供输出
2. 提供前端的完整错误信息
3. 提供用户名供后端检查权限
4. 提供 Network 标签的截图
---
**最后更新**: 2025-11-19