From 85c33509d7875b7471b658a413bdf98db2610615 Mon Sep 17 00:00:00 2001 From: wolin_liyujie <168155038@qq.com> Date: Mon, 21 Sep 2026 20:49:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 学生板块接口实现说明.md | 524 ---------------------------------------- 1 file changed, 524 deletions(-) delete mode 100644 学生板块接口实现说明.md diff --git a/学生板块接口实现说明.md b/学生板块接口实现说明.md deleted file mode 100644 index da038ec..0000000 --- a/学生板块接口实现说明.md +++ /dev/null @@ -1,524 +0,0 @@ -# 学生板块:5 个必做接口完整实现 - -本文按当前项目的 FastAPI、Pydantic 2、SQLAlchemy 同步 Session 编写。代码按文件整理,可分别替换对应文件内容;本文仅提供实现文档,没有改动现有 Python 文件。 - -## 一、接口与约定 - -| 方法 | 路径 | 功能 | -| --- | --- | --- | -| GET | `/students` | 分页查询,按学号、姓名、班级筛选 | -| POST | `/students` | 新增学生,未提供学号时自动生成 | -| GET | `/students/{id}` | 查询未删除学生详情 | -| PUT | `/students/{id}` | 修改学生,只更新本次提交的字段 | -| DELETE | `/students/{id}` | 将 `is_deleted` 改为 1 | - -- `id` 是数据库自增主键,`student_no` 是业务学号,二者不同。 -- 学号精确匹配,姓名按包含关系匹配,班级按 `class_id` 精确匹配;多个条件同时生效。 -- 列表默认第 1 页、每页 20 条,每页最多 100 条;无结果返回空数组。 -- PUT 在这里约定为部分更新:未传字段保持原值;可选字段传 `null` 表示清空。 -- 已删除学生不出现在列表、详情中,也不能再修改;重复删除返回 404。 -- 删除学生不自动删除成绩、就业记录,其他模块查询时需要自行处理学生的删除状态。 -- 性别暂不规定枚举,仅要求非空且不超过数据库长度,避免擅自改变团队约定。 -- 年龄暂设为 0~150;毕业日期不能早于入学日期。 - -## 二、请求和响应模型:`schemas/student.py` - -作用:在进入接口业务代码前检查字段类型、长度和取值;定义返回给前端的数据结构。 - -```python -from datetime import date, datetime -from pydantic import BaseModel, ConfigDict, Field, model_validator - - -class StudentCreate(BaseModel): - # 1. 自动去除字符串首尾空格;禁止多传 id、is_deleted 等未声明字段。 - model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") - - # 2. 学号可省略或传 null,此时由后端生成;空字符串不合法。 - student_no: str | None = Field(default=None, min_length=1, max_length=50) - student_name: str = Field(min_length=1, max_length=50) - class_id: int = Field(gt=0) - consultant_id: int | None = Field(default=None, gt=0) - native_place: str | None = Field(default=None, max_length=100) - graduation_school: str | None = Field(default=None, max_length=100) - major: str | None = Field(default=None, max_length=100) - enrollment_date: date - graduation_date: date | None = None - education: str | None = Field(default=None, max_length=50) - age: int = Field(ge=0, le=150) - gender: str = Field(min_length=1, max_length=10) - - @model_validator(mode="after") - def check_dates(self): - # 3. 新增时两个日期都已确定,可以直接校验先后顺序。 - if self.graduation_date and self.graduation_date < self.enrollment_date: - raise ValueError("毕业日期不能早于入学日期") - return self - - -class StudentUpdate(BaseModel): - model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") - - # 1. 默认值为 None 是为了允许省略字段,不代表每个字段都能清空。 - student_no: str | None = Field(default=None, min_length=1, max_length=50) - student_name: str | None = Field(default=None, min_length=1, max_length=50) - class_id: int | None = Field(default=None, gt=0) - consultant_id: int | None = Field(default=None, gt=0) - native_place: str | None = Field(default=None, max_length=100) - graduation_school: str | None = Field(default=None, max_length=100) - major: str | None = Field(default=None, max_length=100) - enrollment_date: date | None = None - graduation_date: date | None = None - education: str | None = Field(default=None, max_length=50) - age: int | None = Field(default=None, ge=0, le=150) - gender: str | None = Field(default=None, min_length=1, max_length=10) - - @model_validator(mode="after") - def check_explicit_null(self): - # 2. model_fields_set 记录客户端实际提交过的字段。 - # 没传姓名可以;明确传 student_name: null 不可以。 - required = { - "student_no", "student_name", "class_id", - "enrollment_date", "age", "gender", - } - for name in required & self.model_fields_set: - if getattr(self, name) is None: - raise ValueError(f"{name} 不能为 null") - return self - - -class StudentOut(BaseModel): - # 允许 Pydantic 从 SQLAlchemy 对象的属性读取数据。 - model_config = ConfigDict(from_attributes=True) - - id: int - student_no: str | None - student_name: str | None - class_id: int | None - consultant_id: int | None - native_place: str | None - graduation_school: str | None - major: str | None - enrollment_date: date - graduation_date: date | None - education: str | None - age: int - gender: str - # 当前 Student 模型的实际字段名是 create_at 和 update_at。 - create_at: datetime | None - update_at: datetime | None - - -class StudentResponse(BaseModel): - code: int = 200 - detail: str = "操作成功" - data: StudentOut - - -class StudentListResponse(BaseModel): - code: int = 200 - detail: str = "查询成功" - total: int - page: int - page_size: int - data: list[StudentOut] - - -class StudentDeleteResponse(BaseModel): - code: int = 200 - detail: str = "删除学生成功" - id: int -``` - -`StudentOut` 的姓名、班级允许为空,是为了兼容当前数据库模型允许为空的历史记录;新增请求仍要求这些字段必填。 - -## 三、数据库操作:`dao/student.py` - -为方便在现有项目直接使用,下列 DAO 使用 `HTTPException` 返回明确业务错误。项目扩大后,可改成自定义业务异常,再由 API 统一转换。 - -```python -import logging -from contextlib import contextmanager - -from fastapi import HTTPException -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from sqlalchemy.orm import Session - -from models.student import Student -from models.class_model import Classes -from models.consultant import Consultant - -logger = logging.getLogger(__name__) - - -@contextmanager -def database_errors(db: Session): - # 统一处理数据库异常,避免每个接口重复写 try/except。 - try: - yield - except HTTPException: - # 业务检查失败:撤销本次事务,保留原来的 404/409/422 等状态。 - db.rollback() - raise - except IntegrityError: - # 外键、唯一约束等冲突,不能当作成功返回。 - db.rollback() - logger.exception("学生数据约束冲突") - raise HTTPException(409, "数据冲突,请检查学号、班级和顾问信息") from None - except SQLAlchemyError: - db.rollback() - logger.exception("学生数据库操作失败") - raise HTTPException(500, "数据库操作失败,请稍后重试") from None - - -def require_student(student_id: int, db: Session): - # 所有详情、更新和删除操作都复用这一步,排除逻辑删除记录。 - student = db.query(Student).filter( - Student.id == student_id, - Student.is_deleted == 0, - ).first() - if student is None: - raise HTTPException(404, "学生不存在或已删除") - return student - - -def check_references(data: dict, db: Session): - # 1. 仅检查本次涉及的关联字段;更新其他字段时不用重复检查。 - if "class_id" in data: - classroom = db.query(Classes).filter( - Classes.id == data["class_id"], - Classes.is_deleted == 0, - ).first() - if classroom is None: - raise HTTPException(422, "班级不存在或已删除") - - # 2. 顾问允许为 null,此时代表不关联顾问,无需查询。 - if data.get("consultant_id") is not None: - consultant = db.query(Consultant).filter( - Consultant.id == data["consultant_id"], - Consultant.is_deleted == 0, - ).first() - if consultant is None: - raise HTTPException(422, "顾问不存在或已删除") - - -def check_student_no(student_no: str, db: Session, exclude_id=None): - # 学号占用检查包含已删除记录,避免旧学号被其他学生重复使用。 - query = db.query(Student).filter(Student.student_no == student_no) - if exclude_id is not None: - # 修改时必须排除自己,否则原学号也会被判定为重复。 - query = query.filter(Student.id != exclude_id) - if query.first() is not None: - raise HTTPException(409, "学号已存在") - - -def get_student_list(db, page, page_size, student_no=None, - student_name=None, class_id=None): - with database_errors(db): - # 1. 先建立基础查询,只查询未删除学生。 - query = db.query(Student).filter(Student.is_deleted == 0) - # 2. 参数有值时才增加筛选条件,多个 filter 之间为 AND。 - if student_no is not None: - query = query.filter(Student.student_no == student_no) - if student_name is not None: - # 转义 % 和 _,让它们作为姓名的普通字符参与匹配。 - query = query.filter(Student.student_name.contains( - student_name, autoescape=True, - )) - if class_id is not None: - query = query.filter(Student.class_id == class_id) - # 3. 分页前统计符合条件的总数。 - total = query.count() - # 4. 固定按主键倒序,最新记录优先,再跳过前面页的数据。 - rows = query.order_by(Student.id.desc()).offset( - (page - 1) * page_size - ).limit(page_size).all() - return total, rows - - -def get_student_detail(student_id: int, db: Session): - with database_errors(db): - return require_student(student_id, db) - - -def create_students(data: dict, db: Session): - with database_errors(db): - # 1. 校验班级、顾问是否存在且未删除。 - check_references(data, db) - # 2. 用户指定学号时,先检查是否被占用。 - if data.get("student_no") is not None: - check_student_no(data["student_no"], db) - # 3. 创建 ORM 对象并加入当前事务。 - student = Student(**data) - db.add(student) - # 4. flush 执行 INSERT 并获取自增 id,但此时尚未提交事务。 - db.flush() - # 5. 未提供学号时生成 STU0001 格式;四位是最小宽度。 - if student.student_no is None: - generated_no = f"STU{student.id:04d}" - check_student_no(generated_no, db, exclude_id=student.id) - student.student_no = generated_no - # 6. 在提交前生成响应快照,避免提交后再查询失败而误报新增失败。 - db.flush() - db.refresh(student) - from schemas.student import StudentOut - result = StudentOut.model_validate(student) - # 7. 所有步骤成功后提交;失败由 database_errors 回滚。 - db.commit() - return result - - -def update_student(student_id: int, updates: dict, db: Session): - with database_errors(db): - # 1. 先确认学生存在,再检查更新内容。 - student = require_student(student_id, db) - if not updates: - raise HTTPException(422, "请至少提供一个需要修改的字段") - # 2. 若修改班级或顾问,校验新的关联对象。 - check_references(updates, db) - if "student_no" in updates: - check_student_no(updates["student_no"], db, exclude_id=student.id) - # 3. 合并新旧日期再检查,防止只修改一个日期时绕过校验。 - enrollment = updates.get("enrollment_date", student.enrollment_date) - graduation = updates.get("graduation_date", student.graduation_date) - if graduation is not None and graduation < enrollment: - raise HTTPException(422, "毕业日期不能早于入学日期") - # 4. 仅更新实际提交的字段;可选字段传 None 会清空数据库值。 - for name, value in updates.items(): - setattr(student, name, value) - # 5. 执行更新并读取模型自动维护的 update_at,构造响应后提交。 - db.flush() - db.refresh(student) - from schemas.student import StudentOut - result = StudentOut.model_validate(student) - db.commit() - return result - - -def delete_student(student_id: int, db: Session): - with database_errors(db): - # 1. 找到正常学生;不存在或已删除时返回 404。 - student = require_student(student_id, db) - # 2. 只改删除标记,不调用 db.delete,不执行物理删除。 - student.is_deleted = 1 - # 3. 提交后,所有带 is_deleted == 0 的查询都会排除此学生。 - db.commit() - return student_id -``` - -注意:自动生成的学号若已被手动指定的学号占用,会返回 409 并回滚。自增 ID 在回滚后可能留下空号,这是正常现象。 - -## 四、接口层:`api/student.py` - -```python -from fastapi import APIRouter, Depends, Path, Query -from sqlalchemy.orm import Session - -from core.database import get_db -from dao import student as student_dao -from schemas.student import ( - StudentCreate, StudentUpdate, StudentResponse, - StudentListResponse, StudentDeleteResponse, -) - -student_router = APIRouter() - - -@student_router.get("", response_model=StudentListResponse, summary="查询学生列表") -def list_students( - page: int = Query(default=1, ge=1, description="页码,从 1 开始"), - page_size: int = Query(default=20, ge=1, le=100, description="每页条数"), - student_no: str | None = Query(default=None, min_length=1, max_length=50), - student_name: str | None = Query(default=None, min_length=1, max_length=50), - class_id: int | None = Query(default=None, gt=0), - db: Session = Depends(get_db), -): - # 1. FastAPI 校验查询参数,并通过 get_db 注入本次请求的数据库会话。 - # 2. DAO 按条件查询,返回总数和本页数据。 - total, rows = student_dao.get_student_list( - db, page, page_size, student_no, student_name, class_id, - ) - # 3. 返回分页信息,便于前端计算总页数。 - return StudentListResponse( - total=total, page=page, page_size=page_size, data=rows, - ) - - -@student_router.post("", response_model=StudentResponse, summary="新增学生") -def create_student(request: StudentCreate, db: Session = Depends(get_db)): - # 1. 请求体通过 StudentCreate 校验后转为字典。 - # 2. DAO 完成关联检查、学号生成和事务提交。 - result = student_dao.create_students(request.model_dump(), db) - # 3. 返回完整数据,前端可以立即取得 id 和生成的 student_no。 - return StudentResponse(detail="添加学生成功", data=result) - - -@student_router.get("/{id}", response_model=StudentResponse, summary="查询学生详情") -def read_student( - id: int = Path(gt=0), - db: Session = Depends(get_db), -): - # 路径 id 必须是正整数;DAO 对不存在或已删除的记录返回 404。 - result = student_dao.get_student_detail(id, db) - return StudentResponse(detail="查询成功", data=result) - - -@student_router.put("/{id}", response_model=StudentResponse, summary="修改学生") -def update_student( - request: StudentUpdate, - id: int = Path(gt=0), - db: Session = Depends(get_db), -): - # 1. exclude_unset=True 只保留客户端实际传入的字段。 - # 不要换成 exclude_none=True,否则无法通过 null 清空顾问等字段。 - updates = request.model_dump(exclude_unset=True) - # 2. DAO 校验目标学生、关联字段、学号和合并后的日期,再提交修改。 - result = student_dao.update_student(id, updates, db) - return StudentResponse(detail="修改学生成功", data=result) - - -@student_router.delete("/{id}", response_model=StudentDeleteResponse, - summary="逻辑删除学生") -def delete_student( - id: int = Path(gt=0), - db: Session = Depends(get_db), -): - # DAO 只修改删除标记,成功提交后返回被删除学生的主键。 - deleted_id = student_dao.delete_student(id, db) - return StudentDeleteResponse(id=deleted_id) -``` - -## 五、路由和数据库衔接 - -当前 `main.py` 已经有以下代码,保留即可,不要重复注册: - -```python -from api.student import student_router -app.include_router(student_router, tags=["学生接口"], prefix="/students") -``` - -路由文件内部使用空路径或 `/{id}`,是因为 `/students` 前缀由 `main.py` 统一添加。 - -`core.database.get_db()` 负责创建会话并在请求结束后关闭。写入操作由 DAO 提交或回滚,读取操作无需提交。模型继续使用 `core.database.Base`,不要重新创建 Base。 - -### 学号唯一性:当前模型需要补充的约束 - -当前 `models/student.py` 的 `student_no` 没有唯一约束。DAO 的预检查可以处理一般重复输入,但两个并发请求可能同时通过检查;要可靠防重,需配合数据库唯一约束。 - -模型中的字段可改为: - -```python -student_no = Column(VARCHAR(50), nullable=True, unique=True) -``` - -保持允许 NULL,是因为自动编号流程先插入并获取 ID,再写入学号。 - -对于已经存在的表,修改模型或执行 `create_all()` 不会自动添加约束。先检查历史重复数据: - -```sql -SELECT student_no, COUNT(*) AS duplicate_count -FROM student_info_detail -WHERE student_no IS NOT NULL -GROUP BY student_no -HAVING COUNT(*) > 1; -``` - -确认无重复且该唯一约束尚不存在后,再在项目使用的数据库中执行一次: - -```sql -ALTER TABLE student_info_detail -ADD CONSTRAINT uk_student_no UNIQUE (student_no); -``` - -本文没有执行以上 SQL。该约束包含已逻辑删除的数据,与 DAO 的学号占用规则一致。 - -## 六、调用示例 - -按项目当前入口启动后,可在 `http://127.0.0.1:23333/docs` 调试。需要先配置 MySQL、创建表,并准备一个真实存在且未删除的班级。以下 `class_id: 1`、路径中的学生 ID 均为示例,应替换为实际值。 - -### 1. 新增学生 - -`POST /students` - -```json -{ - "student_name": "张三", - "class_id": 1, - "enrollment_date": "2026-09-01", - "age": 20, - "gender": "男", - "major": "软件技术" -} -``` - -成功 HTTP 状态为 200,响应包含 `code`、`detail` 和 `data`,其中 `data.id`、`data.student_no` 是后续操作使用的主键和学号。这里保留原接口成功返回 200 的约定。 - -### 2. 查询列表 - -```text -GET /students?page=1&page_size=20 -GET /students?student_no=STU0001 -GET /students?student_name=张&class_id=1 -``` - -响应包含 `total`、`page`、`page_size` 和 `data` 数组。超出实际页数时,`total` 仍为匹配总数,`data` 为空。 - -### 3. 查询详情 - -```text -GET /students/1 -``` - -### 4. 修改学生 - -`PUT /students/1` - -```json -{ - "student_name": "张三丰", - "age": 21, - "consultant_id": null -} -``` - -这次只更新姓名、年龄和顾问;顾问关联被清空,班级、日期等未传字段保持原值。 - -### 5. 逻辑删除 - -```text -DELETE /students/1 -``` - -```json -{ - "code": 200, - "detail": "删除学生成功", - "id": 1 -} -``` - -删除后数据库行仍存在,`is_deleted` 为 1;后续详情、更新、再次删除均返回 404。 - -## 七、验证清单与错误说明 - -| 场景 | 预期 | -| --- | --- | -| 不传学号新增 | 自动生成学号,响应带主键与学生信息 | -| 指定已占用学号新增或修改 | 409,事务回滚 | -| 班级不存在或已删除 | 422 | -| 顾问不存在或已删除 | 422 | -| 姓名为空白、年龄越界、日期格式错误 | 422 | -| 毕业日期早于入学日期 | 422,新增与部分更新都检查 | -| 查询参数 page=0 或 page_size=101 | 422 | -| 同时按学号、姓名和班级筛选 | 返回满足全部条件的未删除学生 | -| 查询无匹配记录 | 200,total=0,data=[] | -| 更新仅传 age | 只更新年龄,其他字段保留 | -| 更新 consultant_id=null | 清空顾问关联 | -| 更新 student_name=null 或空请求体 | 422 | -| 查询、修改或删除不存在的学生 | 404 | -| 删除已有学生后重新查询 | 列表不可见,详情为 404 | -| 数据库操作失败 | 500,写事务回滚,服务端记录日志 | - -主动抛出的业务错误通常返回 `{"detail": "错误说明"}`;FastAPI 参数校验错误的 `detail` 是包含字段位置的数组。这两种结构应由前端分别处理。 - -以上是供接入项目的实现代码和验证步骤;文档生成时未连接真实 MySQL,实际数据库约束、历史数据和接口联调仍需按清单验证。