From 4d811348ac435851b64456253cd8e1008a2872d1 Mon Sep 17 00:00:00 2001 From: wolin_liyujie <168155038@qq.com> Date: Mon, 21 Sep 2026 17:51:33 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=AD=A6=E7=94=9F=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/student.py | 8 ++++---- dao/student.py | 9 +++++---- database.py | 2 +- main.py | 8 ++++---- schemas/student.py | 2 +- utils/code_generator.py | 2 +- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/api/student.py b/api/student.py index 9299659..ac3edb9 100644 --- a/api/student.py +++ b/api/student.py @@ -1,14 +1,14 @@ from fastapi import APIRouter,Depends,HTTPException -from sqlalchemy.orm import Session from dao.student import * from schemas.student import StudentCreate - +from sqlalchemy.orm import Session student_router = APIRouter() @student_router.post("/student") -def create_student(request:StudentCreate,db:Session,create = Depends(create_students)): +def create_student(request:StudentCreate,db:Session = Depends(get_db)): d = request.model_dump() r = create_students(d,db) if not r: raise HTTPException(status_code=500,detail='服务器繁忙,请稍后添加!') - return {'code':200,'detail':"添加学生成功"} \ No newline at end of file + return {'code':200,'detail':"添加学生成功"} + diff --git a/dao/student.py b/dao/student.py index 9014fe5..49991d7 100644 --- a/dao/student.py +++ b/dao/student.py @@ -1,16 +1,17 @@ from schemas.student import * +from fastapi import Depends from core.database import get_db from models.student import * from utils.code_generator import generate_no +from sqlalchemy.orm import Session - -def create_students(d,db): +def create_students(d,db:Session = Depends(get_db)): stu = Student(**d) try: db.add(stu) - generate_no("student_no", "STU", stu, db) db.commit() - except:return False + except: + return False else: return True diff --git a/database.py b/database.py index ecff48f..7ee23b9 100644 --- a/database.py +++ b/database.py @@ -10,4 +10,4 @@ engine = create_engine(db_url Base.metadata.create_all(engine) db = SessionLocal() -db.commit() \ No newline at end of file +db.commit() #创建全部表 \ No newline at end of file diff --git a/main.py b/main.py index a0cfb07..d8e7658 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from api.example import example_router from middleware import log_middleware -from api.statistics import statistics_router +# from api.statistics import statistics_router import uvicorn from api.student import student_router app = FastAPI() @@ -12,14 +12,14 @@ from api.class_api import classes_router app.include_router(classes_router,tags=['班级接口'],prefix="/classes") #-------------李玉杰-------------- -app.include_router(student_router) +app.include_router(student_router,tags=['学生接口']) #-------------张义--------------- #-------------薄鑫--------------- #-------------张昕浩--------------- -app.include_router(example_router) -app.include_router(statistics_router, prefix="/statistics") +# app.include_router(example_router) +# app.include_router(statistics_router, prefix="/statistics") #-------------曾凯--------------- diff --git a/schemas/student.py b/schemas/student.py index d4a044a..64c42bb 100644 --- a/schemas/student.py +++ b/schemas/student.py @@ -7,7 +7,7 @@ class StudentCreate(BaseModel): class_id: int consultant_id: int | None = None native_place: str | None = None - graduate_school: str | None = None + graduation_school: str | None = None major: str | None = None enrollment_date: date graduation_date: date | None = None diff --git a/utils/code_generator.py b/utils/code_generator.py index f4b48ce..3320511 100644 --- a/utils/code_generator.py +++ b/utils/code_generator.py @@ -33,7 +33,7 @@ def add_student( ''' - +from sqlalchemy.orm import Session def generate_no(field_name: str, prefix: str, db_object, db: Session): if getattr(db_object, field_name): return From 8b0cdbdb1003b775341369b78bb767396cdf4b5d Mon Sep 17 00:00:00 2001 From: wolin_liyujie <168155038@qq.com> Date: Mon, 21 Sep 2026 17:53:12 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=AD=A6=E7=94=9F=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/student.py | 2 +- main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/student.py b/api/student.py index ac3edb9..444dea2 100644 --- a/api/student.py +++ b/api/student.py @@ -4,7 +4,7 @@ from schemas.student import StudentCreate from sqlalchemy.orm import Session student_router = APIRouter() -@student_router.post("/student") +@student_router.post("") def create_student(request:StudentCreate,db:Session = Depends(get_db)): d = request.model_dump() r = create_students(d,db) diff --git a/main.py b/main.py index d8e7658..e15c6e2 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,7 @@ from api.class_api import classes_router app.include_router(classes_router,tags=['班级接口'],prefix="/classes") #-------------李玉杰-------------- -app.include_router(student_router,tags=['学生接口']) +app.include_router(student_router,tags=['学生接口'],prefix="/students") #-------------张义--------------- #-------------薄鑫--------------- From 30e6af308d9d13437d381a48db064414fc0f4278 Mon Sep 17 00:00:00 2001 From: wolin_liyujie <168155038@qq.com> Date: Mon, 21 Sep 2026 19:28:15 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=AD=A6=E7=94=9F=E6=8E=A5=E5=8F=A3=E7=9A=84=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=94=9F=E6=88=90studentno=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/student.py | 4 +++- dao/student.py | 37 ++++++++++++++++++++++++++----------- models/student.py | 2 +- schemas/student.py | 2 +- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/api/student.py b/api/student.py index 444dea2..50311ab 100644 --- a/api/student.py +++ b/api/student.py @@ -4,7 +4,7 @@ from schemas.student import StudentCreate from sqlalchemy.orm import Session student_router = APIRouter() -@student_router.post("") +@student_router.post("",summary='添加学生') def create_student(request:StudentCreate,db:Session = Depends(get_db)): d = request.model_dump() r = create_students(d,db) @@ -12,3 +12,5 @@ def create_student(request:StudentCreate,db:Session = Depends(get_db)): raise HTTPException(status_code=500,detail='服务器繁忙,请稍后添加!') return {'code':200,'detail':"添加学生成功"} + + diff --git a/dao/student.py b/dao/student.py index 49991d7..9fc678b 100644 --- a/dao/student.py +++ b/dao/student.py @@ -1,17 +1,32 @@ from schemas.student import * -from fastapi import Depends from core.database import get_db from models.student import * -from utils.code_generator import generate_no from sqlalchemy.orm import Session -def create_students(d,db:Session = Depends(get_db)): - stu = Student(**d) - try: - db.add(stu) - db.commit() - except: - return False - else: - return True +def create_students(d: dict, db: Session): + stu = Student(**d) + + try: + # 1. 添加到 Session + db.add(stu) + + # 2. flush,让 MySQL 先生成自增 id + db.flush() + + # 3. 根据 id 生成学生编号 + if not stu.student_no: + stu.student_no = f"STU{stu.id:04d}" + + # 4. 提交事务 + db.commit() + + # 5. 刷新对象,拿到数据库最终数据 + db.refresh(stu) + + return stu + + except Exception as e: + db.rollback() + print(f"插入学生失败: {e}") + return None \ No newline at end of file diff --git a/models/student.py b/models/student.py index ab805e4..881c9b8 100644 --- a/models/student.py +++ b/models/student.py @@ -10,7 +10,7 @@ class Student(Base): , comment = '学生编号,自增主键' ) student_no = Column(VARCHAR(50) - , unique = True) + ,nullable = True) student_name = Column(VARCHAR(50)) class_id = Column(Integer ,ForeignKey('class_info_detail.id') diff --git a/schemas/student.py b/schemas/student.py index 64c42bb..6265446 100644 --- a/schemas/student.py +++ b/schemas/student.py @@ -2,7 +2,7 @@ from pydantic import BaseModel from datetime import date class StudentCreate(BaseModel): - student_no: str|None = None + student_no:str|None = None student_name: str class_id: int consultant_id: int | None = None From a11da93412bff4328f3c9f3344efdecda2dc5600 Mon Sep 17 00:00:00 2001 From: wolin_liyujie <168155038@qq.com> Date: Mon, 21 Sep 2026 20:42:53 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=AD=A6=E7=94=9F=E6=8E=A5=E5=8F=A3=E7=9A=84=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=94=9F=E6=88=90studentno=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/statistics.py | 56 +++++++++++++++++++++++------------------------ api/student.py | 1 + dao/student.py | 16 ++++---------- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/api/statistics.py b/api/statistics.py index 082f7f4..37a3393 100644 --- a/api/statistics.py +++ b/api/statistics.py @@ -1,28 +1,28 @@ -from fastapi import APIRouter,HTTPException,Depends -from DoubaoTeam.core.database import get_db -from DoubaoTeam.models.student import Student -from DoubaoTeam.models.score import Score -from DoubaoTeam.dao.statistics import * -from DoubaoTeam.schemas.common import SuccessResponse -from sqlalchemy.orm import Session - - -statistics_router=APIRouter() - - -@statistics_router.get("/scores/age-over-30",response_model=SuccessResponse) -def api_age_over_thirty(db:Session=Depends(get_db)): - try: - age_over_thirty_list=age_over_thirty(Student,db) - return SuccessResponse(status_code=200,msg=age_over_thirty_list) - except NoExist as e: - raise HTTPException(status_code=404,detail=str(e)) -# 这边功能函数返回的是一个字典 -@statistics_router.get("/classes/gender-count",response_model=SuccessResponse) -def api_gender_count(db:Session=Depends(get_db)): - try: - result = gender_count(Student,db) - return SuccessResponse(status_code=200,msg=result) - except NoExist as e: - raise HTTPException(status_code=404,detail=str(e)) - +# from fastapi import APIRouter,HTTPException,Depends +# from DoubaoTeam.core.database import get_db +# from DoubaoTeam.models.student import Student +# from DoubaoTeam.models.score import Score +# from DoubaoTeam.dao.statistics import * +# from DoubaoTeam.schemas.common import SuccessResponse +# from sqlalchemy.orm import Session +# +# +# statistics_router=APIRouter() +# +# +# @statistics_router.get("/scores/age-over-30",response_model=SuccessResponse) +# def api_age_over_thirty(db:Session=Depends(get_db)): +# try: +# age_over_thirty_list=age_over_thirty(Student,db) +# return SuccessResponse(status_code=200,msg=age_over_thirty_list) +# except NoExist as e: +# raise HTTPException(status_code=404,detail=str(e)) +# # 这边功能函数返回的是一个字典 +# @statistics_router.get("/classes/gender-count",response_model=SuccessResponse) +# def api_gender_count(db:Session=Depends(get_db)): +# try: +# result = gender_count(Student,db) +# return SuccessResponse(status_code=200,msg=result) +# except NoExist as e: +# raise HTTPException(status_code=404,detail=str(e)) +# diff --git a/api/student.py b/api/student.py index 50311ab..c606935 100644 --- a/api/student.py +++ b/api/student.py @@ -14,3 +14,4 @@ def create_student(request:StudentCreate,db:Session = Depends(get_db)): + diff --git a/dao/student.py b/dao/student.py index 9fc678b..021ad90 100644 --- a/dao/student.py +++ b/dao/student.py @@ -8,25 +8,17 @@ def create_students(d: dict, db: Session): stu = Student(**d) try: - # 1. 添加到 Session db.add(stu) - - # 2. flush,让 MySQL 先生成自增 id + # 把 SQL 发给数据库执行 db.flush() - - # 3. 根据 id 生成学生编号 + # 根据 id 生成学生编号 if not stu.student_no: stu.student_no = f"STU{stu.id:04d}" - - # 4. 提交事务 db.commit() - - # 5. 刷新对象,拿到数据库最终数据 db.refresh(stu) - return stu - except Exception as e: db.rollback() print(f"插入学生失败: {e}") - return None \ No newline at end of file + return None + From 77aa63b17b6c9bf2c2fdc8744bbed67b4d35c1d6 Mon Sep 17 00:00:00 2001 From: wolin_liyujie <168155038@qq.com> Date: Mon, 21 Sep 2026 20:45:11 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=8E=A5=E5=8F=A3=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 学生板块接口实现说明.md | 524 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 524 insertions(+) create mode 100644 学生板块接口实现说明.md diff --git a/学生板块接口实现说明.md b/学生板块接口实现说明.md new file mode 100644 index 0000000..da038ec --- /dev/null +++ b/学生板块接口实现说明.md @@ -0,0 +1,524 @@ +# 学生板块: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,实际数据库约束、历史数据和接口联调仍需按清单验证。 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 6/8] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86=E6=96=87?= =?UTF-8?q?=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,实际数据库约束、历史数据和接口联调仍需按清单验证。 From 0b92b17283b036f0710183c5c3f9aa7e97145742 Mon Sep 17 00:00:00 2001 From: wolin_liyujie <168155038@qq.com> Date: Mon, 21 Sep 2026 21:09:38 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dao/student.py | 1 + models/student.py | 3 ++- schemas/student.py | 42 ++++++++++++++++++++++++++++++++---------- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/dao/student.py b/dao/student.py index 021ad90..0030b49 100644 --- a/dao/student.py +++ b/dao/student.py @@ -22,3 +22,4 @@ def create_students(d: dict, db: Session): print(f"插入学生失败: {e}") return None +# def updet_students(): \ No newline at end of file diff --git a/models/student.py b/models/student.py index 881c9b8..fc73e95 100644 --- a/models/student.py +++ b/models/student.py @@ -10,7 +10,8 @@ class Student(Base): , comment = '学生编号,自增主键' ) student_no = Column(VARCHAR(50) - ,nullable = True) + ,nullable = True + ,unique = True) student_name = Column(VARCHAR(50)) class_id = Column(Integer ,ForeignKey('class_info_detail.id') diff --git a/schemas/student.py b/schemas/student.py index 6265446..213ae53 100644 --- a/schemas/student.py +++ b/schemas/student.py @@ -1,18 +1,40 @@ from pydantic import BaseModel from datetime import date +from pydantic import BaseModel, ConfigDict, Field, model_validator class StudentCreate(BaseModel): - student_no:str|None = None - student_name: str - class_id: int - consultant_id: int | None = None - native_place: str | None = None - graduation_school: str | None = None - major: str | None = None + 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 = None - age: int - gender: str + 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): + # 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) From 3577495b6c5a42fece4da8b42176aaf2402fbc0a Mon Sep 17 00:00:00 2001 From: wolin_liyujie <168155038@qq.com> Date: Mon, 21 Sep 2026 21:39:13 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/student.py | 87 +++++++++++++++++++++++++++++++++++++++++++--- dao/student.py | 80 +++++++++++++++++++++++++++++++++++++++++- schemas/student.py | 22 ++++++------ 3 files changed, 173 insertions(+), 16 deletions(-) diff --git a/api/student.py b/api/student.py index c606935..996c47a 100644 --- a/api/student.py +++ b/api/student.py @@ -1,17 +1,96 @@ -from fastapi import APIRouter,Depends,HTTPException -from dao.student import * -from schemas.student import StudentCreate +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session + +from core.database import get_db +from schemas.student import StudentCreate, StudentUpdate +from dao import student as student_dao student_router = APIRouter() @student_router.post("",summary='添加学生') def create_student(request:StudentCreate,db:Session = Depends(get_db)): d = request.model_dump() - r = create_students(d,db) + r = student_dao.create_students(d,db) if not r: raise HTTPException(status_code=500,detail='服务器繁忙,请稍后添加!') return {'code':200,'detail':"添加学生成功"} +@student_router.put("/{id}", summary="修改学生") +def update_student( + id: int, + request: StudentUpdate, + db: Session = Depends(get_db), +): + # 只取前端真正传来的字段 + # 例如只传 age,就只修改 age + data = request.model_dump(exclude_unset=True) + student = student_dao.update_student(id, data, db) + # None 表示学生不存在 + if student is None: + raise HTTPException(status_code=404, detail="学生不存在") + # False 表示操作数据库失败 + if student is False: + raise HTTPException(status_code=500, detail="修改学生失败") + + return { + "code": 200, + "detail": "修改学生成功", + "data": student, + } + +@student_router.get("", summary="查询学生列表") +def get_students( + # 三个查询条件都可以不传 + student_no: str | None = None, + student_name: str | None = None, + class_id: int | None = None, + # 获取本次请求使用的数据库连接 + db: Session = Depends(get_db), +): + # 调用 DAO 查询数据库 + students = student_dao.get_students( + db=db, + student_no=student_no, + student_name=student_name, + class_id=class_id, + ) + + return { + "code": 200, + "detail": "查询成功", + "total": len(students), + "data": students, + } + +@student_router.get("/{id}", summary="查询学生详情") +def get_student(id: int, db: Session = Depends(get_db)): + # 根据路径中的 ID 查询学生 + student = student_dao.get_student_by_id(id, db) + + if not student: + # 404 表示请求的数据不存在 + raise HTTPException(status_code=404, detail="学生不存在") + + return { + "code": 200, + "detail": "查询成功", + "data": student, + } + +@student_router.delete("/{id}", summary="逻辑删除学生") +def delete_student(id: int, db: Session = Depends(get_db)): + student = student_dao.delete_student(id, db) + + # None 表示不存在,或者以前已经删除 + if student is None: + raise HTTPException(status_code=404, detail="学生不存在或已经删除") + + if student is False: + raise HTTPException(status_code=500, detail="删除学生失败") + + return { + "code": 200, + "detail": "删除学生成功", + } diff --git a/dao/student.py b/dao/student.py index 0030b49..e11ce68 100644 --- a/dao/student.py +++ b/dao/student.py @@ -22,4 +22,82 @@ def create_students(d: dict, db: Session): print(f"插入学生失败: {e}") return None -# def updet_students(): \ No newline at end of file +def get_student_by_id(student_id: int, db: Session): + # 同时判断 is_deleted,已经逻辑删除的学生不会被查到 + return db.query(Student).filter( + Student.id == student_id, + Student.is_deleted == 0, + ).first() + +def update_student(student_id: int, data: dict, db: Session): + # 第一步:先查询学生 + student = get_student_by_id(student_id, db) + + # 没有查到就返回 None + if not student: + return None + + try: + # 第二步:循环修改前端传来的字段 + # field 是字段名,value 是新的值 + for field, value in data.items(): + setattr(student, field, value) + + # 第三步:提交修改并重新读取数据 + db.commit() + db.refresh(student) + return student + + except Exception as e: + # 修改失败时回滚 + db.rollback() + print("修改学生失败:", e) + return False + +def get_students( + db: Session, + student_no: str | None = None, + student_name: str | None = None, + class_id: int | None = None, +): + # 第一步:只查询没有被删除的学生 + query = db.query(Student).filter(Student.is_deleted == 0) + + # 第二步:前端传了哪个条件,就增加哪个条件 + if student_no: + # 学号使用精确查询 + query = query.filter(Student.student_no == student_no) + + if student_name: + # 姓名使用模糊查询,例如“张”可以查到“张三” + query = query.filter(Student.student_name.like(f"%{student_name}%")) + + if class_id: + # 班级 ID 使用精确查询 + query = query.filter(Student.class_id == class_id) + + # 第三步:执行查询并返回全部结果 + return query.all() + +#逻辑删除学生 +def delete_student(student_id: int, db: Session): + # 第一步:查询学生 + student = get_student_by_id(student_id, db) + + if not student: + return None + + try: + # 第二步:不真正删除记录,只把删除标记改成 1 + student.is_deleted = 1 + + # 第三步:提交修改 + db.commit() + return student + + except Exception as e: + # 删除失败时回滚 + db.rollback() + print("删除学生失败:", e) + return False + diff --git a/schemas/student.py b/schemas/student.py index 213ae53..47f9b13 100644 --- a/schemas/student.py +++ b/schemas/student.py @@ -24,17 +24,17 @@ class StudentCreate(BaseModel): return self class StudentUpdate(BaseModel): - # 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) + # 全部设为可选,前端只传需要修改的字段 + student_no: str | None = None + student_name: str | None = None + class_id: int | None = None + consultant_id: int | None = None + native_place: str | None = None + graduation_school: str | None = None + major: str | None = None 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) + education: str | None = None + age: int | None = None + gender: str | None = None