Merge pull request '完善统计模块及模型' (#22) from 张昕浩 into main

Reviewed-on: #22
This commit is contained in:
2026-09-21 20:00:55 +08:00
6 changed files with 350 additions and 18 deletions
+5
View File
@@ -16,3 +16,8 @@ env/
# 系统垃圾 # 系统垃圾
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.idea/
.idea.backup/
.run/
+60 -5
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter,HTTPException,Depends from fastapi import APIRouter,HTTPException,Depends
from DoubaoTeam.core.database import get_db from core.database import get_db
from DoubaoTeam.models.student import Student from models.employment import Employment
from DoubaoTeam.models.score import Score from models.student import Student
from DoubaoTeam.dao.statistics import * from models.score import Score
from DoubaoTeam.schemas.common import SuccessResponse from dao.statistics import *
from schemas.common import SuccessResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -26,3 +27,57 @@ def api_gender_count(db:Session=Depends(get_db)):
except NoExist as e: except NoExist as e:
raise HTTPException(status_code=404,detail=str(e)) raise HTTPException(status_code=404,detail=str(e))
@statistics_router.get("/scores/all-above-80",response_model=SuccessResponse)
def all_above_eighty(db:Session=Depends(get_db)):
try:
result = score_over_eighty(Score,Student,db)
return SuccessResponse(status_code=200,msg=result)
except NoExist as e:
raise HTTPException(status_code=404,detail=str(e))
@statistics_router.get("/scores/fail-more-than-two",response_model=SuccessResponse)
def fail_two(db:Session=Depends(get_db)):
try:
result = twice_error(Score,Student,db)
return SuccessResponse(status_code=200,msg=result)
except NoExist as e:
raise HTTPException(status_code=404,detail=str(e))
@statistics_router.get("/scores/class-average",response_model=SuccessResponse)
def class_average(db:Session=Depends(get_db)):
try:
result = avg_class(Score,Student,db)
return SuccessResponse(status_code=200,msg=result)
except NoExist as e:
raise HTTPException(status_code=404,detail=str(e))
@statistics_router.get("/employments/top5-salary",response_model=SuccessResponse)
def top_salary(db:Session=Depends(get_db)):
try:
result=max_salary(Student,Employment,db)
return SuccessResponse(status_code=200,msg=result)
except NoExist as e:
raise HTTPException(status_code=404,detail=str(e))
@statistics_router.get("/employments/student-duration",response_model=SuccessResponse)
def employ_time(db:Session=Depends(get_db)):
try:
result = student_market(Employment,db)
return SuccessResponse(status_code=200,msg=result)
except NoExist as e:
raise HTTPException(status_code=404,detail=str(e))
@statistics_router.get("/employments/class-average-duration",response_model=SuccessResponse)
def avg_employ_time(db:Session=Depends(get_db)):
try:
result = avg_class(Employment,Student,db)
return SuccessResponse(status_code=200,msg=result)
except NoExist as e:
raise HTTPException(status_code=404,detail=str(e))
+54 -4
View File
@@ -1,6 +1,6 @@
from DoubaoTeam.schemas.statistics import NoExist from schemas.statistics import NoExist
from sqlalchemy import func from sqlalchemy import func
from time import time
#`GET /statistics/students/age-over-30` #`GET /statistics/students/age-over-30`
#查询所有超过 30 岁的学员信息 #查询所有超过 30 岁的学员信息
@@ -23,8 +23,58 @@ def gender_count(Student,db):
#查询每次考试成绩都在 80 分以上的学生编号、姓名和成绩 #查询每次考试成绩都在 80 分以上的学生编号、姓名和成绩
def score_over_eighty(Score,Student,db): def score_over_eighty(Score,Student,db):
student_list=db.query(Student.id, Student.student_name)\ student_list=db.query(Student.id, Student.student_name)\
.join(Student,Score.student_id==Student.id).group_by(Student.student_no,Student.student_name)\ .join(Score,Score.student_id==Student.id).group_by(Student.student_no,Student.student_name)\
.having(func.min(Score.score)>80).all() .having(func.min(Score.score)>80).all()
if not student_list :
raise NoExist('全是学渣')
list1 = []
for i in student_list: for i in student_list:
student_list2 = db.query(Student.student_no, Student.student_name,Score.score).join(Student,Score.student_id==Student.id).filter(i.id==Student.id).all() student_list2 = db.query(Student.student_no, Student.student_name,Score.score).join(Score,Score.student_id==Student.id).filter(i.id==Student.id).all()
if student_list2 :
list1.extend(student_list2)
return list1
#查询有两次以上不及格的学生姓名、班级和不及格成绩
def twice_error(Score,Student,db):
student_list=db.query(Score.student_id).filter(Score.score<60).group_by(Score.student_id).having(func.count(Score.id)>1).all()
if not student_list :
raise NoExist('全是学霸')
list1=[]
for i in student_list:
result=db.query(Student.student_name,Student.class_id,Score.score).join(Score, Student.id==Score.student_id)\
.filter(i.student_id==Score.student_id,Score.score<60).all()
list1.extend(result)
return list1
#统计每次考试每个班级的平均分,并按照平均分从高到低排序
def avg_class(Score,Student,db):
student_list = db.query(Student.class_id,Score.exam_seq,func.avg(Score.score)).join(Score, Student.id == Score.student_id).group_by(Student.class_id,Score.exam_seq).order_by(func.avg(Score.score).desc()).all()
if not student_list :
raise NoExist('招个人吧')
return student_list
#统计就业薪资最高的前五名学生姓名、班级、就业时间、就业公司
def max_salary(Student,Employment,db):
student_list = db.query(Student.student_name,Student.class_id,Employment.offer_date,Employment.company_name).join(Employment, Student.id == Employment.student_id).order_by(Employment.salary_max.desc()).limit(5).all()
if not student_list :
raise NoExist('没就业的啊兄弟?')
return student_list
# - 统计每个学生的就业时长
# - 就业时长 = `offer_date - employment_open_date`
def student_market(Employment,db):
student_list = db.query(Employment.student_id,func.datediff(Employment.offer_date,Employment.employment_open_date)).all()
return student_list
# - 统计每个班级的平均就业时长
# - 只统计有就业开放时间的学生
def avg_emptime(Employment,Student,db):
student_list = (db.query(Student.class_id,\
func.avg(func.datediff(Employment.offer_date,Employment.employment_open_date)))\
.join(Employment, Student.id==Employment.student_id).filter(Employment.employment_open_date.is_not(None)))\
.group_by(Student.class_id).all()
if not student_list :
raise NoExist('一个就业都没有吗')
return student_list
+217
View File
@@ -0,0 +1,217 @@
# 数据库模型与建表工具改动说明
本次按照《沃林学生管理系统小组分工说明书 V1》修正模型定义,整理范围为 7 个 Model 文件和 1 个建表脚本。各模块继续使用 `core.database.Base`,保留原有 Python 类名和项目目录。
## 一、改动总览
| 模块 | 负责人 | 修改文件 | 模型类 | 最终表名 |
|---|---|---|---|---|
| 学生管理 | 李玉杰 | `models/student.py` | `Student` | `students` |
| 班级管理 | 婷婷 | `models/class_model.py` | `Classes` | `classes` |
| 老师管理 | 张义 | `models/teacher.py` | `Teacher` | `teachers` |
| 班级老师关系 | 张义 | `models/class_teacher.py` | `ClassTeachers` | `class_teachers` |
| 顾问管理 | 薄鑫 | `models/consultant.py` | `Consultant` | `consultants` |
| 成绩管理 | 曾凯 | `models/score.py` | `Score` | `scores` |
| 就业管理 | 南方宇 | `models/employment.py` | `Employment` | `employments` |
| 公共建库建表工具 | 张昕浩 | `utils/create database.py` | — | 使用上述全部模型 |
### 经确认保留的类型
本次没有将所有主键统一为文档中的 `BIGINT`,成绩精度也按已确认的要求保留。
| 项目 | 当前模型类型 |
|---|---|
| 学生、班级、老师、顾问、成绩表的 `id` | `Integer` |
| 班级老师关系表、就业表的 `id` | `BigInteger` |
| 各表关联其他表的外键字段 | `Integer`,与对应模型的主键一致 |
| 成绩字段 `scores.score` | `DECIMAL(4,1)` |
| 薪资字段 `employments.salary` | `DECIMAL(10,2)` |
其中 `class_teachers.class_id`、`class_teachers.teacher_id`、`employments.student_id` 原为 `BigInteger`,经确认改为 `Integer`,解决 MySQL 要求外键与被引用主键类型一致的问题。
## 二、公共字段调整
以下规则落实在各个 Model 文件中,没有增加公共模型文件或额外继承层。
| 字段 | 最终定义及作用 |
|---|---|
| `created_at` | `DATETIME`、不可为空;保留 ORM 创建时间默认值,增加数据库默认值 `CURRENT_TIMESTAMP` |
| `updated_at` | `DATETIME`、不可为空;使用 `onupdate=datetime.now`,增加数据库默认值及 `ON UPDATE CURRENT_TIMESTAMP` |
| `is_deleted` | MySQL `TINYINT`、不可为空;ORM 默认值和数据库默认值均为 `0` |
原先误用 `sqlalchemy.dialects.mssql.TINYINT` 的老师、班级老师关系模型,改为从 `sqlalchemy.dialects.mysql` 导入。
`is_deleted` 是逻辑删除标志。Model 中声明这个字段不会自动过滤查询;接口查询仍需添加 `is_deleted == 0`,删除业务记录应更新该标志。
## 三、学生管理模块
文件:`models/student.py`;负责人:李玉杰。
| 修改项 | 修改前 | 修改后 |
|---|---|---|
| 表名 | `student_info_detail` | `students` |
| 学生编号 | 唯一,但允许为空 | 唯一且 `nullable=False` |
| 学生姓名 | 允许为空 | `nullable=False` |
| 班级外键 | `foreign_key='class_info_detail.id'`,不是有效外键声明 | `ForeignKey('classes.id')`,并设为非空 |
| 顾问外键 | `foreign_key='consultant_info_detail.id'`,不是有效外键声明 | `ForeignKey('consultants.id')`,继续允许为空 |
| 毕业院校字段 | `graduation_school` | `graduate_school` |
| 创建时间字段 | `create_at` | `created_at` |
| 更新时间字段 | `update_at` | `updated_at` |
| 逻辑删除 | `Integer`,允许为空 | MySQL `TINYINT`、非空、默认 `0` |
时间字段按公共规则补齐数据库默认值及非空约束。学生主键、班级外键和顾问外键均保留 `Integer`。
## 四、班级管理模块
文件:`models/class_model.py`;负责人:婷婷。
| 修改项 | 修改前 | 修改后 |
|---|---|---|
| 开课日期 `start_date` | 允许为空 | `nullable=False`,开课时间必填 |
| 更新时间字段 | `update_at` | `updated_at` |
| 备注长度 | `String(100)` | `String(255)` |
| 逻辑删除 | `INTEGER` | MySQL `TINYINT`,补充数据库默认值 `0` |
时间字段增加数据库默认值和数据库自动更新时间设置。保留表名 `classes`、类名 `Classes`、`Integer` 自增主键,以及班级编号唯一且非空的约束。
## 五、老师管理模块
文件:`models/teacher.py`;负责人:张义。
| 修改项 | 修改前 | 修改后 |
|---|---|---|
| 表名 | `teachers_info_detail` | `teachers` |
| 备注长度 | `VARCHAR(2550)` | `VARCHAR(255)` |
| `TINYINT` 来源 | SQL Server 方言 | MySQL 方言 |
| 更新时间 | 仅设置创建时的默认时间 | 补充 `onupdate=datetime.now` 和数据库自动更新时间 |
创建时间增加数据库默认值,逻辑删除增加数据库默认值 `0`。保留 `Integer` 自增主键和老师编号唯一约束。
## 六、班级老师关系模块
文件:`models/class_teacher.py`;负责人:张义。
| 修改项 | 修改前 | 修改后 |
|---|---|---|
| 表名 | `class_teachers_info_detail` | `class_teachers` |
| 班级外键目标 | `ForeignKey('class_id')` | `ForeignKey('classes.id')` |
| 老师外键目标 | `ForeignKey('teacher_id')` | `ForeignKey('teachers.id')` |
| 两个外键字段类型 | `BigInteger` | `Integer`,与班级、老师模型主键匹配 |
| 联合唯一约束 | 未设置 | `(class_id, teacher_id, role)` 联合唯一 |
| 联合唯一约束名称 | — | `uk_class_teacher_role` |
| 时间默认值 | `datetime.now()`,导入模型时就求值 | `datetime.now`,插入记录时再求值 |
| `TINYINT` 来源 | SQL Server 方言 | MySQL 方言 |
补齐时间及逻辑删除的数据库默认值,并为更新时间增加自动更新设置。关系表自身的 `id` 保留 `BigInteger`,类名保留 `ClassTeachers`。
联合唯一约束防止同一个老师以同一个角色被重复分配到同一个班级。
## 七、顾问管理模块
文件:`models/consultant.py`;负责人:薄鑫。
| 修改项 | 修改前 | 修改后 |
|---|---|---|
| 联系电话长度 | `String(11)` | `String(20)` |
| 逻辑删除类型 | `Integer` | MySQL `TINYINT` |
| 数据库默认值 | 未声明 | 补充创建时间、更新时间和逻辑删除默认值 |
保留表名 `consultants`、`Integer` 自增主键、顾问编号唯一且非空的约束,以及其余业务字段。
## 八、成绩管理模块
文件:`models/score.py`;负责人:曾凯。
| 修改项 | 修改前 | 修改后 |
|---|---|---|
| 表名 | `score_info_detail` | `scores` |
| 学生外键目标 | `ForeignKey('student.id')` | `ForeignKey('students.id')` |
| 成绩是否必填 | 允许为空 | `nullable=False` |
| 联合唯一约束名称 | `uk_student_exam_seq` | `uk_scores_student_exam` |
| 备注长度 | `String(225)` | `String(255)` |
| 逻辑删除 | `Integer`,允许为空 | MySQL `TINYINT`、非空、默认 `0` |
创建时间和更新时间补齐非空约束、数据库默认值及自动更新设置;修正注释,明确联合唯一约束是“学生 ID + 考核序次”。
保留 `Integer` 主键及学生外键,成绩精度保留 `DECIMAL(4,1)`,保留成绩范围 `0~100` 的检查约束。一个学生同一次考核只能有一条成绩。
## 九、就业管理模块
文件:`models/employment.py`;负责人:南方宇。
| 修改项 | 修改前 | 修改后 |
|---|---|---|
| 学生外键类型 | `BigInteger` | `Integer`,与学生模型主键匹配 |
| 学生就业唯一约束 | 未设置 | `student_id` 增加 `unique=True` |
| 就业状态长度 | `String(50)` | `String(30)` |
| 就业状态是否必填 | 允许为空 | `nullable=False` |
| 逻辑删除 | `SmallInteger`,允许为空 | MySQL `TINYINT`、非空、默认 `0` |
创建时间和更新时间补齐非空约束、数据库默认值及自动更新设置。修正薪资字段注释:`DECIMAL` 是定点小数。
保留表名 `employments`、`BigInteger` 自增主键、`ForeignKey('students.id')`,以及薪资精度 `DECIMAL(10,2)`。学生外键唯一约束确保每个学生最多对应一条就业信息。
## 十、公共建库建表工具
文件:`utils/create database.py`;负责人:张昕浩。文件名中包含一个空格。
脚本执行流程:
1. 将项目根目录加入当前进程的导入路径,使直接运行此文件时也能导入 `core` 和 `models`。
2. 导入全部 7 个模型,将模型表注册到公共 `Base.metadata`。
3. 检查整数外键与被引用主键的类型是否一致;不一致时列出具体字段并以失败状态退出。
4. 从现有 `core.database.engine` 读取连接设置,先连接不指定数据库的 MySQL 服务。
5. 创建缺失的数据库,字符集为 `utf8mb4`、排序规则为 `utf8mb4_unicode_ci`;当前配置库名为 `student_manager`。
6. 调用 `Base.metadata.create_all(bind=engine)` 创建缺失的表。
7. 查询实际表名,确认全部模型对应的表均存在。
8. 释放数据库连接池;发生数据库或检查错误时输出原因,并返回非零退出码。
在项目根目录运行:
```powershell
python "utils/create database.py"
```
本机已验证使用的解释器命令:
```powershell
& 'D:\conda\envs\ai0720\python.exe' -B -X utf8 '.\utils\create database.py'
```
脚本只创建缺失的库和表,不清空数据、不删除表,也不自动修改已有表结构。
## 十一、验证结果及范围
完成三处外键类型修正后,脚本退出码为 `0`,输出如下:
```text
数据库已就绪:student_manager
已导入的模型表: classes, teachers, consultants, students, class_teachers, scores, employments
建表检查通过:全部模型对应的表均已存在。
```
已经完成的检查包括:
- 7 个模型可以导入,6 个外键目标均能解析。
- 外键与被引用主键在模型中的类型检查通过。
- 7 张表的 MySQL 建表 SQL 可以编译。
- 字段名、非空设置、字符串长度与本次对照结果一致。
- 班级老师关系、成绩和就业的必要唯一约束已在模型中定义。
- 7 个模型可以读取现有数据库中的对应表。
- 文件差异格式检查通过。
本次执行针对已有的 `student_manager` 数据库。`create_all()` 会跳过已有表,因此此结果不等同于已经在全新空库中完成端到端创建测试,也不表示现有数据库已经迁移成当前模型定义。
现有库中的相关主外键仍为原来的 `BIGINT`,成绩列仍为原来的 `DECIMAL(5,2)`;本次没有执行 `ALTER TABLE`,模型里经确认保留的 `Integer` 和 `DECIMAL(4,1)` 不会自动改变这些实库类型。
## 十二、后续接口联调事项
以下配套代码不在本次修改范围内,需要模块负责人继续核对:
| 位置 | 需要核对的内容 |
|---|---|
| `schemas/class_schema.py` 的 `ClassCreate` | 当前缺少 `start_date`,而模型规定开课日期必填 |
| `schemas/class_schema.py` 的 `ClassUpdate` | 当前仍使用 `update_at`,模型字段已统一为 `updated_at` |
| 学生新增及业务编号生成逻辑 | `student_no` 已设为非空;先以空编号执行 `flush()` 再生成编号会违反非空约束,需要与编号生成方案协调 |
本次验证覆盖模型和建表工具,尚未完成上述业务接口的联调验证。
+8 -4
View File
@@ -1,6 +1,7 @@
from sqlalchemy import * # 导入sqlalchemy模块下所有的类,以便下面创建班级表模型和声明表的字段 from sqlalchemy import * # 导入sqlalchemy模块下所有的类,以便下面创建班级表模型和声明表的字段
from datetime import datetime # 导入datetime模块下的datetime类 from datetime import datetime # 导入datetime模块下的datetime类
from core.database import Base # 从core软件包下的database模块导入Base from core.database import Base # 从core软件包下的database模块导入Base
from sqlalchemy.dialects.mysql import TINYINT
class Classes( Base ): # 在python里的名字 class Classes( Base ): # 在python里的名字
__tablename__ = 'class_info_detail' # 在数据库中表的名字 __tablename__ = 'class_info_detail' # 在数据库中表的名字
@@ -21,7 +22,7 @@ class Classes( Base ): # 在python里的名字
) )
# ————创建班级"开课时间"的字段名———— # ————创建班级"开课时间"的字段名————
start_date = Column( DATE # 声明是字段数据类型是日期 start_date = Column( DATE # 声明是字段数据类型是日期
, nullable = True # 声明是'可为空' , nullable = False # 开课时间必填
) )
# ————创建班级"结课时间"的字段名———— # ————创建班级"结课时间"的字段名————
end_date = Column( DATE # 声明是字段数据类型是'日期' end_date = Column( DATE # 声明是字段数据类型是'日期'
@@ -30,20 +31,23 @@ class Classes( Base ): # 在python里的名字
# ————创建班级"创建时间"的字段名———— # ————创建班级"创建时间"的字段名————
created_at = Column( DATETIME # 声明是字段数据类型是"日期时间" created_at = Column( DATETIME # 声明是字段数据类型是"日期时间"
, default=datetime.now # 声明是创建的默认值就是"当前时间" , default=datetime.now # 声明是创建的默认值就是"当前时间"
, server_default=text("CURRENT_TIMESTAMP")
, nullable = False # 声明是"非空" , nullable = False # 声明是"非空"
) )
# ————创建班级"更新时间"的字段名———— # ————创建班级"更新时间"的字段名————
update_at = Column( DATETIME # 声明是字段数据类型是"日期时间" updated_at = Column( DATETIME # 声明是字段数据类型是"日期时间"
, default=datetime.now #声明是第一次更新的时间就是第一次创建的时间一致 , default=datetime.now #声明是第一次更新的时间就是第一次创建的时间一致
, onupdate=datetime.now # 声明"最后修改的时间" , onupdate=datetime.now # 声明"最后修改的时间"
, server_default=text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")
, nullable = False # 声明是"非空" , nullable = False # 声明是"非空"
) )
# ————创建班级"备注信息"的字段名———— # ————创建班级"备注信息"的字段名————
remark = Column( String(100) # 声明是字段数据类型是"字符串" remark = Column( String(255) # 声明是字段数据类型是"字符串"
, nullable = True # 声明是"可以为空" , nullable = True # 声明是"可以为空"
) )
# ————创建班级"逻辑删除"的字段名———— # ————创建班级"逻辑删除"的字段名————
is_deleted = Column( INTEGER # 声明是字段数据类型是"数字" is_deleted = Column( TINYINT # 逻辑删除标记
, default = 0 # 声明默认值是"0,未删除" , default = 0 # 声明默认值是"0,未删除"
, server_default = text("0")
, nullable = False # 声明是"非空" , nullable = False # 声明是"非空"
) )
+5 -4
View File
@@ -1,5 +1,6 @@
from sqlalchemy import * from sqlalchemy import *
from sqlalchemy.dialects.mysql import TINYINT
from datetime import datetime from datetime import datetime
from core.database import Base from core.database import Base
class Consultant(Base): class Consultant(Base):
@@ -8,10 +9,10 @@ class Consultant(Base):
id = Column(Integer, primary_key=True, autoincrement=True, comment="主键") id = Column(Integer, primary_key=True, autoincrement=True, comment="主键")
consultant_no = Column(String(50), unique=True, nullable=False, comment="顾问编号") consultant_no = Column(String(50), unique=True, nullable=False, comment="顾问编号")
consultant_name = Column(String(50), nullable=False, comment="顾问姓名") consultant_name = Column(String(50), nullable=False, comment="顾问姓名")
phone = Column(String(11), nullable=True, comment="联系电话") phone = Column(String(20), nullable=True, comment="联系电话")
email = Column(String(100), nullable=True, comment="邮箱") email = Column(String(100), nullable=True, comment="邮箱")
remark = Column(String(255), nullable=True, comment="备注") remark = Column(String(255), nullable=True, comment="备注")
created_at = Column(DateTime, default=datetime.now, nullable=False, comment="创建时间") created_at = Column(DateTime, default=datetime.now, server_default=text("CURRENT_TIMESTAMP"), nullable=False, comment="创建时间")
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment="更新时间") updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, server_default=text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), nullable=False, comment="更新时间")
is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除:0正常,1删除") is_deleted = Column(TINYINT, default=0, server_default=text("0"), nullable=False, comment="逻辑删除:0正常,1删除")