需求列表接口 422 Unprocessable Entity 排查与修复全记录
环境:Vue 3 + Element Plus + Vite(前端)/ FastAPI + SQLAlchemy(后端)
日期:2026-09-07
一、问题现象
需求列表页面请求报错:
GET http://localhost:5173/api/requirements?page=1&page_size=100&keyword=&priority=&status=&project_id=:projectId
422 Unprocessable Entity
后端接口定义:
python
def list_requirements(project_id: int, page: int = 1, page_size: int = 50,
status: str = "", priority: str = "", keyword: str = "",
db: Session = Depends(get_db), _: User = Depends(get_current_user)):
二、排查过程(四层排查)
第 1 层:看请求参数,怀疑占位符未替换
请求里 project_id=:projectId 带着字面量冒号写法,第一反应是前端模板字符串没替换占位符。
第 2 层:看组件代码,排除组件问题
js
const projectId = route.params.projectId || route.query.projectId || ''
...
const params = { page: 1, page_size: 100, keyword: '', priority: '', status: '' }
if (projectId) params.project_id = projectId // 有值才带,axios 会自动丢弃空值
组件层没问题。注意:axios 序列化时会静默丢弃 undefined 参数 ,project_id: undefined 不会出现在 URL 里。
第 3 层:看 api 封装,排除 api 层
js
list: (params) => request.get('/api/requirements', { params }), // 干净
api 层也没问题。
第 4 层:看路由和菜单 ------ 找到根因
关键线索:URL 里出现 :projectId 字面量,说明拼 URL 的代码里写了 :projectId 。组件和 api 层都没有这串字符,那它只可能来自浏览器地址栏本身。
路由配置:
js
{
path: 'projects/:projectId/requirements', // 项目跳转专用路由
name: 'Requirements',
component: () => import('@/views/requirement/index.vue'),
meta: { title: '需求管理', icon: 'Document', hidden: false }, // ← 显示在左侧菜单!
},
{
path: 'requirements', // 全局入口
name: 'RequirementList',
...
meta: { title: '需求管理', icon: 'Document', hidden: false },
},
根因实锤 :菜单生成逻辑会遍历所有 meta.hidden !== true 的子路由生成菜单项。路由里有两个"需求管理"入口,点击 projects/:projectId/requirements 那个时,Vue Router 把路径段 :projectId 原样解析成参数值:
点击菜单 → 地址栏 /projects/:projectId/requirements
→ route.params.projectId === ':projectId'
→ 请求带出 project_id=:projectId
→ 后端 int 转换失败 → 422
三、修复方案
前端(2 个文件)
1. router/index.js:项目跳转路由从菜单隐藏,全局入口保留
js
// projects/:projectId/requirements 改为 hidden: true(项目页跳转不受影响,路由仍可匹配)
meta: { title: '需求管理', icon: 'Document', hidden: true },
// requirements(全局入口)保持 hidden: false,成为菜单唯一入口
2. views/requirement/index.vue:兼容两种进入方式
js
const projectId = route.params.projectId || route.query.projectId || ''
// fetchData:有项目上下文才带 project_id
const params = { page: 1, page_size: 100, keyword: keyword.value, priority: filterPriority.value, status: filterStatus.value }
if (projectId) params.project_id = projectId
返回项目按钮加 v-if="projectId"(从菜单进入不显示)。
后端(1 个文件)
3. routers/requirement.py:project_id 改可选 + 修复 query 初始化 bug
python
def list_requirements(project_id: int | None = None, page: int = 1, page_size: int = 50, ...):
query = db.query(Requirement) # 无条件初始化(修复前:只在传了 project_id 时才创建 → 不传就 NameError 500)
if project_id is not None:
query = query.filter(Requirement.project_id == project_id)
四、知识点总结(踩坑记录)
1. FastAPI 422 两种报错,看 detail 区分根因
| detail 报错 | 含义 |
|---|---|
"msg": "field required", "type": "value_error.missing" |
参数缺失(必填没传) |
"msg": "value is not a valid integer", "type": "type_error.integer" |
类型错误(传了非数字) |
2. FastAPI 参数可选的关键:必须有默认值
| 写法 | 必填? |
|---|---|
project_id: int |
必填,不传 422 |
| `project_id: int | None = None` |
| `project_id: int | None` |
int | None 只是类型注解;= None 才让参数变可选。想让 FastAPI 参数可选,必须给默认值。
3. SQLAlchemy:db.query 无条件初始化
python
query = db.query(Requirement) # 先建"查全表"的查询对象
if project_id is not None: # 再按需叠加过滤
query = query.filter(Requirement.project_id == project_id)
模型里的 project_id 列只是表结构声明,不会自动按它过滤 ;db.query 是"查整张表",filter 才是加 WHERE 条件。条件分支里创建的变量,分支不执行时变量不存在(NameError → 500)。
4. Pydantic model_validate:展示用字段必须给默认值
python
class RequirementOut(BaseModel):
project_id: int
project_name: str = "" # ❗ 必须带默认值!
model_config = {"from_attributes": True}
model_validate(orm_obj) 从 ORM 对象的属性 取值,ORM 上只有 project 关系(req.project.name),没有扁平的 project_name 属性。不加默认值 = 必填字段 = ValidationError。
补充:RequirementUpdate 里加 project_id 也要用 int | None = None,否则编辑时只改标题不带 project_id 会 422。
5. Pydantic v2 API 对应关系
| Pydantic v1 | Pydantic v2 |
|---|---|
parse_obj(x) |
model_validate(x) |
from_orm(x) |
model_validate(x) + from_attributes: True |
instance.dict() |
instance.model_dump() |
6. Vue Router:useRoute vs useRouter,params vs query
useRoute():读当前路由状态(看 );useRouter():执行跳转(走)route.params:URL 路径 中的动态段(/projects/123/requirements→params.projectId = "123"),必须路由声明:xxxroute.query:URL 问号后 的查询参数(?projectId=123),无需声明,适合筛选条件- 两者值都是字符串
7. Vue 3 其他小知识点
@是 vite 路径别名(vite.config.js里alias: { '@': path.resolve(__dirname, 'src') }),等价于src/Object.assign(form, data)原地修改 reactive 对象(保持响应性);{ ...form }生成新对象(提交用)route.params.projectId || route.query.projectId || ''可兼容"路由参数 / query / 都不传"三种进入方式
五、延伸:需求新增/编辑/列表显示项目名称(改造清单)
后端
schemas/requirement.py:
python
class RequirementOut(BaseModel):
...
project_name: str = "" # 新增
...
class RequirementUpdate(BaseModel):
project_id: int | None = None # 新增(编辑可改项目)
routers/requirement.py:列表批量查项目名避免 N+1:
python
project_ids = {req.project_id for req in requirements}
project_map = {}
if project_ids:
project_map = {p.id: p.name for p in db.query(Project).filter(Project.id.in_(project_ids)).all()}
...
item["project_name"] = project_map.get(req.project_id, "")
create / update / get / tree 四个接口同样补 project_name。
前端(views/requirement/index.vue)
- 表格加列:
<el-table-column prop="project_name" label="所属项目" width="120" /> - 弹窗加下拉(有项目上下文锁定,无则可选):
html
<el-form-item label="所属项目" required>
<el-select v-model="form.project_id" style="width: 100%;" :disabled="!!projectId">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
form加project_id;openDialog编辑回填、新建默认路由项目;handleSubmit校验form.project_id并提交
六、验证清单
| 入口 | 期望 |
|---|---|
| 左侧菜单"需求管理" | URL /requirements,请求无 project_id,返回全部需求 |
| 项目页点项目名/需求按钮 | URL /projects/1/requirements,请求带 project_id=1,只返回该项目需求 |
| 接口返回 | 每条需求带 project_name 字段 |