设备申请模块 - 出错点和解决方案

一、环境搭建与启动问题

1.1 MySQL 数据库不存在

问题原因:MySQL 服务已启动,但 labsystem 数据库尚未创建。

解决方法:

1
/usr/local/mysql/bin/mysql -u root -p5222039@Lst -e "CREATE DATABASE IF NOT EXISTS labsystem DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;"

1.2 后端启动命令

激活虚拟环境后运行

1
2
3
cd /Users/ergou/code/LabManagementSystem
source .venv/bin/activate
python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000

二、Pydantic 模型问题

2.1 默认值问题:default=None vs default=””

核心原则:编辑接口的字段要用 default=None,而非 default="" 或 default=0

写法 含义 适用场景
name: str = Field(..., min_length=1) 必填字段,不传报错 创建接口的必填项
name: str = Field(default="") 可选,默认空字符串 创建接口的可选项
name: Optional[str] = None 可选,默认 None 编辑接口的所有字段

为什么编辑接口要用 default=None?

编辑时需要区分「用户传了空字符串」和「用户没传」:

  • default="":无法区分,前端不传时默认为空字符串,会覆盖原有值
  • default=None:可以判断 if body.name is not None,只更新传了的字段

2.2 类型声明:Optional[int] vs int

错误写法:

1
totalQuantity: int = Field(default=None)  # ❌ 类型是 int,默认值是 None

正确写法:

1
totalQuantity: Optional[int] = Field(default=None)  # ✅ 明确允许 None

问题原因:虽然写了 default=None,但类型声明是 int,Pydantic 会把”没传”的字段当作需要验证的 int 类型,可能产生隐式转换。

三、SQLAlchemy 查询问题

3.1 查询对象创建

错误写法:

1
query = db.query  # ❌ 缺少括号和模型

正确写法:

1
query = db.query(Device)  # ✅ 创建针对 Device 表的查询对象

3.2 链式调用顺序

正确顺序:

1
2
3
4
5
query = db.query(Device)                    # 1. 创建查询
query = query.filter(Device.status == 0) # 2. 添加过滤条件
query = query.order_by(Device.id.desc()) # 3. 添加排序
total = query.count() # 4. 统计总数
rows = query.offset(0).limit(10).all() # 5. 执行查询

3.3 重复校验(编辑时排除自身)

创建时的校验(不排除):

1
exist = db.query(Device).filter(Device.serial_number == new_serial).first()

编辑时的校验(排除自身):

1
2
3
4
exist = db.query(Device).filter(
Device.serial_number == new_serial,
Device.id != device_id # 关键:排除自己
).first()

四、语法错误

4.1 HTTPException 分行

错误写法:

1
2
raise 
HTTPException(status_code=404, detail="未找到该设备") # ❌ 两行分开了

正确写法:

1
raise HTTPException(status_code=404, detail="未找到该设备")  # ✅ 一行

4.2 字符串格式化

错误写法:

1
like = "%{q}%"  # ❌ 普通字符串,{q} 不会被替换

正确写法:

1
like = f"%{q}%"  # ✅ f-string 格式化

4.3 字典键缺少引号

错误写法:

1
2
3
4
return {
data: [...], # ❌ 键名缺少引号
pagination: {...}
}

正确写法:

1
2
3
4
return {
"data": [...], # ✅ 键名加引号
"pagination": {...}
}

五、逻辑错误

5.1 可用数量为 0 时的误判

错误写法:

1
2
if not avail or avail is None:  # ❌ 当 avail=0 时,not avail 为 True
avail = body.totalQuantity

问题场景:用户明确传 availableQuantity: 0(表示无库存),会被错误地改为 totalQuantity。

正确写法:

1
2
if avail is None:  # ✅ 只判断是否为 None
avail = body.totalQuantity

5.2 db.commit() 和 db.refresh() 顺序

错误顺序:

1
2
db.refresh(row)  # ❌ 先刷新,再提交
db.commit()

正确顺序:

1
2
db.commit()      # ✅ 先提交到数据库
db.refresh(row) # 再从数据库刷新对象

六、参数校验

6.1 Query 参数枚举限制

错误写法:

1
scope: str = Query("public"|"admin", default="public")  # ❌ 字符串不能用位或

正确写法(使用 regex):

1
scope: str = Query("public", regex="^(public|admin)$")

七、数据库模型字段

7.1 字段命名:驼峰 vs 下划线

前端 JSON(驼峰) 数据库字段(下划线)
serialNumber serial_number
totalQuantity total_quantity
availableQuantity available_quantity
className class_name

八、状态映射逻辑

数据库 status 值 返回的 status_text 含义
0 + available_quantity > 0 "available" 可借
0 + available_quantity <= 0 "unavailable" 无库存
1 "maintenance" 维修中
2 "retired" 已报废

九、接口汇总

接口 方法 功能 参数 返回
/api/devices GET 设备列表 category, q, page, page_size, scope {data, pagination}
/api/devices/{id} GET 设备详情 device_id device 对象
/api/devices POST 创建设备 DeviceCreateBody {ok, id}
/api/devices/{id} PUT 更新设备 device_id, DeviceUpdateBody {ok, data}

十、注意事项

  • 编辑接口的字段校验:只校验传了的字段(非 None 的),未传的保持原值
  • 数量校验:availableQuantity 不能超过 totalQuantity
  • 编号唯一:创建时检查所有设备,编辑时排除自身
  • 状态值范围:category 03,status 02
  • 软下架:不使用 DELETE,改为 PUT 修改 status=2 或 availableQuantity=0
  • 用户端过滤:scope=public 只显示 status=0 的设备

文档生成时间:2026年7月15日

十一、测试过程中遇到的问题

11.1 Query 参数类型不匹配

错误信息:Input should be a valid integer, unable to parse string as an integer

错误信息:Input should be a valid date or datetime, input is too short

问题原因:Query 参数类型定义错误:

1
2
3
# ❌ 错误
status: int = Query(default="", ge=0, le=3) # int 类型默认值是 ""(空字符串)
data_from: datetime.date = Query(default="") # Query 不能直接接收 datetime.date

解决方法:

1
2
3
# ✅ 正确
status: Optional[int] = Query(default=None, ge=0, le=3)
date_from: Optional[str] = Query(default=None) # 先用 str 接收,再在内部转换

11.2 SQLAlchemy 过滤条件缺少模型前缀

错误信息:NameError: name “start_date” is not defined

问题原因:SQLAlchemy 查询必须使用 Model.field 的形式:

1
2
3
4
5
# ❌ 错误
query = query.filter(start_date >= date_from) # 缺少模型前缀

# ✅ 正确
query = query.filter(DeviceBooking.start_date >= date_from_obj) # 必须指定模型

11.3 日期字符串缺少转换

错误信息:TypeError: can only compare datetime.date to datetime.date

问题原因:Query 参数传入的是字符串,需要转为 date 对象才能和数据库字段比较:

1
2
3
4
5
6
# ❌ 错误
query = query.filter(DeviceBooking.start_date >= date_from) # date_from 是 str

# ✅ 正确
date_from_obj = datetime.datetime.strptime(date_from, "%Y-%m-%d").date()
query = query.filter(DeviceBooking.start_date >= date_from_obj)

11.4 日期格式错误缺少异常处理

错误信息:ValueError: time data “2026/07/17” does not match format “%Y-%m-%d”

问题原因:用户传入错误的日期格式(如用斜杠),没有捕获异常:

1
2
3
4
5
6
7
8
9
# ❌ 错误
date_from_obj = datetime.datetime.strptime(date_from, "%Y-%m-%d").date() # 可能抛出 ValueError

# ✅ 正确(添加 try-except)
try:
date_from_obj = datetime.datetime.strptime(date_from, "%Y-%m-%d").date()
query = query.filter(DeviceBooking.start_date >= date_from_obj)
except ValueError:
raise HTTPException(status_code=400, detail="日期格式错误,请使用 YYYY-MM-DD")

11.5 参数名拼写错误

错误信息:NameError: name “data_from” is not defined

问题原因:参数名和变量名不一致:

1
2
3
4
5
6
7
8
9
10
# ❌ 错误
date_from: Optional[str] = Query(default=None) # 参数名是 date_from
...
if data_from: # 使用了错误的名字 data_from
...

# ✅ 正确
date_from: Optional[str] = Query(default=None) # 参数名是 date_from
...
if date_from: # 使用正确的名字

11.6 测试验证流程

以下是完整的测试验证流程,所有步骤均已通过:

  1. 创建设备:POST /api/devices → 返回 ok, id
  2. 管理端查看:GET /api/devices?scope=admin → 显示所有设备
  3. 用户端查看:GET /api/devices → 只显示 status=0 的设备
  4. 类别筛选:GET /api/devices?category=0 → 只显示对应类别
  5. 创建预约:POST /api/device-bookings → 返回 ok, id, status=0
  6. 审核通过:PATCH /review {action:”approve”} → status=1
  7. 审核拒绝:PATCH /review {action:”reject”, rejectReason:”…”} → status=2
  8. 取消申请:PATCH /cancel → status=3
  9. 库存不足:设 availableQuantity=0,新申请返回 400
  10. 设备报废:PUT status=2,用户端不再显示该设备

11.7 测试结果

所有测试用例通过!

设备 CRUD、预约流程、审核流程、库存校验、软下架功能均正常工作。

十二、前后端联调遇到的问题

12.1 负责教师从下拉选择改为输入框

需求变更:负责教师字段从预设选项改为自由输入

修改文件:

  • frontend/src/components/EquipmentApplyModal.vue:删除 teacherOptions 数组,下拉框改输入框,form.teacherId → form.teacherName
  • backend/app/api/routes/device_booking.py:删除 teacherId 字段,teacherName 改为必填

12.2 学号、姓名、专业、班级改为必填项

需求变更:四个字段从可选改为必填

前端修改:

  • 标签添加红色星号 *
  • 输入框添加错误状态样式
  • errors 对象新增四个字段
  • validate() 函数添加空值校验

后端修改:

  • DeviceBookingCreateBody 中四个字段从 Field(default="") 改为 Field(..., description="xxx")

12.3 申请人姓名显示为破折号

问题现象:设备借用详情弹窗中”申请人”显示为 —

问题原因:前后端字段名不匹配

解决方法:修改后端返回字段名为 user_name

12.4 拒绝原因不显示

问题现象:申请被拒绝后,详情弹窗中不显示拒绝原因

问题原因:前端传递详情数据时缺少 reject_reason 字段

解决方法:

  • 在 openEquipmentDetail() 中添加 rejectReason: item.reject_reason ||
  • 在详情组件中添加拒绝原因显示区块,条件显示(状态为 rejected 且有拒绝原因)

12.5 筛选器选择后不自动搜索

问题现象:管理端页面选择分类/状态后,列表没有刷新,仍显示全部数据

问题原因:<select> 和 <input type="date"> 缺少 @change 事件

解决方法:为筛选控件添加 @change="search" 事件

涉及页面:

  • admin/src/views/AdminDeviceBookingsView.vue:状态选择框、日期选择框
  • admin/src/views/AdminDevicesView.vue:分类选择框

12.6 经验总结

注意事项:

  • 前后端字段名必须严格一致,否则数据无法正确传递
  • 修改前端表单时,后端数据模型也要同步更新
  • 下拉框和日期选择器需要添加 @change 事件才会触发搜索
  • 详情弹窗需要传递完整的数据字段,避免遗漏关键信息

12.7 录入设备500错误测试

问题现象:录入设备时,当 totalQuantity 传入超大数值(如 999999999999)会报 500 Internal Server Error

问题原因:

  • 数据库 total_quantity 字段类型是 Integer
  • MySQL Integer 最大值是 2147483647(约 21 亿)
  • 传入 999999999999(1 万亿)超出范围,导致数据库写入失败

测试结果:

测试场景 状态码 结果
正常录入 200 ✅ 通过
缺少必填字段 422 ✅ 返回字段缺失错误
空名称 422 ✅ 返回长度不足错误
重复编号 400 ✅ 返回编号已存在
category超出范围 422 ✅ 返回范围错误
status超出范围 422 ✅ 返回范围错误
超大totalQuantity(修复前) 500 ❌ 数据库溢出
超大totalQuantity(修复后) 422 ✅ 返回范围错误
availableQuantity超过totalQuantity 400 ✅ 返回数量校验错误
availableQuantity负数 422 ✅ 返回范围错误
特殊字符名称 200 ✅ 通过(SQLAlchemy自动转义)
空请求体 422 ✅ 返回字段缺失错误
无效JSON 422 ✅ 返回JSON解析错误

修复方法:

经验总结:

  • 数据库字段类型有限制,前端/API 需要做相应的校验
  • Integer 类型最大值约 21 亿,实际业务中设备数量不可能这么大,设为 10000 足够
  • 字符串字段要限制长度,避免数据库字段截断或报错
  • 500 错误是服务器内部错误,应该尽量避免,转为 4xx 客户端错误

(注:部分内容由豆包工作 AI 生成)