From 0065eaa6f77126136f241d74c20f7824c5a461da Mon Sep 17 00:00:00 2001 From: wangtian <1582714703@qq.com> Date: Wed, 23 Sep 2026 09:28:16 +0800 Subject: [PATCH] =?UTF-8?q?log=E6=97=A5=E5=BF=97+=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E4=BB=B6+=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/xs_system.iml | 4 +- HttpLog.py | 64 +++++++++++++ Log.py | 90 ++++++++++++++++++ api/statistics_api.py | 180 +++++++++++++++++++++++++----------- dao/statistics_dao.py | 7 ++ database.py | 47 ++++++++-- main.py | 27 +++++- model/statistics_model.py | 8 +- schema/statistics_schema.py | 8 ++ 9 files changed, 365 insertions(+), 70 deletions(-) create mode 100644 HttpLog.py create mode 100644 Log.py diff --git a/.idea/xs_system.iml b/.idea/xs_system.iml index fca1449..350ede8 100644 --- a/.idea/xs_system.iml +++ b/.idea/xs_system.iml @@ -1,7 +1,9 @@ - + + + diff --git a/HttpLog.py b/HttpLog.py new file mode 100644 index 0000000..a40fb1c --- /dev/null +++ b/HttpLog.py @@ -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, + ) \ No newline at end of file diff --git a/Log.py b/Log.py new file mode 100644 index 0000000..ce4855d --- /dev/null +++ b/Log.py @@ -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('请输入正确的日志级别!') \ No newline at end of file diff --git a/api/statistics_api.py b/api/statistics_api.py index 9939884..7d95b63 100644 --- a/api/statistics_api.py +++ b/api/statistics_api.py @@ -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) \ No newline at end of file diff --git a/dao/statistics_dao.py b/dao/statistics_dao.py index 231b8e8..c9194e8 100644 --- a/dao/statistics_dao.py +++ b/dao/statistics_dao.py @@ -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 diff --git a/database.py b/database.py index 5e28742..43fe18a 100644 --- a/database.py +++ b/database.py @@ -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 diff --git a/main.py b/main.py index 86eff53..505c7e1 100644 --- a/main.py +++ b/main.py @@ -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) \ No newline at end of file + uvicorn.run("main:app", host='127.0.0.1', port=12345) \ No newline at end of file diff --git a/model/statistics_model.py b/model/statistics_model.py index debfa21..1597aa1 100644 --- a/model/statistics_model.py +++ b/model/statistics_model.py @@ -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='更新时间') \ No newline at end of file diff --git a/schema/statistics_schema.py b/schema/statistics_schema.py index 8396b2a..ad17a64 100644 --- a/schema/statistics_schema.py +++ b/schema/statistics_schema.py @@ -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 # 类型提示,声明列表