forked from erp-dev/erp
559 lines
13 KiB
Markdown
559 lines
13 KiB
Markdown
# 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
|
||
|