统计分析模块

This commit is contained in:
2026-09-22 19:00:51 +08:00
parent 7ec50c3aea
commit 0a1a7eec16
11 changed files with 577 additions and 1 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
# xs_system
# students-system
学生管理系统
View File
+105
View File
@@ -0,0 +1,105 @@
from fastapi import APIRouter, Depends, Query
from database import get_db
from dao.statistics_dao import StatisticsDao
from schema.statistics_schema import ResponseModel,ResponseModel1,ResponseModel2,ResponseModel3,ResponseModel4,ResponseModel5,ResponseModel6
from math import ceil
BasicInformationAPI = APIRouter(tags=['统计分析'])
@BasicInformationAPI.get('/basic-information',summary='查询年龄区间内的学员信息')
def get_students_by_age_range_api(
n:int=Query(..., description='页码,从1开始', ge=1),
m:int=Query(..., description='每页条数', ge=1),
min_age: int = Query(..., description='最小年龄', ge=0),
max_age: int = Query(..., description='最大年龄', le=100),
db = Depends(get_db)):
if min_age > max_age:
return {f'code=400,message=最小年龄不能大于最大年龄'}
req,total = StatisticsDao.get_students_by_age_range_dao(n,m,min_age, max_age,db)
return ResponseModel(code=200,
message='查询成功',
total=total,
total_pages=ceil(total/m) if total > 0 else 0,
data=req)
@BasicInformationAPI.get('/basic-information/{class_id}',summary='统计每个班级的⼈数以及男⽣/⼥⽣的⼈数')
def get_students_by_class_id_api(class_id:str,
db = Depends(get_db)):
total_count , male_count , female_count = StatisticsDao.get_students_by_class_id_dao(class_id,db)
return {f'code:200,message=查询成功, 全班总人数:{total_count}, 班级男生人数:{male_count}, 班级女生人数:{female_count}'}
@BasicInformationAPI.get('/scores',summary='在某个分数段的学生')
def get_students_by_score_api(
n:int=Query(..., description='页码,从1开始', ge=1),
m:int=Query(..., description='每页条数', ge=1),
score:float=Query(ge=0,le=100),
db = Depends(get_db)):
if score < 0 or score > 100:
return {f'code=400,message=分数需要在0~100!'}
req,total = StatisticsDao.get_students_by_score_dao(n,m,score,db)
return ResponseModel1(code=200,
message='查询成功',
total=total,
total_pages=ceil(total/m) if total > 0 else 0,
data=req)
@BasicInformationAPI.get('/scores_no_pass',summary='查询有n门成绩不合格的学生')
def get_students_by_no_pass_api(
n:int=Query(..., description='页码,从1开始', ge=1),
m:int=Query(..., description='每页条数', ge=1),
fail_count:int=Query(..., description='不及格门数', ge=1),
db = Depends(get_db)):
req,total = StatisticsDao.get_student_by_no_pass_dao(n,m,fail_count,db)
return ResponseModel2(code=200,
message='查询成功',
total=total,
total_pages=ceil(total/m) if total > 0 else 0,
data=req)
@BasicInformationAPI.get('/scores_avg', summary='统计每次考试每个班级的平均分(从高到低排序)')
def get_class_exam_avg_api(
n:int=Query(..., description='页码,从1开始', ge=1),
m:int=Query(..., description='每页条数', ge=1),
db = Depends(get_db)):
req,total = StatisticsDao.get_class_exam_avg_score_dao(n,m,db)
return ResponseModel3(code=200,
message='查询成功',
total=total,
total_pages=ceil(total/m) if total > 0 else 0,
data=req)
@BasicInformationAPI.get('/salary_top', summary='查询就业薪资最高的前五名学生')
def get_salary_top_api(
m:int=Query(5, description='查询前m名', ge=1),
db = Depends(get_db)):
req = StatisticsDao.get_salary_top_dao(m,db)
return ResponseModel4(code=200,
message='查询成功',
data=req)
@BasicInformationAPI.get('/time_size', summary='统计每个学生的就业时长(offer下发时间-就业开放时间)')
def get_time_size_api(
n:int=Query(..., description='页码,从1开始', ge=1),
m:int=Query(..., description='每页条数', ge=1),
db = Depends(get_db)):
req,total = StatisticsDao.get_time_size_dao(n,m,db)
return ResponseModel5(code=200,
message='查询成功',
total=total,
total_pages=ceil(total/m) if total > 0 else 0,
data=req)
@BasicInformationAPI.get('/class_avg_time_size', summary='统计每个班级的平均就业时长(只统计进入就业阶段的学生)')
def get_class_avg_time_size_api(
n:int=Query(..., description='页码,从1开始', ge=1),
m:int=Query(..., description='每页条数', ge=1),
db = Depends(get_db)):
req,total = StatisticsDao.get_class_avg_time_size_dao(n,m,db)
return ResponseModel6(code=200,
message='查询成功',
total=total,
total_pages=ceil(total/m) if total > 0 else 0,
data=req)
View File
+213
View File
@@ -0,0 +1,213 @@
from model.statistics_model import StudentInfo, StudentScore, ClassInfo, EmploymentInfo
from datetime import date
from dateutil.relativedelta import relativedelta
from sqlalchemy import func # SQL 内置函数生成器(MIN/MAX/COUNT 等)
class StatisticsDao:
@staticmethod
def get_students_by_age_range_dao(n:int,m:int,min_age: int, max_age: int,db):
# 年龄 → 出生日期区间(数据库存 birthday,没有 age 字段)
today = date.today()
earliest_birthday = today - relativedelta(years=max_age) # 最大年龄 → 最早出生
latest_birthday = today - relativedelta(years=min_age) # 最小年龄 → 最晚出生
try:
q = db.query(StudentInfo).\
filter(StudentInfo.is_deleted == '0').\
filter(StudentInfo.birthday >= earliest_birthday).\
filter(StudentInfo.birthday <= latest_birthday)
total = q.count() # 总条数
req = q.offset((n - 1) * m).limit(m).all() # 分页
except Exception as e:
db.rollback()
raise e
else:
return req,total
@staticmethod
def get_students_by_class_id_dao(class_id:str,db):
try:
q = db.query(StudentInfo).filter(StudentInfo.class_id == class_id).\
filter(StudentInfo.is_deleted == '0')
total_count = q.count() # 总人数
male_count = q.filter(StudentInfo.gender == '男').count()
female_count = q.filter(StudentInfo.gender == '女').count()
except Exception as e:
db.rollback()
raise e
else:
return total_count, male_count , female_count
@staticmethod
def get_students_by_score_dao(n:int,m:int,score:float,db):
# GROUP BY 每个学生一行,HAVING MIN(score) > 阈值 → 所有成绩都过线
# SELECT MIN(score) AS score → 返回该学生最低分(schema StudentInfo2 对应字段)
try:
q = (db.query(
StudentInfo.student_name,
StudentInfo.student_id,
func.min(StudentScore.score).label('score') # 起别名,后面 i.score 访问
).join(StudentScore, StudentInfo.student_id == StudentScore.student_id)
.filter(StudentInfo.is_deleted == '0') # 逻辑删除:学生表
.filter(StudentScore.is_deleted == '0') # 逻辑删除:成绩表
.group_by(StudentInfo.student_id, StudentInfo.student_name) # only_full_group_by 要求
.having(func.min(StudentScore.score) > score)) # 组最低分 > 阈值 = 全过线
total = q.count()
req = q.offset((n - 1) * m).limit(m).all()
except Exception as e:
db.rollback()
raise e
else:
# Row 对象手动转字典,字段对齐 schema StudentInfo2
return [{"student_name":i.student_name,
'student_id':i.student_id,
"score":i.score} for i in req],total
@staticmethod
def get_student_by_no_pass_dao(n:int,m:int,fail_count:int,db):
try:
q = (db.query(
StudentInfo.student_id,
StudentInfo.student_name,
func.count(StudentScore.score_id).label('fail_count')
).join(StudentScore, StudentInfo.student_id == StudentScore.student_id)
.filter(StudentInfo.is_deleted == '0')
.filter(StudentScore.is_deleted == '0')
.filter(StudentScore.is_pass == '0')
.group_by(StudentInfo.student_id, StudentInfo.student_name)
.having(func.count(StudentScore.score_id) >= fail_count))
total = q.count()
req = q.offset((n - 1) * m).limit(m).all()
except Exception as e:
db.rollback()
raise e
else:
return [{"student_id": i.student_id,
"student_name": i.student_name,
"fail_count": i.fail_count} for i in req], total
@staticmethod
def get_class_exam_avg_score_dao(n:int,m:int,db):
# 统计每次考试每个班级的平均分,按平均分从高到低排序
# 三表联查:student_score →(student_id)→ student_info →(class_id)→ class_info
# 分组维度:exam_type + exam_date + course_id + class_id(一场考试×一个班级)
# 聚合:AVG(score) 排序:DESC
try:
q = (db.query(
StudentScore.course_id, # 课程编号(不同科目)
StudentInfo.class_id, # 班级编号,来自学生表
ClassInfo.class_name, # 班级名称,来自班级表
func.avg(func.ifnull(StudentScore.score, 0)).label('avg_score') # 每个学生缺考分按0算,再求班级平均分
).join(StudentInfo, StudentScore.student_id == StudentInfo.student_id) # 成绩→学生:取 class_id
.join(ClassInfo, StudentInfo.class_id == ClassInfo.class_id) # 学生→班级:取 class_name
.filter(StudentInfo.is_deleted == '0') # 过滤已删除学生
.filter(StudentScore.is_deleted == '0') # 过滤已删除成绩
.filter(ClassInfo.is_deleted == '0') # 过滤已停用班级
.group_by(StudentScore.course_id,
StudentInfo.class_id, # 每个班级单独成组
ClassInfo.class_name) # MySQL only_full_group_by 要求
.order_by(func.avg(func.ifnull(StudentScore.score,0)).desc())) # 平均分降序(从高到低)
total = q.count() # 分组后的总组合数
req = q.offset((n - 1) * m).limit(m).all() # 分页:从 (n-1)*m 开始取 m 条
except Exception as e:
db.rollback()
raise e
else:
# Row 对象转 dict,avg_score 保留两位小数对齐前端展示
return [{"course_id": i.course_id,
"class_id": i.class_id,
"class_name": i.class_name,
"avg_score": round(float(i.avg_score), 2)} for i in req], total
@staticmethod
def get_salary_top_dao(m:int,db): # 统计就业薪资最高的前五名学生的姓名,班级和就业时间,就业公司
try:
# 子查询:获取每个学生的最高薪资
sq = (db.query(
EmploymentInfo.student_id,
func.max(EmploymentInfo.salary).label('max_salary')
).filter(EmploymentInfo.is_deleted == '0')
.group_by(EmploymentInfo.student_id)
.order_by(func.max(EmploymentInfo.salary).desc())
.limit(m)
.subquery())
# 主查询:关联学生表、班级表和就业信息表
q = (db.query(
StudentInfo.student_name,
ClassInfo.class_name,
EmploymentInfo.part_time,
EmploymentInfo.company_name,
EmploymentInfo.salary
).join(EmploymentInfo, StudentInfo.student_id == EmploymentInfo.student_id)
.join(ClassInfo, StudentInfo.class_id == ClassInfo.class_id)
.join(sq,
(EmploymentInfo.student_id == sq.c.student_id) &
(EmploymentInfo.salary == sq.c.max_salary))
.filter(StudentInfo.is_deleted == '0')
.filter(ClassInfo.is_deleted == '0')
.filter(EmploymentInfo.is_deleted == '0'))
req = q.all()
except Exception as e:
db.rollback()
raise e
else:
return [{"student_name": i.student_name,
"class_name": i.class_name,
"part_time": i.part_time,
"company_name": i.company_name,
"salary": i.salary} for i in req]
@staticmethod
def get_time_size_dao(n:int,m:int,db): # 统计每个学生的就业时长(offer下发时间-就业开放时间)
try:
# 查询学生姓名、就业信息,并计算就业时长(天数)
q = (db.query(
StudentInfo.student_id,
StudentInfo.student_name,
EmploymentInfo.offer_date,
EmploymentInfo.resume_open_date,
func.datediff(EmploymentInfo.offer_date, EmploymentInfo.resume_open_date).label('time_size')
).join(EmploymentInfo, StudentInfo.student_id == EmploymentInfo.student_id)
.filter(StudentInfo.is_deleted == '0')
.filter(EmploymentInfo.is_deleted == '0')
.filter(EmploymentInfo.resume_open_date.isnot(None))) # 确保开放简历时间不为空
total = q.count()
req = q.offset((n - 1) * m).limit(m).all()
except Exception as e:
db.rollback()
raise e
else:
return [{"student_id": i.student_id,
"student_name": i.student_name,
"offer_date": i.offer_date,
"resume_open_date": i.resume_open_date,
"time_size": i.time_size} for i in req], total
@staticmethod
def get_class_avg_time_size_dao(n:int,m:int,db):
# 统计每个班级的平均就业时长(只统计进入就业阶段的学生)
try:
# 查询班级名称和平均就业时长
q = (db.query(
ClassInfo.class_id,
ClassInfo.class_name,
func.avg(func.datediff(EmploymentInfo.offer_date, EmploymentInfo.resume_open_date)).label('avg_time_size')
).join(StudentInfo, ClassInfo.class_id == StudentInfo.class_id)
.join(EmploymentInfo, StudentInfo.student_id == EmploymentInfo.student_id)
.filter(ClassInfo.is_deleted == '0')
.filter(StudentInfo.is_deleted == '0')
.filter(EmploymentInfo.is_deleted == '0')
.filter(EmploymentInfo.resume_open_date.isnot(None)) # 只统计有就业开放时间的学生
.group_by(ClassInfo.class_id, ClassInfo.class_name))
total = q.count()
req = q.offset((n - 1) * m).limit(m).all()
except Exception as e:
db.rollback()
raise e
else:
return [{"class_id": i.class_id,
"class_name": i.class_name,
"avg_time_size": round(float(i.avg_time_size), 2)} for i in req], total
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import *
from sqlalchemy.orm import declarative_base,sessionmaker
db_url = "mysql+pymysql://root:123456@127.0.0.1:3306/student?charset=utf8mb4" #创建连接对象
engine = create_engine(db_url) #与数据库进行连接
Base = declarative_base() # 执行函数,返回一个基类
Session = sessionmaker(bind=engine
,autoflush=False
,autocommit = False
)
def get_db():
db = Session()
try:
yield db
finally:
db.close()
+15
View File
@@ -0,0 +1,15 @@
from fastapi import FastAPI
from database import engine, Base
from api.statistics_api import BasicInformationAPI
from model import statistics_model #不可以删
Base.metadata.create_all(engine) #创建所有的表
app = FastAPI()
app.include_router(BasicInformationAPI)
if __name__ == '__main__':
import uvicorn
uvicorn.run("main:app", host='0.0.0.0', port=12345)
View File
+118
View File
@@ -0,0 +1,118 @@
from sqlalchemy import *
from database import DATETIME,Base,Column,Integer,String
class StudentInfo(Base):
"""学生基本信息表"""
__tablename__ = 'student_info'
student_id = Column(String(20), primary_key=True, comment='学生id')
student_name = Column(String(50), nullable=False, comment='学生姓名')
gender = Column(Enum('男', '女'), nullable=False, comment='性别')
id_card = Column(String(18), nullable=False, comment='身份证号')
birthday = Column(Date, nullable=False, comment='出生日期')
ethnicity = Column(String(20), nullable=False, comment='民族')
region_id = Column(String(20), ForeignKey('region.regio_id'),nullable=False, comment='籍贯编码,连接地区表')
phone = Column(String(11), nullable=False, comment='手机号')
major = Column(String(20), nullable=False, comment='就读专业')
class_id = Column(String(20), nullable=False, comment='班级,连接班级表')
enrollment_date = Column(Date, nullable=False, comment='入学日期')
graduation_date = Column(Date, nullable=False, comment='毕业日期')
student_status = Column(Enum('在读', '休学', '退学', '毕业'), nullable=False, comment='学籍状态')
education_level = Column(Enum('大专', '本科', '硕士研究生', '博士研究生'), nullable=False, comment='学历')
is_deleted = Column(Enum('0', '1'), nullable=False, comment='逻辑删除,0=未删除, 1=已删除')
create_time = Column(Date, nullable=False, comment='创建时间')
update_time = Column(Date, nullable=False, comment='更新时间')
class Region(Base):
"""地区维度表"""
__tablename__ = 'region'
regio_id = Column(String(50), primary_key=True, comment='地区编码')
province = Column(String(50), nullable=False, comment='省/直辖市')
city = Column(String(50), nullable=False, comment='市')
district = Column(String(50), nullable=False, comment='区,县')
is_deleted = Column(Enum('0', '1'), nullable=False, comment='逻辑删除:0=未删除, 1=已删除')
create_time = Column(DATETIME, nullable=False, comment='创建时间')
update_time = Column(DATETIME, nullable=False, comment='更新时间')
class EmploymentInfo(Base):
"""学生就业信息表"""
__tablename__ = 'employment_info'
emp_id = Column(String(50), primary_key=True, comment='就业记录ID')
student_id = Column(String(20), ForeignKey('student_info.student_id'), nullable=False,
comment='学号,连接学生表')
regio_id = Column(String(50), ForeignKey('region.regio_id'), nullable=False, comment='就业地区编码,连接地区表')
job_name = Column(String(50), nullable=False, comment='就业岗位名称')
company_name = Column(String(50), nullable=False, comment='就业公司名称')
salary = Column(Float, nullable=False, comment='就业月薪(元)')
offer_date = Column(Date, nullable=False, comment='offer下发/签约时间')
part_time = Column(Date, nullable=False, comment='就业时间')
resume_open_date = Column(Date, nullable=True, comment='开放简历时间')
employment_status = Column(Enum('1', '2', '3', '4'), nullable=False,
comment='就业状态:1=未就业, 2,=已就业,3=升学, 4=灵活就业')
is_deleted = Column(Enum('0', '1'), nullable=False, comment='逻辑删除:0=未删除, 1=已删除')
create_time = Column(DATETIME, nullable=False, comment='创建时间')
update_time = Column(DATETIME, nullable=False, comment='更新时间')
class StudentScore(Base):
"""学生考核成绩表"""
__tablename__ = 'student_score'
score_id = Column(Integer, primary_key=True, autoincrement=True, comment='成绩记录自增主键')
student_id = Column(String(20), ForeignKey('student_info.student_id'),nullable=False,
comment='学号,连接学生表')
course_id = Column(String(20), ForeignKey('course_info.course_id'),nullable=False, comment='课程编号,连接课程表')
exam_type = Column(Enum('期中', '期末', '月考', '周考', '模拟考'), nullable=False,
comment='考试类型:1=期中, 2=期末, 3=月考, 4=周考,5=模拟考')
score = Column(Float, nullable=True, comment='考试分数0~100,缺考为NULL')
score_level = Column(String(10), nullable=False, comment='成绩等级')
is_pass = Column(Enum('0', '1'), nullable=False, comment='是否及格:1=及格, 0=不及格(可由score>=60判断)')
exam_date = Column(Date, nullable=False, comment='考试日期')
exam_category = Column(Enum('首考', '重考', '补考'), nullable=False, comment='考试分类:1=首考, 2=补考, 3=重考')
is_deleted = Column(Enum('0', '1'), nullable=False, comment='逻辑删除:0=未删除, 1=已删除')
create_time = Column(DATETIME, nullable=False, comment='创建时间')
update_time = Column(DATETIME, nullable=False, comment='更新时间')
class ClassInfo(Base):
"""班级信息表"""
__tablename__ = 'class_info'
class_id = Column(String(50), primary_key=True, comment='班级业务编码')
class_name = Column(String(50), nullable=False, comment='班级名称')
grade_year = Column(Date, nullable=False, comment='入学年级')
status = Column(Enum('1', '2', '3'), nullable=False, comment='班级状态:1=在读 2=已毕业 3=已停用')
tags = Column(String(100), nullable=False, comment='存储多维标签,例如:["重点班", "科技特长", "2026届"]')
is_deleted = Column(Enum('0', '1'), nullable=False, comment='逻辑删除:0=未删除, 1=已删除')
create_time = Column(DATETIME, nullable=False, comment='创建时间')
update_time = Column(DATETIME, nullable=False, comment='更新时间')
class TeacherInfo(Base):
"""教师基本信息表"""
__tablename__ = 'teacher_info'
teacher_id = Column(String(20), primary_key=True, comment='教师编号')
teacher_name = Column(String(20), nullable=False, comment='教师姓名')
gender = Column(Enum('男', '女'), nullable=False, comment='性别')
birthday = Column(Date, nullable=False, comment='出生日期')
region_id = Column(String(50), ForeignKey('region.regio_id'), nullable=False, comment='籍贯地区编码')
email = Column(String(50), nullable=False, comment='邮箱')
phone = Column(String(11), nullable=False, comment='手机号码')
graduation_school = Column(String(50), nullable=False, comment='毕业学校')
teacher_rank = Column(Enum('1', '2', '3', '4'), nullable=False, comment='职称:1=初级, 2=中级, 3=高级, 4=特级')
is_deleted = Column(Enum('0', '1'), nullable=False, comment='逻辑删除:0=未删除, 1=已删除')
create_time = Column(DATETIME, nullable=False, comment='创建时间')
update_time = Column(DATETIME, nullable=False, comment='更新时间')
class CourseInfo(Base):
"""课程基础信息"""
__tablename__ = 'course_info'
course_id = Column(String(30), primary_key=True, comment='课程编号')
course_name = Column(String(30), nullable=False, comment='课程名称')
course_type = Column(Enum('1', '2', '3'), nullable=False, comment='课程类型:1 必修课 2 选修课 3 实训课')
is_deleted = Column(Enum('0', '1'), nullable=False, comment='逻辑删除:0=未删除, 1=已删除')
create_time = Column(DATETIME, nullable=False, comment='创建时间')
update_time = Column(DATETIME, nullable=False, comment='更新时间')
View File
+104
View File
@@ -0,0 +1,104 @@
from pydantic import BaseModel,ConfigDict # FastAPI 推荐用 pydantic 做数据校验和序列化
from datetime import date # 日期类型,用于 birthday、enrollment_date 等字段
from typing import List # 类型提示,声明列表
class StudentInfo1(BaseModel):
model_config = ConfigDict(from_attributes=True)
student_id: str # 学号
student_name: str # 姓名
gender: str # 性别(男/女)
id_card: str # 身份证号
birthday: date # 出生日期
ethnicity: str # 民族
region_id: str # 籍贯编码(关联 region表)
phone: str # 手机号
major: str # 专业
class_id: str # 班级编号
enrollment_date: date # 入学日期
graduation_date: date # 毕业日期
student_status: str # 学籍状态(在读/休学/退学/毕业)
education_level: str # 学历(大专/本科/硕士/博士)
class ResponseModel(BaseModel):
"""统一响应格式 — 所有接口都返回这个结构,方便前端统一处理"""
code: int # 状态码,200=成功,400=参数错误
message: str = 'ok' # 提示信息
total:int #总条数
total_pages:int #总页数
data: List[StudentInfo1] # 真正的数据,这里是学员列表
class StudentInfo2(BaseModel):
student_id: str
student_name: str
score:float
class ResponseModel1(BaseModel):
"""统一响应格式 — 所有接口都返回这个结构,方便前端统一处理"""
code: int # 状态码,200=成功,400=参数错误
message: str = 'ok' # 提示信息
total:int #总条数
total_pages:int #总页数
data: List[StudentInfo2] # 真正的数据,这里是学员列表
class StudentInfo3(BaseModel):
student_id: str
student_name: str
fail_count: int
class ResponseModel2(BaseModel):
code: int
message: str = 'ok'
total: int
total_pages: int
data: List[StudentInfo3]
class ClassExamAvg(BaseModel):
course_id: str
class_id: str
class_name: str
avg_score: float
class ResponseModel3(BaseModel):
code: int
message: str = 'ok'
total: int
total_pages: int
data: List[ClassExamAvg]
class SalaryTop(BaseModel):
student_name: str
class_name: str
company_name: str
part_time: date
salary: float
class ResponseModel4(BaseModel):
code: int
message: str = 'ok'
data: List[SalaryTop]
class TimeSize(BaseModel):
student_id: str
student_name: str
offer_date: date
resume_open_date: date
time_size: int # 就业时长(天数)
class ResponseModel5(BaseModel):
code: int
message: str = 'ok'
total: int
total_pages: int
data: List[TimeSize]
class ClassAvgTimeSize(BaseModel):
class_id: str
class_name: str
avg_time_size: float # 平均就业时长(天数)
class ResponseModel6(BaseModel):
code: int
message: str = 'ok'
total: int
total_pages: int
data: List[ClassAvgTimeSize]