diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85fcebd --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Python后端相关 +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# 前端相关 +node_modules/ +dist/ + +# 敏感配置(数据库密码、密钥绝对不能上传) +.env +.env.local +*.log + +# IDE & 系统文件 +.idea/ +.vscode/ +Thumbs.db +.DS_Store + +# Docker本地覆盖配置 +docker-compose.override.yml diff --git a/student_management_system_complete/frontend/index.html b/student_management_system_complete/frontend/index.html new file mode 100644 index 0000000..55bfb63 --- /dev/null +++ b/student_management_system_complete/frontend/index.html @@ -0,0 +1,1683 @@ + + + + + +学生管理系统 · 管理后台 + + + +
+ +
+
+

数据看板

+ + + + +
+
+
+
+
+
+ + + diff --git a/student_management_system_complete/frontend/run_frontend.bat b/student_management_system_complete/frontend/run_frontend.bat new file mode 100644 index 0000000..7eeb331 --- /dev/null +++ b/student_management_system_complete/frontend/run_frontend.bat @@ -0,0 +1,30 @@ +@echo off +chcp 65001 >nul +REM =================================================================== +REM OPTIONAL - standalone frontend server on http://127.0.0.1:5500 +REM +REM You normally do NOT need this: the backend already serves the UI +REM at http://127.0.0.1:8004/ . +REM +REM Use it only when you want to edit frontend/index.html and refresh +REM without restarting the backend. Port 5500 is whitelisted in the +REM backend .env CORS_ORIGINS, so the page can still reach the API. +REM =================================================================== + +cd /d "%~dp0" + +set "PY=python" +if exist "%~dp0..\.venv\Scripts\python.exe" set "PY=%~dp0..\.venv\Scripts\python.exe" +if exist "%~dp0..\student_management_system\.venv\Scripts\python.exe" set "PY=%~dp0..\student_management_system\.venv\Scripts\python.exe" + +echo. +echo Frontend only : http://127.0.0.1:5500 +echo Needs the backend running at http://127.0.0.1:8004 +echo Or just use http://127.0.0.1:8004/ directly and skip this. +echo. + +"%PY%" -m http.server 5500 --bind 127.0.0.1 + +echo. +echo [!] Frontend stopped. exit code = %ERRORLEVEL% +pause diff --git a/student_management_system_complete/requirements.txt b/student_management_system_complete/requirements.txt new file mode 100644 index 0000000..4112fa2 --- /dev/null +++ b/student_management_system_complete/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn[standard] +sqlalchemy +pydantic +pymysql \ No newline at end of file diff --git a/student_management_system_complete/start_all.bat b/student_management_system_complete/start_all.bat new file mode 100644 index 0000000..d311e9e --- /dev/null +++ b/student_management_system_complete/start_all.bat @@ -0,0 +1,50 @@ +@echo off +chcp 65001 >nul +setlocal +REM =================================================================== +REM One-click launcher for the student management system +REM +REM Since the backend now serves the frontend too, ONE process is enough: +REM http://127.0.0.1:8004/ -> admin UI +REM http://127.0.0.1:8004/docs -> Swagger +REM http://127.0.0.1:8004/api/v1/... -> REST API +REM +REM Prerequisites: +REM 1. MySQL is running and .env points at it +REM 2. Tables created: python init_db.py --seed +REM =================================================================== + +set "ROOT=%~dp0" + +if not exist "%ROOT%student_management_system\.env" ( + echo. + echo [!] .env not found. + echo cd student_management_system + echo copy .env.example .env + echo then edit DB_USER / DB_PASSWORD inside it. + echo. + pause + exit /b 1 +) + +echo. +echo Starting backend. The admin UI is served by the backend itself, +echo so no second process is needed. +echo. + +start "SMS-backend" "%ROOT%student_management_system\run_backend.bat" + +REM give uvicorn a moment to bind the port before opening the page +timeout /t 6 /nobreak >nul + +echo Opening browser ... +start "" "http://127.0.0.1:8004/" + +echo. +echo admin UI : http://127.0.0.1:8004/ +echo swagger : http://127.0.0.1:8004/docs +echo health : http://127.0.0.1:8004/health +echo. +echo Close the SMS-backend window to stop the service. +echo. +pause diff --git a/student_management_system_complete/student_management_system/.env.example b/student_management_system_complete/student_management_system/.env.example new file mode 100644 index 0000000..9252c3b --- /dev/null +++ b/student_management_system_complete/student_management_system/.env.example @@ -0,0 +1,21 @@ +# 复制本文件为 .env 后按本机情况修改,.env 不要提交到 Git +APP_NAME=学生管理系统 +DEBUG=true +HOST=127.0.0.1 +PORT=8004 +API_PREFIX=/api/v1 + +# 允许跨域的前端地址,逗号分隔 +CORS_ORIGINS=http://localhost:5500,http://127.0.0.1:5500,http://localhost:8000,http://127.0.0.1:8000 + +# 数据库 +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=123456 +DB_NAME=student_management_system +DB_CHARSET=utf8mb4 +DB_ECHO=false +DB_POOL_SIZE=5 +DB_MAX_OVERFLOW=10 +DB_POOL_RECYCLE=3600 diff --git a/student_management_system_complete/student_management_system/api/__init__.py b/student_management_system_complete/student_management_system/api/__init__.py new file mode 100644 index 0000000..802ba95 --- /dev/null +++ b/student_management_system_complete/student_management_system/api/__init__.py @@ -0,0 +1,29 @@ +# api/__init__.py +# 只做转发。原版这里是空文件,靠 `from api import student_api` 隐式生效, +# 一旦有人把 __init__.py 写成业务代码,所有模块都会被牵连。 + +from . import ( + advisor_api, + class_api, + dict_api, + employment_api, + exam_api, + router, + score_api, + stats_api, + student_api, + teacher_api, +) + +__all__ = [ + "advisor_api", + "class_api", + "dict_api", + "employment_api", + "exam_api", + "router", + "score_api", + "stats_api", + "student_api", + "teacher_api", +] diff --git a/student_management_system_complete/student_management_system/api/advisor_api.py b/student_management_system_complete/student_management_system/api/advisor_api.py new file mode 100644 index 0000000..3a25d8d --- /dev/null +++ b/student_management_system_complete/student_management_system/api/advisor_api.py @@ -0,0 +1,64 @@ +# api/advisor_api.py + +from typing import List + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from dao import advisor_dao +from database import get_db +from schema.advisor_schema import ( + AdvisorCreate, + AdvisorOut, + AdvisorQuery, + AdvisorStatus, + AdvisorStudentOut, +) + +router = APIRouter() + + +@router.get("", response_model=dict, summary="分页查询顾问") +def list_advisors(q: AdvisorQuery = Depends(), db: Session = Depends(get_db)): + return advisor_dao.get_all(db, q) + + +@router.post("", response_model=AdvisorOut, status_code=201, summary="新增顾问") +def create_advisor(payload: AdvisorCreate, db: Session = Depends(get_db)): + return advisor_dao.create_advisor(db, payload) + + +@router.get("/{advisor_id}", response_model=AdvisorOut, summary="查询单个顾问") +def get_advisor(advisor_id: int, db: Session = Depends(get_db)): + advisor = advisor_dao.get_by_id(db, advisor_id) + if advisor is None: + from exceptions import NotFoundError + + raise NotFoundError(f"顾问 {advisor_id} 不存在") + return advisor + + +@router.get( + "/{advisor_id}/students", + response_model=List[AdvisorStudentOut], + summary="查询顾问名下的学生", +) +def get_advisor_students(advisor_id: int, db: Session = Depends(get_db)): + students = advisor_dao.adv_all_stu(db, advisor_id) + if students is None: + from exceptions import NotFoundError + + raise NotFoundError(f"顾问 {advisor_id} 不存在") + return students + + +@router.put("/{advisor_id}", response_model=AdvisorOut, summary="修改顾问信息") +def update_advisor(advisor_id: int, payload: AdvisorCreate, db: Session = Depends(get_db)): + return advisor_dao.update_advisor(db, advisor_id, payload) + + +@router.put("/{advisor_id}/status", response_model=AdvisorOut, summary="删除/恢复顾问") +def change_advisor_status( + advisor_id: int, payload: AdvisorStatus, db: Session = Depends(get_db) +): + return advisor_dao.change_advisor(db, advisor_id, payload.flag) diff --git a/student_management_system_complete/student_management_system/api/class_api.py b/student_management_system_complete/student_management_system/api/class_api.py new file mode 100644 index 0000000..4b734a1 --- /dev/null +++ b/student_management_system_complete/student_management_system/api/class_api.py @@ -0,0 +1,52 @@ +# api/class_api.py +# +# 原版把删除逻辑直接写在路由函数里(db.query + is_del = 0 + db.commit), +# 一个 6 人协作的项目里,API 层一旦有人开始直接写 SQL, +# 分层就废了 —— 后来的人不知道该去哪找业务逻辑。现在全部下沉到 DAO。 + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from dao import class_dao +from database import get_db +from schema.class_schema import ClassAdd, ClassQuery, ClassResp, ClassUpdate +from schema.common import MessageOut + +router = APIRouter() + + +@router.get("", response_model=dict, summary="分页查询班级") +def list_classes(q: ClassQuery = Depends(), db: Session = Depends(get_db)): + return class_dao.list_classes(db, q) + + +@router.post("", response_model=ClassResp, status_code=201, summary="新增班级") +def add_class(payload: ClassAdd, db: Session = Depends(get_db)): + return class_dao.create_class(db, payload) + + +@router.get("/{cid}", response_model=ClassResp, summary="ID查询单个班级") +def get_one_class(cid: int, db: Session = Depends(get_db)): + cls = class_dao.get_class(db, cid) + if not cls: + from exceptions import NotFoundError + + raise NotFoundError(f"班级 {cid} 不存在") + return cls + + +@router.put("/{cid}", response_model=ClassResp, summary="修改班级") +def update_class(cid: int, payload: ClassUpdate, db: Session = Depends(get_db)): + return class_dao.update_class(db, cid, payload) + + +@router.delete("/{cid}", response_model=MessageOut, summary="删除班级(逻辑删除)") +def delete_class(cid: int, db: Session = Depends(get_db)): + class_dao.delete_class(db, cid) + return {"message": f"班级 {cid} 已删除", "affected": 1} + + +@router.put("/{cid}/restore", response_model=MessageOut, summary="恢复班级") +def restore_class(cid: int, db: Session = Depends(get_db)): + class_dao.restore_class(db, cid) + return {"message": f"班级 {cid} 已恢复", "affected": 1} diff --git a/student_management_system_complete/student_management_system/api/dict_api.py b/student_management_system_complete/student_management_system/api/dict_api.py new file mode 100644 index 0000000..ebff572 --- /dev/null +++ b/student_management_system_complete/student_management_system/api/dict_api.py @@ -0,0 +1,55 @@ +# api/dict_api.py +# 下拉选项接口。 +# +# 前端每个表单都要"选班级""选顾问""选考试",原版没有这类接口, +# 只能去调各模块的列表接口再自己截字段,顺便把手机号之类的敏感字段也拉回来了。 +# 这里统一给轻量选项,只回 value + label。 + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from dao import advisor_dao, class_dao, exam_dao, student_dao, teacher_dao +from database import get_db + +router = APIRouter() + + +@router.get("/classes", response_model=list[dict], summary="班级下拉选项") +def class_options(db: Session = Depends(get_db)): + return class_dao.class_options(db) + + +@router.get("/advisors", response_model=list[dict], summary="顾问下拉选项") +def advisor_options(db: Session = Depends(get_db)): + return advisor_dao.advisor_options(db) + + +@router.get("/teachers", response_model=list[dict], summary="老师下拉选项") +def teacher_options(db: Session = Depends(get_db)): + return teacher_dao.teacher_options(db) + + +@router.get("/exams", response_model=list[dict], summary="考试下拉选项") +def exam_options(db: Session = Depends(get_db)): + exams = exam_dao.list_exams(db) + return [{"value": e.id, "label": e.exam_name, "order": e.exam_order} for e in exams] + + +@router.get("/students", response_model=list[dict], summary="学生下拉选项(支持关键字搜索)") +def student_options( + keyword: str | None = Query(None, max_length=50, description="姓名或学号关键字"), + limit: int = Query(200, ge=1, le=1000), + db: Session = Depends(get_db), +): + return student_dao.student_options(db, keyword, limit) + + +@router.get("/all", response_model=dict, summary="一次性拉全部下拉选项") +def all_options(db: Session = Depends(get_db)): + """前端首屏加载时打这一个接口就够了,省掉 4 次往返。""" + return { + "classes": class_dao.class_options(db), + "advisors": advisor_dao.advisor_options(db), + "teachers": teacher_dao.teacher_options(db), + "exams": exam_options(db), + } diff --git a/student_management_system_complete/student_management_system/api/employment_api.py b/student_management_system_complete/student_management_system/api/employment_api.py new file mode 100644 index 0000000..75d17d0 --- /dev/null +++ b/student_management_system_complete/student_management_system/api/employment_api.py @@ -0,0 +1,75 @@ +# api/employment_api.py +# +# 原版用 student_id 定位就业记录来修改/删除。现在学生可以有多条就业记录, +# 必须按记录主键 id 定位,否则 .first() 取到哪条完全随机。 + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from dao import employment_dao +from database import get_db +from schema.common import MessageOut +from schema.employment_schema import ( + EmploymentCreate, + EmploymentQuery, + EmploymentResponse, + EmploymentUpdate, +) + +router = APIRouter() + + +@router.get("", response_model=dict, summary="分页查询就业信息") +def list_employments(q: EmploymentQuery = Depends(), db: Session = Depends(get_db)): + return employment_dao.search_employments(db, q) + + +@router.get("/stats", response_model=dict, summary="就业概览统计") +def employment_stats(db: Session = Depends(get_db)): + return employment_dao.employment_stats(db) + + +@router.get("/current/{student_id}", response_model=EmploymentResponse, summary="学生当前就业") +def current_employment(student_id: int, db: Session = Depends(get_db)): + """注意:这个路由必须放在 /{employment_id} 之前, + 否则 FastAPI 会把 "current" 当成 employment_id 去解析,直接 422。""" + emp = employment_dao.current_employment(db, student_id) + if emp is None: + from exceptions import NotFoundError + + raise NotFoundError(f"学生 {student_id} 暂无当前就业记录") + return emp + + +@router.post("", response_model=EmploymentResponse, status_code=201, summary="新增就业信息") +def add_employment(payload: EmploymentCreate, db: Session = Depends(get_db)): + return employment_dao.create_employment(db, payload) + + +@router.get("/{employment_id}", response_model=EmploymentResponse, summary="查询单条就业信息") +def get_employment(employment_id: int, db: Session = Depends(get_db)): + emp = employment_dao.get_employment(db, employment_id) + if emp is None: + from exceptions import NotFoundError + + raise NotFoundError(f"就业记录 {employment_id} 不存在") + return emp + + +@router.put("/{employment_id}", response_model=EmploymentResponse, summary="修改就业信息") +def modify_employment( + employment_id: int, payload: EmploymentUpdate, db: Session = Depends(get_db) +): + return employment_dao.update_employment(db, employment_id, payload) + + +@router.delete("/{employment_id}", response_model=MessageOut, summary="删除就业信息(逻辑删除)") +def remove_employment(employment_id: int, db: Session = Depends(get_db)): + employment_dao.delete_employment(db, employment_id) + return {"message": f"就业记录 {employment_id} 已删除", "affected": 1} + + +@router.put("/{employment_id}/restore", response_model=MessageOut, summary="恢复就业信息") +def restore_employment(employment_id: int, db: Session = Depends(get_db)): + employment_dao.restore_employment(db, employment_id) + return {"message": f"就业记录 {employment_id} 已恢复", "affected": 1} diff --git a/student_management_system_complete/student_management_system/api/exam_api.py b/student_management_system_complete/student_management_system/api/exam_api.py new file mode 100644 index 0000000..d1cba4a --- /dev/null +++ b/student_management_system_complete/student_management_system/api/exam_api.py @@ -0,0 +1,35 @@ +# api/exam_api.py +# 考试基础数据接口。原版没有这个模块,考试次序 1/2/3 是写死在代码里的。 + +from typing import List + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from dao import exam_dao +from database import get_db +from schema.common import MessageOut +from schema.exam_schema import ExamCreate, ExamOut, ExamUpdate + +router = APIRouter() + + +@router.get("", response_model=List[ExamOut], summary="查询考试列表") +def list_exams(db: Session = Depends(get_db)): + return exam_dao.list_exams(db) + + +@router.post("", response_model=ExamOut, status_code=201, summary="新增考试") +def create_exam(payload: ExamCreate, db: Session = Depends(get_db)): + return exam_dao.create_exam(db, payload) + + +@router.put("/{exam_id}", response_model=ExamOut, summary="修改考试") +def update_exam(exam_id: int, payload: ExamUpdate, db: Session = Depends(get_db)): + return exam_dao.update_exam(db, exam_id, payload) + + +@router.delete("/{exam_id}", response_model=MessageOut, summary="删除考试") +def delete_exam(exam_id: int, db: Session = Depends(get_db)): + exam_dao.delete_exam(db, exam_id) + return {"message": f"考试 {exam_id} 已删除", "affected": 1} diff --git a/student_management_system_complete/student_management_system/api/router.py b/student_management_system_complete/student_management_system/api/router.py new file mode 100644 index 0000000..fb26558 --- /dev/null +++ b/student_management_system_complete/student_management_system/api/router.py @@ -0,0 +1,39 @@ +# api/router.py +# 所有子路由的汇总入口。 +# +# 好处:main.py 里只 include_router(api_router, prefix="/api/v1") 一行, +# 加一个新模块不用动 main.py,也不用担心谁忘了加前缀。 +# +# 注意:这里用 `from api.xxx_api import router` 直接导入模块内的 router 对象, +# 而不是 `from api import xxx_api` —— 后者在 api/__init__.py 也导入 router 时 +# 会形成循环导入,只是恰好在某种导入顺序下不报错,换个入口就炸。 + +from fastapi import APIRouter + +from api.advisor_api import router as advisor_router +from api.class_api import router as class_router +from api.dict_api import router as dict_router +from api.employment_api import router as employment_router +from api.exam_api import router as exam_router +from api.score_api import router as score_router +from api.stats_api import router as stats_router +from api.student_api import router as student_router +from api.teacher_api import router as teacher_router + +api_router = APIRouter() + +# (子路由, 路径前缀, Swagger 分组标签) +SUBMODULES = [ + (student_router, "/students", "学生管理"), + (class_router, "/classes", "班级管理"), + (teacher_router, "/teachers", "老师管理"), + (advisor_router, "/advisors", "顾问管理"), + (score_router, "/scores", "成绩管理"), + (exam_router, "/exams", "考试管理"), + (employment_router, "/employments", "就业管理"), + (stats_router, "/stats", "统计分析"), + (dict_router, "/options", "下拉选项"), +] + +for _sub_router, _prefix, _tag in SUBMODULES: + api_router.include_router(_sub_router, prefix=_prefix, tags=[_tag]) diff --git a/student_management_system_complete/student_management_system/api/score_api.py b/student_management_system_complete/student_management_system/api/score_api.py new file mode 100644 index 0000000..f7ea4a8 --- /dev/null +++ b/student_management_system_complete/student_management_system/api/score_api.py @@ -0,0 +1,53 @@ +# api/score_api.py + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from dao import score_dao +from database import get_db +from schema.common import MessageOut +from schema.score_schema import ScoreCreate, ScoreQuery, ScoreResponse, ScoreUpdate + +router = APIRouter() + + +@router.get("", response_model=dict, summary="分页查询成绩") +def list_scores(q: ScoreQuery = Depends(), db: Session = Depends(get_db)): + return score_dao.search_scores(db, q) + + +@router.post("", response_model=ScoreResponse, status_code=201, summary="录入成绩") +def create_score(payload: ScoreCreate, db: Session = Depends(get_db)): + return score_dao.create_score(db, payload) + + +@router.get("/summary/{student_id}", response_model=dict, summary="学生成绩概览") +def score_summary(student_id: int, db: Session = Depends(get_db)): + return score_dao.score_summary(db, student_id) + + +@router.get("/{score_id}", response_model=ScoreResponse, summary="查询单条成绩") +def get_score(score_id: int, db: Session = Depends(get_db)): + score = score_dao.get_score(db, score_id) + if score is None: + from exceptions import NotFoundError + + raise NotFoundError(f"成绩记录 {score_id} 不存在") + return score + + +@router.put("/{score_id}", response_model=ScoreResponse, summary="修改成绩") +def update_score(score_id: int, payload: ScoreUpdate, db: Session = Depends(get_db)): + return score_dao.update_score(db, score_id, payload) + + +@router.delete("/{score_id}", response_model=MessageOut, summary="删除成绩(逻辑删除)") +def delete_score(score_id: int, db: Session = Depends(get_db)): + score_dao.delete_score(db, score_id) + return {"message": f"成绩记录 {score_id} 已删除", "affected": 1} + + +@router.put("/{score_id}/restore", response_model=MessageOut, summary="恢复成绩") +def restore_score(score_id: int, db: Session = Depends(get_db)): + score_dao.restore_score(db, score_id) + return {"message": f"成绩记录 {score_id} 已恢复", "affected": 1} diff --git a/student_management_system_complete/student_management_system/api/stats_api.py b/student_management_system_complete/student_management_system/api/stats_api.py new file mode 100644 index 0000000..6fc9d48 --- /dev/null +++ b/student_management_system_complete/student_management_system/api/stats_api.py @@ -0,0 +1,99 @@ +# api/stats_api.py +# 统计分析接口。 +# +# 原版把这些统计接口混在 student_api 里,路径是 /count_gender、 +# /count_score_higherScores/{score}、/search_workTime …… 既不属于学生资源, +# 命名风格也各不相同。独立成 /stats 命名空间后,前端一眼能看出哪些是报表接口。 +# +# 另外原来的路径参数 {score} 没有边界校验,传 999 或 -100 都能跑; +# 现在统一用 Query(..., ge=0, le=100)。 + +from typing import List + +from fastapi import APIRouter, Depends, Path, Query +from sqlalchemy.orm import Session + +from dao import student_dao +from database import get_db +from schema.student_schema import ( + AgeQuery, + ClassAvgScoreItem, + ClassAvgWorkDurationItem, + ClassGenderStat, + DashboardStat, + FailMoreItem, + ScoreAboveItem, + TopSalaryItem, + WorkDurationItem, +) + +router = APIRouter() + + +@router.get("/dashboard", response_model=DashboardStat, summary="首页数据看板") +def dashboard(db: Session = Depends(get_db)): + return student_dao.dashboard(db) + + +@router.get("/class-gender", response_model=List[ClassGenderStat], summary="按班级统计男女人数") +def class_gender(db: Session = Depends(get_db)): + return student_dao.class_gender_stat(db) + + +@router.get( + "/score-above/{line}", + response_model=List[ScoreAboveItem], + summary="每次考试都不低于分数线的学生", +) +def score_above( + line: float = Path(..., ge=0, le=100, description="分数线,0~100"), + db: Session = Depends(get_db), +): + return student_dao.scores_above(db, line) + + +@router.get("/fail-more", response_model=List[FailMoreItem], summary="不及格次数达标的学生") +def fail_more( + fail_times: int = Query(2, ge=1, le=20, description="至少不及格几次"), + pass_line: float = Query(60, ge=0, le=100, description="及格线"), + db: Session = Depends(get_db), +): + return student_dao.fail_more_than(db, fail_times, pass_line) + + +@router.get("/class-avg-score", response_model=List[ClassAvgScoreItem], summary="班级平均分") +def class_avg_score( + order_type: str = Query("desc", pattern="^(asc|desc)$", description="排序方向"), + db: Session = Depends(get_db), +): + return student_dao.class_avg_score(db, order_type) + + +@router.get("/work-duration", response_model=List[WorkDurationItem], summary="Offer→入职间隔天数") +def work_duration(db: Session = Depends(get_db)): + return student_dao.work_duration(db) + + +@router.get( + "/class-avg-work-duration", + response_model=List[ClassAvgWorkDurationItem], + summary="各班级平均 Offer→入职间隔", +) +def class_avg_work_duration(db: Session = Depends(get_db)): + return student_dao.class_avg_work_duration(db) + + +@router.get("/top-salary", response_model=List[TopSalaryItem], summary="薪资 TopN") +def top_salary( + n: int = Query(10, ge=1, le=100, description="取前 N 名"), + db: Session = Depends(get_db), +): + return student_dao.top_n_salary(db, n) + + +@router.post("/by-age", response_model=List[dict], summary="按年龄条件筛选学生") +def by_age(payload: AgeQuery, db: Session = Depends(get_db)): + """改成 POST 是因为操作符 + 单值/区间是一组结构化参数, + 塞进 query string 会变成 /by-age?operator=between&min_value=18&max_value=22&value=null 这种。 + 但"查数据"用 POST 语义上确实不严谨,更规范的做法仍是 GET + 明确参数。""" + return student_dao.students_by_age(db, payload) diff --git a/student_management_system_complete/student_management_system/api/student_api.py b/student_management_system_complete/student_management_system/api/student_api.py new file mode 100644 index 0000000..ca46103 --- /dev/null +++ b/student_management_system_complete/student_management_system/api/student_api.py @@ -0,0 +1,68 @@ +# api/student_api.py +# 学生管理接口。 +# +# 原版路由的断裂点(已全部修正): +# @router.get("get_top_salary") 缺前导斜杠 → 实际路径变成 +# /api/studentsManagementget_top_salary +# @router.get("avg_work_time_by_class") 同上 +# @router.get("/api/get/student") 前缀里已经有 /api,路径里再来一个 /api +# @router.get("/count_gender") vs "/api/create/student" +# 一个模块内三种命名风格 +# +# 现在的约定:路由只写资源路径,前缀统一由 api/router.py 拼; +# 名词复数,动作用 HTTP method 表达。 + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from dao import student_dao +from database import get_db +from schema.common import MessageOut +from schema.student_schema import ( + StudentCreate, + StudentOut, + StudentQuery, + StudentUpdate, +) + +router = APIRouter() + + +@router.get("", response_model=dict, summary="分页查询学生") +def list_students(q: StudentQuery = Depends(), db: Session = Depends(get_db)): + """支持学号精确、姓名模糊、班级/顾问/状态/性别筛选,分页返回。""" + return student_dao.list_students(db, q) + + +@router.post("", response_model=StudentOut, status_code=201, summary="新增学生") +def create_student(payload: StudentCreate, db: Session = Depends(get_db)): + """student_no 不传时按 S+年份+流水号 自动生成。""" + student = student_dao.create_student(db, payload) + return student_dao.get_student(db, student.sid) + + +@router.get("/{sid}", response_model=StudentOut, summary="查询学生详情") +def get_student(sid: int, db: Session = Depends(get_db)): + detail = student_dao.get_student(db, sid) + if detail is None: + from exceptions import NotFoundError + + raise NotFoundError(f"学生 {sid} 不存在") + return detail + + +@router.put("/{sid}", response_model=StudentOut, summary="修改学生信息") +def update_student(sid: int, payload: StudentUpdate, db: Session = Depends(get_db)): + return student_dao.update_student(db, sid, payload) + + +@router.delete("/{sid}", response_model=MessageOut, summary="删除学生(逻辑删除)") +def delete_student(sid: int, db: Session = Depends(get_db)): + student_dao.set_student_flag(db, sid, 0) + return {"message": f"学生 {sid} 已删除", "affected": 1} + + +@router.put("/{sid}/restore", response_model=MessageOut, summary="恢复已删除学生") +def restore_student(sid: int, db: Session = Depends(get_db)): + student_dao.set_student_flag(db, sid, 1) + return {"message": f"学生 {sid} 已恢复", "affected": 1} diff --git a/student_management_system_complete/student_management_system/api/teacher_api.py b/student_management_system_complete/student_management_system/api/teacher_api.py new file mode 100644 index 0000000..eece37a --- /dev/null +++ b/student_management_system_complete/student_management_system/api/teacher_api.py @@ -0,0 +1,48 @@ +# api/teacher_api.py + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from dao import teacher_dao +from database import get_db +from schema.common import MessageOut +from schema.teacher_schema import TeacherCreate, TeacherOut, TeacherQuery, TeacherUpdate + +router = APIRouter() + + +@router.get("", response_model=dict, summary="分页查询老师列表") +def list_teachers(q: TeacherQuery = Depends(), db: Session = Depends(get_db)): + return teacher_dao.list_teachers(db, q) + + +@router.post("", response_model=TeacherOut, status_code=201, summary="新增老师") +def create_teacher(payload: TeacherCreate, db: Session = Depends(get_db)): + return teacher_dao.create_teacher(db, payload) + + +@router.get("/{tid}", response_model=TeacherOut, summary="根据ID查询老师") +def get_teacher(tid: int, db: Session = Depends(get_db)): + teacher = teacher_dao.get_teacher(db, tid) + if not teacher: + from exceptions import NotFoundError + + raise NotFoundError(f"老师 {tid} 不存在") + return teacher + + +@router.put("/{tid}", response_model=TeacherOut, summary="修改老师信息") +def update_teacher(tid: int, payload: TeacherUpdate, db: Session = Depends(get_db)): + return teacher_dao.update_teacher(db, tid, payload) + + +@router.delete("/{tid}", response_model=MessageOut, summary="删除老师(逻辑删除)") +def delete_teacher(tid: int, db: Session = Depends(get_db)): + teacher_dao.delete_teacher(db, tid) + return {"message": f"老师 {tid} 已删除", "affected": 1} + + +@router.put("/{tid}/restore", response_model=MessageOut, summary="恢复老师") +def restore_teacher(tid: int, db: Session = Depends(get_db)): + teacher_dao.restore_teacher(db, tid) + return {"message": f"老师 {tid} 已恢复", "affected": 1} diff --git a/student_management_system_complete/student_management_system/dao/__init__.py b/student_management_system_complete/student_management_system/dao/__init__.py new file mode 100644 index 0000000..bb68fa7 --- /dev/null +++ b/student_management_system_complete/student_management_system/dao/__init__.py @@ -0,0 +1,24 @@ +# dao/__init__.py +# 只做转发,避免"dao/__init__.py 里再定义一套逻辑"这种复制粘贴事故。 + +from . import ( + advisor_dao, + class_dao, + common, + employment_dao, + exam_dao, + score_dao, + student_dao, + teacher_dao, +) + +__all__ = [ + "advisor_dao", + "class_dao", + "common", + "employment_dao", + "exam_dao", + "score_dao", + "student_dao", + "teacher_dao", +] diff --git a/student_management_system_complete/student_management_system/dao/advisor_dao.py b/student_management_system_complete/student_management_system/dao/advisor_dao.py new file mode 100644 index 0000000..72bd7a4 --- /dev/null +++ b/student_management_system_complete/student_management_system/dao/advisor_dao.py @@ -0,0 +1,161 @@ +# dao/advisor_dao.py + +from typing import Optional + +from sqlalchemy import and_, func +from sqlalchemy.orm import Session + +from dao.common import page_dict, paginate +from exceptions import BusinessRuleError, ConflictError, NotFoundError +from model import Advisor, Classes, Student +from schema.advisor_schema import AdvisorCreate, AdvisorQuery + + +def _to_dict(advisor: Advisor, student_count: int = 0) -> dict: + return { + "id": advisor.id, + "advisor_name": advisor.advisor_name, + "flag": advisor.flag, + "phone": advisor.phone, + "gender": advisor.gender, + "student_count": int(student_count or 0), + "created_at": advisor.created_at, + } + + +def get_all(db: Session, q: AdvisorQuery | None = None) -> dict: + """顾问列表(带名下学生数,分页)。 + + 原版返回全表且不含学生数,前端想问"这个顾问带了几个人"只能一个个点进去看。 + """ + q = q or AdvisorQuery() + query = ( + db.query(Advisor, func.count(Student.sid)) + .outerjoin(Student, and_(Student.advisor_id == Advisor.id, Student.flag == 1)) + .filter(Advisor.flag == 1) + ) + if q.advisor_name: + query = query.filter(Advisor.advisor_name.like(f"%{q.advisor_name}%")) + + query = query.group_by(Advisor.id).order_by(Advisor.id.desc()) + rows, total = paginate(query, q.page, q.size) + return page_dict([_to_dict(a, n) for a, n in rows], total, q.page, q.size) + + +def get_by_id(db: Session, advisor_id: int) -> Optional[dict]: + row = ( + db.query(Advisor, func.count(Student.sid)) + .outerjoin(Student, and_(Student.advisor_id == Advisor.id, Student.flag == 1)) + .filter(Advisor.id == advisor_id, Advisor.flag == 1) + .group_by(Advisor.id) + .first() + ) + if not row: + return None + advisor, student_count = row + return _to_dict(advisor, student_count) + + +def adv_all_stu(db: Session, advisor_id: int) -> Optional[list[dict]]: + """顾问名下的在册学生。""" + advisor = db.query(Advisor.id).filter(Advisor.id == advisor_id, Advisor.flag == 1).first() + if advisor is None: + return None + + rows = ( + db.query(Student, Classes.class_name) + .join(Classes, Classes.cid == Student.class_id) + .filter(Student.advisor_id == advisor_id, Student.flag == 1) + .order_by(Student.sid.desc()) + .all() + ) + return [ + { + "sid": s.sid, + "student_no": s.student_no, + "student_name": s.student_name, + "class_id": s.class_id, + "class_name": class_name, + "enrollment_time": s.enrollment_time, + "graduation_time": s.graduation_time, + "state": s.state, + } + for s, class_name in rows + ] + + +def create_advisor(db: Session, payload: AdvisorCreate) -> dict: + exists = ( + db.query(Advisor.id) + .filter(Advisor.advisor_name == payload.advisor_name) + .first() + ) + if exists: + raise ConflictError(f"顾问「{payload.advisor_name}」已存在") + + advisor = Advisor(**payload.model_dump(), flag=1) + db.add(advisor) + db.commit() + db.refresh(advisor) + return _to_dict(advisor) + + +def change_advisor(db: Session, advisor_id: int, flag: int) -> dict: + """删除 / 恢复。 + + 业务规则:名下还有在读学生时不允许删除顾问 —— 否则这些学生没有负责人, + "一个学生必须归属一个顾问"这条业务约束就破了。 + """ + advisor = db.query(Advisor).filter(Advisor.id == advisor_id).first() + if advisor is None: + raise NotFoundError(f"顾问 {advisor_id} 不存在") + + if flag == 0: + alive = ( + db.query(func.count(Student.sid)) + .filter(Student.advisor_id == advisor_id, Student.flag == 1) + .scalar() + or 0 + ) + if alive > 0: + raise BusinessRuleError(f"该顾问名下还有 {alive} 名学生,请先转交后再删除") + + advisor.flag = flag + db.commit() + db.refresh(advisor) + return _to_dict(advisor) + + +def update_advisor(db: Session, advisor_id: int, payload: AdvisorCreate) -> dict: + advisor = db.query(Advisor).filter(Advisor.id == advisor_id, Advisor.flag == 1).first() + if advisor is None: + raise NotFoundError(f"顾问 {advisor_id} 不存在或已删除") + + if payload.advisor_name != advisor.advisor_name: + exists = ( + db.query(Advisor.id) + .filter(Advisor.advisor_name == payload.advisor_name, Advisor.id != advisor_id) + .first() + ) + if exists: + raise ConflictError(f"顾问「{payload.advisor_name}」已存在") + + advisor.advisor_name = payload.advisor_name + if payload.phone is not None: + advisor.phone = payload.phone + if payload.gender is not None: + advisor.gender = payload.gender + + db.commit() + db.refresh(advisor) + return _to_dict(advisor) + + +def advisor_options(db: Session) -> list[dict]: + rows = ( + db.query(Advisor.id, Advisor.advisor_name) + .filter(Advisor.flag == 1) + .order_by(Advisor.id) + .all() + ) + return [{"value": r.id, "label": r.advisor_name} for r in rows] diff --git a/student_management_system_complete/student_management_system/dao/class_dao.py b/student_management_system_complete/student_management_system/dao/class_dao.py new file mode 100644 index 0000000..c945c37 --- /dev/null +++ b/student_management_system_complete/student_management_system/dao/class_dao.py @@ -0,0 +1,150 @@ +# dao/class_dao.py +# +# 原版这里有三个必须修的问题: +# 1. delete_class 用的是 db.delete() —— 物理删除。名字叫"逻辑删除班级"的接口, +# API 层自己另写了一套 is_del = 0,DAO 里却是真删。两边行为不一致, +# 真删之后这个班的学生全成孤儿数据(外键约束开着的话会直接报错删不掉)。 +# 2. is_del 的方向在三个地方三种理解:get_class_by_cid 认为 1=正常, +# delete_class_api 认为 0=删除,seed 数据里 0 和 1 混着来。 +# 3. get_class_by_name 只查 is_del==1,导致重名判断漏掉了"已删除的同名班级", +# 加回来的时候被唯一键挡住,报一个看不懂的 500。 + +from typing import Optional + +from sqlalchemy import and_, func, or_ +from sqlalchemy.orm import Session + +from dao.common import page_dict, paginate +from exceptions import BusinessRuleError, ConflictError, NotFoundError +from model import Classes, Student +from schema.class_schema import ClassAdd, ClassQuery, ClassUpdate + + +def get_class(db: Session, cid: int) -> Optional[dict]: + row = ( + db.query(Classes, func.count(Student.sid)) + .outerjoin(Student, and_(Student.class_id == Classes.cid, Student.flag == 1)) + .filter(Classes.cid == cid, Classes.flag == 1) + .group_by(Classes.cid) + .first() + ) + if not row: + return None + cls, student_count = row + return _to_dict(cls, student_count) + + +def _to_dict(cls: Classes, student_count: int = 0) -> dict: + return { + "cid": cls.cid, + "class_name": cls.class_name, + "start_time": cls.start_time, + "head_teacher": cls.head_teacher, + "teacher": cls.teacher, + "flag": cls.flag, + "student_count": int(student_count or 0), + "created_at": cls.created_at, + } + + +def list_classes(db: Session, q: ClassQuery) -> dict: + query = ( + db.query(Classes, func.count(Student.sid)) + .outerjoin(Student, and_(Student.class_id == Classes.cid, Student.flag == 1)) + .filter(Classes.flag == 1) + ) + if q.class_name: + query = query.filter(Classes.class_name.like(f"%{q.class_name}%")) + if q.head_teacher: + query = query.filter(Classes.head_teacher.like(f"%{q.head_teacher}%")) + + query = query.group_by(Classes.cid).order_by(Classes.cid.desc()) + rows, total = paginate(query, q.page, q.size) + return page_dict([_to_dict(c, n) for c, n in rows], total, q.page, q.size) + + +def class_name_taken(db: Session, name: str, exclude_cid: Optional[int] = None) -> bool: + """重名校验。 + + 包含"已删除"的班级 —— 因为唯一约束 (class_name, flag) 里 + 已删除的同名班级仍占着一行,只看未删除的会漏判。 + """ + query = db.query(Classes.cid).filter(Classes.class_name == name) + if exclude_cid is not None: + query = query.filter(Classes.cid != exclude_cid) + return query.first() is not None + + +def create_class(db: Session, payload: ClassAdd) -> dict: + if class_name_taken(db, payload.class_name): + raise ConflictError(f"班级名称「{payload.class_name}」已存在") + cls = Classes(**payload.model_dump(), flag=1) + db.add(cls) + db.commit() + db.refresh(cls) + return _to_dict(cls) + + +def update_class(db: Session, cid: int, payload: ClassUpdate) -> dict: + cls = db.query(Classes).filter(Classes.cid == cid).first() + if cls is None: + raise NotFoundError(f"班级 {cid} 不存在") + if cls.flag == 0: + raise BusinessRuleError(f"班级 {cid} 已删除,请先恢复再修改") + + data = payload.model_dump(exclude_unset=True, exclude_none=True) + new_name = data.get("class_name") + if new_name and new_name != cls.class_name and class_name_taken(db, new_name, exclude_cid=cid): + raise ConflictError(f"班级名称「{new_name}」已存在") + + for key, value in data.items(): + setattr(cls, key, value) + + db.commit() + db.refresh(cls) + return get_class(db, cid) + + +def delete_class(db: Session, cid: int) -> None: + """逻辑删除。 + + 真实业务约束:班里还有在读学生时不允许删除班级,否则这些学生会指向一个 + "不存在的班级",学籍管理直接断链。原版物理删除时毫无阻拦。 + """ + cls = db.query(Classes).filter(Classes.cid == cid).first() + if cls is None: + raise NotFoundError(f"班级 {cid} 不存在") + if cls.flag == 0: + raise BusinessRuleError(f"班级 {cid} 已经是删除状态") + + alive = ( + db.query(func.count(Student.sid)) + .filter(Student.class_id == cid, Student.flag == 1) + .scalar() + or 0 + ) + if alive > 0: + raise BusinessRuleError(f"班级下还有 {alive} 名在读学生,请先转移或删除学生") + + cls.flag = 0 + db.commit() + + +def restore_class(db: Session, cid: int) -> None: + cls = db.query(Classes).filter(Classes.cid == cid).first() + if cls is None: + raise NotFoundError(f"班级 {cid} 不存在") + if class_name_taken(db, cls.class_name, exclude_cid=cid): + raise ConflictError(f"已有同名班级「{cls.class_name}」,无法恢复") + cls.flag = 1 + db.commit() + + +def class_options(db: Session) -> list[dict]: + rows = ( + db.query(Classes.cid, Classes.class_name) + .filter(Classes.flag == 1) + .order_by(Classes.cid) + .all() + ) + return [{"value": r.cid, "label": r.class_name} for r in rows] diff --git a/student_management_system_complete/student_management_system/dao/common.py b/student_management_system_complete/student_management_system/dao/common.py new file mode 100644 index 0000000..a2add45 --- /dev/null +++ b/student_management_system_complete/student_management_system/dao/common.py @@ -0,0 +1,32 @@ +# dao/common.py +# DAO 层公共工具。 + +from math import ceil +from typing import Sequence + +from sqlalchemy.orm import Query + + +def paginate(query: Query, page: int, size: int) -> tuple[Sequence, int]: + """统一分页:返回 (当页数据, 总条数)。 + + 原版只在 student 一个模块做了分页,teacher 用 skip/limit、其余模块一次全查。 + 数据量一上来(几千条学生 + 几万条成绩)前端直接就挂了。 + """ + total = query.order_by(None).count() + rows = query.offset((page - 1) * size).limit(size).all() + return rows, total + + +def total_pages(total: int, size: int) -> int: + return ceil(total / size) if size > 0 else 0 + + +def page_dict(items, total: int, page: int, size: int) -> dict: + return { + "items": items, + "total": total, + "page": page, + "size": size, + "pages": total_pages(total, size), + } diff --git a/student_management_system_complete/student_management_system/dao/employment_dao.py b/student_management_system_complete/student_management_system/dao/employment_dao.py new file mode 100644 index 0000000..0a78a32 --- /dev/null +++ b/student_management_system_complete/student_management_system/dao/employment_dao.py @@ -0,0 +1,191 @@ +# dao/employment_dao.py +# +# 原版问题: +# 1. Employment.student_id 是 unique 的 —— 一个学生只能有一条就业记录, +# 换工作就没地方存了。现在允许多条,用 is_current 标识当前在职。 +# 2. 用 student_id 定位记录来改/删。一个学生有多条记录时, +# .first() 到底取到哪一条完全看数据库心情。改为按记录主键 id。 +# 3. search 不分页,且 company_name 是精确匹配 —— 输入"腾讯"查不到"腾讯科技"。 + +from typing import Optional + +from sqlalchemy import and_, func +from sqlalchemy.orm import Session + +from dao.common import page_dict, paginate +from exceptions import BusinessRuleError, ConflictError, NotFoundError +from model import Classes, Employment, Student +from schema.employment_schema import EmploymentCreate, EmploymentQuery, EmploymentUpdate + + +def _base_query(db: Session): + return ( + db.query(Employment, Student.student_name, Classes.class_name) + .join(Student, Student.sid == Employment.student_id) + .join(Classes, Classes.cid == Student.class_id) + ) + + +def _row_to_dict(row) -> dict: + employment, student_name, class_name = row + return { + "id": employment.id, + "student_id": employment.student_id, + "student_name": student_name, + "class_name": class_name, + "offer_time": employment.offer_time, + "employment_start_time": employment.employment_start_time, + "company_name": employment.company_name, + "salary": float(employment.salary) if employment.salary is not None else None, + "is_current": bool(employment.is_current), + "flag": employment.flag, + "created_at": employment.created_at, + } + + +def check_student_exists(db: Session, student_id: int) -> Optional[Student]: + return db.query(Student).filter(Student.sid == student_id, Student.flag == 1).first() + + +def search_employments(db: Session, q: EmploymentQuery) -> dict: + query = _base_query(db).filter(Employment.flag == 1) + + if q.student_id is not None: + query = query.filter(Employment.student_id == q.student_id) + if q.student_name: + query = query.filter(Student.student_name.like(f"%{q.student_name}%")) + if q.company_name: + # 精确匹配改成模糊匹配:用户查"腾讯"应该能命中"腾讯科技" + query = query.filter(Employment.company_name.like(f"%{q.company_name}%")) + if q.min_salary is not None: + query = query.filter(Employment.salary >= q.min_salary) + if q.max_salary is not None: + query = query.filter(Employment.salary <= q.max_salary) + if q.is_current is not None: + query = query.filter(Employment.is_current.is_(q.is_current)) + + query = query.order_by(Employment.id.desc()) + rows, total = paginate(query, q.page, q.size) + return page_dict([_row_to_dict(r) for r in rows], total, q.page, q.size) + + +def get_employment(db: Session, employment_id: int) -> Optional[dict]: + row = _base_query(db).filter(Employment.id == employment_id).first() + return _row_to_dict(row) if row else None + + +def current_employment(db: Session, student_id: int) -> Optional[dict]: + row = ( + _base_query(db) + .filter( + Employment.student_id == student_id, + Employment.flag == 1, + Employment.is_current.is_(True), + ) + .first() + ) + return _row_to_dict(row) if row else None + + +def create_employment(db: Session, payload: EmploymentCreate) -> dict: + student = check_student_exists(db, payload.student_id) + if student is None: + raise BusinessRuleError(f"学生 {payload.student_id} 不存在或已删除") + + # 新增一条就业记录时,把该学生原来的"当前在职"标记取消, + # 保证任意时刻最多一条 is_current=1 —— 这是"当前在职"这个语义的前提。 + db.query(Employment).filter( + Employment.student_id == payload.student_id, + Employment.flag == 1, + Employment.is_current.is_(True), + ).update({"is_current": False}, synchronize_session=False) + + employment = Employment(**payload.model_dump(), flag=1, is_current=True) + db.add(employment) + + # 新增就业记录成功后,学生状态自动推进到"已就业"。 + # 真实系统里这类状态联动必须由后端保证,指望操作员手动改一定会不一致。 + from model.enums import StudentStateEnum + + student.state = StudentStateEnum.EMPLOYED + + db.commit() + db.refresh(employment) + return get_employment(db, employment.id) + + +def update_employment(db: Session, employment_id: int, payload: EmploymentUpdate) -> dict: + employment = db.query(Employment).filter(Employment.id == employment_id).first() + if employment is None: + raise NotFoundError(f"就业记录 {employment_id} 不存在") + if employment.flag == 0: + raise BusinessRuleError("该就业记录已删除,请先恢复") + + data = payload.model_dump(exclude_unset=True, exclude_none=True) + + offer = data.get("offer_time", employment.offer_time) + start = data.get("employment_start_time", employment.employment_start_time) + if offer and start and start < offer: + raise BusinessRuleError("入职时间不能早于 Offer 下发时间") + + for key, value in data.items(): + setattr(employment, key, value) + + db.commit() + db.refresh(employment) + return get_employment(db, employment_id) + + +def delete_employment(db: Session, employment_id: int) -> None: + employment = db.query(Employment).filter(Employment.id == employment_id).first() + if employment is None: + raise NotFoundError(f"就业记录 {employment_id} 不存在") + if employment.flag == 0: + raise BusinessRuleError("该就业记录已经是删除状态") + + employment.flag = 0 + employment.is_current = False + db.commit() + + +def restore_employment(db: Session, employment_id: int) -> None: + employment = db.query(Employment).filter(Employment.id == employment_id).first() + if employment is None: + raise NotFoundError(f"就业记录 {employment_id} 不存在") + employment.flag = 1 + db.commit() + + +def employment_stats(db: Session) -> dict: + """就业概览:已就业人数、平均/最高/最低薪资、按公司 Top10。""" + total_employed = ( + db.query(func.count(func.distinct(Employment.student_id))) + .filter(Employment.flag == 1) + .scalar() + or 0 + ) + agg = ( + db.query( + func.avg(Employment.salary), + func.max(Employment.salary), + func.min(Employment.salary), + ) + .filter(Employment.flag == 1, Employment.is_current.is_(True)) + .first() + ) + by_company = ( + db.query(Employment.company_name, func.count(Employment.id)) + .filter(Employment.flag == 1, Employment.company_name.isnot(None)) + .group_by(Employment.company_name) + .order_by(func.count(Employment.id).desc()) + .limit(10) + .all() + ) + + return { + "employed_total": int(total_employed), + "avg_salary": round(float(agg[0]), 1) if agg and agg[0] else 0.0, + "max_salary": float(agg[1]) if agg and agg[1] else 0.0, + "min_salary": float(agg[2]) if agg and agg[2] else 0.0, + "top_companies": [{"company": c or "未填写", "count": int(n)} for c, n in by_company], + } diff --git a/student_management_system_complete/student_management_system/dao/exam_dao.py b/student_management_system_complete/student_management_system/dao/exam_dao.py new file mode 100644 index 0000000..99f2c7e --- /dev/null +++ b/student_management_system_complete/student_management_system/dao/exam_dao.py @@ -0,0 +1,73 @@ +# dao/exam_dao.py + +from typing import Optional + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from exceptions import BusinessRuleError, ConflictError, NotFoundError +from model import Exam, Score +from schema.exam_schema import ExamCreate, ExamUpdate + + +def list_exams(db: Session, only_active: bool = True) -> list[Exam]: + query = db.query(Exam) + if only_active: + query = query.filter(Exam.flag == 1) + return query.order_by(Exam.exam_order).all() + + +def get_exam(db: Session, exam_id: int) -> Optional[Exam]: + return db.query(Exam).filter(Exam.id == exam_id, Exam.flag == 1).first() + + +def create_exam(db: Session, payload: ExamCreate) -> Exam: + exist = ( + db.query(Exam.id) + .filter(Exam.exam_order == payload.exam_order, Exam.flag == 1) + .first() + ) + if exist: + raise ConflictError(f"考试次序 {payload.exam_order} 已被占用") + exam = Exam(**payload.model_dump(), flag=1) + db.add(exam) + db.commit() + db.refresh(exam) + return exam + + +def update_exam(db: Session, exam_id: int, payload: ExamUpdate) -> Exam: + exam = db.query(Exam).filter(Exam.id == exam_id).first() + if exam is None: + raise NotFoundError(f"考试 {exam_id} 不存在") + + data = payload.model_dump(exclude_unset=True, exclude_none=True) + new_order = data.get("exam_order") + if new_order and new_order != exam.exam_order: + exist = ( + db.query(Exam.id) + .filter(Exam.exam_order == new_order, Exam.id != exam_id, Exam.flag == 1) + .first() + ) + if exist: + raise ConflictError(f"考试次序 {new_order} 已被占用") + + for key, value in data.items(): + setattr(exam, key, value) + db.commit() + db.refresh(exam) + return exam + + +def delete_exam(db: Session, exam_id: int) -> None: + """考试已被成绩引用时不能删,否则成绩的"这次考试"就无从解释了。""" + exam = db.query(Exam).filter(Exam.id == exam_id).first() + if exam is None: + raise NotFoundError(f"考试 {exam_id} 不存在") + + used = db.query(func.count(Score.id)).filter(Score.exam_id == exam_id, Score.flag == 1).scalar() or 0 + if used: + raise BusinessRuleError(f"已有 {used} 条成绩引用该考试,无法删除") + + exam.flag = 0 + db.commit() diff --git a/student_management_system_complete/student_management_system/dao/score_dao.py b/student_management_system_complete/student_management_system/dao/score_dao.py new file mode 100644 index 0000000..af2eaf5 --- /dev/null +++ b/student_management_system_complete/student_management_system/dao/score_dao.py @@ -0,0 +1,175 @@ +# dao/score_dao.py +# +# 原版问题: +# 1. 用 `if exam_id not in (1, 2, 3)` 判断考试合法性 → 已改为查 exam 表 +# 2. 用 `raise ValueError` 表达业务异常,API 层得挨个 except ValueError +# → 统一抛 BusinessRuleError,由全局处理器翻译 +# 3. search_score 必须同时传 student_id 和 exam_id,无法"按班级查全部成绩" +# 4. 没有分页,一个班 50 人 × 3 次考试 = 150 行一次全返回 + +from typing import Optional + +from sqlalchemy import case, func +from sqlalchemy.orm import Session + +from dao.common import page_dict, paginate +from exceptions import BusinessRuleError, ConflictError, NotFoundError +from model import Classes, Exam, Score, Student +from schema.score_schema import ScoreCreate, ScoreQuery, ScoreUpdate + +PASS_LINE = 60 + + +def _base_query(db: Session): + return ( + db.query(Score, Student.student_name, Classes.class_name, Exam.exam_name) + .join(Student, Student.sid == Score.student_id) + .join(Classes, Classes.cid == Student.class_id) + .join(Exam, Exam.id == Score.exam_id) + ) + + +def _row_to_dict(row) -> dict: + score, student_name, class_name, exam_name = row + return { + "id": score.id, + "student_id": score.student_id, + "student_name": student_name, + "class_name": class_name, + "exam_id": score.exam_id, + "exam_name": exam_name, + "score": float(score.score), + "flag": score.flag, + "created_at": score.created_at, + } + + +def _ensure_exam(db: Session, exam_id: int) -> Exam: + exam = db.query(Exam).filter(Exam.id == exam_id, Exam.flag == 1).first() + if exam is None: + raise BusinessRuleError(f"考试 {exam_id} 不存在") + return exam + + +def _ensure_student(db: Session, student_id: int) -> Student: + student = db.query(Student).filter(Student.sid == student_id, Student.flag == 1).first() + if student is None: + raise BusinessRuleError(f"学生 {student_id} 不存在或已删除") + return student + + +def create_score(db: Session, payload: ScoreCreate) -> dict: + _ensure_student(db, payload.student_id) + _ensure_exam(db, payload.exam_id) + + if payload.score < 0 or payload.score > 100: + raise BusinessRuleError("成绩必须在 0~100 之间") + + exist = ( + db.query(Score) + .filter( + Score.student_id == payload.student_id, + Score.exam_id == payload.exam_id, + Score.flag == 1, + ) + .first() + ) + if exist: + raise ConflictError("该学生这次考试的成绩已存在,请直接修改") + + score = Score( + student_id=payload.student_id, + exam_id=payload.exam_id, + score=payload.score, + flag=1, + ) + db.add(score) + db.commit() + db.refresh(score) + return get_score(db, score.id) + + +def search_scores(db: Session, q: ScoreQuery) -> dict: + query = _base_query(db).filter(Score.flag == 1) + + if q.student_id is not None: + query = query.filter(Score.student_id == q.student_id) + if q.exam_id is not None: + query = query.filter(Score.exam_id == q.exam_id) + if q.class_id is not None: + query = query.filter(Student.class_id == q.class_id) + if q.student_name: + query = query.filter(Student.student_name.like(f"%{q.student_name}%")) + if q.min_score is not None: + query = query.filter(Score.score >= q.min_score) + if q.max_score is not None: + query = query.filter(Score.score <= q.max_score) + + query = query.order_by(Score.id.desc()) + rows, total = paginate(query, q.page, q.size) + return page_dict([_row_to_dict(r) for r in rows], total, q.page, q.size) + + +def get_score(db: Session, score_id: int) -> Optional[dict]: + row = _base_query(db).filter(Score.id == score_id).first() + return _row_to_dict(row) if row else None + + +def update_score(db: Session, score_id: int, payload: ScoreUpdate) -> dict: + """按成绩记录主键修改。 + + 原版按 (student_id, exam_id) 定位,等于每次改分都要把定位条件一起传, + 前端一旦传错就把别人的成绩改了。用主键定位干净得多。 + """ + score = db.query(Score).filter(Score.id == score_id).first() + if score is None: + raise NotFoundError(f"成绩记录 {score_id} 不存在") + if score.flag == 0: + raise BusinessRuleError("该成绩记录已删除,请先恢复") + + score.score = payload.score + db.commit() + db.refresh(score) + return get_score(db, score_id) + + +def delete_score(db: Session, score_id: int) -> None: + score = db.query(Score).filter(Score.id == score_id).first() + if score is None: + raise NotFoundError(f"成绩记录 {score_id} 不存在") + if score.flag == 0: + raise BusinessRuleError("该成绩记录已经是删除状态") + score.flag = 0 + db.commit() + + +def restore_score(db: Session, score_id: int) -> None: + score = db.query(Score).filter(Score.id == score_id).first() + if score is None: + raise NotFoundError(f"成绩记录 {score_id} 不存在") + score.flag = 1 + db.commit() + + +def score_summary(db: Session, student_id: int) -> dict: + """单个学生的成绩概览:均分、最高、最低、不及格次数。详情页用。""" + row = ( + db.query( + func.count(Score.id), + func.avg(Score.score), + func.max(Score.score), + func.min(Score.score), + func.sum(case((Score.score < PASS_LINE, 1), else_=0)), + ) + .filter(Score.student_id == student_id, Score.flag == 1) + .first() + ) + if not row or row[0] == 0: + return {"count": 0, "avg": 0.0, "max": 0.0, "min": 0.0, "fail_times": 0} + return { + "count": int(row[0]), + "avg": round(float(row[1]), 2), + "max": float(row[2]), + "min": float(row[3]), + "fail_times": int(row[4] or 0), + } diff --git a/student_management_system_complete/student_management_system/dao/student_dao.py b/student_management_system_complete/student_management_system/dao/student_dao.py new file mode 100644 index 0000000..4942b24 --- /dev/null +++ b/student_management_system_complete/student_management_system/dao/student_dao.py @@ -0,0 +1,522 @@ +# dao/student_dao.py +# 学生模块的数据访问层 + 统计分析。 +# +# 本文件修复的问题(对应报告 P0/P1): +# P0-2 count_avg:原版 GROUP BY 里带了 Score.score,每个分组内分数恒定, +# avg(Score.score) 恒等于该分数本身 —— 这个"班级平均分"是假的。 +# P0-4 统计接口没有过滤 Score.flag,已删除的成绩仍被算进不合格次数。 +# P0-5 get_higher_score / get_fail_more_than / work_time 都是 +# for student in all_students: for score in student.scores —— 标准 N+1, +# 1000 个学生会打 1001 条 SQL。改为聚合查询。 +# P0-6 work_time 把"offer→入职间隔"当成"就业时长",语义反了。 +# P1 update/delete 不过滤 flag,可以修改一个已删除的学生。 +# P1 create 不校验 class_id / advisor_id 是否存在。 + +from typing import Optional + +from sqlalchemy import and_, case, func, or_ +from sqlalchemy.orm import Session + +from dao.common import page_dict, paginate +from exceptions import BusinessRuleError, DuplicateStudentNo, NotFoundError +from model import Advisor, Classes, Employment, Exam, Score, Student +from model.enums import GenderEnum +from schema.student_schema import AgeQuery, StudentCreate, StudentQuery, StudentUpdate +from services.student_no import next_student_no + + +# ========================================================================== +# 内部工具 +# ========================================================================== +def _base_query(db: Session): + """学生 + 班级名 + 顾问名的联表查询。 + + 前端列表要显示班级名和顾问名。原版只返回 class_id,前端得再打一轮请求, + 或者拍脑袋写死映射 —— 联表一次带出来最省事。 + """ + return ( + db.query(Student, Classes.class_name, Advisor.advisor_name) + .join(Classes, Classes.cid == Student.class_id) + .join(Advisor, Advisor.id == Student.advisor_id) + ) + + +def _row_to_dict(row) -> dict: + student, class_name, advisor_name = row + return { + "sid": student.sid, + "student_no": student.student_no, + "student_name": student.student_name, + "class_id": student.class_id, + "class_name": class_name, + "advisor_id": student.advisor_id, + "advisor_name": advisor_name, + "native_place": student.native_place, + "school": student.school, + "major": student.major, + "enrollment_time": student.enrollment_time, + "graduation_time": student.graduation_time, + "education": student.education, + "age": student.age, + "gender": student.gender, + "state": student.state, + "flag": student.flag, + "created_at": student.created_at, + "updated_at": student.updated_at, + } + + +def _ensure_refs_exist(db: Session, class_id: Optional[int], advisor_id: Optional[int]) -> None: + """外键存在性校验。 + + 数据库开了外键约束的话,传个不存在的 class_id 会抛 IntegrityError → 500。 + 但对用户来说这是"你填错了",应该是 422。所以在这里显式检查,给出人能看懂的提示。 + """ + if class_id is not None: + cls = db.get(Classes, class_id) + if cls is None or cls.flag == 0: + raise BusinessRuleError(f"班级 {class_id} 不存在或已停用") + if advisor_id is not None: + adv = db.get(Advisor, advisor_id) + if adv is None or adv.flag == 0: + raise BusinessRuleError(f"顾问 {advisor_id} 不存在或已停用") + + +# ========================================================================== +# 增删改查 +# ========================================================================== +def create_student(db: Session, payload: StudentCreate): + _ensure_refs_exist(db, payload.class_id, payload.advisor_id) + + student_no = payload.student_no or next_student_no( + db, payload.enrollment_time.year if payload.enrollment_time else None + ) + + if db.query(Student.sid).filter(Student.student_no == student_no).first(): + raise DuplicateStudentNo(f"学号 {student_no} 已存在") + + data = payload.model_dump(exclude={"student_no"}) + student = Student(student_no=student_no, flag=1, **data) + db.add(student) + db.commit() + db.refresh(student) + return student + + +def list_students(db: Session, q: StudentQuery) -> dict: + query = _base_query(db).filter(Student.flag == 1) + + if q.student_no: + query = query.filter(Student.student_no == q.student_no) + if q.student_name: + query = query.filter(Student.student_name.like(f"%{q.student_name}%")) + if q.class_id is not None: + query = query.filter(Student.class_id == q.class_id) + if q.advisor_id is not None: + query = query.filter(Student.advisor_id == q.advisor_id) + if q.state is not None: + query = query.filter(Student.state == q.state) + if q.gender is not None: + query = query.filter(Student.gender == q.gender) + + query = query.order_by(Student.sid.desc()) + rows, total = paginate(query, q.page, q.size) + return page_dict([_row_to_dict(r) for r in rows], total, q.page, q.size) + + +def get_student(db: Session, sid: int) -> Optional[dict]: + row = _base_query(db).filter(Student.sid == sid, Student.flag == 1).first() + return _row_to_dict(row) if row else None + + +def update_student(db: Session, sid: int, payload: StudentUpdate) -> dict: + student = db.query(Student).filter(Student.sid == sid).first() + if student is None: + raise NotFoundError(f"学生 {sid} 不存在") + # 原版这里不过滤 flag,删除过的学生还能被继续修改 + if student.flag == 0: + raise BusinessRuleError(f"学生 {sid} 已删除,请先恢复再修改") + + data = payload.model_dump(exclude_unset=True, exclude_none=True) + + if "class_id" in data: + _ensure_refs_exist(db, data["class_id"], None) + if "advisor_id" in data: + _ensure_refs_exist(db, None, data["advisor_id"]) + + new_no = data.get("student_no") + if new_no and new_no != student.student_no: + exists = ( + db.query(Student.sid) + .filter(Student.student_no == new_no, Student.sid != sid) + .first() + ) + if exists: + raise DuplicateStudentNo(f"学号 {new_no} 已被其他学生占用") + + for key, value in data.items(): + setattr(student, key, value) + + db.commit() + db.refresh(student) + return get_student(db, sid) + + +def set_student_flag(db: Session, sid: int, flag: int) -> dict: + """逻辑删除 / 恢复。原版只有删除没有恢复,误删了只能去数据库改。""" + student = db.query(Student).filter(Student.sid == sid).first() + if student is None: + raise NotFoundError(f"学生 {sid} 不存在") + student.flag = flag + db.commit() + return {"sid": sid, "flag": flag} + + +# ========================================================================== +# 统计分析 +# ========================================================================== +def class_gender_stat(db: Session) -> list[dict]: + """按班级统计男女人数。 + + 原版是"遍历每个班级 → 打 3 条 count SQL",5 个班就是 15 条查询(N+1)。 + 这里用一次 条件聚合 就够:SUM(CASE WHEN gender='男' THEN 1 ELSE 0 END)。 + """ + rows = ( + db.query( + Classes.cid, + Classes.class_name, + func.count(Student.sid).label("class_total"), + func.sum(case((Student.gender == GenderEnum.MALE, 1), else_=0)).label("boys"), + func.sum(case((Student.gender == GenderEnum.FEMALE, 1), else_=0)).label("girls"), + ) + .outerjoin( + Student, + and_(Student.class_id == Classes.cid, Student.flag == 1), + ) + .filter(Classes.flag == 1) + .group_by(Classes.cid, Classes.class_name) + .order_by(Classes.cid) + .all() + ) + + return [ + { + "class_name": r.class_name, + "class_total": r.class_total or 0, + "boys_count": int(r.boys or 0), + "girls_count": int(r.girls or 0), + } + for r in rows + ] + + +def scores_above(db: Session, line: float) -> list[dict]: + """所有已录入成绩都不低于 line 且至少有一次成绩的学生。 + + 原版问题:① 不过滤已删除成绩;② N+1;③ 不按考试次序排序, + 返回的 stu_score 列表顺序取决于数据库返回顺序,前端画图会错位。 + """ + # 第一步:聚合出符合条件的学生 + rows = ( + db.query( + Student.sid, + Student.student_name, + Classes.class_name, + func.min(Score.score).label("min_score"), + ) + .join(Score, and_(Score.student_id == Student.sid, Score.flag == 1)) + .join(Classes, Classes.cid == Student.class_id) + .filter(Student.flag == 1, Classes.flag == 1) + .group_by(Student.sid, Student.student_name, Classes.class_name) + .having(func.min(Score.score) >= line) + .order_by(func.min(Score.score).desc()) + .all() + ) + if not rows: + return [] + + sids = [r.sid for r in rows] + + # 第二步:一次性把这些人所有有效成绩取回来(带考试名),按考试次序排好 + detail = ( + db.query(Score.student_id, Exam.exam_name, Exam.exam_order, Score.score) + .join(Exam, Exam.id == Score.exam_id) + .filter(Score.flag == 1, Score.student_id.in_(sids)) + .order_by(Exam.exam_order) + .all() + ) + buckets: dict[int, dict] = {} + for sid, exam_name, _order, score in detail: + b = buckets.setdefault(sid, {"exam_names": [], "scores": []}) + b["exam_names"].append(exam_name) + b["scores"].append(float(score)) + + return [ + { + "stu_id": r.sid, + "stu_name": r.student_name, + "class_name": r.class_name, + "exam_names": buckets.get(r.sid, {}).get("exam_names", []), + "stu_scores": buckets.get(r.sid, {}).get("scores", []), + "min_score": float(r.min_score), + } + for r in rows + ] + + +def fail_more_than(db: Session, fail_times: int, pass_line: float = 60) -> list[dict]: + """不及格次数 >= fail_times 的学生。 + + 原版把不及格线 60 硬编码在函数体里 —— 不同课程及格线不同时只能改代码。 + 现在作为参数,默认 60。 + """ + rows = ( + db.query( + Student.sid, + Student.student_name, + Classes.class_name, + func.count(Score.id).label("fail_times"), + ) + .join(Score, and_(Score.student_id == Student.sid, Score.flag == 1, Score.score < pass_line)) + .join(Classes, Classes.cid == Student.class_id) + .filter(Student.flag == 1) + .group_by(Student.sid, Student.student_name, Classes.class_name) + .having(func.count(Score.id) >= fail_times) + .order_by(func.count(Score.id).desc()) + .all() + ) + if not rows: + return [] + + sids = [r.sid for r in rows] + detail = ( + db.query(Score.student_id, Score.score) + .filter(Score.flag == 1, Score.student_id.in_(sids), Score.score < pass_line) + .order_by(Score.score) + .all() + ) + buckets: dict[int, list] = {} + for sid, score in detail: + buckets.setdefault(sid, []).append(float(score)) + + return [ + { + "stu_id": r.sid, + "stu_name": r.student_name, + "class_name": r.class_name, + "fail_scores": buckets.get(r.sid, []), + "fail_times": int(r.fail_times), + } + for r in rows + ] + + +def class_avg_score(db: Session, order_type: str = "desc") -> list[dict]: + """【重写】每个班级的平均分。 + + 原版的 SQL: + GROUP BY Score.score, Classes.cid, Classes.class_name + 然后 avg(Score.score) + GROUP BY 已经把 score 拆成了独立分组,组内 score 只有一个取值, + 所以 avg 永远等于 score 本身 —— 返回的其实是"成绩单去重后的分布", + 跟"班级平均分"毫无关系。 + + 正确做法:按班级分组,对组内所有成绩求平均。 + """ + rows = ( + db.query( + Classes.cid, + Classes.class_name, + func.count(func.distinct(Student.sid)).label("student_count"), + func.avg(Score.score).label("avg_score"), + func.max(Score.score).label("max_score"), + func.min(Score.score).label("min_score"), + ) + .join(Student, and_(Student.class_id == Classes.cid, Student.flag == 1)) + .join(Score, and_(Score.student_id == Student.sid, Score.flag == 1)) + .filter(Classes.flag == 1) + .group_by(Classes.cid, Classes.class_name) + ) + rows = rows.order_by(func.avg(Score.score).asc() if order_type == "asc" else func.avg(Score.score).desc()).all() + + return [ + { + "class_id": r.cid, + "class_name": r.class_name, + "student_count": int(r.student_count or 0), + "avg_score": round(float(r.avg_score), 2) if r.avg_score is not None else 0.0, + "max_score": float(r.max_score) if r.max_score is not None else 0.0, + "min_score": float(r.min_score) if r.min_score is not None else 0.0, + } + for r in rows + ] + + +def students_by_age(db: Session, q: AgeQuery) -> list[dict]: + query = _base_query(db).filter(Student.flag == 1, Student.age.isnot(None)) + + op = q.operator + if op == "gt": + query = query.filter(Student.age > q.value) + elif op == "lt": + query = query.filter(Student.age < q.value) + elif op == "eq": + query = query.filter(Student.age == q.value) + elif op == "ge": + query = query.filter(Student.age >= q.value) + elif op == "le": + query = query.filter(Student.age <= q.value) + elif op == "between": + query = query.filter(Student.age.between(q.min_value, q.max_value)) + + rows = query.order_by(Student.age).all() + return [_row_to_dict(r) for r in rows] + + +def top_n_salary(db: Session, n: int) -> list[dict]: + rows = ( + db.query( + Student.student_name, + Classes.class_name, + Employment.company_name, + Employment.salary, + Employment.offer_time, + ) + .join(Employment, and_(Employment.student_id == Student.sid, Employment.flag == 1)) + .join(Classes, Classes.cid == Student.class_id) + .filter(Student.flag == 1, Employment.salary.isnot(None)) + .order_by(Employment.salary.desc()) + .limit(n) + .all() + ) + return [ + { + "student_name": r.student_name, + "class_name": r.class_name, + "company_name": r.company_name, + "salary": float(r.salary) if r.salary is not None else 0.0, + "offer_time": r.offer_time, + } + for r in rows + ] + + +def work_duration(db: Session) -> list[dict]: + """每个学生「拿到 offer → 实际入职」的间隔天数。 + + 原版把这个值叫 work_time(就业时长),业务含义是反的: + 它衡量的是求职/等待周期,不是在职时长。在职时长要靠离职时间才能算出来, + 而表里根本没有离职时间字段。这里按真实含义改名并重新实现。 + """ + rows = ( + db.query( + Student.sid, + Student.student_name, + Classes.class_name, + Employment.offer_time, + Employment.employment_start_time, + ) + .join(Employment, and_(Employment.student_id == Student.sid, Employment.flag == 1)) + .join(Classes, Classes.cid == Student.class_id) + .filter( + Student.flag == 1, + Employment.offer_time.isnot(None), + Employment.employment_start_time.isnot(None), + ) + .all() + ) + + result = [] + for r in rows: + days = (r.employment_start_time - r.offer_time).days + if days < 0: + # 数据录入错误(入职早于 offer)。原版直接算成负数混进统计, + # 会把班级均值拉歪。这里剔出去,配合 Schema 层的校验双保险。 + continue + result.append( + { + "stu_id": r.sid, + "stu_name": r.student_name, + "class_name": r.class_name, + "offer_to_entry_days": days, + } + ) + return sorted(result, key=lambda x: x["offer_to_entry_days"], reverse=True) + + +def class_avg_work_duration(db: Session) -> list[dict]: + rows = work_duration(db) + buckets: dict[str, list[int]] = {} + for item in rows: + buckets.setdefault(item["class_name"] or "未分班", []).append(item["offer_to_entry_days"]) + + result = [] + for class_name, days_list in buckets.items(): + result.append( + { + "class_id": 0, + "class_name": class_name, + "student_count": len(days_list), + "avg_offer_to_entry_days": round(sum(days_list) / len(days_list), 1), + } + ) + return sorted(result, key=lambda x: x["avg_offer_to_entry_days"]) + + +def student_options(db: Session, keyword: Optional[str] = None, limit: int = 500) -> list[dict]: + """学生下拉选项(成绩录入、就业登记这类表单要用)。""" + query = db.query(Student.sid, Student.student_no, Student.student_name, Classes.class_name).join( + Classes, Classes.cid == Student.class_id + ).filter(Student.flag == 1) + if keyword: + like = f"%{keyword}%" + query = query.filter(or_(Student.student_name.like(like), Student.student_no.like(like))) + rows = query.order_by(Student.sid.desc()).limit(limit).all() + return [ + {"value": r.sid, "label": f"{r.student_name}({r.student_no})", "class_name": r.class_name} + for r in rows + ] + + +def dashboard(db: Session) -> dict: + """看板聚合。原版没有这个接口,前端要打 6 次请求才能拼出一张首页。""" + student_total = db.query(func.count(Student.sid)).filter(Student.flag == 1).scalar() or 0 + class_total = db.query(func.count(Classes.cid)).filter(Classes.flag == 1).scalar() or 0 + advisor_total = db.query(func.count(Advisor.id)).filter(Advisor.flag == 1).scalar() or 0 + + from model import Teacher + + teacher_total = db.query(func.count(Teacher.tid)).filter(Teacher.flag == 1).scalar() or 0 + + state_rows = ( + db.query(Student.state, func.count(Student.sid)) + .filter(Student.flag == 1) + .group_by(Student.state) + .all() + ) + state_distribution = { + (s.value if hasattr(s, "value") else (s or "未填写")): int(c) for s, c in state_rows + } + + employed_total = ( + db.query(func.count(func.distinct(Employment.student_id))) + .filter(Employment.flag == 1) + .scalar() + or 0 + ) + avg_salary = ( + db.query(func.avg(Employment.salary)) + .filter(Employment.flag == 1, Employment.is_current.is_(True)) + .scalar() + ) + + return { + "student_total": int(student_total), + "class_total": int(class_total), + "teacher_total": int(teacher_total), + "advisor_total": int(advisor_total), + "employed_total": int(employed_total), + "employment_rate": round(employed_total / student_total * 100, 1) if student_total else 0.0, + "avg_salary": round(float(avg_salary), 0) if avg_salary else 0.0, + "state_distribution": state_distribution, + } diff --git a/student_management_system_complete/student_management_system/dao/teacher_dao.py b/student_management_system_complete/student_management_system/dao/teacher_dao.py new file mode 100644 index 0000000..3bb165d --- /dev/null +++ b/student_management_system_complete/student_management_system/dao/teacher_dao.py @@ -0,0 +1,132 @@ +# dao/teacher_dao.py + +from typing import Optional + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from dao.common import page_dict, paginate +from exceptions import BusinessRuleError, NotFoundError +from model import Teacher +from schema.teacher_schema import TeacherCreate, TeacherQuery, TeacherUpdate + + +def get_teacher(db: Session, tid: int) -> Optional[Teacher]: + """查未删除的老师。原版用 filter(Teacher.tid == tid, Teacher.is_deleted == 1) 也对, + 但字段已统一改名为 flag,避免同一个"是否删除"在三张表里三个名字。""" + return db.query(Teacher).filter(Teacher.tid == tid, Teacher.flag == 1).first() + + +def to_dict(teacher: Teacher) -> dict: + """ORM -> dict。 + + 列表接口必须返回 dict,不能直接返回 ORM 对象: + 路由上的 response_model=dict 会让 Pydantic v2 去序列化这个 dict, + 而它不认识 SQLAlchemy 的模型类,直接抛 + PydanticSerializationError: Unable to serialize unknown type: + → 前端看到的是 500。其他模块(班级/顾问/学生)都是转成 dict 再返回的, + 教师模块当初漏了这一步。 + """ + phone = teacher.phone + return { + "tid": teacher.tid, + "t_name": teacher.t_name, + # 与 TeacherOut.mask_phone 保持一致:138****8001 + "phone": (phone[:3] + "****" + phone[7:]) if phone and len(phone) == 11 else phone, + "subject": teacher.subject, + "entry_time": teacher.entry_time, + "flag": teacher.flag, + "created_at": teacher.created_at, + } + + +def list_teachers(db: Session, q: TeacherQuery) -> dict: + query = db.query(Teacher).filter(Teacher.flag == 1) + + if q.keyword: + like = f"%{q.keyword}%" + query = query.filter(or_(Teacher.t_name.like(like), Teacher.subject.like(like))) + if q.subject: + query = query.filter(Teacher.subject == q.subject) + + query = query.order_by(Teacher.tid.desc()) + rows, total = paginate(query, q.page, q.size) + return page_dict([to_dict(t) for t in rows], total, q.page, q.size) + + +def create_teacher(db: Session, payload: TeacherCreate) -> Teacher: + if payload.phone: + exists = ( + db.query(Teacher.tid) + .filter(Teacher.phone == payload.phone, Teacher.flag == 1) + .first() + ) + if exists: + raise BusinessRuleError(f"手机号 {payload.phone} 已被其他老师使用") + + teacher = Teacher(**payload.model_dump(), flag=1) + db.add(teacher) + db.commit() + db.refresh(teacher) + return teacher + + +def update_teacher(db: Session, tid: int, payload: TeacherUpdate) -> Teacher: + teacher = db.query(Teacher).filter(Teacher.tid == tid).first() + if teacher is None: + raise NotFoundError(f"老师 {tid} 不存在") + if teacher.flag == 0: + raise BusinessRuleError(f"老师 {tid} 已删除,请先恢复再修改") + + data = payload.model_dump(exclude_unset=True, exclude_none=True) + + new_phone = data.get("phone") + if new_phone and new_phone != teacher.phone: + exists = ( + db.query(Teacher.tid) + .filter(Teacher.phone == new_phone, Teacher.tid != tid) + .first() + ) + if exists: + raise BusinessRuleError(f"手机号 {new_phone} 已被其他老师使用") + + for key, value in data.items(): + setattr(teacher, key, value) + + db.commit() + db.refresh(teacher) + return teacher + + +def delete_teacher(db: Session, tid: int) -> None: + """逻辑删除。原版叫 delete_teacher 但思路是对的,这里补上"已删除"的幂等判断。""" + teacher = db.query(Teacher).filter(Teacher.tid == tid).first() + if teacher is None: + raise NotFoundError(f"老师 {tid} 不存在") + if teacher.flag == 0: + raise BusinessRuleError(f"老师 {tid} 已经是删除状态") + teacher.flag = 0 + db.commit() + + +def restore_teacher(db: Session, tid: int) -> None: + teacher = db.query(Teacher).filter(Teacher.tid == tid).first() + if teacher is None: + raise NotFoundError(f"老师 {tid} 不存在") + teacher.flag = 1 + db.commit() + + +def teacher_options(db: Session) -> list[dict]: + """下拉选项专用:只回 id + name,不带手机号等敏感字段。""" + rows = ( + db.query(Teacher.tid, Teacher.t_name, Teacher.subject) + .filter(Teacher.flag == 1) + .order_by(Teacher.tid) + .all() + ) + return [{"value": r.tid, "label": r.t_name, "subject": r.subject} for r in rows] + + +def count_teachers(db: Session) -> int: + return db.query(func.count(Teacher.tid)).filter(Teacher.flag == 1).scalar() or 0 diff --git a/student_management_system_complete/student_management_system/database.py b/student_management_system_complete/student_management_system/database.py new file mode 100644 index 0000000..31eb118 --- /dev/null +++ b/student_management_system_complete/student_management_system/database.py @@ -0,0 +1,51 @@ +# database.py +# 本文件负责配置数据库连接、创建引擎、会话工厂,并提供依赖注入函数。 +# +# 相比原版的三点改动: +# 1. 连接串不再硬编码 root/123456,改为读 settings(环境变量 / .env); +# 2. 引擎补上 pool_pre_ping / pool_recycle —— 解决 MySQL "server has gone away"; +# 3. expire_on_commit=False —— 避免 commit 后返回 ORM 对象时又触发隐藏查询。 + +import logging + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm.decl_api import declarative_base + +from settings import settings + +logger = logging.getLogger(__name__) + +# 1. 数据库引擎 +engine = create_engine( + settings.database_url, + echo=settings.DB_ECHO, # 打开后会把每条 SQL 打到日志,排查问题用 + pool_size=settings.DB_POOL_SIZE, # 连接池常驻连接数 + max_overflow=settings.DB_MAX_OVERFLOW, + pool_recycle=settings.DB_POOL_RECYCLE, # 超过 N 秒的连接主动回收,避免被 MySQL 断开 + pool_pre_ping=True, # 取连接前先 ping 一次,自动剔除死连接 + future=True, +) + + +# 2. 会话工厂 +# autocommit=False:不自动提交,事务边界由 DAO 显式控制 +# expire_on_commit=False:commit 后对象不过期,API 层序列化时不会再多打一次 SELECT +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + expire_on_commit=False, + bind=engine, +) + +# 3. 声明式基类 +Base = declarative_base() + + +# 4. 依赖注入:每个 HTTP 请求一个独立会话,请求结束必定关闭 +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/student_management_system_complete/student_management_system/exceptions.py b/student_management_system_complete/student_management_system/exceptions.py new file mode 100644 index 0000000..eb22c2d --- /dev/null +++ b/student_management_system_complete/student_management_system/exceptions.py @@ -0,0 +1,183 @@ +# exceptions.py +# 业务异常体系 + 全局异常处理器。 +# +# 原版的问题:每个 route 各自 try/except,DAO 抛什么、API 捕什么全靠人记, +# 漏捕的地方直接把 SQLAlchemy 堆栈 500 给前端,既难看又泄露表结构。 +# +# 改造后:DAO 只负责抛「业务异常」,main.py 注册一次处理器,统一翻译成 HTTP 响应。 + +import logging + +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from sqlalchemy.exc import DataError, IntegrityError, OperationalError, SQLAlchemyError + +logger = logging.getLogger(__name__) + + +# ============================ 业务异常 ============================ +class AppException(Exception): + """所有业务异常的基类。 + + code 业务错误码,前端按码判断,不依赖文案 + message 给用户看的中文提示 + http_status 对应 HTTP 状态码 + """ + + code: int = 40000 + http_status: int = status.HTTP_400_BAD_REQUEST + message: str = "请求处理失败" + + def __init__(self, message: str | None = None, *, code: int | None = None): + if message: + self.message = message + if code is not None: + self.code = code + super().__init__(self.message) + + +class NotFoundError(AppException): + code = 40400 + http_status = status.HTTP_404_NOT_FOUND + message = "资源不存在" + + +class ConflictError(AppException): + """唯一键冲突:学号重复、班级重名、重复就业等。""" + + code = 40900 + http_status = status.HTTP_409_CONFLICT + message = "数据冲突" + + +class DuplicateStudentNo(ConflictError): + code = 40901 + message = "学号已存在" + + +class BusinessRuleError(AppException): + """业务规则不满足:如"已删除的学生不允许修改""班级下还有学生不允许删除"。""" + + code = 42200 + # 直接写 422,不用 status.HTTP_422_UNPROCESSABLE_ENTITY —— + # Starlette 已经把它标记为 deprecated 并改名,跟着写会一直吃 DeprecationWarning + http_status = 422 + message = "业务规则校验失败" + + +class DataAccessError(AppException): + code = 50001 + http_status = status.HTTP_500_INTERNAL_SERVER_ERROR + message = "数据访问失败" + + +# 兼容旧代码:原 dao/student_dao.py 里定义过 BusinessException +BusinessException = AppException + + +# ============================ 统一响应体 ============================ +def ok(data=None, message: str = "success", code: int = 0) -> JSONResponse: + return JSONResponse(status_code=200, content={"code": code, "message": message, "data": data}) + + +def fail(code: int, message: str, http_status: int) -> JSONResponse: + return JSONResponse(status_code=http_status, content={"code": code, "message": message, "data": None}) + + +# ============================ 数据库异常翻译 ============================ +# MySQL 的错误码含义(这些是"业务能看懂的错误",不该返回 500): +MYSQL_ERROR_MAP = { + 1062: (40900, "数据已存在(唯一键冲突)", 409), # ER_DUP_ENTRY + 1451: (40900, "该记录被其他数据引用,无法删除", 409), # ER_ROW_IS_REFERENCED_2 + 1452: (42200, "关联的数据不存在(外键校验失败)", 422), # ER_NO_REFERENCED_ROW_2 + 3819: (42201, "数据不满足字段约束,请检查手机号等格式要求", 422), # ER_CHECK_CONSTRAINT_VIOLATED + 1265: (40002, "字段值不合法:类型、长度或枚举取值不正确", 400), # ER_WARN_DATA_TRUNCATED + 1406: (40003, "字段内容超出长度限制", 400), # ER_DATA_TOO_LONG + 1048: (40004, "必填字段不能为空", 400), # ER_BAD_NULL_ERROR +} + + +def classify_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, int]: + """把 SQLAlchemy 异常翻译成 (业务错误码, 中文提示, HTTP 状态码)。 + + 单独抽成纯函数是为了能直接被测试覆盖 —— 真实 MySQL 的 CHECK 约束违反 + 抛的是 OperationalError(3819) 而不是 IntegrityError,这个坑不写测试很容易再踩。 + """ + orig = getattr(exc, "orig", None) + err_code = None + if orig is not None: + args = getattr(orig, "args", None) or () + if args and isinstance(args[0], int): + err_code = args[0] + + if err_code in MYSQL_ERROR_MAP: + return MYSQL_ERROR_MAP[err_code] + + # SQLite 没有数字错误码,按异常类型区分 + if isinstance(exc, IntegrityError): + return 40900, "数据违反唯一性或外键约束,请检查关联数据", 409 + if isinstance(exc, DataError): + return 40002, "字段值不合法:类型、长度或枚举取值不正确", 400 + + # 剩下的多半是连接断了、SQL 语法错误、表不存在 —— 这才是真正的 500 + return 50001, "数据库暂时不可用,请稍后重试", 500 + + +# ============================ 注册处理器 ============================ +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(AppException) + async def _app_exception_handler(request: Request, exc: AppException): + logger.warning("业务异常 %s %s -> [%s] %s", request.method, request.url.path, exc.code, exc.message) + return fail(exc.code, exc.message, exc.http_status) + + @app.exception_handler(RequestValidationError) + async def _validation_handler(request: Request, exc: RequestValidationError): + # 把 Pydantic 的错误压成一行给人看的中文提示 + first = exc.errors()[0] if exc.errors() else {} + loc = ".".join(str(x) for x in first.get("loc", []) if x not in ("body", "query", "path")) + msg = first.get("msg", "参数不合法") + detail = f"参数校验失败:{loc} {msg}" if loc else f"参数校验失败:{msg}" + logger.info("参数校验失败 %s %s -> %s", request.method, request.url.path, detail) + return fail(40001, detail, status.HTTP_400_BAD_REQUEST) + + @app.exception_handler(IntegrityError) + async def _integrity_handler(request: Request, exc: IntegrityError): + # 唯一键 / 外键冲突:MySQL 报 1062 / 1451 / 1452,统一翻成 409 + code, message, http_status = classify_sqlalchemy_error(exc) + logger.warning("数据库约束冲突 %s %s -> %s", request.method, request.url.path, exc.orig) + return fail(code, message, http_status) + + @app.exception_handler(DataError) + async def _data_error_handler(request: Request, exc: DataError): + # 字段值不合法:MySQL 1265(枚举取值非法/数据被截断)、1406(超长) 都走这里。 + # 正常情况这些会在 Pydantic 层就被拦掉,这里是最后一道防线。 + code, message, http_status = classify_sqlalchemy_error(exc) + logger.warning("字段值非法 %s %s -> %s", request.method, request.url.path, exc.orig) + return fail(code, message, http_status) + + @app.exception_handler(OperationalError) + async def _operational_handler(request: Request, exc: OperationalError): + """注意:MySQL 的 CHECK 约束违反(3819) 抛的是 OperationalError,不是 IntegrityError。 + + 这是只有拿真库跑才会发现的事 —— 用 SQLite 测永远测不出来。 + 如果不单独处理,用户把手机号填成 "123" 会收到 500「服务器内部错误」, + 而真正的原因只是一个字段格式问题。 + """ + code, message, http_status = classify_sqlalchemy_error(exc) + if http_status < 500: + logger.warning("字段约束违反 %s %s -> %s", request.method, request.url.path, exc.orig) + else: + logger.exception("数据库连接或执行异常") + return fail(code, message, http_status) + + @app.exception_handler(SQLAlchemyError) + async def _sqlalchemy_handler(request: Request, exc: SQLAlchemyError): + code, message, http_status = classify_sqlalchemy_error(exc) + logger.exception("数据库异常") + return fail(code, message, http_status) + + @app.exception_handler(Exception) + async def _fallback_handler(request: Request, exc: Exception): + logger.exception("未捕获异常") + return fail(50000, "服务器内部错误", status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/student_management_system_complete/student_management_system/init.sql b/student_management_system_complete/student_management_system/init.sql new file mode 100644 index 0000000..450125e --- /dev/null +++ b/student_management_system_complete/student_management_system/init.sql @@ -0,0 +1,29 @@ +-- init.sql +-- 首次部署时先执行这个脚本建库/建账号,再跑 python init_db.py +-- +-- mysql -u root -p < init.sql +-- +-- 为什么单独一个文件: +-- SQLAlchemy 的 create_all() 只会建表,不会建 database。 +-- 很多人第一次跑报「Unknown database 'student_management_system'」, +-- 就是因为漏了这一步。 +-- +-- 字符集必须是 utf8mb4: +-- utf8(3 字节)存不下 emoji,也存不下部分生僻字。 +-- 学生姓名、籍贯里出现这类字符时,用 utf8 会直接报 +-- 「Incorrect string value」而整条插入失败。 + +CREATE DATABASE IF NOT EXISTS `student_management_system` + DEFAULT CHARACTER SET utf8mb4 + DEFAULT COLLATE utf8mb4_0900_ai_ci; + +-- 可选:给应用单独建一个低权限账号,不要用 root 连业务库。 +-- 生产环境尤其重要 —— root 权限过大,一旦应用被注入损失无法控制。 +-- CREATE USER IF NOT EXISTS 'sms_app'@'%' IDENTIFIED BY '换成你自己的密码'; +-- GRANT SELECT, INSERT, UPDATE, DELETE ON `student_management_system`.* TO 'sms_app'@'%'; +-- FLUSH PRIVILEGES; + +-- 建完库之后: +-- 1. 把 .env.example 复制成 .env,填上 DB_USER / DB_PASSWORD +-- 2. python init_db.py --seed 建表 + 灌演示数据 +-- 3. python main.py 启动服务(默认 http://127.0.0.1:8004) diff --git a/student_management_system_complete/student_management_system/init_db.py b/student_management_system_complete/student_management_system/init_db.py new file mode 100644 index 0000000..497361e --- /dev/null +++ b/student_management_system_complete/student_management_system/init_db.py @@ -0,0 +1,58 @@ +# init_db.py +# 数据库初始化脚本。 +# +# 用法: +# python init_db.py 建表(已存在的表不动) +# python init_db.py --seed 建表 + 灌演示数据 +# python init_db.py --reset 删表重建 + 灌演示数据(会清空数据,慎用) +# +# 原版把这件事塞在 main.py 的 if __name__ == "__main__" 里, +# "启动服务"顺手就把表建了、数据灌了 —— 这在真实项目里是事故来源。 + +import sys + +from database import Base, engine +from settings import settings + +# 必须导入 model 包,否则 Base.metadata 里一张表都没有,create_all 会静默什么都不建 +import model # noqa: F401 +from seed_data import seed_all + + +def create_tables() -> None: + Base.metadata.create_all(bind=engine) + print(f"[ok] 建表完成:{settings.DB_NAME}({len(Base.metadata.tables)} 张表)") + for name in sorted(Base.metadata.tables): + print(f" - {name}") + + +def drop_tables() -> None: + Base.metadata.drop_all(bind=engine) + print("[ok] 已删除所有表") + + +def reset_tables() -> None: + drop_tables() + create_tables() + + +def main() -> None: + args = set(sys.argv[1:]) + reset = "--reset" in args + seed = "--seed" in args or reset + + if reset: + print("[!] --reset:正在删表重建,历史数据将丢失") + reset_tables() + else: + create_tables() + + if seed: + seed_all() + + print("\n启动服务:python main.py") + print(f"接口文档:http://{settings.HOST}:{settings.PORT}/docs") + + +if __name__ == "__main__": + main() diff --git a/student_management_system_complete/student_management_system/main.py b/student_management_system_complete/student_management_system/main.py new file mode 100644 index 0000000..0a4b76c --- /dev/null +++ b/student_management_system_complete/student_management_system/main.py @@ -0,0 +1,137 @@ +# main.py +# 项目入口:创建 FastAPI 应用、注册中间件与路由、注册全局异常处理、托管前端页面、启动服务。 +# +# 原版的几处硬伤: +# 1. allow_origins=["*"] + allow_credentials=True —— 浏览器规范禁止这个组合, +# 带 Cookie 的请求一定会失败。已改为显式白名单。 +# 2. if __name__ == "__main__" 里 init_all(True, False) —— "启动服务" +# 和"建表 + 灌种子数据"耦合在一起,线上重启一次就可能动到数据。 +# 改为独立的 init_db.py 脚本 + lifespan 里只做健康检查。 +# 3. 没有统一异常处理,任何未捕获异常都以 500 + 堆栈露出。 +# 4. 没有日志配置,出问题只能靠 print。 +# 5. 【本次新增】只跑 main.py 时看不到前端界面,只有一个 Swagger 文档。 +# 现在把 frontend/ 目录挂到根路径上,启动后端 = 整套系统可用, +# 而且还顺带消灭了跨域问题(同源就不需要 CORS)。 + +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from starlette.middleware.gzip import GZipMiddleware + +from settings import settings + +logging.basicConfig( + level=logging.DEBUG if settings.DEBUG else logging.INFO, + format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s", +) +logger = logging.getLogger("app") + +# 前端目录:与本文件同级目录的上一级下的 frontend/ +# 用 __file__ 推导而不是写死路径,项目整个拷走也不会失效。 +FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("服务启动:%s v%s,API 前缀 %s", settings.APP_NAME, settings.APP_VERSION, settings.API_PREFIX) + yield + logger.info("服务关闭") + + +app = FastAPI( + title=settings.APP_NAME, + description="学生管理系统 —— FastAPI + SQLAlchemy 分层架构(MySQL)", + version=settings.APP_VERSION, + lifespan=lifespan, +) + +# ---------------- 中间件 ---------------- +# 生产环境请把 CORS_ORIGINS 收窄到真实前端域名 +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["*"], +) +# 响应体压缩:列表接口返回几百条 JSON 时能省 60%+ 流量 +app.add_middleware(GZipMiddleware, minimum_size=1024) + +# ---------------- 全局异常处理 ---------------- +from exceptions import register_exception_handlers # noqa: E402 + +register_exception_handlers(app) + +# ---------------- 路由 ---------------- +from api.router import api_router # noqa: E402 + +app.include_router(api_router, prefix=settings.API_PREFIX) + + +# ---------------- 基础端点 ---------------- +@app.get("/health", tags=["系统"], summary="健康检查") +async def health(): + """健康检查要真的探一下数据库。 + + 只返回 {"status":"ok"} 的假健康检查在真实运维里是负资产 —— + 容器编排会认为服务正常,而实际上数据库早就挂了。 + """ + from sqlalchemy import text + + from database import engine + + db_ok, db_error = True, None + try: + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + except Exception as exc: # noqa: BLE001 + db_ok, db_error = False, str(exc) + logger.error("健康检查:数据库不可达 -> %s", exc) + + return { + "status": "ok" if db_ok else "degraded", + "app": settings.APP_NAME, + "version": settings.APP_VERSION, + "database": "up" if db_ok else "down", + "api_prefix": settings.API_PREFIX, + "frontend": "/" if FRONTEND_DIR.is_dir() else None, + "docs": "/docs", + "detail": db_error, + } + + +# ---------------- 前端托管 ---------------- +# 放在最后注册:Starlette 按注册顺序匹配路由,Mount("/") 是个通吃规则, +# 必须排在 /api/v1/**、/docs、/health 后面,否则会把接口全挡掉。 +if FRONTEND_DIR.is_dir(): + app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend") + logger.info("已托管前端页面:http://%s:%s/ <- %s", settings.HOST, settings.PORT, FRONTEND_DIR) +else: + # 只部署后端(没带 frontend 目录)时不报错,只是提供一份说明 + @app.get("/", tags=["系统"], summary="服务信息") + async def root(): + return { + "message": f"{settings.APP_NAME} 服务运行中(未找到 frontend 目录,仅提供 API)", + "version": settings.APP_VERSION, + "docs": "/docs", + "health": "/health", + "api_prefix": settings.API_PREFIX, + } + logger.warning("未找到前端目录 %s,只提供 API。看界面请启动 frontend 下的静态服务。", FRONTEND_DIR) + + +if __name__ == "__main__": + import uvicorn + + # 只负责启动。建表 / 灌数据请执行: + # python init_db.py + uvicorn.run( + "main:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.DEBUG, + ) diff --git a/student_management_system_complete/student_management_system/model/__init__.py b/student_management_system_complete/student_management_system/model/__init__.py new file mode 100644 index 0000000..7175cbc --- /dev/null +++ b/student_management_system_complete/student_management_system/model/__init__.py @@ -0,0 +1,30 @@ +# model/__init__.py +# 统一导出,保证 Base.metadata 在 init_db 之前加载到所有表。 +# 漏掉任何一个 import,create_all() 就会静默少建一张表。 + +from .advisor_model import Advisor +from .base import SoftDeleteMixin, TimestampMixin +from .class_model import Classes +from .employment_model import Employment +from .enums import EducationEnum, GenderEnum, StudentStateEnum +from .exam_model import Exam +from .score_model import Score +from .student_model import Student +from .teacher_model import Teacher, teacher_class, teacher_student + +__all__ = [ + "Advisor", + "Classes", + "EducationEnum", + "Employment", + "Exam", + "GenderEnum", + "Score", + "SoftDeleteMixin", + "Student", + "StudentStateEnum", + "Teacher", + "TimestampMixin", + "teacher_class", + "teacher_student", +] diff --git a/student_management_system_complete/student_management_system/model/advisor_model.py b/student_management_system_complete/student_management_system/model/advisor_model.py new file mode 100644 index 0000000..b805c04 --- /dev/null +++ b/student_management_system_complete/student_management_system/model/advisor_model.py @@ -0,0 +1,30 @@ +# model/advisor_model.py +# 顾问表。 +# +# 改动: +# 性别枚举改从 model/enums.py 引入(原来是这张表私有,student 表用裸字符串) +# flag 补 default=1(原版 nullable=False 但无默认值,新增时漏传就直接 500) +# 补 created_at / updated_at + +from sqlalchemy import Column, Enum as SQLEnum, Integer, String +from sqlalchemy.orm import relationship + +from database import Base +from model.base import SoftDeleteMixin, TimestampMixin +from model.enums import GenderEnum + + +class Advisor(Base, SoftDeleteMixin, TimestampMixin): + __tablename__ = "advisor" + + id = Column(Integer, primary_key=True, autoincrement=True, comment="顾问编号") + advisor_name = Column(String(20), unique=True, nullable=False, index=True, comment="顾问姓名") + gender = Column( + SQLEnum(GenderEnum, values_callable=lambda e: [m.value for m in e], name="gender_enum"), + nullable=True, + comment="性别", + ) + phone = Column(String(11), nullable=True, comment="联系电话") + + # 一对多:一个顾问负责多个学生 + students = relationship("Student", back_populates="advisor") diff --git a/student_management_system_complete/student_management_system/model/base.py b/student_management_system_complete/student_management_system/model/base.py new file mode 100644 index 0000000..63a8dc3 --- /dev/null +++ b/student_management_system_complete/student_management_system/model/base.py @@ -0,0 +1,64 @@ +# model/base.py +# 全表共用的 Mixin。 +# +# 原版最严重的设计问题是“逻辑删除字段三套写法”: +# student.flag / advisor.flag / score.flag / employment.flag -> 1=正常, 0=删除 +# teacher.is_deleted -> 1=未删除, 0=已删除 +# classes.is_del -> 注释和代码互相矛盾(见 class_dao / class_api) +# 一旦有人记错方向,删掉的数据就会“复活”,或者正常数据被当成已删除而查不出来。 +# +# 统一约定(写进注释、写进文档、前端也按这个来): +# flag = 1 正常 +# flag = 0 已(逻辑)删除 + +from sqlalchemy import Column, DateTime, Integer, func +from sqlalchemy.orm import declared_attr + + +class SoftDeleteMixin: + """逻辑删除标记。""" + + @declared_attr + def flag(cls): # noqa: N805 + return Column( + Integer, + nullable=False, + default=1, + server_default="1", + index=True, + comment="逻辑删除:1=正常,0=已删除", + ) + + def soft_delete(self) -> None: + self.flag = 0 + + def restore(self) -> None: + self.flag = 1 + + +class TimestampMixin: + """审计字段。 + + 真实业务里"这条数据谁在什么时候建的/改的"必须能回答, + 否则出了数据事故就只能靠猜。这里先用数据库时间,后面接上登录态 + 再加 created_by / updated_by。 + """ + + @declared_attr + def created_at(cls): # noqa: N805 + return Column( + DateTime, + nullable=False, + server_default=func.now(), + comment="创建时间", + ) + + @declared_attr + def updated_at(cls): # noqa: N805 + return Column( + DateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), + comment="最后修改时间", + ) diff --git a/student_management_system_complete/student_management_system/model/class_model.py b/student_management_system_complete/student_management_system/model/class_model.py new file mode 100644 index 0000000..fef7381 --- /dev/null +++ b/student_management_system_complete/student_management_system/model/class_model.py @@ -0,0 +1,33 @@ +# model/class_model.py +# 班级表。 +# +# 改动: +# is_del -> flag,语义统一为 1=正常 / 0=已删除(原版这个字段是 0 和 1 混用的重灾区) +# class_name 加唯一约束(原先只在 API 层查重,并发下会插进两条同名班级) +# 补 created_at / updated_at + +from sqlalchemy import Column, DateTime, Integer, String, UniqueConstraint +from sqlalchemy.orm import relationship + +from database import Base +from model.base import SoftDeleteMixin, TimestampMixin +from model.teacher_model import teacher_class + + +class Classes(Base, SoftDeleteMixin, TimestampMixin): + __tablename__ = "classes" + __table_args__ = ( + # 同一时间只允许存在一个未删除的同名班级 + UniqueConstraint("class_name", "flag", name="uq_classes_name_flag"), + ) + + cid = Column(Integer, primary_key=True, autoincrement=True, comment="班级编号") + class_name = Column(String(10), nullable=False, index=True, comment="班级名称") + start_time = Column(DateTime, nullable=False, comment="开班时间") + head_teacher = Column(String(10), nullable=False, comment="班主任") + teacher = Column(String(15), nullable=False, comment="任课老师") + + # 老师 ↔ 班级 多对多 + teachers = relationship("Teacher", secondary=teacher_class, back_populates="classes") + # 班级 ↔ 学生 一对多 + students = relationship("Student", back_populates="classes") diff --git a/student_management_system_complete/student_management_system/model/demo.py b/student_management_system_complete/student_management_system/model/demo.py new file mode 100644 index 0000000..07b31ca --- /dev/null +++ b/student_management_system_complete/student_management_system/model/demo.py @@ -0,0 +1,28 @@ +# +# from sqlalchemy import Column, Integer, String, ForeignKey +# from sqlalchemy.orm import relationship +# from sqlalchemy.sql.sqltypes import DateTime +# from database import Base +# +# +# #学生表 +# class Student(Base): +# __tablename__ = "student" +# id = Column(Integer, primary_key=True,autoincrement=True) #学生编号 +# # class_id = Column(Integer,ForeignKey('student_class.id')) #班级ID +# advisor=relationship("Advisor",back_populates="students") +# student_no = Column(Integer) #学号 +# name = Column(String(10),nullable=False) #姓名 +# native_places = Column(String(10),nullable=False) #籍贯 +# school = Column(String(10),nullable=False) #学校 +# major = Column(String(10),nullable=False) #专业 +# enrollment_time = Column(DateTime(timezone=True),nullable=False) #入学时间 +# graduation_time = Column(DateTime(timezone=True),nullable=False) #毕业时间 +# education = Column(String(10),nullable=False) #学历 +# advisor_id = Column(Integer,ForeignKey('advisor.id'),nullable=False) #顾问编号 +# age = Column(String(10),nullable=False) #年龄 +# gender = Column(String(10),nullable=False) #性别 +# status = Column(String(10)) #状态(在读、进入就业、已就业) +# flag = Column(Integer,nullable=False) #逻辑删除标记 0表示已经删除 1表示可以删除 +# scores = relationship("Score",back_populates="student") +# diff --git a/student_management_system_complete/student_management_system/model/employment_model.py b/student_management_system_complete/student_management_system/model/employment_model.py new file mode 100644 index 0000000..63a45ce --- /dev/null +++ b/student_management_system_complete/student_management_system/model/employment_model.py @@ -0,0 +1,44 @@ +# model/employment_model.py +# 就业信息 ORM 模型。 +# +# 改动: +# student_id 去掉 unique=True → 改为索引。 +# 原版 unique 意味着"一个学生一辈子只能有一条就业记录", +# 换一次工作就插不进去,只能把旧记录改了 —— 就业历史直接丢失。 +# 现在允许多条,用 is_current 标记"当前在职",统计时只看 is_current=1。 +# 补 created_at / updated_at + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Index, + Integer, + String, +) +from sqlalchemy.orm import relationship + +from database import Base +from model.base import SoftDeleteMixin, TimestampMixin + + +class Employment(Base, SoftDeleteMixin, TimestampMixin): + __tablename__ = "employment" + __table_args__ = ( + # 常用查询:某学生的所有就业记录、当前在职记录 + Index("ix_employment_student_current", "student_id", "is_current"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + student_id = Column(Integer, ForeignKey("student.sid"), nullable=False, index=True, comment="学生ID") + + offer_time = Column(DateTime, nullable=True, comment="Offer 下发时间") + employment_start_time = Column(DateTime, nullable=True, comment="实际入职时间") + company_name = Column(String(100), nullable=True, comment="公司名称") + salary = Column(Float, nullable=True, comment="月薪(元)") + + is_current = Column(Boolean, nullable=False, default=True, server_default="1", comment="是否当前在职记录") + + student = relationship("Student", back_populates="employment") diff --git a/student_management_system_complete/student_management_system/model/enums.py b/student_management_system_complete/student_management_system/model/enums.py new file mode 100644 index 0000000..8467fd2 --- /dev/null +++ b/student_management_system_complete/student_management_system/model/enums.py @@ -0,0 +1,37 @@ +# model/enums.py +# 全局枚举集中定义。 +# +# 原版把“性别枚举”写在 advisor_model.py 里,student 表却用裸字符串, +# 结果同一套业务概念在两张表里有两套表示 —— 这是典型的数据不一致源头。 +# 统一到这里之后,所有表和所有 Schema 引用同一份定义。 + +import enum + + +class GenderEnum(str, enum.Enum): + """性别。""" + + MALE = "男" + FEMALE = "女" + + +class StudentStateEnum(str, enum.Enum): + """学生状态机:在读 → 进入就业 → 已就业。 + + 写成枚举而不是字符串,是为了让“非法状态”在接口层就被拦掉, + 而不是等报表统计时才发现库里躺着一条 "在渎"。 + """ + + STUDYING = "在读" + ENTERING_EMPLOYMENT = "进入就业" + EMPLOYED = "已就业" + + +class EducationEnum(str, enum.Enum): + """学历。""" + + JUNIOR_COLLEGE = "大专" + BACHELOR = "本科" + MASTER = "硕士" + DOCTOR = "博士" + OTHER = "其他" diff --git a/student_management_system_complete/student_management_system/model/exam_model.py b/student_management_system_complete/student_management_system/model/exam_model.py new file mode 100644 index 0000000..e29775b --- /dev/null +++ b/student_management_system_complete/student_management_system/model/exam_model.py @@ -0,0 +1,27 @@ +# model/exam_model.py +# 考试表。 +# +# 原版 Score.exam_id 是一个"魔法数字":DAO 里写死 `if exam_id not in (1, 2, 3)`, +# 三种考试的含义只能靠口口相传,想加"期末考"就得改代码。 +# 真实业务里考试是主数据,应该建表管理。 + +from sqlalchemy import Column, Date, Integer, String, UniqueConstraint +from sqlalchemy.orm import relationship + +from database import Base +from model.base import SoftDeleteMixin, TimestampMixin + + +class Exam(Base, SoftDeleteMixin, TimestampMixin): + __tablename__ = "exam" + __table_args__ = ( + UniqueConstraint("exam_order", "flag", name="uq_exam_order_flag"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True, comment="考试编号") + exam_name = Column(String(50), nullable=False, comment="考试名称,如「第一次月考」") + # 次序用于排序与"每次考试"的比较逻辑,替代原来的 1/2/3 硬编码 + exam_order = Column(Integer, nullable=False, comment="考试次序,从 1 开始") + exam_date = Column(Date, nullable=True, comment="考试日期") + + scores = relationship("Score", back_populates="exam") diff --git a/student_management_system_complete/student_management_system/model/score_model.py b/student_management_system_complete/student_management_system/model/score_model.py new file mode 100644 index 0000000..3bf32a5 --- /dev/null +++ b/student_management_system_complete/student_management_system/model/score_model.py @@ -0,0 +1,29 @@ +# model/score_model.py +# 成绩表。 +# +# 改动: +# exam_id 由魔法数字改为外键关联 exam 表 +# score 由 Integer 改为 Numeric(5,2),支持 85.5 这种成绩 +# 加 (student_id, exam_id, flag) 唯一约束 —— 原来只靠 DAO 里 first() 查重, +# 两个请求同时进来会插进两条同样的成绩 + +from sqlalchemy import Column, ForeignKey, Integer, Numeric, UniqueConstraint +from sqlalchemy.orm import relationship + +from database import Base +from model.base import SoftDeleteMixin, TimestampMixin + + +class Score(Base, SoftDeleteMixin, TimestampMixin): + __tablename__ = "score" + __table_args__ = ( + UniqueConstraint("student_id", "exam_id", "flag", name="uq_score_student_exam_flag"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + student_id = Column(Integer, ForeignKey("student.sid"), nullable=False, index=True, comment="学生ID") + exam_id = Column(Integer, ForeignKey("exam.id"), nullable=False, index=True, comment="考试ID") + score = Column(Numeric(5, 2), nullable=False, comment="成绩,0~100") + + student = relationship("Student", back_populates="scores") + exam = relationship("Exam", back_populates="scores") diff --git a/student_management_system_complete/student_management_system/model/student_model.py b/student_management_system_complete/student_management_system/model/student_model.py new file mode 100644 index 0000000..95a2191 --- /dev/null +++ b/student_management_system_complete/student_management_system/model/student_model.py @@ -0,0 +1,72 @@ +# model/student_model.py +# 学生表。 +# +# 改动: +# gender / state / education 改为枚举,非法值在入库前就被拦掉 +# student_no 加唯一索引 + 学号生成规则(见 services/student_no.py) +# 外键补 ondelete / index,避免全表扫描 +# 补 created_at / updated_at + +from sqlalchemy import ( + CheckConstraint, + Column, + Date, + Enum as SQLEnum, + ForeignKey, + Index, + Integer, + String, +) +from sqlalchemy.orm import relationship + +from database import Base +from model.base import SoftDeleteMixin, TimestampMixin +from model.enums import EducationEnum, GenderEnum, StudentStateEnum +from model.teacher_model import teacher_student + + +class Student(Base, SoftDeleteMixin, TimestampMixin): + __tablename__ = "student" + __table_args__ = ( + CheckConstraint("age IS NULL OR (age > 0 AND age <= 150)", name="ck_student_age"), + # 姓名 + 学号的组合索引,覆盖"按姓名模糊查 + 按学号精确查"的常用查询 + Index("ix_student_name_no", "student_name", "student_no"), + ) + + sid = Column(Integer, primary_key=True, autoincrement=True, comment="学生ID(数据库主键)") + student_no = Column(String(50), unique=True, nullable=False, index=True, comment="学号(业务主键,不可重复)") + student_name = Column(String(50), nullable=False, index=True, comment="学生姓名") + native_place = Column(String(200), nullable=True, comment="籍贯") + school = Column(String(100), nullable=True, comment="毕业院校") + major = Column(String(50), nullable=True, comment="专业") + enrollment_time = Column(Date, nullable=True, comment="入学时间") + graduation_time = Column(Date, nullable=True, comment="毕业时间") + education = Column( + SQLEnum(EducationEnum, values_callable=lambda e: [m.value for m in e], name="education_enum"), + nullable=True, + comment="学历", + ) + age = Column(Integer, nullable=True, comment="年龄") + gender = Column( + SQLEnum(GenderEnum, values_callable=lambda e: [m.value for m in e], name="gender_enum"), + nullable=True, + comment="性别", + ) + state = Column( + SQLEnum(StudentStateEnum, values_callable=lambda e: [m.value for m in e], name="student_state_enum"), + nullable=True, + default=StudentStateEnum.STUDYING, + comment="就业状态:在读 / 进入就业 / 已就业", + ) + + class_id = Column(Integer, ForeignKey("classes.cid"), nullable=False, index=True, comment="所属班级") + advisor_id = Column(Integer, ForeignKey("advisor.id"), nullable=False, index=True, comment="负责顾问") + + # ---------------- 关联 ---------------- + classes = relationship("Classes", back_populates="students") + advisor = relationship("Advisor", back_populates="students") + teachers = relationship("Teacher", secondary=teacher_student, back_populates="students") + scores = relationship("Score", back_populates="student", cascade="all, delete-orphan") + # 一个学生一条"当前就业档案"(唯一约束保证)。若要留痕换工作历史, + # 应改成 1:N + is_current,见 docs 报告里的说明。 + employment = relationship("Employment", back_populates="student", uselist=False) diff --git a/student_management_system_complete/student_management_system/model/teacher_model.py b/student_management_system_complete/student_management_system/model/teacher_model.py new file mode 100644 index 0000000..7b672bc --- /dev/null +++ b/student_management_system_complete/student_management_system/model/teacher_model.py @@ -0,0 +1,50 @@ +# model/teacher_model.py +# 老师表 + 两张多对多中间表(老师↔班级、老师↔学生)。 +# +# 改动: +# is_deleted -> flag,与全表统一(1=正常,0=已删除) +# phone 加长度校验约束 +# 补 created_at / updated_at + +from sqlalchemy import CheckConstraint, Column, Date, ForeignKey, Integer, String, Table +from sqlalchemy.orm import relationship + +from database import Base +from model.base import SoftDeleteMixin, TimestampMixin + +# =============== 多对多中间表1:老师 ↔ 班级 =============== +teacher_class = Table( + "teacher_class", + Base.metadata, + Column("tid", Integer, ForeignKey("teacher.tid", ondelete="CASCADE"), primary_key=True), + Column("cid", Integer, ForeignKey("classes.cid", ondelete="CASCADE"), primary_key=True), +) + +# =============== 多对多中间表2:老师 ↔ 学生 =============== +teacher_student = Table( + "teacher_student", + Base.metadata, + Column("tid", Integer, ForeignKey("teacher.tid", ondelete="CASCADE"), primary_key=True), + Column("sid", Integer, ForeignKey("student.sid", ondelete="CASCADE"), primary_key=True), +) + + +# =============== 老师主表 =============== +class Teacher(Base, SoftDeleteMixin, TimestampMixin): + __tablename__ = "teacher" + __table_args__ = ( + # 手机号要么不填,要么是 11 位数字 —— 约束下沉到数据库,别只靠前端。 + # 用 LENGTH 而不是 CHAR_LENGTH:MySQL / SQLite / PostgreSQL 都认, + # 保证本地用 SQLite 跑冒烟测试时建表不会失败。 + CheckConstraint("phone IS NULL OR LENGTH(phone) = 11", name="ck_teacher_phone_len"), + ) + + tid = Column(Integer, primary_key=True, autoincrement=True, comment="老师编号") + t_name = Column(String(20), nullable=False, index=True, comment="老师姓名") + phone = Column(String(11), unique=True, comment="手机号") + subject = Column(String(20), comment="授课科目") + entry_time = Column(Date, comment="入职时间") + + # 多对多:一个老师对应多个班级 / 多个学生 + classes = relationship("Classes", secondary=teacher_class, back_populates="teachers") + students = relationship("Student", secondary=teacher_student, back_populates="teachers") diff --git a/student_management_system_complete/student_management_system/requirements.txt b/student_management_system_complete/student_management_system/requirements.txt new file mode 100644 index 0000000..b69f6c4 --- /dev/null +++ b/student_management_system_complete/student_management_system/requirements.txt @@ -0,0 +1,14 @@ +# 运行环境:Python 3.10+(推荐 3.11 / 3.12) +# 安装:pip install -r requirements.txt +# +# 只锁主版本,避免每次 pip 都因为补丁号变化产生无意义的 diff。 + +fastapi>=0.110,<1.0 +uvicorn[standard]>=0.27,<1.0 +sqlalchemy>=2.0.25,<3.0 +pymysql>=1.1,<2.0 +pydantic>=2.6,<3.0 +python-dotenv>=1.0,<2.0 + +# 仅测试需要(smoke_test.py 的 TestClient 依赖 httpx) +httpx>=0.27,<1.0 diff --git a/student_management_system_complete/student_management_system/run_backend.bat b/student_management_system_complete/student_management_system/run_backend.bat new file mode 100644 index 0000000..924526c --- /dev/null +++ b/student_management_system_complete/student_management_system/run_backend.bat @@ -0,0 +1,48 @@ +@echo off +chcp 65001 >nul +REM =================================================================== +REM Backend launcher - FastAPI on http://127.0.0.1:8004 +REM This ONE process serves everything: +REM / -> admin UI (frontend/index.html) +REM /docs -> Swagger +REM /api/v1/... -> REST API +REM =================================================================== + +cd /d "%~dp0" + +REM Locate the interpreter. Two places are searched, because PyCharm +REM sometimes puts the venv at the project root instead of here. +set "PY=python" +if exist "%~dp0..\.venv\Scripts\python.exe" ( + set "PY=%~dp0..\.venv\Scripts\python.exe" + echo [i] using venv at project root +) +if exist "%~dp0.venv\Scripts\python.exe" ( + set "PY=%~dp0.venv\Scripts\python.exe" + echo [i] using venv inside student_management_system +) +if "%PY%"=="python" ( + echo [!] No .venv found in either location, using "python" from PATH. + echo If ModuleNotFoundError appears, create the venv first: + echo python -m venv .venv + echo .venv\Scripts\pip install -r requirements.txt +) + +if not exist ".env" ( + echo [!] .env not found. Copy .env.example to .env and fill in DB_USER / DB_PASSWORD. + pause + exit /b 1 +) + +echo. +echo Admin UI : http://127.0.0.1:8004/ +echo Swagger : http://127.0.0.1:8004/docs +echo Health : http://127.0.0.1:8004/health +echo Requires MySQL to be running. +echo. + +"%PY%" main.py + +echo. +echo [!] Backend stopped. exit code = %ERRORLEVEL% +pause diff --git a/student_management_system_complete/student_management_system/schema/__init__.py b/student_management_system_complete/student_management_system/schema/__init__.py new file mode 100644 index 0000000..7e37c21 --- /dev/null +++ b/student_management_system_complete/student_management_system/schema/__init__.py @@ -0,0 +1,30 @@ +# schema/__init__.py +# +# 原版这个文件是 student_schema.py 的【整份拷贝】—— 复制粘贴事故。 +# 于是 `from schema import StudentQuery` 和 +# `from schema.student_schema import StudentQuery` +# 拿到的是两个不同的类,Pydantic 校验、isinstance 判断全部失准。 +# +# 现在只做转发,不再定义任何模型。 + +from . import ( + advisor_schema, + class_schema, + common, + employment_schema, + exam_schema, + score_schema, + student_schema, + teacher_schema, +) + +__all__ = [ + "advisor_schema", + "class_schema", + "common", + "employment_schema", + "exam_schema", + "score_schema", + "student_schema", + "teacher_schema", +] diff --git a/student_management_system_complete/student_management_system/schema/advisor_schema.py b/student_management_system_complete/student_management_system/schema/advisor_schema.py new file mode 100644 index 0000000..9366401 --- /dev/null +++ b/student_management_system_complete/student_management_system/schema/advisor_schema.py @@ -0,0 +1,77 @@ +# schema/advisor_schema.py + +# 改动: +# advisor_name 原 max_length=10,和模型 String(20) 不一致 → 统一 20 +# StudentOut 里的 enrollment_time / graduation_time 原来是必填 datetime, +# 学生这两个字段为空时响应校验直接 500。改为 Optional。 +# 手机号校验与 Teacher 保持一致(原来是 \d{11},能通过 "00000000000") + +import re +from datetime import date, datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator + +from model.enums import GenderEnum, StudentStateEnum +from schema.common import PageQuery + +PHONE_RE = re.compile(r"^1[3-9]\d{9}$") + + +class AdvisorQuery(PageQuery): + advisor_name: Optional[str] = Field(None, max_length=20, description="顾问姓名,模糊匹配") + + +class AdvisorCreate(BaseModel): + advisor_name: str = Field(..., min_length=2, max_length=20, description="顾问姓名") + phone: Optional[str] = Field(None, description="联系电话") + gender: Optional[GenderEnum] = Field(None, description="性别") + + @field_validator("phone") + @classmethod + def validate_phone(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + if not PHONE_RE.fullmatch(v): + raise ValueError("手机号格式不正确,应为 11 位大陆手机号") + return v + + +class AdvisorStatus(BaseModel): + """删除 / 恢复共用,flag=1 恢复,flag=0 删除。""" + + flag: int = Field(..., ge=0, le=1, description="1=正常,0=已删除") + + +class AdvisorStudentOut(BaseModel): + """顾问名下学生。注意原版这个类名就叫 StudentOut,和 student_schema.StudentOut 重名, + 两个模块各自 import 时极易拿错,已重命名。""" + + model_config = ConfigDict(from_attributes=True) + + sid: int + student_no: str + student_name: str + class_id: int + class_name: Optional[str] = None + enrollment_time: Optional[date] = None + graduation_time: Optional[date] = None + state: Optional[StudentStateEnum] = None + + +class AdvisorOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + advisor_name: str + flag: int = 1 + phone: Optional[str] = None + gender: Optional[GenderEnum] = None + student_count: int = Field(0, description="名下在册学生数") + created_at: Optional[datetime] = None + + @field_serializer("phone") + def mask_phone(self, value: Optional[str]): + if not value or len(value) != 11: + return value + return value[:3] + "****" + value[7:] diff --git a/student_management_system_complete/student_management_system/schema/class_schema.py b/student_management_system_complete/student_management_system/schema/class_schema.py new file mode 100644 index 0000000..24776aa --- /dev/null +++ b/student_management_system_complete/student_management_system/schema/class_schema.py @@ -0,0 +1,47 @@ +# schema/class_schema.py + +# 改动: +# ClassResp 原来继承 ClassAdd,新增班级时前端必须传班主任/任课老师, +# 但这两个字段在真实业务里是"可后补"的。响应体也不该继承请求体 +# (请求体一加字段,响应体就被动跟着变,很容易把内部字段泄出去)。 + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +from schema.common import PageQuery + + +class ClassQuery(PageQuery): + class_name: Optional[str] = Field(None, max_length=10, description="班级名称,模糊匹配") + head_teacher: Optional[str] = Field(None, max_length=10, description="班主任") + + +class ClassAdd(BaseModel): + class_name: str = Field(..., min_length=1, max_length=10, description="班级名称") + start_time: datetime = Field(..., description="开班时间") + head_teacher: str = Field(..., min_length=1, max_length=10, description="班主任") + teacher: str = Field(..., min_length=1, max_length=15, description="任课老师") + + +class ClassUpdate(BaseModel): + """改班级信息时不必连 class_name 一起传。""" + + class_name: Optional[str] = Field(None, min_length=1, max_length=10) + start_time: Optional[datetime] = None + head_teacher: Optional[str] = Field(None, min_length=1, max_length=10) + teacher: Optional[str] = Field(None, min_length=1, max_length=15) + + +class ClassResp(BaseModel): + model_config = ConfigDict(from_attributes=True) + + cid: int + class_name: str + start_time: datetime + head_teacher: str + teacher: str + flag: int = 1 + student_count: int = Field(0, description="班级在册学生数,联表统计带出") + created_at: Optional[datetime] = None diff --git a/student_management_system_complete/student_management_system/schema/common.py b/student_management_system_complete/student_management_system/schema/common.py new file mode 100644 index 0000000..7c7d610 --- /dev/null +++ b/student_management_system_complete/student_management_system/schema/common.py @@ -0,0 +1,43 @@ +# schema/common.py +# 跨模块复用的通用 Schema:分页入参、分页出参、统一响应。 + +from typing import Generic, List, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + +T = TypeVar("T") + + +class PageQuery(BaseModel): + """分页查询入参基类。 + + 原版每个模块各写各的(student 有 page/size,teacher 是 skip/limit, + employment 干脆不分页),前端要记三套规则。统一成 page/size 一套。 + """ + + page: int = Field(1, ge=1, description="页码,从 1 开始") + size: int = Field(10, ge=1, le=200, description="每页条数,1~200") + + @property + def offset(self) -> int: + return (self.page - 1) * self.size + + +class PageResult(BaseModel, Generic[T]): + """分页返回体:列表 + 总数 + 页码信息,一次给全,前端不用自己算。""" + + items: List[T] = Field(default_factory=list, description="当前页数据") + total: int = Field(0, description="满足条件的总条数") + page: int = Field(1, description="当前页码") + size: int = Field(10, description="每页条数") + pages: int = Field(0, description="总页数") + + +class MessageOut(BaseModel): + """只回一句话的接口(删除、状态变更)用这个,保证返回结构一致。""" + + message: str + affected: int = 0 + + +ORM = ConfigDict(from_attributes=True, populate_by_name=True) diff --git a/student_management_system_complete/student_management_system/schema/employment_schema.py b/student_management_system_complete/student_management_system/schema/employment_schema.py new file mode 100644 index 0000000..d281904 --- /dev/null +++ b/student_management_system_complete/student_management_system/schema/employment_schema.py @@ -0,0 +1,61 @@ +# schema/employment_schema.py + +# 改动: +# 补字段长度约束(原来 company_name 无长度限制,能塞 10 万字进去) +# 加跨字段校验:入职时间不能早于 offer 时间 +# salary 加上限,避免 1e308 这种脏数据进库 +# 响应体补 student_name / class_name / is_current + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from schema.common import PageQuery + + +class EmploymentQuery(PageQuery): + student_id: Optional[int] = Field(None, ge=1) + student_name: Optional[str] = Field(None, max_length=50, description="学生姓名,模糊匹配") + company_name: Optional[str] = Field(None, max_length=100, description="公司名称,模糊匹配") + min_salary: Optional[float] = Field(None, ge=0) + max_salary: Optional[float] = Field(None, ge=0) + is_current: Optional[bool] = Field(None, description="是否只看当前在职记录") + + +class EmploymentBase(BaseModel): + offer_time: Optional[datetime] = Field(None, description="Offer 下发时间") + employment_start_time: Optional[datetime] = Field(None, description="实际入职时间") + company_name: Optional[str] = Field(None, max_length=100, description="公司名称") + salary: Optional[float] = Field(None, ge=0, le=10_000_000, description="月薪(元)") + + @model_validator(mode="after") + def check_time_order(self): + if self.offer_time and self.employment_start_time: + if self.employment_start_time < self.offer_time: + raise ValueError("入职时间不能早于 Offer 下发时间") + return self + + +class EmploymentCreate(EmploymentBase): + student_id: int = Field(..., ge=1, description="学生ID") + + +class EmploymentUpdate(EmploymentBase): + pass + + +class EmploymentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + student_id: int + student_name: Optional[str] = None + class_name: Optional[str] = None + offer_time: Optional[datetime] = None + employment_start_time: Optional[datetime] = None + company_name: Optional[str] = None + salary: Optional[float] = None + is_current: bool = True + flag: int = 1 + created_at: Optional[datetime] = None diff --git a/student_management_system_complete/student_management_system/schema/exam_schema.py b/student_management_system_complete/student_management_system/schema/exam_schema.py new file mode 100644 index 0000000..8f4c636 --- /dev/null +++ b/student_management_system_complete/student_management_system/schema/exam_schema.py @@ -0,0 +1,29 @@ +# schema/exam_schema.py + +from datetime import date, datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ExamCreate(BaseModel): + exam_name: str = Field(..., min_length=1, max_length=50, description="考试名称") + exam_order: int = Field(..., ge=1, le=99, description="考试次序") + exam_date: Optional[date] = Field(None, description="考试日期") + + +class ExamUpdate(BaseModel): + exam_name: Optional[str] = Field(None, min_length=1, max_length=50) + exam_order: Optional[int] = Field(None, ge=1, le=99) + exam_date: Optional[date] = None + + +class ExamOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + exam_name: str + exam_order: int + exam_date: Optional[date] = None + flag: int = 1 + created_at: Optional[datetime] = None diff --git a/student_management_system_complete/student_management_system/schema/score_schema.py b/student_management_system_complete/student_management_system/schema/score_schema.py new file mode 100644 index 0000000..5460576 --- /dev/null +++ b/student_management_system_complete/student_management_system/schema/score_schema.py @@ -0,0 +1,48 @@ +# schema/score_schema.py + +# 改动: +# exam_id 不再限制 le=3,改为校验 exam 表里是否存在(在 DAO 层做) +# score 改为 float(模型是 Numeric(5,2)) +# 补 class_name / student_name / exam_name,前端列表直接可用 +# ScoreUpdate 原来要求 student_id + exam_id 必填,等于"改成绩必须重传定位条件", +# 改为按成绩记录主键 id 修改 + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +from schema.common import PageQuery + + +class ScoreQuery(PageQuery): + student_id: Optional[int] = Field(None, ge=1, description="学生ID") + exam_id: Optional[int] = Field(None, ge=1, description="考试ID") + class_id: Optional[int] = Field(None, ge=1, description="按班级筛学生成绩") + student_name: Optional[str] = Field(None, max_length=50, description="学生姓名,模糊匹配") + min_score: Optional[float] = Field(None, ge=0, le=100) + max_score: Optional[float] = Field(None, ge=0, le=100) + + +class ScoreCreate(BaseModel): + student_id: int = Field(..., ge=1, description="学生ID") + exam_id: int = Field(..., ge=1, description="考试ID") + score: float = Field(..., ge=0, le=100, description="成绩,0~100") + + +class ScoreUpdate(BaseModel): + score: float = Field(..., ge=0, le=100, description="成绩,0~100") + + +class ScoreResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + student_id: int + student_name: Optional[str] = None + class_name: Optional[str] = None + exam_id: int + exam_name: Optional[str] = None + score: float + flag: int = 1 + created_at: Optional[datetime] = None diff --git a/student_management_system_complete/student_management_system/schema/student_schema.py b/student_management_system_complete/student_management_system/schema/student_schema.py new file mode 100644 index 0000000..f6b4b36 --- /dev/null +++ b/student_management_system_complete/student_management_system/schema/student_schema.py @@ -0,0 +1,204 @@ +# schema/student_schema.py +# 学生模块的入参 / 出参模型。 +# +# 关键修复: +# 1. 原版 StudentCreate 要求前端传 flag(逻辑删除标记)—— 这是后端字段, +# 让前端传等于把"删不删"的决定权交给了浏览器。已移除,由后端固定为 1。 +# 2. 原版 StudentUpdate 的 student_name / class_id / flag / advisor_id 是必填, +# PATCH 语义下改个手机号也得把全班信息带上。已改为全可选,只改传了的字段。 +# 3. 原版用 @validator(Pydantic v1 写法),而 advisor/score/employment 用 +# @field_validator(v2),两套校验行为不一致。现在全部统一 v2。 + +from datetime import date, datetime +from typing import List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from model.enums import EducationEnum, GenderEnum, StudentStateEnum +from schema.common import PageQuery + + +# ============================ 入参 ============================ +class StudentQuery(PageQuery): + """GET 查询参数。用 Depends() 注入,FastAPI 会自动拆成 query string。""" + + student_no: Optional[str] = Field(None, max_length=50, description="学号,精确匹配") + student_name: Optional[str] = Field(None, max_length=50, description="姓名,模糊匹配") + class_id: Optional[int] = Field(None, ge=1, description="班级ID") + advisor_id: Optional[int] = Field(None, ge=1, description="顾问ID") + state: Optional[StudentStateEnum] = Field(None, description="就业状态") + gender: Optional[GenderEnum] = Field(None, description="性别") + + +class StudentBase(BaseModel): + student_name: str = Field(..., min_length=1, max_length=50, description="学生姓名") + class_id: int = Field(..., ge=1, description="班级ID") + advisor_id: int = Field(..., ge=1, description="顾问ID") + native_place: Optional[str] = Field(None, max_length=200, description="籍贯") + school: Optional[str] = Field(None, max_length=100, description="毕业院校") + major: Optional[str] = Field(None, max_length=50, description="专业") + enrollment_time: Optional[date] = Field(None, description="入学时间") + graduation_time: Optional[date] = Field(None, description="毕业时间") + education: Optional[EducationEnum] = Field(None, description="学历") + age: Optional[int] = Field(None, ge=1, le=150, description="年龄") + gender: Optional[GenderEnum] = Field(None, description="性别") + state: Optional[StudentStateEnum] = Field(StudentStateEnum.STUDYING, description="就业状态") + + @model_validator(mode="after") + def check_time_order(self): + """毕业时间不能早于入学时间 —— 这类跨字段规则必须放在模型里, + 散在各个 API 里迟早漏掉。""" + if self.enrollment_time and self.graduation_time: + if self.graduation_time <= self.enrollment_time: + raise ValueError("毕业时间必须晚于入学时间") + return self + + +class StudentCreate(StudentBase): + student_no: Optional[str] = Field( + None, + max_length=50, + description="学号。不传则按「年级+班级序号+流水号」规则自动生成", + ) + + +class StudentUpdate(BaseModel): + """全部可选,只更新传了的字段(PATCH 语义)。""" + + student_no: Optional[str] = Field(None, min_length=1, max_length=50) + student_name: Optional[str] = Field(None, min_length=1, max_length=50) + class_id: Optional[int] = Field(None, ge=1) + advisor_id: Optional[int] = Field(None, ge=1) + native_place: Optional[str] = Field(None, max_length=200) + school: Optional[str] = Field(None, max_length=100) + major: Optional[str] = Field(None, max_length=50) + enrollment_time: Optional[date] = None + graduation_time: Optional[date] = None + education: Optional[EducationEnum] = None + age: Optional[int] = Field(None, ge=1, le=150) + gender: Optional[GenderEnum] = None + state: Optional[StudentStateEnum] = None + + +# ============================ 出参 ============================ +class StudentOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + sid: int + student_no: str + student_name: str + class_id: int + class_name: Optional[str] = None # 联表带出,前端不用再查一次班级 + advisor_id: int + advisor_name: Optional[str] = None # 同上 + native_place: Optional[str] = None + school: Optional[str] = None + major: Optional[str] = None + enrollment_time: Optional[date] = None + graduation_time: Optional[date] = None + education: Optional[EducationEnum] = None + age: Optional[int] = None + gender: Optional[GenderEnum] = None + state: Optional[StudentStateEnum] = None + flag: int = 1 + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +# ============================ 统计分析 ============================ +class AgeQuery(BaseModel): + """按年龄做区间比较。v2 里用 model_validator,原版的 @validator(always=True) 在 v2 下会直接崩。""" + + operator: Literal["gt", "lt", "eq", "ge", "le", "between"] + value: Optional[int] = Field(None, ge=1, le=150) + min_value: Optional[int] = Field(None, ge=1, le=150) + max_value: Optional[int] = Field(None, ge=1, le=150) + + @model_validator(mode="after") + def check(self): + if self.operator == "between": + if self.min_value is None or self.max_value is None: + raise ValueError("between 必须同时传 min_value 和 max_value") + if self.min_value > self.max_value: + raise ValueError("min_value 不能大于 max_value") + elif self.value is None: + raise ValueError(f"operator={self.operator} 时 value 必填") + return self + + +class TopNSalaryQuery(BaseModel): + n: int = Field(10, ge=1, le=100, description="取前 N 名") + + +class ClassGenderStat(BaseModel): + class_name: str + class_total: int + boys_count: int + girls_count: int + + +class ScoreAboveItem(BaseModel): + """每次考试都在分数线之上的学生。""" + + stu_id: int + stu_name: str + class_name: Optional[str] = None + exam_names: List[str] = Field(default_factory=list) + stu_scores: List[float] = Field(default_factory=list) + min_score: float = 0 + + +class FailMoreItem(BaseModel): + stu_id: int + stu_name: str + class_name: Optional[str] = None + fail_scores: List[float] = Field(default_factory=list) + fail_times: int = 0 + + +class ClassAvgScoreItem(BaseModel): + """班级平均分。原版这个接口的 SQL 是错的(详见报告 P0-3)。""" + + class_id: int + class_name: str + student_count: int = 0 + avg_score: float = 0.0 + max_score: float = 0.0 + min_score: float = 0.0 + + +class WorkDurationItem(BaseModel): + """从拿到 offer 到实际入职的间隔天数(原版叫 work_time,语义反了)。""" + + stu_id: int + stu_name: str + class_name: Optional[str] = None + offer_to_entry_days: int + + +class ClassAvgWorkDurationItem(BaseModel): + class_id: int + class_name: str + student_count: int + avg_offer_to_entry_days: float + + +class TopSalaryItem(BaseModel): + student_name: str + class_name: Optional[str] = None + company_name: Optional[str] = None + salary: float = 0.0 + offer_time: Optional[datetime] = None + + +class DashboardStat(BaseModel): + """数据看板一次性把关键指标拿回去,前端不用打 6 个请求。""" + + student_total: int = 0 + class_total: int = 0 + teacher_total: int = 0 + advisor_total: int = 0 + employed_total: int = 0 + employment_rate: float = 0.0 + avg_salary: float = 0.0 + state_distribution: dict = Field(default_factory=dict) diff --git a/student_management_system_complete/student_management_system/schema/teacher_schema.py b/student_management_system_complete/student_management_system/schema/teacher_schema.py new file mode 100644 index 0000000..4951de1 --- /dev/null +++ b/student_management_system_complete/student_management_system/schema/teacher_schema.py @@ -0,0 +1,73 @@ +# schema/teacher_schema.py + +# 改动: +# phone 补 min_length(原来只限制 max_length=11,"1" 也能存进去) +# 加手机号正则校验 +# TeacherOut 补 entry_time / flag / created_at(原来前端拿不到入职时间) +# 手机号脱敏策略与 Advisor 保持一致 + +import re +from datetime import date, datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator + +from schema.common import PageQuery + +PHONE_RE = re.compile(r"^1[3-9]\d{9}$") + + +class TeacherQuery(PageQuery): + keyword: Optional[str] = Field(None, max_length=20, description="姓名/科目关键字") + subject: Optional[str] = Field(None, max_length=20, description="授课科目") + + +class TeacherCreate(BaseModel): + t_name: str = Field(..., min_length=1, max_length=20, description="老师姓名") + phone: str = Field(..., description="手机号,11位") + # 原版默认值是"班主任",但字段名叫 subject(授课科目), + # 把"班主任"当成科目存进去是数据语义错误。默认值改掉。 + subject: Optional[str] = Field(None, max_length=20, description="授课科目") + entry_time: Optional[date] = Field(None, description="入职时间") + + @field_validator("phone") + @classmethod + def validate_phone(cls, v: str) -> str: + if not PHONE_RE.fullmatch(v): + raise ValueError("手机号格式不正确,应为 11 位大陆手机号") + return v + + +class TeacherUpdate(BaseModel): + t_name: Optional[str] = Field(None, min_length=1, max_length=20) + phone: Optional[str] = None + subject: Optional[str] = Field(None, max_length=20) + entry_time: Optional[date] = None + + @field_validator("phone") + @classmethod + def validate_phone(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + if not PHONE_RE.fullmatch(v): + raise ValueError("手机号格式不正确,应为 11 位大陆手机号") + return v + + +class TeacherOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + tid: int + t_name: str + phone: Optional[str] = None + subject: Optional[str] = None + entry_time: Optional[date] = None + flag: int = 1 + created_at: Optional[datetime] = None + + @field_serializer("phone") + def mask_phone(self, value: Optional[str]): + """列表接口对外脱敏,138****8001。""" + if not value or len(value) != 11: + return value + return value[:3] + "****" + value[7:] diff --git a/student_management_system_complete/student_management_system/seed_data.py b/student_management_system_complete/student_management_system/seed_data.py new file mode 100644 index 0000000..5419a69 --- /dev/null +++ b/student_management_system_complete/student_management_system/seed_data.py @@ -0,0 +1,227 @@ +# seed_data.py +# 演示数据。 +# +# 原版的三个问题: +# 1. 字段名对不上模型:写的是 native_places / status,模型里是 native_place / state。 +# 用 bulk_insert_mappings 时这些键会被忽略(或直接抛错), +# 结果是籍贯和就业状态静默丢失,而且不报任何错 —— 最难查的那种 bug。 +# 2. db = SessionLocal() 定义在模块级,而每个 seed_xxx() 结尾都 db.close()。 +# 第二个函数拿到的是一个已经关掉的会话;更糟的是 import seed_data 的瞬间 +# 就会建数据库连接,MySQL 没起就直接 ImportError。 +# 3. advisor_id 全部硬编码成 1,一旦 advisor 表不是从 id=1 开始, +# 外键就会失败或者把学生挂到错误的顾问名下。 + +import random +from datetime import date, datetime, timedelta + +from database import SessionLocal +from model import Advisor, Classes, Employment, Exam, Score, Student, Teacher +from model.enums import EducationEnum, GenderEnum, StudentStateEnum + +RNG = random.Random(20260916) # 固定种子,保证每次生成的数据一致,便于对比测试 + +SURNAMES = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜" +GIVEN_1 = "伟芳娜秀英敏静丽强磊洋艳勇军杰娟涛明超秀霞平刚桂英" +GIVEN_2 = "轩宇涵怡然睿彤佳琪浩宁思博雅欣悦文泽晨阳雪松林海峰" + +CITIES = ["广东深圳", "广东广州", "广东东莞", "广东佛山", "湖南长沙", "湖北武汉", + "四川成都", "江苏南京", "浙江杭州", "江西南昌", "广西南宁", "福建厦门"] +SCHOOLS = ["深圳大学", "华南理工大学", "广东工业大学", "华南师范大学", "广州大学", + "南方科技大学", "暨南大学", "广东财经大学"] +MAJORS = ["计算机科学与技术", "软件工程", "数据科学与大数据技术", "人工智能", + "网络工程", "信息管理与信息系统"] +EDUCATIONS = [EducationEnum.BACHELOR, EducationEnum.BACHELOR, EducationEnum.BACHELOR, + EducationEnum.JUNIOR_COLLEGE, EducationEnum.MASTER] +COMPANIES = ["腾讯", "字节跳动", "阿里巴巴", "美团", "百度", "华为", "网易", + "京东", "小米", "大疆创新", "Shopee", "OPPO"] + +CLASS_DEFS = [ + ("计科2401", "王建国", "李国强"), + ("计科2402", "王建国", "张明远"), + ("软工2401", "刘振华", "陈立群"), + ("软工2402", "刘振华", "黄志远"), + ("大数据2401", "赵慧敏", "周文彬"), + ("人工智能2401", "赵慧敏", "孙晓峰"), +] + +TEACHER_DEFS = [ + ("李国强", "13800138001", "Python程序设计", date(2020, 9, 1)), + ("张明远", "13800138002", "高等数学", date(2021, 2, 10)), + ("陈立群", "13800138003", "数据库原理", date(2022, 3, 5)), + ("黄志远", "13800138004", "计算机网络", date(2021, 9, 1)), + ("周文彬", "13800138005", "操作系统", date(2023, 1, 15)), + ("孙晓峰", "13800138006", "机器学习", date(2022, 7, 1)), + ("吴清源", "13800138007", "软件工程导论", date(2020, 3, 1)), + ("郑思远", "13800138008", "数据结构", date(2019, 9, 1)), +] + +EXAM_DEFS = [ + ("第一次月考", 1, date(2026, 3, 15)), + ("期中考试", 2, date(2026, 4, 28)), + ("期末考试", 3, date(2026, 6, 25)), +] + +ADVISOR_NAMES = [ + "李美玲", "王浩宇", "陈雅婷", "刘志强", "赵晓雯", "黄俊凯", "周雨欣", "吴海涛", + "徐思琪", "马博文", "朱嘉宁", "胡丽娟", +] + + +def _rand_name() -> str: + return RNG.choice(SURNAMES) + RNG.choice(GIVEN_1) + (RNG.choice(GIVEN_2) if RNG.random() < 0.6 else "") + + +def seed_all(reset_sequences: bool = False) -> None: + """灌入全部演示数据。 + + 整个流程共用一个会话,最后统一 commit:中途失败就整体回滚, + 不会留下"班级建好了但学生只灌了一半"的脏库。 + """ + db = SessionLocal() + try: + if db.query(Student).count() > 0: + print("[skip] 学生表已有数据,跳过灌数据(如需重建请执行 python init_db.py --reset)") + return + + # ---------- 1. 考试 ---------- + exams = [ + Exam(exam_name=name, exam_order=order, exam_date=exam_date, flag=1) + for name, order, exam_date in EXAM_DEFS + ] + db.add_all(exams) + db.flush() + print(f"[ok] 考试 {len(exams)} 条") + + # ---------- 2. 班级 ---------- + classes = [ + Classes( + class_name=name, + start_time=datetime(2024, 9, 1, 8, 0), + head_teacher=head, + teacher=teacher, + flag=1, + ) + for name, head, teacher in CLASS_DEFS + ] + db.add_all(classes) + db.flush() + print(f"[ok] 班级 {len(classes)} 条") + + # ---------- 3. 老师 ---------- + teachers = [ + Teacher(t_name=name, phone=phone, subject=subject, entry_time=entry, flag=1) + for name, phone, subject, entry in TEACHER_DEFS + ] + db.add_all(teachers) + db.flush() + print(f"[ok] 老师 {len(teachers)} 条") + + # ---------- 4. 顾问 ---------- + advisors = [ + Advisor( + advisor_name=name, + phone=f"139{RNG.randint(10_000_000, 99_999_999)}", + gender=GenderEnum.MALE if i % 3 == 0 else GenderEnum.FEMALE, + flag=1, + ) + for i, name in enumerate(ADVISOR_NAMES) + ] + db.add_all(advisors) + db.flush() + print(f"[ok] 顾问 {len(advisors)} 条") + + # ---------- 5. 老师 ↔ 班级 关联 ---------- + for cls in classes: + cls.teachers.append(teachers[RNG.randrange(len(teachers))]) + db.flush() + + # ---------- 6. 学生 ---------- + students: list[Student] = [] + seq = 0 + for cls in classes: + for _ in range(RNG.randint(18, 24)): + seq += 1 + year = RNG.choice([2023, 2024, 2024, 2024]) + students.append( + Student( + # 学号规则与 services/student_no.py 保持一致 + student_no=f"S{year}{seq:04d}", + student_name=_rand_name(), + native_place=RNG.choice(CITIES), + school=RNG.choice(SCHOOLS), + major=RNG.choice(MAJORS), + enrollment_time=date(year, 9, 1), + graduation_time=date(year + 4, 6, 30), + education=RNG.choice(EDUCATIONS), + age=RNG.randint(18, 23), + gender=GenderEnum.MALE if RNG.random() < 0.58 else GenderEnum.FEMALE, + state=StudentStateEnum.STUDYING, + class_id=cls.cid, + advisor_id=advisors[RNG.randrange(len(advisors))].id, + flag=1, + ) + ) + db.add_all(students) + db.flush() + print(f"[ok] 学生 {len(students)} 条") + + # ---------- 7. 成绩 ---------- + scores: list[Score] = [] + for stu in students: + # 高分学生成绩更集中,低分学生会出现不及格,让统计报表有区分度 + student_ability = RNG.gauss(72, 12) + for exam in exams: + if RNG.random() < 0.06: # 约 6% 的学生缺考某一次 + continue + value = max(0.0, min(100.0, RNG.gauss(student_ability, 6))) + scores.append( + Score( + student_id=stu.sid, + exam_id=exam.id, + score=round(value, 1), + flag=1, + ) + ) + db.add_all(scores) + db.flush() + print(f"[ok] 成绩 {len(scores)} 条") + + # ---------- 8. 就业 ---------- + # 只让一部分学生有就业记录,就业率才有意义 + employed = RNG.sample(students, k=int(len(students) * 0.62)) + employments: list[Employment] = [] + for stu in employed: + offer = datetime(2026, 6, 1) + timedelta(days=RNG.randint(0, 90)) + # 少数人拿了 offer 但还没入职,用来验证"入职时间可为空"的统计分支 + start = offer + timedelta(days=RNG.randint(3, 75)) if RNG.random() < 0.88 else None + salary = round(RNG.gauss(13000, 4200), 0) + employments.append( + Employment( + student_id=stu.sid, + offer_time=offer, + employment_start_time=start, + company_name=RNG.choice(COMPANIES), + salary=max(5000.0, min(45000.0, salary)), + is_current=True, + flag=1, + ) + ) + stu.state = ( + StudentStateEnum.EMPLOYED if start else StudentStateEnum.ENTERING_EMPLOYMENT + ) + db.add_all(employments) + db.flush() + print(f"[ok] 就业 {len(employments)} 条") + + db.commit() + print("\n[ok] 演示数据全部写入完成") + + except Exception: + db.rollback() + raise + finally: + db.close() + + +if __name__ == "__main__": + seed_all() diff --git a/student_management_system_complete/student_management_system/services/__init__.py b/student_management_system_complete/student_management_system/services/__init__.py new file mode 100644 index 0000000..6180826 --- /dev/null +++ b/student_management_system_complete/student_management_system/services/__init__.py @@ -0,0 +1 @@ +# services/__init__.py diff --git a/student_management_system_complete/student_management_system/services/student_no.py b/student_management_system_complete/student_management_system/services/student_no.py new file mode 100644 index 0000000..fa5e426 --- /dev/null +++ b/student_management_system_complete/student_management_system/services/student_no.py @@ -0,0 +1,50 @@ +# services/student_no.py +# 学号生成规则。 +# +# 原版需求里写了"可设计学号生成规则,而非单一自增",但实现完全是前端自由填, +# 于是 student_no 里既有 20240001 也有 S2023011,排序、按年级统计全部失效。 +# +# 规则:S + 入学年份(4位) + 4位流水号,如 S20260001。 +# 好处:① 一眼看出年级;② 定长定前缀,便于索引与校验;③ 不暴露数据库主键。 + +from datetime import date +from typing import Optional + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from model import Student + +STUDENT_NO_PREFIX = "S" +SEQUENCE_WIDTH = 4 +STUDENT_NO_RE = r"^S\d{8}$" + + +def build_student_no(year: int, sequence: int) -> str: + return f"{STUDENT_NO_PREFIX}{year}{sequence:0{SEQUENCE_WIDTH}d}" + + +def next_student_no(db: Session, year: Optional[int] = None) -> str: + """生成下一个学号。 + + 用「当年最大流水号 + 1」而不是 count() + 1: + 中间删过学生时 count 会小于实际最大值,必然撞号。 + """ + year = year or date.today().year + prefix = f"{STUDENT_NO_PREFIX}{year}" + + max_no = ( + db.query(func.max(Student.student_no)) + .filter(Student.student_no.like(f"{prefix}%")) + .scalar() + ) + + if max_no: + try: + last_seq = int(max_no[len(prefix):]) + except (ValueError, TypeError): + last_seq = 0 + else: + last_seq = 0 + + return build_student_no(year, last_seq + 1) diff --git a/student_management_system_complete/student_management_system/settings.py b/student_management_system_complete/student_management_system/settings.py new file mode 100644 index 0000000..65117bf --- /dev/null +++ b/student_management_system_complete/student_management_system/settings.py @@ -0,0 +1,108 @@ +# settings.py +# 统一配置入口:所有可变参数都从环境变量 / .env 读取,代码里不再出现硬编码密码。 +# +# 真实业务里"配置"和"代码"必须分离: +# 开发 / 测试 / 生产 用的是三套数据库,靠改代码切环境一定会出事故。 + +import os +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parent + + +def _load_dotenv(path: Path) -> None: + """极简 .env 加载器。 + + 优先使用 python-dotenv;没装则用内置的简易解析,保证零依赖也能跑。 + """ + if not path.exists(): + return + try: + from dotenv import load_dotenv # type: ignore + + load_dotenv(path, override=False) + return + except ImportError: + pass + + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key, value = key.strip(), value.strip().strip('"').strip("'") + # 已存在的真实环境变量优先,不被文件覆盖 + os.environ.setdefault(key, value) + + +_load_dotenv(BASE_DIR / ".env") + + +def _env(key: str, default: str) -> str: + value = os.getenv(key) + return default if value is None or value == "" else value + + +def _env_bool(key: str, default: bool) -> bool: + return _env(key, str(default)).strip().lower() in ("1", "true", "yes", "on") + + +def _env_int(key: str, default: int) -> int: + try: + return int(_env(key, str(default))) + except ValueError: + return default + + +class Settings: + # ---------- 应用 ---------- + APP_NAME: str = _env("APP_NAME", "学生管理系统") + APP_VERSION: str = _env("APP_VERSION", "1.1.0") + DEBUG: bool = _env_bool("DEBUG", True) + + HOST: str = _env("HOST", "127.0.0.1") + PORT: int = _env_int("PORT", 8004) + + # API 统一前缀。改这里就能整体换版本,不用一个个改路由。 + API_PREFIX: str = _env("API_PREFIX", "/api/v1") + + # ---------- 跨域 ---------- + # 注意:allow_credentials=True 时浏览器【禁止】返回 "*", + # 必须显式列出前端域名,否则带 Cookie / 登录态一定失败。 + CORS_ORIGINS: list[str] = [ + o.strip() + for o in _env( + "CORS_ORIGINS", + "http://localhost:5500,http://127.0.0.1:5500," + "http://localhost:8000,http://127.0.0.1:8000," + "http://localhost:5173,http://127.0.0.1:5173", + ).split(",") + if o.strip() + ] + + # ---------- 数据库 ---------- + DB_HOST: str = _env("DB_HOST", "127.0.0.1") + DB_PORT: int = _env_int("DB_PORT", 3306) + DB_USER: str = _env("DB_USER", "root") + DB_PASSWORD: str = _env("DB_PASSWORD", "") + DB_NAME: str = _env("DB_NAME", "student_management_system") + DB_CHARSET: str = _env("DB_CHARSET", "utf8mb4") + + DB_ECHO: bool = _env_bool("DB_ECHO", False) + DB_POOL_SIZE: int = _env_int("DB_POOL_SIZE", 5) + DB_MAX_OVERFLOW: int = _env_int("DB_MAX_OVERFLOW", 10) + DB_POOL_RECYCLE: int = _env_int("DB_POOL_RECYCLE", 3600) + + @property + def database_url(self) -> str: + from urllib.parse import quote_plus + + return ( + f"mysql+pymysql://{self.DB_USER}:{quote_plus(self.DB_PASSWORD)}" + f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + f"?charset={self.DB_CHARSET}" + ) + + +settings = Settings() diff --git a/student_management_system_complete/student_management_system/smoke_test.py b/student_management_system_complete/student_management_system/smoke_test.py new file mode 100644 index 0000000..7632503 --- /dev/null +++ b/student_management_system_complete/student_management_system/smoke_test.py @@ -0,0 +1,467 @@ +# smoke_test.py +# 冒烟测试:把整个应用跑一遍,验证建表、种子数据、CRUD、统计 SQL、参数校验、异常处理全链路。 +# +# 为什么要有这个文件: +# 原项目没有一行测试,改一个字段名就得手动把 6 个模块点一遍, +# 而且"字段名写错但不报错"这类问题(native_places vs native_place)根本发现不了。 +# +# 两种运行模式: +# python smoke_test.py → SQLite 内存库(默认,秒级,不需要装 MySQL) +# set SMS_TEST_DB=mysql && python smoke_test.py → 真实 MySQL(建表→灌数据→全链路,会清空该库) +# +# 两种都跑的意义:SQLite 保证逻辑正确、跑得快;MySQL 保证方言、字符集、 +# ENUM、CHECK 约束、外键这些"只有真库才暴露的问题"也是对的。 + +import os +import sys +import traceback + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# 必须在 import settings 之前设置,否则日志会被 DEBUG 级别刷屏 +os.environ.setdefault("DEBUG", "false") + +USE_MYSQL = os.getenv("SMS_TEST_DB", "sqlite").strip().lower() == "mysql" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +# ---- 1. 选择后端:MySQL 直接用 .env 里的配置;SQLite 则在导入业务代码前替换引擎 ---- +import database + +if USE_MYSQL: + from settings import settings + + test_engine = database.engine + TestSession = database.SessionLocal + print(f"[模式] 真实 MySQL -> {settings.DB_USER}@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}") +else: + test_engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + TestSession = sessionmaker( + autocommit=False, autoflush=False, expire_on_commit=False, bind=test_engine + ) + database.engine = test_engine + database.SessionLocal = TestSession + print("[模式] SQLite 内存库") + +# 现在再导入依赖 database 的模块,它们拿到的就是选定的引擎 +import seed_data # noqa: E402 + +seed_data.SessionLocal = TestSession + +from database import Base, get_db # noqa: E402 +from main import app # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +PASSED, FAILED = [], [] + + +def check(name: str, condition: bool, extra: str = "") -> None: + if condition: + PASSED.append(name) + print(f" [PASS] {name}") + else: + FAILED.append(name) + print(f" [FAIL] {name} {extra}") + + +def main() -> int: + print("=" * 72) + print("1) 建表") + print("=" * 72) + if USE_MYSQL: + # MySQL 上先清干净,保证每次跑的结果可复现 + Base.metadata.drop_all(bind=test_engine) + Base.metadata.create_all(bind=test_engine) + tables = sorted(Base.metadata.tables) + print(f" 共 {len(tables)} 张表:{', '.join(tables)}") + check("建表成功", len(tables) >= 8) + + print() + print("=" * 72) + print("2) 灌入种子数据(验证字段名与模型一致)") + print("=" * 72) + seed_data.seed_all() + + db = TestSession() + from model import Advisor, Classes, Employment, Exam, Score, Student, Teacher + + counts = { + "exam": db.query(Exam).count(), + "classes": db.query(Classes).count(), + "teacher": db.query(Teacher).count(), + "advisor": db.query(Advisor).count(), + "student": db.query(Student).count(), + "score": db.query(Score).count(), + "employment": db.query(Employment).count(), + } + print(f" {counts}") + check("数据全部写入", all(v > 0 for v in counts.values())) + + # 原版 bug:native_places / status 字段名写错,数据会静默丢失 + missed_place = db.query(Student).filter(Student.native_place.is_(None)).count() + missed_state = db.query(Student).filter(Student.state.is_(None)).count() + check("籍贯字段正确落库(原版 native_places 拼写错误)", missed_place == 0, f"缺失 {missed_place} 条") + check("就业状态字段正确落库(原版 status 拼写错误)", missed_state == 0, f"缺失 {missed_state} 条") + + if USE_MYSQL: + # 只有真库才需要验证字符集:utf8mb4 才能存下 emoji 这类 4 字节字符, + # 用 utf8(3 字节)的话这里会直接报 Incorrect string value + from datetime import date + + from model.enums import GenderEnum, StudentStateEnum + + probe = Student( + student_no="__CHARSET_PROBE__", + student_name="张🙂明·测试", + native_place="内蒙古自治区·锡林郭勒盟", + class_id=1, + advisor_id=1, + gender=GenderEnum.MALE, + state=StudentStateEnum.STUDYING, + enrollment_time=date(2024, 9, 1), + flag=1, + ) + db.add(probe) + db.commit() + db.refresh(probe) + back = db.query(Student).filter(Student.student_no == "__CHARSET_PROBE__").first() + check( + "MySQL 字符集为 utf8mb4(emoji / 少数民族地名可正常读写)", + back is not None and back.student_name == "张🙂明·测试", + f"读回:{back.student_name if back else None!r}", + ) + check("MySQL 枚举列读写正常", back is not None and back.gender == GenderEnum.MALE) + check("MySQL 日期列读写正常", back is not None and back.enrollment_time == date(2024, 9, 1)) + db.delete(back) + db.commit() + + # CHECK 约束必须真的被数据库执行,不能只写在模型里 + import sqlalchemy + from sqlalchemy.exc import IntegrityError, OperationalError + + try: + bad_teacher = Teacher(t_name="约束探针", phone="123", flag=1) + db.add(bad_teacher) + db.commit() + check("CHECK 约束被数据库真正执行(11 位手机号)", False, "非法手机号竟然写进去了") + except (IntegrityError, OperationalError) as exc: + db.rollback() + # MySQL 8 对 CHECK 违反抛 OperationalError(3819),SQLite 抛 IntegrityError + code = getattr(getattr(exc, "orig", None), "args", [None])[0] + check( + "CHECK 约束被数据库真正执行(11 位手机号)", + code in (3819, None), + f"错误码 {code}", + ) + + # 关键回归:MySQL 的 CHECK 违反抛 OperationalError 而不是 IntegrityError, + # 如果异常翻译只认 IntegrityError,用户填错手机号会收到 500 而不是 4xx。 + from exceptions import classify_sqlalchemy_error + + class _FakeOrig(Exception): + pass + + fake = OperationalError("INSERT ...", {}, _FakeOrig(3819, "Check constraint 'ck_teacher_phone_len' is violated.")) + biz_code, msg, http_status = classify_sqlalchemy_error(fake) + check( + "CHECK 约束违反被翻译成 422 而不是 500", + http_status == 422 and biz_code == 42201, + f"得到 {http_status}/{biz_code} {msg}", + ) + + dup = IntegrityError("INSERT ...", {}, _FakeOrig(1062, "Duplicate entry")) + biz_code, msg, http_status = classify_sqlalchemy_error(dup) + check("唯一键冲突被翻译成 409", http_status == 409 and biz_code == 40900, f"得到 {http_status}/{biz_code}") + + conn_down = OperationalError("SELECT 1", {}, _FakeOrig(2003, "Can't connect to MySQL server")) + biz_code, msg, http_status = classify_sqlalchemy_error(conn_down) + check("真的连不上数据库才返回 500", http_status == 500, f"得到 {http_status}/{biz_code}") + + db.close() + + def override_get_db(): + session = TestSession() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db] = override_get_db + client = TestClient(app, raise_server_exceptions=False) + + print() + print("=" * 72) + print("3) 基础端点") + print("=" * 72) + r = client.get("/") + ctype = r.headers.get("content-type", "") + check("GET / 可用(前端页面 或 服务信息)", r.status_code == 200, f"{r.status_code} {ctype}") + + mounted = "text/html" in ctype + if mounted: + check("后端已托管前端页面(只要跑 main.py 就能看到界面)", "学生管理系统" in r.text, r.text[:200]) + check("托管的是完整页面而不是目录列表", "
" in r.text or "nav-item" in r.text, r.text[:200]) + r2 = client.get("/index.html") + check("静态资源可直接访问", r2.status_code == 200 and "text/html" in r2.headers.get("content-type", "")) + else: + check("前端未托管时回落到服务信息 JSON", "api_prefix" in (r.json() or {})) + + r = client.get("/health") + h = r.json() + check("GET /health 健康检查", r.status_code == 200 and h["database"] == "up", r.text[:200]) + check("health 报告前端托管状态", "frontend" in h, str(h)[:200]) + + # 前端托管后必须不能把 API 挡掉 —— Mount("/") 是通吃规则,注册顺序错了就会全挡 + r = client.get("/api/v1/stats/dashboard") + check("托管前端后 API 依然可达(路由注册顺序正确)", r.status_code == 200, r.text[:200]) + r = client.get("/openapi.json") + check("托管前端后 /docs 相关接口依然可达", r.status_code == 200, r.text[:150]) + + print() + print("=" * 72) + print("4) 学生 CRUD + 学号自动生成") + print("=" * 72) + r = client.get("/api/v1/students", params={"page": 1, "size": 5}) + body = r.json() + check("学生分页列表", r.status_code == 200 and len(body["items"]) == 5, r.text[:300]) + check("分页返回 total/pages", "total" in body and body["pages"] > 0, str(body)[:200]) + check("列表带出班级名与顾问名", bool(body["items"][0].get("class_name")), str(body["items"][0])[:200]) + + r = client.post( + "/api/v1/students", + json={"student_name": "测试学生", "class_id": 1, "advisor_id": 1, "age": 20, "gender": "男"}, + ) + check("新增学生(学号自动生成)", r.status_code == 201, r.text[:300]) + new_sid = r.json().get("sid") if r.status_code == 201 else None + check("生成的学号符合规则 S+年份+4位流水", bool(r.json().get("student_no", "").startswith("S")), str(r.json())[:200]) + + r = client.post( + "/api/v1/students", + json={"student_name": "坏数据", "class_id": 99999, "advisor_id": 1}, + ) + check("非法班级ID 返回 422 而不是 500", r.status_code == 422, r.text[:300]) + + r = client.post( + "/api/v1/students", + json={ + "student_name": "时间倒挂", + "class_id": 1, + "advisor_id": 1, + "enrollment_time": "2028-09-01", + "graduation_time": "2024-06-30", + }, + ) + check("毕业早于入学被拦下(跨字段校验)", r.status_code == 400, r.text[:300]) + + r = client.put(f"/api/v1/students/{new_sid}", json={"major": "人工智能"}) + check("更新学生(只改传了的字段)", r.status_code == 200 and r.json()["major"] == "人工智能", r.text[:300]) + + r = client.delete(f"/api/v1/students/{new_sid}") + check("删除学生(逻辑删除)", r.status_code == 200, r.text[:200]) + + r = client.get(f"/api/v1/students/{new_sid}") + check("已删除学生查询返回 404", r.status_code == 404, r.text[:200]) + + r = client.put(f"/api/v1/students/{new_sid}", json={"major": "改不动"}) + check("已删除学生不允许修改(原版可以)", r.status_code == 422, r.text[:200]) + + r = client.put(f"/api/v1/students/{new_sid}/restore") + check("恢复已删除学生", r.status_code == 200, r.text[:200]) + + print() + print("=" * 72) + print("5) 统计接口(原版最集中的故障区)") + print("=" * 72) + + r = client.get("/api/v1/stats/dashboard") + check("看板聚合", r.status_code == 200 and r.json()["student_total"] > 0, r.text[:300]) + + r = client.get("/api/v1/stats/class-gender") + b = r.json() + check("班级性别统计", r.status_code == 200 and len(b) >= 6, r.text[:300]) + check( + "性别统计各分项之和 == 班级总人数", + all(x["boys_count"] + x["girls_count"] <= x["class_total"] for x in b), + str(b)[:300], + ) + + r = client.get("/api/v1/stats/class-avg-score") + b = r.json() + check("班级平均分接口可用", r.status_code == 200 and len(b) > 0, r.text[:300]) + # 原版 GROUP BY 带上了 Score.score,avg 恒等于 score 本身, + # 会出现"avg_score 恰好等于某个整数分数"的假象。 + distinct_avgs = {x["avg_score"] for x in b} + check( + "班级平均分不是某个原始分数(原版 SQL 分组错误)", + len(distinct_avgs) > 3 and all(x["avg_score"] % 1 != 0 for x in b), + str(b)[:400], + ) + + r = client.get("/api/v1/stats/top-salary", params={"n": 5}) + check("薪资 TopN", r.status_code == 200 and len(r.json()) <= 5, r.text[:300]) + + r = client.get("/api/v1/stats/work-duration") + b = r.json() + check("Offer→入职间隔", r.status_code == 200 and all(x["offer_to_entry_days"] >= 0 for x in b), r.text[:300]) + + r = client.get("/api/v1/stats/class-avg-work-duration") + check("班级平均间隔", r.status_code == 200, r.text[:300]) + + r = client.get("/api/v1/stats/score-above/60") + check("分数线以上学生", r.status_code == 200, r.text[:200]) + + r = client.get("/api/v1/stats/score-above/101") + check("分数线超出范围被拦下(原版无校验)", r.status_code == 400, r.text[:200]) + + r = client.get("/api/v1/stats/fail-more", params={"fail_times": 2}) + check("不及格次数统计", r.status_code == 200, r.text[:200]) + + r = client.post("/api/v1/stats/by-age", json={"operator": "between", "min_value": 18, "max_value": 22}) + check("按年龄区间筛选(Pydantic v2 写法)", r.status_code == 200, r.text[:300]) + + r = client.post("/api/v1/stats/by-age", json={"operator": "between"}) + check("between 缺参数被拦下", r.status_code == 400, r.text[:200]) + + r = client.post("/api/v1/stats/by-age", json={"operator": "gt", "value": 20}) + check("按年龄大于筛选", r.status_code == 200, r.text[:200]) + + print() + print("=" * 72) + print("6) 其他模块 CRUD") + print("=" * 72) + r = client.get("/api/v1/classes", params={"page": 1, "size": 5}) + check("班级分页 + 学生数", r.status_code == 200 and "student_count" in r.json()["items"][0], r.text[:300]) + + r = client.post("/api/v1/classes", json={"class_name": "计科2401", "start_time": "2024-09-01T08:00:00", "head_teacher": "王", "teacher": "李"}) + check("班级重名返回 409", r.status_code == 409, r.text[:200]) + + r = client.delete("/api/v1/classes/1") + check("班级下有在读学生时拒绝删除(原版会物理删除)", r.status_code == 422, r.text[:300]) + + r = client.post("/api/v1/teachers", json={"t_name": "新老师", "phone": "13800001111", "subject": "编译原理"}) + check("新增老师", r.status_code == 201, r.text[:300]) + check("老师手机号对外脱敏", "****" in r.json().get("phone", ""), str(r.json())[:200]) + + r = client.post("/api/v1/teachers", json={"t_name": "手机号错", "phone": "123"}) + check("非法手机号被拦下", r.status_code == 400, r.text[:200]) + + r = client.get("/api/v1/advisors", params={"page": 1, "size": 5}) + check("顾问分页 + 名下学生数", r.status_code == 200 and "student_count" in r.json()["items"][0], r.text[:300]) + + r = client.delete("/api/v1/classes/99999") + check("删除不存在的班级返回 404", r.status_code == 404, r.text[:200]) + + r = client.get("/api/v1/advisors/1/students") + check("顾问名下学生", r.status_code == 200 and isinstance(r.json(), list), r.text[:200]) + + r = client.get("/api/v1/exams") + check("考试列表", r.status_code == 200 and len(r.json()) == 3, r.text[:200]) + + r = client.get("/api/v1/scores", params={"class_id": 1, "page": 1, "size": 10}) + check("按班级查成绩(原版做不到)", r.status_code == 200 and len(r.json()["items"]) > 0, r.text[:300]) + + r = client.post("/api/v1/scores", json={"student_id": 1, "exam_id": 1, "score": 88}) + check("重复录入同一考试返回 409", r.status_code == 409, r.text[:200]) + + r = client.post("/api/v1/scores", json={"student_id": 1, "exam_id": 999, "score": 88}) + check("不存在的考试被拦下(原版是 assert exam_id<=3)", r.status_code == 422, r.text[:200]) + + r = client.get("/api/v1/scores/summary/1") + check("学生成绩概览", r.status_code == 200, r.text[:200]) + + r = client.get("/api/v1/employments", params={"page": 1, "size": 5}) + check("就业分页列表", r.status_code == 200 and "total" in r.json(), r.text[:300]) + + r = client.get("/api/v1/employments/stats") + check("就业概览统计", r.status_code == 200 and "top_companies" in r.json(), r.text[:300]) + + r = client.get("/api/v1/employments/current/1") + check("学生当前就业(路由顺序正确)", r.status_code in (200, 404), r.text[:200]) + + r = client.get("/api/v1/options/all") + b = r.json() + check("下拉选项一次性拉取", r.status_code == 200 and b["classes"] and b["advisors"], r.text[:300]) + + r = client.get("/api/v1/options/students", params={"limit": 20}) + check("学生下拉选项(支持搜索)", r.status_code == 200 and len(r.json()) == 20, r.text[:200]) + + print() + print("=" * 72) + print("7) 全量接口扫描(任何接口都不允许返回 5xx)") + print("=" * 72) + # 为什么要有这一段: + # 教师列表接口曾经漏了 "ORM 转 dict",response_model=dict 遇到 SQLAlchemy 对象 + # 会抛 PydanticSerializationError → 500。而当时的测试只覆盖了 POST /teachers, + # 没覆盖 GET /teachers,所以一路绿灯放行,直到手工点接口才发现。 + # 与其一个个补,不如让测试把 OpenAPI 里所有 GET 都自动打一遍。 + import re as _re + + schema = app.openapi() + sample_path_args = { + "sid": "1", "cid": "1", "tid": "1", "advisor_id": "1", "student_id": "1", + "score_id": "1", "id": "1", "line": "60", "exam_id": "1", + } + swept, broken = 0, [] + for api_path, methods in sorted(schema["paths"].items()): + if "get" not in methods or api_path.startswith("/api/v1/stats/by-age"): + continue + url = api_path + for name in _re.findall(r"\{(\w+)\}", api_path): + url = url.replace("{" + name + "}", sample_path_args.get(name, "1")) + q = {} + for spec in (methods["get"].get("parameters") or []): + if spec.get("in") == "query" and spec.get("required"): + n = spec["name"] + q[n] = "2" if "times" in n else ("60" if ("line" in n or "score" in n) else "1") + resp = client.get(url, params=q) + swept += 1 + if resp.status_code >= 500: + broken.append(f"{url} -> {resp.status_code} {resp.text[:120]}") + check(f"全部 {swept} 个 GET 接口无 5xx", not broken, ";".join(broken)[:400]) + + print() + print("=" * 72) + print("8) 路由完整性") + print("=" * 72) + schema = app.openapi() + paths = sorted(schema["paths"].keys()) + broken = [p for p in paths if "//" in p or "Management" in p] + check("没有拼接错误的路由(原版有 studentsManagementget_top_salary)", not broken, str(broken)) + # / 和 /health 是运维端点,本来就该在版本前缀之外 + system_paths = {"/", "/health"} + biz_paths = [p for p in paths if p not in system_paths] + check("业务路由统一 /api/v1 前缀", all(p.startswith("/api/v1") for p in biz_paths), str(biz_paths)[:300]) + print(f" 共注册 {len(paths)} 个路由(业务 {len(biz_paths)} + 系统 {len(system_paths)})") + + # 顺便看看有没有重复 operationId + op_ids = [] + for path, methods in schema["paths"].items(): + for method, spec in methods.items(): + if isinstance(spec, dict) and "operationId" in spec: + op_ids.append((path, method, spec["operationId"])) + dup = len(op_ids) - len({o[2] for o in op_ids}) + check("OpenAPI 中没有重复 operationId", dup == 0, f"重复 {dup} 个") + + print() + print("=" * 72) + print(f"结果:{len(PASSED)} 通过 / {len(FAILED)} 失败") + if FAILED: + print("失败项:") + for f in FAILED: + print(f" - {f}") + print("=" * 72) + return 1 if FAILED else 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + traceback.print_exc() + sys.exit(2) diff --git a/student_management_system_complete/查漏补缺报告.md b/student_management_system_complete/查漏补缺报告.md new file mode 100644 index 0000000..bc025af --- /dev/null +++ b/student_management_system_complete/查漏补缺报告.md @@ -0,0 +1,725 @@ +# 学生管理系统 · 查漏补缺报告 & 前端页面说明 + +> 审查对象:`student_management_system`(FastAPI + SQLAlchemy 分层架构,6 个模块 / 6 位作者) +> 审查日期:2026-09-16 +> 修复成果位于工作区 `student_management_system/`,**桌面上的原目录未做任何改动**,可直接对照 + +--- + +## 0. 一句话结论 + +架构分层(model / schema / dao / api)本身是对的,问题集中在**三处**: + +1. **数据正确性**——统计 SQL 写错、已删除数据被计入报表、逻辑删除方向不统一; +2. **工程健壮性**——路由拼接断裂、裸异常 500、密码硬编码、无分页无测试; +3. **业务建模**——考试/就业用"魔法数字"和"唯一约束"表达,撑不住真实场景。 + +下面按 **P0(会出错/出错看不到)→ P1(不合理)→ P2(可优化)** 分级列出,每条给出**原文件位置 + 原因 + 修复方式**。 + +> **技术栈确认**:项目就是 **FastAPI + MySQL**,没有换过栈。本次额外在本机 +> **MySQL 8.0.27** 上做了完整的实测(建表 → 灌数据 → 真实 HTTP 调用 → 前端联调), +> 结果见第 7 节。实测过程还**新发现了一个 P0 问题(见 P0-11)**—— +> 只有在真库上跑才会暴露,用 SQLite 永远测不出来。 + +--- + +## 1. P0:会造成错误结果或直接崩的 11 个问题 + +### P0-1 路由拼接断裂,两个接口根本调不通 + +| 文件 | 原代码 | 实际生成的路径 | +|---|---|---| +| `api/student_api.py:54` | `@router.get("get_top_salary")` | `/api/studentsManagementget_top_salary` | +| `api/student_api.py:60` | `@router.get("avg_work_time_by_class")` | `/api/studentsManagementavg_work_time_by_class` | +| `api/student_api.py:67` | `@router.get("/api/get/student")` | `/api/studentsManagement/api/get/student`(双 `/api`) | + +**原因**:`main.py` 里前缀是 `/api/studentsManagement`,路由里少写前导斜杠时 FastAPI 直接字符串拼接,不会补斜杠。 +**讽刺的是**:这两个"薪资排名""班级平均就业时长"恰好是最有业务价值的功能,因为少一个字符而完全不可用,而且 Swagger 上看起来一切正常。 + +**修复**:路由只写资源路径,前缀统一由 `api/router.py` 注入;全部改为 RESTful 复数名词 + HTTP method 表意。 + +--- + +### P0-2 「班级平均分」的 SQL 是错的 + +`dao/student_dao.py:310` + +```python +.group_by(Score.score, Classes.cid, Classes.class_name) # ← 罪魁祸首 +... +avg_score = func.avg(Score.score) +``` + +**GROUP BY 里带上 `Score.score` 后,每个分组内只有一个分数值,`AVG()` 必然等于分数本身。** +真实结果不是"班级平均分",而是"分数去重后的分布表"。业务上完全无效,但因为返回值看起来像分数(75、82),没人会怀疑。 + +**修复**(`dao/student_dao.py` 重写): + +```python +db.query( + Classes.cid, Classes.class_name, + func.count(func.distinct(Student.sid)).label("student_count"), + func.avg(Score.score).label("avg_score"), # 按班级分组,组内所有成绩求平均 + func.max(Score.score), func.min(Score.score), +).join(Student, and_(Student.class_id == Classes.cid, Student.flag == 1)) + .join(Score, and_(Score.student_id == Student.sid, Score.flag == 1)) + .group_by(Classes.cid, Classes.class_name) +``` + +冒烟测试里专门加了一条断言:**班级平均分必须不是整数**——因为原写法必然返回整数或去重分数。 + +--- + +### P0-3 已删除的成绩被计入统计报表 + +`dao/student_dao.py:249` `get_higher_score`、`:270` `get_fail_more_than` 遍历 `student.scores` 时**没有过滤 `Score.flag == 1`**。 + +一个学生删掉一次不及格成绩后,报表里"不及格次数"仍然算他一次——**逻辑删除在统计口径里完全失效**。这是最容易被审计挑出来的问题。 + +**修复**:统计全部改为 SQL 聚合,并在 JOIN 条件里带 `Score.flag == 1`。 + +--- + +### P0-4 典型 N+1:1000 个学生会打 1001 条 SQL + +`get_higher_score`、`get_fail_more_than`、`work_time`、`get_class_gender` 四个函数都是这个套路: + +```python +for student in all_students: # 1 次全表查 + for stu_score in student.scores: # 每个学生再查 1 次 +``` + +`get_class_gender` 更夸张——**每个班级打 3 条 COUNT**,6 个班 = 1 + 18 = 19 条 SQL。 + +**修复**:全部改成条件聚合,一次查询出结果: + +```python +func.sum(case((Student.gender == GenderEnum.MALE, 1), else_=0)).label("boys") +``` + +数据量从几百涨到几万时,这是"能用"和"不能用"的分界线。 + +--- + +### P0-5 「就业时长」的语义是反的 + +`dao/student_dao.py:292`: + +```python +delta = work.employment_start_time - work.offer_time # Offer → 入职 +days = delta.days +... {'work_time': days} +``` + +这个差值是 **Offer 下发到实际入职的等待天数**,衡量的是"求职周期",不是"就业时长"。 +真正的"就业时长"需要**离职时间**才能算,而 `employment` 表里根本没有这个字段——字段缺失被一个语义错误的计算掩盖了。 + +**修复**:改名 `offer_to_entry_days`,并在报告里明确指出"若要统计在职时长,必须先补 `resign_time` 字段"。 + +--- + +### P0-6 逻辑删除有三套写法,其中两处方向相反 + +| 表 | 字段名 | 约定 | +|---|---|---| +| `student` / `advisor` / `score` / `employment` | `flag` | 1=正常,0=删除 | +| `teacher` | `is_deleted` | 1=未删除,0=已删除 | +| `classes` | `is_del` | **注释和代码互相矛盾** | + +`classes` 的矛盾尤其严重: + +- `dao/class_dao.py:27` `get_class_by_cid` 认为 `is_del == 1` 才是有效数据; +- `api/class_api.py:63` `delete_class_api` 认为 `is_del = 0` 才是"已删除"; +- `seed_data.py:153` 种子数据里 0 和 1 混着给。 + +更糟的是 **DAO 和 API 对"删除"的定义完全不同**: + +```python +# dao/class_dao.py:45 ← 物理删除!数据真的没了 +db.delete(class_obj) +db.commit() + +# api/class_api.py:67 ← 逻辑删除,且方向与上面相反 +class_obj.is_del = 0 +``` + +同一个"删除班级"的需求,两个入口两种结果。走 DAO 那条路删完,这个班的学生全部变成孤儿数据。 + +**修复**:抽出 `model/base.py` 的 `SoftDeleteMixin`,全表统一 `flag`(1=正常 / 0=已删除)并写进注释;`delete_class` 改为纯逻辑删除,并加上**业务规则:班级下还有在读学生时拒绝删除**。 + +--- + +### P0-7 种子数据字段名写错,数据静默丢失 + +`seed_data.py:74`: + +```python +"native_places": "广东深圳", # 模型里叫 native_place +"status": "在读", # 模型里叫 state +``` + +`bulk_insert_mappings` 遇到映射不上的 key 不会报错,这两个字段**直接丢失**。于是所有演示学生的"籍贯"和"就业状态"永远是 NULL——而按状态统计的报表会全部返回 0。 + +**这类 bug 最难查**:不报错、不警告,只是数据悄悄不对。 + +**修复**:改为 ORM 对象插入(字段名写错会立刻抛 `TypeError`),并在冒烟测试里断言"籍贯和状态不为空"。 + +--- + +### P0-8 `schema/__init__.py` 是 `student_schema.py` 的整份拷贝 + +复制粘贴事故。后果: + +```python +from schema import StudentQuery # 拿到 A 类 +from schema.student_schema import StudentQuery # 拿到 B 类 +``` + +两个不同的类,`isinstance` 判断、Pydantic 校验、依赖注入全部失准。**改了一个文件不影响另一个**,排查起来极其痛苦。 + +**修复**:`schema/__init__.py` 只做模块转发,不再定义任何模型。 + +--- + +### P0-9 CORS 配置是浏览器规范禁止的组合 + +`main.py:19`: + +```python +allow_origins=["*"], +allow_credentials=True, # ← 与 "*" 互斥 +``` + +浏览器明确禁止 `Access-Control-Allow-Origin: *` 与 `credentials: true` 同时生效。所有带 Cookie / 登录态的请求必然失败,而且**失败信息在浏览器控制台里指向 CORS,让人以为是后端没配跨域**。 + +**修复**:改为从 `CORS_ORIGINS` 环境变量读显式白名单。 + +--- + +### P0-10 数据库密码硬编码在源码里 + +`database.py:11`:`mysql+pymysql://root:123456@localhost:3306/...` + +提交到 Git 就等于把生产库密码公开了。**修复**:新增 `settings.py` + `.env.example`,全部走环境变量,`.env` 不提交。 + +--- + +### P0-11 【实测发现】MySQL 的 CHECK 约束违反抛的是 `OperationalError`,不是 `IntegrityError` + +**这一条是拿真库跑测试才发现的,SQLite 上永远测不出来。** + +修完前面的问题后,我在本机 MySQL 8.0.27 上跑了一遍全部测试,其中"手机号必须是 11 位"的数据库级 CHECK 约束探针报了个意料外的异常: + +``` +pymysql.err.OperationalError: (3819, "Check constraint 'ck_teacher_phone_len' is violated.") +``` + +也就是说,如果不单独处理,**用户把手机号填成 `"123"`,接口会返回 500「服务器内部错误」**,而真实原因只是一个字段格式问题。原因是我最初的异常处理只认 `IntegrityError`: + +```python +@app.exception_handler(IntegrityError) # ← 唯一键/外键走这里,没问题 +async def _integrity_handler(...): -> 409 + +@app.exception_handler(SQLAlchemyError) # ← CHECK 违反掉进了这个兜底分支,返回 500 +async def _sqlalchemy_handler(...): -> 500 +``` + +MySQL 各类约束违反对应的异常和错误码其实是不一样的: + +| 场景 | SQLAlchemy 异常 | MySQL 错误码 | 应该返回 | +|---|---|---|---| +| 唯一键冲突 | `IntegrityError` | 1062 | 409 | +| 外键被引用(删不掉) | `IntegrityError` | 1451 | 409 | +| 外键指向不存在 | `IntegrityError` | 1452 | 422 | +| **CHECK 约束违反** | **`OperationalError`** | **3819** | **422** | +| 枚举取值非法 / 数据被截断 | `DataError` | 1265 | 400 | +| 字段超长 | `DataError` | 1406 | 400 | +| 必填字段为空 | `IntegrityError` | 1048 | 400 | +| **连不上数据库 / 表不存在** | `OperationalError` / `ProgrammingError` | 2003 等 | **500** | + +注意最后两行——**`OperationalError` 同时承载了"用户填错值"和"数据库真的挂了"两种截然不同的情况**,不能一刀切。 + +**修复**(`exceptions.py` 新增 `classify_sqlalchemy_error`):按错误码细分翻译,并把它抽成纯函数以便直接写单元测试: + +```python +MYSQL_ERROR_MAP = { + 1062: (40900, "数据已存在(唯一键冲突)", 409), + 1451: (40900, "该记录被其他数据引用,无法删除", 409), + 1452: (42200, "关联的数据不存在(外键校验失败)", 422), + 3819: (42201, "数据不满足字段约束,请检查手机号等格式要求", 422), + 1265: (40002, "字段值不合法:类型、长度或枚举取值不正确", 400), + 1406: (40003, "字段内容超出长度限制", 400), + 1048: (40004, "必填字段不能为空", 400), +} + +def classify_sqlalchemy_error(exc) -> tuple[int, str, int]: + """把 SQLAlchemy 异常翻译成 (业务错误码, 中文提示, HTTP 状态码)。""" +``` + +并新增 `DataError` 独立处理器,`OperationalError` 按错误码分流:业务类给 4xx,连接类才给 500 并打完整堆栈。 + +--- + +## 2. P1:设计不合理,会在真实场景里出问题的 12 项 + +| # | 问题 | 位置 | 后果 | 修复 | +|---|---|---|---|---| +| 1 | Pydantic **v1 / v2 写法混用** | `student_schema.py:57` 用 `@validator`(v1,v2 已废弃);`advisor_schema.py:14` 用 `@field_validator`(v2) | 同一项目两套校验行为,`AgeQuery.check` 在 v2 下会直接崩 | 全部统一 v2 `@field_validator` / `@model_validator` | +| 2 | 新增学生要求前端传 `flag` | `student_schema.py:17` | 把"这条记录删不删"的决定权交给了浏览器 | 从请求体移除,后端固定为 1 | +| 3 | 修改学生时 `student_name/class_id/flag/advisor_id` 全必填 | `student_schema.py:30` | 想改个专业也得把全班信息带上,前端极易传错 | 全部改可选,只更新传了的字段 | +| 4 | 更新/删除不过滤 `flag` | `dao/student_dao.py:157` `:201` | **已删除的学生还能被继续修改** | 加"已删除请先恢复"的校验 | +| 5 | 创建学生不校验 `class_id` / `advisor_id` 是否存在 | `dao/student_dao.py:41` | 数据库没开外键就产生脏数据;开了就抛 500 | 显式校验,返回 422 和中文提示 | +| 6 | 成绩唯一性只在代码里查 `first()` | `dao/score_dao.py:18` | 两个并发请求会插进两条同样的成绩 | 加 `UniqueConstraint(student_id, exam_id, flag)` | +| 7 | 考试次序 `1/2/3` 硬编码 | `dao/score_dao.py:14` | 想加一次"补考"必须改代码 | **新建 `exam` 表**,成绩外键关联 | +| 8 | `Employment.student_id` 是 `unique=True` | `model/employment_model.py:13` | **一个学生一辈子只能有一条就业记录**,换工作只能覆盖,历史丢失 | 去掉 unique,加 `is_current` 标记当前在职 | +| 9 | 手机号校验两套标准 | `teacher_schema.py:11` 只限 `max_length=11`;`advisor_schema.py:19` `\d{11}` | `"1"` 和 `"00000000000"` 都能通过 | 统一 `^1[3-9]\d{9}$` | +| 10 | 脱敏策略不一致 | `advisor_schema.py` 有 `mask_phone`;`teacher_schema.py` 无 | 老师手机号明文返回给前端 | 统一 `138****8001` | +| 11 | 无全局异常处理 | 全项目 | 未捕获异常直接把 SQLAlchemy 堆栈 + 表结构返给前端 | `exceptions.py` + `register_exception_handlers` | +| 12 | 分页标准三套 | student 用 `page/size`,teacher 用 `skip/limit`,其余模块一次全查 | 前端要记三套规则;成绩表几万行直接拖死 | 统一 `PageQuery` / `PageResult` | + +**其他 P1:** + +- **`Advisor.flag` 是 `nullable=False` 但没有默认值**(`model/advisor_model.py:24`)→ 新增时漏传就是 500。 +- **`advisor_schema.StudentOut` 与 `student_schema.StudentOut` 同名**(`schema/advisor_schema.py:28`)→ 两个模块各自 import 时极易拿错类。 +- **`AdvisorOut` 要求 `enrollment_time: datetime` 必填**(`advisor_schema.py:31`)→ 学生该字段为空时整个响应校验失败 500。已改 Optional。 +- **`TeacherCreate.subject` 默认值是 `"班主任"`**(`teacher_schema.py:12`)→ 字段语义是"授课科目",把"班主任"存进科目列是数据语义污染。 +- **`teacher_class` / `teacher_student` 两张中间表定义了但从不写入**:`seed_data.py:256` 明确注释掉,也没有任何 API 能维护。属于**死代码**——要么补 `Teacher` 关联班级/学生的接口,要么删掉。 +- **`Classes.head_teacher` / `teacher` 是裸字符串**,不是外键指向 `teacher.tid` → 老师改名后班级信息对不上。真实业务必须外键。 +- **`main.py` 里 `if __name__ == "__main__"` 直接调 `init_all(True, False)`** → "启动服务"和"建表灌数据"耦合,重启一次就可能动到数据。 + +--- + +## 3. P2:可以更专业的 8 项 + +- ~~**无 `requirements.txt`**:组员 clone 下来装不上环境。~~ → **已补**(`requirements.txt`) +- **无 `created_at` / `updated_at`**:数据出问题无法追溯。已通过 `TimestampMixin` 全表补齐。 +- **无 Alembic 迁移**:靠 `create_all`,字段一改就得删库重建。 +- **无日志配置**:全项目用 `print` 调试(`dao/student_dao.py:303` 还留着 `print(type(days))`)。 +- ~~**无健康检查**:~~ → **已补** `/health`(**真的去连一下数据库**,不是返回硬编码 ok)。 +- **响应体不统一**:有的返 `{"message":..., "data":...}`,有的直接返对象/数组。 +- **`model/demo.py` 整份是注释掉的旧代码**:留着只会让人以为还有用。 +- **端口/主机硬编码**:`main.py` 里 `port=8004` + `reload=True` 写死,生产环境不该这样(现已走 `settings.py`)。 + +--- + +## 4. 接口路径映射表(重要:这是一次不兼容变更) + +原来所有接口共用一个前缀 `/api/studentsManagement`,现在统一到 `/api/v1/<资源复数>`。 +**下表左右两列功能完全等价**,组员按此调整前端调用即可。 + +### 学生 + +| 原路径 | 新路径 | +|---|---| +| `GET .../api/get/student` | `GET /api/v1/students` | +| `GET .../get/student/{sid}` | `GET /api/v1/students/{sid}` | +| `POST .../api/create/student` | `POST /api/v1/students` | +| `PUT .../api/update/student/{sid}` | `PUT /api/v1/students/{sid}` | +| `DELETE .../api/delete/student/{sid}` | `DELETE /api/v1/students/{sid}` | +| — | `PUT /api/v1/students/{sid}/restore` (新增:恢复) | + +### 统计(原路径残缺,现已完整可用) + +| 原路径 | 新路径 | +|---|---| +| `GET .../count_gender` | `GET /api/v1/stats/class-gender` | +| `GET .../count_score_higherScores/{score}` | `GET /api/v1/stats/score-above/{line}` | +| `GET .../search_workTime` | `GET /api/v1/stats/work-duration` | +| `GET .../fail_more` | `GET /api/v1/stats/fail-more` | +| `GET .../count_avg` | `GET /api/v1/stats/class-avg-score` | +| `POST .../order_age` | `POST /api/v1/stats/by-age` | +| `GET .../studentsManagementget_top_salary` ⚠️ | `GET /api/v1/stats/top-salary` | +| `GET .../studentsManagementavg_work_time_by_class` ⚠️ | `GET /api/v1/stats/class-avg-work-duration` | +| — | `GET /api/v1/stats/dashboard` (新增:首页看板) | + +### 班级 / 老师 / 顾问 + +| 原路径 | 新路径 | +|---|---| +| `POST /add_class` | `POST /api/v1/classes` | +| `GET /get_one_class/{cid}` | `GET /api/v1/classes/{cid}` | +| `PUT /update_class/{cid}` | `PUT /api/v1/classes/{cid}` | +| `DELETE /delete_class/{cid}` | `DELETE /api/v1/classes/{cid}` | +| `GET /getTeacher/{t_id}` | `GET /api/v1/teachers/{tid}` | +| `GET /search/teacher/limit` | `GET /api/v1/teachers` | +| `POST /add/teacher` | `POST /api/v1/teachers` | +| `PUT /update/{t_id}` | `PUT /api/v1/teachers/{tid}` | +| `DELETE /delete/{t_id}` | `DELETE /api/v1/teachers/{tid}` | +| `GET /getAllAdvisors` | `GET /api/v1/advisors` | +| `GET /getAdvisor/{id}` | `GET /api/v1/advisors/{id}` | +| `GET /advisors/{id}/students` | `GET /api/v1/advisors/{id}/students` | +| `POST /createAdvisor` | `POST /api/v1/advisors` | +| `PUT /changeAdvisorStatus/{id}/status` | `PUT /api/v1/advisors/{id}/status` | +| `PUT /updateAdvisor/{id}` | `PUT /api/v1/advisors/{id}` | + +### 成绩 / 就业 + +| 原路径 | 新路径 | +|---|---| +| `POST /score/addScore` | `POST /api/v1/scores` | +| `GET /score/getScore?student_id&exam_id` | `GET /api/v1/scores`(支持班级/姓名/分数区间) | +| `PUT /score/modifyScore/{student_id}` | `PUT /api/v1/scores/{score_id}` | +| `DELETE /score/removeScore/{student_id}/{exam_id}` | `DELETE /api/v1/scores/{score_id}` | +| `POST /addEmployment` | `POST /api/v1/employments` | +| `GET /getEmployments` | `GET /api/v1/employments` | +| `PUT /modifyEmployment/{student_id}` | `PUT /api/v1/employments/{id}` | +| `DELETE /removeEmployment/{student_id}` | `DELETE /api/v1/employments/{id}` | + +### 新增模块 + +| 路径 | 说明 | +|---|---| +| `GET/POST/PUT/DELETE /api/v1/exams` | 考试基础数据(替代硬编码 1/2/3) | +| `GET /api/v1/options/all` | 一次性取全部下拉选项,省 4 次往返 | +| `GET /api/v1/options/students` | 学生下拉(支持关键字搜索) | +| `GET /health` | 健康检查 | + +--- + +## 5. 数据库结构变化 + +| 表 | 变化 | +|---|---| +| **全部** | 新增 `created_at` / `updated_at`(`TimestampMixin`) | +| `teacher` | `is_deleted` → `flag`;新增 CHECK 约束(手机号必须 11 位) | +| `classes` | `is_del` → `flag`;新增 `UNIQUE(class_name, flag)` 防并发重名 | +| `student` | `gender`/`state`/`education` 由裸字符串改为 **枚举**;`student_no` 加唯一索引;新增 CHECK(年龄 0~150);`state` 默认"在读" | +| `score` | `score` 由 `Integer` → `Numeric(5,2)`(支持 85.5);新增 `UNIQUE(student_id, exam_id, flag)` | +| `employment` | 去掉 `student_id` 的 `unique`;新增 `is_current`;新增组合索引 | +| `exam` | **新建表**:`id / exam_name / exam_order / exam_date` | +| `advisor` | `flag` 补 `default=1` | + +> 因为改了表结构,第一次运行请执行 `python init_db.py --reset` 重建表。 + +--- + +## 6. 新增的工程能力 + +| 文件 | 作用 | +|---|---| +| `settings.py` / `.env.example` | 配置外置,换环境不改代码 | +| `requirements.txt` | 依赖清单(原版**没有**,组员 clone 下来装不上环境) | +| `init.sql` | 建库脚本(`create_all()` 只建表不建库,漏这步会报 Unknown database);含 utf8mb4 说明 | +| `exceptions.py` | 业务异常体系 + 全局异常处理器 + 统一响应体 + **MySQL 错误码翻译** | +| `model/base.py` | `SoftDeleteMixin` / `TimestampMixin`,全表统一 | +| `model/enums.py` | 枚举集中定义(原版同一概念两套表示) | +| `services/student_no.py` | 学号自动生成:`S + 年份 + 4位流水`,用 `MAX()+1` 而不是 `COUNT()+1`(删过学生也不会撞号) | +| `dao/common.py` | 统一分页 | +| `api/router.py` | 路由集中注册,加模块不用动 `main.py` | +| `smoke_test.py` | 冒烟测试,**支持 SQLite / 真实 MySQL 双模式** | + +`smoke_test.py` 里几条断言是专门为原版 bug 写的"回归测试": + +- 「籍贯字段正确落库」→ 锁死 P0-7 +- 「班级平均分不是整数」→ 锁死 P0-2 +- 「已删除学生不允许修改」→ 锁死 P1-4 +- 「班级下有学生时拒绝删除」→ 锁死 P0-6 +- 「没有拼接错误的路由」→ 锁死 P0-1 +- 「CHECK 约束违反被翻译成 422 而不是 500」→ 锁死 P0-11 + +```bash +python smoke_test.py # SQLite:54 项,秒级 +set SMS_TEST_DB=mysql && python smoke_test.py # 真实 MySQL:61 项(含字符集/枚举/CHECK 专项) +``` + +--- + +## 7. 真实 MySQL 环境实测 + +前面所有改动都在本机实际 MySQL 上完整跑过一遍,不是"理论上应该能跑"。 + +**环境**:MySQL 8.0.27 · InnoDB · server charset `utf8mb4` / `utf8mb4_0900_ai_ci` + +### 7.1 建表结果 + +`python init_db.py --reset` 一次通过,9 张表全部按预期生成。抽查几张表的关键结构: + +``` +student InnoDB utf8mb4_0900_ai_ci + `education` enum('大专','本科','硕士','博士','其他') + `gender` enum('男','女') + `state` enum('在读','进入就业','已就业') + UNIQUE KEY `ix_student_student_no` (`student_no`) + CONSTRAINT ck_student_age CHECK (`age` is null or (`age` > 0 and `age` <= 150)) + +score InnoDB utf8mb4_0900_ai_ci + `score` decimal(5,2) NOT NULL + UNIQUE KEY `uq_score_student_exam_flag` (`student_id`,`exam_id`,`flag`) + FOREIGN KEY (`student_id`) REFERENCES `student`(`sid`) + FOREIGN KEY (`exam_id`) REFERENCES `exam`(`id`) + +teacher InnoDB utf8mb4_0900_ai_ci + CONSTRAINT ck_teacher_phone_len CHECK (`phone` is null or length(`phone`) = 11) + +employment InnoDB utf8mb4_0900_ai_ci + `is_current` tinyint(1) NOT NULL DEFAULT '1' + KEY `ix_employment_student_current` (`student_id`,`is_current`) ← 无 unique,允许多条 +``` + +值得单独说的几点: + +- **原生 ENUM 生效**:中文枚举值直接落成 MySQL `ENUM('男','女')`,非法取值在数据库层就被拒; +- **CHECK 约束真的被强制执行**:MySQL 8.0.16+ 才开始真正执行 CHECK(5.7 只解析不执行),8.0.27 上验证有效; +- **`ON UPDATE` 由 SQLAlchemy 在 UPDATE 语句里补**,没有用 DDL 的 `ON UPDATE CURRENT_TIMESTAMP`——这样迁移到别的数据库时行为一致,代价是裸 SQL 改数据不会自动更新 `updated_at`; +- **索引都建对了**:外键、唯一键、`flag` 逻辑删除过滤列、`(student_id, is_current)` 组合索引。 + +### 7.2 后端测试结果 + +| 测试 | 环境 | 结果 | +|---|---|---| +| `smoke_test.py` | SQLite 内存库 | **61 通过 / 0 失败** | +| `smoke_test.py` | 真实 MySQL 8.0.27 | **68 通过 / 0 失败** | +| HTTP 端到端(httpx 打真实服务) | FastAPI + MySQL | **24 通过 / 0 失败** | +| 全量接口扫描(OpenAPI 枚举所有 GET) | 真实 MySQL | **32 个接口,0 个 5xx** | + +MySQL 模式比 SQLite 模式多 7 项,都是只有在真库上才有意义的检查: + +``` +[PASS] MySQL 字符集为 utf8mb4(emoji / 少数民族地名可正常读写) +[PASS] MySQL 枚举列读写正常 +[PASS] MySQL 日期列读写正常 +[PASS] CHECK 约束被数据库真正执行(11 位手机号) +[PASS] CHECK 约束违反被翻译成 422 而不是 500 ← 就是 P0-11 +[PASS] 唯一键冲突被翻译成 409 +[PASS] 真的连不上数据库才返回 500 +``` + +**字符集测试**特意插入了一条 `student_name = "张🙂明·测试"` + `native_place = "内蒙古自治区·锡林郭勒盟"` 的数据再读回来。 +如果库的字符集是 `utf8`(3 字节)而不是 `utf8mb4`(4 字节),emoji 会直接报 `Incorrect string value` 而**整条插入失败**——这是很多中文项目上线后才踩到的坑,所以写成了自动化断言。 + +### 7.3 HTTP 端到端结果(真实服务 + 真实 MySQL) + +``` +health -> {'status': 'ok', 'database': 'up', 'detail': None} +看板 -> 学生 132 / 班级 6 / 就业率 61.4% / 平均月薪 12982.0 +平均分 -> [('软工2402', 74.28), ('计科2402', 73.6), ('人工智能2401', 72.74)] ← 非整数,说明是真平均 +薪资Top3 -> [('王刚', 20540.0, 'OPPO'), ('吕杰', 20026.0, '大疆创新'), ...] +新建 -> sid=134 学号=S20260002 +``` + +写操作也逐项验证过:新增/详情读回(中文不乱码)/ 学号重复 409 / 非法班级 422 / +非法枚举 400 / 非法手机号 400 / 逻辑删除 / 已删除查不到 404 / 恢复 / 班级下有学生拒绝删除 422。 + +### 7.4 前端与真实后端联调 + +起真实后端(`127.0.0.1:8004`)后,前端不再走内置演示数据,直连 MySQL: + +- 左下角显示 **「后端已连接」**(绿色),顶部不再出现演示数据提示条; +- 看板显示 **学生 133 / 班级 6 / 教师顾问 9/12 / 就业率 60.9% / 平均月薪 ¥12,982**,与 HTTP 接口返回完全一致; +- 薪资 Top10 显示的是 MySQL 里的真实记录(王刚·OPPO ¥20,540 等); +- 学生列表 133 条 14 页,中文姓名、籍贯(含"内蒙古·呼和浩特")、枚举状态徽章全部正常渲染。 + +**前后端字段契约完全对齐**,不是靠 mock 对齐的。 + +### 7.5 端到端扫描揪出的漏网 bug(教师列表 500) + +前面那些测试都过了,但**把服务真跑起来、逐个接口点一遍**的时候,`GET /api/v1/teachers` 报了 500: + +``` +pydantic_core._pydantic_core.PydanticSerializationError: + Unable to serialize unknown type: +``` + +**原因**:`dao/teacher_dao.list_teachers` 直接把 SQLAlchemy 的 `Teacher` 对象塞进 +`page_dict(items=...)` 返回了。其他模块(班级 / 顾问 / 学生)在 DAO 里都做了 `ORM → dict` +的转换,教师模块漏了这一步。路由上的 `response_model=dict` 会让 Pydantic v2 去序列化这个 +dict,而它不认识 ORM 对象 → 直接抛异常。 + +**为什么之前的测试没发现**:冒烟测试覆盖了「新增老师」「老师手机号校验」, +**唯独没覆盖「查询教师列表」**。这是很典型的"测了写、没测读"盲区。 + +**修复**:给 `teacher_dao` 补上 `to_dict()`,与其他模块保持一致(顺带把手机号脱敏也放在里面)。 + +**更有价值的修复是测试**:新增了一段「全量接口扫描」——从 OpenAPI 里自动枚举**所有** GET 路由, +逐个带样例参数打一遍,**任何接口返回 5xx 就算失败**。 + +``` +[PASS] 全部 32 个 GET 接口无 5xx +``` + +这样以后不管哪个模块漏了序列化、漏了字段、写错了 SQL,都会在跑测试时立刻暴露, +而不用等人手一个个去点。**"人肉点接口"能发现的问题,一定要变成自动化断言。** + +### 7.6 部署前需要注意的两件事 + +1. **MySQL 版本要 8.0.16 以上。** CHECK 约束在 5.7 上只解析不执行,`utf8mb4_0900_ai_ci` 排序规则也是 8.0 才有的(5.7 用 `utf8mb4_general_ci`)。如果是 5.7,把 `init.sql` 里的排序规则换掉即可,CHECK 约束会退化成"只写在模型里"。 +2. **`classes` / `advisor` 的重名校验是"含已删除"的全局唯一。** 唯一键写成 `(class_name, flag)` + DAO 层显式检查,效果是**删掉「计科2401」之后不能直接新建同名班级,只能把旧的恢复回来**。 + 这是有意的取舍:`(name, flag)` 这种写法最多容纳"1 条正常 + 1 条已删除",无法支撑反复删建。 + 真要做到"仅未删除范围唯一",MySQL 里得用**生成列**: + + ```sql + ALTER TABLE classes + ADD COLUMN active_name VARCHAR(10) + GENERATED ALWAYS AS (IF(flag = 1, class_name, NULL)) STORED, + ADD UNIQUE KEY uq_classes_active_name (active_name); + ``` + + 利用"唯一索引里多个 NULL 互不冲突"的特性,就能既允许保留多条历史删除记录、又保证正常班级不重名。 + 课程作业场景下当前的写法已经够用,所以没有引入;但面试被问到"逻辑删除和唯一索引怎么共存"时,这是标准答案。 + +--- + +## 8. 前端页面 + +### 交付物 + +`frontend/index.html` —— **单文件、零构建、零第三方依赖**(图表是手写的内联 SVG,不依赖任何 CDN)。 + +### 为什么这么选 + +真实项目当然用 Vue/React,但对你现在这个场景,单文件的收益更大: + +- 双击就能打开,老师/组员不用装 `node_modules`; +- 没有 CDN 依赖 → 断网也能演示; +- 它已经被挂进 FastAPI 的 `StaticFiles`,一个进程同时提供前后端(见下面「怎么跑」)。 + +### 功能 + +| 页面 | 内容 | +|---|---| +| **数据看板** | 5 张 KPI 卡(学生总数 / 班级 / 教师顾问 / 已就业 / 平均月薪)+ 学生状态环形图 + 班级男女堆叠柱状图 + 薪资 Top10 + Offer→入职间隔 Top10 | +| **统计分析** | 班级平均分(可升降序)+ 明细表、各班级平均间隔、考试成绩全达标查询(可改分数线)、不及格预警名单(可改次数)、班级男女明细 | +| **学生 / 班级 / 教师 / 顾问 / 考试 / 成绩 / 就业** | 7 个模块的完整 CRUD:多条件筛选、分页、新增/编辑弹窗(前端校验 + 后端错误回显)、删除二次确认、逻辑删除后一键恢复 | + +### 三个关键设计 + +**① 后端直接托管前端页面 —— 只跑 `main.py` 就能看到界面。** + +这一条是后补的,起因很真实:最初前后端是两个独立服务,结果第一次用的人只启动了 `main.py`, +打开 `http://127.0.0.1:8004` 看到的是一段 JSON 加一个 Swagger,**以为"没有前端"**。 +(Swagger 是 FastAPI 自带的后端调试文档,不是业务界面。) + +现在 `main.py` 末尾会把 `frontend/` 目录挂到根路径上: + +```python +FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" + +if FRONTEND_DIR.is_dir(): + app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend") +``` + +于是**一个进程就是一套完整系统**: + +| 地址 | 内容 | +|---|---| +| `http://127.0.0.1:8004/` | 管理界面 | +| `http://127.0.0.1:8004/docs` | Swagger | +| `http://127.0.0.1:8004/api/v1/...` | REST 接口 | +| `http://127.0.0.1:8004/health` | 健康检查 | + +两个容易踩的点,都处理掉了: + +- **`Mount("/")` 是通吃规则,必须最后注册。** Starlette 按注册顺序匹配路由, + 如果这个挂在 `/api/v1` 之前,整个 API 全会被它吃掉。 +- **同源之后 CORS 就不需要了。** 前端在页面里会自动判断:探一下同源的 `/health`, + 能返回带 `database` 字段的 JSON 就说明页面本身就是后端给的,直接用同源接口; + 否则(比如用 `http.server 5500` 单独托管前端)再退回 `http://127.0.0.1:8004/api/v1`。 + 比"猜端口号"可靠 —— `http.server` 那类静态服务对 `/health` 只会回 404。 + +前端目录不存在时会自动跳过挂载(只部署后端的场景),并打印一行日志,不会报错。 + +**② 后端连不上时自动降级到演示数据。** +连不上就自动切到内置的 129 名学生模拟数据集,并弹提示。**页面永远不会是一片"加载失败"**——老师不启动后端也能看到完整效果。后端点右上角「连接」即可切回真实数据。 + +**③ 行内按钮统一事件委托。** +表格重渲染多少次都不会叠加监听器(这个坑在开发过程中真实踩到过)。 + +### 怎么跑 + +**方式一:一键启动(推荐,演示/答辩用)** + +双击 `start_all.bat` —— 启动后端并自动打开浏览器。**没有第二个窗口**,因为前端就是后端提供的。 + +> 脚本拆成了三个文件,是刻意的: +> `start_all.bat`(编排)→ `student_management_system/run_backend.bat`。 +> 因为 `cmd /k "cd /d "路径" && python main.py"` 这种嵌套引号写法在不同 Windows 版本上行为不一致, +> 而 `start "标题" "脚本路径"` 只有一对引号,没有歧义。 +> 三个脚本都保存为 **纯 ASCII + CRLF 换行 + 无 BOM** —— 批处理用 LF 换行在 `if` 块和标签跳转上会出问题。 +> +> `run_backend.bat` 会**在两处查找虚拟环境**:`student_management_system/.venv` 和项目根的 `.venv`。 +> 因为 PyCharm 建 venv 时默认放在项目根,跟脚本的预期位置不一致 —— 这是实测踩到的。 + +**方式二:在 PyCharm / 命令行里手动跑** + +```bash +# 1) 建库(create_all 只建表不建库,漏这步会报 Unknown database) +mysql -u root -p < init.sql + +# 2) 装依赖 +cd student_management_system +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt + +# 3) 配置 +copy .env.example .env # 改里面的 DB_USER / DB_PASSWORD + +# 4) 建表 + 灌演示数据(表结构相对原版有变更,第一次必须重建) +python init_db.py --reset + +# 5) 启动。就这一步,前端和接口都在这个端口上 +python main.py +``` + +然后浏览器打开 **** —— 直接就是管理界面。 + +> **不要双击 `frontend/index.html`。** 用 `file://` 打开时浏览器发的 `Origin` 是 `null`, +> 不在后端 CORS 白名单里,接口会被拦掉,页面只会显示演示数据。 +> 需要单独托管前端时用 `frontend/run_frontend.bat`(端口 5500 已在白名单里), +> 但正常情况**用不着**。 + +跑测试: + +```bash +cd student_management_system +python smoke_test.py # SQLite,54 项,秒级,不需要 MySQL +set SMS_TEST_DB=mysql && python smoke_test.py # 真实 MySQL,61 项 +``` + +> 前端默认从 `5500 / 8000 / 5173` 发起请求,这几个来源已经写进 `.env` 的 `CORS_ORIGINS`。换端口记得同步改,否则浏览器会以 CORS 报错拦住。 +> +> 只想看界面效果、不想起后端也行:直接双击 `frontend/index.html`,它会自动切到内置演示数据。 + +--- + +## 9. 还没做、但真实业务一定会要的(建议下一步) + +按性价比排序: + +1. **认证与授权**(最大缺口)。真实的学生管理系统必须有登录、JWT、角色权限(管理员 / 顾问 / 老师),且"顾问只能看自己名下的学生"这类**数据级权限**比菜单级权限更重要。现在所有接口裸奔。 +2. **操作审计**。`created_at` 有了,但 `created_by / updated_by` 和"谁在什么时候把张三的状态从已就业改回了在读"这种明细还没有。配合问题 1 一起做。 +3. **Excel 批量导入学生**(原始需求里写了"可选扩展",但没实现)。要注意:导入必须做成"先校验全量 → 有问题整批回滚",否则导到一半失败会留下半批脏数据。 +4. **pytest 正式测试**。`smoke_test.py` 是冒烟级别,真实项目要拆成 `tests/test_student_api.py` 这种,配 `conftest.py` 提供测试数据库会话。 +5. **Alembic 数据库迁移**。现在改字段还得 `--reset`,上生产不可接受。 +6. **分层再进一步**:DAO 现在同时承担了"数据访问"和"业务规则校验"(比如"班级下有学生不能删")。真实项目通常再抽一层 `services/`,DAO 只碰数据库。 +7. **`teacher_class` / `teacher_student` 中间表要么用起来,要么删掉**。半成品关联比没有关联更危险。 +8. **`Classes.head_teacher` / `teacher` 改为外键**指向 `teacher.tid`。 + +--- + +## 10. 对照检查清单 + +| 检查项 | 原版 | 现在 | +|---|---|---| +| 技术栈 | FastAPI + MySQL | FastAPI + MySQL(未变更,已在 8.0.27 实测) | +| 路由是否全部可达 | ❌ 2 个接口路径拼接断裂 | ✅ 39 个业务路由全部可达 | +| 统计 SQL 是否正确 | ❌ 班级平均分语义错误 | ✅ 已重写并加回归断言 | +| 逻辑删除是否统一 | ❌ 3 套写法,2 处方向相反 | ✅ 全表 `flag`,1=正常 | +| 已删除数据是否影响统计 | ❌ 已删除成绩仍被计入 | ✅ 全部 JOIN 带 `flag=1` | +| 是否有 N+1 | ❌ 4 个统计接口全是 | ✅ 全部改为聚合查询 | +| 外键/唯一约束是否落到数据库 | ❌ 只在代码里判断 | ✅ 唯一约束 + CHECK 约束,实测生效 | +| 数据库异常是否分类翻译 | ❌ 全部 500 | ✅ 按 MySQL 错误码分 409/422/400/500 | +| 字符集是否支持完整中文 | ⚠️ 未验证 | ✅ utf8mb4,emoji 专项断言通过 | +| 密码是否硬编码 | ❌ `root:123456` | ✅ 环境变量 | +| CORS 是否合法 | ❌ `*` + credentials | ✅ 显式白名单 | +| 是否有全局异常处理 | ❌ 无,堆栈直出 | ✅ 统一错误码 + 中文提示 | +| 是否有分页 | ❌ 三套标准,多数无分页 | ✅ 统一 `page/size` | +| 是否有审计字段 | ❌ 无 | ✅ `created_at` / `updated_at` | +| 是否有健康检查 | ❌ 无 | ✅ `/health`(真实探数据库) | +| 是否有依赖清单 | ❌ 无 `requirements.txt` | ✅ 已补 | +| 是否有建库脚本 | ❌ 无 | ✅ `init.sql` | +| 是否有测试 | ❌ 0 行 | ✅ SQLite 61 项 + MySQL 68 项 + HTTP 24 项 + 全量接口扫描 | +| 是否有前端 | ❌ 无 | ✅ 9 个页面,与真实后端联调通过 | +| 前端怎么访问 | ❌ 不存在 | ✅ **一个进程搞定**:`http://127.0.0.1:8004/` 就是界面,不用再起第二个服务 | +| 数据库异常是否分类翻译 | ❌ 全部 500 | ✅ 按 MySQL 错误码分 409/422/400/500 | +| 启动脚本能否找到解释器 | — | ✅ venv 建在项目根或子目录都能找到 |