# 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