Files

163 lines
6.6 KiB
Python
Raw Permalink Normal View History

2026-09-21 19:03:31 +08:00
"""统计分析接口(需求 2.6、2.7.2、4.4),统一挂在 /statistics 前缀下。"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Query
from app.core.config import settings
from app.core.deps import DbSession, ReadAccount
from app.core.response import Resp, ok
from app.dao.student_dao import StudentDao
from app.schema.common import PageResult
from app.schema.statistics_schema import (
AllAboveItem,
ClassAvgDurationItem,
ClassAvgScoreItem,
ClassOverviewItem,
FailDetailItem,
FunnelItem,
OverviewOut,
SalaryTopItem,
StudentDurationItem,
VolatilityItem,
)
from app.schema.student_schema import StudentOut
from app.service.statistics_service import StatisticsService
router = APIRouter(prefix="/statistics", tags=["2.6 统计分析"])
# ==================================================================== 2.6.1
@router.get(
"/students/by-age",
response_model=Resp[PageResult[StudentOut]],
summary="2.6.1 动态年龄范围查询(gt/gte/lt/lte/eq/between)",
)
def students_by_age(
db: DbSession,
_: ReadAccount,
operator: Annotated[str, Query(description="gt / gte / lt / lte / eq / ne / between")] = "gte",
value: Annotated[int | None, Query(ge=1, le=100, description="年龄阈值")] = None,
value2: Annotated[int | None, Query(ge=1, le=100, description="between 时的第二个值")] = None,
class_id: Annotated[int | None, Query()] = None,
gender: Annotated[int | None, Query(ge=1, le=2)] = None,
status: Annotated[int | None, Query(ge=1, le=3)] = None,
advisor_id: Annotated[int | None, Query()] = None,
education: Annotated[str | None, Query()] = None,
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=200)] = 10,
order_by: Annotated[str, Query(description="支持按 age 排序")] = "age",
order: Annotated[str, Query(pattern="^(asc|desc)$")] = "asc",
):
stmt = StatisticsService.age_query(
db, operator, value, value2,
class_id=class_id, gender=gender, status=status, advisor_id=advisor_id,
education=education, order_by=order_by, order=order,
)
items, total, page, pages = StudentDao.paginate(db, stmt, page, page_size)
return ok({
"total": total, "page": page, "page_size": page_size, "pages": pages,
"items": [StudentOut.model_validate(s) for s in items],
})
@router.get("/classes/overview", response_model=Resp[list[ClassOverviewItem]], summary="2.6.1 多维度班级统计(人数 + 性别分布)")
def class_overview(db: DbSession, _: ReadAccount):
return ok(StatisticsService.class_overview(db))
@router.get("/students/age-distribution", summary="年龄分布(图表用)")
def age_distribution(db: DbSession, _: ReadAccount):
from app.dao.statistics_dao import StatisticsDao
return ok(StatisticsDao.age_distribution(db))
# ==================================================================== 2.6.2
@router.get("/scores/all-above", response_model=Resp[list[AllAboveItem]], summary="2.6.2 每次考核都在分数线以上的学生")
def scores_all_above(
db: DbSession,
_: ReadAccount,
threshold: Annotated[float, Query(ge=0, le=1000, description="分数线")] = 80,
class_id: Annotated[int | None, Query()] = None,
):
rows = StatisticsService.all_above(db, threshold, class_id)
return ok(rows, msg=f"{threshold:g} 分以上(每次考核都达标)的学生共 {len(rows)} 人")
@router.get("/scores/failures", response_model=Resp[list[FailDetailItem]], summary="2.6.2 不及格次数达到 N 次的学生及明细")
def score_failures(
db: DbSession,
_: ReadAccount,
threshold: Annotated[float, Query(ge=0, le=1000, description="不及格线")] = 60,
min_times: Annotated[int, Query(ge=1, le=50, description="至少不及格几次")] = 2,
):
return ok(StatisticsService.fail_students(db, threshold, min_times))
@router.get("/scores/class-average", response_model=Resp[list[ClassAvgScoreItem]], summary="2.6.2 每场考核每个班级的平均分(可动态排序)")
def class_average(
db: DbSession,
_: ReadAccount,
exam_seq: Annotated[int | None, Query(ge=1, description="只看某一场;不传=全部场次")] = None,
class_id: Annotated[int | None, Query()] = None,
order: Annotated[str, Query(pattern="^(asc|desc)$", description="按平均分升降序")] = "desc",
pass_line: Annotated[float | None, Query(ge=0, le=1000, description="及格线,用于算及格率")] = None,
):
return ok(
StatisticsService.class_exam_avg(
db, exam_seq, class_id, order, pass_line if pass_line is not None else settings.SCORE_PASS_LINE
)
)
# ==================================================================== 2.6.3
@router.get("/employment/top-salary", response_model=Resp[list[SalaryTopItem]], summary="2.6.3 就业薪资 Top N")
def top_salary(
db: DbSession,
_: ReadAccount,
top_n: Annotated[int, Query(ge=1, le=100, description="取前几名")] = 5,
):
return ok(StatisticsService.salary_top(db, top_n))
@router.get("/employment/durations", response_model=Resp[list[StudentDurationItem]], summary="2.6.3 每个学生的就业时长")
def student_durations(
db: DbSession,
_: ReadAccount,
class_id: Annotated[int | None, Query()] = None,
):
return ok(StatisticsService.student_durations(db, class_id))
@router.get("/employment/class-avg-duration", response_model=Resp[list[ClassAvgDurationItem]], summary="2.6.3 每个班级的平均就业时长")
def class_avg_duration(db: DbSession, _: ReadAccount):
return ok(StatisticsService.class_avg_duration(db))
# ==================================================================== 2.7.2
@router.get("/scores/volatility", response_model=Resp[list[VolatilityItem]], summary="2.7.2 成绩波动最大的 Top N(标准差 / 最大分差)")
def score_volatility(
db: DbSession,
_: ReadAccount,
top_n: Annotated[int, Query(ge=1, le=50)] = 5,
metric: Annotated[str, Query(description="stddev=标准差,range=最大分差")] = "stddev",
):
return ok(StatisticsService.score_volatility(db, top_n, metric))
@router.get("/employment/funnel", response_model=Resp[list[FunnelItem]], summary="2.7.2 班级就业漏斗(按就业率降序)")
def employment_funnel(
db: DbSession,
_: ReadAccount,
high_line: Annotated[float | None, Query(ge=0, description="高薪线,默认取配置值")] = None,
):
return ok(StatisticsService.employment_funnel(db, high_line))
@router.get("/overview", response_model=Resp[OverviewOut], summary="仪表盘总览")
def overview(db: DbSession, _: ReadAccount):
return ok(StatisticsService.overview(db))