log日志+中间件+注释

This commit is contained in:
2026-09-23 09:28:16 +08:00
parent 1abb8ab737
commit 0065eaa6f7
9 changed files with 365 additions and 70 deletions
+3 -1
View File
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="ai0720" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
+64
View File
@@ -0,0 +1,64 @@
# ============================================================
# HTTP 请求日志中间件
# 功能:拦截每一次 API 请求,记录请求方信息、请求参数、
# 响应状态、响应数据以及接口耗时,统一写入 log.txt
# 工作流程:
# 请求进入 → 记录 method / url → 调用真实接口 → 记录耗时
# → 记录响应状态码 & 响应体 → 返回重新组装的 Response
# ============================================================
import time
from fastapi import Request
from fastapi.responses import Response
from Log import Log
async def m1(req: Request, call_next):
"""
FastAPI HTTP 中间件入口函数。
参数:
req: FastAPI 自动注入的 Request 对象,包含 method / url / body 等
call_next: 由中间件机制注入的回调函数,调用它会执行下一个中间件或真正的接口处理器
注意:
中间件里读取响应体后,必须手动重新构造一个 Response 返回;
否则 FastAPI 流式响应的 body_iterator 被读完就没了,前端会收到空内容。
"""
# 初始化日志器:日志名 ai0824,默认 INFO 级别
log = Log('ai0824', 'INFO')
# 将日志输出到 log.txt(会自动追加)
log.logFile('log.txt')
# —— 1. 请求前置记录 ——
log.message('Info', '请求方式:' + req.method) # GET / POST / PUT / DELETE ...
log.message('Info', '请求地址:' + str(req.url)) # 例如 http://127.0.0.1:12345/basic-information
# —— 2. 执行真正的接口,记录耗时 ——
beginTime = time.time() # 请求开始时间戳(秒)
r = await call_next(req) # 调用后续链:下一个中间件或路由处理器
endTime = time.time() # 响应返回时间戳
# —— 3. 读取原始请求体(JSON 参数等)并记录 ——
body = await req.body() # body() 是异步方法,必须 await;返回 bytes
log.message('Info', '请求参数:' + body.decode('utf-8', 'ignore'))
# —— 4. 读取流式响应体并记录 ——
# call_next 返回的是 StreamingResponse,响应体藏在 body_iterator 里,且只能遍历一次
resp_bytes = b''
async for chunk in r.body_iterator:
resp_bytes += chunk if isinstance(chunk, bytes) else chunk.encode()
log.message('Info', '响应状态码:' + str(r.status_code))
log.message('Info', '响应数据:' + resp_bytes.decode('utf-8', 'ignore'))
log.message('Info', '请求耗时:%.2f ms' % ((endTime - beginTime) * 1000))
# —— 5. 重新构造 Response 返回给前端 ——
# 原因:body_iterator 已经被遍历过一次,原始 Response 再返回会导致响应体为空
return Response(
content=resp_bytes,
status_code=r.status_code,
headers=dict(r.headers),
media_type=r.media_type,
)
+90
View File
@@ -0,0 +1,90 @@
# ============================================================
# 日志工具类 — 对标准库 logging 进行简单封装
# 提供控制台输出、文件输出、统一 message() 记录三个能力
# 避免在各处重复写 logger.handlers / FileHandler / Formatter 等样板代码
# ============================================================
import logging
class Log:
"""
简易日志封装类。
用法示例:
log = Log('myapp', 'INFO')
log.logFile('app.log') # 输出到文件
log.logConsol() # 同时输出到控制台
log.message('info', '启动成功')
设计要点:
- 同一日志器重复 addHandler 会导致同一条日志输出多次,
所以 logFile / logConsol 里做了 handler 去重判断。
"""
def __init__(self, name, lv):
"""
构造日志器实例。
参数:
name: 日志器名称,同名日志器是全局单例(logging.getLogger 内部维护)
lv: 初始日志级别字符串,不区分大小写
可选值: 'DEBUG' / 'INFO' / 'WARNING' / 'ERROR' / 'CRITICAL'
"""
# ① 创建命名日志器
self.logName = logging.getLogger(name)
# ② 设置日志级别(字符串转 logging.XXX 枚举)
self.logName.setLevel(eval(f'logging.{lv.upper()}'))
# ④ 定义统一的日志输出格式:时间 - 名称 - 级别 - 消息
self.formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# 保存现有 handler 列表,方便后续去重
self.handlers = self.logName.handlers
def logConsol(self):
"""将日志输出到控制台(stdout)。"""
con_hand = logging.StreamHandler() # ③ 创建控制台处理器
con_hand.setFormatter(self.formatter) # 绑定格式
# 去重:避免重复添加同一个类型的 handler 导致日志重复打印
if 'StreamHandler' not in str(self.handlers):
self.logName.addHandler(con_hand) # ⑤ 注册到日志器
def logFile(self, fileName):
"""
将日志输出到指定文件(UTF-8 编码)。
参数:
fileName: 日志文件名,例如 'log.txt'
"""
file_hand = logging.FileHandler(fileName, encoding='utf-8') # ③ 创建文件处理器
file_hand.setFormatter(self.formatter)
# 去重:同一个文件 handler 只注册一次
if 'FileHandler' not in str(self.handlers):
self.logName.addHandler(file_hand) # ⑤ 注册到日志器
def message(self, lv, mes):
"""
按照指定级别输出一条日志。
参数:
lv: 日志级别字符串(不区分大小写),可选 debug / info / warning / error / critical
mes: 日志内容
"""
lv = lv.lower()
if lv == 'debug':
self.logName.debug(mes)
elif lv == 'info':
self.logName.info(mes)
elif lv == 'warning':
self.logName.warning(mes)
elif lv == 'error':
self.logName.error(mes)
elif lv == 'critical':
self.logName.critical(mes)
else:
print('请输入正确的日志级别!')
+124 -56
View File
@@ -1,114 +1,182 @@
# ============================================================
# API 接口层 — 对外暴露的 HTTP 路由
# 职责:
# 1. 定义 URL 路径、请求方法(GET/POST)和参数校验规则
# 2. 调用 dao 层获取数据
# 3. 用 schema 层的 ResponseModel 封装统一响应格式返回给前端
# 依赖注入:
# db = Depends(get_db) — 由 FastAPI 自动为每个请求创建并回收数据库会话
# ============================================================
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,ResponseModel7,ResponseModel8
from schema.statistics_schema import (
ResponseModel, ResponseModel1, ResponseModel2, ResponseModel3,
ResponseModel4, ResponseModel5, ResponseModel6, ResponseModel7, ResponseModel8
)
from math import ceil
# 统一路由前缀分组,所有接口在 Swagger 里归到"统计分析"标签下
BasicInformationAPI = APIRouter(tags=['统计分析'])
@BasicInformationAPI.get('/basic-information',summary='查询年龄区间内的学员信息')
@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),
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)):
db=Depends(get_db)
):
"""
按年龄区间分页查询学员基本信息。
前端传入 min_age / max_age,后端换算为出生日期区间后在 student_info 表中过滤。
"""
# 参数合法性校验:最小年龄不能大于最大年龄
if min_age > max_age:
return {f'code=400,message=最小年龄不能大于最大年龄'}
return {'code': 400, 'message': '最小年龄不能大于最大年龄', 'total': 0, 'total_pages': 0, 'data': []}
req,total = StatisticsDao.get_students_by_age_range_dao(n,m,min_age, max_age,db)
# 调用 DAO 层查询
req, total = StatisticsDao.get_students_by_age_range_dao(n, m, min_age, max_age, db)
# 计算总页数:ceil 向上取整;total 为 0 时避免除以 0
total_pages = ceil(total / m) if total > 0 else 0
return ResponseModel(code=200,
message='查询成功',
total=total,
total_pages=ceil(total/m) if total > 0 else 0,
total_pages=total_pages,
data=req)
@BasicInformationAPI.get('/basic-information/{class_id}',summary='统计指定班级的⼈数以及男⽣/⼥⽣的⼈数')
def get_students_by_class_id_api(class_id:str,
db = Depends(get_db)):
data = StatisticsDao.get_students_by_class_id_dao(class_id,db)
@BasicInformationAPI.get('/basic-information/{class_id}', summary='统计指定班级的人数以及男生/女生人数')
def get_students_by_class_id_api(class_id: str, db=Depends(get_db)):
"""
根据班级编号查询该班级的总人数、男生人数、女生人数。
URL 路径参数 class_id 由 FastAPI 自动解析。
"""
data = StatisticsDao.get_students_by_class_id_dao(class_id, db)
return ResponseModel7(code=200,
message='查询成功',
data=data)
@BasicInformationAPI.get('/class-stats',summary='统计所有班级的人数以及男⽣/⼥⽣的⼈数')
def get_class_stats_api(db = Depends(get_db)):
@BasicInformationAPI.get('/class-stats', summary='统计所有班级的人数以及男生/女生人数')
def get_class_stats_api(db=Depends(get_db)):
"""
一次性返回所有班级的统计结果(不需要分页,数据量通常可控)。
"""
data = StatisticsDao.get_class_stats_dao(db)
return ResponseModel8(code=200,
message='查询成功',
data=data)
@BasicInformationAPI.get('/scores',summary='在某个分数段的学生')
@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)):
n: int = Query(..., description='页码,从1开始', ge=1),
m: int = Query(..., description='每页条数', ge=1),
score: float = Query(ge=0, le=100, description='分数阈值,0~100'),
db=Depends(get_db)
):
"""
查询最低单科成绩 > score 的学生(即所有考试成绩都超过阈值)。
例如 score=60,表示没有任何一门不及格的学生。
"""
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 {'code': 400, 'message': '分数需要在0~100!', 'total': 0, 'total_pages': 0, 'data': []}
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,
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='统计每次考试每个班级的平均分(从高到低排序)')
@BasicInformationAPI.get('/scores_no_pass', summary='查询不及格门数 >= fail_count 的学生')
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)
):
"""
统计每个学生不及格门数,返回不及格门数 >= fail_count 的学生名单。
"""
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)
n: int = Query(..., description='页码,从1开始', ge=1),
m: int = Query(..., description='每页条数', ge=1),
db=Depends(get_db)
):
"""
三表联查 student_score → student_info → class_info,
按 course_id + class_id 分组,计算每个班级每门课的平均分,按平均分降序排列。
"""
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,
total_pages=ceil(total / m) if total > 0 else 0,
data=req)
@BasicInformationAPI.get('/salary_top', summary='查询就业薪资最高的前五名学生')
@BasicInformationAPI.get('/salary_top', summary='查询就业薪资前 m 名的学生')
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)
m: int = Query(5, description='返回前 m 名,默认5', ge=1),
db=Depends(get_db)
):
"""
查询就业薪资最高的前 m 名学生的姓名、班级、就业公司、就业时间和薪资。
每人只取自己历史最高薪资那条记录。
"""
req = StatisticsDao.get_salary_top_dao(m, db)
return ResponseModel4(code=200,
message='查询成功',
data=req)
@BasicInformationAPI.get('/time_size', summary='统计每个学生的就业时长(offer下发时间-就业开放时间)')
@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)
n: int = Query(..., description='页码,从1开始', ge=1),
m: int = Query(..., description='每页条数', ge=1),
db=Depends(get_db)
):
"""
计算每个学生从开放简历到拿到 offer 的天数差 time_size。
只统计开放简历时间不为空的记录。
"""
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,
total_pages=ceil(total / m) if total > 0 else 0,
data=req)
@BasicInformationAPI.get('/class_avg_time_size', summary='统计每个班级的平均就业时长(只统计进入就业阶段的学生)')
@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)
n: int = Query(..., description='页码,从1开始', ge=1),
m: int = Query(..., description='每页条数', ge=1),
db=Depends(get_db)
):
"""
按班级分组,计算进入就业阶段(resume_open_date 不为空)学生的平均就业时长。
"""
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,
total_pages=ceil(total / m) if total > 0 else 0,
data=req)
+7
View File
@@ -1,3 +1,10 @@
# ============================================================
# 数据访问层 DAO — 直接与数据库交互
# 职责:接收 API 层传来的参数,拼装 SQLAlchemy 查询,返回查询结果
# 所有查询都带有 is_deleted = '0' 过滤,实现逻辑删除
# 异常统一捕获并 rollback 后重新抛出,由 API 层决定如何响应
# ============================================================
from model.statistics_model import StudentInfo, StudentScore, ClassInfo, EmploymentInfo
from datetime import date
from dateutil.relativedelta import relativedelta
+39 -8
View File
@@ -1,18 +1,49 @@
# ============================================================
# 数据库连接与会话管理
# 负责:
# 1. 构建 SQLAlchemy 引擎(engine),建立与 MySQL 的连接
# 2. 声明式基类 Base — 所有 ORM 模型都继承它
# 3. 提供 FastAPI 依赖注入用的 get_db() 会话生成器
# ============================================================
from sqlalchemy import *
from sqlalchemy.orm import declarative_base,sessionmaker
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) #与数据库进行连接
# MySQL 连接串格式: mysql+pymysql://用户名:密码@主机:端口/数据库?字符集
# 当前项目使用 pymysql 作为底层驱动,数据库为本地 student
db_url = "mysql+pymysql://root:123456@127.0.0.1:3306/student?charset=utf8mb4"
Base = declarative_base() # 执行函数,返回一个基类
# 创建 SQLAlchemy 引擎:负责连接池管理、SQL 执行、事务提交等底层工作
# 所有 ORM 操作最终都经由 engine 下发到 MySQL
engine = create_engine(db_url)
# 声明式基类:ORM 模型必须继承它才能被 SQLAlchemy 识别为表
# main.py 中的 Base.metadata.create_all(engine) 会据此自动建表
Base = declarative_base()
# 会话工厂:每次调用 Session() 就会拿到一个全新的数据库会话
# autoflush=False —— 不自动 flush,由我们在事务边界手动控制写入
# autocommit=False —— 关闭自动提交,事务由调用方显式 commit / rollback
Session = sessionmaker(
bind=engine,
autoflush=False,
autocommit=False,
)
Session = sessionmaker(bind=engine
,autoflush=False
,autocommit = False
)
def get_db():
"""
FastAPI 依赖注入函数 —— 为每个请求生成一个独立的数据库会话。
使用 yield 构成生成器,前半段(yield 之前)在接口执行前拿到 Session,
后半段(yield 之后,finally 块)在接口执行完毕后自动 close,
确保无论接口正常返回还是抛异常,会话都不会泄漏。
典型用法(在路由函数里声明 db = Depends(get_db)):
def some_api(db = Depends(get_db)):
... 使用 db.query(...) 查数据 ...
db.commit() # 有写入时手动提交
"""
db = Session()
try:
yield db
+23 -4
View File
@@ -1,12 +1,27 @@
# ============================================================
# 项目入口主文件 — FastAPI 应用启动与路由装配
# 职责:
# 1. 初始化 ORM 引擎,自动建表
# 2. 创建 FastAPI 实例并配置跨域中间件
# 3. 挂载 API 路由和 HTTP 请求日志中间件
# 4. 通过 uvicorn 启动服务
# ============================================================
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database import engine, Base
from api.statistics_api import BasicInformationAPI
from model import statistics_model #不可以删
Base.metadata.create_all(engine) #创建所有的表
from HttpLog import m1
from model import statistics_model # 必须导入,触发 SQLAlchemy 把模型注册到 Base.metadata
# 根据 model 中定义的 ORM 模型,在数据库中创建所有表(若已存在则跳过)
Base.metadata.create_all(engine)
# 创建 FastAPI 应用实例
app = FastAPI()
# 配置跨域中间件:允许任意来源、任意方法、任意请求头
# 解决前端开发时浏览器的同源策略拦截问题
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -14,10 +29,14 @@ app.add_middleware(
allow_headers=["*"],
)
# 将统计分析模块的路由挂载到主应用,所有路由前缀在路由文件内定义
app.include_router(BasicInformationAPI)
# 注册 HTTP 请求日志中间件:对每一次请求记录方法、地址、参数、响应和耗时
app.middleware('http')(m1)
# 直接运行本文件时,用 uvicorn 启动开发服务器
# 地址:http://127.0.0.1:12345
if __name__ == '__main__':
import uvicorn
uvicorn.run("main:app", host='0.0.0.0', port=12345)
uvicorn.run("main:app", host='127.0.0.1', port=12345)
+7 -1
View File
@@ -1,3 +1,9 @@
# ============================================================
# ORM 模型定义层 — 与 MySQL 数据库表一一对应
# 每个类继承 database.py 里的 Base,类属性即表字段
# 字段上的 comment 会同步到 MySQL 的字段注释,方便数据库维护
# ============================================================
from sqlalchemy import *
from database import DATETIME,Base,Column,Integer,String
@@ -115,4 +121,4 @@ class CourseInfo(Base):
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='更新时间')
update_time = Column(DATETIME, nullable=False, comment='更新时间')
+8
View File
@@ -1,3 +1,11 @@
# ============================================================
# 数据校验 / 序列化模型层(Pydantic Schema)
# 职责:
# 1. 定义接口请求参数和响应数据的结构,自动做类型校验
# 2. 作为 FastAPI 自动生成 Swagger 文档的依据
# 3. ResponseModel* 系列封装统一响应格式 { code, message, total, total_pages, data }
# ============================================================
from pydantic import BaseModel,ConfigDict # FastAPI 推荐用 pydantic 做数据校验和序列化
from datetime import date # 日期类型,用于 birthday、enrollment_date 等字段
from typing import List # 类型提示,声明列表