From d3c60b2fbf9ed956724c8632f7ef61cd3159c79d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=8F=AF=E6=AC=A3?= <17608355+likexin_666@user.noreply.gitee.com> Date: Mon, 21 Sep 2026 23:17:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=A6=E7=94=9F=E7=AE=A1=E7=90=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 6 + Dockerfile | 27 + README.md | 136 ++++++ __pycache__/config.cpython-310.pyc | Bin 0 -> 1135 bytes __pycache__/config.cpython-313.pyc | Bin 0 -> 1636 bytes __pycache__/database.cpython-310.pyc | Bin 0 -> 773 bytes __pycache__/database.cpython-313.pyc | Bin 0 -> 940 bytes __pycache__/main.cpython-310.pyc | Bin 0 -> 2347 bytes __pycache__/main.cpython-313.pyc | Bin 0 -> 3174 bytes api/__init__.py | 2 + api/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 176 bytes api/__pycache__/auth.cpython-310.pyc | Bin 0 -> 2008 bytes api/__pycache__/auth.cpython-313.pyc | Bin 0 -> 2958 bytes api/__pycache__/classes.cpython-310.pyc | Bin 0 -> 2732 bytes api/__pycache__/classes.cpython-313.pyc | Bin 0 -> 3882 bytes api/__pycache__/employment.cpython-310.pyc | Bin 0 -> 5864 bytes api/__pycache__/employment.cpython-313.pyc | Bin 0 -> 8598 bytes api/__pycache__/scores.cpython-310.pyc | Bin 0 -> 4437 bytes api/__pycache__/scores.cpython-313.pyc | Bin 0 -> 6970 bytes api/__pycache__/statistics.cpython-310.pyc | Bin 0 -> 5193 bytes api/__pycache__/statistics.cpython-313.pyc | Bin 0 -> 7857 bytes api/__pycache__/students.cpython-310.pyc | Bin 0 -> 4925 bytes api/__pycache__/students.cpython-313.pyc | Bin 0 -> 7462 bytes api/__pycache__/teachers.cpython-310.pyc | Bin 0 -> 3079 bytes api/__pycache__/teachers.cpython-313.pyc | Bin 0 -> 4501 bytes api/auth.py | 52 ++ api/classes.py | 80 +++ api/employment.py | 160 ++++++ api/scores.py | 123 +++++ api/statistics.py | 147 ++++++ api/students.py | 146 ++++++ api/teachers.py | 87 ++++ config.py | 36 ++ core/__init__.py | 1 + core/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 177 bytes core/__pycache__/deps.cpython-310.pyc | Bin 0 -> 2596 bytes core/__pycache__/deps.cpython-313.pyc | Bin 0 -> 3481 bytes core/__pycache__/security.cpython-310.pyc | Bin 0 -> 2214 bytes core/__pycache__/security.cpython-313.pyc | Bin 0 -> 3348 bytes core/deps.py | 73 +++ core/security.py | 70 +++ dao/__init__.py | 2 + dao/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 176 bytes dao/__pycache__/classes_dao.cpython-310.pyc | Bin 0 -> 2906 bytes dao/__pycache__/classes_dao.cpython-313.pyc | Bin 0 -> 4503 bytes .../employment_dao.cpython-310.pyc | Bin 0 -> 5012 bytes .../employment_dao.cpython-313.pyc | Bin 0 -> 8971 bytes dao/__pycache__/scores_dao.cpython-310.pyc | Bin 0 -> 3539 bytes dao/__pycache__/scores_dao.cpython-313.pyc | Bin 0 -> 5717 bytes .../statistics_dao.cpython-310.pyc | Bin 0 -> 12493 bytes .../statistics_dao.cpython-313.pyc | Bin 0 -> 26391 bytes dao/__pycache__/students_dao.cpython-310.pyc | Bin 0 -> 4533 bytes dao/__pycache__/students_dao.cpython-313.pyc | Bin 0 -> 7850 bytes dao/__pycache__/teachers_dao.cpython-310.pyc | Bin 0 -> 3925 bytes dao/__pycache__/teachers_dao.cpython-313.pyc | Bin 0 -> 6440 bytes dao/__pycache__/users_dao.cpython-310.pyc | Bin 0 -> 1409 bytes dao/__pycache__/users_dao.cpython-313.pyc | Bin 0 -> 2160 bytes dao/classes_dao.py | 76 +++ dao/employment_dao.py | 166 +++++++ dao/scores_dao.py | 90 ++++ dao/statistics_dao.py | 461 ++++++++++++++++++ dao/students_dao.py | 150 ++++++ dao/teachers_dao.py | 105 ++++ dao/users_dao.py | 32 ++ database.py | 36 ++ docker-compose.yml | 57 +++ main.py | 77 +++ model/__init__.py | 11 + model/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 543 bytes model/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 493 bytes model/__pycache__/advisor.cpython-310.pyc | Bin 0 -> 907 bytes model/__pycache__/advisor.cpython-313.pyc | Bin 0 -> 1066 bytes model/__pycache__/classes.cpython-310.pyc | Bin 0 -> 1133 bytes model/__pycache__/classes.cpython-313.pyc | Bin 0 -> 1330 bytes model/__pycache__/employment.cpython-310.pyc | Bin 0 -> 2359 bytes model/__pycache__/employment.cpython-313.pyc | Bin 0 -> 2996 bytes model/__pycache__/scores.cpython-310.pyc | Bin 0 -> 1128 bytes model/__pycache__/scores.cpython-313.pyc | Bin 0 -> 1343 bytes model/__pycache__/students.cpython-310.pyc | Bin 0 -> 1849 bytes model/__pycache__/students.cpython-313.pyc | Bin 0 -> 2361 bytes model/__pycache__/teachers.cpython-310.pyc | Bin 0 -> 1249 bytes model/__pycache__/teachers.cpython-313.pyc | Bin 0 -> 1530 bytes model/__pycache__/user.cpython-310.pyc | Bin 0 -> 1354 bytes model/__pycache__/user.cpython-313.pyc | Bin 0 -> 1695 bytes model/advisor.py | 16 + model/classes.py | 22 + model/employment.py | 51 ++ model/scores.py | 20 + model/students.py | 34 ++ model/teachers.py | 24 + model/user.py | 24 + nginx.conf | 23 + requirements.txt | 9 + scheme/__init__.py | 2 + scheme/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 179 bytes scheme/__pycache__/classes.cpython-310.pyc | Bin 0 -> 2130 bytes scheme/__pycache__/classes.cpython-313.pyc | Bin 0 -> 2607 bytes scheme/__pycache__/employment.cpython-310.pyc | Bin 0 -> 3522 bytes scheme/__pycache__/employment.cpython-313.pyc | Bin 0 -> 4431 bytes scheme/__pycache__/scores.cpython-310.pyc | Bin 0 -> 2519 bytes scheme/__pycache__/scores.cpython-313.pyc | Bin 0 -> 3064 bytes scheme/__pycache__/statistics.cpython-310.pyc | Bin 0 -> 5926 bytes scheme/__pycache__/statistics.cpython-313.pyc | Bin 0 -> 7289 bytes scheme/__pycache__/students.cpython-310.pyc | Bin 0 -> 4239 bytes scheme/__pycache__/students.cpython-313.pyc | Bin 0 -> 5511 bytes scheme/__pycache__/teachers.cpython-310.pyc | Bin 0 -> 2545 bytes scheme/__pycache__/teachers.cpython-313.pyc | Bin 0 -> 3213 bytes scheme/__pycache__/users.cpython-310.pyc | Bin 0 -> 2518 bytes scheme/__pycache__/users.cpython-313.pyc | Bin 0 -> 3122 bytes scheme/classes.py | 42 ++ scheme/employment.py | 67 +++ scheme/scores.py | 46 ++ scheme/statistics.py | 130 +++++ scheme/students.py | 95 ++++ scheme/teachers.py | 54 ++ scheme/users.py | 53 ++ smoke_test.py | 12 + sql/init.sql | 177 +++++++ static/app.js | 457 +++++++++++++++++ static/index.html | 316 ++++++++++++ static/style.css | 72 +++ 121 files changed, 4155 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 __pycache__/config.cpython-310.pyc create mode 100644 __pycache__/config.cpython-313.pyc create mode 100644 __pycache__/database.cpython-310.pyc create mode 100644 __pycache__/database.cpython-313.pyc create mode 100644 __pycache__/main.cpython-310.pyc create mode 100644 __pycache__/main.cpython-313.pyc create mode 100644 api/__init__.py create mode 100644 api/__pycache__/__init__.cpython-310.pyc create mode 100644 api/__pycache__/auth.cpython-310.pyc create mode 100644 api/__pycache__/auth.cpython-313.pyc create mode 100644 api/__pycache__/classes.cpython-310.pyc create mode 100644 api/__pycache__/classes.cpython-313.pyc create mode 100644 api/__pycache__/employment.cpython-310.pyc create mode 100644 api/__pycache__/employment.cpython-313.pyc create mode 100644 api/__pycache__/scores.cpython-310.pyc create mode 100644 api/__pycache__/scores.cpython-313.pyc create mode 100644 api/__pycache__/statistics.cpython-310.pyc create mode 100644 api/__pycache__/statistics.cpython-313.pyc create mode 100644 api/__pycache__/students.cpython-310.pyc create mode 100644 api/__pycache__/students.cpython-313.pyc create mode 100644 api/__pycache__/teachers.cpython-310.pyc create mode 100644 api/__pycache__/teachers.cpython-313.pyc create mode 100644 api/auth.py create mode 100644 api/classes.py create mode 100644 api/employment.py create mode 100644 api/scores.py create mode 100644 api/statistics.py create mode 100644 api/students.py create mode 100644 api/teachers.py create mode 100644 config.py create mode 100644 core/__init__.py create mode 100644 core/__pycache__/__init__.cpython-310.pyc create mode 100644 core/__pycache__/deps.cpython-310.pyc create mode 100644 core/__pycache__/deps.cpython-313.pyc create mode 100644 core/__pycache__/security.cpython-310.pyc create mode 100644 core/__pycache__/security.cpython-313.pyc create mode 100644 core/deps.py create mode 100644 core/security.py create mode 100644 dao/__init__.py create mode 100644 dao/__pycache__/__init__.cpython-310.pyc create mode 100644 dao/__pycache__/classes_dao.cpython-310.pyc create mode 100644 dao/__pycache__/classes_dao.cpython-313.pyc create mode 100644 dao/__pycache__/employment_dao.cpython-310.pyc create mode 100644 dao/__pycache__/employment_dao.cpython-313.pyc create mode 100644 dao/__pycache__/scores_dao.cpython-310.pyc create mode 100644 dao/__pycache__/scores_dao.cpython-313.pyc create mode 100644 dao/__pycache__/statistics_dao.cpython-310.pyc create mode 100644 dao/__pycache__/statistics_dao.cpython-313.pyc create mode 100644 dao/__pycache__/students_dao.cpython-310.pyc create mode 100644 dao/__pycache__/students_dao.cpython-313.pyc create mode 100644 dao/__pycache__/teachers_dao.cpython-310.pyc create mode 100644 dao/__pycache__/teachers_dao.cpython-313.pyc create mode 100644 dao/__pycache__/users_dao.cpython-310.pyc create mode 100644 dao/__pycache__/users_dao.cpython-313.pyc create mode 100644 dao/classes_dao.py create mode 100644 dao/employment_dao.py create mode 100644 dao/scores_dao.py create mode 100644 dao/statistics_dao.py create mode 100644 dao/students_dao.py create mode 100644 dao/teachers_dao.py create mode 100644 dao/users_dao.py create mode 100644 database.py create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 model/__init__.py create mode 100644 model/__pycache__/__init__.cpython-310.pyc create mode 100644 model/__pycache__/__init__.cpython-313.pyc create mode 100644 model/__pycache__/advisor.cpython-310.pyc create mode 100644 model/__pycache__/advisor.cpython-313.pyc create mode 100644 model/__pycache__/classes.cpython-310.pyc create mode 100644 model/__pycache__/classes.cpython-313.pyc create mode 100644 model/__pycache__/employment.cpython-310.pyc create mode 100644 model/__pycache__/employment.cpython-313.pyc create mode 100644 model/__pycache__/scores.cpython-310.pyc create mode 100644 model/__pycache__/scores.cpython-313.pyc create mode 100644 model/__pycache__/students.cpython-310.pyc create mode 100644 model/__pycache__/students.cpython-313.pyc create mode 100644 model/__pycache__/teachers.cpython-310.pyc create mode 100644 model/__pycache__/teachers.cpython-313.pyc create mode 100644 model/__pycache__/user.cpython-310.pyc create mode 100644 model/__pycache__/user.cpython-313.pyc create mode 100644 model/advisor.py create mode 100644 model/classes.py create mode 100644 model/employment.py create mode 100644 model/scores.py create mode 100644 model/students.py create mode 100644 model/teachers.py create mode 100644 model/user.py create mode 100644 nginx.conf create mode 100644 requirements.txt create mode 100644 scheme/__init__.py create mode 100644 scheme/__pycache__/__init__.cpython-310.pyc create mode 100644 scheme/__pycache__/classes.cpython-310.pyc create mode 100644 scheme/__pycache__/classes.cpython-313.pyc create mode 100644 scheme/__pycache__/employment.cpython-310.pyc create mode 100644 scheme/__pycache__/employment.cpython-313.pyc create mode 100644 scheme/__pycache__/scores.cpython-310.pyc create mode 100644 scheme/__pycache__/scores.cpython-313.pyc create mode 100644 scheme/__pycache__/statistics.cpython-310.pyc create mode 100644 scheme/__pycache__/statistics.cpython-313.pyc create mode 100644 scheme/__pycache__/students.cpython-310.pyc create mode 100644 scheme/__pycache__/students.cpython-313.pyc create mode 100644 scheme/__pycache__/teachers.cpython-310.pyc create mode 100644 scheme/__pycache__/teachers.cpython-313.pyc create mode 100644 scheme/__pycache__/users.cpython-310.pyc create mode 100644 scheme/__pycache__/users.cpython-313.pyc create mode 100644 scheme/classes.py create mode 100644 scheme/employment.py create mode 100644 scheme/scores.py create mode 100644 scheme/statistics.py create mode 100644 scheme/students.py create mode 100644 scheme/teachers.py create mode 100644 scheme/users.py create mode 100644 smoke_test.py create mode 100644 sql/init.sql create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/style.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9f17feb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.git/ +.gitignore +*.md +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ee4ef2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Dockerfile:FastAPI 应用镜像(gunicorn + uvicorn worker 多进程生产模式) +FROM python:3.10-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + TZ=Asia/Shanghai + +WORKDIR /app + +# 先装依赖,利用 Docker 层缓存 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 拷贝项目代码 +COPY . . + +EXPOSE 8000 + +# gunicorn 多 worker 启动(uvicorn.workers.UvicornWorker 兼容 FastAPI 异步) +# 生产环境建议 worker 数 = CPU 核数 * 2 + 1 +CMD ["gunicorn", "main:app", \ + "--workers", "4", \ + "--worker-class", "uvicorn.workers.UvicornWorker", \ + "--bind", "0.0.0.0:8000", \ + "--timeout", "60", \ + "--access-logfile", "-", \ + "--error-logfile", "-"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1ffd3e --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# 沃林学生管理系统 + +基于 **FastAPI + SQLAlchemy(同步)+ MySQL + Pydantic v2** 的学生管理系统,提供学生信息、考核成绩、就业管理、班级/老师管理与统计分析能力,内置 JWT 认证(RBAC 角色权限)与 Vue3 前端管理页面,附 Docker / Nginx 生产部署配置。 + +## 目录结构 + +``` +walin_student_system/ +├── main.py # 项目入口:创建应用、注册路由、建表、挂载前端 +├── config.py # 全局配置(环境变量可覆盖) +├── database.py # 数据库连接 / 会话 / 依赖注入 +├── core/ +│ ├── security.py # 密码哈希(PBKDF2) + JWT 签发/校验 +│ └── deps.py # 认证依赖 + 角色级 RBAC +├── model/ # Model 层:SQLAlchemy 表模型 +├── scheme/ # Pydantic 请求/响应模型 +├── dao/ # 数据访问层(含统计动态查询、高级筛选器) +├── api/ # 路由层:auth/classes/teachers/students/scores/employment/statistics +├── static/ # Vue3 前端管理页面(index.html + app.js + style.css) +├── sql/init.sql # 修正版建表语句 + 种子数据(含初始账号) +├── Dockerfile # gunicorn + uvicorn worker 生产镜像 +├── docker-compose.yml # MySQL + API + Nginx 一键部署 +└── nginx.conf # 反向代理配置 +``` + +## 本地运行 + +1. 准备 MySQL,执行初始化脚本(建库建表 + 种子数据 + 初始账号): + + ```bash + mysql -uroot -p < sql/init.sql + ``` + +2. 安装依赖并启动(Python 3.10): + + ```bash + pip install -r requirements.txt + python main.py # 或 uvicorn main:app --reload --port 8000 + ``` + +3. 访问: + - 前端管理页面:(自动跳转到 /static/index.html) + - Swagger 文档: + +4. 默认账号(见 `sql/init.sql`): + + | 账号 | 密码 | 角色 | + |---|---|---| + | admin | admin123 | 管理员(全部权限) | + | teacher1 | teacher123 | 教师(班级1,可管理本班学生) | + | s2026010003 | student123 | 学生(只读本人信息) | + + 本地默认数据库连接为 `root:123456@localhost:3306/walin_db`,与 compose 不同;可用环境变量 `DATABASE_URL` 覆盖。 + +## Docker 一键部署 + +```bash +docker compose up -d --build +``` + +- Nginx 监听 80 端口反向代理 API 容器; +- MySQL 数据持久化在 `mysql_data` 卷,首次启动自动执行 `sql/init.sql`; +- 访问 `http://服务器IP/`。 + +生产环境注意: +- 修改 `docker-compose.yml` 中的 `MYSQL_ROOT_PASSWORD` 与 `SECRET_KEY`; +- 建议在 Nginx 层配置 HTTPS(证书挂载后改 `listen 443 ssl`)。 + +## 认证与权限(RBAC) + +- 登录:`POST /api/auth/login`,返回 JWT;后续请求携带 `Authorization: Bearer `。 +- 角色权限: + - **admin**:全部接口; + - **teacher**:查询类接口 + 本班学生的成绩/就业/学生信息管理(资源归属校验:`teacher.class_id == student.class_id`); + - **student**:只读本人信息与成绩。 + +## 主要接口一览 + +| 模块 | 路径前缀 | 说明 | +|---|---|---| +| 认证 | /api/auth | login / register(仅admin) / me | +| 班级 | /api/classes | add / total_query / single_query / update / delete | +| 老师 | /api/teachers | teacher/add、teacher/delete、teacher/update、teacher/total_query、teacher/single_query | +| 学生 | /api/students | add(学号自动生成)/ total_query(多条件分页)/ single_query / update / delete | +| 成绩 | /api/scores | add(60分红线预警)/ query / update / delete | +| 就业 | /api/employment | open(状态联动→进入就业)/ offer(状态联动→已就业)/ students/{id} / class/{id} / total_query / update / delete | +| 统计 | /api/statistics | 见下 | + +### 统计分析接口 + +| 接口 | 说明 | +|---|---| +| GET /statistics/students/by-age | 动态年龄查询(gt/lt/eq/gte/lte/between) | +| GET /statistics/class/gender-stats | 每班总人数 + 男女分布 | +| GET /statistics/score/all-above?line=80 | 每次考试都在分数线以上的学生 | +| GET /statistics/score/fail?times=2 | 不及格次数 ≥ N 的学生(含明细) | +| GET /statistics/score/class-avg?order=desc | 每次考试每班平均分(动态排序) | +| GET /statistics/employment/top-salary?n=10 | 薪资 Top N | +| GET /statistics/employment/duration | 每个学生就业时长(offer时间-开放时间) | +| GET /statistics/employment/class-avg-duration | 每班平均就业时长 | +| GET /statistics/score/volatility?top_n=5 | 成绩波动最大 Top N(最大分差) | +| GET /statistics/employment/funnel | 班级就业漏斗(总人数→已就业→高薪→就业率) | +| POST /statistics/filter | 通用高级筛选器(AND/OR 嵌套规则,见下) | + +### 高级筛选器示例 + +```json +POST /api/statistics/filter +{ + "model": "student", + "rules": [ + { "field": "age", "operator": ">", "value": 20 }, + { "logic": "OR", "sub_rules": [ + { "field": "salary", "operator": ">=", "value": 10000 }, + { "field": "class_name", "operator": "like", "value": "Java" } + ]} + ] +} +``` + +支持字段:`stu_id, stu_name, age, gender, education, major, native_place, status, class_id, class_name, salary, company_name`;操作符:`> < = != >= <= like in`。 + +## 设计说明(对原始建表语句的修正) + +1. `c_lass` 补充 `class_name`(原表无班级名称,展示/统计都需要); +2. `student` 补充 `status`(在读/进入就业/已就业),就业联动依赖该字段; +3. `score` 补充 `is_deleted`,统一逻辑删除风格; +4. `employment_base` 补充冗余字段 `stu_name / class_name`(需求 2.3 设计提示),登记时从 student/class 同步写入保证一致性; +5. 新增 `user` 表支撑 JWT 登录与 RBAC(原建表语句缺失); +6. 学号生成规则:`入学年份(4位) + 班级号(2位) + 序号(4位)`,如 2026010001。 + +## 待扩展(需求文档第 5 节) + +- Excel 批量导入学生(python-multipart 已装,可加 pandas/openpyxl) +- 部门管理、顾问管理独立模块 +- 强制下线/单点登录管控(需引入 Redis Session) diff --git a/__pycache__/config.cpython-310.pyc b/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c12c72b6d54f737783808fbe87a6accd142fa0a8 GIT binary patch literal 1135 zcmY*Y&u<$=6yBfC#$G#Vnm|OWiUXGjC2^cKKop=GdzF|pw&k@6tSpT-o=IZ5-c4uM zMDD4QA-w?QfS@80tr05X&{V*og|zB_!C|g6-T1`4QW0<0MwHRcoA15%z4vC`?6}Ef zoP(j9`}w00ndANpl^2Hy%0-yos~|9kd7Cr26%Gqn{Di||M=*K9cQ{kPyunRLJz#N# zdMIBUkh!1b4*-NSc?=O%$YzRIFeM_B7#5c}ESWE(>OY zsKAR2e8V{270hJBA0F5e|Kq13eq!KLaT53=5nqK`DRVT!Bj7(3RU5^pxi^=?)R>Jd-C{tUp>BYvv>W*$({Y+;OpMe z&6C3)j;}pzO+vn>zjU5{_s#L$I}qK^8vJ?`9Nl7yRw|)_wys~5=VsC~X_g2L@9SkF zUo0>=r!QAmn7n1#9$}JCTZE}Ot)eY!hK{P`OINeaws*yTeYYL0i?g%Tb^XP;%>4Na z3m0v-ZrMAo=P%CBrx#}TEW6<#y!mc@$D$tb-){L^Z#Fm2v!UEF(zB~Y7B}>4S+Afq z{X=spyQ&pdbhNIcd;yip#auO8flZ-Zn^+z}EtnRvU1u9n%fW6Fc|L^(q8&nsDX$ut z3kxjy-bMv!msX19d}Vc=ozb#c-7rw4xTY77{y`~Uh64FQwW1rQ_(mqpQd$mlR5kQ+ z*cDT@aI@k3Yc6Squ~97NOeHeIF$+@@OhL%8ngk&hN2uxImK|ahp(`!R9(aZj!fqWQ z8omi8!dBS`LYCvWzU4Pu#{-uXx&+r_v2EfL=VK<@?jE5ud;m<^bX}V&0O@cj!k0%A zp+{^uK2<|C8HyB$pE&wTh(qygJ-b+|dW3qljRy2-4_236UT$IBu4U4hh3WKL)0w&H zIgs#WYW-{S{1zsTkG!_$lV+{%I$MqHncX%OAZHAw_Zo;eC-JJN@@IKT6nH^UpP!W@ z#H)X+(xfmhPV!O2lt?FlA7Q4z_W__==oAT&`- literal 0 HcmV?d00001 diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ce4fb5e1b5634d073df662df3b96c2adb253a45e GIT binary patch literal 1636 zcmb7E?N3`(7(bWZwwKZlC@tkhH$^AbMcxZSaI=&vbc{lJ%H~{?lS{djF6CZ!dh5bI z)hq&uFC%0MfkZZA<}?}j^1&%H{tJ+sI6R*;j+K5AQxm^>&ZS-UMG{YPpYy!@p5OW1 z=d?HPaM%D{!trk|mzx3jg9XLK7o2<(bv_0VKyCJE98vLQV8;oIK~$Rn1cY9Kn|NuQbx=}6E;UJQ7hydDHNKqU_=%cMN7l3 zLjO}6RbqugQAgPGRH@(=Rl(ApK>)Bl>Y)7ABN=V5tjLIu847Gi;_d%SLAjy8ek7v< z9($so!cgE~cvy}rji|!tb~gO?R4Spf$l#;XW(!wEo%G!+k36{wx{7_+Wt>%YB;F0H zpWxjF-fbxL;Ocmdx0VP{*0h8YPvzL{r-!J?t<62Ue=oanJG(fSyZu9UZ6o*IhuNPt z{(SI3_U8JdAHT?cw7K`$_5IE7bD7ospXU$5y&EgJ`5XIp=CW&_Lrbi(wcX2@!L@*f%yn z%#%_wB@d1_|X*n66rv8rl4t!?cczRow2YD`K_ zsVS|ey}hNg`KpvmC}?8*Ol(TRDOr0xtxdi*J>EgA{e4Ib42}>R6a!%~f`-J4q&G0= z4-JTDSVX}P8V!&1j|C!B6q-rOQc6Z?`ba3LDshA*WkQ`sDGk#pqA3~6#5@Swe4WH` z_Cf^t&kc-(gOR~uQtuB0Lv)ejYULAOkHg)4CTR&(Nzsy(l}gkUvBYIfR^B1zqfeOnyp^xDWX>(VkO#cka$o~)&vI}n zxRSW*(Os<%U0pk_F5T6A|NM?CkQrEr=zM@OcxsmuONm{YrWru6nm$?D#sa z*PYIU76UqedbjSGV)n9L=V#epct1W_9D~<{PZWJ3(4VgHcQs~r2zrTDiO_p^^{4U6 lG-O|t6ze)LaU7So0M7Q0rJn2K{sM;W1$Ni_J?{zz!M}rIrPu%f literal 0 HcmV?d00001 diff --git a/__pycache__/database.cpython-310.pyc b/__pycache__/database.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..934f80d7cb09b700a88fc07bfc0d76f80c28f126 GIT binary patch literal 773 zcmYjPK~EDw6rP#gZnw*};iQQN&h`L-9!xYwZ2`lfLbM=p(q!H43}s<=r_O9`YA`|3 z#Dk~_F(w+K95pHu4-(*)?9E6koVXhg&XlOU-Ff?c-@Ms*@6E7m)&kPZfvtt_IsiWu zIcbI@SL6`4r6YjA0E7@js4xmps9`PCvEJiakO~cKKtPR!G&U)uCecYsvJ5d+b!<_M zq)D1u#9Y;IAJta?wn>Iqj{we+z6!|OhjJH?he_7s)Qzb_qq-l_JW6aqh45Jvx{H)2 z{e*gf%iY+&OP!i4s9a(S8pnQA7t?-!PX0v0*#={Q)8N!;NFCcf%Bk1(0*ZC&&9kdA z%5s}A^H>=AG0rGF7c|9uqSyS0cq*O01|=}%P@*_1-2)JV6>uNivzEY30KqI+0`K64 zLgUeR+Wy|_gU$7$ zy{E@9KX=!<>u)97J$&_PQgxwn+=Vjp+#pGL0qd2HNbNn~>L7njjb5nE2+Bou zR_4!I^2_*QsYyt?S{N>z8yr4ASQr@`kzT%qYRe7$$Psaq&?t69Tf{W15;t~LSBDzy z#HiEQA+-TjwAukJ8wf!Yozb!#+{iPWqi7Fy<9AhPK;l><(!;I(qkoOd~{+eF^+EoPpidxNha7! qp;!0`*QchCkCeBD_YyHqhhK5oIsz-;$@0@99w*Cj`OWz>? literal 0 HcmV?d00001 diff --git a/__pycache__/database.cpython-313.pyc b/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a787f38005182611e441addf37ea6c4d88b30772 GIT binary patch literal 940 zcmah{&1(}u6rag%Hk-|dNo%eBu*Md_T-sBy1wTmoK}ll8ZhMdhmd$38u5Pk%c2npr z6omTGRxCwOii#&itwqGr_TZmj@zAgr6_ePWYzTq}56&hH)sqiq-tWElo5#H0OvUBm z05P^>?b=rwfNy3{H`@WIED*2^c)-Ij2tf>eCbonvAqrC=8q?L73R^=AW_%#ZjJfMA z4n1b0uoFaCp60ED(QsI)BcAD}F&Cve0dM1NQI2Q(DeQ>SouK!Cf}Om*6<}AA^E>v; zoF5u42_sQQ0!k<4H1b;vr-n35R?{i*I>Lr0jwD6IqApJ(Atq{w9AP!2>vB4&jmsW# zQ$J<6GODTw8H|Js$s5jUh>$aSvTw^PAFMz%AjL%Ixf|x zFg+0xbdgojtH4LZn}h^btv*Z|bbtvk4VIyg_%Z6B{FkdUeIN->{g;yf=c$t*0nbti z=(l7ql18P~#nQ{CyQ}X@YqQ0BLQZ1F44i|3@jx)Z z4+#@v5u=&E5(z{GMusk375*vM27|)Tz{q8Tsr@5t{$PU+lDVqGJO>q$0N87$*>fp1 zo>kBVe1e3g4B8~w_9+PAPZq$_pFw{SwCyvXt!t;XXS=oMBWNyjfNRdvZTW^{d2dtR x?%lDsY};GPb}MJeF=Y?%v_HwNTR#kM9|`B2Wd^XvKiOJxbcJPLBW@@C{{R;b^`ig) literal 0 HcmV?d00001 diff --git a/__pycache__/main.cpython-310.pyc b/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ee0d90e01d104eeaddd65761e104f86c2ce7b47 GIT binary patch literal 2347 zcmY*a>rWI{6yJHUJNsaHD&PxzyC^KuM^e)m(Q0E_(L%M!gl04By};l+Hg{%4_7eo6 z*mM;Vk|w4tVoZx|1WnVH0@e@xCz|_JhK2M4->pr0?w#c|%iQ}rzk6opo^yZaV3(Fg z0&x9Z|DxXu2Li8|vHc2xaSm>W3V{F$Ai)Tjf+`3Mi-u@Qs>HEm$YzOJ!m(@w&5#=6 zxWrJ*uo~t#Xhh7Y8s#`-#LQB))QqcfZYxHaS_bb78|7w&TEV#p;7YZM<0#;2br;7m zz`NBNj!Oa8s&yR40q;@wa$E*@pSoWNU;&k5sYgT=sB%;u5!3@%!qNVER0U%LK7gw8 zvU(67>_3D{(5_wq?dBsxHF<~!Ru1Ee{v)Us)d9CB5AkxY3b?&!-wtjUaQo4L9b64? z^{8P7R|nidbZ7^+7r4Xd$PR8la7WRxZQN0G9EZ^f*kvLw!rWNg&oOiookC5(HKXKc z0OCB3PoS2YBKZfhsu&41ffN0W=rn3Y??B`y(YtvWGNp9#JMrEcJN1M>t2AfON~dkh z#kXBkv$S4JfDT>K9JlS-Wq|PuS35ed=m;5jKqL66K+8KY(g{wx9oWg(mV?17+UaVp zp1!0T*a1^;vg{gdAb8tH>%39X#gE^EoG{^D%U96@1F!7&tvj|%* z!$Bu)6YdegX2!4wO;`Xv;;_#-@I{A8S|*bQxo%zzOK@KhLCAXm42y1{U+5QI2?=>7 zX7NUVjh%snl>HJMyoJfaNrgzM5J2=C{?mKLPL-7gncd z)|bYX=kv=iUx9x^n}N-4J-xp4wYN0nJ(x(YEKL@kEEMj~d5=fDCsW|FzI2a8+^t)v zJs}fTeN-{At0B$RXe3Rr=HirQ7zD}@4un*~^fW+r!}R8p3un5nJCK#GFW`B50A`!q zJfB5qu&cGD_5G%n51Lv}H=TwN{#v^RG()#ij-S4iGw8V3>@qdoYR(K&#n5};yfmv0 zUhM>75QpMYL?E>Qvm-EgQwz%r_Zx~w-*B>_^RrLe3^*2Zu%Ym9)O+>=WT-GZT^PSp z_+_!M$dkm$HX!h>gpg&&ivurNQi z#V$^*&Q5z{cUHb1Psp^y)m;PAD8f#f=oweHEgHH7NdUn2N`=PH!LJmOW)GxnqJz2| zTH%|JP^KMSGn@?%$abHNHqAb)A$VIZoYXRUl1VYklCe7bWOZ(s$u&c8kA9oVuGZ`1 zV%)1J)NBkK{UH3^=SunVhIsk<_UMZ1#S?M(`t~&DqWpPZ% zn)5xjih+B;$!sPx7fSNgG?WsrP>3+>SFcS5sodw91{0{InfQ&6yac#Hx6%ewV2Wvp zoxYZauyng6?++!Wjj+M`nogqNL|B$-v?#(9qe~eR+t3}S;+)!$8mGz_74g-@iE>87 zw*3QqEE@`lPu))c7HcI{fKz3s|H8fECl#g5L)SKE1uqHztO)K*4ld_y(F&bpXD zdi@!K$yzRzda=8CPE2g66b)r>>5xc^N+fI1l8-K)zy9&I(`GG@@FQ0~gp6bje3o@~ zryGWt6cb|t|B2;-B5qoo6Qy!N49S8husirxR(vFb9sIXCTrF(j)Cac! literal 0 HcmV?d00001 diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99d6b9f80a51f852f6b2854d7a48428e878d632e GIT binary patch literal 3174 zcma)8ZA=^I9e>Vuet!Ym0m4fXCnW?YscE}p9Z8jyw4@6jB=x1ywz#q`_5oMfXY|}T z4W86NQx!5T4M?dvMQaIFw2@5>t)^`Z=$eoFXloy^ZbF+B4#TH7V*9Xf`#;b5a8xm| z!#vOL`M=!V^Lu{(zrE%4x)5BY17E$PI}v(J7Ut!sf~`Rop^p)dcqWSa7|etSwnQy` zEM_TYqg;`{kvH-`_#55{^;TWyY8`8{0>-#|x#8oPlF zh<&3LW1_e}_5%~wX1;*Y?ob#_}|4p+JFHzR5@f0RGQ zABU*tThO+Oj>J3p$f+M8BpgDdA{@~$4#&f>OSL=`!E?I!7x))JTbE5+ZRTI<=0a%5 zG2{q;XOo!2j20A>Q8}5+%Bpx>%?PqEBw{ez&k2g!bMZVt-)k3Qe7}@RrNwszEIweg z`k0uKu$WY1qB4?|6%nG`yeg$nr8f$b{M4 z+Yq5I-YMUiE8m&Xr@z*x=hlBU^JMw1J~glZd~W@dKW&th__ZQ0_&Fi1v(C^Pc%#-DtgsZEPoEivo*bNWU z92rp+Qi3XIt|S%(RZIx!G$ySz%-|qQ3gIE7*)xJ9M@B|9M_L+$LlR`%3=ZwH3Lx`yB-`hgUi0e-y&t`<+Lb+zkNw%;MVON1>`BLdQbaVD zFTYw_xND@qvV8lC^5Sp0Ho&nkS+g$9Y&@LRCqG>OY%0uYR#j5dqUKJCN)k&Ws+5&A z`!!Gu0J=GiIeH1=yue8I-9#2kAUZ|!8%tn|NJ%67 z|16DpH_bP+PrU|p)w|bGZ>=7T{AW1L8xge{p%HL0+l1!rUDTU|3ZsP!J9HXFoyDUX zH>_sTM18W55jCdk9ALjBC(}@v2}w>UNjtde>ZPzb943&o1cE34^&^Y3+h0;9;v<< zo5N{L&gTKzf)U}pFlZdkW>p*kNWO6uQV><8d)f-26GivQ0(Y{iZkGqTR3bqNC6UNG z$b1SKN#6cKg#JmdLZO6NpdzcN8aI-~s%FDtIxD2Y$D#a;j+vIOV))g9H2WYlOi&Ic zJZMeTRV1_BHzUx<6igb8<~P02*=YY%u$|b0rrkJ_LDEYx`E{h(;BkY}5Qd*l&;&>W zxC*_L7QWFKteI>|Op{h9VWLLtC6Vr`Koi95BNmUTNouO2CR2vgR8LGULwVHTCk9_l zY}6Q_M*5nx2U8>IE}Em~;>E;3Pk*oGCg^f+j6Z*2fc7TMC!|t|8Y7y|R20tUR1pKc z#tfUu$*RTOngm438jEwXW_`W)>|1Z(6PH&V({>s{Ai%?8c)5KYTj2?f*r+R$K1~z$%WpxsQ2>!>pis+Y%d1e=iYu4DfGNa zb7QsKGb_Q)Vz9ID;@Q7O3g>@J-9OpI4fHYGgmc_E8J@l}_q~PoV#D_z4UajC=mKHZ zI?*)VG#R{S9cwBu9ZxM5=DUR7BNc)w9>jSj^M7NT|IP*e$+;(@??+3HK*`%!^7>14 VO}F2>`QDb(YPD7Dh-;z8|6kJvjClY6 literal 0 HcmV?d00001 diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..753f86f --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,2 @@ +# api/__init__.py +# 路由层包 diff --git a/api/__pycache__/__init__.cpython-310.pyc b/api/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a3eee0e47dfacab1cfbd2711d8741f4c3adc2fb3 GIT binary patch literal 176 zcmd1j<>g`kf^+g4vlM~!V-N=!FakLaKwQiMBvKfH88jLFRx%WUgb~EAU}vkC(Bjmh z;+XKvyp;TMAe~>7?NpkQQW;}pU}UCiV5w_lsA~u$fygMPJTWITFTS{>G$l2!B)+(^ qxFj_x`VT13;B#)zl+#3nwqsju5QK^yf9KVc`7tb3-Pw3B|y zPAT2+(tgIy_*pxv^n{o5^LAdzrf2yDyP)KxSM>YrJ|(BTet*Co@Jn{dAG8PkA$y2u zoGfbg9=>O8n5Fp;%dqUS&M7Y}l4-(nyu|W6F>Nr56~0X@5__+TEb>9t_bL+Nv#fuS zKn{hR0nA|oyl>iMB{rz?_Cel$HUxQldU+Xj2aKX@nC(?j2iU$-T4nz>B304j^s%o# zKN&=U+2xx4H^{^Cr@!HQKWIJBJjAxUB3qN@na`IG%=b@fn=bu zVsxFy7>miESXN=3a+28U|_e#=VWDhnjisBNwJV!<7q)NQ-dbC`Pvfi%oiLKM zIvgW584n%Tt57?)y9%)pA(ocFHi-)e@l;FIqT%>FPN7#x8CTW-4IXkPOpHyvtZsfs zs?9obRv;iEi6KeMuDL8Wu$-cZjsiL?g>3DtiF3ByCY-ojf@{1ISMQM$E)XH-$qS_5kq3hmJGRH zb((I~iNe{@W-Cr88uY>5vL656hq($ z@&VZZEHRidPemE|f-Dkc@F$qL%bx`Q82C-)A5;ER*AF~QA`gLuM%w`Pq$k|lNE|%$ zOX74MKIr^%tMk)6fo%bF@8agJVB{e%^$g{$pTFO_`k?dV^7iWb#T2wxzV?0h{Cd{lB^oEx@OTdKyh)5^hkQwsdR z3Hb6sG;@Mc>_}*2C+IeO3fj+^a=@6ykc{d}6(n+K$Vt2<@1}`7?q%VEU99>!HbwWt zU^D|6sODjsOpg;!^EVPae!EPQ_m3N TIrQbfl+qVe&po&L50L)=6xKoM literal 0 HcmV?d00001 diff --git a/api/__pycache__/auth.cpython-313.pyc b/api/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ecb388d4fe54f56a739433ecd6ecbc0ea670ed31 GIT binary patch literal 2958 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 Wv`8d0Lq$JN>L(MZKl2OClm7v*pbX#u literal 0 HcmV?d00001 diff --git a/api/__pycache__/classes.cpython-313.pyc b/api/__pycache__/classes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4f6d107e93041a9de5ea450f0a39d0eeb31aaaca GIT binary patch literal 3882 zcmb7GTW}NC89sZFc4b)?OEShX4!+f(5Nwm+IPO3)E|=IcF^SjC&{#7bSz3&!EQzyH z8ko*hhF+Wi6Vm45G|9wd($q{tpqXB1fcCA^hd#*o!Dh#_FpY)%gc#E4kPW6OOwT3 zcd&w11U=Lf^iproM}2i9#59n0<6UTFsIq^n`C_GBvW2`-nPl(eLTm#GIocV0PJ6O7 z5UJc8G5hVT-nDkWF{?BRoE@3)5DPrrE(%YoKZl9gUUxTF)9z!WL#EYi?}NuR+Qac z`$4wi^5_Ui{2(j_aDBEb8ddGuy1`U5oR(FG_RudYsboTt)e7x#Urb4xL&x~ma5S2M zL?w$8?=4>b;`Zz3Z-4PCcqJz_b`6guqjFpol+4&zm>yLv;pkW_p_WDPeTD%htg_J& zwS494M>r(KxGd10fcxI(&@_@d$oxUlZ%nT2Mj%UukP+?b=S`{9H;@DFwRW}}yAs?W zx7k=8hH5h;n0qBgVkNGVm6;U?qB&dY5y8~fL^`;GOn~oOeiy1h<}p~M)6=ti@eiLb zo}XNNfBMdqiN#Yt&Y%2r>FR~WiK*pxKVJIm;^Nh}?p%5G9IM)ubU2++h9f|sYK_Y2 za4a6MsG`nYEK2R5Q}x9XN=lAE1Zb4TqQjZQ5sI{_Ht>!<9N=jsjL>!1RN2`n6&OeaE{h zyQ-LD<*n&tIvgK9f&rd+1Sawy{&MNUM>=1N6YngY)u@{I$@1CX0XGo3?5MmP#W%tV zyZl#Z0xWHSPG#e=$}8WCrBq8iHWo`qRz?DXYS9z~L64>(ko2p+7(M0)A>h%vK}KRa zxeUyv_6#Ah+I3)$#CEcVm$^QG-vr|n0|*kpqFJO`Vrh9y32?fr1kAi}JWeZck+rm; z!MiBa?u8!8jvufAU9GxsH6b~OHN@<1$-SU-__%_UW8?SjAr@nKka>o12 z_`d9(1^b4n_8I$=IsVD7HAf$>Z)A=+S`~17Ts8>zfr|XpAM>X_>)E}M_!e3VGizt# z0L(ljf5nWz{EA>+6*(;B)gY%cc@FaBP4MZzngBre|Cg6eo7r+IKk+ss&(cS~E+!21 z!$OnbGL7I!I`*=xWdMXdnoy!w6{911Mij$dC)ZXamu`n0=mn(>8jad=;$A=JYPjZV zn0kEH)t2p^7hE$!)q=BTn#*l}X~sE}6Nl#Q-t2yZEr@guW1m~m{6%1VUBwAz1P3>Pcni=g2leNaD0+KsB3;0O5! zdV$xyKwCkzNvc)1LnqXt*O67M3i^_$g73B>WXtt&opFi>d+-g0Qp(c2Y0uFg#%>D0yP-$bT8OJS6M)MZU!BBigA_ zihej#Bpy$dK~A!K`v6%zZZIL9ETcUDGp*CCz1&64vXgJwo9BuH`k<{z4h~FP0VfYBb zPZ+gK`&MbeFDUB30jGkxWpg8SqCOWkcp_?5T(wgDtBp@3m9%zRsvOi3m5U|PT9wx7 ziOQuiX)OWpAjk04csob#fg+-pAaMuaXX5hHbU%#Yxu#UWnN#2xhWWRZFgs_-jvJ)m zHtD!Qns1O=`2T|p%#wjyHqXhLthFE#R{Uwrc+JF%Q_o#`I$M)tf_JUN>z{1?dF%D+ zy0f*@-=D4Tn5};5u3%$Y=H2TH9P~FuSHS{(0hU&Mg){d!!uB!+76<*P(?9N?=*w-{ zk=uJP>(4Qxx7?Ly!joNZIImZ%JEKei}Ld`;hg5Jbi|@2n_r z&}();Z`c76DjovGIJn+a+%q+n+xY@2?gw%z{ir&(?b#V$cS-vSrffFGJMXSRro1-+ z37GO4Mxdk{0TN2nmv6`$#uon5U~izG|SVT^?hhBZb4`G^mU!3JM(gl*xZvW07F*&gH-GrLRA z3`X**N_fEri~`sM!r@qQ`2Z45A1Hv~L%!rg{(#ivTcW+bgb%4y`69`C-Frz0k`dE% zzkdC?=l$N*n`&zEOYr=4{`vk@cS_RVaZvxX!eAA=)<1|OQHjciWXciQBoSgN5yey^ zs*H4EcuXy#@xEesO<%-kHbfdsf5gves?lf$A_3m_7)@p{66Af&m|-?Ynt9)Aw3w}t zR^Im+cbILFHnTm_ZiXTub7o|wIV&>DoE@1hOS+tuB6Dbiu2H|F=O-=(A`WZ68r|qBmz{Xd7sV&didm44kv{ zHZEzl-U?FY&^vc{vT|fTUpW_6-gTRm?R6{X(YyJ|dtl|gv;$VopS04)cZ0Lmt+|KZ z%hxR6YZmCU=|a9n1wBjjz7e9UbP-)l!}R`XPp{G?AnyTqm)6Pk(@xsOJpq2*WBPscaeWbe0$#)o_1a8V z&?otc7Q>0cbR}r#sq%?d!QQKHAz1-*LbP6IKKe9$hD*91BrTz9=vw+LeU6{}_LAzQ zX!Lpd0+;dt?e3MrFP6{~!m8V_`sEj1O*yvCT<;n^ttYAFHa)+7{mW~&#r3qENF`m* zD~`?v!m{h_)h!DsVa09k)9q;7VN6fjQOAPSO-$e7B$ys$DMPp1c4V;iSo{s0MdL=y zvZ4uUfsm%PX4*&%n6Plo>UBWy_u3Avjx=0HHv(OK4a9~uH&|P+4$kNL)@|CPv(=Qk z&9#}=(llo4ZWEt-RkzZqq@}y9HHIoJRHMC=uL;7W#WRRj{$yTXXqAKXy)8l$#)J!DTyWX-^o(1iz zs4Ul4(GP0?$*#Ymwge3;tPM!49qC?6DC(rd?t=06uRZhl2Go(Y;TMS{O}z>Kr`YDF z9ZCl_bT8|EWa+ZUmUb^+x*S^g*S+D*n2|_E%R-{ofMx6EhFChWL9ZgxnI3TGMdPt# z)Qlx#eR`F<#xfJOwlJ{zWmWdet_OI`SWkOnh^^IJ`4|e0ToCY~jpUVgEbDv!9mP&YhnaKRR)3EWiD1 zPwAj6NzMWk$z@E8j|ef>uk6k5I+VM3q4@Fdir<{g?HVl|`Q#*V8!bC#J61HFqPnY5 z-Hs)U(g!HyF%Xjf^^x58uH3#;#q2lbZQnhWyLdjodpI}#Sz+Jl!llo}Munq0a&-p@ ztE>^$yM8qNSlmu*)fp;{s}&}z7Qi}?Iu{!>0MX2Yp4%P;KZ|d!!X?l{SgZgv0pgPb z{13od1cW#oy81x&lhD4oZKFB_NMrJlOl3-TNExZ0qyv4@8YwGh6J@RJRUeC4R%EBRFsu{y>o?;ipLCVC3Gsx;60e**<##GOJUfsU=uIh7~L3y z@Sx)2shb7rrG{}k4H~~3LPPI@?x3eD1#yUDUN~~Oc;=h{Gk0Y;fAH${E4#`d@A*1E zyuG;nU~bQezrFK*@!GN6&QAsB^((vK7{SWXqg)N(-i~R;*Z@NhW3!-f)r~1S;0EgB z>?HR>h2#52crEme?VxUOABaEwa#oQgn~W+jhLLbMHq%xSQ>t)hBcmQM3mn>*GNjtx zjG9Gs)trUYPEK_ZH7y%)NK5^zKORlM%td?tyh-AGbAOwHPs6A6hu7 zp4+!O1N9iKg%-E1){pWzJ7MZv>0uA+1_{C!@KCN#-v&4*l6`C`PE2+P_Ath=SdvFj@ab`Zg6?_m3s@P3}03Fo}FNa_&bp96vRDzf{yB-Em zz9*yfYnvsu5a=pIy$U?uDm)(G^1<6M<^fp!^}uq#v>rAec6U}oQv>YW(fsjKx5RAc zuHw0D;oymmuA1JtZFBfa`_TYUKU^~t4eL@r&7T z;}qQ4Lm)&z?&q?i9M?yH>NdnEjdCK`Ie2(1IS@_8Oo*>m%s_9z13(a*jQ+HkKMtHv z!E2$@3(PBFZw?^OaDK40tDFF>uD3+O8gkIr@*jO#Jp1v@S>h(J5pWLni_qB)zE-XS zS}^lxq|FLznVir@RRpn|@DTj@^@+^}=sAGi9ue6Wf>;MlTAZc$`TND)7erQpGZv1& zSL9h`N)F+OaSQktTy<&|kU#o%{_v>4NTj{6#%2SD@JgO$xN8QvYYtR0NJZ@($jTEC zI^A??cSE?cQdQIp>1E(AdtaM|$p^6j??Z$JTe^73)Ad%R@)>e4j|mX!Q{|+S%P#5D zOB5JOV470tPtZUvSq1;O#L(Fs?5Nh}MB4Qj2{U2GF*sHp^imAv96T)H&{F|2QE?C| zkEk2SkY`AP^38)G5)z+=DGw_UUc$j%YhfLG7Mh!qAF8q!-hZODc=FRQ`DNs6ed;?Y za>z--Ftxby#;Jh|6Wkjp29sQyG`JYGE3{P8?R3cw>JyKhvSWtpNkCy|LGnXjBY`ro zTn4)XNMSG6RP9^p0R9=wvmR_F2UNFhx^qN{ZEoMiE-dTzjdv9fohp8{qqsMlJN6~y$sPww-~8U);0?f4 zxNx;Fc1DP)Am2!JE6x)9uHvBaBR3b8+doqH{9OLTSmBF3ja0WZ`gO3BX>c4ml0|ilJ2vW9O&P3@+f%)Fs~O z?1{YutYsr|y@-CyFoga1Z-3JN$DEp{9>IX!c-=^b)kY%M|M|ZQHL&$~aBr8x?|D2} zOk6vYKlq3Gh@JcBQekXZgt<-*shBNXIRv&7M-Qz$7znDB)6ZeR(>9{T>n2FFSo=by zG(v{djB14-X2TVQdc-9KVmeg885L?K51@-teGzbI)LZ5NDv#>Ep7l`bHR;>p23&HZ z4%|v@H&7WSQ%RjY3cK@LNb%h-a_0`@_wAp!dTe5Rcw+oi@w4sXD>2%iKAb=ERV~i5 zCt)1~b1dgwzo?|CW2VK`fyeYlp)FBy@jR-DJlX+kYA8i2d{BhPrVa5eWkIzFv;!oR z^K8H?YO7!+!v7NH04gZ$bz3j}8Ai_2(1>ePJzfGS2mz(x%f+h)1Or^Y3cPgA9s6x* z7_@K+E_TJUmwD05?gc??IW)J<%aza$_&^=Zt6+H)(JJR)tKC*=H86#s4b?7Bom{0{ z!%%RbUU>v{1L8}fMxyTxo2wV5J8-(17_oSd$KltJsbRCFytbyfL$Huv%E)j4@*-BH zaIttDsNtoqcT)`RnCS$==+6{v&}tc)svESn7%>Abdgef9ikS>;*lmcXnBGbCw8b#N zF$_)oXM{*Ajiowk`$l0XzNZ-{Dpe3CH=-mT!q1CyjE)guQl0pV2x~$@i-lY3bs`xS z*K7th&Dh|jPTaUK&A1v9zbnBlCS{1<2$V$97MewKbk%gqvUxqui!)b&FvAN3o=IE< zCSA`aBNel`|GP@su^E~d-oDYgxZdh?UX6IJZw0u7W9TcfQn3C4%`6g-+u#*{9kSLC zQbN9P+L!K5S9E$Jb@pA0YBf- u&v)f~-`>neejpn8o_NW3#25HBI5PnIfW#jsa|V74wgd>DsXJ@)jsF5Y*wwNC literal 0 HcmV?d00001 diff --git a/api/__pycache__/employment.cpython-313.pyc b/api/__pycache__/employment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..92021c26303e260cd335a1007c353d2ffb4278d9 GIT binary patch literal 8598 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
    ^zI_}T_hvmi$ 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 Oze#rB`l(!WT>Bq3w#&u< literal 0 HcmV?d00001 diff --git a/api/__pycache__/students.cpython-313.pyc b/api/__pycache__/students.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b3ef175f6d41b0033727596672a1d25c9144b654 GIT binary patch literal 7462 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 literal 0 HcmV?d00001 diff --git a/api/__pycache__/teachers.cpython-310.pyc b/api/__pycache__/teachers.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..671aba93dc7f408ede047b6406443327528d2f5a GIT binary patch literal 3079 zcmai0?T;J98Q+;*uYLCV27euu>$#OiCuwmDB zW_AS^Int#^!ELdUN(pTUf*b-uppX<=9fi<*=$C$|`Ugxumi5`6@~K}c0z5Oj_BkOD zd$*5g=6RWUUVhJPJ)2Ey@cd`TpN~A8*R=o2$?zk&KZlq98)+KR2y!(K*~r5-_Hl-+_lkBAX%sDM_5__cvVo-NI2j@7woWn4E+e2LB(sc`(IHGMI!dzCBsrQtWRg57 zyqH)<_D0Z~B%@@EjFTeh8DxTNAREc#Eo|sy6Wugq;|{V}*|-C2Y$jV^@2zU@$i}wY z*w{|TZm^RgQ)Gv-vjyyIC3n&yxhu9KW42S-G05F;u3h9Fa&Oxhc$Wgt3{rwq-8Xcy z6uF=LKv~?TEKbA8W`<6-yNz$KaI;@~$X=zroy^W_r3ZR4(&dNgnt zEwj2yIY_4u&`R}L%J%Pj9A>6eAO~S&JPqQobDzJD5Rr{#pKg$fPeneSK1F$>UgK1Z z#|w`+-0v?Qvv&K@4!bLa1owbHVe4G@`DdLoXTwih*RHg})px>^tKrE@-Iw1FfBW9r z={GyAQ`fHi2F{;jvFWl`C)5>59(Z1bEs8{icuq|uW77hYgTfkiw5rR<#ZLl$4S4?n zFFyd}1oE}>sEL}|5xjzr>rI^(e4QYIiQdM)lIsT10>pNLTtdkiAZIiUSkZ|=5|JEm zP03xskSueqcN!r0zg@7w+Q|>sKD+Sf{x3gmhadhieEq#^S6=C~UJX}2?!NUw_lrwm z>wNF*+ZVCOaKGXQyj%tUgh{Ahaa`vtm| z1}n)%KtU9p8gI}lWZkWBUUo=1s2vrl{*1{mh;$q_VA~t|3hQf1B?H+i$7`ho8wWjB zlw?9$$k7)d$4>2Vw2xgF%wv^{8k+*a|Ih#8CkwKY_`)+zjntoq-#UBlp@5LZh1or` z4^Hp-@$~H8>AgVVe|F*ditE(M+z*H>S-!}9>Mc|n&O&UL&omapYM8R}%~sj~q?)hcudm7j?002899l*+M% zINWyuhszrVFIlFHu7TLSFdd3+pqhaSZ0FoxIFN{Ur=eNK8UY224))=sa#>Igc>OCx)>LTm!fgmHP2SGXXjp+odFo z7_|s64H62qi74c3BW$8#set#r!5<*7WU?(%Rick=mPA3!jKU&DWJ5i;2uK6Ll4Q$5 zSx*Ps4pWhgq{+=W$wljZ9PRQV71jJS@QxJ+HSgE`id#M^uY^FVb>4iZ zd+Dv%t=1p9E6|ZzVe6&d%3q;1g{MA{U;^b?iO?&>Gqt1m;=qBhexbGb*r zR_~ucqRd`B9iDqbruFru@a+o&F{sC#AS19#+1+wr@aLR{NVtyY__t4VA0V1qXcoMWg`|vp#dR5cG(mG-R~QQZ z1JcM3C7Bu!1qMqh_gl_Gz~iza@lr7f?d3rj9-A11nnN3OX&h^b#?JY_b=n_AtztOt zCAd$3efusUZ;qS0geu?B;ZD)i=b@lMH1?F;593>dJ!;!ilCOyJEi>fX9IyuXmX!qf z{t>Lrm8L}UPz5fhhQqMbm-ngKgh^bA96#z-T=;PC7H8_rV=_Eqq*`ZmhR_CQNhzKJ zVi!oIKGP2+AiW;7s+7e1!3tarvc1@8b-~ZbZxoi13wbV$(}5@{6H4QvWy7|jhzs?SOcUnk5J^qfG?iO6d6U9Zj7ibiM`S^ z@TYS6Kxx82r;tL(G$xZ2(>!p~cAyCxUS>6VaMs|qt-x&q+)XN4wPszg!qw6WRowP(iMu9Xe*oLScC5f&4cMEjn5x9O z15Ipa1sg}&BVps2bb$xfvfb=DpQeuQ4HbWD*vDYq>uc6-V7)8w*L)5BjMTzk^G1@dz(kzQ2F}-kq<6xnwGu zh>O~NX$~FrlA@mFcph4oVJ{F~O0-RIsbDyb5EoAc(>$24Anr&yint(3#5f*?H0 z;X@qSF}Mf5ba+5y93;&sr~|y(KNyLK7Dab7840C0(W-ooaC|Zm=Q+`#G&~>WQ)=Te zb8kvU=nX|8>BnGV#q9awXP=hNoGV_<-o2JBUU|QG@=EdK<)!1lEB<0;@$@^T?5Vrg zUW55FNHHCpOhmYtsOQs@lOc3e)P^FH(YUBrOp8=xTr}4%G)!anDUTNFpTK*+33gr= z;Rc>Kp!N|X*jFna5AmXHM6;q1KKm8jI6X?QSGps>KnL`yE=#f$OZzB}93@5!YHPI~ zhh1BPt+H2DfsnB4jI3a5Ml~vS=s|1^tCg{95X?2E6%iw-*47f6=$A>aZg}}L_~u{h zKxOgdM~k0c+`Z$At0#&doh`mSbNAXCrR{qC^v)3Z>F~ zFbuH~brCKVipHYnz)bpaFr$R^$|tIkZ1Ihsm0tg_Vqxjy+2ZGCaG>?b1OvpTXq-=S zVaV86i06aRNH85gA{x{$gu^H{C?SV#u|*wFZIgzWAk%_Mn^%jPVH|3~MJu+zaIZjq zM&lDQj%cfVD?@k;rqQzZk?wf1wn;j|m zcISM%^UeOu@GX;bfpN{yLSG=qj0uLZ+j^#;cjoY~d%@LRaBZ1$ZMiy97-VyUY~HnB zFs!;|axK_9X0{6hk(`|q%-n*hxnSy?Gj&dT@}?d^)AJRt1Kd})>9!fjV^(!~y|6*J z6u10$=u6x2#4?{P{pFW(wB*g^rO;~VKn^H+mLX6?dKDB)znzk`7T@}*tSHA0HXt>@ ziKYn0hf!3b8srB3`g`jK(DSH!OU=HA_7V2H^i=oJgp{F|G{eXVJraS`PO@&6%F976 z7~rMl#xaU^qB$vVJ8Qf7QF8Hgsu;P^GAq&i}FW z=B&KJFRouGO{(GK2Te5MBXj&H@DV8X;%+xCDtv=*l8Aa)J0fje zoLXvfF>;4WSEvuPpM;lBLV?q&%n*jAf_`-l|JE-!y9&;MIp@GtdtvKHZtF$6Udq`+f;qIntST@ab4AU(mU@jU4B<_VD{Hb7vO}*7PCKJzVIhFDvGB* zkWY}}sS}GAXMx|Pyk5HUho$SsA$w)oFP)iPj^Gvk6$&}Sub(cS{|!#>w=>1}E>_fF z1LNaPq+)O%!GebWVKgafW6{ZI>VbsEtSBYCAB0DwBKIiCl0G^ONtN7rph7<{?qn$+ zr6dqN48%DAHjxhsU`4Iu5m6gWag)4CFnXK^p;!zxVkKR5Kus!a#zwY4@lYC=@o4q@ zLC=87HmruvW95KpT2pXz&pCiwuJ-2~gHz9Eb}pFKOpoSF1A=Bip_R@q@>p{va6BAx zF;0mbX8O|fpGzk`mf5{Fie6IkaeZRn4+9_3o0V$?CU$89S3FuJ^if*&E5gL7cn@s^ zxjrjP$M*_GHr03wJqtsJ;pOq>t07{~9OIeZm}fRVnEVwgwr7SFudQ284tw3A{^bzd zXp&KcSqNb&m1cx+UWp8UBo>OnO?mQYPXbLMeB%?1;RND(B3zP3My!qrNwh^mi5@kS zLHLjlAOj}o@etgq@s&($kZ!a+_)?B6xWUR}<6LDVf`7UMvf{#q3!GM>4$1!nG|2>? zlI|h+;)TzBk&ebw(xD{M$#hEM5s?NzNy#KR=@M}tY!LFHX4nD~NXNKm&|YZ6cL9Da zY=SaPlH|X21i3X&Y`IBz7Ku$a3GYp!9e)2H#`4729i#nqS4LMh5R~D#Ys!^9II~CC z8PB)`GI38wINHvx`&I7^r~5+t%#ZS&oASX{c`-tYt0KWl(MX68GGv z2`WjJDcrag8*QIz7p(4?u&{Ygrd=TS-f3~2-99}iJpDqhW%!2OSqH#C+U9MJG7Yr^ z0JQ=D5(d}@0Jw1#256gV6U^;131QcPOq)OkVA`##zX8H%A)lPLca&+UZv&JTYJf1> z%6cpr@M^FQ1{(t~+~}!*d4#8*D-*;%ayyl21A*E>-LW;F3Y{GkHheE bool: + if user.role == "admin": + return True + if user.role == "teacher": + return student.class_id in get_teacher_class_ids(db, user.teacher_id) + return False + + +def _check_student_manage(db: Session, user: User, stu_id: int): + """公共校验:学生存在 + 操作者有权管理该学生就业信息""" + student = StudentDAO.get_active(db, stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student_employment(db, user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级学生的就业信息") + return student + + +def _to_response(db_base) -> EmploymentResponse: + resp = EmploymentResponse.model_validate(db_base) + resp.offers = [OfferResponse.model_validate(o) for o in (db_base.offers or [])] + return resp + + +# ---------- 登记就业开放 ---------- +@router.post("/open", response_model=EmploymentResponse, summary="登记就业开放(学生状态联动→进入就业)") +async def open_employment( + body: EmploymentOpen, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + _check_student_manage(db, current_user, body.stu_id) + existing = EmploymentDAO.get_base(db, body.stu_id) + if existing: + raise HTTPException(status_code=409, detail="该学生已登记就业开放,请勿重复登记") + db_base = EmploymentDAO.open_employment(db, body.stu_id, body.employment_open_time) + return _to_response(db_base) + + +# ---------- 登记 offer ---------- +@router.post("/offer", response_model=EmploymentResponse, summary="登记offer(学生状态联动→已就业)") +async def add_offer( + body: OfferAdd, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + _check_student_manage(db, current_user, body.stu_id) + db_base = EmploymentDAO.get_base(db, body.stu_id) + if db_base is None: + raise HTTPException(status_code=404, detail="该学生尚未登记就业开放,请先调用 /employment/open") + if body.offer_time < db_base.employment_open_time: + raise HTTPException(status_code=400, detail="offer下发时间不能早于就业开放时间") + EmploymentDAO.add_offer(db, body.stu_id, body.offer_time, body.company_name, body.salary) + return _to_response(EmploymentDAO.get_base(db, body.stu_id)) + + +# ---------- 查询学生就业信息 ---------- +@router.get("/students/{stu_id}", response_model=EmploymentResponse, summary="查询学生就业信息") +async def get_student_employment( + stu_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if current_user.role == "student" and current_user.stu_id != stu_id: + raise HTTPException(status_code=403, detail="学生角色只能查看自己的就业信息") + db_base = EmploymentDAO.get_by_student(db, stu_id) + if db_base is None: + raise HTTPException(status_code=404, detail="该学生暂无就业信息") + return _to_response(db_base) + + +# ---------- 查询班级就业信息 ---------- +@router.get("/class/{class_id}", response_model=EmploymentListResponse, summary="查询班级学生就业信息") +async def get_class_employment( + class_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=200), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + total, items = EmploymentDAO.get_by_class(db, class_id, skip=skip, limit=limit) + return EmploymentListResponse(total=total, items=[_to_response(b) for b in items]) + + +# ---------- 多条件查询就业信息 ---------- +@router.get("/total_query", response_model=EmploymentListResponse, summary="多条件查询就业信息(学号/公司/薪资范围)") +async def query_employment( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=200), + stu_id: int = Query(None, ge=1, description="按学号精确查询"), + company_name: str = Query(None, description="按公司名称模糊查询"), + salary_min: float = Query(None, ge=0, description="薪资下限"), + salary_max: float = Query(None, ge=0, description="薪资上限"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + total, items = EmploymentDAO.get_all( + db, + skip=skip, + limit=limit, + stu_id=stu_id, + company_name=company_name, + salary_min=salary_min, + salary_max=salary_max, + ) + return EmploymentListResponse(total=total, items=[_to_response(b) for b in items]) + + +# ---------- 修改就业基础信息 ---------- +@router.put("/update", response_model=EmploymentResponse, summary="修改学生就业基础信息") +async def update_employment( + body: EmploymentUpdate, + stu_id: int = Query(..., ge=1, description="学生编号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + _check_student_manage(db, current_user, stu_id) + update_data = body.model_dump(exclude_unset=True, exclude_none=True) + if not update_data: + raise HTTPException(status_code=400, detail="至少提供一个要修改的字段") + db_base = EmploymentDAO.update_base(db, stu_id, update_data) + if db_base is None: + raise HTTPException(status_code=404, detail="该学生暂无就业信息") + return _to_response(db_base) + + +# ---------- 删除就业信息 ---------- +@router.delete("/delete", status_code=204, summary="删除学生就业信息(逻辑删除,状态回退为在读)") +async def delete_employment( + stu_id: int = Query(..., ge=1, description="学生编号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + _check_student_manage(db, current_user, stu_id) + success = EmploymentDAO.delete_light(db, stu_id) + if not success: + raise HTTPException(status_code=404, detail="该学生暂无就业信息") + return None diff --git a/api/scores.py b/api/scores.py new file mode 100644 index 0000000..2115fcf --- /dev/null +++ b/api/scores.py @@ -0,0 +1,123 @@ +# api/scores.py +# 成绩模块接口:录入(含 60 分红线预警)、修改、删除、查询 +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.deps import get_current_user, require_roles, get_teacher_class_ids +from dao.scores_dao import ScoreDAO +from dao.students_dao import StudentDAO +from database import get_db +from model.user import User +from scheme.scores import ( + ScoreAdd, + ScoreUpdate, + ScoreDelete, + ScoreResponse, + ScoreListResponse, + ScoreAddResponse, +) + +router = APIRouter() + + +def _can_manage_student_scores(db: Session, user: User, student) -> bool: + """教师只能管理自己所带班级学生的成绩;管理员不受限""" + if user.role == "admin": + return True + if user.role == "teacher": + return student.class_id in get_teacher_class_ids(db, user.teacher_id) + return False + + +# ---------- 录入成绩 ---------- +@router.post("/add", response_model=ScoreAddResponse, summary="录入成绩(低于60分触发预警)") +async def add_score( + score_data: ScoreAdd, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + # 外键校验:学生必须存在 + student = StudentDAO.get_active(db, score_data.stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student_scores(db, current_user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级学生的成绩") + # 复合主键冲突校验 + existing = ScoreDAO.get_one(db, score_data.stu_id, score_data.exam_id, include_deleted=True) + if existing: + raise HTTPException(status_code=409, detail="该学生此考核序次的成绩已存在,请使用修改接口") + db_score = ScoreDAO.add_score(db, score_data) + item = ScoreDAO.build_score_item(db_score, stu_name=student.stu_name) + warning = None + if item["is_warning"]: + warning = f"预警:学生 {student.stu_name}(学号 {student.stu_id})第 {score_data.exam_id} 次考核成绩 {score_data.score} 分低于 {ScoreDAO.WARNING_LINE} 分红线,需要多加关注!" + return ScoreAddResponse(score=ScoreResponse(**item), warning=warning) + + +# ---------- 查询学生成绩 ---------- +@router.get("/query", response_model=ScoreListResponse, summary="查询成绩(按学号或考核序次)") +async def get_scores( + stu_id: int = Query(None, ge=1, description="按学号查询(学生角色只能查自己)"), + exam_id: int = Query(None, ge=1, description="按考核序次查询"), + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=200), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + # 学生角色只能查自己的成绩 + if current_user.role == "student": + stu_id = current_user.stu_id + if stu_id is None and exam_id is None: + raise HTTPException(status_code=400, detail="stu_id 与 exam_id 至少提供一个") + if stu_id is not None: + rows = ScoreDAO.get_by_student(db, stu_id) + student = StudentDAO.get_active(db, stu_id) + stu_name = student.stu_name if student else None + items = [ScoreResponse(**ScoreDAO.build_score_item(s, stu_name)) for s in rows] + return ScoreListResponse(total=len(items), items=items) + total, rows = ScoreDAO.get_by_exam(db, exam_id, skip=skip, limit=limit) + items = [] + for s in rows: + student = StudentDAO.get_active(db, s.stu_id) + items.append( + ScoreResponse(**ScoreDAO.build_score_item(s, student.stu_name if student else None)) + ) + return ScoreListResponse(total=total, items=items) + + +# ---------- 修改成绩 ---------- +@router.put("/update", response_model=ScoreResponse, summary="修改指定学生的某次成绩") +async def update_score( + score_data: ScoreUpdate, + stu_id: int = Query(..., ge=1, description="学生编号"), + exam_id: int = Query(..., ge=1, description="考核序次"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + student = StudentDAO.get_active(db, stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student_scores(db, current_user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级学生的成绩") + db_score = ScoreDAO.update_score(db, stu_id, exam_id, score_data.score) + if db_score is None: + raise HTTPException(status_code=404, detail="该学生此考核序次的成绩不存在") + return ScoreResponse(**ScoreDAO.build_score_item(db_score, student.stu_name)) + + +# ---------- 删除成绩 ---------- +@router.post("/delete", status_code=204, summary="删除指定学生的某次成绩(逻辑删除)") +async def delete_score( + body: ScoreDelete, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + student = StudentDAO.get_active(db, body.stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student_scores(db, current_user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级学生的成绩") + success = ScoreDAO.delete_score(db, body.stu_id, body.exam_id) + if not success: + raise HTTPException(status_code=404, detail="该成绩不存在,或已被删除") + return None diff --git a/api/statistics.py b/api/statistics.py new file mode 100644 index 0000000..e7e6d38 --- /dev/null +++ b/api/statistics.py @@ -0,0 +1,147 @@ +# api/statistics.py +# 统计分析模块接口:动态年龄查询、班级统计、成绩统计、就业统计、高级筛选器、聚合统计 +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.deps import require_roles +from dao.statistics_dao import StatisticsDAO, FilterBuilder +from database import get_db +from scheme.statistics import ( + FilterRequest, + FilterResponse, + ClassGenderStat, + AllAboveStudent, + FailStudent, + ClassExamAvg, + TopSalaryStudent, + EmploymentDuration, + ClassAvgDuration, + ScoreVolatility, + EmploymentFunnel, +) +from scheme.students import StudentResponse + +router = APIRouter() + +# 统计模块统一要求 admin / teacher 角色(各接口通过 Depends(require_roles(...)) 校验) + + +# ==================== 2.6.1 基本信息动态统计 ==================== +@router.get("/students/by-age", response_model=list[StudentResponse], summary="动态年龄范围查询") +async def students_by_age( + op: str = Query(..., description="比较条件:gt/lt/eq/gte/lte/between"), + value: int = Query(None, description="op 为 gt/lt/eq/gte/lte 时必填"), + min_value: int = Query(None, description="op=between 时的下界"), + max_value: int = Query(None, description="op=between 时的上界"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + try: + items = StatisticsDAO.students_by_age(db, op=op, value=value, min_value=min_value, max_value=max_value) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return items + + +@router.get("/class/gender-stats", response_model=list[ClassGenderStat], summary="多维度班级统计(总人数+男女分布)") +async def class_gender_stats( + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.class_gender_stats(db) + + +# ==================== 2.6.2 成绩综合统计 ==================== +@router.get("/score/all-above", response_model=list[AllAboveStudent], summary="每次考试都在分数线以上的学生") +async def score_all_above( + line: float = Query(..., ge=0, le=100, description="分数线,如 80"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.students_all_above(db, line) + + +@router.get("/score/fail", response_model=list[FailStudent], summary="不及格次数>=N的学生(含明细)") +async def score_fail( + times: int = Query(..., ge=1, description="不及格次数阈值,如 2"), + line: float = Query(60.0, ge=0, le=100, description="及格线,默认 60"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.fail_students(db, times, line) + + +@router.get("/score/class-avg", response_model=list[ClassExamAvg], summary="每次考试每个班级平均分(支持动态排序)") +async def score_class_avg( + exam_id: int = Query(None, ge=1, description="考核序次(不传则返回所有考核)"), + order: str = Query("desc", pattern="^(asc|desc)$", description="按平均分排序方向"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.class_exam_avg(db, exam_id=exam_id, order=order) + + +# ==================== 2.6.3 就业数据统计 ==================== +@router.get("/employment/top-salary", response_model=list[TopSalaryStudent], summary="就业薪资排名 Top N") +async def employment_top_salary( + n: int = Query(..., ge=1, le=100, description="取前 N 名"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.top_salary(db, n) + + +@router.get("/employment/duration", response_model=list[EmploymentDuration], summary="每个学生的就业时长(天)") +async def employment_duration( + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.employment_durations(db) + + +@router.get("/employment/class-avg-duration", response_model=list[ClassAvgDuration], summary="每个班级平均就业时长") +async def employment_class_avg_duration( + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.class_avg_duration(db) + + +# ==================== 2.7.1 通用高级筛选器 ==================== +@router.post("/filter", response_model=FilterResponse, summary="通用高级筛选器(AND/OR 嵌套规则)") +async def advanced_filter( + body: FilterRequest, + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + try: + total, items = FilterBuilder.query_students(db, body.rules) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + # 组装为字典响应(含班级名称) + data = [] + for s in items: + row = StudentResponse.model_validate(s).model_dump() + if s.classes: + row["class_name"] = s.classes.class_name + data.append(row) + return FilterResponse(total=total, items=data) + + +# ==================== 2.7.2 多维度聚合统计 ==================== +@router.get("/score/volatility", response_model=list[ScoreVolatility], summary="成绩波动最大 Top N(最大分差)") +async def score_volatility( + top_n: int = Query(5, ge=1, le=50, description="取前 N 名,默认 5"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.score_volatility(db, top_n) + + +@router.get("/employment/funnel", response_model=list[EmploymentFunnel], summary="班级就业漏斗(总人数→已就业→高薪→就业率)") +async def employment_funnel( + high_salary_line: float = Query(10000.0, ge=0, description="高薪线,默认 10000"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.employment_funnel(db, high_salary_line) diff --git a/api/students.py b/api/students.py new file mode 100644 index 0000000..5a25a72 --- /dev/null +++ b/api/students.py @@ -0,0 +1,146 @@ +# api/students.py +# 学生模块接口:增、查(多条件分页)、改、逻辑删除 +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.deps import get_current_user, require_roles, get_teacher_class_ids +from dao.students_dao import StudentDAO +from database import get_db +from model.user import User +from scheme.students import StudentAdd, StudentUpdate, StudentResponse, StudentListResponse + +router = APIRouter() + + +def _can_read_student(user: User, student) -> bool: + """学生角色只能读取自己的信息""" + if user.role == "student": + return user.stu_id == student.stu_id + return True + + +def _can_manage_student(db: Session, user: User, student) -> bool: + """教师角色只能管理自己所带班级的学生;管理员不受限""" + if user.role == "admin": + return True + if user.role == "teacher": + return student.class_id in get_teacher_class_ids(db, user.teacher_id) + return False + + +# ---------- 创建新学生 ---------- +@router.post("/add", response_model=StudentResponse, summary="创建学生(学号可自动生成)") +async def add_student( + student: StudentAdd, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + # 外键校验:班级 / 顾问 + if not StudentDAO.inspect_class_id_unq(db, student.class_id): + raise HTTPException(status_code=404, detail="班级ID不存在,或已被软删除") + if not StudentDAO.inspect_advisor_id_unq(db, student.advisor_id): + raise HTTPException(status_code=404, detail="顾问ID不存在,或已被软删除") + # 教师只能给自己班上的学生建档 + if current_user.role == "teacher" and student.class_id not in get_teacher_class_ids( + db, current_user.teacher_id + ): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级的学生") + # 主键校验(显式传入学号时) + if student.stu_id is not None: + existing = StudentDAO.inspect_student_id_unq(db, student.stu_id) + if existing: + raise HTTPException(status_code=409, detail="学号已存在(包括软删除记录)") + return StudentDAO.add_student(db, student) + + +# ---------- 查询学生列表(多条件 + 分页) ---------- +@router.get("/total_query", response_model=StudentListResponse, summary="查询学生列表(多条件筛选+分页)") +async def get_students( + skip: int = Query(0, ge=0, description="跳过的记录数"), + limit: int = Query(100, ge=1, le=200, description="返回的最大记录数"), + stu_id: Optional[int] = Query(None, description="按学号精确查询"), + stu_name: Optional[str] = Query(None, description="按姓名模糊查询"), + class_id: Optional[int] = Query(None, description="按班级ID筛选"), + gender: Optional[str] = Query(None, description="按性别筛选:男/女"), + status: Optional[str] = Query(None, description="按状态筛选:在读/进入就业/已就业"), + education: Optional[str] = Query(None, description="按学历筛选"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + total, items = StudentDAO.get_all( + db, + skip=skip, + limit=limit, + stu_id=stu_id, + stu_name=stu_name, + class_id=class_id, + gender=gender, + status=status, + education=education, + ) + # 学生角色只能看到自己 + if current_user.role == "student": + items = [s for s in items if s.stu_id == current_user.stu_id] + total = len(items) + return StudentListResponse( + total=total, + items=[StudentResponse.model_validate(s) for s in items], + ) + + +# ---------- 根据 ID 查询单个学生 ---------- +@router.get("/single_query", response_model=StudentResponse, summary="根据学号查询学生") +async def get_student( + stu_id: int = Query(..., ge=1, description="学号"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + student = StudentDAO.get_active(db, stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_read_student(current_user, student): + raise HTTPException(status_code=403, detail="学生角色只能查看自己的信息") + resp = StudentResponse.model_validate(student) + # 联表补充班级名与顾问名 + if student.classes: + resp.class_name = student.classes.class_name + if student.advisor: + resp.advisor_name = student.advisor.advisor_name + return resp + + +# ---------- 更新学生信息 ---------- +@router.put("/update", response_model=StudentResponse, summary="更新学生信息") +async def update_student( + student_data: StudentUpdate, + stu_id: int = Query(..., ge=1, description="要更新的学号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + student = StudentDAO.get_active(db, stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student(db, current_user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级的学生") + updated = StudentDAO.update(db, stu_id, student_data) + return StudentResponse.model_validate(updated) + + +# ---------- 逻辑删除学生 ---------- +@router.delete("/delete", status_code=204, summary="逻辑删除学生") +async def delete_student( + stu_id: int = Query(..., ge=1, description="要删除的学号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + student = StudentDAO.get_active(db, stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student(db, current_user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级的学生") + success = StudentDAO.delete_light(db, stu_id) + if not success: + raise HTTPException(status_code=404, detail="学生不存在") + return None diff --git a/api/teachers.py b/api/teachers.py new file mode 100644 index 0000000..6da2867 --- /dev/null +++ b/api/teachers.py @@ -0,0 +1,87 @@ +# api/teachers.py +# 老师模块接口:增、查(分页)、改、逻辑删除(沿用原 teacher 模块接口风格) +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.deps import get_current_user, require_roles +from dao.teachers_dao import TeacherDAO +from database import get_db +from model.user import User +from scheme.teachers import TeacherAdd, TeacherUpdate, TeacherResponse, TeacherListResponse + +router = APIRouter() + + +# ---------- 创建新教师 ---------- +@router.post("/teacher/add", response_model=TeacherResponse, summary="创建教师(工号可自动生成)") +async def add_teacher( + teacher: TeacherAdd, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin")), +): + # 外键校验:班级必须存在 + existing_class = TeacherDAO.inspect_class_id_unq(db, teacher.class_id) + if not existing_class: + raise HTTPException(status_code=404, detail="班级ID不存在,或已被软删除") + # 主键校验(显式传入工号时) + if teacher.teacher_id is not None: + existing_teacher = TeacherDAO.inspect_teacher_id_unq(db, teacher.teacher_id) + if existing_teacher: + raise HTTPException(status_code=409, detail="工号已存在(包括软删除记录)") + return TeacherDAO.add_teacher(db, teacher) + + +# ---------- 软删除教师 ---------- +@router.delete("/teacher/delete", status_code=204, summary="逻辑删除教师") +async def light_delete_teacher( + teacher_id: int = Query(..., ge=1, description="要删除的教师工号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin")), +): + success = TeacherDAO.delete_light(db, teacher_id) + if not success: + raise HTTPException(status_code=404, detail="教师工号不存在,或已被删除") + return None + + +# ---------- 更新教师信息 ---------- +@router.put("/teacher/update", response_model=TeacherResponse, summary="更新教师信息") +async def update_teacher( + teacher_data: TeacherUpdate, + teacher_id: int = Query(..., ge=1, description="要更新的教师工号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin")), +): + if teacher_data.class_id is not None: + existing_class = TeacherDAO.inspect_class_id_unq(db, teacher_data.class_id) + if not existing_class: + raise HTTPException(status_code=404, detail="班级ID不存在,或已被软删除") + updated = TeacherDAO.update(db, teacher_id, teacher_data) + if updated is None: + raise HTTPException(status_code=404, detail="教师工号不存在,或已被软删除") + return TeacherDAO.get_by_id(db, teacher_id) + + +# ---------- 查询所有教师(分页) ---------- +@router.get("/teacher/total_query", response_model=TeacherListResponse, summary="查询教师列表(分页,含班级名称)") +async def get_teachers( + skip: int = Query(0, ge=0, description="跳过的记录数"), + limit: int = Query(100, ge=1, le=200, description="返回的最大记录数"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + total, items = TeacherDAO.get_all(db, skip=skip, limit=limit) + return TeacherListResponse(total=total, items=items) + + +# ---------- 根据 ID 查询单个教师 ---------- +@router.get("/teacher/single_query", response_model=TeacherResponse, summary="根据工号查询教师") +async def get_teacher( + teacher_id: int = Query(..., ge=1), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + teacher = TeacherDAO.get_by_id(db, teacher_id) + if teacher is None: + raise HTTPException(status_code=404, detail="教师工号不存在,或已被删除") + return teacher diff --git a/config.py b/config.py new file mode 100644 index 0000000..a8f7e48 --- /dev/null +++ b/config.py @@ -0,0 +1,36 @@ +# config.py +# 全局配置:通过环境变量覆盖,便于 Docker 部署时注入 +import os + + +class Settings: + """集中管理项目配置,生产环境请通过环境变量覆盖默认值""" + + # ---------- 应用 ---------- + APP_NAME: str = "沃林学生管理系统" + APP_VERSION: str = "1.0.0" + DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true" + + # ---------- 数据库 ---------- + DATABASE_URL: str = os.getenv( + "DATABASE_URL", + # 默认本地开发环境:请替换为你自己的 MySQL 信息 + "mysql+pymysql://root:123456@localhost:3306/walin_db?charset=utf8mb4", + ) + DB_ECHO: bool = os.getenv("DB_ECHO", "false").lower() == "true" + + # ---------- JWT ---------- + SECRET_KEY: str = os.getenv( + "SECRET_KEY", + "CHANGE_ME_IN_PRODUCTION_please_use_a_long_random_string_here", + ) + JWT_ALGORITHM: str = "HS256" + # token 有效期(分钟),默认 12 小时 + ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "720")) + + # ---------- 初始管理员(仅用于 seeding 参考,见 sql/init.sql) ---------- + ADMIN_USERNAME: str = os.getenv("ADMIN_USERNAME", "admin") + ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123") + + +settings = Settings() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..9bde55f --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +# core 包:安全与依赖 diff --git a/core/__pycache__/__init__.cpython-310.pyc b/core/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cb122a23170ae48db1875817fd41fb4145262ded GIT binary patch literal 177 zcmd1j<>g`k0uH&2S+YR-F^Gc<7=auIATDMB5-AM944RC7D;bJF!U*D5h_h8pXmM&$ zaZGq-UP^vBkj^j4b}CItsf;l)Ff!9Mu+%j&)HMW>Kx7nCo|u!F7hhabnv$AV5?@?d rT#}j_lbm0a8WSI%nU`4-AFo$Xd5gm)H$SB`C)EyQTQL)mU||3N&gm*9 literal 0 HcmV?d00001 diff --git a/core/__pycache__/deps.cpython-310.pyc b/core/__pycache__/deps.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9cf33c62895fd541e3ce5a93d047de82302a3843 GIT binary patch literal 2596 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/security.cpython-310.pyc b/core/__pycache__/security.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..da5d0a26458f7b6f4c48408ffc728622f307a6dc GIT binary patch literal 2214 zcmZWq>rYfi6u)y{ds$fI6>Mu_w~2l12dt22s*7F%(-V~&dl$3 z&N`7uSb}D3{bl%XLz4aylNXx~ll{=$c@S8_C?lD{h)m?qWmASx&L~;MRI;k6W;Iho zfsbzL@J-2tvW97ZO*Lzq0}8yxPdK3n4KlOMo3ID%;JVnPdhmhL)>r) zV|H%Pwg31NNvly<$LsSM%g&|pmg|l>6my*tR-m~w61%Q71;!BcPU!AI5KJl~j4Egh zF?m?Qa!D$ySgD{AVj9!2I;D-tSYz513F{?P4pk&ylLPHr9l{P3#LI@G(3US<^(JPj zw?AJlpRHb;s4h%CUYzvGi`B*H$BT3M{t-OTk#dKuj-9)MtTsS^Tn`R@q7~Rc+j}>Vso{|f$(^p>7}pcDEoN}pae1gftsHi;JS0NO z*!{eA&2=birMXUWX$KQto9ar&dk!SKj&&!vkvbedkvbCZ<_g>jI<^Oi z%Wg>Y6)-+YcJApr?h@+u_1bXED2xsrIat8B*w?Y6V^`ab-EAH1ZS5f8zoT!|$^g7B zD`1jiDYxh{lI=@7l=QhIU7$8AZqFBa#DBhZ5;Vg>uE>wDrlJO#DsV@^S_Lt+ ztd&$4^^!7vO_@pzVTvO85trfXfN=T~2!mM^cF20W>@pr=CcT}*e8$$5P zmDQgn1E_&X%a?uxY`yZm<+*dqK#&WQE01n2J-7xG@Gjq3JwN4rb7$pNW%<^u;NRH` z)mwAk?A_Jb-@MzGRvwPi2yAIaHsF4Uif5v=LeznX)p5rm>zBrm>zAbhC&<&?gYDishS55^4_=pJo2*V05>FkmV+1ARVblhEZsB zNdKoL1VljcQ~H)z94Y_>Q#R%*buwfF5mfjts;5tcL!gLaAeZ%3H;ZW8tVb zbHBRq1HkJ|+>3U75M5gMYUTQcl`npUnitC2xdJ?WOy!DO=%??%CR`z>@}^>2EQaWt zV2IUG!DX)GoKY$+z?A|^Q&IP*CZ<~sbauuQiB$5~;dpl{{@Ev8J@M4huI}T>cmnoi zlqly+qdtbmhBQZ3Rm48_oK%NdoX9g4@SVx zJyHMs=*pw>-u%`7t?A00gldG7;wnD1*ZsZOvUhpH_Xo(r&xq~DG`a(#f+9#n)%3xj z?O>teDFD4)LVt6~3!ot^R-ba~S{1He)O LFp|)Ea%Qip8trj`yh^c0g7LY9@_3p|B zj~__eX^2e%aZDjDiRDSB!L4IR+lgtyB$+(*p$}kn271FxXBs5nDRQARojmp2l~zC> z?A(#gIeYHebI;!UopbK0&1Ob0j5|NP^lu|VU(p9`@Ofc%41{4MAc1K@IL8^xsLwjA z1Fmag8(GXYa+qt>W4$Y9!($%i*d{}x5gS3rVUwW8W`S?B2!=LG4JR14qDvgMsv483 zwbk%~S=HJFOPfQm=52YwrfQvnUDXu9+YVJz+{w6|D>T#=H%p8mQIV8zw?s^ohNOrh z5+fC_#iEj%Ax2qJlyJ0D4uAf42CcZ@Jz|eX#Beka7iIZs42KAhB_)BQL56zG57->U z!hiK&K!%Y&uSUqLD~JVQ4{X(u{M%8+&uJ50ZA&!;aIn?+kk_<9l@|q8^WkIs>M_v? zzg4poxLSQwC+OGb>`35!jMt%Atn(vzQMJ%L-Qv4^1#%k_L-2}u>Gs`B|4@43S4+uP z(r@*rrv|>B9>^r8)6;`rPv42RUk-It2jniX`pAnF$HIz)MI{`I$|ow0$zntabV)rR zc8PMADw>ymGnDR|&P)tu9^6~GGng6sIJW>eO%Dw$PW9zhTAcbV7^H6`>EkDFW!@cI zoO&G$%qva^itxDwztJa@j)O<bTOG25?G9eV%@|*Ly3gj zi6w8^)hz}IFGYi~kVGtjhE|XFWNX8P7J(Q8O`e|wnmsLqg|Be4m?D61^s@*b3U^Af zB2z%PTrT1a#;|lqmVyZ!R(h-Ay~L(|vxX?V9Slq0FaH!sA6hdbt9`KUM%_^R%@cj6 z7i^_-HrHdDYu@M_s+udVoGq>#H>8RWPgYD@{&w^UtF!ZcXRJ$4dwe=US9cX55v)5|f5E4>fbZO!lmeNmS6|QkdUE;Uy`_gEGz(w3m40|9GxE{O$e%M4x0gTt1y-}ewkZqF zU|KIQ-9l^!;O%n;c!Q9p)%tt(C(t9_ki@FEN9b~&DWfqt2sMcbM~)ns-}6?_?>>! zXwST*bkvcuRL+-_FBFx}6;(Vgs+hNy&Re(6S$EG`cPA@T)&mRHqQUd8ogdwwvhH25 zI)= zz(WjSED=NgD`dLbde)Q#u>`lEfEWx)vK&xim!&BF0hm<7UycLmLyNYu!L}Q1WBc#E z@b(KSTjc}yg0pZJ z44#d2A9^~rHp~`!3pmb?LkwV>;z5`A8K}(5@4rWHHGlaiSA{7u%yn1{%aZJZBdPMJ zyBre2olz0)gSi4%=Tg7IwfxzunX$W@>>Hw2#7H>QO7~l>i<)g_B$*lRHv^>1g^DP< z^|%hAfV+f5FCQvc@b^K3j{yO6{ScT81pwuXVM)$#^Rb?WdDURV2Xa2JOBhrCBv*55##t0 zS-wQ3f1>JHRQ(k)r_naJ@;J8iS>`RaK4aEsV$Lwhy;(&0_}(>@XUiQ-S+YKhfK9O~ s`&PuYDI<|30U$s)?f!G!Y3`ZbkjxAV+P%&442lqZ${s9cTqfANza-T(jq literal 0 HcmV?d00001 diff --git a/core/deps.py b/core/deps.py new file mode 100644 index 0000000..f576450 --- /dev/null +++ b/core/deps.py @@ -0,0 +1,73 @@ +# core/deps.py +# 认证与权限依赖:从请求头解析 JWT -> 加载用户 -> 角色级 RBAC 校验 + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +import jwt +from sqlalchemy.orm import Session + +from core.security import decode_access_token +from database import get_db +from model.user import User + +# 自动从 Authorization: Bearer 中提取 token +_bearer_scheme = HTTPBearer(auto_error=False) + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme), + db: Session = Depends(get_db), +) -> User: + """ + 解析 JWT 并返回当前登录用户(model.user.User 对象) + 未登录 / token 无效 / 用户不存在 均抛 401 + """ + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未登录,请先获取 token", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = decode_access_token(credentials.credentials) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="token 已过期,请重新登录") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="token 无效") + + user_id = int(payload["sub"]) + user = ( + db.query(User) + .filter(User.user_id == user_id, User.is_deleted == 0) + .first() + ) + if user is None: + raise HTTPException(status_code=401, detail="用户不存在或已被禁用") + return user + + +def require_roles(*roles: str): + """ + 角色级 RBAC 依赖工厂。 + 用法:user: User = Depends(require_roles("admin", "teacher")) + """ + def checker(user: User = Depends(get_current_user)) -> User: + if user.role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"权限不足,需要角色:{' / '.join(roles)},当前角色:{user.role}", + ) + return user + return checker + + +def get_teacher_class_ids(db: Session, teacher_id: int) -> list[int]: + """获取某教师所带班级 ID 列表(用于资源归属校验)""" + from model.teachers import Teacher + + rows = ( + db.query(Teacher.class_id) + .filter(Teacher.teacher_id == teacher_id, Teacher.is_deleted == 0) + .all() + ) + return [r[0] for r in rows] diff --git a/core/security.py b/core/security.py new file mode 100644 index 0000000..bd35ea1 --- /dev/null +++ b/core/security.py @@ -0,0 +1,70 @@ +# core/security.py +# 安全工具:密码哈希(PBKDF2-SHA256,标准库实现,无额外依赖)+ JWT 签发与校验 + +import hashlib +import hmac +import os +from datetime import datetime, timedelta, timezone + +import jwt + +from config import settings + +# PBKDF2 迭代次数与哈希长度 +_ITERATIONS = 120_000 +_KEY_LEN = 32 + + +# ==================== 密码哈希 ==================== +def hash_password(plain_password: str) -> str: + """ + 生成密码哈希,格式:pbkdf2_sha256$$$ + 盐值每次随机生成,同一密码两次加密结果不同 + """ + salt = os.urandom(16) + digest = hashlib.pbkdf2_hmac( + "sha256", plain_password.encode("utf-8"), salt, _ITERATIONS, dklen=_KEY_LEN + ) + return f"pbkdf2_sha256${_ITERATIONS}${salt.hex()}${digest.hex()}" + + +def verify_password(plain_password: str, password_hash: str) -> bool: + """校验密码:用同样的盐和迭代次数重新计算,恒定时间比较""" + try: + algorithm, iterations, salt_hex, hash_hex = password_hash.split("$") + if algorithm != "pbkdf2_sha256": + return False + digest = hashlib.pbkdf2_hmac( + "sha256", + plain_password.encode("utf-8"), + bytes.fromhex(salt_hex), + int(iterations), + dklen=len(bytes.fromhex(hash_hex)), + ) + return hmac.compare_digest(digest, bytes.fromhex(hash_hex)) + except (ValueError, TypeError): + return False + + +# ==================== JWT ==================== +def create_access_token(user_id: int, role: str) -> str: + """签发 JWT,payload 携带用户 ID 与角色""" + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + payload = { + "sub": str(user_id), # sub 建议为字符串 + "role": role, + "exp": expire, + "iat": datetime.now(timezone.utc), + } + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_access_token(token: str) -> dict: + """ + 解码并校验 JWT + :raises jwt.ExpiredSignatureError: token 过期 + :raises jwt.InvalidTokenError: token 无效 + """ + return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) diff --git a/dao/__init__.py b/dao/__init__.py new file mode 100644 index 0000000..f0c27f1 --- /dev/null +++ b/dao/__init__.py @@ -0,0 +1,2 @@ +# dao/__init__.py +# 数据访问层包 diff --git a/dao/__pycache__/__init__.cpython-310.pyc b/dao/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc692939e1671dd48af3d3b349a35e58b56d0c4e GIT binary patch literal 176 zcmd1j<>g`k0$2HsS?WOgF^Gc<7=auIATDMB5-AM944RC7D;bJF!U*D5u(MT6XmM&$ zaZGq-UP^vBkj^j4b}CItsf;l)Ff!9Mu+%j&)HMW>Kx7nCo|u!F7hhabnv$AV5?@?d qT#}j_laiPp6Ca2KczG$)edA?F%ytrVE_QlKPmVC literal 0 HcmV?d00001 diff --git a/dao/__pycache__/classes_dao.cpython-310.pyc b/dao/__pycache__/classes_dao.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d98ec1070a35bd47b5174f13a7d9061c791cf5f5 GIT binary patch literal 2906 zcmZuz?QaxC7~k2q+q+&1l!Aa_PL0YLTcsu>5@L7@#4k0G`jSh{=D3-IZTD{5*!n04 zA#BYyO37TZl**-uv|pk+O&pVG;;5YKpvD0yn{2|mQkDeTpg(FeFcJrt>sn43%;X+B z4|ciehjk%1kS& zRH5oI^(e{d5A8oCZ}d8~EFy}RBBWO^_#3J8U1(Si(z zX+LPIQ1(D^H$hdAs**=)q$+!4SgDW^8EmyC!HUG60Ebkj_0+4s)u(SRotazu{o3n$ z8>fE_&zy}~;jP~qKh^FnOfAh^U;O>_;_dn7%*}A-*5d8S`sB6xca!x?SMM#Hg1T|* z^3wbl^^51jQ^&(ke`s9(p>gL{I8|+ab7kqlh6`fHzd9a?0??gvwi3-mvMLK9ji>858&tTaUP&X9nRxhB|sqvJu3Rdz>N!86;?ntpk#6Tavo ztednWn+3Tb0)3D2d?*ka%o2fk8jObn0Rd4d21KrEA|SHA0~{md>*r@zp!djFgjdc& zEb7%-bN1A|g%g~>0$H7Rz)L{Nk@69qDGLtDZ00e_@ld=AD+DyER)ZC!4l&Qn7d-2I z)&p(VfXYyNh)U#tkF1T4f8Q%eMPNa`$Q9rwL7f}3C&Y0N=2<`M?rYU)&YFLCIO#b zQ3Rk0z^D4Jz~c3DKLdho_@=@$vr!LXcl;`(X!F9w%m5&j3Fp45-~0tAH82SJ+Sx|Y zFT2d+>tU&Ef+IQjTC5&}D$p#*D>q1hN8a-|--siqK|shjhdF}DA4h*>gpCK<`*|A# zUM+MZe5nqoCj1&KAIn*Da=!7$XDz)GLJAyh zoV*$VzIglO;{0^|{0YeI$S0ipere_su(p2k^QHLUM6VXq(j-3?Hsb&R7SM}lH#D$yaL zkS6QmcrKq8?V93NRCmN04K&+=1brxh;u~)_xI%!I-FQM^2?i2xy$MK_ss_TVL(mhD zWTa}K##ECqreP8lQEOy>KP(hZeblV|(vss7lK^X+IEX%6Z|%Dc(gg0m#@(6l+{Mh6 z`s955%O79Ngi~KNYS*)oCZ|?vG6Kw>#iJPxr;M8i$0MEAonjG_EQ@1G-nQH6vjt~h zuUIP3Jjcu1{7LLs0e(X0vu*E#F(KjWA%C6#q>rqQac;Q`WM#2I{yg|*y92{C%lQ&B z%^+o(AdtR|cG@(@{Jh;7NxP8XR-wea!w%)BW`SY>PuAd8gdU)(MfcAWXeF@1OE6Fm zI*!dTuQ}eSfofZ>$NR8-{|v&yv=?sStFzIFrSm;z;5k bLiQf9+rZcn0rncEn2Y;P%i@o$Cf&;ad*oBb literal 0 HcmV?d00001 diff --git a/dao/__pycache__/classes_dao.cpython-313.pyc b/dao/__pycache__/classes_dao.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ea626ecc92a4bf34174766cf3a7a9e44cecae7d GIT binary patch literal 4503 zcmcInU2s#!72bQLdv*0^$(9_9F|i7ci=ctvhz1xYDT58hG!QJ5&_UBXLb^60vSjv3 zDQ^4VhbEN@#3nI62@V(<8i(-X%#eu_lj27neMd~>xziSAYRUE!N}Wz$I@6xrE6J9l zNv984k9W`RIlEfz_njXtSgj@m?enKUJ$@aezmrL^=?lWbVGzzCFY+?`PzQq<7m?Y0 zY=;i(K-PKn`}7?g=BUi=<2wx4KxKZP&|$>J4ih%HP#s!>yoP$@6}GV2$=KX&berbM z#LcMOp+s*)0>R*wWH}s*&J#138+Szlun_HvQDr<4lA>`ps|s{|XDFnaspRVo1>#ar zJ6r25g~`I-Ksbl|2s2)USucZie!Z9Vb6%aF_vpR)dURaBrC^K=Wjd}*ClqwN-{>{? zO&;DWl1IJGyP|>cWGx?mRG0zPHMTj;cbCyqy>h1q*LZzav3b2mcF6=*}S@>SXA8 zS$CwgV@pBXNp7&LEn0Q*%TzG+G27b_D(yFZ*P{3}ngHoU59m#~kuP#*ZqE+fn*H*l zy{&U^f1VvUug$V|zL@*;>XUoP*`d*yFW;USpO_!IogKO}Gv1%;|0wsz{@j)8Pwt(D ze(uh-*@<^^mxr^b-^>nvGI#Bhxv%bIlOyx*eK`C6?~3Q7Om0r)P9!AUhsottzAGGw zOBh#?3D*_Ia{OZ!TVY0*Wbz^|ms%4F#5U1~m1Gg*jlF%UGaQwBrC?kvKDd~Oo={g) zVge!?E2+uBatn0*C~rdcnt_w4lf%ZeZB54JR&4H@?#%if%K9CXg0j9nZQGl%9Z+ls z(zb(B!odaDg~~7`*icMwg7Cv*x&(KD+@-vEHQWmf>NJ;Y$!rPkY+?s2Xh7$NZ!80= zBJ|l0&ja+ik*o7JPCvPq!sLV8JhsC?k;K(RqA;4mfRPlD;!+6LkW~kC1thT(WYyFy z#l=7{9)3eo9VNgP7m1PlB6I+gfULHG_Eh`OFVdFAM^;Db)uFu^XS3pL9$lw6Tkh`7 zc-j?D`-7c|=jF6@|08Qn%6ICuyg_d?;T815G=4y&3aC7m;t3orgONNYh4Fn5&Y>eE z$Mou+19$2qx@FDFc-d`CEAvw(%7QCba8e&zbU_z!cEhp*U|NKO>MOWu*$FH-t3L58 zY|EV=%U-*zLGQm6AF2wWqnew^^U z>WFMi8Bd#uU`m+=w>}garfn;dHVS5NDHtSxP~aYe0Kh@7wMvj3W{Np5s`KfB0CG3u zV=F=DW#Lb^jr~5*9H<@mo&YE4Z97r|@36K2C zQom_DG1|6?1+f@f2-CrO2%!MsJ5-H6Hd4AJge4NQm$l+v7wYERd?E#E(|rHL+@H@Dk~yss zAo9$ex~|c1X8hF5#F^Z13NndilfCt)*`X^Ci*l#lnVtARTQqIgV()g>A)Jt0x#VE> z4_929T)P7iSt8*pd+TC$cobHi-RvrzJa_*hoU9#a+D)_0{C}X$!xut_+{Psorm2Vo z+Qn3aSHe`~g0Vz2PTB0%FCjAJuvONoTag;cdf2c?VvCl>#7MaNSX`|wQF-YysV3x; z&;g+>$XS`tb&oAoG>N^Iwyf5Y*q)5DNpUuf3X1c&yFbpfv??vF4;ZCoZ`#^XN@B?P z6x1n}l@~Xs%?;Cz+Ucholfpw`)nb^mIkYUNmtPO}s$3)tF}y5Fl5a$#&#J(BA5+p+;PqlU?Ih*MvR1kkceu?%k}aFq z118zzTk}^xE7VD;eqdrL@)E*AnOm%JAO6d+D-kU9Nv3cW&$_u59wc+|^OH zRxFv87fXbkm;`lIh~bcg#iM;%0ma9vkPyfSTHo3XD~?CWoC%WQZ_+3?cjW@STb z+TNDrrtP&C%xBH-S~aoiY}IgIrg5{s- z#IUYpSXVGKa&?0*TU0*Y*Bg#@YcE?R zpNIq^!DCWS-^Lj3(O|OD%5!6o{7jZ)ts1S;)(Wv$#w3iZRWjKrl`JWs6h>ONxWHx+ z6jj)vQRhW`02Cw&$!nm?>llXlCy$twS+pjLp7|?kdW2g3#s7?98vcPuPtW)-WQZ_e literal 0 HcmV?d00001 diff --git a/dao/__pycache__/employment_dao.cpython-310.pyc b/dao/__pycache__/employment_dao.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e08992d69d92babe31cc3b59713eae81ca88e388 GIT binary patch literal 5012 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 literal 0 HcmV?d00001 diff --git a/dao/__pycache__/employment_dao.cpython-313.pyc b/dao/__pycache__/employment_dao.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..77a7d775fad69a78856f33d33d0965bfaa971fa5 GIT binary patch literal 8971 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 literal 0 HcmV?d00001 diff --git a/dao/__pycache__/scores_dao.cpython-310.pyc b/dao/__pycache__/scores_dao.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..28dd1def24cd971ca361a07b3d92f8dc4a26f9cd GIT binary patch literal 3539 zcmaJ@|8Eq>72nzKcRm|q%ol@04nosjRb1jqk%FQ&G%YDjbWsawq?4u9`DQMxy?1M7 zcSUU-5|wS@P$0B%pimUmkx>64j|OCsAWo&ZVe%T*@Sxu(y;^LiG*X z^F#B^Dc^R>c_-9P1XB*nkkB~JJP+1js2wl3JYMqy%E~^hnPTk-r3Kum^VtHESD<-& zi6k*8N2rvODak2JrTeJDG^#S4YS46MP=lE)F(eg})TD_yZIvVu_6Vk09-7nsb=i32o zCp3c%FukQKi?i;cL*lI4TD$FL32M+iaMoe^jM!-qB+pS?-wQX~J_O*i9Uy;}riJBB zx(D2FpoQblj=b4?88AB<%5*%`JwLE)8XD}k`4SGc*yVx)$Xk>-%x5&zIr9TvE~sF$ z@n|r>_9`^*Fmx5ED*2>Fs-Dg4zo}%+K$gSPr-x(`K2&vd+lp!MVotm zM&mswZtL(qoc2TaiNput=ihI=dU)(Dk8yA8ZM#g})9~x^$)5(4PLGWq8h!ELp_dMh zzHsmb=;42K>~!9-%ND?j?6tgU&u67Ens>)MWHuZi?~Y7OhsHb1x7;#A3VSAWdli-R z6O+vJ3%Y}wMp`OiQka6JpD)Ouy76H2ZgPV!QPq|zq)KYi5}}03Hd!K<$QcZAU=@Ok)Y^d6?sND@gz^NmMd3&<3!r7ADHjcmvjdP+Fa_*j@eR`;^N_-QiV1k= z@Yh0SdmEWf{Q~l6C1P@pm(2CCAO#d)_H1BvUy|!{OCS85uO7z9&@vd#t?9nFX zfD;+@39K;-5l>j{iu0$U80WkgMZCL*d7)2_QN(`c34)!vo1zjUw2k2RVFCy}B{_;j zC|5N|lsY6%qm>L_1cNvk#&HJRiRPD^v&-$EtY3I9&Wh3M`;Z~)m;MNYb`CT}@)FU; zx58d=YC~d$rpqbg*7$Us4VAp(WaJK9x;WK`>1TX;BbB`bTZl+A)j{BsO632}>}>OC zv#ua`lj2KNZ_=I$HOGc1-xPWVNZT;|5+>gUlc*P3O*#YNR@DJA10XXoeN__`1^Oh& z^omx|i<+4U{MrF^ti|)kbX@tI5K-X6^57^m@9{!@4186lw)GDEs_5TtHfc zx?3!I%;(?3l>^Xaj2KK|^IcpOB8_RIx4z$k168X#yLRfC(9j0Q8XVh`SX>zA~Xs!sqY}O{DrC zf%^KxxyE1STa*$IM0c)6^DB=ZT}QxHZm+N2T3`LTd26n5=XSmNZsYE~EM#@0kBC9x zMR6?gZE`x#%XaylZ)#wn2vct#`E~YK_BHE`W7(fW;38DQwi~AY@xXRk_0dAY{R%Wi zDpOSz1D6aE{Xa89ZLGTWGvp}jkLi>@4_$_2GrL055_nryn6xY?OIeDjWl9i@MTwW6Q%B95T%!ntx?8L2d weXXVV`_JRZ`monSL$;wPE-i}xla$*&dT72Jqx)lYfNdn1^@s6 literal 0 HcmV?d00001 diff --git a/dao/__pycache__/scores_dao.cpython-313.pyc b/dao/__pycache__/scores_dao.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bde0e88db9751b44598c245a106de8be18edf010 GIT binary patch literal 5717 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
    bIT0u|gggi;}ntNC!DC)LAoEgvaFQ&EoV`BN(D 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 b{_c9ppvk_+p!@gln(H+MUoha1Uh)3{eo4kJ literal 0 HcmV?d00001 diff --git a/dao/__pycache__/students_dao.cpython-310.pyc b/dao/__pycache__/students_dao.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d7947b501972a3cc6f037cb91763a53270bd9120 GIT binary patch literal 4533 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

    Apy;M^E2Nm;8 z?~0_1sg@jXjpRt+8p6_SdT;2J!M&F0zQjEs-{hTy02TwY#9dGoSdB*WcLSsGe#WfL gF;F+>n8rUccl?oA_ZhS8pN9QfP2FD^tm!%Y8}y0CQ~&?~ literal 0 HcmV?d00001 diff --git a/dao/__pycache__/teachers_dao.cpython-310.pyc b/dao/__pycache__/teachers_dao.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cda9451a6522e4c018fd3ff46a96d4e0fe2294c7 GIT binary patch literal 3925 zcmbVP>u(g-6`y{3z)5tvks;(%NJk5RtWv$j9+J!;gRb7yxwcBoW!%-p?? zb070Nzxz9fYBrlz;Q8v=&&I#%Q!ovw1akS=!@x{300g&8b$T4AYEw2)!uVI|+S25T#M8k%p_ zJiA&cI-x%7*BqWFVd^kdfiR^&xylIF809)sxxrIAl`(^9Oh2uiA_cQGYP3eXKvD z`{69xNO6%XVgYA^7Y-76s@!cKJ4!!nPNZDwZ_F`}? zn}vqpo-ZmT3Lv!~tZVQdgxB2#MWECbk2FY~dSqM;$OMJD)=*$|;!lG?I@fxd^)H)q z_nH?jM~ky>9$G#7MRe|B+=>>yT>Y%^cxiTJ{`T^Nv&)MQ*XHj<^9#$1GtHS>%|FjH zuYde_=|d=27jCRP{6q8F)#$_bqVu1w-uQI&?+ejveeLqil|P+suXCoeXI>BWNuP@; zp~F!^W6X9uE<`sr^f6nwUY-hMzU=x$5x{-r>vs+w7}!X0_XuCVGc_^pbGpZSbU zjSTJ^{NbK`FYg)Lzh^&`@NaPBSkbX7mh1V9S3Jv|ay?!iVa4i53ryF7#@^agIAB-Y z8ZUX)I?9$`naskbE+V^2Kk;mfx!{n!Pqq%t60#mV^exZeM@r9p2P)t840uRm=pkCy zrH81`!LWl??4gx&cUK_P@)~pJ~g*7@AYK*(3Bwhsrb^=;#hOD>3k0&Yj3@qm2CEl&}lM}lVNNq9< zGi{!NiWWDp4X1Z>U<4Wg8HcFA=p31*0j(3JGOdBJK@aG=L~QqHT@3)lfHM6R#nYH! zD*?Sp&XQ^9uj_%@AOwLo8jAc)wR%*5H|4(sTQo1;?*RLF=}P--Hp>Bo%S-iW_A+As z!R^=&!w?`c{CLTMYiU)g6)un_LlQ;_QQi#GslwFtq) zC{@d4+Y3{Ij|uJ`&1r_@OR*f%JmF0ug~r+C9fFVd0$+7<;&Rm7AblA3=Ergd(7 zOv0F}z|r<#1dgS_K~CBAQ-q9#xq6_pl1MzfZ_H8 zhDqxZ0@r>}e9*a4D3Q7e5Y<2h5Y;4z0I3RO20G749XO=?COtx@wQ1c0(gs?fpHhi3 zx#Pc3SNC6rEt*&U2JDS-UVhYQo?HO5qjPuSe&p_ZA433J`{-J32$-6S?p|u%`y8bJ zAsZTo^d{S;vR`qzC!U7>ytx_3-OwBAHpCw{Oo3U^^MpiK4Yy~53()-? z7X;7%@F0uu0O|%E*F4N6bZF@lz-G`CwQfL9im8}gfL^F|6Qo#!-!AA+gEV);9U%2g zfRdesG5v@#Z3H?vD+A6-O`Cxkq%1Ws>ODaUm1c4exXu_u@z(rNI6^f0+qK5$t+eFK zO!MlQ&b2^pL$cA%G{7yO<*O&>R~xsZ#lJ-FKWGKX^K&b=?&Hv-`RK~E-1nO^51SY6 zzX%uR5}4+PKY`@ccB?+7AX43X<8X*j$e0+W;}}&bmU*a+SM7=nka4I)^$PV;)vtJB zJI;_7)NPx z@%qP*_ftr0{$&~(fNh}I^=|@PP8x#T38^JsfFeH-nwEuRvaB#|SrF`e2kRNjn)HiK zYb4`>PS~X~_l{PX!1xlXHjoU{A{hpSRBD;DZNokpA5&O+4vn?ZYSj_n#Wv<(;s;ou z;)a@IyPiM+6hFpdU0q}iB}OV33b~7#Zz`1RCe(m8-A|~#gU}4VIYPUiC$uj|r2SAn z8yenJ&8{4YPbqinaVMRk1OGKFPwlOWa{MJdGQ#g|aom;PQ^>}h^0IL^zIweb&gAQs g@&n}u$!PV2vxnv@F{D31$YnU{qZ#}oePpZpAJJ-en*aa+ literal 0 HcmV?d00001 diff --git a/dao/__pycache__/teachers_dao.cpython-313.pyc b/dao/__pycache__/teachers_dao.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8ff8124e2f377d4785224a530831aa9fcdc04ad GIT binary patch literal 6440 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 literal 0 HcmV?d00001 diff --git a/dao/__pycache__/users_dao.cpython-310.pyc b/dao/__pycache__/users_dao.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a20f229230525ebbd0fb0dc76c4c9d1cf8626769 GIT binary patch literal 1409 zcmZWp&5ImG6tAlOoZguv#-K3-!QA?=L$U`UA;=~XyscmqVHjw7=hb9-(>x)a*7Ox@2-%@`gFN<-e%>?AwWO}NNtoo4GeL_HI$aNn~ z?b&V5Ma#jRZxdoi`RKU(cpBD{}$wqSiIR z6|wooJoNvxD{0%tSw@d2%WRTmRSlDp_;!}9Px5l6X={_4Vo*slszFh1*3(9*Gjun- zwkFtjoT3;Tb(v(;>!%3etU@G|lP1@g34VbK{~{Nii`?l}(w04=*UP-bE~uv6T2;+` z35F)iE@9ogwYDY;=_J|(VLO9b$*z`zi7L$0^@=<6R1hwvrs6l7WIrIxHO15x`0+MB G@BIq^+hX?s literal 0 HcmV?d00001 diff --git a/dao/__pycache__/users_dao.cpython-313.pyc b/dao/__pycache__/users_dao.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b111e2e9a7ded0095da005d2f0a88486e93e1591 GIT binary patch literal 2160 zcmcIk+iTla7(bFMS(f7{mnGiZE%Tami72Em4Wn7eD6FI;&@ro_l(-1TQeqX`YK~;u zyo|s`u~|kuXm)|Y)-7Wh8#H|?g^c|RF&Ws16$V>wPxWTTU{5>Wk>$-vUw1&??e~30 z=X{sbh(shr)}w)kH`xF}zk=c~qSMv5OxQY7k;=@VX$CWKq_QeE!%lOUqjF#-FwJA0 z%KVHl9mK&ndLErdst`kJaE$df!s4Q+gpNQ_7&oXIwryJZBS48Bn6(WIcHkFvorsZ4 z%FbxFQnr2D!g|`<#iPNd(#R5F9VHQFRD@ZT!CW$+vPoX$lEV3b8i=8rfw87D4mL49 zDXKy;bY4({ZMf9L#blVohT5F9YO9~R-bpp0y%R&l{3=KtI{7r z0y*+EiiE7AS(IR?y^YMZBF36%f^T!6%D-L-;UqfCjFTF*;zK7r{0t0{Ocy2yzQ0;J zxVO1qe6&~oVE^;44XM5s`5}p9@@D2&5ipvwlbA7 zle_X8RryjyzO*Y(?TAwi5`x?i>`(sbq+9_TP66lJsw5I0TBW~T_Ya@h&s#q|A|9ib)WR`D^Bs= zlX%+>Ea?mOio$y}jQA6$!Th-aY-J5sa*R|uLlSA~u3$R_!WD@&01d)0kgn)Q;unaD zV?a&;z@>B&mbP*^({Y2?SipvzQ8=$l`oJTo&(lV*p%(FY9K)Slk;GB6+vWF$Z|Neq z4R68<`mLwG);Cb?8?N*X{~R0M3{^+QDR()#%wu^lYv7#cJzP;*#GUv&F5YMBPW`vM^==oh*B#M~zDC%3Lu&P;6UKU$BiowpN} z$;7U#R^{sz`TDLrwd5d(ro323z&E)D< aAZBn6z4Qc~eu9+0gnlN;{DFYdL;eG0fXSo) literal 0 HcmV?d00001 diff --git a/dao/classes_dao.py b/dao/classes_dao.py new file mode 100644 index 0000000..f508404 --- /dev/null +++ b/dao/classes_dao.py @@ -0,0 +1,76 @@ +# dao/classes_dao.py +# 班级表的数据访问层(增、查、改、逻辑删除) +from typing import Tuple + +from sqlalchemy.orm import Session + +from model.classes import Classinfo +from model.students import Student +from scheme.classes import ClassAdd, ClassUpdate + + +class ClassDAO: + @staticmethod + def inspect_class_id_unq(db: Session, class_id: int): + """ + 根据班级ID获取班级对象(用于主键唯一性检查,查询结果包含被软删除的对象) + """ + return db.query(Classinfo).filter(Classinfo.class_id == class_id).first() + + @staticmethod + def get_active(db: Session, class_id: int): + """获取未被软删除的班级对象(用于外键校验)""" + return ( + db.query(Classinfo) + .filter(Classinfo.class_id == class_id, Classinfo.is_deleted == 0) + .first() + ) + + @staticmethod + def add_class(db: Session, class_data: ClassAdd) -> Classinfo: + """新增班级;class_id 不传则使用自增""" + db_class = Classinfo(**class_data.model_dump(exclude_none=True)) + db.add(db_class) + db.commit() + db.refresh(db_class) + return db_class + + @staticmethod + def update(db: Session, class_id: int, class_data: ClassUpdate): + """更新班级(只更新传入的非 None 字段)""" + db_class = ClassDAO.get_active(db, class_id) + if db_class is None: + return None + for key, value in class_data.model_dump(exclude_unset=True, exclude_none=True).items(): + setattr(db_class, key, value) + db.commit() + db.refresh(db_class) + return db_class + + @staticmethod + def delete_light(db: Session, class_id: int) -> bool: + """ + 逻辑删除班级;若班级下仍有未删除学生则拒绝删除 + :return: True 成功 / False 不存在 / -1 班级下还有学生 + """ + db_class = ClassDAO.get_active(db, class_id) + if db_class is None: + return False + student_count = ( + db.query(Student) + .filter(Student.class_id == class_id, Student.is_deleted == 0) + .count() + ) + if student_count > 0: + return -1 + db_class.is_deleted = 1 + db.commit() + return True + + @staticmethod + def get_all(db: Session, skip: int = 0, limit: int = 100) -> Tuple[int, list]: + """分页获取所有班级(不含软删除),返回 (总数, 列表)""" + query = db.query(Classinfo).filter(Classinfo.is_deleted == 0) + total = query.count() + items = query.order_by(Classinfo.class_id).offset(skip).limit(limit).all() + return total, items diff --git a/dao/employment_dao.py b/dao/employment_dao.py new file mode 100644 index 0000000..1bb81b6 --- /dev/null +++ b/dao/employment_dao.py @@ -0,0 +1,166 @@ +# dao/employment_dao.py +# 就业表的数据访问层(基础表 + offer 表联动 + 学生状态联动) +from datetime import date +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session, joinedload + +from model.employment import EmploymentBase, EmploymentOffer +from model.students import Student +from model.classes import Classinfo + + +class EmploymentDAO: + @staticmethod + def get_base(db: Session, stu_id: int, include_deleted: bool = False) -> Optional[EmploymentBase]: + """获取学生就业基础记录""" + query = db.query(EmploymentBase).filter(EmploymentBase.stu_id == stu_id) + if not include_deleted: + query = query.filter(EmploymentBase.is_deleted == 0) + return query.first() + + # ==================== 就业开放登记 ==================== + @staticmethod + def open_employment(db: Session, stu_id: int, open_time: date) -> EmploymentBase: + """ + 登记就业开放(开放简历): + 1. 插入就业基础表记录(冗余字段从 student/class 同步写入) + 2. 学生表状态更新为『进入就业』(需求 2.3 联动) + """ + student = db.query(Student).filter(Student.stu_id == stu_id).one() + class_row = ( + db.query(Classinfo).filter(Classinfo.class_id == student.class_id).one() + ) + db_base = EmploymentBase( + stu_id=stu_id, + employment_open_time=open_time, + stu_name=student.stu_name, + class_name=class_row.class_name, + ) + student.status = "进入就业" + db.add(db_base) + db.commit() + db.refresh(db_base) + return db_base + + # ==================== offer 登记 ==================== + @staticmethod + def add_offer(db: Session, stu_id: int, offer_time: date, company_name: str, salary: float) -> EmploymentOffer: + """ + 登记offer(拿到 offer): + 1. offer 表插入记录(offer_id 同一学生内自增) + 2. 就业基础表更新 offer 时间 / 公司 / 薪资 + 3. 学生表状态更新为『已就业』(需求 2.3 联动) + """ + max_offer_id = ( + db.query(EmploymentOffer.offer_id) + .filter(EmploymentOffer.stu_id == stu_id) + .order_by(EmploymentOffer.offer_id.desc()) + .first() + ) + offer_id = (max_offer_id[0] + 1) if max_offer_id else 1 + db_offer = EmploymentOffer( + stu_id=stu_id, + offer_id=offer_id, + offer_time=offer_time, + company_name=company_name, + salary=salary, + ) + # 更新基础表(以最新 offer 为准) + db_base = EmploymentDAO.get_base(db, stu_id) + db_base.job_time = offer_time + db_base.company_name = company_name + db_base.salary = salary + # 学生状态联动 + student = db.query(Student).filter(Student.stu_id == stu_id).one() + student.status = "已就业" + db.add(db_offer) + db.commit() + db.refresh(db_offer) + return db_offer + + # ==================== 查询 ==================== + @staticmethod + def get_by_student(db: Session, stu_id: int) -> Optional[EmploymentBase]: + """获取学生就业信息(含 offer 列表)""" + return ( + db.query(EmploymentBase) + .options(joinedload(EmploymentBase.offers)) + .filter(EmploymentBase.stu_id == stu_id, EmploymentBase.is_deleted == 0) + .first() + ) + + @staticmethod + def get_by_class(db: Session, class_id: int, skip: int = 0, limit: int = 100) -> Tuple[int, List[EmploymentBase]]: + """获取班级学生的就业信息(通过学生表关联班级)""" + query = ( + db.query(EmploymentBase) + .join(Student, EmploymentBase.stu_id == Student.stu_id) + .filter(Student.class_id == class_id, EmploymentBase.is_deleted == 0) + ) + total = query.count() + items = ( + query.options(joinedload(EmploymentBase.offers)) + .order_by(EmploymentBase.stu_id) + .offset(skip) + .limit(limit) + .all() + ) + return total, items + + @staticmethod + def get_all( + db: Session, + skip: int = 0, + limit: int = 100, + stu_id: Optional[int] = None, + company_name: Optional[str] = None, + salary_min: Optional[float] = None, + salary_max: Optional[float] = None, + ) -> Tuple[int, List[EmploymentBase]]: + """多条件查询就业信息:学号精确、公司名模糊、薪资范围""" + query = db.query(EmploymentBase).filter(EmploymentBase.is_deleted == 0) + if stu_id is not None: + query = query.filter(EmploymentBase.stu_id == stu_id) + if company_name: + query = query.filter(EmploymentBase.company_name.like(f"%{company_name}%")) + if salary_min is not None: + query = query.filter(EmploymentBase.salary >= salary_min) + if salary_max is not None: + query = query.filter(EmploymentBase.salary <= salary_max) + total = query.count() + items = ( + query.options(joinedload(EmploymentBase.offers)) + .order_by(EmploymentBase.stu_id) + .offset(skip) + .limit(limit) + .all() + ) + return total, items + + # ==================== 修改 / 删除 ==================== + @staticmethod + def update_base(db: Session, stu_id: int, update_data: dict) -> Optional[EmploymentBase]: + """修改就业基础信息(只更新传入的非 None 字段)""" + db_base = EmploymentDAO.get_base(db, stu_id) + if db_base is None: + return None + for key, value in update_data.items(): + setattr(db_base, key, value) + db.commit() + db.refresh(db_base) + return db_base + + @staticmethod + def delete_light(db: Session, stu_id: int) -> bool: + """逻辑删除学生就业基础记录""" + db_base = EmploymentDAO.get_base(db, stu_id) + if db_base is None: + return False + db_base.is_deleted = 1 + # 学生状态回退为在读 + student = db.query(Student).filter(Student.stu_id == stu_id).first() + if student: + student.status = "在读" + db.commit() + return True diff --git a/dao/scores_dao.py b/dao/scores_dao.py new file mode 100644 index 0000000..7e8445e --- /dev/null +++ b/dao/scores_dao.py @@ -0,0 +1,90 @@ +# dao/scores_dao.py +# 成绩表的数据访问层(录入、修改、删除、查询 + 60分红线预警) +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session + +from model.scores import Score +from model.students import Student +from scheme.scores import ScoreAdd + + +class ScoreDAO: + WARNING_LINE = 60 # 成绩红线 + + @staticmethod + def get_one(db: Session, stu_id: int, exam_id: int, include_deleted: bool = False) -> Optional[Score]: + """获取指定学生某次考核的成绩(复合主键查询)""" + query = db.query(Score).filter(Score.stu_id == stu_id, Score.exam_id == exam_id) + if not include_deleted: + query = query.filter(Score.is_deleted == 0) + return query.first() + + @staticmethod + def add_score(db: Session, score_data: ScoreAdd) -> Score: + """录入成绩(调用方需先校验学生存在与复合主键冲突)""" + db_score = Score(**score_data.model_dump(), is_deleted=0) + db.add(db_score) + db.commit() + db.refresh(db_score) + return db_score + + @staticmethod + def update_score(db: Session, stu_id: int, exam_id: int, new_score: float) -> Optional[Score]: + """修改指定学生的某次成绩""" + db_score = ScoreDAO.get_one(db, stu_id, exam_id) + if db_score is None: + return None + db_score.score = new_score + db_score.is_deleted = 0 # 若曾被软删除,修改视为恢复 + db.commit() + db.refresh(db_score) + return db_score + + @staticmethod + def delete_score(db: Session, stu_id: int, exam_id: int) -> bool: + """ + 逻辑删除指定学生的某次成绩 + :return: True 成功 / False 不存在或已删除 + """ + db_score = ScoreDAO.get_one(db, stu_id, exam_id) + if db_score is None: + return False + db_score.is_deleted = 1 + db.commit() + return True + + @staticmethod + def get_by_student(db: Session, stu_id: int) -> List[Score]: + """获取学生的全部成绩(按考核序次排序)""" + return ( + db.query(Score) + .filter(Score.stu_id == stu_id, Score.is_deleted == 0) + .order_by(Score.exam_id) + .all() + ) + + @staticmethod + def get_by_exam(db: Session, exam_id: int, skip: int = 0, limit: int = 100) -> Tuple[int, List[Score]]: + """按考核序次查询成绩列表(分页)""" + query = db.query(Score).filter(Score.exam_id == exam_id, Score.is_deleted == 0) + total = query.count() + items = query.order_by(Score.stu_id).offset(skip).limit(limit).all() + return total, items + + @staticmethod + def attach_student_name(db: Session, score: Score) -> None: + """把学生姓名附加到 Score 对象上(供响应模型冗余展示)""" + student = db.query(Student).filter(Student.stu_id == score.stu_id).first() + score.stu_name = student.stu_name if student else None + + @staticmethod + def build_score_item(score: Score, stu_name: Optional[str] = None) -> dict: + """组装成绩响应字典:附带红线预警标记""" + return { + "stu_id": score.stu_id, + "exam_id": score.exam_id, + "score": score.score, + "stu_name": stu_name, + "is_warning": score.score is not None and score.score < ScoreDAO.WARNING_LINE, + } diff --git a/dao/statistics_dao.py b/dao/statistics_dao.py new file mode 100644 index 0000000..1758548 --- /dev/null +++ b/dao/statistics_dao.py @@ -0,0 +1,461 @@ +# dao/statistics_dao.py +# 统计分析模块的数据访问层: +# - 全部使用 SQLAlchemy 表达式动态拼装查询(避免原生 SQL 字符串拼接带来的注入风险) +# - 覆盖需求 2.6(动态年龄/班级统计/成绩统计/就业统计)与 2.7(高级筛选器/聚合统计) +from typing import List, Tuple + +from sqlalchemy import and_, or_, func, case +from sqlalchemy.orm import Session + +from model.students import Student +from model.classes import Classinfo +from model.scores import Score +from model.employment import EmploymentBase +from scheme.statistics import FilterRule, FilterGroup + +# 高级筛选器允许的字段白名单 -> (SQLAlchemy 列, 是否需要外联 employment_base) +_FILTER_FIELDS = { + "stu_id": (Student.stu_id, False), + "stu_name": (Student.stu_name, False), + "age": (Student.age, False), + "gender": (Student.gender, False), + "education": (Student.education, False), + "major": (Student.major, False), + "native_place": (Student.native_place, False), + "status": (Student.status, False), + "class_id": (Student.class_id, False), + "class_name": (Classinfo.class_name, False), + "salary": (EmploymentBase.salary, True), + "company_name": (EmploymentBase.company_name, True), +} + + +class StatisticsDAO: + # ==================== 2.6.1 动态年龄范围查询 ==================== + @staticmethod + def students_by_age( + db: Session, + op: str, + value: int = None, + min_value: int = None, + max_value: int = None, + ) -> List[Student]: + """ + 动态年龄查询 + :param op: 比较条件 gt/lt/eq/gte/lte/between + """ + query = ( + db.query(Student) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Student.is_deleted == 0) + ) + if op == "gt": + query = query.filter(Student.age > value) + elif op == "lt": + query = query.filter(Student.age < value) + elif op == "eq": + query = query.filter(Student.age == value) + elif op == "gte": + query = query.filter(Student.age >= value) + elif op == "lte": + query = query.filter(Student.age <= value) + elif op == "between": + query = query.filter(Student.age >= min_value, Student.age <= max_value) + else: + raise ValueError(f"不支持的年龄比较条件: {op}") + return query.order_by(Student.age).all() + + # ==================== 2.6.1 多维度班级统计 ==================== + @staticmethod + def class_gender_stats(db: Session) -> List[dict]: + """统计每个班级总人数及男女分布""" + male_cnt = func.sum(case((Student.gender == "男", 1), else_=0)) + female_cnt = func.sum(case((Student.gender == "女", 1), else_=0)) + rows = ( + db.query( + Classinfo.class_id, + Classinfo.class_name, + func.count(Student.stu_id).label("total"), + male_cnt.label("male"), + female_cnt.label("female"), + ) + .join(Student, Student.class_id == Classinfo.class_id, isouter=True) + .filter(Classinfo.is_deleted == 0, Student.is_deleted == 0) + .group_by(Classinfo.class_id, Classinfo.class_name) + .order_by(Classinfo.class_id) + .all() + ) + return [ + { + "class_id": r.class_id, + "class_name": r.class_name, + "total": r.total or 0, + "male": r.male or 0, + "female": r.female or 0, + } + for r in rows + ] + + # ==================== 2.6.2 每次考试都在分数线以上的学生 ==================== + @staticmethod + def students_all_above(db: Session, line: float) -> List[dict]: + """查询每次考试成绩都在 line 分以上的学生(按最低分聚合判断)""" + rows = ( + db.query( + Student.stu_id, + Student.stu_name, + Classinfo.class_name, + func.count(Score.exam_id).label("exam_count"), + func.min(Score.score).label("min_score"), + ) + .join(Score, Score.stu_id == Student.stu_id) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Score.is_deleted == 0, Student.is_deleted == 0) + .group_by(Student.stu_id, Student.stu_name, Classinfo.class_name) + .having(func.min(Score.score) >= line) + .all() + ) + result = [] + for r in rows: + details = ( + db.query(Score.exam_id, Score.score) + .filter(Score.stu_id == r.stu_id, Score.is_deleted == 0) + .order_by(Score.exam_id) + .all() + ) + result.append( + { + "stu_id": r.stu_id, + "stu_name": r.stu_name, + "class_name": r.class_name, + "exam_count": r.exam_count, + "min_score": r.min_score, + "scores": [{"exam_id": d.exam_id, "score": d.score} for d in details], + } + ) + return result + + # ==================== 2.6.2 不及格次数 >= N 的学生 ==================== + @staticmethod + def fail_students(db: Session, min_times: int, line: float = 60.0) -> List[dict]: + """查询不及格(< line)次数 >= min_times 的学生及其不及格明细""" + fail_cond = and_(Score.score < line, Score.is_deleted == 0) + rows = ( + db.query( + Student.stu_id, + Student.stu_name, + Classinfo.class_name, + func.count(Score.exam_id).label("fail_count"), + ) + .join(Score, Score.stu_id == Student.stu_id) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Student.is_deleted == 0, fail_cond) + .group_by(Student.stu_id, Student.stu_name, Classinfo.class_name) + .having(func.count(Score.exam_id) >= min_times) + .all() + ) + result = [] + for r in rows: + details = ( + db.query(Score.exam_id, Score.score) + .filter(fail_cond, Score.stu_id == r.stu_id) + .order_by(Score.exam_id) + .all() + ) + result.append( + { + "stu_id": r.stu_id, + "stu_name": r.stu_name, + "class_name": r.class_name, + "fail_count": r.fail_count, + "fail_details": [{"exam_id": d.exam_id, "score": d.score} for d in details], + } + ) + return result + + # ==================== 2.6.2 每次考试每个班级的平均分(动态排序) ==================== + @staticmethod + def class_exam_avg(db: Session, exam_id: int = None, order: str = "desc") -> List[dict]: + """统计每次考试每个班级的平均分,order: asc/desc""" + avg_expr = func.round(func.avg(Score.score), 2) + query = ( + db.query( + Score.exam_id, + Classinfo.class_id, + Classinfo.class_name, + avg_expr.label("avg_score"), + ) + .join(Student, Score.stu_id == Student.stu_id) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Score.is_deleted == 0, Student.is_deleted == 0) + .group_by(Score.exam_id, Classinfo.class_id, Classinfo.class_name) + ) + if exam_id is not None: + query = query.filter(Score.exam_id == exam_id) + query = query.order_by(avg_expr.desc() if order == "desc" else avg_expr.asc()) + return [ + { + "exam_id": r.exam_id, + "class_id": r.class_id, + "class_name": r.class_name, + "avg_score": float(r.avg_score), + } + for r in query.all() + ] + + # ==================== 2.6.3 就业薪资 Top N ==================== + @staticmethod + def top_salary(db: Session, n: int) -> List[dict]: + """薪资排名 Top N(从就业基础表取最新薪资)""" + rows = ( + db.query( + EmploymentBase.stu_id, + EmploymentBase.stu_name, + EmploymentBase.class_name, + EmploymentBase.job_time, + EmploymentBase.company_name, + EmploymentBase.salary, + ) + .join(Student, EmploymentBase.stu_id == Student.stu_id) + .filter( + EmploymentBase.is_deleted == 0, + Student.is_deleted == 0, + EmploymentBase.salary > 0, + ) + .order_by(EmploymentBase.salary.desc()) + .limit(n) + .all() + ) + return [ + { + "stu_id": r.stu_id, + "stu_name": r.stu_name, + "class_name": r.class_name, + "job_time": r.job_time, + "company_name": r.company_name, + "salary": r.salary, + } + for r in rows + ] + + # ==================== 2.6.3 每个学生的就业时长 ==================== + @staticmethod + def employment_durations(db: Session) -> List[dict]: + """就业时长 = offer下发时间(job_time) - 就业开放时间(employment_open_time),单位天""" + duration_days = func.datediff(EmploymentBase.job_time, EmploymentBase.employment_open_time) + rows = ( + db.query( + EmploymentBase.stu_id, + EmploymentBase.stu_name, + EmploymentBase.class_name, + EmploymentBase.employment_open_time, + EmploymentBase.job_time, + duration_days.label("duration_days"), + ) + .filter(EmploymentBase.is_deleted == 0) + .order_by(EmploymentBase.stu_id) + .all() + ) + return [ + { + "stu_id": r.stu_id, + "stu_name": r.stu_name, + "class_name": r.class_name, + "employment_open_time": r.employment_open_time, + "job_time": r.job_time, + # 未拿到 offer 记为 -1,前端展示为"未就业" + "duration_days": int(r.duration_days) if r.duration_days is not None else -1, + } + for r in rows + ] + + # ==================== 2.6.3 每个班级平均就业时长 ==================== + @staticmethod + def class_avg_duration(db: Session) -> List[dict]: + """平均就业时长:仅统计进入就业阶段(有就业开放时间)的学生; + 平均值仅对已拿到 offer 的学生计算""" + opened = func.count(EmploymentBase.stu_id) + offered = func.sum(case((EmploymentBase.job_time.isnot(None), 1), else_=0)) + avg_days = func.round( + func.avg( + case( + ( + EmploymentBase.job_time.isnot(None), + func.datediff(EmploymentBase.job_time, EmploymentBase.employment_open_time), + ) + ) + ), + 1, + ) + rows = ( + db.query( + Student.class_id, + Classinfo.class_name, + opened.label("opened_count"), + offered.label("offered_count"), + avg_days.label("avg_duration_days"), + ) + .join(EmploymentBase, EmploymentBase.stu_id == Student.stu_id) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Student.is_deleted == 0, EmploymentBase.is_deleted == 0) + .group_by(Student.class_id, Classinfo.class_name) + .order_by(Student.class_id) + .all() + ) + return [ + { + "class_id": r.class_id, + "class_name": r.class_name, + "opened_count": r.opened_count or 0, + "offered_count": int(r.offered_count or 0), + "avg_duration_days": float(r.avg_duration_days) if r.avg_duration_days is not None else 0.0, + } + for r in rows + ] + + # ==================== 2.7.2 成绩波动分析(最大分差 Top N) ==================== + @staticmethod + def score_volatility(db: Session, top_n: int = 5) -> List[dict]: + """成绩波动最大 Top N(最大分差 = 最高分 - 最低分,SQL 聚合计算)""" + diff_expr = (func.max(Score.score) - func.min(Score.score)).label("diff") + rows = ( + db.query( + Student.stu_id, + Student.stu_name, + Classinfo.class_name, + func.max(Score.score).label("max_score"), + func.min(Score.score).label("min_score"), + diff_expr, + ) + .join(Score, Score.stu_id == Student.stu_id) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Score.is_deleted == 0, Student.is_deleted == 0) + .group_by(Student.stu_id, Student.stu_name, Classinfo.class_name) + .order_by(diff_expr.desc()) + .limit(top_n) + .all() + ) + return [ + { + "stu_id": r.stu_id, + "stu_name": r.stu_name, + "class_name": r.class_name, + "max_score": r.max_score, + "min_score": r.min_score, + "diff": float(r.diff), + } + for r in rows + ] + + # ==================== 2.7.2 班级就业漏斗 ==================== + @staticmethod + def employment_funnel(db: Session, high_salary_line: float = 10000.0) -> List[dict]: + """每个班级:总人数 -> 已就业人数 -> 高薪人数(>10k) -> 就业率,按就业率降序""" + employed_cnt = func.sum(case((EmploymentBase.stu_id.isnot(None), 1), else_=0)) + high_salary_cnt = func.sum( + case((and_(EmploymentBase.stu_id.isnot(None), EmploymentBase.salary > high_salary_line), 1), else_=0) + ) + rows = ( + db.query( + Classinfo.class_id, + Classinfo.class_name, + func.count(Student.stu_id).label("total"), + employed_cnt.label("employed"), + high_salary_cnt.label("high_salary"), + ) + .join(Student, Student.class_id == Classinfo.class_id) + .join( + EmploymentBase, + and_( + EmploymentBase.stu_id == Student.stu_id, + EmploymentBase.is_deleted == 0, + ), + isouter=True, + ) + .filter(Student.is_deleted == 0, Classinfo.is_deleted == 0) + .group_by(Classinfo.class_id, Classinfo.class_name) + .all() + ) + result = [] + for r in rows: + total = r.total or 0 + employed = int(r.employed or 0) + rate = round(employed / total * 100, 2) if total else 0.0 + result.append( + { + "class_id": r.class_id, + "class_name": r.class_name, + "total": total, + "employed": employed, + "high_salary": int(r.high_salary or 0), + "employment_rate": rate, + } + ) + result.sort(key=lambda x: x["employment_rate"], reverse=True) + return result + + +# ============================================================ +# 2.7.1 通用高级筛选器:把规则树递归翻译为 SQLAlchemy 表达式 +# ============================================================ +class FilterBuilder: + @staticmethod + def _rule_to_expr(rule: FilterRule): + """把单条规则翻译为 SQLAlchemy 比较表达式""" + if rule.field not in _FILTER_FIELDS: + raise ValueError(f"不支持筛选的字段: {rule.field},允许的字段: {sorted(_FILTER_FIELDS)}") + column, _ = _FILTER_FIELDS[rule.field] + op = rule.operator + if op == ">": + return column > rule.value + if op == "<": + return column < rule.value + if op == "=": + return column == rule.value + if op == "!=": + return column != rule.value + if op == ">=": + return column >= rule.value + if op == "<=": + return column <= rule.value + if op == "like": + return column.like(f"%{rule.value}%") + if op == "in": + if not isinstance(rule.value, (list, tuple)): + raise ValueError("operator=in 时 value 必须是列表") + return column.in_(list(rule.value)) + raise ValueError(f"不支持的操作符: {op}") + + @classmethod + def to_expr(cls, rules: list): + """把规则列表(顶层默认 AND)翻译为一个组合表达式""" + if not rules: + raise ValueError("筛选规则不能为空") + exprs = [] + for r in rules: + exprs.append(cls._node_to_expr(r)) + return and_(*exprs) if len(exprs) > 1 else exprs[0] + + @classmethod + def _node_to_expr(cls, node): + """递归处理规则节点:FilterGroup 组合子规则,FilterRule 直接翻译""" + if isinstance(node, FilterGroup): + sub = [cls._node_to_expr(r) for r in node.sub_rules] + return or_(*sub) if node.logic == "OR" else and_(*sub) + if isinstance(node, FilterRule): + return cls._rule_to_expr(node) + raise ValueError(f"无法识别的筛选规则节点: {type(node)}") + + @staticmethod + def query_students(db: Session, rules: list) -> Tuple[int, list]: + """执行高级筛选查询(student 模型,自动关联班级与就业表)""" + expr = FilterBuilder.to_expr(rules) + query = ( + db.query(Student) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .outerjoin(EmploymentBase, EmploymentBase.stu_id == Student.stu_id) + .filter(Student.is_deleted == 0, expr) + ) + total = query.count() + items = query.order_by(Student.stu_id).all() + return total, items diff --git a/dao/students_dao.py b/dao/students_dao.py new file mode 100644 index 0000000..96b7361 --- /dev/null +++ b/dao/students_dao.py @@ -0,0 +1,150 @@ +# dao/students_dao.py +# 学生表的数据访问层(增、查、改、逻辑删除 + 学号自动生成规则) +from datetime import date +from typing import Optional, Tuple + +from sqlalchemy import or_ +from sqlalchemy.orm import Session, joinedload + +from model.students import Student +from model.classes import Classinfo +from model.advisor import Advisor +from scheme.students import StudentAdd, StudentUpdate + + +class StudentDAO: + @staticmethod + def inspect_student_id_unq(db: Session, stu_id: int) -> Optional[Student]: + """根据学号获取学生对象(用于主键唯一性检查,包含被软删除的对象)""" + return db.query(Student).filter(Student.stu_id == stu_id).first() + + @staticmethod + def get_active(db: Session, stu_id: int) -> Optional[Student]: + """获取未被软删除的学生对象(用于外键校验与查询)""" + return ( + db.query(Student) + .filter(Student.stu_id == stu_id, Student.is_deleted == 0) + .first() + ) + + @staticmethod + def inspect_class_id_unq(db: Session, class_id: int) -> Optional[Classinfo]: + """班级外键校验(排除软删除)""" + return ( + db.query(Classinfo) + .filter(Classinfo.class_id == class_id, Classinfo.is_deleted == 0) + .first() + ) + + @staticmethod + def inspect_advisor_id_unq(db: Session, advisor_id: int) -> Optional[Advisor]: + """顾问外键校验(排除软删除)""" + return ( + db.query(Advisor) + .filter(Advisor.advisor_id == advisor_id, Advisor.is_deleted == 0) + .first() + ) + + # ==================== 学号生成规则 ==================== + # 规则:入学年份(4位) + 班级号(2位) + 班内序号(4位) + # 示例:2026 年入学、班级 3 的第 1 个学生 -> 2026030001 + # 说明:班级号超过 99 时会溢出到序号位,业务上建议班级数控制在 99 以内 + @staticmethod + def generate_stu_id(db: Session, enroll_time: date, class_id: int) -> int: + prefix = enroll_time.year * 10000 + class_id # 前6位:年份+班级 + # 找到同一前缀下最大的学号,在其基础上 +1 + max_stu_id = ( + db.query(Student.stu_id) + .filter( + Student.stu_id >= prefix * 10000, + Student.stu_id <= prefix * 10000 + 9999, + ) + .order_by(Student.stu_id.desc()) + .first() + ) + seq = (max_stu_id[0] % 10000) + 1 if max_stu_id else 1 + return prefix * 10000 + seq + + # ==================== CRUD ==================== + @staticmethod + def add_student(db: Session, student_data: StudentAdd) -> Student: + """新增学生;stu_id 不传则按规则自动生成""" + data = student_data.model_dump(exclude_none=True) + data.pop("is_deleted", None) + if "stu_id" not in data: + data["stu_id"] = StudentDAO.generate_stu_id( + db, data["enroll_time"], data["class_id"] + ) + db_student = Student(**data) + db.add(db_student) + db.commit() + db.refresh(db_student) + return db_student + + @staticmethod + def update(db: Session, stu_id: int, student_data: StudentUpdate) -> Optional[Student]: + """更新学生信息(只更新传入的非 None 字段)""" + db_student = StudentDAO.get_active(db, stu_id) + if db_student is None: + return None + for key, value in student_data.model_dump(exclude_unset=True, exclude_none=True).items(): + setattr(db_student, key, value) + db.commit() + db.refresh(db_student) + return db_student + + @staticmethod + def delete_light(db: Session, stu_id: int) -> bool: + """ + 逻辑删除学生 + :return: True 成功 / False 学号不存在或已被软删除 + """ + db_student = StudentDAO.get_active(db, stu_id) + if db_student is None: + return False + db_student.is_deleted = 1 + db.commit() + return True + + @staticmethod + def get_all( + db: Session, + skip: int = 0, + limit: int = 100, + stu_id: Optional[int] = None, + stu_name: Optional[str] = None, + class_id: Optional[int] = None, + gender: Optional[str] = None, + status: Optional[str] = None, + education: Optional[str] = None, + ) -> Tuple[int, list]: + """ + 分页 + 多条件筛选查询学生列表(不含软删除) + 支持按编号精确、姓名模糊、班级、性别、状态、学历筛选 + """ + query = ( + db.query(Student) + .options(joinedload(Student.classes), joinedload(Student.advisor)) + .filter(Student.is_deleted == 0) + ) + if stu_id is not None: + query = query.filter(Student.stu_id == stu_id) + if stu_name: + query = query.filter(Student.stu_name.like(f"%{stu_name}%")) + if class_id is not None: + query = query.filter(Student.class_id == class_id) + if gender: + query = query.filter(Student.gender == gender) + if status: + query = query.filter(Student.status == status) + if education: + query = query.filter(Student.education == education) + + total = query.count() + items = ( + query.order_by(Student.stu_id) + .offset(skip) + .limit(limit) + .all() + ) + return total, items diff --git a/dao/teachers_dao.py b/dao/teachers_dao.py new file mode 100644 index 0000000..87167f0 --- /dev/null +++ b/dao/teachers_dao.py @@ -0,0 +1,105 @@ +# dao/teachers_dao.py +# 老师表的数据访问层(沿用原 teacher 模块风格,修复原代码中 Classinfo 引用不一致的问题) +from typing import Optional, Tuple + +from sqlalchemy.orm import Session + +from model.teachers import Teacher +from model.classes import Classinfo +from scheme.teachers import TeacherAdd, TeacherUpdate + + +class TeacherDAO: + @staticmethod + def inspect_teacher_id_unq(db: Session, teacher_id: int) -> Optional[Teacher]: + """ + 根据教师ID获取教师对象(用于主键唯一性检查,查询结果包含被软删除的对象) + """ + return db.query(Teacher).filter(Teacher.teacher_id == teacher_id).first() + + @staticmethod + def inspect_class_id_unq(db: Session, class_id: int) -> Optional[Classinfo]: + """ + 根据班级ID获取班级对象(用于外键检查,排除被软删除对象) + """ + return ( + db.query(Classinfo) + .filter(Classinfo.class_id == class_id, Classinfo.is_deleted == 0) + .first() + ) + + @staticmethod + def next_teacher_id(db: Session) -> int: + """生成自增教师ID(max + 1,保证逻辑删除后不复用旧 ID)""" + max_id = db.query(Teacher.teacher_id).order_by(Teacher.teacher_id.desc()).first() + return (max_id[0] + 1) if max_id else 1 + + @staticmethod + def add_teacher(db: Session, teacher_data: TeacherAdd) -> Teacher: + """新增教师;teacher_id 不传则自动生成""" + data = teacher_data.model_dump(exclude_none=True) + data.pop("is_deleted", None) + if "teacher_id" not in data: + data["teacher_id"] = TeacherDAO.next_teacher_id(db) + db_teacher = Teacher(**data) + db.add(db_teacher) + db.commit() + db.refresh(db_teacher) + return db_teacher + + @staticmethod + def delete_light(db: Session, teacher_id: int) -> bool: + """ + 删除教师(逻辑删除) + :return: True 删除成功 / False 不存在或已被软删除 + """ + db_teacher = TeacherDAO.inspect_teacher_id_unq(db, teacher_id) + if (not db_teacher) or db_teacher.is_deleted == 1: + return False + db_teacher.is_deleted = 1 + db.commit() + return True + + @staticmethod + def update(db: Session, teacher_id: int, teacher_data: TeacherUpdate) -> Optional[Teacher]: + """更新教师信息(只更新传入的非 None 字段)""" + db_teacher = TeacherDAO.inspect_teacher_id_unq(db, teacher_id) + if (not db_teacher) or db_teacher.is_deleted == 1: + return None + for key, value in teacher_data.model_dump(exclude_unset=True, exclude_none=True).items(): + setattr(db_teacher, key, value) + db.commit() + db.refresh(db_teacher) + return db_teacher + + @staticmethod + def get_all(db: Session, skip: int = 0, limit: int = 100) -> Tuple[int, list]: + """分页获取所有教师(不含软删除),联表带出班级名称,返回 (总数, 列表)""" + query = ( + db.query(Teacher, Classinfo.class_name) + .join(Classinfo, Teacher.class_id == Classinfo.class_id, isouter=True) + .filter(Teacher.is_deleted == 0) + ) + total = query.count() + rows = query.order_by(Teacher.teacher_id).offset(skip).limit(limit).all() + # 把 class_name 附加到对象上,供响应模型 from_attributes 读取 + items = [] + for teacher, class_name in rows: + teacher.class_name = class_name + items.append(teacher) + return total, items + + @staticmethod + def get_by_id(db: Session, teacher_id: int) -> Optional[Teacher]: + """根据 ID 查询单个教师(不含软删除),联表带出班级名称""" + row = ( + db.query(Teacher, Classinfo.class_name) + .join(Classinfo, Teacher.class_id == Classinfo.class_id, isouter=True) + .filter(Teacher.teacher_id == teacher_id, Teacher.is_deleted == 0) + .first() + ) + if row is None: + return None + teacher, class_name = row + teacher.class_name = class_name + return teacher diff --git a/dao/users_dao.py b/dao/users_dao.py new file mode 100644 index 0000000..e5ced3e --- /dev/null +++ b/dao/users_dao.py @@ -0,0 +1,32 @@ +# dao/users_dao.py +# 用户表的数据访问层(认证模块) +from sqlalchemy.orm import Session + +from model.user import User +from scheme.users import UserAdd +from core.security import hash_password + + +class UserDAO: + @staticmethod + def get_by_username(db: Session, username: str): + """按用户名查询(含软删除检查由调用方处理)""" + return db.query(User).filter(User.username == username).first() + + @staticmethod + def add_user(db: Session, user_data: UserAdd) -> User: + db_user = User( + username=user_data.username, + password_hash=hash_password(user_data.password), + role=user_data.role, + teacher_id=user_data.teacher_id, + stu_id=user_data.stu_id, + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + @staticmethod + def get_by_id(db: Session, user_id: int): + return db.query(User).filter(User.user_id == user_id, User.is_deleted == 0).first() diff --git a/database.py b/database.py new file mode 100644 index 0000000..56c74e6 --- /dev/null +++ b/database.py @@ -0,0 +1,36 @@ +# database.py +# 本文件负责配置数据库连接、创建引擎、会话工厂,并提供依赖注入函数(同步模式) + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +from config import settings + +# 1. 配置 MySQL 数据库连接 URL(从 config 读取,支持环境变量覆盖) +SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL + +# 2. 创建数据库引擎 +# - pool_pre_ping=True:每次取连接前先 ping,防止使用已断开的连接 +# - pool_recycle=3600:连接最长复用 1 小时,避免 MySQL wait_timeout 断连 +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + pool_pre_ping=True, + pool_recycle=3600, + echo=settings.DB_ECHO, # True 会在控制台打印所有 SQL,便于调试 +) + +# 3. 创建会话工厂:不自动提交,需要手动 commit +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 4. 声明式基类,所有模型类都继承自它 +Base = declarative_base() + + +# 5. 依赖注入函数:用于 FastAPI 路由中获取数据库会话 +def get_db(): + """每次请求创建一个数据库会话,请求结束后关闭""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f3dba46 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +services: + mysql: + image: mysql:8.0 + container_name: walin-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root123456 + MYSQL_DATABASE: walin_db + TZ: Asia/Shanghai + volumes: + - mysql_data:/var/lib/mysql + # 容器首次启动时自动执行建表 + 种子数据脚本 + - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot123456"] + interval: 5s + timeout: 3s + retries: 20 + networks: + - walin-net + + api: + build: . + container_name: walin-api + restart: unless-stopped + environment: + # 注意:api 容器内通过服务名 mysql 访问数据库 + DATABASE_URL: mysql+pymysql://root:root123456@mysql:3306/walin_db?charset=utf8mb4 + SECRET_KEY: change_me_to_a_long_random_string_in_production + DB_ECHO: "false" + DEBUG: "false" + depends_on: + mysql: + condition: service_healthy + networks: + - walin-net + + nginx: + image: nginx:1.25-alpine + container_name: walin-nginx + restart: unless-stopped + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - api + networks: + - walin-net + +volumes: + mysql_data: + +networks: + walin-net: + driver: bridge diff --git a/main.py b/main.py new file mode 100644 index 0000000..a061b5b --- /dev/null +++ b/main.py @@ -0,0 +1,77 @@ +# main.py +# 项目入口文件:创建 FastAPI 应用、注册路由、建表、挂载前端页面 + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles + +from config import settings +from database import engine, Base +# 导入 model 包触发全部模型注册到 Base.metadata +import model # noqa: F401 + +from api import auth +from api import classes as classes_api +from api import teachers as teachers_api +from api import students as students_api +from api import scores as scores_api +from api import employment as employment_api +from api import statistics as statistics_api + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期:启动时自动建表(不使用 Alembic,开发/演示环境够用)""" + Base.metadata.create_all(bind=engine) + yield + + +# 1. 创建 FastAPI 实例 +app = FastAPI( + title=settings.APP_NAME, + description="基于 FastAPI + SQLAlchemy 的学生管理系统:学生信息、考核成绩、就业管理、班级/老师管理、统计分析", + version=settings.APP_VERSION, + lifespan=lifespan, +) + +# 2. 添加跨域中间件(生产环境应指定具体域名) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 3. 注册子路由 +app.include_router(auth.router, prefix="/api/auth", tags=["认证"]) +app.include_router(classes_api.router, prefix="/api/classes", tags=["班级管理"]) +app.include_router(teachers_api.router, prefix="/api/teachers", tags=["老师管理"]) +app.include_router(students_api.router, prefix="/api/students", tags=["学生管理"]) +app.include_router(scores_api.router, prefix="/api/scores", tags=["成绩管理"]) +app.include_router(employment_api.router, prefix="/api/employment", tags=["就业管理"]) +app.include_router(statistics_api.router, prefix="/api/statistics", tags=["统计分析"]) + +# 4. 挂载前端静态页面 +app.mount("/static", StaticFiles(directory="static", html=True), name="static") + + +@app.get("/", include_in_schema=False) +async def root(): + """根路径重定向到前端管理页面""" + return RedirectResponse(url="/static/index.html") + + +# 5. 直接运行时启动 uvicorn +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=settings.DEBUG, + ) diff --git a/model/__init__.py b/model/__init__.py new file mode 100644 index 0000000..761a5be --- /dev/null +++ b/model/__init__.py @@ -0,0 +1,11 @@ +# model/__init__.py +# 汇总导入所有模型,保证 Base.metadata 能扫描到全部表 +from .user import User +from .classes import Classinfo +from .teachers import Teacher +from .advisor import Advisor +from .students import Student +from .scores import Score +from .employment import EmploymentBase, EmploymentOffer + +__all__ = ["User", "Classinfo", "Teacher", "Advisor", "Student", "Score", "EmploymentBase", "EmploymentOffer"] diff --git a/model/__pycache__/__init__.cpython-310.pyc b/model/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74416bbd0c85264ff9468ad4fa5c0396f5f9dfa1 GIT binary patch literal 543 zcmYk2zfQw25XS8^sgv|CFTkFmsO^9RLI_&1!cZzCE0Pt}E(GkPic_k-4jTgluj7@8 zS774o0>!a^{_YgteRkRD#0bXO;rrtoM(D!^|Az;`1uV@09C0j>!W3hxJ?W_iZ8+VK zz6xmIv@e^AP~vnTLlx1;>86ZTi?*C5vaLF_qq?+<(LLhf3#L6D%}^45f)A8ngDfB?RPFWQzxvCzt#=*QNc1%<454MGZ2TEy_wHp6Onxl`OXk(vwUJ)7!^4 z%Oz9@D|@-BVc}Z@7EKFc5n4om>!fe|HJEV+3|Uw|Lc5bDtapaAt3gS&Gv$hzQ15(c zSJpJO^`>2U6N^88`&Nc9mPyIjE2<7`e~YjA^fa}Pq0_s(;N=GPvU-}VIp3z^;rMtk zJQ<8fgAowsINfA2FW66SrnkBlDpe&HGG#0;@|v-u)z*Y(kXuV}ZXaCt0d231f4@F% Fd*7;0h(7=T literal 0 HcmV?d00001 diff --git a/model/__pycache__/__init__.cpython-313.pyc b/model/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..544739fa641a111074e6f587faa395d25e396067 GIT binary patch literal 493 zcmYL_PfNov7{=f9U)s)Y3Ze))=CK!d^fCk$FDWQeJ%_SpTIt%6bb}rJ5Pk>`9{f6i zA?V4QFua>&>T-DUdtcr>??aYR6oNW~?(4$~0{GCy-?4sSnJajL6jGD`K^*loTZu(% zZX31}hq&A|>?9t+92<5Mp9DNG>?I*-@s?qnY>tfEY>V zRxqya;JnO)D5_Dd3Vg$|;l1hO(|lHlT94qgndYo&6uRlK<}9{k`(ivP>-ku<&oaSe zXI)>7M#{nGwpzp;Ev^<%3)bRm3AE^+xr{fZGgV>C1Br(^4-@94^qW;6VLT+xR-ufU z^u;QdFt*YYI-4-|GGzbx)~h6@G%HI=pJAysgWMX|IV<~=7FE$udNi3!f1rBPlAZ7! lHK9LC94h&;5klV{pxp&@7jUqEeWQDCaQq%tkC<<(sUOxHdVBx? literal 0 HcmV?d00001 diff --git a/model/__pycache__/advisor.cpython-310.pyc b/model/__pycache__/advisor.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6b66297a1b4401017075cca434109550726c4c0 GIT binary patch literal 907 zcmYjPOKTKC5bmCt-I?qo#u#!F;wdhekSrn?l8^`Zxa5+EFf26VRwtQc9!d9%VHXcV za_|yRJcx*(D2N9IZvi1cr12&z?qBd^)jnW*rmDN1UsYG9)oO|1`gP;S!K%gBZxTkA zgTfk;zKskR@QB6S=dtBmvF+QObXMfV1;4=AeFio-XAB%!IJNyE6t-Ed*u_XzvrTa` zjq*4#?!zRLds3O=cBaB)uf~nD5op;YztL4hVGT(?Kqi^bf%z72-)Ab&$DnlX_+wCp${F)ZP{n%2jk^v{L!Bz)2K~cM%`)X96~=*Ti9^{k z)gaH(Flj0o%Ova85&vFZLEim%*?sf9^W}Btk=i*{!v9G}Ea6#?{d6cO#Ix9-vq}bu2a{*}+NlzmK{)`>4 zcX-{nbKLFsbiK}2dun;m(9J*yo zcE2)p6#@V4Y%Vn(X{mJMahO1Qf_thCH*$bhqrOnTGq-SeuD&?8h#dcV<0Oc}MCdFB zya=INI+JlDPC-VE0nGeS%XmU4d8CB6LY?88W7E7nvEHA={Lr{nE0ZUcC4?ynfqC+X z&J`h!^B@{nszPKmfWD|GowA9I6QkFw2*;E{EwB)oNl@2FOC64|NfPP7c}SY3;W3Ag zTa$c(SFE08tLB=LK8}K@xi90^2o}K@4+5+N-?5^x^&&>{_uvS`c+34(R%ybTAg8Q~ HHD&(?cM$Jy literal 0 HcmV?d00001 diff --git a/model/__pycache__/advisor.cpython-313.pyc b/model/__pycache__/advisor.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..70f0198a327bd390d4feed3eaf386aa44ea4444d GIT binary patch literal 1066 zcmZ8g&1(}u6rauRX18h5CboW6DnYDuMcS$;SYp8sT8!F*(?Sbrhi!IRS2vq>c2{Um zIY}>qBBJ7@qR@I!^i~i`|AgeE&P^m@ZbB?xebe25b{2m7K4;$Vy_sezl|Zm^M?c;j ziXrq}D6OSNLUR~|rwAh~-9UMXN%0ZFs6|S%0K!vLKup4j z#jwO=9AipBJ%$Q#EW@WrjSzUJ5gD9BWtyUt1L029^YFzo@VTPWB+yj#DhT z&|!3P?Xq>>_R6fGLn_zIP@6DfynT_`C6g_advuwn%)l?(ZjsRvb^XR!2p$cRfQ?sM zjc4!c>rd*B9zv4t299ISIh1R~a!JH8K{MzTp_NZRw;tD5*BUS03}=Uayn9{W+{~T{ z4bUjfqVs0p_^cCxSsI`i@c|JbF&NR{W)1)bBTb^)AnjcpFdp!TQ}-t zx^ofllM!QLTbEYSr?V~9HB0nT|BBpii~y}f%WPXb*^)(wn4d?Tc~4ZD<>^w{qRwCx zF|fGIbwU_jWQ6ea<*>H{EsLP(^#N4SmrUQ9wO)QzuJ&E1UKri>KaWk-#-^&H)79xh zwR5Jb&ol*_w`kl*i9N;HuxEl0I5BX9oFrr^FrCQLNyxm-Jm0Zh>XyNsBE%Pi3H?lT zk|_eZ1c>IcZUCOJJpvr4Pc3#(81@t5`KE6diRr_|oeO;G0fiosaah0s0c|Z1_sAOo zc!0ii_f~Fw%N%^JZA@%;*D|A({MW9-FHWrOdwFUj*mP@Mw<2$t*71LBT+f9~8ezvFL`=#% g3h+yoBsP)A38$8~8|TD5kN`0p0*7wqI3DF6Tf literal 0 HcmV?d00001 diff --git a/model/__pycache__/classes.cpython-310.pyc b/model/__pycache__/classes.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b1e0e17b4512e1b044f7419db4ece097602b2701 GIT binary patch literal 1133 zcmYjQ&ubJh6i#MlXQtcTZLPHmBGi*E*wU^bC@Q5}6g~9PiZCpMxJm2S%*>k1C|f*; zdhlTVaS{LDTKDALlP|MfyP3(C_vP37-b<2{%S8m`$Kg-c9@+@~ zG-R+iK<1#T1ptN^7AV32j;z3n?7)tkz`>^95{1YO+}yT>7x{sY(RsuiR=9^)f%|vu zV2HU(sOoiLCscK`TTet5#oD_Nr+k%5?JlJ24s7o50<++=H|)R#<}v3kW(DRl4|;s)83N|#teDR>yvT+( zoM4!jSc#QcWy1=}V2|wB6*kKD+(W?#8-rN(>Y=)zN`>)C61Yub?B%MZhg&j?sBDup z-qvNBrAZh!C69QVc55K>Ty_v}`_cRDuWvh#H@4q?{Im6-^XAUvMS6qQj?c`(9)6q& zL9Ym|y=D@brCA9Ub+&H*{_y?R(=XkppCyKl><~cr_P5{PpLFiO>^^&aYI5e+`&XUK z&B@tbN7dFI<0~{1sWe;2GJy7$Ic;8rw0lh>n=8n0QOh zB6z$msDQ(d+5_5hS-GOh?)Y>fqa7uTLQL=irPud0;6ImRL}>qXFDYluJyAx|0n2zF GxBmeq#5&mk literal 0 HcmV?d00001 diff --git a/model/__pycache__/classes.cpython-313.pyc b/model/__pycache__/classes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c972f687d0b86883f548e3b973f6952714be1d1 GIT binary patch literal 1330 zcmZ8hO>7%Q6rT02ch`=8;*bbP0+^~OwS+={l0%T&q9!1^5jikTTacyEW<7D2?ylY0 zU8Lk>aX>wQ2#2bvxCDVnI8+!3p$Mu9PF!tqDI;+}$W48M%2Zr=vumfQbNKw;`{uoQ zf9rlWn?`WT&wu;gri{>^LK&XqKrUu;Ts!Xtv_=_MalmY^#+4;A zpe2cC7oVqgb_<%iOD#@luGgls;H0} zB)1?lEz)(7+`JTfVijvx2QC$HY0y)VJ~HqXmJys;P*|pt1ndr~8zBsa`dq_7ScR9ZK_|fvA+b1O;QcZH zERM8_-UjK(lY_+pMx^+}0Y*NRJPJFqvN>Kp?BcN~4`NqOZ{}Vu#)@aU^z_W8JX1O& zS~9%B(~oJfGd^|JeUW34kGl0LbzX^h>d!VdxK0S84MqqbTO3Yoc4(UbC;vr&Cc0O6 zX{WmDe(vs&*2+(FznuN~Z2KH;Suyd}?bU%n%AK0>@quFfCwY2x4pf8o-C>-uQI$ ztv~V;TeZEp{h*UyYQFhf;ptoIHv25Sx7seh(99Pmm*a_?VY5JprHJPHw=7B0zbcaEe?@b@qnR$6`bW)5 Om!&P`5fZ;qi2nd8lyolu literal 0 HcmV?d00001 diff --git a/model/__pycache__/employment.cpython-310.pyc b/model/__pycache__/employment.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..82b26636452cfb15f640193c908e551011931f8b GIT binary patch literal 2359 zcma)7-ES0C6rYdXnc3;?cKcmLVDSr9ODQ!mk+6h-))+~7KteLvWHRl&En8-GmODGa z-T2Ui4>4E^kw8F2qefC91` z0`;v@h0anIC`T%+SegxDuUi!w#3yXmss#Fk%V=@7^fs;L#30G2ZB>eH$va*wgC09z zd2|{Rc0LLy`{DKKAgEx7L@*>G8ZwR1I8}&rS|l=Vbs=V`VAF7$L|c%J1lJNoCtaZF zpmmWXM5joaTasW&k#3UVTAKTNz^{;AKEIn=`naW^TQXpY!wiKCkU?0lhxU>oGEAOY zkc>VUKTS4Kbyg(LzdfuWZ6Dn0@4 zD16N#dFNpg4jkV8X6^FlYs*){#h;d>psUP^j>W3x30e)*Y1hGY6aMoUdFSp*{d%}O zzk2!p>eU|`SHFL-d_GdwzMBs}I`?4t1IVD2eA~9BY|7FwY0@79>CVhhmR+e|2p2E4 zrmlW^YxTmf;rTn+4fnn5(RNxn;*lx#yGk+{nx#=czf;M z%_#H3&ERj$|Ga+xQh5H0#S z8xM|Ecn`w+=N1q|d{3AYK7k&EuLxfmz7l+6pQ!+=_+)KpZ+lVP&5j5@`C4Wq3jirw zrK(x79Gc%=)3=XgJiDSK_1(V8L*Tc;zd*u|6TBUm|hcNV!xCY(_$~aQm>d1 zlaeIjUrbc~N-8}5$wc`sB>5ji83)=_fHq}A16oTUX_7>XD0QGcpi7bhOGK1uAU@QP zDC0n(DiCK6kYG2^Wgie-hV%m>2N*W0k?AZeZ<@gl3-a9t%`rSULrU+jzRzq|nyG{Ffb3hpE>){WWvKm>x1<7U1M4q`p%Z?RD1jaY~c zqH74m6I+sDOJdUvcQoB$JYK8ox<(?t1P0I7QX2|_D$fw!Ihlhm(D>3273k3jL)de; zg*HMsIIt?a(7ppiZMfYQeBoAKOy+u64#QqT@iK}L6puqb5-Nv3dj&_2!8qe9J`u2Z z$GBGyYleW8q@<|)ot7ieB49ZVCUdDk_fFcDJ$;-y)sD^ZHL(MuzEWWnq}!3BE_0$T r(uf6(41i(y1H+-uQ+Je!7Yu6^XnP|%*oQalVOwQmN!UzA?jQaK#lO4h literal 0 HcmV?d00001 diff --git a/model/__pycache__/employment.cpython-313.pyc b/model/__pycache__/employment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b37ae8587ef6a95a1bb5f23895fca372326eaa5 GIT binary patch literal 2996 zcmc&$U2q%K72eg)YQ5HvWm&dkCqxC4L?m$%h&vc!hNiJXzrK#iTOrLtrt}Gdo zr@nL_j_$qRx#ym9?>XPmn#UtB@Ev*J^Rpi{Fw8%2vhg{qgS9g-c%LB*VF#EfOWCN6 z+M;%9k2ZHynN4XH(z%Ka4Y7vfXiSw7*1?u^RVQozg2DUDFhg0wP#a;Xo!F>j*!dtc z%niH1Z+j1G=@JKV60X-lc@u3QF2a+BUON@4bAdDxH_RK&xf}L+YJ0uJXJQ@`^O7dw zH|M^ZZgWl7MFOVXrW)3=2@9H--^4hu<07r34ZLa|4#0`+WE<(|wb2%H)=9Px^9NY6 z1H1~72RazqN_Nt=ln@RX-u_HZ&W>dyC6BjLX@n>ZpHy2EB^rQDgW5rm*^v>6hHlNjS)RC9+q&|H zt1EAQTfQ(C+JLWz28T|Zu*|rU(nw5`^QM(b5{zh&(F|;|{Lq?VR(0w1Ps$VX<;k~K zugp=*63q-ul{_`;Z2Pns4w7qRv#?X*B7b-re+lh*B|a|Hmw-$5vz>w71cyF z=fNw6!CJwpU3&=xumC&6oP^QV&kNNL2wvuv4-h*bjxEbD1;p`~tBVOc2glov)E&hL z_E;&`H{P@_1WA>PDPy7%%Se5@$A#VdLTXHgoa_6KE&Np2Zg7^e;ohtXtESQ{Fn}t_ zBPvD}G6I=wQj!lywN&rUjTyWsQYlA8(P;ar8{HegPS~tI2BN?$`S%oCKI@$AEPC{w z{>9{t$jhb3%X4*-HQ2_?GU0YS2ZMlf1W^P>G#STk zQ5@A`auxB3;z*jRc{!~}N)}*`DCV&|ES%zFP`pjbqj(qv2f}En$0TNRk|O5Q8Oh+! zX5;3FBA?S;DDX|`ZrsD;&7omKNXRi&t&Y>G2$v+~r6k1~GF+gBL||}c<*9Tpt_DR> z%>&&g#8@7vIj%u!!hLn4_5COwLE+gD$T8{yi1W-+%fp3dZ+Zfg$ERP__dNc?j^4$* zK9neVl7+}}=Pzcu=7RduBc;xi?r2~31t*g;$Mi>&C7)DyW;w8Jde=c>u% z0N{r>OvFYeTze@#Cfzi~QK zI-Sv9Q*UgVQ8pk}BPisWWd9h%+}zri1|$o?sM98hdTD&GA+?JUdw qTVU8{*c;68ADP`{rt=;bWL0+Z#d{3?-aF`lKireabq0UUC;Ttwk!x!J literal 0 HcmV?d00001 diff --git a/model/__pycache__/scores.cpython-310.pyc b/model/__pycache__/scores.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f0eb455ec9f4b761b94282c558946dcfb3ec7d5 GIT binary patch literal 1128 zcmYjQ&ubJ(6t3zY)1A&tn`o51tYmN7iwQ{%f?`CBWLXw+i6S%uMLVz5G1c9Zs%~@! z4}o~lgQy_Fy2`q&EV$^w(+;cbA5!dXoryW^eNVpXjyOfv_v+Pq{oeOpRpqT#Kydu? z&D$H7Z9@Jv%H(iRIfqkSKmY<%kd($WwPGu^V>@+Xhnjs$xM?G9RBKy!sUQ24d{4ju z_YMJ<`@42L1&vJ-dHWcNM2>E(<)Tb8?fsAy{2G_qT^D&$=wLmUyn8MCk@q92o05y9 z=;oQa-tD38oKF<*m=Kerfyz0Y3J^GnDUjF#8r$6AE^mOfOTji|?mMw(_E2k}X2(8i zQ{aIQQ>X=%Er4lgLJNJTc@si71T*)oxCOIt_zsCf@UV;{+TH9RQSB-t2B9iS;90SV zl6B!?*+eFzH^1*a{$=m|lhK1eBTG;BWH(J@pKbBJ_Bwf*;t%C_==gDGcl=^F`s=~? z^{@ZEfBIp#^I`Z${rC9Uz0u33xL^BhRDtXD`0m5Kcdsz82mESMib76fEJ+d8%dZjl zciw&c`_bs`i~XNoo}OPCzxi!69L_J-8<8yyN9|{&5Xlw6tG~6kSr?)kRbdKy9$ra0 zTdbG&N<0d#IwryAVb&hbHJs`=0?^y!2KgDU1WJn1MromRo}0e88-wsn)wyr$POT1t z6Z8C5l493Z2mXoqD(&jPiq1whlBn9Ly#o(9g(=K6P+VMnMdTc+|8K6HZeLPds`lk> z2Kh~tx!gKm0`%J}ODiWAmQF3KEH5l0;n3B{Wg-qNEyfDd;98X?mc++K zq-$W-z+nUCu~h}?psN_t;ROe2?zZ+0*vezZ$5cH5M~&(unC7S%1fMu`&I)NrXK2%E zT9tJp3Ur{hMIt)adD@@gQ6)BHboj--n9H=hY9LHlRw2AnmQ OLK#9|SxwA8XMX}ZmOsb< literal 0 HcmV?d00001 diff --git a/model/__pycache__/scores.cpython-313.pyc b/model/__pycache__/scores.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ebdcc0a5bfd82f52d9f99e58fa241ff6083dfc1f GIT binary patch literal 1343 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 O$LPt>9g2K)AO8dHTS^XYDYUdS5@&6_t_wdxTV zf4}?NzF`sa4>I!uWaI`+^#K4UoW>-f0Zoj+NX)=YtiYluH{w!a2ljYv#!lh}E+rol zZt>D1!b`$^Xa;3&-zF{R3_OvRrR~iuE|OF`AEmk26H0XO! zUy3-)qbyZ-qaN_q%}|Lh`j{U!FgIYT0e~O@jT4NPH)5;x#856!?vPB`3pNO_6d z+&Q!Y7i4AbjwNM~n1MGZ@y3$M*h>X$d|m|`UsQRGFYx-I5!Cn_eDM(p7Wf4?T|+zD zBJAE3GH~+AV!LJNN>4^fDEsUH&Q=YJJd4t<6p2Xl!9thCp;D_|L>BQ|g%4*q`TW6X z_{*y=U%dL`88~p$B96nISZJr4C1`in2Ca$e)1%Sw@#xt%qpuE&CP+^|`(pIu$Jc*; z`*L`E`s?qPzzeKi9v@#CJ$Zie^hi=PeFxy|!SU%|UymODaCY?b)#lpCi=RfrVRL;V zXqnpKVmB<}Tvk9?VqUtuiFropZl>Mo2^CyRZ71v=uwK?H;I@S7Vu;zmbs6R+O!XcB zr=OC2@*T7dPzuxpY5=u>nm|k6m!X*U)}V21TF&>Uv7kU3gUWJqR;`UeeYrW?OT$EL zEDfql%gz04hfP^?sdXLt&6{aOT)Q)K?a!pBtlgOzHkuOg?sFpFf`tFQvw5|1M+vDq zpF}Co?gP!_!Oep6ey6?GzPz$_Wu?8ovJMD;yK_H`qm-$<;BZw;^;IsCPLgpEccuZ= zYOk+d#-!*;#u``}tnZ>>QS02I_PKFBsBceNXmzHx)vBWn=E|7%7z39@jBB57%Q6rT02*Xxac;{1p<#jP75f~(*FA&306qD7%NG@x$E$>WdBUd!A|%+kZ_=L@ zi3mEc2$O-dM5F{dgnE(U??H-~k%k~PTO^ndB&7yZlL8gbScY3LY2ZWC!Ff!mJYf;6 z=gmpHKt;tNx|vUMG(@nWIl5)q7xiMp$9y>|oYridWr12N&{Xzrg0hS)}1xyBvCv7jq+V-(f^Og`q z3M&za6wX8-N>piAV#7WXRXTbQX;(T)EZ>#vrh#d!WiMjV2tooh;CQ>~q>r{0iC)mi zf_e#h5!PJC(#g(6*SHE%-aJuu^m-Q<*{<{0k)5_M)4?hh+6wsb-Z4K0~ z+}d7VC9Lai97LnMwR7)UeQB++dUGW4*7oi1>Xl03ZSNq-(?AZ-X|Carb}%L}5Hxs( z<*A!<7L}S6+AzV$td_l`7OkQS)5UhSIRo%Oh_!=JX0HeWsK;GE=RrHTB0xPrSaJsz zAUq(#fn(SLA^;-36!8xRUqeZ8s?;^soXi`|jG@irrMCV=tLNjT_Wnc*GqnO9?<ukfheQjBfV?yuAjPbs@glaIa+b zzcWymQ3u=PDBE?bs_no_kX1DY9*0>MXalt$XZ{H$7(q!}OE41IHi*yBqpo=Q+>_WV zD}jya2VJ$;L^=I9+I=;@TB@EHeRyo_fm{7FSBv8E*~guSS6^T2`1ZtxTQO^$=gJ>} z;kCJy^VjD$hN>f(T66}C_9MkMFI3;p)?&GG`bnf`WpuMti<~Zh@LUl5vX>7_WhyCC z*}iOO2K?xSg%^6U-v-tJ8u<@TS)}0AF6A`nA-r;g72=ik|5B+m<_+W|c@s<+W$a@h gV2~X5kBGR@hiK$C)K^DG{uZO0yp(&6;Ip6Gzn9yo`~Uy| literal 0 HcmV?d00001 diff --git a/model/__pycache__/user.cpython-310.pyc b/model/__pycache__/user.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a569710670757439f7592672bf6b1944045e8bed GIT binary patch literal 1354 zcmYjR-D?|15a0XI=_E^vV=G^!4a$>q5IeRW3N=Xxc5qt)rX>l1MX+478~dF5u=nmT z5`Aci2@cfsGXyu32HK|dA(WQVn3(ptf5XzZa<=s^^rBO{(Pn~i9eVs82u`@>lYmg7;aK;zK&N_OP$Mv@85C$Cu&9;K8GbRa zsjZQB2{(CRi|~T59~!j8ty{z?_P`r)G-Y!)5s4QFr=YCG$WMb%72gjNu_C0hZY9zS zSCoANNVkEc%1e=@Jn ztH{lV8b{3H#T}E{IcDcriI;&@%8{`gspLo*y@)Yh1x)8Pz)V^hU?*~HJjbet@sr?v zf=>dc2KEUtzIKw=`6=)`!B6uuTZB&Xvv3~glzAtHjLj6}ZAVvQ>(UEc*>9)-N%qxtw)g494;F6TSZX?sE^9C)$3gUvl)eSx=%+7_9{)DqaCzW`mlNT(SB1PB zCn<*ry0yVz1~l_{dJ0x#FJByf{at_E;Wt}gW}m(4P7IdL4D^tF@MJgJ+dgK$`VrVt z8*bnGc>n0HFS70Dz1<(?8?y&5zt8se8gqRBk}Yy^-%b7GIP|oFF>F7}_Fo@7{iFBv zcgSlJpggZOIvzG07ojggkI#eP+DBxKJcD`$t${W`>!8hVi*PrEQX~SJ`Rm3=Ng7LU@Ch^QzkO7W@F*s|Cz|MuH2|AJw7E;bTt>$d=?djQfrkiuqb0Fc@Y;C%}7qY?Ov$zu{B4`B>7k&#jnpy8C zn=vWYC1YnWJsre(ty{%SW`;A%P913&J!7iO7J{-8kNiBxkE`yed!Q1RgN8`sG2($Ct0lUd$j~kq9pFpDHm#WrWct z9fJagKRUzbK)!) xgU(0}dH8t%7;63wx%c2Xkv5pQgOQE%IB<7mU+Ev#HLRi-xfT6Qy`tBR{{c0$e=`68 literal 0 HcmV?d00001 diff --git a/model/__pycache__/user.cpython-313.pyc b/model/__pycache__/user.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..527a2a6366889519e349a0533688e236eb1d7c96 GIT binary patch literal 1695 zcmZ8hU2GFa5Z<%T_QmlJ4h{)M1Vf_+SCCNJAVLG8V1?EZ5%rNEfu-YOUy@7D=Vb3r zD5+GrQiW2fRg=<)1RfgH7QzF9R#kBuwap`swop}aZ>Vw-zg0@p$Eq`T&QG%svorI} z?9R;2?3&;2C9pc1z8n6RCFD;OHkPZ(tn~mhO?bjH-6TOMi6ZalW)cqSNI0o8!BRHi zqOOFSx&d?YY_})DQ7%dv$sxkKB7}FRxOS&~9<3Sl#JCCyF~;OFqAuxjR*Jby_xYS+ zWL4AiuBuCe5;fhtPGxn_^V`B()QUl^;Ctme}I`L zDMA^ZPzTRYC-0za%5{jO+z=)N+To-gOXe`pcEgL4C$sFM!@%2NL}bk{aCWT62Y-jwp z&2GLSLTH$8r1gV)V-b_>)g)TM=QUXa*vm18Svx}ItVqX%_oXq@FB*DIR?}3iUzPU2@QTf(H zx$x=XOXs@dUB_cF2dx8$B3?ma><6;?;EUByzCRTeGg(<}(}dSVPebr>9=4_u~Dq+EBS@h zU#H3wbCua|PDPI`Kl-{{C`4bk6_BhaBMpd#qHl$6_%McvyXA$Smv8@Ax%~s=)c`6h zuXMFtKqds64m*6U4+u;P(@T=T?OXwv96&In9SaaAATWhH7a;5#o@Nqr^^6BP@mUY! zgF@1$$9+el$g5&jI^8l}({dzAa|*J~me?6gscLC*8)hbp7BG6OCMg3N<`6ag*<40a z+R&zTWX$9Qfl4D(5KMnBsy0MSU26rBCoe(==GX_md%l?u=Tn8|-;Vxtv~b{AO)=T` zNAg-Jd9C*(oa_|@xYSUP_X)zNAu3fQC zv!qKIijPO}^-QOlyJ6PCOu&yL;J-whP+czwnl9>cS`c;klll!^(x4~@(JzVwyPIOO zSOlj|yAR|hc@d7}FTMj=hC-ud%eWmb~{Kci((A29lb+=xdGm0mAOSOIZ_)Gup$qTbri-%7>-`}wqFDB)Z ze>mT@6l$39+=~>CoPB=q?Pu}g)zMOj<`b5BKJ=uw7*Cf%8Bo+U&NR-SeBAl4bFryZ zcPZb!&N42Mu~We4dtzR@4qk0k5fvEE?AW$?Tcx&&Wt9~_Z?PY~Uy2Fa>HohZCf8{{ r@)>#zOt9HBwD`-(FwDA}Fik5Y{yT}6$-ckc0Y+pd-drcRTOs`iKyBFN literal 0 HcmV?d00001 diff --git a/model/advisor.py b/model/advisor.py new file mode 100644 index 0000000..f529edb --- /dev/null +++ b/model/advisor.py @@ -0,0 +1,16 @@ +# model/advisor.py +# 顾问表:学生表的外键关联表 +from sqlalchemy import Column, Integer, String + +from database import Base + + +class Advisor(Base): + __tablename__ = "advisor" + + advisor_id = Column(Integer, primary_key=True, autoincrement=True) + advisor_name = Column(String(50), nullable=False, comment="顾问姓名") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + def __repr__(self): + return f"" diff --git a/model/classes.py b/model/classes.py new file mode 100644 index 0000000..5a6f79b --- /dev/null +++ b/model/classes.py @@ -0,0 +1,22 @@ +# model/classes.py +# 班级表:对应建表语句中的 c_lass(补充 class_name 字段,便于展示与统计) +from sqlalchemy import Column, Integer, String, Date +from sqlalchemy.orm import relationship + +from database import Base + + +class Classinfo(Base): + __tablename__ = "c_lass" + + class_id = Column(Integer, primary_key=True, autoincrement=True) + class_name = Column(String(50), nullable=False, comment="班级名称,如 Java2301") + start_time = Column(Date, nullable=False, comment="开课时间") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + # 反向关联 + teachers = relationship("Teacher", back_populates="classes") + students = relationship("Student", back_populates="classes") + + def __repr__(self): + return f"" diff --git a/model/employment.py b/model/employment.py new file mode 100644 index 0000000..f4353e8 --- /dev/null +++ b/model/employment.py @@ -0,0 +1,51 @@ +# model/employment.py +# 就业表(两张): +# EmploymentBase 基础信息表:每个开放简历的学生 1 条数据(补充冗余字段 stu_name/class_name) +# EmploymentOffer offer 表:拿到 offer 后才插入数据,一个学生可有多条 +from sqlalchemy import Column, Integer, String, Date, Float, ForeignKey +from sqlalchemy.orm import relationship + +from database import Base + + +class EmploymentBase(Base): + __tablename__ = "employment_base" + + stu_id = Column(Integer, ForeignKey("student.stu_id"), primary_key=True, comment="学生编号") + employment_open_time = Column(Date, nullable=False, comment="就业开放时间(开放简历)") + job_time = Column(Date, default=None, comment="offer 下发时间(未拿到 offer 为 NULL)") + company_name = Column(String(100), default="未就业", comment="就业公司名称") + salary = Column(Float, default=0, comment="就业薪资") + # ---------- 冗余字段(需求 2.3 设计提示:优化查询与一致性,登记时从 student/class 同步写入) ---------- + stu_name = Column(String(10), nullable=False, comment="冗余:学生姓名") + class_name = Column(String(50), nullable=False, comment="冗余:学生班级名称") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + student = relationship("Student", back_populates="employment") + # 一个学生可有多条 offer;viewonly=True:offer 由 offer 表接口单独维护,不走本关系写入 + offers = relationship( + "EmploymentOffer", + primaryjoin="EmploymentBase.stu_id == foreign(EmploymentOffer.stu_id)", + foreign_keys="EmploymentOffer.stu_id", + viewonly=True, + order_by="EmploymentOffer.offer_id", + ) + + def __repr__(self): + return f"" + + +class EmploymentOffer(Base): + __tablename__ = "employment_offer" + + stu_id = Column( + Integer, ForeignKey("employment_base.stu_id"), primary_key=True, comment="学生编号" + ) + offer_id = Column(Integer, primary_key=True, comment="offer 序号(同一学生内自增)") + offer_time = Column(Date, nullable=False, comment="offer 下发时间") + company_name = Column(String(100), default="", comment="offer 公司名") + salary = Column(Float, default=0, comment="offer 薪资") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + def __repr__(self): + return f"" diff --git a/model/scores.py b/model/scores.py new file mode 100644 index 0000000..5fc463d --- /dev/null +++ b/model/scores.py @@ -0,0 +1,20 @@ +# model/scores.py +# 成绩表:复合主键 (stu_id, exam_id),补充 is_deleted 以统一逻辑删除风格 +from sqlalchemy import Column, Integer, Float, ForeignKey +from sqlalchemy.orm import relationship + +from database import Base + + +class Score(Base): + __tablename__ = "score" + + stu_id = Column(Integer, ForeignKey("student.stu_id"), primary_key=True, comment="学生编号") + exam_id = Column(Integer, primary_key=True, comment="考核序次:一个学生有多次考核") + score = Column(Float, default=None, comment="考核成绩") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + student = relationship("Student", back_populates="scores") + + def __repr__(self): + return f"" diff --git a/model/students.py b/model/students.py new file mode 100644 index 0000000..689d1df --- /dev/null +++ b/model/students.py @@ -0,0 +1,34 @@ +# model/students.py +# 学生表:在建表语句基础上补充 status 字段(需求 2.1 可选字段 + 就业模块状态联动依赖它) +from sqlalchemy import Column, Integer, String, Date, ForeignKey +from sqlalchemy.orm import relationship + +from database import Base + + +class Student(Base): + __tablename__ = "student" + + stu_id = Column(Integer, primary_key=True, comment="学号(支持按规则自动生成)") + stu_name = Column(String(10), nullable=False, comment="学生姓名") + native_place = Column(String(30), nullable=False, comment="籍贯") + graduate_school = Column(String(50), nullable=False, comment="毕业院校") + major = Column(String(20), nullable=False, comment="专业") + enroll_time = Column(Date, nullable=False, comment="入学时间") + graduate_time = Column(Date, nullable=False, comment="毕业时间") + education = Column(String(10), nullable=False, comment="学历:大专/本科/硕士等") + age = Column(Integer, nullable=False, comment="年龄") + gender = Column(String(10), nullable=False, comment="性别:男/女") + class_id = Column(Integer, ForeignKey("c_lass.class_id"), nullable=False, comment="所属班级") + advisor_id = Column(Integer, ForeignKey("advisor.advisor_id"), nullable=False, comment="顾问编号") + status = Column(String(10), default="在读", nullable=False, comment="状态:在读/进入就业/已就业") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + # 关联关系 + classes = relationship("Classinfo", back_populates="students") + advisor = relationship("Advisor") + scores = relationship("Score", back_populates="student") + employment = relationship("EmploymentBase", back_populates="student", uselist=False) + + def __repr__(self): + return f"" diff --git a/model/teachers.py b/model/teachers.py new file mode 100644 index 0000000..edfbd24 --- /dev/null +++ b/model/teachers.py @@ -0,0 +1,24 @@ +# model/teachers.py +# 老师表:沿用原 teacher 模块模型,统一命名与关系定义 +from sqlalchemy import Column, Integer, ForeignKey, String +from sqlalchemy.orm import relationship + +from database import Base + + +class Teacher(Base): + __tablename__ = "teacher" + + teacher_id = Column(Integer, primary_key=True, autoincrement=True) + class_id = Column(Integer, ForeignKey("c_lass.class_id"), nullable=False, comment="所带班级") + teacher_name = Column(String(50), nullable=False, comment="教师姓名") + job_name = Column(String(50), nullable=False, comment="职务:主讲/班主任/助教") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + classes = relationship("Classinfo", back_populates="teachers") + + def __repr__(self): + return ( + f"" + ) diff --git a/model/user.py b/model/user.py new file mode 100644 index 0000000..17157ca --- /dev/null +++ b/model/user.py @@ -0,0 +1,24 @@ +# model/user.py +# 用户表:用于 JWT 登录认证与 RBAC 角色控制(建表语句中缺失,此处补充) +from datetime import datetime + +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey + +from database import Base + + +class User(Base): + __tablename__ = "user" + + user_id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(50), nullable=False, unique=True, comment="登录名") + password_hash = Column(String(200), nullable=False, comment="密码哈希(PBKDF2)") + role = Column(String(20), nullable=False, comment="角色: admin/teacher/student") + # 关联业务身份:student 角色账号绑定学号,teacher 角色账号绑定教师工号(便于资源归属校验) + stu_id = Column(Integer, ForeignKey("student.stu_id"), nullable=True, comment="学生角色的学号") + teacher_id = Column(Integer, ForeignKey("teacher.teacher_id"), nullable=True, comment="教师角色的工号") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + create_time = Column(DateTime, default=datetime.now, comment="创建时间") + + def __repr__(self): + return f"" diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..b65078f --- /dev/null +++ b/nginx.conf @@ -0,0 +1,23 @@ +# nginx.conf:反向代理 FastAPI 后端 +server { + listen 80; + server_name _; + + client_max_body_size 20m; + + # 健康检查 + location = /health { + proxy_pass http://api:8000/; + access_log off; + } + + # API 与文档 + location / { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7108abf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +gunicorn>=21.2.0 +SQLAlchemy>=2.0.25,<2.1 +PyMySQL>=1.1.0 +cryptography>=42.0.0 +pydantic>=2.6.0 +PyJWT>=2.8.0 +python-multipart>=0.0.9 diff --git a/scheme/__init__.py b/scheme/__init__.py new file mode 100644 index 0000000..805e351 --- /dev/null +++ b/scheme/__init__.py @@ -0,0 +1,2 @@ +# scheme/__init__.py +# Schema 层包 diff --git a/scheme/__pycache__/__init__.cpython-310.pyc b/scheme/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d3974caaa6071b4039a22e0f684f4e6cc25170a GIT binary patch literal 179 zcmd1j<>g`k0$2HsS*k$#F^Gc<7=auIATDMB5-AM944RC7D;bJF!U*D5n6p((XmM&$ zaZGq-UP^vBkj^j4b}CItsf;l)Ff!9Mu+%j&)HMW>Kx7nCo|u!F7hhabnv$AV5?@?d tT#}j_Q=FWUnwuIEAD@|*SrQ+wS5SG2!zMRBr8Fni4rE_36OdqG003M@D>?uG literal 0 HcmV?d00001 diff --git a/scheme/__pycache__/classes.cpython-310.pyc b/scheme/__pycache__/classes.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4bef3203c960bb589c389fe1a86d6a23a95734a8 GIT binary patch literal 2130 zcmZ`)-)|d55We4cKHG^~0yHI*2zc>>qr@Pjq6mc~0)dE;Xd}eQ;&ga7>A~IE?4Bv% z0YwyvRw617MHM{If}mB9C`D9&5{o}%Uy0-7DepWnv*$P=NzcByotfRa*>As@YZi;H z0@tVKetZACsVM)Ta`-YpnSqEQqO~4AVtuNQZJC93rcx-7qU>w zsxU~9(y}IuH$t8Y=gn3cHj^L%OKvvcbfHOTB+OSr8j%JrIeb~5%)rerf}o1062;RN z6{-`JXhbK*n(i4C(jHZ)P0VTZSm3cS;Wfi^Xb$Xd-<~IhHO+Hr9&E*F00`0?63$!{ zaQ-qOwOtk7(tZ*|@A@yjjk~YS?{%(j{k68$x!t|`&CVA;cYpY9uX7DD*OsZs6UrMb zl-m(b1B3V>nb@sjkS7K~x?ipJ?%v<)eA&J8+4$?hM?v+u$*FZ+xN(^H5lxoUOE3;T z?vELsv2e3+Nf6WaI2gK})tz7e-oE+A?#%}fYWsGl+qpje4qTOmh06T=y4qjI(}1OZ z8pgC-5RUI-1m72~@5fEjj&NM?{SVtglzA8;%&q~t8 zQ^ro?Obx`k!bWk7;@`!&7we0hGG2c>Oi1%GJe%x;*)}07_3C8xh05glN_DC-1rq+% z`sE;k&GWQPXp;JTg{L&G^Ts6_)4JR_om$kL+Yw5nYIobR9k^b_!hx zS=nWe;CKYZqbMZqxJu&A&fw^A6a$10@XbbN(TlvoRUFs5%9uK)O4~6&bnwsM#OFaA z280?!h0UAr)Zl5sQ=dN60m^^H2ss2|3#aaYbHzK^2J)&I0B6rUSiGI57Lx{@3m9PPXa<%*?Po-BXb?aQQ0{d z)XL70fb20aXW&Yhk-l>3NrBlIcyR$o=D83Rf2TERKep!q%1^`O{XJuU8la4wNg_>j zK*12FBnsFH_i+_w3Z(`uFoY`LwX*&2^zn5vkrOk23cPstx#6fKm7#tt>%oWOUGN@c zjb}xnMEblikr&V@;nP4YStRJQ`!+|~a&kTtjhyF7n=5U3D5L`HxF|;DjxS`zKG$p~ zgPpf4@cyipwl+4lzrNqwytQ-hduY1dAHVHwKIm@T$-MjaYX=;hXy;(@_UdM~GAp%l zFl7aLeeVcyWX<9`*fS`eMKNfC z*+>>Ck^!B32!yK`l0qZu2zD>@)c_R-`wI4H*-eF&uC&5rnaLBz^AS#Kg#<|&HW+;C q`~3nNqAd4}1bDbI;7M5sXF6;7FJWQIqAX}7wWO7dk_Erqi1{BaKJ?oF literal 0 HcmV?d00001 diff --git a/scheme/__pycache__/classes.cpython-313.pyc b/scheme/__pycache__/classes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..926cc077b93cbdc921cfd97958e9d4b73919da1d GIT binary patch literal 2607 zcmah~-ER{|5Wn-C&u5=Mk`NLI)G^SswJO3lB^9a!Nl`FO__$K3s-@G_zBq^c5%$ig zc|lf@N>Gb15~yh(z^z&cmD*AjwJ2Cs|AbhPqU2usP#ohYD%q&7o!N7OO|5pNo7?@( z?(ELYZ)a;hpND~VG5FKDAt%H9iG#HUW7=xB;CGXe8JRu7L|DRxun{CRC!R7f@dl z>P`5=fhukiW-UIhnp)Ul@$cw{X^AJNOg)oUlYqF7s)ja}iEByAc}&-m@tA!sTe=#9 z+BxXlWTFgVWrjFpmT&`1lm~YlGAHx0bBH70RMdshiHfrDD*jRL0=+1^WzP^#+))qc zq$ZtL_6<3R6!ik`M_djNU&0aYu*Bh{Y8Xf2@hYZbyvl;iz67Fj?fc67-Qy!qi!+ZO zUVU6FlxMzpa{0UR*SDS)uYg02gl0)`&4>}5dTEIyU~08cEElhZ-iNkxY;5Pq$UU~P&M;MCDyE*&?lED%B`ONqP!!9fD5*?5o5a3X zQO;-8WJ42BlnI>}W>QaU>5QTf%m?wKz(8A^o;EF*q^8puQ^lMbFlhr-873hcaV8SY z+Ndb7zp2L*)ijCzVb;`eY7gjY@G(}W5u4Of+AivyW(-bE6F1;gU zxzh`WKPUyGxzU-)ym2M9$VXRtw#*LC9eKEYA(ANdOy(kaZjql{>D@XjFASb4^_~WV zU*b=b{xzfRdG;Qt|BGj`06c1Dnrod+4&)iI2`)_3)8L_pa!Mi+aLQ{*lv8J?@G?|8 zV5e~+;c)^WvR)82w`^JvoIqnqRn2K8-Ke6$#Q=uAq!)orC^n<$LqT(exuCfs$X&7( z#d=mq5Qk{O4oe7o+t&o{Z|ng9Q4b9i;`h)0erDlptQ3kvEXCOk*L{n8d}Z4!1@mFB zv~84v-Pip~{HSdW4YdTB2LM}b6vV%S9CgYL8cYFt9PPQF$77FH7$;O1;eZ28z&S

    1q?>TWSRIGjmO z=!q)c-gjxt#=}CpxLbhQKAzlAWkUf%c6q5a6!lzZ6-P0(oCa3NG`y?8*jv577OcY?vcIH zE+)IsC@n!Zey!ZNZL2Ikr6x7I{CxZKb6A+rP|?VguU)1rm!iZoF(~mb&=h%2a5u6a z#Q_xU2#Zf8L4oeB6@UJX=bT;X?8zH*QmJ!)?(LaJYtbq0R*E(hB`>qbR?-6OC=1@|++hp0#Y0Q$#mRR910 literal 0 HcmV?d00001 diff --git a/scheme/__pycache__/employment.cpython-310.pyc b/scheme/__pycache__/employment.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3bde7c12e735faa1b876143f732745da7a462b67 GIT binary patch literal 3522 zcmbVP-H#Jh6rX9Q?ex35tO$r$@v{qSRc$1 zKH^7k{XmGIK^_p+$d>^{T;>0mSGL{VCmswhKKVOmy4%8bd9d9%z31GIIrsd2_nf;< zI-St)`FO*nJvZZ;c7v4Oj{#)_kNYEp(1c#ms(MM+sU50>s^L<&YLpDt!<9%iT8dU< zrI@b0rU^qt4rn4Gqx-`p6M9s{pvPE`LpMbndYttH^n^%4PqLnbo)T&3Y1UKFGa?H; z%X%7mP7FXFU_B!S-_i=J7T~2;(0xPLo-BlX@#%N0KCjw;zkGma0Y9(E0rO+;Hy%d&)2#GLl>~Oe*jmemZZr9iv6XxMi$&_)} z;@hzqxRdlVuT<+5r&*OXZ%19$8auJ%+;@M@pTF3;cxm?P@z%^_`^eunXFdrUbLS^p zUmUtQ^Xbi*FL!zznggEY3siT1JI7LHw>))g({ z56LCREeD=kUb!lZg@kWfR?V(T%kmSJRdqz8LhY1gebBHgot~Iw31`f*R1{08G_4md z*E~OKS$3`Fcs8NoqCZFdA&6;B-Aio}|Gx9`bECUlsoc@G%QfNb#jm5@f2kovb9CF5 zZO?4p^6chqTQ_fo#NW2jy>_Ksv#>3JFk5cZ^<;I_9osFda#{9qt)Y=?A&8MKIgrqZ9Yf&9I-Pq~ zX2^#az7j)36u5JySgT1>#5&j`^@#*>2$G*na8I(+gQSN%f+Q$YY|nJ;S+*k?cI?EL z5co3S=ejv&SDo@+gLX4}{e1h#AH9^Ay?&~_??N#EdOd~&f$qUd-481VjDfgCfej!C zI6(na>qsyI%stL~jK9i|jY4-fgz_|q>WSJ&f??tnA0qWp5)2q8g{5rJgU`blLR(wH zXFaf4qp#N$^{kA~yXftY4W4XU9i=ZHGWxw#+g7Y3Le_tPBFH>F!gxsX92F@v;I797B0z*gGVG7ohaNAUbZ zgj)2FsyO2n{%Gj7)rVhpYUAaJoyBS6j_CT?aphDk+w)ZUy@n^9M!i!+!w8qiZz+E_;-gPw+-VLinp zK>89Htd)}ka**v=2EB8K1L&(noXg)}H=W&v{BUCfoDV8m>&!Q(E_7`=bZqvo?^@@M zwa;I{wW*Vr=!QdAk)9cIQ|IQcomoD^PmEP;*A1!<5v3SpqCA>hFd7e_HGkoBr<5K) z&^mZ_VgK3NDlo2_$&7inNnXK+IJQk^`p1|UT@JDNx?|F%jY7@EA z@4ON}o~O=X63n%smAS@Matn>zNMb7q<{xKXP8oT!E%DDcD!4qWr^raBovFpI>FW(K zpT@dWlZli=_y)AhA(McbG-6pOXyp++E_v0Dnlj3C34vB&5XA6TcY{T@`$dxY5f3*@ z-0I7?{<+0MY~@h!ZpfRy8+jTXFOo+TG1^E@S7a*|vc5_6m9D}ZIzQ?)>*d-+5FQGl zZ`PZ_u6gA#MgK+wR};E*@)d&X6sOZ5mh1v8-Omr-+#X!7Um&({R8t`w@=(smMRO*9 IlS7&R0B)^gYXATM literal 0 HcmV?d00001 diff --git a/scheme/__pycache__/employment.cpython-313.pyc b/scheme/__pycache__/employment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac419a9c41f2bcaf5efa2ce1fa0c7d1917f61af4 GIT binary patch literal 4431 zcmb`KU2GHC6@X{r@&9iU69Nl~ae&2*$ga9Y-KZ@BvY|A8s5`6*lGTy1C&pXH&fXbI zO;xQzSy4X}0vS>XWTVQ8F)mAa;@`D#fU;J?G3Mo`3~? z=tw@kbIzT6@7(j9bI+`Ky)F)}ciX;v`>~Vb9^uXUWi=XW|AfX>PUJ+spX=ipAHsG+ ze?y;zS^5Mf(7mPK+Gk_7K0C9AxK=LAiNbbHv_@?QEc!UiL0TJV?Ix|0v<}cZO7$!um@cI@} z5UrxE+rn&7d(#UVPo8<;cd0=*0MqMNyso`_fT^^T0DvtuK2Mm;_%XI9ZY zxf39kZ~S%T{&acz%cYr{<=OGdmA^fny{I>q?~j*1y6|}R(&O2i<$IqkU%9pX!8euh zvnyw>ls}lNeDYQ0+Vs-QgGb{ZtvvjseEw5?kVoSaU8_8d{KNyocy9JudE(o<4O&A| z*4znMiL(^#ie^*PoRmsDX~MpyMO3bTvwHojyFBxQK?}+otCzB{d`V4>$alG+h(~is zQYJPcOOoc2q>*eQm&Ue7lHSS1(uTz^Ny90osOeNj&Sb%Cm!w2CE=kM|qq6``VM%3F z4LZg$nXDQ^k11epMmvJ0ZIL7e^>Hbtsw{Onr^?tN2(~r&!5WqL8F@s0rMA?r(J=<_ zb0l!Xl}-@vbBn?+7n`;gER+1yyQQXs<3}&{%n1hbct@rZ!XAhZo}>|*}b9< zvYZF3Uc>4)Sp#JCnXENdEs!lm$5JkqR;Vm_B64_8c|321)capIuHVeugkuNf)Jsx zF{iLsQ4lONVm&tYV;2(cQ#p?9%;_vDG<5Bp2)1LMaop2@41GIPSfV&|*V!D7!ZN}VS$ zd+nS2!HuqY;iS$A&RU<9-+<-$tbnJ@oY+jBwu^Ayi7p_?Op4po19BPAV0o4mF~EFD zOQcbAQ|2;22*GM79q1rbZsjX~yshV~p_&i%$fUGGL|}?9uM_bU4(t^$nm`~|`(Ka< zI`A2CHF=a=P3|Yp*PLF@%iqUmQH0>941ho{@4&-FEp`uMflRCQ4`cK~xOT+Ac7m^W#SxHT_1@l9D7g;QHzrM;H@FZ8&I!SV@ZK6Gdl%ZmwagIykHA~os~iY@!8p{WjYcQqwe}AX zn>Tt7XX3=RUgkpZBOL}8QC)8rtyJWqUOzTm&~g$vE-GAH22GR-kP<9r3>_ULp!0#w zZ|J=EERG(9!$b7L=#9~)XtQArpq2P*gsQ)3gTJ8I%(f(5Q2sR4j+6}uUxSC}Nbkzw z(X3ZAH-Vw3)h#vyHUP(y>kC+D6oyQ0c&9_SxrwYPuyF6aOc@4G*>(wQxv`W zz??s%p8@1`^mVQs#L5?+8l}|D_sZv|R?kgss=(lj#GjCdW4W{{4aeeY7HSo$w`~+R zbV$G``t`p?$+l-VV?9)mJR?-tkD&c@`9fs=6b#fjh&^_qpj%3vEiWM7j@^%-pj_F5 zEs6#@Akm?<9Ki78)PyvAq_JMKU>YO-^$F{R$RX^40^O_p7K9NZR3{ZX`%6JQ72|j! zw!Bp6DefOEwP4D%K)Quko)g*@+B<6JX22GWONIL)fU=Mj9qrc`-E(bHbU z`2w%d#vX#F>t#OHj|LPtzJe^np1OVK`WD>&Vjx^{cgzbNItbWnDB{-~ShwLfp#E)U zQ^!VAnWXMOd?n2>I+loK;A@RxJ*L;!PHcrxP$@(wC8fMWsH0VEE~hoekM$4AgX~S{ zf&rkULHygo^E?!K{PurwyOy~Gs`Pw{!WI|X+F sf0WNhsvQ1T0}j4D|FbFwf7KQ@-(J{O<>0Tnqk-RDIQ4{sKYH)~0)v`ZrvLx| literal 0 HcmV?d00001 diff --git a/scheme/__pycache__/scores.cpython-310.pyc b/scheme/__pycache__/scores.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1f7c94201a1c75349f647b5615f62eb6891424f GIT binary patch literal 2519 zcmb7F-ES0C6rZo%nVs#@MJoa#uHRWr%d#;fnub&$_@YInm1NRnTzW4!y1P^F%!u{D zG*WS!P}+n7p@Hxrq9uazqRmp`|8Za4nPs1N^2I05xwGBYc71T$IWywg+3L-2St5JVz!S*pmETv04VMqMeZ70uEr2`f=aT1i=Y zM5`&Jp`-@W9Atm2s zZ7UC(e>r9J{SFbBb>VvU6}!6boIvSl3I9cHk_|DmLIeh?mb?a8EDDi_OBlFE!;F$ zE{1czFDYC(NqL%3Z=5+IO+4xOf$b16cl|b8r(c#TFv8GPi+~8Wg6M2n=-*o?z+Ou# zOM{&&Wy)|vp71cX;e47Kwp}e%sBLr8wks|P%J|)5+n)!ea(hI#ZQ_pGHcP--mO%%q zQ}ubTZI`N5*Dql)Js9uB@h8OwC(3TgUy|4}XhQM#$icV9Mm@^Bv13k^xToOlvd<0# zgiMYV3&nl?g*W?)d;9l-g#Y5$DX4$dhR_ME`nEUe`Lr_TjekNb6kc$AZ(w4Q>EJpA zkB4nxN}9Y4{&va2{@=iP1tzZnhpI&Gzy_Lw0&oyEL>?su6P>2*_ir7vRJ5Y3T6o2hP+p{2W7Y`fSlprFsY>Eio z4Z-8>2eB5>|7``5f=JDFq=#sk`ZPEK9>d!8*5zB#+3&)8i*1xHPDj_~0pp#pTUVx{ z``_f=7%1kN3qR%FKXP;=w?`Cc4>UD+%C){f7hPXL%rgU=GOQQFdI>DCE%=4d8P2CR zrt4`O>%fj}M^)rZK&@NicEc3RxrbZDfY>QBv~EgXhbupZ(Y54JWDyzoL9hwD6QHZ` zP9m?6Cn+6VNU+4!+Ye z!vRJUp1IU`csaa(p?RwgG}9)SAJ2#LvrBT^y9TlWcIM&WMOe|S-)z?ZXx*HCboVx_ zh|7{2%o!5N)eTK>QE|4y}IG_pd__9CWKE zoRcHNd2K_ffVP=iu}i+soa2E{y>77P$$0$>xFM$BkPMl<1Rwd#27l{nEjmSj;CYzI zR1jSCe8$iX*N(exS@`I-BYO_!U}impnF-tB-*&lAenJsrn-YTVhj0OUUroqpj0Qvn zkb|ha0tBH#iZs4eqzG@e)!Ooe53~q3nggWeb)kVZAS1i& z(6;H+9V{1NbnVnJ5zqvI(*l0un{f@lfaZ1-I?swI;J#Z8A~*!D*57;K`Ps0(03d5! zo(`99$C!)e<^@)3Z5}8B4}^2Sw5DdFnI!-+w0+El0gVGiHy4z}fb8?SkDBQ69bU{zxx5V&|9RV*BP8AUfN@zDOG)EoMCEX4#Facn?;bZAeFduSQ6o@+t%C|j4#&x*lVKCvPz$l)v~&H I(tU}40Nw-{V1X6REl&4Y)Ncl3Ul}fE3wUtS2R4RbfUM3Z& z)B#dwnbaP1d1~^wwa1`x-618W@;!sGP&DEXdyJ}epI?y=MFVnJH60Ae;ehJu$CvcA zKO749V^Pwl|7{&D#gCN}Fc@ci3}Hov7(|vBw}StK^O?|NkrPd#d6$uxeHI@tT3&(K zB@ItCR;sa6h(+C|4Mj@Wzxta74GbP;>7##5{EVqcWVdjo;I6~yrm0klWs+V?+a zld0UpsVB3O%hQSF>D$`fo4K*8TIwu(K28j0)6?3_X!g!EZS;2b$_NOvKVDm1lGJXT z(^ARh>4*8TdrxLZTJkKIeQHPB)J$&nqBi>bltDEFWmO2sN*@W)*r{eE7MDT+I=6fq z)gpiE9{>$8fR%gzAYKbl+Ol=q8?CLd*OZYsL9QCYGI8O+q@aUSjMw8(c}a@+2V_Z7 zZIUz)4aCDZwoB4iaeug|aY<5th$yjeC?ZFqpteX-AlfHM1c=L!8nkH)MPe#U^hYAm zm>-j_fPNk7H_$!%!%=@seNK`TRQE~#Sd4^@$73?itA{Bo@MF{(rSD65K;BOOqS7)r zL@)^yaG>l3IKwP)$ClRBPZ-net&^E`9f^a<{qtPMQeDGDUAp$ZGgG%G@m^A#=k^pu zX=U#1u1sBb;@xE10@qDmT(w!9ayvl1mU2E0=w>Jpjw2=?P);YD$P+$j_1IK_Qs>AZ z&{@u7Tv}*yNF5C0>jAPC@Bb@51Qj$&G!9;ml{BE_d4!D!FCfrZV=PG%LL-730fVP| z)QqE-Rsr`{&=SN;j|_&vo2l#3!_cEI!ub_pz>V-(@dB(3!~d;3(MD`R!DBCm=ztuS zAw=kTyazx_rt_C?WY3<_?oAd8V)9h>Y6{A!6tDc1ME1dV?wu_=+_|w^?!!G|uUqHI zc4#VK&Yl11d^SCc}%ZjYV>TwRfb(h+bHG;5RKgXSk1a19+9>BtN+j*cwARA`E# zWtV~QKC91$#z4!Lv{unZwSvzMaslPErQ4yUJKC_Hz(X9OlQ@GWk4tq>UVkJjgHYFU zp7N1~v{c~UI107XmzE!0)*g)KZp=WREOy9WE@-K9Q>@;6cx-3bSPsUwz>3_=_1w%K z`Rk`1-@OSd^klntyW3mc%hNONUJ{p8TS$?<@smg>60C?3worUlkmQvyLYj(3VhVB| zle)g*-?!|KM*2g+JQhoDvEt0tm;mP!i4K%Cs%qt?nssxe70jVY#R71eS=`w8gGmx!$GvO%q4vT90Jvk0v@(+&p*m8ONHM z3Jiem0a~qyyb%QWtf1GGk>9FwQfDW0$RhVBM@0_$q{(AeYiUOCicsRz%V0@0#o+LO z`#~rs4=BalRPToWlqLQb>UXvDkhf5afc6!nD?~+b4tFjJu9PDqG%j$Bx|2Am!pTwz z?gPzQC-pw)GmCohVae$?k@Av*62#Ji<3Ru-TGblrHry;qUSqR}9{?!7VQ3f5X)|L` z4f)HbwCS6Aab!nRw5XEBHn;=Ypf>tzJ~5IVnSzRgu1C)hFD)y*j+P&tU!FPN-l`=} zXD|Jhn>)Y`B`EEPlwPzCQi68CVoFf56Gv`@HH6%aG6Zx|!6O&j zqIXefnCBXnHaDkt&b57(*?bI+>Q7RABcCsD$Mj6#8oZmBV?%>b^Mq!Y76U&x6!1sj z`am!_dY8s}(=Q2llF>e_(}AX*{)x&zrt-V>i%ti@+XQwIvI#=71vv}LVU0WOD>(ADsui;oL33H=i9k6>Jgg&~Kz z9EBWVQOHp)#~{a89ppML*FmmlamaBl*F#>$mP207 zKg}8;H*$Fe8Zsuz9?16>byo!%`$b$Wz zHop>3g3Q0c#rp!hPf|iGxXOiosoEdn{b417zVHTE4eN}W;q6%!%i3kAim@CovHH*1 zpAA-qE=~0uo4na;^?jN&o~Qny^Y-1Zt<(LF#!gx{4$7HMWnHI|XNtB?&!`!#dtJMl z(e-pblT&WH%?MNUb}idg#7<3r2W`%%$29iv*~y28?2r4)qi z{?+p%T&=h^Y&3mp_FcvFWb>v}=z`IT1rJ^bIL5iA;6OY@+)?b24!Z)zU@aqBC4_vnhI@BKqI&kFD3 zUeMm%VldXdw`G0Hi%siaZfa?6YDSCyEqmY3sM&m4FB(kA7t?yTUQ}{>_4fTrPT8x| zYxQEbT~Bs(YZ9iXh3R!@!Y-e?!R>MT+{?df^m5$-=ka6QCVJp?H2=+cn4ddJfIA8t zh61b~A*qCvFpI2macvYX6NAgtnGxYKJG6q)WxRp8r+P}056@Iez1HbI;WLlME?NV_ z*4HPk>*r+ODoQ4n49_{c{A)JZ&ST*XE z_Y4KTM_Zfv{6b~)W;O7KAK3Q~md8I%8b61sa_O#p^{9Po(7JNs(O91~{;_qe-#$BR zpZrL!dM@nBF*1YI4%3fFQ&jDTc-~k?lgy$N5u%uCYVaHD{=uozg~_|u$PeVni7WQ0 z>ol8;XG}l$n^YoJ8;74^+$igX4J7SF-G9{5>vdj$6 zBpZ!3WcqR$RWSn{3f)tQ;5=iAxTOptEcP`0a!7ktj6M&=dRz|4N^bEl>efiNz*3G+ zyaVkMEJYS0OCeXY01F~>OWb;tp{WlU8+{;Mj6DQThsLeTw_rgSv@$wTE?qB=9Ctj;3?N?9u&6|L zN`@LacT&^>0|%|+S22x!bp-Qa0^yQj<%3Kv-Ci*AMS}v&zT8tDKZREyqFXo0&btuC zW{9X(Z!c(yL6K^m?xmUfPFG5|?F*+Wr6cm6KIGG7+4C|V_qRV|QS<olS43&bmhKa~Db4)ag zp+e9RL(!NJ?p1bTe(-z5jK5Xr+RYCD)BZ|zF`4iMdGeDxlVA7B7$K+Td1s0fOrfJg z(d6>T32WdCO8BYsUkL}5-YM)$7qdCVjJ6kYU7370rzY?Me^ZUJY#zOHT6h(U;YGSu z)10js@)oWIEFif-`lPLG(l%(9YEt2KibT}UBu@0fs!1h=I-B=}c|stPr=lTdE`Kf8 zr53t#I2>#boaTqcSSz4OQicXk^EvK`qzLhD>gf#k;Ms9{cP=fg{dmi%rB9t zCoWlsuM5LZUHArPb5Sz^di(ORxlmL~^C%&%uqyG>Nyd8cueq4>vdfHdjGd9h+Y1+j z7nMtgMbS2Y9w#Re7cL{>n*;jy$sF79a%~4SuTxVqieH0vKdw2MgEBdQCtYpezYi@| zW0>NWPRmmDutnfg-^>B4em7MxZx>XAteP!$bIe+jFHvzOlI)lNMAD#A>B?!Uc`x@^ z*HG#nu^!xAlrT|9Wj+x3gO@o;gFI)>V;g0NIL2WX#H3S{2};GGpra#|2roJFaQgf` z-mz$;{SJ!va4m>5i40EK+6LF4{Xaw+rFldekG?36KB}7iEQaW-L-f^~@fkARZshZd zY7q7U6_MPlBsw-=e{z1#Nu;S&hLa1m3;||Fc6+w9dHuTyl7*&n>gen^r2l|qW zn^MumKZiMUir_UR!OsdaWDF56apJm~PFv;X_4uREitNvJ?oW%PD9UTj7+40Pv-0Uf zat|&F|*Eau;)v_X6*Y^4uOx{RjL6Zgae W@sJdXd*c%Rh2mjc5?b6F!v6twri-Ki literal 0 HcmV?d00001 diff --git a/scheme/__pycache__/statistics.cpython-313.pyc b/scheme/__pycache__/statistics.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cfeb05d5e57a1bca2ce8cf5a895d6bc431cb8f48 GIT binary patch literal 7289 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 literal 0 HcmV?d00001 diff --git a/scheme/__pycache__/students.cpython-310.pyc b/scheme/__pycache__/students.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8cfebb4f92239e87b4852006f1f339f6a7624dc GIT binary patch literal 4239 zcma)9>vI#=72k(m*29ut!Wd(O^B@WVgZoM-O&lO;J5$`5QpiN(&W@^kW0Xo-_wFtf zhIXKa#7=Rc1wz0f4QZ(3q&(cZ!GMAPW53obS)cgCmwxIwXV+^Rn@l9_*?WKYb?&+6 z{LYo0NJJ(0{Gn^=-J?lK`Y%?NegaVGgU2vIFp0?}sVwJY8OusZDXTfP9LNQzu9kx3 zP%cys=fdSkE+R`mmRNuVPf9GvL&IvW0qP+ZhI*LlQK&~)1JoO+9)o(6#h@OedYr}g zN!dglR+h4|9bkErXB9i}v!Y?zk)IElMZJ2aj<;mLx9aWG3a5{jk5B$^%ScDr!Ea=0I~EUMukk3+3hPYa(o|hjkFvOk>x1SSMT0y4VKLs)e-8c=$%@+e&@AJxpcK(7xKJ=d*-9$DXI2 zY3jKNur;iESmy2Q1(rQ24a>uFZY^M&2z!xj_OK47z{$O2NB5Z);}!FD#;mu%+Rn(h zbMLNm{U5dSe|t20*)T1w$TD*?V{@~gIwO~=r$^@}PC{Y+{V$ylzJN~Ek+Y9xN1RjF zV2pETdhXu+&9yVvYj-D{Gq*Q8chA7<(d=k{9S%{lHsT7<53vz{(&clFf&(do+>SAB z6hx7H$PT%RY_%HQ8DL>|m7nghBZX4lFhI>Z2Fl~O-@}`2g~_)bz!YRmB>QXi(l6-PG{bR&z8M=x9r(7 zDZ7irctz-?l4cgmocghk_k?U6<)Gyws&3*{SX-D(yP|?r@9D_Ua$6w6fy!ZHd=vVvT>+10n zs2*WG^AE?-7`LXtLOssyf4L<*;=E}Y1qFsIe6Xy-vl||x1H>_z$%nvXCUDy^d0e`p z49kC%kIDO_Nwq(#SlAhKr8nqn;rg!18Ry}t`qg{Yk8WnX%{-bt?c6`-jE*_SXLn>K zWjlHxU$XccLg->rvDHG!u;oLOlGqND{O`c-9RvFfE{uV;=!l*47?5kOp14Z` zJu#v-$^rRk$38zmw?5HznW=E+L3qeiFF;`ssPZ%U_h6=D3Ye^!Os0%WOeMnwe8UC$ zv#P)mbgD~F$7%r7KOqZ4!abxV#^eK5Q2=Y29o4k5&a4uLa$M7XYvoHA+E5E? z8q*7!CeXG5{a0Y-wAEt8w3C{auT*q!K!_0oIwi2Zh1v~Mh%{E)QQ+~2P88^yVm*p3 z6wjhScN3dXyoe$U!j2I=mAQFPXW}~u{{RI#)e?br5V&QGL<2`hrGVTar{#8d@Y_n? zc92*ODT*AILqSDO$uzD2LzaDTpMCHcFMmV+WFg2riy1UbIR$eoGbgRgtz#37vQa|6S83&+rTnjHcSK7Mc78x?O|&uLq20Cya3!k zgtr?DZ&}sx>FS5K{3Pj|`O-zIA3bnp&tRrJb**;!Wc|t~nSQ;(GtTvk)f@kLG&{bK zsd^UK5_79_>csqwndMi@-w-p zXvGqSq3frZZCqzvizW0}itMEsNUXyq^kZsTnrz6YE%Y8Yuek2L0sCiAU~r0V6y);H zVTt@cizW2jWxTYE!f_Z-xMay9=}~!dC6$ZKI1{pm*otB?R47U?hA@PPB~va-u3vUY z-<~LumKKSU6ia)AnSL5!itnOQq~7vf*yypidHCq2jd z1viEuHE=g`w7`Z|J%rWr0p$`uDfC+ydd_q$l?tsWaCfFC&)d?p~c7H3Dva%0m}J2~`(3wk-84k51Fp(c zaEgH4;+YKp<}Nsf9W>$o0Do_baM3gRv*D+278fK{R^8K^(E2M>2csYN1PLCo{%pdI zU<$%}Kixj;ka=XNSQ!*FA)3pM3>{(lidifOybHR<#9Ihm5kw!B&}KB@YOC9ko$meV WRdgEzvrSw93R6;nR3_Ec+V+3iN`1-z literal 0 HcmV?d00001 diff --git a/scheme/__pycache__/students.cpython-313.pyc b/scheme/__pycache__/students.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5bd2d9f3d3aa3f1668e3c27613c3c5ca6cf8b214 GIT binary patch literal 5511 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 I+~_R+3!k*pvH$=8 literal 0 HcmV?d00001 diff --git a/scheme/__pycache__/teachers.cpython-310.pyc b/scheme/__pycache__/teachers.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd5b646d93948d41be41c334c5c15744579b9081 GIT binary patch literal 2545 zcma)8>u(!H5Wl@U+vl?#yJ;HQ@@RNtAZZ#Q1XYDXTR@c{NYEC^iga?l+vLjm!S1 z8yYpJc}AlqGiydJ27H7@fsZQQ0zO79;4Q_+fsa!gcw6x{O}wjRlS{Chmer;CrYn4D zyg$utd+`f6}ptK5JUI>t`BwOl(n-_yGCqSEBbW4uMN<;oIPk>k^o7zhGB3sg7 zYUM#7^_khlU;nr}_A#i1aea0c#=+-PV6C)Hl#e;MoU220!Sdk3^kVJK;$Ihovp@DO z&z0cheGAVYx2 z>*Payl#J>p32*lSk)!$*z~CskqJ3x9$R}hJ{cBcVg5~p9uPsfV?`m4u75sg6>Dui4 z*S~dzC)(XQ&+hOvUG+b|sLzZ|8C3*PxTqS3VQZlhgo{_Uf?xyPE8Aecp$SeO+_@N> zn+V3{_I6DXX&=pdRrVU^WjciPRdx`^U9|foznQD@bSM#59}R0A{ZBg@3|#f zJ_UW5zrVjq>BwMTZ{LgEy)Sq7?djeF6n=e!ryv+DXmZQS$IMQ~UVwqMw2IHjTd{E7M z2n5IB7&MN6;}}9|xFuhv9VcHZmHj;Cwtz*5R^G1U!si^l#ved}zLyCF+#>Ub%alKk z)99I1AfVOT9xq5MzVBno-6}=JF4D+WG50#dXDvIt-mo4yP$=TF)|@! zcHMWN4N%j@pFm|#B6$jl0*C?t0)#(}1QR?wZ5Brg4+sVRERszj@El0dXo2bw0{Aw< zkFs^)Z{2Z+LApBp%%lWsIrKX85$KIZZ$cl%n+*c3=VA;%01z9XdRaHP^%hE>>PHI(gsc?y;)R#H!e< z)Wd|6(eQi3nLtNcnnn0-7-a1=`c?&1#Z!g68&;~CpuX8?2mU-NgVualiflU|Secy;@c+;zxQUmV>xwYXG{I}7Jkc{|Ho8VuD3$;@WWMi8jM~8is zDI404u6Y5?LG0S#9w+dL;k= literal 0 HcmV?d00001 diff --git a/scheme/__pycache__/teachers.cpython-313.pyc b/scheme/__pycache__/teachers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bbbdd4acb1643626ae0cf786f578b6568dfb1056 GIT binary patch literal 3213 zcmbVOU2Gf25#IYFPyE$?9sfutxlVFzEje)<$8i$aj$6cvEL%CtZe7-g6M3c>P^7YV zOyQS;g^^fFVnl%1|-W4bbb`((8-+ z)j$c|sk|fhCoJ2M&y3lLbjnDog41xqu*}oxn3;5>UnItCSr!2rcrk( zF2X+9ul!eNrbvWPP9v1pI4bNT5fOI4Yl0?f(h-455l_U62N97q&lA|9-V1t}=^G+m z&@|L(e3~Cd`XYYN1h!~`T8L=^OcSnY8a4bkY0XDO8f5-jm@Wh}g%5FTtt1lGT6dB| z?nkYqjanORit}o_<2z>>(Gim#kHtz2U^Y;~k(oUJ_or?a=f_W-e6TpaGJkz#ai%za zbM4xl;-CNYU~yuOclfyJ_+zFOr3rSZj?Z>|>4{j+MhHDjWQ+5ES$+T0)vs@#I_b#K zq+wa0-3Zj&7jNGvPF@FZzR^TVPnxN?JwkD;6R3Ji8KY*2hpvrX04wtgYoC4jUH13j z7UY$M1(1vH%)x3M`StX$j^-52GYUZQ{X1({@2!1vqxjY*2OccGcVK1VqqSSJ_dmQ- z9G@xO{8$xe5X>AtX}Z(;5|$2>Fl{pyMK9H#2Lz;M`FVgT(hIa6s5;1zE!%laDV!9#ob?T zZFjxy-jtQE-d&l`&IuV@h-;L=fYl6{!ZqG#0mU}HH`<`T;t9^8c<)AW;(9T=_`EX5 zIll8oGGqRn(lnhD93h&t9PUlWYb2BD-CX@VTk^*1s`5-9**VO_tRpq8CXq|8)W z*C`$y4I_vEj*v*%Xk?^PY1_cMV1W^Yl2AM-N3d*4ci`tv1TXga8HYzrdn6sBk7G}p zu3I=dsvEXV6T=zXL^mo9c@kkC0)~nK3f4OS*T}N6ZzeYTM((BGn&onbf0b{)2u_89c=4Z7>*U3Pq%28||K=sBsVoo=gRZg&pYAeoVRZ!$ zPYZwL4*~wKf`(YwVj0A(yt&7DY43vN@!LRyH1 zp^Sx{V1;f&V^Fxw62@zQ`T;3&jbgoV1KW%erUwf#ZAa-x2pH$>6aE-zHWPk`8-_vu zd)g1s-8c;vR@#BkiO>a5O)Zxwj8mO;+j%sBaIEzV03?v7nm`jTP7fCL?Ek?ea>I@M zR2%6}prJoS_!+`}1ePW&4KPh;4?-^jW(CE(b+ZB!f6N_pxZ69$2C#}jEFS? z0JQJeJ9Bb&F!w@#zT-6Tc4X3;7%hmWm%E>s73Z7&7Rq;D0E$03eei?*1@QtizdM*a zY2-VGH_eCZNG;Zr8a~}c;M8@buDwpiK=UZ6Ba+6uH!Mg3lRO}aHA!NU7s%;h)aI!i z0dg~HOYV)%-XcDDi}(*!E)eR|0@NS(s6i)OiR^2pH3s#@EU^P)HK|s3*y#>r@U|$bvvzIM`dJo{Z2KWiW9n=I|@csK0E z=ooacdDOsm(`?}%qaI`DQ5?hA$wqHSy(4IXfG2Dn2DnC+8e6WOSqe2>J-yW2RuJ2m z$jWf;xuLvrezORle<*TN=Q;s!SBakL&R2jA{FQN4_2-N4!5WjQFTo0*G?B`2N%c4_ zm2<~;X^joYS4XnpI)X=O!Zt^(N(35y;An6)Y`5D5fu2Au0{XYG2EtkRLs$Ej{Nc%P z-mm;!RNQT%(SYiAJoc5bL@G|1Uv^u{V^?BE3hp_IWOs`M#?rmo(6Ku|o2$MIIPweb z75x%@1?-TS)-iy82^_~g^boFll{}j#&#ucMu4(eIbpp+Lw;$wF>jawh4w>tmOso@V Q*4ufmYg*kP(6A-{2P+*TtN;K2 literal 0 HcmV?d00001 diff --git a/scheme/__pycache__/users.cpython-310.pyc b/scheme/__pycache__/users.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1702da97373aaa656a5e7e24537a5b3aef498e59 GIT binary patch literal 2518 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 literal 0 HcmV?d00001 diff --git a/scheme/classes.py b/scheme/classes.py new file mode 100644 index 0000000..adee616 --- /dev/null +++ b/scheme/classes.py @@ -0,0 +1,42 @@ +# scheme/classes.py +# 班级模块的请求/响应模型 +from datetime import date +from typing import List, Optional + +from pydantic import BaseModel, Field + + +# -------------------- 请求体模型 ----------------------------- +class ClassAdd(BaseModel): + class_id: Optional[int] = Field(None, ge=1, description="班级ID(不传则自增)") + class_name: str = Field(..., min_length=1, max_length=50, description="班级名称,如 Java2301") + start_time: date = Field(..., description="开课时间,格式 YYYY-MM-DD") + + +class ClassUpdate(BaseModel): + class_name: Optional[str] = Field(None, min_length=1, max_length=50, description="班级名称") + start_time: Optional[date] = Field(None, description="开课时间") + + +# -------------------- 响应模型 ---------------------------- +class ClassResponse(BaseModel): + class_id: int + class_name: str + start_time: date + + class Config: + from_attributes = True + + +class ClassListResponse(BaseModel): + total: int + items: List[ClassResponse] + + +class ClassStudentCountResponse(BaseModel): + """班级人数统计(多维度班级统计)""" + class_id: int + class_name: str + total: int = Field(..., description="总人数") + male: int = Field(..., description="男生人数") + female: int = Field(..., description="女生人数") diff --git a/scheme/employment.py b/scheme/employment.py new file mode 100644 index 0000000..dffb757 --- /dev/null +++ b/scheme/employment.py @@ -0,0 +1,67 @@ +# scheme/employment.py +# 就业模块的请求/响应模型 +from datetime import date +from typing import List, Optional + +from pydantic import BaseModel, Field + + +# -------------------- 请求体模型 ----------------------------- +class EmploymentOpen(BaseModel): + """登记就业开放(开放简历):学生状态联动更新为『进入就业』""" + stu_id: int = Field(..., ge=1, description="学生编号") + employment_open_time: date = Field(..., description="就业开放时间") + + +class OfferAdd(BaseModel): + """登记 offer:插入 offer 记录 + 更新就业基础表 + 学生状态联动更新为『已就业』""" + stu_id: int = Field(..., ge=1, description="学生编号") + offer_time: date = Field(..., description="offer 下发时间") + company_name: str = Field(..., min_length=1, max_length=100, description="就业公司名称") + salary: float = Field(..., ge=0, description="就业薪资") + + +class EmploymentUpdate(BaseModel): + """修改就业基础信息""" + company_name: Optional[str] = Field(None, min_length=1, max_length=100, description="公司名称") + salary: Optional[float] = Field(None, ge=0, description="薪资") + job_time: Optional[date] = Field(None, description="offer 下发时间") + employment_open_time: Optional[date] = Field(None, description="就业开放时间") + + +class OfferUpdate(BaseModel): + """修改某条 offer""" + offer_time: Optional[date] = Field(None, description="offer 下发时间") + company_name: Optional[str] = Field(None, min_length=1, max_length=100, description="公司名") + salary: Optional[float] = Field(None, ge=0, description="薪资") + + +# -------------------- 响应模型 ---------------------------- +class OfferResponse(BaseModel): + stu_id: int + offer_id: int + offer_time: date + company_name: Optional[str] = None + salary: Optional[float] = None + + class Config: + from_attributes = True + + +class EmploymentResponse(BaseModel): + stu_id: int + stu_name: str = Field(..., description="学生姓名(冗余字段)") + class_name: str = Field(..., description="学生班级(冗余字段)") + employment_open_time: date + job_time: Optional[date] = None + company_name: Optional[str] = None + salary: Optional[float] = None + offers: List[OfferResponse] = Field(default_factory=list, description="该学生的全部 offer 记录") + + class Config: + from_attributes = True + + +class EmploymentListResponse(BaseModel): + total: int + items: List[EmploymentResponse] diff --git a/scheme/scores.py b/scheme/scores.py new file mode 100644 index 0000000..d339519 --- /dev/null +++ b/scheme/scores.py @@ -0,0 +1,46 @@ +# scheme/scores.py +# 成绩模块的请求/响应模型(含 60 分红线预警标记) +from typing import List, Optional + +from pydantic import BaseModel, Field, field_validator + + +# -------------------- 请求体模型 ----------------------------- +class ScoreAdd(BaseModel): + """录入成绩:一个学生同一考核序次只有一条成绩(复合主键)""" + stu_id: int = Field(..., ge=1, description="学生编号") + exam_id: int = Field(..., ge=1, description="考核序次") + score: float = Field(..., ge=0, le=100, description="成绩(0-100)") + + +class ScoreUpdate(BaseModel): + score: float = Field(..., ge=0, le=100, description="新成绩(0-100)") + + +class ScoreDelete(BaseModel): + """删除指定学生的某次成绩(需求 4.2 用 POST /score/delete 风格)""" + stu_id: int = Field(..., ge=1, description="学生编号") + exam_id: int = Field(..., ge=1, description="考核序次") + + +# -------------------- 响应模型 ---------------------------- +class ScoreResponse(BaseModel): + stu_id: int + exam_id: int + score: float + stu_name: Optional[str] = Field(None, description="冗余展示:学生姓名") + is_warning: bool = Field(False, description="红线预警:成绩 < 60 为 True") + + class Config: + from_attributes = True + + +class ScoreListResponse(BaseModel): + total: int + items: List[ScoreResponse] + + +class ScoreAddResponse(BaseModel): + """录入成绩响应:附带预警提示(需求 2.2 可选扩展)""" + score: ScoreResponse + warning: Optional[str] = Field(None, description="成绩低于60分时的预警提示") diff --git a/scheme/statistics.py b/scheme/statistics.py new file mode 100644 index 0000000..2c4006a --- /dev/null +++ b/scheme/statistics.py @@ -0,0 +1,130 @@ +# scheme/statistics.py +# 统计分析模块的请求/响应模型(含 2.7 通用高级筛选器) +from typing import Any, List, Literal, Union + +from pydantic import BaseModel, Field + + +# ============================================================ +# 2.7.1 通用高级筛选器:支持 AND/OR 逻辑组合与嵌套 sub_rules +# ============================================================ +class FilterRule(BaseModel): + """单条筛选规则""" + field: str = Field(..., description="字段名,如 age/gender/salary/class_name") + operator: Literal[">", "<", "=", "!=", ">=", "<=", "like", "in"] = Field( + ..., description="比较操作符" + ) + value: Any = Field(..., description="比较值;operator=in 时必须是列表") + + +class FilterGroup(BaseModel): + """逻辑组合规则:可嵌套 sub_rules 实现任意深度的 AND/OR 组合""" + logic: Literal["AND", "OR"] = Field("AND", description="组合方式") + sub_rules: List[Union["FilterGroup", "FilterRule"]] = Field( + ..., min_length=1, description="子规则(可继续嵌套 FilterGroup)" + ) + + +# Pydantic v2 递归模型需要显式 rebuild +FilterGroup.model_rebuild() + + +class FilterRequest(BaseModel): + """高级筛选请求体""" + model: Literal["student"] = Field(..., description="目标模型(当前支持 student)") + rules: List[Union[FilterGroup, FilterRule]] = Field( + ..., min_length=1, description="筛选规则列表(顶层默认 AND 连接)" + ) + + +class FilterResponse(BaseModel): + total: int + items: List[dict] = Field(..., description="命中的学生记录") + + +# ============================================================ +# 2.6 统计分析响应模型 +# ============================================================ +class ClassGenderStat(BaseModel): + """2.6.1 多维度班级统计:总人数 + 性别分布""" + class_id: int + class_name: str + total: int + male: int + female: int + + +class AllAboveStudent(BaseModel): + """2.6.2 每次考试都在分数线以上的学生""" + stu_id: int + stu_name: str + class_name: str + exam_count: int = Field(..., description="参加考核次数") + min_score: float = Field(..., description="最低分(>= 分数线)") + scores: List[dict] = Field(..., description="各次成绩明细 [{exam_id, score}]") + + +class FailStudent(BaseModel): + """2.6.2 不及格次数 >= N 的学生""" + stu_id: int + stu_name: str + class_name: str + fail_count: int = Field(..., description="不及格次数") + fail_details: List[dict] = Field(..., description="不及格成绩明细 [{exam_id, score}]") + + +class ClassExamAvg(BaseModel): + """2.6.2 每次考试每个班级的平均分(支持动态排序)""" + exam_id: int + class_id: int + class_name: str + avg_score: float + + +class TopSalaryStudent(BaseModel): + """2.6.3 薪资 Top N""" + stu_id: int + stu_name: str + class_name: str + job_time: Any = Field(None, description="offer 下发时间") + company_name: str + salary: float + + +class EmploymentDuration(BaseModel): + """2.6.3 单个学生就业时长(天)""" + stu_id: int + stu_name: str + class_name: str + employment_open_time: Any + job_time: Any = Field(None, description="未拿到 offer 为 null") + duration_days: int = Field(..., description="offer下发时间 - 就业开放时间;未拿到 offer 为 -1") + + +class ClassAvgDuration(BaseModel): + """2.6.3 每个班级平均就业时长""" + class_id: int + class_name: str + opened_count: int = Field(..., description="进入就业阶段人数(有开放时间)") + offered_count: int = Field(..., description="已拿到 offer 人数") + avg_duration_days: float = Field(..., description="平均就业时长(仅统计已拿到 offer 的学生)") + + +class ScoreVolatility(BaseModel): + """2.7.2 成绩波动分析:最大分差 Top N""" + stu_id: int + stu_name: str + class_name: str + max_score: float + min_score: float + diff: float = Field(..., description="最大分差 = 最高分 - 最低分") + + +class EmploymentFunnel(BaseModel): + """2.7.2 班级就业漏斗:总人数 -> 已就业 -> 高薪(>10k) -> 就业率""" + class_id: int + class_name: str + total: int = Field(..., description="班级总人数") + employed: int = Field(..., description="已就业人数") + high_salary: int = Field(..., description="高薪人数(薪资 > 10000)") + employment_rate: float = Field(..., description="就业率(百分比,保留2位)") diff --git a/scheme/students.py b/scheme/students.py new file mode 100644 index 0000000..059340f --- /dev/null +++ b/scheme/students.py @@ -0,0 +1,95 @@ +# scheme/students.py +# 学生模块的请求/响应模型 +from datetime import date +from typing import List, Optional + +from pydantic import BaseModel, Field, field_validator + + +# -------------------- 请求体模型 ----------------------------- +class StudentAdd(BaseModel): + """创建学生:stu_id 不传则按规则自动生成(入学年份+班级号+序号)""" + stu_id: Optional[int] = Field(None, ge=1, description="学号(不传则自动生成)") + class_id: int = Field(..., ge=1, description="学生班级ID") + stu_name: str = Field(..., min_length=1, max_length=10, description="学生姓名") + native_place: str = Field(..., min_length=1, description="籍贯") + graduate_school: str = Field(..., min_length=1, description="毕业院校") + major: str = Field(..., min_length=1, description="专业") + enroll_time: date = Field(..., description="入学时间 YYYY-MM-DD") + graduate_time: date = Field(..., description="毕业时间 YYYY-MM-DD") + education: str = Field(..., description="学历:大专/本科/硕士等") + age: int = Field(..., ge=15, le=60, description="年龄") + gender: str = Field(..., description="性别:男/女") + advisor_id: int = Field(..., ge=1, description="顾问编号") + status: str = Field("在读", description="状态:在读/进入就业/已就业") + + @field_validator("gender") + @classmethod + def validate_gender(cls, v): + if v not in ("男", "女"): + raise ValueError(f"gender 必须是 男/女,当前值: {v}") + return v + + @field_validator("status") + @classmethod + def validate_status(cls, v): + allowed = ["在读", "进入就业", "已就业"] + if v not in allowed: + raise ValueError(f"status 必须是 {allowed} 之一,当前值: {v}") + return v + + +class StudentUpdate(BaseModel): + """更新学生:只更新显式传入的非 None 字段;class_id/advisor_id 不允许改(保持归属一致性)""" + stu_name: Optional[str] = Field(None, min_length=1, max_length=10, description="学生姓名") + native_place: Optional[str] = Field(None, min_length=1, description="籍贯") + graduate_school: Optional[str] = Field(None, min_length=1, description="毕业院校") + major: Optional[str] = Field(None, min_length=1, description="专业") + enroll_time: Optional[date] = Field(None, description="入学时间") + graduate_time: Optional[date] = Field(None, description="毕业时间") + education: Optional[str] = Field(None, description="学历") + age: Optional[int] = Field(None, ge=15, le=60, description="年龄") + gender: Optional[str] = Field(None, description="性别:男/女") + status: Optional[str] = Field(None, description="状态:在读/进入就业/已就业") + + @field_validator("gender") + @classmethod + def validate_gender(cls, v): + if v is not None and v not in ("男", "女"): + raise ValueError(f"gender 必须是 男/女,当前值: {v}") + return v + + @field_validator("status") + @classmethod + def validate_status(cls, v): + allowed = ["在读", "进入就业", "已就业"] + if v is not None and v not in allowed: + raise ValueError(f"status 必须是 {allowed} 之一,当前值: {v}") + return v + + +# -------------------- 响应模型 ---------------------------- +class StudentResponse(BaseModel): + stu_id: int + stu_name: str + native_place: str + graduate_school: str + major: str + enroll_time: date + graduate_time: date + education: str + age: int + gender: str + class_id: int + advisor_id: int + status: str + class_name: Optional[str] = Field(None, description="冗余展示:班级名称") + advisor_name: Optional[str] = Field(None, description="冗余展示:顾问姓名") + + class Config: + from_attributes = True + + +class StudentListResponse(BaseModel): + total: int + items: List[StudentResponse] diff --git a/scheme/teachers.py b/scheme/teachers.py new file mode 100644 index 0000000..1eb9773 --- /dev/null +++ b/scheme/teachers.py @@ -0,0 +1,54 @@ +# scheme/teachers.py +# 老师模块的请求/响应模型(沿用原 teacher 模块风格) +from typing import List, Optional + +from pydantic import BaseModel, Field, field_validator + + +# -------------------- 请求体模型 ----------------------------- +class TeacherAdd(BaseModel): + teacher_id: Optional[int] = Field(None, ge=1, description="教师ID(不传则自增)") + class_id: int = Field(..., ge=1, description="所带班级ID") + teacher_name: str = Field(..., min_length=1, description="教师姓名") + job_name: str = Field(..., min_length=2, description="主讲、班主任、助教") + is_deleted: int = Field(0, ge=0, le=0, description="只能输入0,0代表未删除") + + @field_validator("job_name") + @classmethod + def validate_job_name(cls, v): + """校验 job_name 必须是 主讲/班主任/助教 之一""" + allowed = ["主讲", "班主任", "助教"] + if v not in allowed: + raise ValueError(f"job_name 必须是 {allowed} 之一,当前值: {v}") + return v + + +class TeacherUpdate(BaseModel): + class_id: Optional[int] = Field(None, ge=1, description="所带班级ID") + teacher_name: Optional[str] = Field(None, min_length=1, description="教师姓名") + job_name: Optional[str] = Field(None, min_length=2, description="主讲、班主任、助教") + + @field_validator("job_name") + @classmethod + def validate_job_name(cls, v): + allowed = ["主讲", "班主任", "助教"] + if v is not None and v not in allowed: + raise ValueError(f"job_name 必须是 {allowed} 之一,当前值: {v}") + return v + + +# -------------------- 响应模型 ---------------------------- +class TeacherResponse(BaseModel): + teacher_id: int + class_id: int + teacher_name: str + job_name: str + class_name: Optional[str] = Field(None, description="冗余展示:班级名称") + + class Config: + from_attributes = True + + +class TeacherListResponse(BaseModel): + total: int + items: List[TeacherResponse] diff --git a/scheme/users.py b/scheme/users.py new file mode 100644 index 0000000..9e43de2 --- /dev/null +++ b/scheme/users.py @@ -0,0 +1,53 @@ +# scheme/users.py +# 用户认证模块的请求/响应模型 +from typing import Optional + +from pydantic import BaseModel, Field, field_validator + + +# -------------------- 请求体模型 ----------------------------- +class UserAdd(BaseModel): + """创建用户(仅管理员)""" + username: str = Field(..., min_length=3, max_length=50, description="登录名") + password: str = Field(..., min_length=6, max_length=64, description="密码(明文,服务端加密存储)") + role: str = Field(..., description="角色:admin/teacher/student") + teacher_id: Optional[int] = Field(None, ge=1, description="教师角色的工号") + stu_id: Optional[int] = Field(None, ge=1, description="学生角色的学号") + + @field_validator("role") + @classmethod + def validate_role(cls, v): + allowed = ["admin", "teacher", "student"] + if v not in allowed: + raise ValueError(f"role 必须是 {allowed} 之一,当前值: {v}") + return v + + +class UserLogin(BaseModel): + """登录""" + username: str = Field(..., description="登录名") + password: str = Field(..., description="密码") + + +# -------------------- 响应模型 ---------------------------- +class UserResponse(BaseModel): + user_id: int + username: str + role: str + teacher_id: Optional[int] = None + stu_id: Optional[int] = None + + class Config: + from_attributes = True + + +class TokenResponse(BaseModel): + """登录成功返回""" + access_token: str = Field(..., description="JWT token") + token_type: str = Field("bearer", description="token 类型") + user: UserResponse + + +class MessageResponse(BaseModel): + """通用消息响应""" + message: str diff --git a/smoke_test.py b/smoke_test.py new file mode 100644 index 0000000..6bb43bc --- /dev/null +++ b/smoke_test.py @@ -0,0 +1,12 @@ +# smoke_test.py 冒烟测试:应用构建 + 路由清单(不需要连接数据库) +import main + +print("APP_OK:", main.app.title) +count = 0 +for x in main.app.routes: + methods = getattr(x, "methods", None) + if methods: + ms = sorted(methods - {"HEAD", "OPTIONS"}) + print(f"{'/'.join(ms):8} {x.path}") + count += 1 +print("TOTAL_ROUTES:", count) diff --git a/sql/init.sql b/sql/init.sql new file mode 100644 index 0000000..f1b6d8b --- /dev/null +++ b/sql/init.sql @@ -0,0 +1,177 @@ +-- ============================================================ +-- 沃林学生管理系统 数据库初始化脚本(修正版) +-- 基于原始建表语句修正,主要变更: +-- 1. c_lass 补充 class_name 字段(原表缺少班级名称) +-- 2. student 补充 status 字段(在读/进入就业/已就业,就业联动依赖) +-- 3. score 补充 is_deleted 字段(统一逻辑删除风格) +-- 4. employment_base 补充冗余字段 stu_name / class_name(需求 2.3 设计提示) +-- 5. user 新增用户表(JWT 登录 + RBAC 角色,原建表语句缺失) +-- 6. 修复原语句语法错误(c_lass 末尾多余逗号) +-- 7. 追加种子数据:初始管理员 admin/admin123 + 演示数据 +-- 使用:mysql -uroot -p < init.sql (或 docker-compose 自动执行) +-- ============================================================ + +CREATE DATABASE IF NOT EXISTS walin_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE walin_db; + +-- ---------- 班级表 ---------- +CREATE TABLE IF NOT EXISTS c_lass( + class_id INT PRIMARY KEY AUTO_INCREMENT, + class_name VARCHAR(50) NOT NULL COMMENT '班级名称', + start_time DATE NOT NULL COMMENT '开课时间', + is_deleted INT NOT NULL DEFAULT 0 COMMENT '逻辑删除' +); + +-- ---------- 老师表 ---------- +CREATE TABLE IF NOT EXISTS teacher( + teacher_id INT PRIMARY KEY AUTO_INCREMENT, + teacher_name VARCHAR(50) NOT NULL, + job_name VARCHAR(50) NOT NULL COMMENT '主讲/班主任/助教', + class_id INT NOT NULL, + is_deleted INT NOT NULL DEFAULT 0, + FOREIGN KEY (class_id) REFERENCES c_lass(class_id) +); + +-- ---------- 顾问表 ---------- +CREATE TABLE IF NOT EXISTS advisor( + advisor_id INT PRIMARY KEY AUTO_INCREMENT, + advisor_name VARCHAR(50) NOT NULL, + is_deleted INT NOT NULL DEFAULT 0 +); + +-- ---------- 学生表 ---------- +CREATE TABLE IF NOT EXISTS student( + stu_id INT PRIMARY KEY COMMENT '学号(支持规则生成:入学年份+班级号+序号)', + stu_name VARCHAR(10) NOT NULL, + native_place VARCHAR(30) NOT NULL, + graduate_school VARCHAR(50) NOT NULL, + major VARCHAR(20) NOT NULL, + enroll_time DATE NOT NULL, + graduate_time DATE NOT NULL, + education VARCHAR(10) NOT NULL, + age INT NOT NULL, + gender VARCHAR(10) NOT NULL, + class_id INT NOT NULL, + advisor_id INT NOT NULL, + status VARCHAR(10) NOT NULL DEFAULT '在读' COMMENT '在读/进入就业/已就业', + is_deleted INT NOT NULL DEFAULT 0, + FOREIGN KEY (class_id) REFERENCES c_lass(class_id), + FOREIGN KEY (advisor_id) REFERENCES advisor(advisor_id) +); + +-- ---------- 成绩表 ---------- +CREATE TABLE IF NOT EXISTS score( + stu_id INT, + exam_id INT COMMENT '考核序次', + score FLOAT DEFAULT NULL, + is_deleted INT NOT NULL DEFAULT 0, + PRIMARY KEY (stu_id, exam_id), + FOREIGN KEY (stu_id) REFERENCES student(stu_id) +); + +-- ---------- 就业表-基础信息表 ---------- +CREATE TABLE IF NOT EXISTS employment_base( + stu_id INT PRIMARY KEY, + employment_open_time DATE NOT NULL COMMENT '就业开放时间', + job_time DATE DEFAULT NULL COMMENT 'offer下发时间', + company_name VARCHAR(100) DEFAULT '未就业', + salary FLOAT DEFAULT 0, + stu_name VARCHAR(10) NOT NULL COMMENT '冗余:学生姓名', + class_name VARCHAR(50) NOT NULL COMMENT '冗余:班级名称', + is_deleted INT NOT NULL DEFAULT 0, + FOREIGN KEY (stu_id) REFERENCES student(stu_id) +); + +-- ---------- 就业表-offer表 ---------- +CREATE TABLE IF NOT EXISTS employment_offer( + stu_id INT, + offer_id INT COMMENT '同一学生内自增', + offer_time DATE NOT NULL, + company_name VARCHAR(100) DEFAULT '', + salary FLOAT DEFAULT 0, + is_deleted INT NOT NULL DEFAULT 0, + PRIMARY KEY (stu_id, offer_id), + FOREIGN KEY (stu_id) REFERENCES employment_base(stu_id) +); + +-- ---------- 用户表(新增:JWT 登录 + RBAC) ---------- +CREATE TABLE IF NOT EXISTS user( + user_id INT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash VARCHAR(200) NOT NULL COMMENT 'PBKDF2-SHA256', + role VARCHAR(20) NOT NULL COMMENT 'admin/teacher/student', + stu_id INT NULL COMMENT 'student 角色绑定的学号', + teacher_id INT NULL COMMENT 'teacher 角色绑定的工号', + is_deleted INT NOT NULL DEFAULT 0, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (stu_id) REFERENCES student(stu_id), + FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) +); + +-- ============================================================ +-- 种子数据(演示用,可按需删减) +-- 使用 INSERT IGNORE:重复执行本脚本不会报主键冲突(幂等) +-- ============================================================ + +-- 班级 +INSERT IGNORE INTO c_lass(class_id, class_name, start_time) VALUES +(1, 'Java2301', '2026-03-01'), +(2, 'Java2302', '2026-04-10'), +(3, 'Python2301', '2026-05-06'); + +-- 顾问 +INSERT IGNORE INTO advisor(advisor_id, advisor_name) VALUES +(1, '王顾问'), (2, '李顾问'); + +-- 老师 +INSERT IGNORE INTO teacher(teacher_id, teacher_name, job_name, class_id) VALUES +(1, '张主讲', '主讲', 1), +(2, '刘班主任', '班主任', 1), +(3, '陈主讲', '主讲', 2), +(4, '赵班主任', '班主任', 2), +(5, '孙助教', '助教', 3); + +-- 学生(学号规则:入学年份4位 + 班级号2位 + 序号4位) +INSERT IGNORE INTO student(stu_id, stu_name, native_place, graduate_school, major, enroll_time, graduate_time, education, age, gender, class_id, advisor_id, status) VALUES +(2026010001, '张伟', '湖北武汉', '武汉大学', '软件工程', '2026-03-01', '2026-09-01', '本科', 24, '男', 1, 1, '进入就业'), +(2026010002, '王芳', '湖南长沙', '中南大学', '计算机科学', '2026-03-01', '2026-09-01', '本科', 23, '女', 1, 1, '进入就业'), +(2026010003, '李娜', '河南郑州', '郑州大学', '软件工程', '2026-03-01', '2026-09-01', '大专', 22, '女', 1, 1, '在读'), +(2026010004, '刘强', '四川成都', '四川大学', '信息安全', '2026-03-01', '2026-09-01', '本科', 25, '男', 1, 1, '在读'), +(2026020001, '陈静', '江苏南京', '南京大学', '软件工程', '2026-04-10', '2026-10-10', '本科', 24, '女', 2, 2, '在读'), +(2026020002, '杨帆', '安徽合肥', '合肥工业大学', '计算机科学', '2026-04-10', '2026-10-10', '硕士', 27, '男', 2, 2, '在读'), +(2026020003, '黄磊', '广东广州', '华南理工', '软件工程', '2026-04-10', '2026-10-10', '本科', 23, '男', 2, 2, '在读'), +(2026030001, '周婷', '浙江杭州', '浙江大学', '人工智能', '2026-05-06', '2026-11-06', '硕士', 26, '女', 3, 2, '在读'), +(2026030002, '吴涛', '陕西西安', '西安电子科大', '大数据', '2026-05-06', '2026-11-06', '本科', 24, '男', 3, 2, '在读'); + +-- 成绩(部分低于 60 分用于演示红线预警) +INSERT IGNORE INTO score(stu_id, exam_id, score) VALUES +(2026010001, 1, 88), (2026010001, 2, 92), (2026010001, 3, 85), +(2026010002, 1, 76), (2026010002, 2, 58), (2026010002, 3, 81), +(2026010003, 1, 55), (2026010003, 2, 49), +(2026010004, 1, 67), (2026010004, 2, 72), +(2026020001, 1, 95), (2026020001, 2, 90), +(2026020002, 1, 82), (2026020002, 2, 60), +(2026020003, 1, 45), (2026020003, 2, 52), (2026020003, 3, 61), +(2026030001, 1, 89), (2026030001, 2, 94), +(2026030002, 1, 70), (2026030002, 2, 65); + +-- 就业基础表(张伟已拿 offer;王芳开放简历未拿 offer) +INSERT IGNORE INTO employment_base(stu_id, employment_open_time, job_time, company_name, salary, stu_name, class_name) VALUES +(2026010001, '2026-08-01', '2026-08-20', '腾讯科技', 18000, '张伟', 'Java2301'), +(2026010002, '2026-08-15', NULL, '未就业', 0, '王芳', 'Java2301'); + +-- offer 记录 +INSERT IGNORE INTO employment_offer(stu_id, offer_id, offer_time, company_name, salary) VALUES +(2026010001, 1, '2026-08-20', '腾讯科技', 18000); + +-- ============================================================ +-- 初始账号(密码均为 PBKDF2-SHA256 哈希,120000 次迭代): +-- admin / admin123 管理员 +-- teacher1 / teacher123 教师(工号1 张主讲,班级1) +-- s2026010003 / student123 学生(学号 2026010003 李娜) +-- 生产环境请立即修改密码! +-- ============================================================ +INSERT IGNORE INTO user(username, password_hash, role, teacher_id, stu_id) VALUES +('admin', 'pbkdf2_sha256$120000$897b4de0db4f0edf881627fc49df801e$02fc3387a341190448589f5569ef750f48ff3d9938fcea327ee2d5ee0f7258b9', 'admin', NULL, NULL), +('teacher1', 'pbkdf2_sha256$120000$03dd3da6882e0dd77bc71e5d7ca1bcb5$f02eb626afdc528eca67644f23a39d70d898ddea4adc9b7dccb30a8e1fb788b2', 'teacher', 1, NULL), +('s2026010003', 'pbkdf2_sha256$120000$5f9bd2dee6ff5fe7e145d3e2f9f8e11e$56b07cd8d35882547f378d62d3075e9f2eac38c47b6b917782c5199cb6864d6b', 'student', NULL, 2026010003); diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..6ca0c0c --- /dev/null +++ b/static/app.js @@ -0,0 +1,457 @@ +// app.js +// 沃林学生管理系统前端逻辑(Vue 3 Options API) +const { createApp } = Vue; + +const API = "/api"; + +// 统计接口配置:key -> { url, method, headers, rowKeys, headerMap } +const STAT_CONFIG = { + byAge: { + url: () => `/statistics/students/by-age?op=${S.params.op}` + + (S.params.op === "between" + ? `&min_value=${S.params.min_value}&max_value=${S.params.max_value}` + : `&value=${S.params.value}`), + pick: (s) => ({ 学号: s.stu_id, 姓名: s.stu_name, 班级: s.class_name, 性别: s.gender, 年龄: s.age, 状态: s.status }), + }, + genderStats: { + url: () => "/statistics/class/gender-stats", + pick: (r) => ({ 班级ID: r.class_id, 班级: r.class_name, 总人数: r.total, 男: r.male, 女: r.female }), + }, + allAbove: { + url: () => `/statistics/score/all-above?line=${S.params.line}`, + pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 考核次数: r.exam_count, 最低分: r.min_score, 明细: JSON.stringify(r.scores) }), + }, + fail: { + url: () => `/statistics/score/fail?times=${S.params.times}`, + pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 不及格次数: r.fail_count, 明细: JSON.stringify(r.fail_details) }), + }, + classAvg: { + url: () => `/statistics/score/class-avg?order=${S.params.order}` + + (S.params.exam_id ? `&exam_id=${S.params.exam_id}` : ""), + pick: (r) => ({ 考核序次: `第${r.exam_id}次`, 班级ID: r.class_id, 班级: r.class_name, 平均分: r.avg_score }), + }, + topSalary: { + url: () => `/statistics/employment/top-salary?n=${S.params.n}`, + pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, offer时间: r.job_time, 公司: r.company_name, 薪资: r.salary }), + }, + duration: { + url: () => "/statistics/employment/duration", + pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 开放时间: r.employment_open_time, offer时间: r.job_time, 就业时长: r.duration_days < 0 ? "未就业" : r.duration_days + " 天" }), + }, + classAvgDuration: { + url: () => "/statistics/employment/class-avg-duration", + pick: (r) => ({ 班级ID: r.class_id, 班级: r.class_name, 进入就业人数: r.opened_count, 已拿offer人数: r.offered_count, 平均就业时长: r.avg_duration_days + " 天" }), + }, + volatility: { + url: () => `/statistics/score/volatility?top_n=${S.params.top_n}`, + pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 最高分: r.max_score, 最低分: r.min_score, 最大分差: r.diff }), + }, + funnel: { + url: () => "/statistics/employment/funnel", + pick: (r) => ({ 班级ID: r.class_id, 班级: r.class_name, 总人数: r.total, 已就业: r.employed, 高薪人数: r.high_salary, 就业率: r.employment_rate + "%" }), + }, +}; + +// 状态对象(非响应式共享,供 STAT_CONFIG 读取) +const S = { + params: { op: "gt", value: 25, min_value: 20, max_value: 30, line: 80, times: 2, n: 10, exam_id: "", order: "desc", top_n: 5 }, +}; + +const app = createApp({ + data() { + return { + token: localStorage.getItem("token") || "", + me: { username: "", role: "" }, + loading: false, + tip: "", + loginForm: { username: "admin", password: "admin123" }, + tabs: [ + { key: "students", label: "学生管理" }, + { key: "scores", label: "成绩管理" }, + { key: "employment", label: "就业管理" }, + { key: "classes", label: "班级管理" }, + { key: "teachers", label: "老师管理" }, + { key: "statistics", label: "统计分析" }, + { key: "filter", label: "高级筛选" }, + ], + tab: "students", + // 学生 + students: [], stuTotal: 0, stuPage: 1, + stuQ: { stu_id: "", stu_name: "", class_id: "", status: "" }, + showStu: false, stuForm: {}, + // 成绩 + scores: [], scoreQ: { stu_id: "", exam_id: "" }, scoreWarning: "", + showScore: false, scoreForm: {}, + // 就业 + employment: [], empTotal: 0, empPage: 1, + empQ: { stu_id: "", company_name: "", salary_min: "", salary_max: "" }, + showEmp: false, empFormMode: "open", empForm: {}, + // 班级 / 老师 + classes: [], showClass: false, classForm: {}, + teachers: [], teacherTotal: 0, teacherPage: 1, showTeacher: false, teacherForm: {}, + // 统计 + statKey: "", statRows: [], statParams: S.params, + // 高级筛选 + filterJson: "", filterRows: [], + }; + }, + computed: { + roleLabel() { + return { admin: "管理员", teacher: "教师", student: "学生" }[this.me.role] || this.me.role; + }, + statKeys() { + return this.statRows.length ? Object.keys(this.statRows[0]) : []; + }, + statHeaders() { + return this.statKeys; + }, + }, + methods: { + // ==================== 基础 ==================== + async api(path, method = "GET", body = null, raw = false) { + const opt = { + method, + headers: { Authorization: "Bearer " + this.token }, + }; + if (body !== null) { + opt.headers["Content-Type"] = "application/json"; + opt.body = JSON.stringify(body); + } + const resp = await fetch(API + path, opt); + if (resp.status === 401) { + this.logout(true); + throw new Error("登录已失效,请重新登录"); + } + if (!resp.ok) { + let detail = `请求失败(${resp.status})`; + try { detail = (await resp.json()).detail || detail; } catch (e) {} + // detail 可能是 pydantic 校验数组 + if (Array.isArray(detail)) detail = detail.map((d) => `${d.loc.join(".")}: ${d.msg}`).join("; "); + throw new Error(detail); + } + if (resp.status === 204) return null; + return raw ? resp : resp.json(); + }, + flash(msg, ok = true) { + this.tip = msg; + setTimeout(() => (this.tip = ""), ok ? 2500 : 5000); + }, + logout(silent = false) { + localStorage.removeItem("token"); + this.token = ""; + this.me = { username: "", role: "" }; + if (!silent) this.tip = ""; + }, + async login() { + this.loading = true; + this.tip = ""; + try { + const resp = await fetch(API + "/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(this.loginForm), + }); + if (!resp.ok) throw new Error((await resp.json()).detail || "登录失败"); + const data = await resp.json(); + this.token = data.access_token; + localStorage.setItem("token", this.token); + await this.afterLogin(); + } catch (e) { + this.tip = e.message; + } finally { + this.loading = false; + } + }, + async afterLogin() { + try { + this.me = await this.api("/auth/me"); + await Promise.all([this.loadClasses(), this.loadStudents(1), this.loadTeachers(1)]); + this.switchTab(this.me.role === "student" ? "students" : this.tab); + this.flash("登录成功"); + } catch (e) { + this.tip = e.message; + } + }, + switchTab(key) { + this.tab = key; + const loaders = { + students: () => this.loadStudents(1), + scores: () => this.loadScores(), + employment: () => this.loadEmployment(1), + classes: () => this.loadClasses(), + teachers: () => this.loadTeachers(1), + }; + if (loaders[key]) loaders[key](); + }, + closeAll() { + this.showStu = this.showScore = this.showEmp = this.showClass = this.showTeacher = false; + }, + + // ==================== 学生 ==================== + async loadStudents(page = 1) { + try { + const q = new URLSearchParams(); + if (this.stuQ.stu_id) q.set("stu_id", this.stuQ.stu_id); + if (this.stuQ.stu_name) q.set("stu_name", this.stuQ.stu_name); + if (this.stuQ.class_id) q.set("class_id", this.stuQ.class_id); + if (this.stuQ.status) q.set("status", this.stuQ.status); + q.set("skip", (page - 1) * 10); + q.set("limit", 10); + const data = await this.api("/students/total_query?" + q.toString()); + this.students = data.items; + this.stuTotal = data.total; + this.stuPage = page; + } catch (e) { this.flash(e.message, false); } + }, + resetStuQ() { this.stuQ = { stu_id: "", stu_name: "", class_id: "", status: "" }; this.loadStudents(1); }, + openStuForm(s = null) { + this.stuForm = s + ? { ...s, _editing: true } + : { stu_id: "", class_id: this.classes[0]?.class_id, stu_name: "", gender: "男", age: 20, education: "本科", major: "", native_place: "", graduate_school: "", advisor_id: 1, enroll_time: "", graduate_time: "", status: "在读" }; + this.showStu = true; + }, + async saveStudent() { + try { + if (this.stuForm._editing) { + await this.api(`/students/update?stu_id=${this.stuForm.stu_id}`, "PUT", { + stu_name: this.stuForm.stu_name, native_place: this.stuForm.native_place, + graduate_school: this.stuForm.graduate_school, major: this.stuForm.major, + education: this.stuForm.education, age: this.stuForm.age, gender: this.stuForm.gender, + status: this.stuForm.status, + }); + this.flash("学生更新成功"); + } else { + const body = { ...this.stuForm }; + if (body.stu_id) body.stu_id = Number(body.stu_id); else delete body.stu_id; + await this.api("/students/add", "POST", body); + this.flash("学生创建成功"); + } + this.closeAll(); + this.loadStudents(this.stuPage); + } catch (e) { this.flash(e.message, false); } + }, + async delStudent(s) { + if (!confirm(`确认删除学生 ${s.stu_name}(${s.stu_id})?`)) return; + try { await this.api(`/students/delete?stu_id=${s.stu_id}`, "DELETE"); this.flash("删除成功"); this.loadStudents(this.stuPage); } + catch (e) { this.flash(e.message, false); } + }, + + // ==================== 成绩 ==================== + async loadScores() { + try { + const q = new URLSearchParams(); + if (this.scoreQ.stu_id) q.set("stu_id", this.scoreQ.stu_id); + if (this.scoreQ.exam_id) q.set("exam_id", this.scoreQ.exam_id); + if (!q.toString()) { this.scores = []; this.flash("请输入学号或考核序次查询", false); return; } + const data = await this.api("/scores/query?" + q.toString()); + this.scores = data.items; + this.scoreWarning = ""; + } catch (e) { this.flash(e.message, false); } + }, + openScoreForm(s = null) { + this.scoreForm = s + ? { stu_id: s.stu_id, exam_id: s.exam_id, score: s.score, _editing: true } + : { stu_id: "", exam_id: 1, score: 80 }; + this.showScore = true; + }, + async saveScore() { + try { + if (this.scoreForm._editing) { + await this.api(`/scores/update?stu_id=${this.scoreForm.stu_id}&exam_id=${this.scoreForm.exam_id}`, "PUT", { score: this.scoreForm.score }); + this.flash("成绩修改成功"); + } else { + const data = await this.api("/scores/add", "POST", { stu_id: this.scoreForm.stu_id, exam_id: this.scoreForm.exam_id, score: this.scoreForm.score }); + if (data.warning) { this.scoreWarning = data.warning; this.flash(data.warning, false); } + else this.flash("成绩录入成功"); + } + this.closeAll(); + if (this.scoreQ.stu_id || this.scoreQ.exam_id) this.loadScores(); + } catch (e) { this.flash(e.message, false); } + }, + async delScore(s) { + if (!confirm(`确认删除第 ${s.exam_id} 次考核成绩?`)) return; + try { await this.api("/scores/delete", "POST", { stu_id: s.stu_id, exam_id: s.exam_id }); this.flash("删除成功"); this.loadScores(); } + catch (e) { this.flash(e.message, false); } + }, + + // ==================== 就业 ==================== + async loadEmployment(page = 1) { + try { + const q = new URLSearchParams(); + if (this.empQ.stu_id) q.set("stu_id", this.empQ.stu_id); + if (this.empQ.company_name) q.set("company_name", this.empQ.company_name); + if (this.empQ.salary_min !== "" && this.empQ.salary_min !== null) q.set("salary_min", this.empQ.salary_min); + if (this.empQ.salary_max !== "" && this.empQ.salary_max !== null) q.set("salary_max", this.empQ.salary_max); + q.set("skip", (page - 1) * 10); + q.set("limit", 10); + const data = await this.api("/employment/total_query?" + q.toString()); + this.employment = data.items; + this.empTotal = data.total; + this.empPage = page; + } catch (e) { this.flash(e.message, false); } + }, + openEmpEdit(e) { + this.empFormMode = "edit"; + this.empForm = { stu_id: e.stu_id, company_name: e.company_name, salary: e.salary }; + this.showEmp = true; + }, + async saveEmployment() { + try { + if (this.empFormMode === "open") { + await this.api("/employment/open", "POST", { stu_id: this.empForm.stu_id, employment_open_time: this.empForm.employment_open_time }); + this.flash("就业开放登记成功,学生状态已更新为『进入就业』"); + } else if (this.empFormMode === "offer") { + await this.api("/employment/offer", "POST", this.empForm); + this.flash("offer 登记成功,学生状态已更新为『已就业』"); + } else { + await this.api(`/employment/update?stu_id=${this.empForm.stu_id}`, "PUT", { company_name: this.empForm.company_name, salary: this.empForm.salary }); + this.flash("就业信息更新成功"); + } + this.closeAll(); + this.loadEmployment(this.empPage); + } catch (e) { this.flash(e.message, false); } + }, + async delEmployment(e) { + if (!confirm(`确认删除 ${e.stu_name} 的就业信息?学生状态将回退为在读。`)) return; + try { await this.api(`/employment/delete?stu_id=${e.stu_id}`, "DELETE"); this.flash("删除成功"); this.loadEmployment(this.empPage); } + catch (err) { this.flash(err.message, false); } + }, + + // ==================== 班级 ==================== + async loadClasses() { + try { + const data = await this.api("/classes/total_query?limit=200"); + this.classes = data.items; + } catch (e) { this.flash(e.message, false); } + }, + openClassForm(c = null) { + this.classForm = c ? { ...c } : { class_id: "", class_name: "", start_time: "" }; + this.showClass = true; + }, + async saveClass() { + try { + if (this.classForm.class_id && !this.classForm._editing) { + await this.api("/classes/add", "POST", { class_id: Number(this.classForm.class_id), class_name: this.classForm.class_name, start_time: this.classForm.start_time }); + } else if (this.classForm._editing) { + await this.api(`/classes/update?class_id=${this.classForm.class_id}`, "PUT", { class_name: this.classForm.class_name, start_time: this.classForm.start_time }); + } else { + await this.api("/classes/add", "POST", { class_name: this.classForm.class_name, start_time: this.classForm.start_time }); + } + this.flash("班级保存成功"); + this.closeAll(); + this.loadClasses(); + } catch (e) { this.flash(e.message, false); } + }, + async delClass(c) { + if (!confirm(`确认删除班级 ${c.class_name}?`)) return; + try { await this.api(`/classes/delete?class_id=${c.class_id}`, "DELETE"); this.flash("删除成功"); this.loadClasses(); } + catch (e) { this.flash(e.message, false); } + }, + + // ==================== 老师 ==================== + async loadTeachers(page = 1) { + try { + const data = await this.api(`/teachers/teacher/total_query?skip=${(page - 1) * 10}&limit=10`); + this.teachers = data.items; + this.teacherTotal = data.total; + this.teacherPage = page; + } catch (e) { this.flash(e.message, false); } + }, + openTeacherForm(t = null) { + this.teacherForm = t ? { ...t, _editing: true } : { teacher_name: "", job_name: "主讲", class_id: this.classes[0]?.class_id }; + this.showTeacher = true; + }, + async saveTeacher() { + try { + if (this.teacherForm._editing) { + await this.api(`/teachers/teacher/update?teacher_id=${this.teacherForm.teacher_id}`, "PUT", { + class_id: this.teacherForm.class_id, teacher_name: this.teacherForm.teacher_name, job_name: this.teacherForm.job_name, + }); + } else { + await this.api("/teachers/teacher/add", "POST", { + class_id: this.teacherForm.class_id, teacher_name: this.teacherForm.teacher_name, job_name: this.teacherForm.job_name, + }); + } + this.flash("老师保存成功"); + this.closeAll(); + this.loadTeachers(this.teacherPage); + } catch (e) { this.flash(e.message, false); } + }, + async delTeacher(t) { + if (!confirm(`确认删除老师 ${t.teacher_name}?`)) return; + try { await this.api(`/teachers/teacher/delete?teacher_id=${t.teacher_id}`, "DELETE"); this.flash("删除成功"); this.loadTeachers(this.teacherPage); } + catch (e) { this.flash(e.message, false); } + }, + + // ==================== 统计 ==================== + runStat(key) { + this.statKey = key; + this.statRows = []; + // 只有需要参数的统计自动执行一次默认查询 + if (!["byAge", "allAbove", "fail", "topSalary", "classAvg", "volatility"].includes(key)) this.doStat(); + }, + async doStat() { + const cfg = STAT_CONFIG[this.statKey]; + if (!cfg) return; + try { + const data = await this.api(cfg.url()); + this.statRows = data.map(cfg.pick); + } catch (e) { this.flash(e.message, false); } + }, + + // ==================== 高级筛选 ==================== + resetFilterJson() { + this.filterJson = JSON.stringify( + { + model: "student", + rules: [ + { field: "age", operator: ">", value: 20 }, + { + logic: "OR", + sub_rules: [ + { field: "salary", operator: ">=", value: 10000 }, + { field: "class_name", operator: "like", value: "Java" }, + ], + }, + ], + }, + null, + 2 + ); + }, + async runFilter() { + try { + const body = JSON.parse(this.filterJson); + const data = await this.api("/statistics/filter", "POST", body); + this.filterRows = data.items; + this.flash(`筛选完成,共命中 ${data.total} 条记录`); + } catch (e) { + this.flash("筛选失败:" + e.message, false); + } + }, + }, + mounted() { + if (this.token) this.afterLogin(); + this.resetFilterJson(); + }, +}); + +// 分页组件 +app.component("pager", { + props: ["page", "total"], + emits: ["go"], + computed: { + pages() { + return Math.max(1, Math.ceil((this.total || 0) / 10)); + }, + }, + template: ` +

    + + 第 {{ page }} / {{ pages }} 页(共 {{ total }} 条) + +
    + `, +}); + +app.mount("#app"); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..43654c7 --- /dev/null +++ b/static/index.html @@ -0,0 +1,316 @@ + + + + + + 沃林学生管理系统 + + + + +
    + + + + + + + + + + +
    {{ tip }}
    +
    + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..19cad7b --- /dev/null +++ b/static/style.css @@ -0,0 +1,72 @@ +/* style.css 沃林学生管理系统前端样式(浅色主题) */ +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f7fa; color: #2c3e50; font-size: 14px; } + +/* ---------- 登录页 ---------- */ +.login-wrap { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: linear-gradient(135deg, #4f7cff 0%, #6a5cff 100%); } +.login-card { background: #fff; padding: 40px; border-radius: 12px; width: 360px; box-shadow: 0 10px 40px rgba(0,0,0,.15); } +.login-card h2 { text-align: center; margin-bottom: 4px; } +.login-card .sub { text-align: center; color: #999; margin-bottom: 24px; font-size: 12px; } +.login-card label { display: block; margin: 12px 0 4px; color: #666; font-size: 13px; } +.login-card input { width: 100%; padding: 10px 12px; border: 1px solid #dcdfe6; border-radius: 6px; font-size: 14px; } +.login-card .block { width: 100%; margin-top: 20px; } +.login-card .tip { color: #e74c3c; margin-top: 12px; font-size: 13px; min-height: 18px; text-align: center; } + +/* ---------- 顶栏 / 标签页 ---------- */ +.topbar { display: flex; align-items: center; gap: 12px; background: #fff; padding: 12px 24px; box-shadow: 0 1px 4px rgba(0,0,0,.06); position: sticky; top: 0; z-index: 10; } +.topbar .logo { font-size: 17px; font-weight: 700; color: #4f7cff; } +.topbar .spacer { flex: 1; } +.role-tag { background: #eef3ff; color: #4f7cff; padding: 2px 10px; border-radius: 10px; font-size: 12px; } +.tabs { display: flex; gap: 4px; padding: 12px 24px 0; flex-wrap: wrap; } +.tab { border: none; background: transparent; padding: 9px 18px; cursor: pointer; border-radius: 8px 8px 0 0; color: #666; font-size: 14px; } +.tab.active { background: #fff; color: #4f7cff; font-weight: 600; box-shadow: 0 -2px 6px rgba(0,0,0,.04); } +.content { padding: 16px 24px 40px; } + +/* ---------- 工具栏 / 表格 ---------- */ +.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; } +.toolbar.wrap { flex-wrap: wrap; } +.toolbar input, .toolbar select { padding: 7px 10px; border: 1px solid #dcdfe6; border-radius: 6px; } +.w90 { width: 90px; } .w120 { width: 120px; } .w140 { width: 140px; } .w70 { width: 70px; } +table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.05); } +th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #f0f2f5; white-space: nowrap; } +thead th { background: #fafbfc; color: #555; font-weight: 600; } +tbody tr:hover { background: #f7faff; } +.empty { text-align: center; color: #aaa; padding: 24px !important; } + +/* ---------- 按钮 / 徽标 ---------- */ +.btn { border: 1px solid #dcdfe6; background: #fff; padding: 7px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; color: #444; transition: .15s; } +.btn:hover { border-color: #4f7cff; color: #4f7cff; } +.btn.primary { background: #4f7cff; border-color: #4f7cff; color: #fff; } +.btn.primary:hover { background: #3f6cf0; } +.btn.ghost { border-color: transparent; background: transparent; } +.btn.danger { color: #e74c3c; } +.btn.danger:hover { border-color: #e74c3c; } +.btn.mini { padding: 3px 8px; font-size: 12px; margin-right: 4px; } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.badge { padding: 2px 8px; border-radius: 10px; font-size: 12px; background: #f0f2f5; } +.badge.ok { background: #e6f9ee; color: #18a058; } +.badge.warn { background: #fff5e6; color: #d48806; } +.badge.danger { background: #fdeaea; color: #e74c3c; } +.warn-tip { background: #fff5e6; color: #d48806; padding: 8px 12px; border-radius: 6px; margin-bottom: 10px; } + +/* ---------- 分页 ---------- */ +.pager { display: flex; align-items: center; gap: 12px; margin-top: 12px; color: #666; } + +/* ---------- 弹窗 ---------- */ +.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: flex; justify-content: center; align-items: flex-start; padding-top: 8vh; z-index: 100; } +.modal { background: #fff; border-radius: 10px; padding: 24px; width: 560px; max-height: 82vh; overflow: auto; box-shadow: 0 10px 40px rgba(0,0,0,.2); } +.modal h3 { margin-bottom: 16px; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; } +.form-grid label { display: flex; flex-direction: column; gap: 4px; color: #666; font-size: 13px; } +.form-grid input, .form-grid select { padding: 8px 10px; border: 1px solid #dcdfe6; border-radius: 6px; } +.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } + +/* ---------- 统计 / 筛选 ---------- */ +.param-box { display: flex; gap: 10px; align-items: center; background: #fff; padding: 12px 16px; border-radius: 8px; margin-bottom: 12px; flex-wrap: wrap; } +.param-box label { display: flex; align-items: center; gap: 6px; color: #555; } +.param-box input, .param-box select { padding: 6px 8px; border: 1px solid #dcdfe6; border-radius: 6px; } +.sub-tip { color: #888; font-size: 13px; margin-bottom: 10px; line-height: 1.8; } +.filter-json { width: 100%; font-family: Consolas, monospace; font-size: 13px; padding: 12px; border: 1px solid #dcdfe6; border-radius: 8px; margin-bottom: 10px; } + +/* ---------- 提示 ---------- */ +.toast { position: fixed; top: 70px; left: 50%; transform: translateX(-50%); background: #333; color: #fff; padding: 10px 20px; border-radius: 8px; z-index: 200; box-shadow: 0 4px 14px rgba(0,0,0,.25); }