diff --git a/api/statistics.py b/api/statistics.py new file mode 100644 index 0000000..be0218e --- /dev/null +++ b/api/statistics.py @@ -0,0 +1,110 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Path, Query +from sqlalchemy.orm import Session + +from dao.wl_statistics_dao import StatisticDao +from database import get_db +from dao import wl_statistics_dao +from scheme.wl_statistics_scheme import (AgeCompareOp, ClassAvgEmpTimeOut, + ClassAvgScoreOut, ClassGenderStatOut, + EmpTopOut, FailCountModel, + ScoreOutLine, SortOrder, EmpTimeOut) +from scheme.wl_student_scheme import StudentOut + +router = APIRouter(prefix='/statistics',tags=["统计分析模块"]) + + +# ---- 2.6.1.1 动态年龄范围查询 ---- +@router.get( + "/students/age", + response_model=list[StudentOut], + summary="动态年龄范围查询", + responses={400: {"description": "查询参数不合法,例如区间查询缺少上界 value2"}}, +) +def query_age_range( + op: AgeCompareOp = Query(AgeCompareOp.gt, description="比较条件:gt/ge/lt/le/eq/between"), + value: int = Query(..., description="年龄阈值"), + value2: int | None = Query(None, description="年龄上界,仅 op=between 时必填"), + db: Session = Depends(get_db), +): + try: + return StatisticDao.search_students_by_age(db, op, value, value2) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +# ---- 2.6.1.2 多维度班级统计 ---- +@router.get( + '/classes/count', + summary="多维度班级统计", + response_model=list[ClassGenderStatOut] +) +def query_classes_count(db: Session = Depends(get_db)): + return StatisticDao.statistic_classes_count(db) + +# 查询每次考试成绩都在输入分数线(如80分)以上的学生的编号、姓名和成绩 +@router.get( + '/scores/out_score_line/{score_line}', + summary='统计每次超分数线的学生', + response_model=list[ScoreOutLine] +) +def query_out_score_line(score_line:float,db:Session=Depends(get_db)): + return StatisticDao.query_score_by_line(db, score_line) + +# 查询有输入指定次数(如两次)以上不及格的学生的姓名、班级和不及格成绩明细 +@router.get( + '/scores/fail_score/{fail_count}', + summary='统计不及格次数超过指定次数的学生信息', + response_model=list[FailCountModel] +) +def query_info_by_fail_count(fail_count:int,db: Session = Depends(get_db)): + return StatisticDao.query_by_fail_count(db,fail_count) + +# 统计每次考试每个班级的平均分,并支持按分数从高到低或从低到高动态排序 +@router.get( + '/scores/get_class_avg_score', + summary='统计每次考试每个班级的平均分,并支持按分数从高到低或从低到高动态排序', + response_model=list[ClassAvgScoreOut] +) +def get_class_avg_score( + sort_order: SortOrder | None = Query( + None, description="排序方向:asc 从低到高,desc 从高到低;不传则按班级+考试序次"), + db: Session = Depends(get_db), +): + return StatisticDao.get_class_avg_score(db, sort_order) + +# 统计就业薪资排名Top N(动态输入N)的学生的姓名、班级、就业时间和就业公司 +@router.get( + '/emp/top_rank/{rank}', + summary='统计就业薪资排名Top N', + response_model=list[EmpTopOut] +) +def get_top_rank( + rank: int = Path(..., gt=0, description="取薪资排名前 N 名"), + db: Session = Depends(get_db), +): + return StatisticDao.get_info_by_rank(db, rank) + +# 统计每个学生的就业时长(计算公式:offer下发时间-就业开放时间) +@router.get( + '/emp/emp_time', + summary='统计每个学生的就业时长', + response_model=list[EmpTimeOut] +) +def get_emp_time(db: Session = Depends(get_db)): + return StatisticDao.get_emp_time(db) + +# 统计每个班级的平均就业时长(仅统计已进入就业阶段且已拿到 offer 的学生) +@router.get( + '/emp/class_avg_emp_time', + summary='统计每个班级的平均就业时长', + response_model=list[ClassAvgEmpTimeOut] +) +def get_class_avg_emp_time( + sort_order: SortOrder | None = Query( + None, description="排序方向:asc 从低到高,desc 从高到低;不传则按班级编号"), + db: Session = Depends(get_db), +): + return StatisticDao.get_class_avg_emp_time(db, sort_order) + + diff --git a/dao/wl_statistics_dao.py b/dao/wl_statistics_dao.py new file mode 100644 index 0000000..04b112f --- /dev/null +++ b/dao/wl_statistics_dao.py @@ -0,0 +1,222 @@ +#统计分析 数据访问层(动态查询 + 聚合) +from os.path import join +from typing import Optional + +from sqlalchemy import and_, case, func +from sqlalchemy.orm import Session + +from model.wl_class_model import Class_ +from model.wl_emp_model import Emp +from model.wl_score_model import Score +from model.wl_student_model import Student +from scheme.wl_statistics_scheme import AgeCompareOp, SortOrder + +# stu_gender 字段的实际取值,确认数据库里存的到底是什么后改这两行即可 +MALE_VALUE = "男" +FEMALE_VALUE = "女" + + + + +# ---- 2.6.1.1 动态年龄范围查询 ---- +# 比较条件 -> SQLAlchemy 表达式工厂,动态拼接 where 条件 +_AGE_OP_FACTORY = { + AgeCompareOp.gt: lambda v1, v2: Student.stu_age > v1, + AgeCompareOp.ge: lambda v1, v2: Student.stu_age >= v1, + AgeCompareOp.lt: lambda v1, v2: Student.stu_age < v1, + AgeCompareOp.le: lambda v1, v2: Student.stu_age <= v1, + AgeCompareOp.eq: lambda v1, v2: Student.stu_age == v1, + AgeCompareOp.between: lambda v1, v2: Student.stu_age.between(v1, v2), +} + +class StatisticDao: + + @staticmethod + def search_students_by_age(db: Session, op: AgeCompareOp, + value: int, value2: Optional[int] = None): + if op == AgeCompareOp.between: + if value2 is None: + raise ValueError("区间查询(between)需要同时提供上界 value2") + if value > value2: + raise ValueError("区间查询的下界不能大于上界") + + condition = _AGE_OP_FACTORY[op](value, value2) + return db.query(Student) \ + .filter(Student.is_deleted == 0, condition) \ + .order_by(Student.stu_age) \ + .all() + @staticmethod + def statistic_classes_count(db: Session): + """每个班级的总人数 + 按性别细分的分布(含 0 人的空班级)""" + result=[] + for c in db.query(Class_).filter(Class_.is_deleted==0).all(): + students=[s for s in c.students if s.is_deleted == 0] + result.append({ + "class_id":c.class_id, + "total":len(students), + "male_count":sum(1 for s in students if s.stu_gender==MALE_VALUE), + "female_count":sum(1 for s in students if s.stu_gender==FEMALE_VALUE) + }) + return result + # 另一个版本的写法,效率更高 + # male = func.sum(case((Student.stu_gender == MALE_VALUE, 1), else_=0)) + # female = func.sum(case((Student.stu_gender == FEMALE_VALUE, 1), else_=0)) + # + # return db.query( + # WlClass.class_id.label("class_id"), + # func.count(Student.stu_id).label("total"), + # male.label("male_count"), + # female.label("female_count"), + # ).select_from(WlClass) \ + # .outerjoin( + # Student, + # # 逻辑删除的过滤条件必须写在 ON 里,写进 where 会把空班级整行滤掉 + # and_(Student.class_id == WlClass.class_id, Student.is_deleted == 0), + # ) \ + # .filter(WlClass.is_deleted == 0) \ + # .group_by(WlClass.class_id) \ + # .order_by(WlClass.class_id) \ + # .all() + + @staticmethod + def query_score_by_line(db:Session,score_line:float): + result=[] + for s in db.query(Student).filter(Student.is_deleted==0).all(): + if all(sc.score>score_line for sc in s.scores): + result.append({ + "stu_no":s.stu_no, + "stu_name":s.stu_name, + "scores": [{"exam_order":i.exam_order,"score":i.score} for i in s.scores] + }) + return result + + @staticmethod + def query_by_fail_count(db:Session,fail_count:int): + result=[] + students=db.query(Student).filter(Student.is_deleted==0).all() + for stu in students: + if len([sc for sc in stu.scores if sc.score<60])>fail_count: + result.append({ + "stu_name":stu.stu_name, + "class_id":stu.class_id, + "scores":[{"exam_order":i.exam_order,"score":i.score} for i in stu.scores] + }) + return result + + @staticmethod + def get_class_avg_score(db: Session, sort_order: SortOrder | None = None): + """每次考试每个班级的平均分,支持按平均分动态升/降序""" + avg_score = func.avg(Score.score).label("avg_score") + + q = db.query( + Student.class_id.label("class_id"), + Score.exam_order.label("exam_order"), + avg_score, + ).select_from(Score) \ + .join(Score.student) \ + .filter(Student.is_deleted == 0, Student.class_id.isnot(None)) \ + .group_by(Student.class_id, Score.exam_order) + + # 动态排序:传了方向就按平均分排,没传则按班级 + 考试序次排 + if sort_order == SortOrder.desc: + q = q.order_by(avg_score.desc()) + elif sort_order == SortOrder.asc: + q = q.order_by(avg_score.asc()) + else: + q = q.order_by(Student.class_id, Score.exam_order) + + return q.all() + + @staticmethod + def get_info_by_rank(db:Session,rank:int): + q=(db.query( + Student.stu_name, + Student.class_id, + Emp.offer_time, + Emp.company_name + ).select_from(Student) \ + .join(Student.emp)) \ + .filter(Student.is_deleted==0) \ + .order_by(Emp.salary.desc())\ + .limit(rank) + + return q.all() + @staticmethod + def get_emp_time(db:Session): + """每个学生的就业时长(天)= offer下发时间 - 就业开放时间 + """ + emp_total_time = func.coalesce( + # 未就业学生左外连接后 Emp 列为 NULL,DATEDIFF 也返回 NULL,统一兜成 0 + func.datediff(Emp.offer_time, Emp.emp_open_time), + 0, + ).label('emp_total_time') + + q=db.query( + Student.stu_name, + emp_total_time, + ).select_from(Student) \ + .outerjoin(Student.emp) \ + .filter(Student.is_deleted==0) + + return q.all() + + @staticmethod + def get_class_avg_emp_time(db:Session, sort_order: SortOrder | None = None): + """每个班级的平均就业时长(天)= offer下发时间 - 就业开放时间 + + 口径: + - 以班级表为主表左外连接,保证没有任何就业学生的班级也出现在结果里 + - 只统计已进入就业阶段(emp_open_time 非空)且已拿到 offer(offer_time 非空)的学生 + - 该班可统计的人数为 0 时 AVG 返回 NULL,出参里 avg_emp_time 给 None,即"无就业学生" + """ + emp_time = func.datediff(Emp.offer_time, Emp.emp_open_time) + # AVG 和 COUNT(表达式) 都会自动忽略 NULL,所以没 offer 时间的学生不参与分子也不占分母 + avg_emp_time = func.avg(emp_time).label("avg_emp_time") + # 用 COUNT(表达式) 而不是 COUNT(*),它正好是平均值的分母 + emp_stu_count = func.count(emp_time).label("emp_stu_count") + + q = db.query( + Class_.class_id.label("class_id"), + avg_emp_time, + emp_stu_count, + ).select_from(Class_) \ + .outerjoin( + Student, + # 逻辑删除的过滤条件必须写在 ON 里,写进 where 会把空班级整行滤掉 + and_(Student.class_id == Class_.class_id, Student.is_deleted == 0), + ) \ + .outerjoin( + Emp, + and_( + Emp.stu_id == Student.stu_id, + Emp.emp_open_time.isnot(None), + Emp.offer_time.isnot(None), + ), + ) \ + .group_by(Class_.class_id) + + # 动态排序逻辑与 get_class_avg_score 保持一致 + if sort_order == SortOrder.desc: + # MySQL 里 DESC 时 NULL 排最后,"无就业学生"的班级自然落到末尾 + q = q.order_by(avg_emp_time.desc()) + elif sort_order == SortOrder.asc: + q = q.order_by(avg_emp_time.asc()) + else: + q = q.order_by(Class_.class_id) + + return [ + { + "class_id": r.class_id, + # AVG 出来是 Decimal,转 float 并按天保留 1 位小数;None 表示该班无就业学生 + "avg_emp_time": round(float(r.avg_emp_time), 1) + if r.avg_emp_time is not None else None, + "emp_stu_count": r.emp_stu_count, + } + for r in q.all() + ] + + + + + + diff --git a/database.py b/database.py index c7bea1f..77f8c17 100644 --- a/database.py +++ b/database.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import sessionmaker,declarative_base # 1. 配置 MySQL 数据库连接 URL # 格式:mysql+pymysql://用户名:密码@主机:端口/数据库名?编码 # 请将下面的 'root', '123456', 'localhost', '3306', 'test_db' 替换为你自己的实际信息 -SQLALCHEMY_DATABASE_URL = "mysql+pymysql://root:123456@localhost:3306/test_db" +SQLALCHEMY_DATABASE_URL = "mysql+pymysql://root:123456@localhost:3306/wl_student_manager_system" # 2. 创建数据库引擎 # - pool_pre_ping=True 表示每次从连接池取出连接前先 ping 一下,防止使用已断开的连接 diff --git a/main.py b/main.py index a75bc39..56965fe 100644 --- a/main.py +++ b/main.py @@ -1,11 +1,11 @@ #项目初始化入口 from fastapi import FastAPI -# 周学灵、熊浩钦、圣国伟、刘盼的路由 -from api import wl_student_api,wl_class_api,wl_score_api,wl_teacher_api +# 周学灵、熊浩钦、圣国伟、刘盼、彭少的路由 +from api import wl_student_api,wl_class_api,wl_score_api,wl_teacher_api,statistics from database import Base, engine from model import wl_student_model,wl_class_model,wl_advisor_model,wl_score_model,wl_teacher_model - +Base.metadata.drop_all(engine) Base.metadata.create_all(bind=engine) app = FastAPI(title="沃林学生管理系统") @@ -13,6 +13,9 @@ app.include_router(wl_student_api.router) app.include_router(wl_class_api.router) app.include_router(wl_score_api.router) app.include_router(wl_teacher_api.router) +app.include_router(statistics.router) + + if __name__ == "__main__": import uvicorn diff --git a/model/wl_class_model.py b/model/wl_class_model.py index 65d1600..87221db 100644 --- a/model/wl_class_model.py +++ b/model/wl_class_model.py @@ -1,4 +1,6 @@ from sqlalchemy import Column, Integer, Date, String +from sqlalchemy.orm import relationship + from database import Base class Class_(Base): @@ -6,5 +8,7 @@ class Class_(Base): class_id = Column(Integer, primary_key=True, autoincrement=True, comment="班级编号") start_time = Column(Date, comment="开课时间") + + students=relationship("Student", back_populates="class_") # 逻辑删除标记(0代表正常,1代表已删除) is_deleted = Column(Integer, default=0, comment="逻辑删除标记") \ No newline at end of file diff --git a/model/wl_emp_model.py b/model/wl_emp_model.py index f68f679..82f8fd3 100644 --- a/model/wl_emp_model.py +++ b/model/wl_emp_model.py @@ -1,18 +1,20 @@ from sqlalchemy import * from sqlalchemy.orm import * +from database import Base + class Emp(Base): __tablename__ = "wl_emp" - stu_id = Column(Integer, primary_key=True, autoincrement=True) - + stu_id = Column(Integer,ForeignKey('wl_student.stu_id'), primary_key=True, autoincrement=True) emp_open_time = Column(DateTime, default="2026-11-30 18:18:18") offer_time = Column(DateTime, default=func.now()) company_name = Column(String(100), nullable=False) salary = Column(Integer, nullable=False, default=15000) - student = relationship("wl_student", back_populates="wl_emp") + # Student.emp 用的是 back_populates="student",这个反向属性必须保留,否则 mapper 配置失败 + student = relationship("Student", back_populates="emp") def __repr__(self): pass diff --git a/model/wl_score_model.py b/model/wl_score_model.py index bebf403..1ea285f 100644 --- a/model/wl_score_model.py +++ b/model/wl_score_model.py @@ -1,11 +1,15 @@ -from sqlalchemy import Column, Integer, Float +from sqlalchemy import Column, Integer, Float, ForeignKey +from sqlalchemy.orm import relationship + from database import Base class Score(Base): __tablename__ = "wl_score" - stu_id = Column(Integer, primary_key=True, comment="学生编号") + stu_id = Column(Integer, ForeignKey('wl_student.stu_id'),primary_key=True, comment="学生编号") exam_order = Column(Integer, primary_key=True, comment="考核序次") score = Column(Float, nullable=False, comment="成绩") + student=relationship("Student",back_populates="scores") + diff --git a/model/wl_student_model.py b/model/wl_student_model.py index 3066dd1..8789196 100644 --- a/model/wl_student_model.py +++ b/model/wl_student_model.py @@ -22,7 +22,9 @@ class Student(Base): stu_age = Column(Integer, nullable=True, comment='年龄') stu_gender = Column(String(8), nullable=True, comment='性别') - # class_ = relationship("Class", back_populates="students") + class_ = relationship("Class_", back_populates="students") + scores=relationship("Score",back_populates="student") + emp=relationship("Emp",back_populates="student") # advisor = relationship("Advisor", back_populates="students") is_deleted = Column(Integer, default=0, comment='逻辑删除: 0正常 1删除') diff --git a/scheme/wl_statistics_scheme.py b/scheme/wl_statistics_scheme.py new file mode 100644 index 0000000..17f6a36 --- /dev/null +++ b/scheme/wl_statistics_scheme.py @@ -0,0 +1,80 @@ +#统计分析模块 入参/出参定义 +from datetime import datetime +from enum import Enum +from typing import Optional, List + +from pydantic import BaseModel, ConfigDict + +from model.wl_score_model import Score + + +# ---- 入参:动态查询的比较条件 ---- +class AgeCompareOp(str, Enum): + """年龄比较条件:大于/大于等于/小于/小于等于/等于/区间""" + gt = "gt" + ge = "ge" + lt = "lt" + le = "le" + eq = "eq" + between = "between" + + +class SortOrder(str, Enum): + """排序方向:升序/降序""" + asc = "asc" + desc = "desc" + + +# ---- 出参:后续统计接口的聚合结果 DTO 放这里 ---- +class ClassGenderStatOut(BaseModel): + """班级多维度统计:总人数 + 男女分布""" + class_id: Optional[int] = None + total: int = 0 + male_count: int = 0 + female_count: int = 0 + +class ScoreShow(BaseModel): + exam_order:int=0 + score:float=0 + +class ScoreOutLine(BaseModel): + stu_no:str='' + stu_name:str='' + scores:List[ScoreShow]=[] + +class FailCountModel(BaseModel): + stu_name:str='' + class_id:int=0 + scores:List[ScoreShow]=[] + +class ClassAvgScoreOut(BaseModel): + """每次考试每个班级的平均分""" + # DAO 返回的是 SQLAlchemy Row 对象,必须开 from_attributes 才能按属性名读取 + model_config = ConfigDict(from_attributes=True) + + class_id:Optional[int]=None + exam_order:Optional[int]=None + avg_score:Optional[float]=None + +class EmpTopOut(BaseModel): + """就业薪资排名 Top N""" + # 同样是 Row 对象,必须开 from_attributes + model_config = ConfigDict(from_attributes=True) + + stu_name:Optional[str]=None + class_id:Optional[int]=None + offer_time:Optional[datetime]=None + company_name:Optional[str]=None + +class ClassAvgEmpTimeOut(BaseModel): + """每个班级的平均就业时长""" + class_id: Optional[int] = None + # 单位:天,保留 1 位小数;为 None 表示该班无就业学生,前端展示"无就业学生" + avg_emp_time: Optional[float] = None + # 参与统计的人数(平均值的分母),为 0 同样表示该班无就业学生 + emp_stu_count: int = 0 + +class EmpTimeOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + stu_name:str='' + emp_total_time:int=0 # 单位为天