lxUYZ_10j_L+^xn*wcZvka%_s~N
zifdCR?e|`a-QOB_SrS~eq*AsV2rM^Pp7x?{Q+KS-!f{DR!15fe%ust|cvO^mva5vC!8}!2jp3bf268O(K
z2aF1mV!tA76}ZZnZo{N`rKRVwNAAd*DSoagQw7Ny300bKPL65b9EPaUFv8L16n8
zO9dQDA?H>+#4%FX#~y-J#Y?!$H7f`E%?IFfwU@wrdi(>&;q9+Kv)_IN`~iLb%+
^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
zcmb_gYjhJwmhM*TWwk8HlHczK4iONGhelCzN?XMaQt=?qFvkk7gRdws$s$2Ko`bxL*^DPW~|6KF_Gh?L;^FP?ozG9WQ^(&5HPB8)_u$@c?
zOW0bZb)C8nj&L1%qVF&eLx+(VX+PI#>M#>C(0akpnb%<%qMPlmF~A~)
zl|a=}YE`E1flOahhVo=6v^3hFP*}al26cC^!5Xml+Kdg>!LFOB4eqv94%S$cWAF8g
zbk&2d?V6RdweTS5dT4>J4R_FWr<3M{mhPej+RehoMcS8v_Rg$UjS?OfHVKaikE-X3
zJKg+uYhSqUEsHeY*IVmuokxXkRyOY$hy?q?zK~ll=e6$L+0`G7h(tEEi34IdAj$bV
z_U+r-{+wT=Y_j2rs7Q{&Jd+?w5=@*&FL!gYqgRad_@jh~;Yd$Z0ulK{JQ58Ov4`}B
zL`g0}hKT6%KP{3Tf5<0EJ;8tkk}N_b8h}}Ct$UzlM7F>op!8ml0;|%-TLS?(U*+`<
z1bh)uwyVu9Q5xtEOQP&jJ01^8kxZwaF2_j3NR)*AYE)8tBMrHUx%DIzrx-8OMY|Uk
z0=scP!}!V=9M7GtvU%C1dN=N1m}N|_&do)a17qsFw`X5HH+TLwbHg8`PX2E0=bz18
z{3LbqwYlTJOI?0{_RKH7zB)cLbWt`*iZS-Nb+R4}CmW#$EaldbLXbmBppbACwY9R{
zHxTrw6QrhrqjEuy-xuy7qA$>+c9RNVExrv+`MXdSE*XJWgNexIOwOF91$=2ME{+>tt99VA6!Fe|@
zwcr&8ps9RIoozj{-_fPlII^{NruVB|0YDT7Oxn1>3Oa#n(TQx1bh9>^t*La1MY4TJ
z{h)NcA|3n@Pj=BK7^uHKKx2Q2wI8r+6ZSj192%XiHSG_BjeHCDWfV-RW~R%TouN^O
z)_@hX2GfPHzv!boUSg)EGt7B8&8CZVn}xjZ*;GSoIa*MICT$t>5O6+Do|uWfmb(1G+;88V`|M&WHa36y&3AM%FGYNjsMO;JSCfqa
zG2#n`sD%0P&*xvi@Iw-)JI=oXYQKepP=j4bqCXhs!PB_f6);T=hRYvlYmKE&{Bq`{
z-)9y&ci~d%vr`xzR+0}B4GC+*
zEl#(MHQr)$D~-2}jJ*;y_!G83+#I-JvK{~F&`%Sly6dj0q^o(-)qJVzYV}9eQ?4B`
z^Iv(_b!W}Qw)loX!YRhBB3)43BwsgHKgBo2^-bSNXzQPCTfJ>9d(7&I^hbQ5o+AiC
zqD?R`bMCFVi@#MeUMlwb+&DO4EcND@nSUMs`jbD-zIS$haCl`Z_QL%5AK+x87%p>{
zKb*UI94-Roz-LZff_$j!6=gmkN`4ZgKxaOHh6LYX=Km9lC}!)qtFNZc{u@l3d1Em3
z=K1V&jx@uttP6<*!Tj!M5tK70h835a{owQ2@e9gA-7MwEFr0rSHS+Sz#Q5xoFDeXC
zysKfV7F)^=qUbl!J2QAb6?<3d`{vU#v#(zEq~7{4>QU#s_{W*Sq0A@*Bw!5B+|{$G
zU%r*P^#0dZ&Uhd;6)K8}OI_lxUQp)9dg&j71F|6$>bd(>h=iE!U2GG
zfJrc*;MDa+tuJTdjV89=;xaqPK8iyVq
zY)_Xm=E9szb*-{)%F%wNedNgb{p0&DRVLT8CE$N!+v^3V+hc~fqy4(WHPZfyC1$wJ
zSA*4_81C+!bR+8>zk{l&ieqsD}7%S{7kHKrNPVEpcH2BzRK_Pf8C
z8G9@Hodo{E{A}IUdiD=hTTAtSDmG$SSppq@T35TZ%JLsBJJ84SJrX4EkfK9Pt2#X%=JWV5%H7k=t|SzgCJO%W2pf0>O+~;!mK4w*CM$~a4dzmKyEhN
z^(x*m?j|Vh+htHFx6Iwq4sin`Sp~%%o_%o6vv0h#=-KDCD3NKQk?D^FpA{*@pszO?
zlB69($|j0L;4y7cAPHt>*`&e^*{b0V>~Upv@_c;NZt&rg@vo%5XbB+Hu<<;^ku2|LK7{gn;p_MG06
ztlW^O+z``$VJ?Fw>0qad0=717UCkadd7||BPjB;?v;RKx+9ma9=o?zOLTR?s><+QrMvEGH1vP
z0ie9j%V@H~h69=4TPnmL3=LWsE_7+K6GUd+l_0cso2Va>4jjT9L>i!wjf%aqj_5X$
zCvcSBL5n=hq%a34Zd3BT;$=2vK+E^U3lb#!dm0KAv)+rNRgaQOB}u*|!MCWcrb5l@
zw$h}nX3|zOrki+t%GNTteWCNk^=nl>YF*XZgnk4sUoj%i{}~oQy=H#!ley1-t-Mk-|p+QA{2e*#?yl50X=@xiG-X>exc#rZQ}wm>gzd4o`7y{!nlCMHb+hi}vN3XW04{if
zCnK8<`v4*j1PP`Yg84-WC%m|1n{*`P3&HQ8eMg)6Ngu%=m-GDnL~II(0}{c2CWtZV
z!%=nweEm%tNa}%xa#xr!jUDpAZ=?t^WebIDP54tML2NJECDb5hW)i#x2|h2BI7YN5
z8;SCBul`Vfe@MA)XvFIW`lSecRmvP(4l)-ELjheBq|rM<<_4k>8pQad$2g+L@D4+N
zlu|<8ya`qn4T+DE5NHF;!btxMP&>`BEc>mIVJl~uO;gOKFPZvTW_No+`Tdoxp
zk4U3O#)SBTdlLmu0QwwT7H{rMR6c&Kx?!v>{@|`e^=_5inW*ki8TKS9_o}3rs62eF
zX4%-j_(MAqH9N04%hWZ;>()$_t-V=P%^IgI`DqU78@w%TfEretUo;{N?@F7n1(>vC
zL>lf(=V8mDwRmh}+E-
zW9*sO6L0T9Tl~XU&XQ4XjGcHc;oR`Om75%_W7=v@b5K(|L#^5wD3t93+1Po%CVJ;s
ze|)omqW9tP!Rp4?xMy3!*_M^Q;4Jg3?7C@ZO`3!H2Iw+C4LV>aCZx=`6XZc{UeJb}
wwWzIXs48}J%pYI3eXuId?oc;!S6!xjbw5zk{Xnhm2Po9Alf9_}k_N;70n{{Y5dZ)H
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
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 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
zcmZuz|8Eq>6`z^?c0S*o4cJsPskL>gife;wNEN9OMFH$6?hhmaBTFk+r}fPk*511_
zv%7K5IwS(L9BoWU8b~N#VmAt3Qbj3=0)#^If9$Ve&*z``y%MDF%^ui9U3=cn%$s@h
z=DpAR?A7x5EP*F8aC7FbU4(p(gY}OEgIA#iw?J^hsZT1@p%vz^ison(=a{co49BRL
zj;Y3)Z#fps>wc!v<#eez!_QW_oo>}P{ahvQ3vJjGx&M?ln#&wT!
zV^KR!7pzcgkQx01J<29X^*Nn{#*A5iffD=+2{*aLGmGpvRT`^4yiU`VYUW9pX+Ye!
z5U`>(=u%ghU8YOjVRk0h&}JFD^SpbOEwiPPB%31pl6XSOB?XC8AA0`*CB=`T8oL*eH@bBdQxs?yEx0<&f-fLDMivCap!5YGd
z*~ypRwr?&!ymwleXHdfJ-fe$*JNZ+yedc!Z-ucwnLhr=H#5T-6CcNy1A~w@(19rMojFd3V
zvB^c~dVXvj7A_Yu@ZP6{L_dcUQm>NRU$wtG)w=Xadd}*p#n$I1*2ZwVC-a?jL=7&I8OH@D5c0@luQ`9Bvq^zUBHJA4k}xQ<_%|W8+99WL+Xd
z#OAc;heE~{=BDKFIO_!^$dw2MkBw{x8~fsUvs&99!EOvx3Qqj-f)a$1JV-KP&0BNic2VK4{+3lFG?avDPUg=|_sMPLMFcrt&KHYh;fh$^tK
z1N{%$r@vacd(GazXZK$F!GA8dzkxh{mYg~M{juX}3Fi6ha}V$R9bJFL#@N`uw>xCD
zRf;1KB(0=8+dAm-idP+c(H;ziTRtr0V6mu-NE=R+1rTKf(31!ICbUh^f?*Iq52Q)n
zqH=Qx{?BNfa<-&5$uhl2IFbjsNG|JKN0Mj|yi2EcE}vMva25)zed}w0_3EW#?W^y!
zui@pu^92oRFxm+AL6=tcQ=P=Ki$<*D4c1|HV+XS@m0o*e|DLgtkq)6LX;tm_K{&qQiyr(Y`rYwsHBY$|FJQ>pT-
zFkNJ+{UXjHGUZkj>ZJTLwzk83fOR%t=$1rW#o12yp%k8>Yg~nBQFOfvIv8L4XdY!0
zV!y;15ObhY9o|NR&f?hQ@CG`yfKD}qBht`DfPNK{&{9fMtxwLj&Rt0EG+U>SC3mi_
zy!QpdYHY+#njf{7uLFGok{;YW)BfgU>+ad){s+m;E3KvF)z9Ar0#&pEA0TTy#mzG)
z!N8#B2cQLgAP#}Ae>4%zoNOWQvm*?Ap~{8|6?`k%&~Qv;8aNd_6}aq9-wVQWtunW3
z42_}XKC)263Vjl?vhTu2(&NEej@EV+GfpA>3UrnS@