diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..983ca84 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# 版本控制 / IDE +.git +.gitignore +.idea +.vscode + +# 本地虚拟环境(几十 MB,必须排除;镜像里自己装依赖) +.venv +venv +env + +# 本机真实配置:不进镜像,容器里用 .env.docker + compose 的 environment 覆盖 +.env + +# Python 缓存 +__pycache__ +*.py[cod] +*.egg-info +.pytest_cache +.mypy_cache +.ruff_cache + +# 本地数据库文件 / 日志 +*.db +*.sqlite3 +*.log + +# 走查脚本与截图(不是运行时依赖;需要时可在容器里手动跑) +verify +tests +走查与改进记录.md +start.bat + +# Docker 自身产物 +docker-compose.yml +Dockerfile +.dockerignore diff --git a/.env b/.env new file mode 100644 index 0000000..9d0a26f --- /dev/null +++ b/.env @@ -0,0 +1,23 @@ +# 复制为 .env 后按本机情况修改 +APP_NAME=沃林学生管理系统 +DEBUG=true + +# ---- MySQL(默认)---- +DB_DRIVER=mysql+pymysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=123456 +DB_NAME=wolin_sms + +# ---- 或者:切换成 SQLite 零配置跑起来(把 DB_DRIVER 改成 sqlite 即可)---- +# DB_DRIVER=sqlite +# SQLITE_PATH=./wolin_sms.db + +# ---- JWT ---- +JWT_SECRET=wolin-sms-dev-secret-change-me +JWT_EXPIRE_MINUTES=720 + +# ---- 初始管理员(仅初始化数据库时使用)---- +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 diff --git a/.env.docker b/.env.docker new file mode 100644 index 0000000..a66ff51 --- /dev/null +++ b/.env.docker @@ -0,0 +1,35 @@ +# 容器内配置:由 docker-compose 的 environment 注入,这里只做兜底 +APP_NAME=沃林学生管理系统 +DEBUG=true + +DB_DRIVER=mysql+pymysql +DB_HOST=mysql +DB_PORT=3306 +DB_USER=wolin +DB_PASSWORD=wolin123456 +DB_NAME=wolin_sms + +DB_ECHO=false +DB_POOL_SIZE=10 +DB_POOL_RECYCLE=3600 + +JWT_SECRET=wolin-sms-docker-secret-change-me +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=720 +AUTH_ENABLED=true + +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +SCORE_PASS_LINE=60 +SCORE_WARN_LINE=60 +HIGH_SALARY_LINE=10000 +STU_NO_PREFIX=WL + +# ---- 容器启动行为(docker/entrypoint.sh 读取)---- +# 是否在启动时建表 + 建初始账号 +INIT_DB=true +# 是否灌演示数据(顾问/老师/班级/学生/成绩/就业) +SEED_DATA=true +# 等待 MySQL 就绪的最长秒数 +DB_WAIT_TIMEOUT=120 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9d0a26f --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# 复制为 .env 后按本机情况修改 +APP_NAME=沃林学生管理系统 +DEBUG=true + +# ---- MySQL(默认)---- +DB_DRIVER=mysql+pymysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=123456 +DB_NAME=wolin_sms + +# ---- 或者:切换成 SQLite 零配置跑起来(把 DB_DRIVER 改成 sqlite 即可)---- +# DB_DRIVER=sqlite +# SQLITE_PATH=./wolin_sms.db + +# ---- JWT ---- +JWT_SECRET=wolin-sms-dev-secret-change-me +JWT_EXPIRE_MINUTES=720 + +# ---- 初始管理员(仅初始化数据库时使用)---- +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5b0e8fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# 沃林学生管理系统 —— 生产/演示用镜像 +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + TZ=Asia/Shanghai + +WORKDIR /app + +# 时区 + 编译 pymysql/cryptography 所需的构建工具 +RUN sed -i 's/deb.debian.org/mirrors.tencent.com/g; s/security.debian.org/mirrors.tencent.com/g' \ + /etc/apt/sources.list.d/debian.sources 2>/dev/null || true \ + && apt-get update \ + && apt-get install -y --no-install-recommends tzdata curl \ + && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ + && echo $TZ > /etc/timezone \ + && rm -rf /var/lib/apt/lists/* + +# 先装依赖,充分利用镜像层缓存 +COPY requirements.txt . +RUN pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple \ + && pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 再拷代码 +COPY . . + +RUN chmod +x /app/docker/entrypoint.sh + +EXPOSE 8000 + +# 用 shell 形式,避免 Windows 换行导致 exec 失败 +ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..2e3be15 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,12 @@ +"""沃林学生管理系统(Wolin Student Management System)。 + +分层约定: + api -> 只做参数校验、权限、调用 service/dao、拼装响应 + service -> 业务规则(状态流转、事务、预警、规则引擎) + dao -> 数据访问(SQLAlchemy 2.0 表达式查询) + model -> ORM 模型 + schema -> Pydantic v2 出入参模型 + core -> 配置、数据库、鉴权、异常、统一响应 +""" + +__version__ = "1.0.0" diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..53e25f5 Binary files /dev/null and b/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..ddd1f2c Binary files /dev/null and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..d0beca7 Binary files /dev/null and b/app/__pycache__/main.cpython-311.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..c32c663 Binary files /dev/null and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..1418e44 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,31 @@ +"""把所有模块的路由拼成一个总路由。""" + +from fastapi import APIRouter + +from app.api import ( + advisor_api, + advanced_api, + auth_api, + clazz_api, + employment_api, + score_api, + statistics_api, + student_api, + teacher_api, +) + +api_router = APIRouter() + +# 顺序会影响 Swagger 里的展示顺序,按需求文档的模块顺序排 +api_router.include_router(auth_api.router) +api_router.include_router(student_api.router) +api_router.include_router(score_api.router) +api_router.include_router(employment_api.router) +api_router.include_router(clazz_api.router) +api_router.include_router(teacher_api.router) +api_router.include_router(advisor_api.router) +api_router.include_router(statistics_api.router) +api_router.include_router(advanced_api.router) +api_router.include_router(employment_api.legacy_router) + +__all__ = ["api_router"] diff --git a/app/api/__pycache__/__init__.cpython-311.pyc b/app/api/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..5a57042 Binary files /dev/null and b/app/api/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/api/__pycache__/__init__.cpython-313.pyc b/app/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..9e381dd Binary files /dev/null and b/app/api/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/api/__pycache__/advanced_api.cpython-311.pyc b/app/api/__pycache__/advanced_api.cpython-311.pyc new file mode 100644 index 0000000..7b4131d Binary files /dev/null and b/app/api/__pycache__/advanced_api.cpython-311.pyc differ diff --git a/app/api/__pycache__/advanced_api.cpython-313.pyc b/app/api/__pycache__/advanced_api.cpython-313.pyc new file mode 100644 index 0000000..ae4350b Binary files /dev/null and b/app/api/__pycache__/advanced_api.cpython-313.pyc differ diff --git a/app/api/__pycache__/advisor_api.cpython-311.pyc b/app/api/__pycache__/advisor_api.cpython-311.pyc new file mode 100644 index 0000000..fb99ad6 Binary files /dev/null and b/app/api/__pycache__/advisor_api.cpython-311.pyc differ diff --git a/app/api/__pycache__/advisor_api.cpython-313.pyc b/app/api/__pycache__/advisor_api.cpython-313.pyc new file mode 100644 index 0000000..43da74a Binary files /dev/null and b/app/api/__pycache__/advisor_api.cpython-313.pyc differ diff --git a/app/api/__pycache__/auth_api.cpython-311.pyc b/app/api/__pycache__/auth_api.cpython-311.pyc new file mode 100644 index 0000000..54b17ca Binary files /dev/null and b/app/api/__pycache__/auth_api.cpython-311.pyc differ diff --git a/app/api/__pycache__/auth_api.cpython-313.pyc b/app/api/__pycache__/auth_api.cpython-313.pyc new file mode 100644 index 0000000..fb9d583 Binary files /dev/null and b/app/api/__pycache__/auth_api.cpython-313.pyc differ diff --git a/app/api/__pycache__/clazz_api.cpython-311.pyc b/app/api/__pycache__/clazz_api.cpython-311.pyc new file mode 100644 index 0000000..e98d2b0 Binary files /dev/null and b/app/api/__pycache__/clazz_api.cpython-311.pyc differ diff --git a/app/api/__pycache__/clazz_api.cpython-313.pyc b/app/api/__pycache__/clazz_api.cpython-313.pyc new file mode 100644 index 0000000..eb1a4fc Binary files /dev/null and b/app/api/__pycache__/clazz_api.cpython-313.pyc differ diff --git a/app/api/__pycache__/employment_api.cpython-311.pyc b/app/api/__pycache__/employment_api.cpython-311.pyc new file mode 100644 index 0000000..75657d2 Binary files /dev/null and b/app/api/__pycache__/employment_api.cpython-311.pyc differ diff --git a/app/api/__pycache__/employment_api.cpython-313.pyc b/app/api/__pycache__/employment_api.cpython-313.pyc new file mode 100644 index 0000000..d2785a0 Binary files /dev/null and b/app/api/__pycache__/employment_api.cpython-313.pyc differ diff --git a/app/api/__pycache__/score_api.cpython-311.pyc b/app/api/__pycache__/score_api.cpython-311.pyc new file mode 100644 index 0000000..4934f5e Binary files /dev/null and b/app/api/__pycache__/score_api.cpython-311.pyc differ diff --git a/app/api/__pycache__/score_api.cpython-313.pyc b/app/api/__pycache__/score_api.cpython-313.pyc new file mode 100644 index 0000000..cf8023b Binary files /dev/null and b/app/api/__pycache__/score_api.cpython-313.pyc differ diff --git a/app/api/__pycache__/statistics_api.cpython-311.pyc b/app/api/__pycache__/statistics_api.cpython-311.pyc new file mode 100644 index 0000000..946a282 Binary files /dev/null and b/app/api/__pycache__/statistics_api.cpython-311.pyc differ diff --git a/app/api/__pycache__/statistics_api.cpython-313.pyc b/app/api/__pycache__/statistics_api.cpython-313.pyc new file mode 100644 index 0000000..bf5b341 Binary files /dev/null and b/app/api/__pycache__/statistics_api.cpython-313.pyc differ diff --git a/app/api/__pycache__/student_api.cpython-311.pyc b/app/api/__pycache__/student_api.cpython-311.pyc new file mode 100644 index 0000000..62dcc18 Binary files /dev/null and b/app/api/__pycache__/student_api.cpython-311.pyc differ diff --git a/app/api/__pycache__/student_api.cpython-313.pyc b/app/api/__pycache__/student_api.cpython-313.pyc new file mode 100644 index 0000000..44d807c Binary files /dev/null and b/app/api/__pycache__/student_api.cpython-313.pyc differ diff --git a/app/api/__pycache__/teacher_api.cpython-311.pyc b/app/api/__pycache__/teacher_api.cpython-311.pyc new file mode 100644 index 0000000..eb9394b Binary files /dev/null and b/app/api/__pycache__/teacher_api.cpython-311.pyc differ diff --git a/app/api/__pycache__/teacher_api.cpython-313.pyc b/app/api/__pycache__/teacher_api.cpython-313.pyc new file mode 100644 index 0000000..23c9631 Binary files /dev/null and b/app/api/__pycache__/teacher_api.cpython-313.pyc differ diff --git a/app/api/advanced_api.py b/app/api/advanced_api.py new file mode 100644 index 0000000..c1d88ca --- /dev/null +++ b/app/api/advanced_api.py @@ -0,0 +1,101 @@ +"""通用高级筛选与聚合接口(需求 2.7.1 / 2.7.2)。""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.deps import DbSession, ReadAccount +from app.core.response import Resp, ok +from app.schema.advanced_schema import AggregationParams, AggregationResult, QueryRequest +from app.service.advanced_service import AdvancedQueryService + +router = APIRouter(prefix="/advanced", tags=["2.7 高级筛选与聚合"]) + + +@router.get("/meta", summary="可选模型与字段白名单(前端规则构建器用)") +def meta(_: ReadAccount): + return ok(AdvancedQueryService.meta()) + + +@router.post("/query", summary="2.7.1 通用高级筛选(支持嵌套 AND/OR、8 种操作符)") +def advanced_query(db: DbSession, _: ReadAccount, payload: QueryRequest): + result = AdvancedQueryService.execute(db, payload) + return ok(result, msg=f"命中 {result['total']} 条") + + +@router.post( + "/aggregate", + response_model=Resp[AggregationResult], + summary="2.7.2 通用分组聚合(count/avg/sum/max/min,可带 having)", +) +def advanced_aggregate(db: DbSession, _: ReadAccount, payload: AggregationParams): + return ok(AdvancedQueryService.aggregate(db, payload)) + + +@router.get("/examples", summary="规则示例(直接复制改改就能用)") +def examples(_: ReadAccount): + return ok( + [ + { + "title": "年龄大于 25 且(薪资 >= 15000 或 班级名含 Java)—— 需求 2.7.1 的原始示例", + "body": { + "model": "student", + "rules": [ + {"field": "age", "operator": ">", "value": 25}, + {"field": "gender", "operator": "=", "value": "男"}, + { + "logic": "OR", + "sub_rules": [ + {"field": "salary", "operator": ">=", "value": 15000}, + {"field": "class_name", "operator": "like", "value": "Java"}, + ], + }, + ], + "order_by": "salary", + "order": "desc", + "page": 1, + "page_size": 10, + }, + }, + { + "title": "2026 年入学的、30 岁以下、学历在大专/本科之间的学生", + "body": { + "model": "student", + "rules": [ + {"field": "age", "operator": "between", "value": [18, 30]}, + {"field": "enroll_date", "operator": ">=", "value": "2026-01-01"}, + {"field": "education", "operator": "in", "value": ["大专", "本科"]}, + ], + "fields": ["stu_no", "name", "age", "education", "class_name", "enroll_date"], + }, + }, + { + "title": "成绩在 60-70 分之间的成绩记录(含学生与班级)", + "body": { + "model": "score", + "rules": [ + {"field": "score", "operator": "between", "value": [60, 70]}, + {"field": "exam_seq", "operator": "<=", "value": 3}, + ], + "order_by": "score", + "order": "asc", + }, + }, + { + "title": "聚合:按班级算平均薪资、最高薪资,只看平均薪资 > 10000 的班", + "body": { + "model": "employment", + "group_by": ["class_name"], + "metrics": [ + {"func": "count", "alias": "人数"}, + {"func": "avg", "field": "salary", "alias": "平均薪资"}, + {"func": "max", "field": "salary", "alias": "最高薪资"}, + ], + "having": [{"field": "平均薪资", "operator": ">", "value": 10000}], + "order_by": "平均薪资", + "order": "desc", + "limit": 20, + }, + }, + ] + ) diff --git a/app/api/advisor_api.py b/app/api/advisor_api.py new file mode 100644 index 0000000..065d62d --- /dev/null +++ b/app/api/advisor_api.py @@ -0,0 +1,95 @@ +"""顾问管理接口(需求第 5 节扩展模块)。""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Query + +from app.core.deps import DbSession, ReadAccount, WriteAccount +from app.core.exceptions import BusinessError, ConflictError +from app.core.response import Resp, ok +from app.dao.advisor_dao import AdvisorDao +from app.model import Advisor +from app.schema.advisor_schema import AdvisorCreate, AdvisorOut, AdvisorUpdate +from app.schema.common import PageResult + +router = APIRouter(prefix="/advisors", tags=["2.8 顾问管理(扩展)"]) + + +def _to_out(advisor, count: int | None = None) -> AdvisorOut: + out = AdvisorOut.model_validate(advisor) + if count is not None: + out.student_count = count + return out + + +@router.get("", response_model=Resp[PageResult[AdvisorOut]], summary="顾问列表") +def list_advisors( + db: DbSession, + _: ReadAccount, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=200)] = 10, + keyword: Annotated[str | None, Query(description="姓名/编号/电话")] = None, + dept: Annotated[str | None, Query()] = None, + gender: Annotated[int | None, Query(ge=1, le=2)] = None, + order_by: Annotated[str, Query()] = "id", + order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", +): + stmt = AdvisorDao.build_stmt(keyword=keyword, dept=dept, gender=gender, order_by=order_by, order=order) + items, total, page, pages = AdvisorDao.paginate(db, stmt, page, page_size) + counts = AdvisorDao.student_count_map(db) + return ok({ + "total": total, "page": page, "page_size": page_size, "pages": pages, + "items": [_to_out(a, counts.get(a.id, 0)) for a in items], + }) + + +@router.post("", response_model=Resp[AdvisorOut], summary="新增顾问") +def create_advisor(db: DbSession, _: WriteAccount, payload: AdvisorCreate): + advisor_no = (payload.advisor_no or "").strip() or AdvisorDao.next_advisor_no(db) + if AdvisorDao.first_by(db, advisor_no=advisor_no): + raise ConflictError(f"顾问编号 {advisor_no} 已存在") + advisor = Advisor( + advisor_no=advisor_no, + name=payload.name, + gender=payload.gender, + phone=payload.phone, + email=payload.email, + dept=payload.dept, + remark=payload.remark, + ) + db.add(advisor) + db.commit() + db.refresh(advisor) + return ok(_to_out(advisor, 0), msg=f"顾问 {advisor.name} 已创建,编号 {advisor.advisor_no}") + + +@router.get("/{advisor_id}", response_model=Resp[AdvisorOut], summary="顾问详情") +def get_advisor(db: DbSession, _: ReadAccount, advisor_id: int): + advisor = AdvisorDao.get_or_404(db, advisor_id, "顾问") + counts = AdvisorDao.student_count_map(db) + return ok(_to_out(advisor, counts.get(advisor.id, 0))) + + +@router.put("/{advisor_id}", response_model=Resp[AdvisorOut], summary="更新顾问") +def update_advisor(db: DbSession, _: WriteAccount, advisor_id: int, payload: AdvisorUpdate): + advisor = AdvisorDao.get_or_404(db, advisor_id, "顾问") + for key, value in payload.model_dump(exclude_unset=True).items(): + if value is not None: + setattr(advisor, key, value) + db.commit() + db.refresh(advisor) + counts = AdvisorDao.student_count_map(db) + return ok(_to_out(advisor, counts.get(advisor.id, 0)), msg="更新成功") + + +@router.delete("/{advisor_id}", summary="逻辑删除顾问(名下还有学生时拒绝)") +def delete_advisor(db: DbSession, _: WriteAccount, advisor_id: int): + advisor = AdvisorDao.get_or_404(db, advisor_id, "顾问") + count = AdvisorDao.student_count_map(db).get(advisor_id, 0) + if count: + raise BusinessError(f"顾问「{advisor.name}」名下还有 {count} 名学生,请先转交后再删除") + advisor.soft_delete() + db.commit() + return ok(msg=f"已删除顾问 {advisor.name}") diff --git a/app/api/auth_api.py b/app/api/auth_api.py new file mode 100644 index 0000000..28f0a26 --- /dev/null +++ b/app/api/auth_api.py @@ -0,0 +1,58 @@ +"""认证接口。""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.deps import AdminAccount, CurrentAccount, DbSession +from app.core.response import Resp, ok +from app.dao.account_dao import AccountDao +from app.schema.auth_schema import AccountCreate, AccountOut, LoginParams, PasswordChange, TokenOut +from app.service.auth_service import AuthService + +router = APIRouter(prefix="/auth", tags=["2.9 登录认证"]) + + +@router.post("/login", response_model=Resp[TokenOut], summary="登录获取 token") +def login(db: DbSession, payload: LoginParams): + account, token, expires_in = AuthService.login(db, payload.username, payload.password) + db.commit() + return ok( + { + "access_token": token, + "token_type": "Bearer", + "expires_in": expires_in, + "account": AccountOut.model_validate(account), + }, + msg=f"欢迎回来,{account.real_name}", + ) + + +@router.get("/me", response_model=Resp[AccountOut], summary="当前登录用户") +def me(account: CurrentAccount): + return ok(AccountOut.model_validate(account)) + + +@router.post("/password", summary="修改自己的密码") +def change_password(db: DbSession, account: CurrentAccount, payload: PasswordChange): + AuthService.change_password(db, account, payload) + db.commit() + return ok(msg="密码修改成功,请重新登录") + + +@router.get("/accounts", summary="账号列表(管理员)") +def list_accounts(db: DbSession, _: AdminAccount): + from sqlalchemy import select + + from app.model import Account + + accounts = db.scalars(select(Account).where(Account.alive()).order_by(Account.id.asc())).unique().all() + return ok([AccountOut.model_validate(a) for a in accounts]) + + +@router.post("/accounts", response_model=Resp[AccountOut], summary="新增账号(管理员)") +def create_account(db: DbSession, _: AdminAccount, payload: AccountCreate): + account = AuthService.create_account(db, payload) + db.commit() + db.refresh(account) + return ok(AccountOut.model_validate(account), msg=f"账号 {account.username} 创建成功") diff --git a/app/api/clazz_api.py b/app/api/clazz_api.py new file mode 100644 index 0000000..1c679ec --- /dev/null +++ b/app/api/clazz_api.py @@ -0,0 +1,174 @@ +"""班级管理接口(需求 2.4)。""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Query + +from app.core.deps import DbSession, ReadAccount, WriteAccount +from app.core.exceptions import BusinessError, ConflictError, NotFoundError +from app.core.response import Resp, ok +from app.core.utils import parse_date +from app.dao.advisor_dao import AdvisorDao +from app.dao.clazz_dao import ClazzDao +from app.dao.teacher_dao import TeacherDao +from app.model import Clazz +from app.schema.clazz_schema import ClazzCreate, ClazzOut, ClazzUpdate +from app.schema.common import PageResult + +router = APIRouter(prefix="/classes", tags=["2.4 班级管理"]) + + +def _to_out(clazz: Clazz, student_count: int | None = None) -> ClazzOut: + out = ClazzOut.model_validate(clazz) + if student_count is not None: + out.student_count = student_count + return out + + +@router.get("", response_model=Resp[PageResult[ClazzOut]], summary="班级列表") +def list_classes( + db: DbSession, + _: ReadAccount, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=200)] = 10, + keyword: Annotated[str | None, Query(description="班级名称/编号/方向")] = None, + status: Annotated[int | None, Query(ge=1, le=3, description="1=在读 2=已结课 3=已解散")] = None, + advisor_id: Annotated[int | None, Query(description="带班顾问")] = None, + head_teacher_id: Annotated[int | None, Query(description="班主任")] = None, + teacher_id: Annotated[int | None, Query(description="授课老师")] = None, + order_by: Annotated[str, Query()] = "id", + order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", +): + stmt = ClazzDao.build_stmt( + keyword=keyword, status=status, advisor_id=advisor_id, + head_teacher_id=head_teacher_id, teacher_id=teacher_id, order_by=order_by, order=order, + ) + items, total, page, pages = ClazzDao.paginate(db, stmt, page, page_size) + counts = ClazzDao.student_counts(db) + return ok({ + "total": total, "page": page, "page_size": page_size, "pages": pages, + "items": [_to_out(c, counts.get(c.id, 0)) for c in items], + }) + + +@router.get("/meta/options", summary="班级下拉选项(含老师)") +def class_options(db: DbSession, _: ReadAccount): + classes = ClazzDao.all(db, ClazzDao.build_stmt(order_by="class_no", order="asc")) + teachers = TeacherDao.all(db, TeacherDao.build_stmt(order_by="id", order="asc")) + advisors = AdvisorDao.all(db, AdvisorDao.build_stmt(order_by="id", order="asc")) + counts = ClazzDao.student_counts(db) + return ok({ + "classes": [ + {"id": c.id, "name": c.name, "class_no": c.class_no, "student_count": counts.get(c.id, 0)} + for c in classes + ], + "teachers": [{"id": t.id, "name": t.name, "subject": t.subject} for t in teachers], + "advisors": [{"id": a.id, "name": a.name} for a in advisors], + }) + + +@router.post("", response_model=Resp[ClazzOut], summary="创建班级") +def create_class(db: DbSession, _: WriteAccount, payload: ClazzCreate): + class_no = (payload.class_no or "").strip() + if not class_no: + prefix = _direction_prefix(payload.direction or payload.name) + from datetime import date + + class_no = ClazzDao.next_class_no(db, prefix, (parse_date(payload.open_date, "开课时间") or date.today()).year) + if ClazzDao.get_by_class_no(db, class_no, with_deleted=True): + raise ConflictError(f"班级编号 {class_no} 已存在") + + _check_refs(db, payload.head_teacher_id, payload.advisor_id, payload.teacher_ids) + + klass = Clazz( + class_no=class_no, + name=payload.name, + direction=payload.direction, + open_date=parse_date(payload.open_date, "开课时间"), + close_date=parse_date(payload.close_date, "结课时间"), + classroom=payload.classroom, + capacity=payload.capacity, + status=payload.status, + head_teacher_id=payload.head_teacher_id, + advisor_id=payload.advisor_id, + description=payload.description, + ) + if payload.teacher_ids: + all_teachers = {t.id: t for t in TeacherDao.all(db, TeacherDao.build_stmt())} + missing = [tid for tid in payload.teacher_ids if tid not in all_teachers] + if missing: + raise NotFoundError(f"老师不存在:{missing}") + klass.teachers = [all_teachers[tid] for tid in payload.teacher_ids] + db.add(klass) + db.flush() + db.commit() + db.refresh(klass) + return ok(_to_out(klass, 0), msg=f"班级 {klass.name} 创建成功,编号 {klass.class_no}") + + +@router.get("/{class_id}", response_model=Resp[ClazzOut], summary="班级详情") +def get_class(db: DbSession, _: ReadAccount, class_id: int): + klass = ClazzDao.get_or_404(db, class_id, "班级") + counts = ClazzDao.student_counts(db) + return ok(_to_out(klass, counts.get(klass.id, 0))) + + +@router.put("/{class_id}", response_model=Resp[ClazzOut], summary="更新班级") +def update_class(db: DbSession, _: WriteAccount, class_id: int, payload: ClazzUpdate): + klass = ClazzDao.get_or_404(db, class_id, "班级") + data = payload.model_dump(exclude_unset=True) + _check_refs(db, data.get("head_teacher_id"), data.get("advisor_id"), data.get("teacher_ids")) + + for field in ("open_date", "close_date"): + if field in data: + setattr(klass, field, parse_date(data.pop(field), field)) + teacher_ids = data.pop("teacher_ids", None) + + for key, value in data.items(): + if value is not None and hasattr(klass, key): + setattr(klass, key, value) + + if teacher_ids is not None: + all_teachers = {t.id: t for t in TeacherDao.all(db, TeacherDao.build_stmt())} + missing = [tid for tid in teacher_ids if tid not in all_teachers] + if missing: + raise NotFoundError(f"老师不存在:{missing}") + klass.teachers = [all_teachers[tid] for tid in teacher_ids] + + db.commit() + db.refresh(klass) + counts = ClazzDao.student_counts(db) + return ok(_to_out(klass, counts.get(klass.id, 0)), msg="更新成功") + + +@router.delete("/{class_id}", summary="逻辑删除班级(班内还有学生时拒绝)") +def delete_class(db: DbSession, _: WriteAccount, class_id: int): + klass = ClazzDao.get_or_404(db, class_id, "班级") + counts = ClazzDao.student_counts(db) + if counts.get(class_id, 0) > 0: + # 直接删会让这些学生"无班可归",统计里凭空少人,所以这里拦一道 + raise BusinessError( + f"班级「{klass.name}」下还有 {counts[class_id]} 名学生,请先转班或删除学生后再删班级" + ) + klass.soft_delete() + db.commit() + return ok(msg=f"已删除班级 {klass.name}") + + +def _direction_prefix(text: str) -> str: + letters = "".join(ch for ch in text if ch.isascii() and ch.isalpha()).upper() + return letters[:6] or "CLS" + + +def _check_refs(db, head_teacher_id, advisor_id, teacher_ids) -> None: + if head_teacher_id is not None and TeacherDao.get(db, head_teacher_id) is None: + raise NotFoundError(f"班主任(老师 id={head_teacher_id})不存在") + if advisor_id is not None and AdvisorDao.get(db, advisor_id) is None: + raise NotFoundError(f"顾问(id={advisor_id})不存在") + if teacher_ids: + exist = {t.id for t in TeacherDao.all(db, TeacherDao.build_stmt())} + missing = [tid for tid in teacher_ids if tid not in exist] + if missing: + raise NotFoundError(f"老师不存在:{missing}") diff --git a/app/api/employment_api.py b/app/api/employment_api.py new file mode 100644 index 0000000..e9efe64 --- /dev/null +++ b/app/api/employment_api.py @@ -0,0 +1,186 @@ +"""就业管理接口(需求 2.3、4.3)。 + +需求里给的示例路径是 /employment/students/{id}、/employment/class/{id}, +这里在同一套语义下改成标准 REST 写法(资源在复数、动作用方法表达), +同时对老路径做了兼容别名,见文件末尾。 +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Query + +from app.core.deps import DbSession, ReadAccount, WriteAccount +from app.core.exceptions import BusinessError +from app.core.response import Resp, ok +from app.dao.employment_dao import EmploymentDao +from app.dao.student_dao import StudentDao +from app.schema.common import PageResult +from app.schema.employment_schema import ( + EmploymentCreate, + EmploymentOut, + EmploymentRegisterResult, + EmploymentUpdate, +) +from app.service.employment_service import EmploymentService + +router = APIRouter(prefix="/employments", tags=["2.3 学生就业管理"]) +legacy_router = APIRouter(prefix="/employment", tags=["2.3 学生就业管理(需求示例路径)"], include_in_schema=False) + + +@router.get("", response_model=Resp[PageResult[EmploymentOut]], summary="就业信息列表(按编号/公司/薪资区间多条件查询)") +def list_employments( + db: DbSession, + _: ReadAccount, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=200)] = 10, + stu_id: Annotated[int | None, Query(description="学生ID")] = None, + class_id: Annotated[int | None, Query(description="班级ID")] = None, + company: Annotated[str | None, Query(description="公司名称,模糊匹配")] = None, + salary_min: Annotated[float | None, Query(ge=0, description="薪资下限")] = None, + salary_max: Annotated[float | None, Query(ge=0, description="薪资上限")] = None, + has_offer: Annotated[bool | None, Query(description="是否已下发 offer")] = None, + has_open: Annotated[bool | None, Query(description="是否已开放就业")] = None, + keyword: Annotated[str | None, Query(description="姓名/学号/公司/城市")] = None, + order_by: Annotated[str, Query(description="id/salary/open_date/offer_date/company/student_name")] = "id", + order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", +): + if salary_min is not None and salary_max is not None and salary_min > salary_max: + salary_min, salary_max = salary_max, salary_min # 顺序写反了自动纠正 + + stmt = EmploymentDao.build_stmt( + stu_id=stu_id, class_id=class_id, company=company, salary_min=salary_min, + salary_max=salary_max, has_offer=has_offer, has_open=has_open, + keyword=keyword, order_by=order_by, order=order, + ) + items, total, page, pages = EmploymentDao.paginate(db, stmt, page, page_size) + return ok({ + "total": total, "page": page, "page_size": page_size, "pages": pages, + "items": [EmploymentOut.model_validate(e) for e in items], + }) + + +def _status_msg(action: str, changed: bool, before: str | None, after: str | None) -> str: + if not changed: + return action + return f"{action},学生状态:{before} → {after}" + + +@router.post("", response_model=Resp[EmploymentRegisterResult], summary="登记就业信息(事务内同步学生状态)") +def register_employment(db: DbSession, _: WriteAccount, payload: EmploymentCreate): + employment, created, changed, before, after = EmploymentService.register(db, payload) + db.commit() + db.refresh(employment) + msg = _status_msg("就业信息登记成功" if created else "就业信息已更新", changed, before, after) + return ok( + { + "employment": EmploymentOut.model_validate(employment), + "created": created, + "status_changed": changed, + "from_status_text": before, + "to_status_text": after, + "msg": msg, + }, + msg=msg, + ) + + +@router.get("/student/{stu_id}", response_model=Resp[EmploymentOut], summary="查询某个学生的就业信息") +def employment_of_student(db: DbSession, _: ReadAccount, stu_id: int): + StudentDao.get_or_404(db, stu_id, "学生") + employment = EmploymentDao.get_by_stu_id(db, stu_id) + if employment is None: + return Resp.fail("该学生还没有登记就业信息", 404) + return ok(EmploymentOut.model_validate(employment)) + + +@router.get("/class/{class_id}", summary="查询某个班级的就业情况(含班级就业率)") +def employment_of_class(db: DbSession, _: ReadAccount, class_id: int): + from app.dao.clazz_dao import ClazzDao + + klass = ClazzDao.get_or_404(db, class_id, "班级") + stmt = EmploymentDao.build_stmt(class_id=class_id, order_by="salary", order="desc") + items = EmploymentDao.all(db, stmt) + students_total = len([s for s in StudentDao.all(db, StudentDao.build_stmt(class_id=class_id, order_by="id", order="asc"))]) + offered = [e for e in items if e.offer_date] + salaries = [float(e.salary) for e in items if e.salary is not None] + return ok({ + "class_id": klass.id, + "class_name": klass.name, + "student_total": students_total, + "open_count": len([e for e in items if e.open_date]), + "offer_count": len(offered), + "employment_rate": round(len(offered) / students_total * 100, 1) if students_total else 0.0, + "avg_salary": round(sum(salaries) / len(salaries), 2) if salaries else None, + "items": [EmploymentOut.model_validate(e) for e in items], + }) + + +@router.put("/{employment_id}", response_model=Resp[EmploymentRegisterResult], summary="修改就业信息") +def update_employment(db: DbSession, _: WriteAccount, employment_id: int, payload: EmploymentUpdate): + employment = EmploymentDao.get_or_404(db, employment_id, "就业记录") + employment, changed, before, after = EmploymentService.update(db, employment, payload) + db.commit() + db.refresh(employment) + msg = _status_msg("修改成功", changed, before, after) + return ok( + { + "employment": EmploymentOut.model_validate(employment), + "created": False, + "status_changed": changed, + "from_status_text": before, + "to_status_text": after, + "msg": msg, + }, + msg=msg, + ) + + +@router.delete("/{employment_id}", summary="删除就业信息(学生状态回退到在读)") +def delete_employment(db: DbSession, _: WriteAccount, employment_id: int): + employment = EmploymentDao.get_or_404(db, employment_id, "就业记录") + changed, before, after = EmploymentService.delete(db, employment) + db.commit() + msg = _status_msg("已删除就业记录", changed, before, after) + return ok( + {"status_changed": changed, "from_status_text": before, "to_status_text": after, "msg": msg}, + msg=msg, + ) + + +# ----------------------------------------------------------------- 兼容老路径 +@legacy_router.get("/students/{stu_id}", summary="[兼容] 获取学生就业信息") +def legacy_get_student_employment(db: DbSession, account: ReadAccount, stu_id: int): + return employment_of_student(db, account, stu_id) + + +@legacy_router.post("/students/{stu_id}", summary="[兼容] 添加/更新就业信息") +def legacy_add_student_employment(db: DbSession, account: WriteAccount, stu_id: int, payload: EmploymentUpdate): + body = payload.model_dump(exclude_unset=True) + body["stu_id"] = stu_id + try: + create_payload = EmploymentCreate(**body) + except Exception as exc: # noqa: BLE001 + raise BusinessError( + "登记就业至少需要 就业开放时间 / offer 时间 / 公司 / 薪资 中的一项" + ) from exc + employment, created, changed, before, after = EmploymentService.register(db, create_payload) + db.commit() + db.refresh(employment) + msg = _status_msg("登记成功" if created else "已更新", changed, before, after) + return ok( + { + "employment": EmploymentOut.model_validate(employment), + "created": created, + "status_changed": changed, + "from_status_text": before, + "to_status_text": after, + }, + msg=msg, + ) + + +@legacy_router.get("/class/{class_id}", summary="[兼容] 获取班级学生就业信息") +def legacy_class_employment(db: DbSession, account: ReadAccount, class_id: int): + return employment_of_class(db, account, class_id) diff --git a/app/api/score_api.py b/app/api/score_api.py new file mode 100644 index 0000000..95ccf77 --- /dev/null +++ b/app/api/score_api.py @@ -0,0 +1,165 @@ +"""成绩管理接口(需求 2.2、4.2)。""" + +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, WriteAccount +from app.core.response import Resp, ok +from app.dao.score_dao import ScoreDao +from app.dao.student_dao import StudentDao +from app.schema.common import PageResult +from app.schema.score_schema import ScoreBatchCreate, ScoreCreate, ScoreOut, ScoreRecordResult, ScoreUpdate +from app.service.score_service import ScoreService + +router = APIRouter(prefix="/scores", tags=["2.2 学生考核成绩管理"]) + + +@router.get("", response_model=Resp[PageResult[ScoreOut]], summary="成绩列表(多条件筛选)") +def list_scores( + db: DbSession, + _: ReadAccount, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=200)] = 10, + stu_id: Annotated[int | None, Query(description="学生ID")] = None, + exam_seq: Annotated[int | None, Query(description="考核序次")] = None, + class_id: Annotated[int | None, Query(description="班级ID")] = None, + flag: Annotated[int | None, Query(ge=0, le=1, description="1=只看预警")] = None, + min_score: Annotated[float | None, Query(description="分数下限")] = None, + max_score: Annotated[float | None, Query(description="分数上限")] = None, + keyword: Annotated[str | None, Query(description="学生姓名/学号")] = None, + order_by: Annotated[str, Query()] = "id", + order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", +): + stmt = ScoreDao.build_stmt( + stu_id=stu_id, exam_seq=exam_seq, class_id=class_id, flag=flag, + min_score=min_score, max_score=max_score, keyword=keyword, order_by=order_by, order=order, + ) + items, total, page, pages = ScoreDao.paginate(db, stmt, page, page_size) + return ok({ + "total": total, "page": page, "page_size": page_size, "pages": pages, + "items": [ScoreOut.model_validate(s) for s in items], + }) + + +@router.get("/meta", summary="成绩模块元信息:已用过的考核序次 / 红线 / 课程") +def score_meta(db: DbSession, _: ReadAccount): + from sqlalchemy import select + + from app.model import Score + + exam_names = [ + name + for name in db.scalars( + select(Score.exam_name).where(Score.alive(), Score.exam_name.is_not(None)).distinct() + ).all() + if name + ] + return ok({ + "exam_seqs": ScoreDao.exam_seq_list(db), + "max_exam_seq": ScoreDao.max_exam_seq(db), + "exam_names": exam_names, + "pass_line": settings.SCORE_PASS_LINE, + "warn_line": settings.SCORE_WARN_LINE, + }) + + +@router.get("/student/{stu_id}", summary="某个学生的全部成绩 + 汇总(需求 4.2 的 GET /score/{stu_id})") +def scores_of_student(db: DbSession, _: ReadAccount, stu_id: int): + StudentDao.get_or_404(db, stu_id, "学生") + return ok(ScoreService.student_summary(db, stu_id)) + + +@router.get("/warnings", summary="红线预警名单(低于红线的学生汇总)") +def warning_list( + db: DbSession, + _: ReadAccount, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=200)] = 10, +): + stmt = ScoreDao.build_stmt(flag=1, order_by="score", order="asc") + items, total, page, pages = ScoreDao.paginate(db, stmt, page, page_size) + return ok({ + "total": total, "page": page, "page_size": page_size, "pages": pages, + "warn_line": settings.SCORE_WARN_LINE, + "items": [ScoreOut.model_validate(s) for s in items], + }) + + +@router.post("", response_model=Resp[ScoreRecordResult], summary="录入成绩(低于红线自动触发预警)") +def create_score(db: DbSession, _: WriteAccount, payload: ScoreCreate): + score, warning, warning_msg = ScoreService.create(db, payload) + db.commit() + db.refresh(score) + summary = ScoreService.student_summary(db, score.stu_id) + return ok( + { + "record": ScoreOut.model_validate(score), + "warning": warning, + "warning_msg": warning_msg, + "avg_score": summary["avg"], + }, + msg=warning_msg or "成绩录入成功", + ) + + +@router.post("/batch", summary="一次录入同一个学生的多次成绩") +def create_scores_batch(db: DbSession, _: WriteAccount, payload: ScoreBatchCreate): + results = ScoreService.batch_create(db, payload.stu_id, payload.scores) + db.commit() + items = [] + for score, warning, warning_msg in results: + db.refresh(score) + items.append( + { + "record": ScoreOut.model_validate(score), + "warning": warning, + "warning_msg": warning_msg, + } + ) + warnings = [i for i in items if i["warning"]] + return ok( + {"count": len(items), "warning_count": len(warnings), "items": items}, + msg=f"录入 {len(items)} 条成绩" + (f",其中 {len(warnings)} 条触发红线预警" if warnings else ""), + ) + + +@router.put("/{score_id}", response_model=Resp[ScoreRecordResult], summary="修改成绩(重新计算预警标记)") +def update_score(db: DbSession, _: WriteAccount, score_id: int, payload: ScoreUpdate): + score = ScoreDao.get_or_404(db, score_id, "成绩记录") + score, warning, warning_msg = ScoreService.update(db, score, payload) + db.commit() + db.refresh(score) + summary = ScoreService.student_summary(db, score.stu_id) + return ok( + { + "record": ScoreOut.model_validate(score), + "warning": warning, + "warning_msg": warning_msg, + "avg_score": summary["avg"], + }, + msg="修改成功" + (f"({warning_msg})" if warning_msg else ""), + ) + + +@router.delete("/{score_id}", summary="删除成绩(逻辑删除,同序次可重新录入)") +def delete_score(db: DbSession, _: WriteAccount, score_id: int): + score = ScoreDao.get_or_404(db, score_id, "成绩记录") + student_name = score.student_name + exam_seq = score.exam_seq + ScoreService.delete(db, score) + db.commit() + return ok(msg=f"已删除 {student_name} 第 {exam_seq} 次考核成绩") + + +@router.post("/student/{stu_id}/clear", summary="清空某个学生的全部成绩") +def clear_student_scores(db: DbSession, _: WriteAccount, stu_id: int): + StudentDao.get_or_404(db, stu_id, "学生") + scores = ScoreDao.list_by_student(db, stu_id) + for score in scores: + score.soft_delete() + db.commit() + return ok({"deleted": len(scores)}, msg=f"已清空 {len(scores)} 条成绩") diff --git a/app/api/statistics_api.py b/app/api/statistics_api.py new file mode 100644 index 0000000..3c53640 --- /dev/null +++ b/app/api/statistics_api.py @@ -0,0 +1,162 @@ +"""统计分析接口(需求 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)) diff --git a/app/api/student_api.py b/app/api/student_api.py new file mode 100644 index 0000000..eeb7b00 --- /dev/null +++ b/app/api/student_api.py @@ -0,0 +1,171 @@ +"""学生管理接口(需求 2.1、4.1)。""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Body, File, Query, Response, UploadFile + +from app.core.deps import DbSession, ReadAccount, WriteAccount +from app.core.response import Resp, ok +from app.dao.clazz_dao import ClazzDao +from app.dao.student_dao import StudentDao +from app.model import Education +from app.model.constants import STUDENT_STATUS_TEXT +from app.schema.common import PageResult +from app.schema.student_schema import ( + StudentCreate, + StudentImportResult, + StudentOut, + StudentUpdate, +) +from app.service.score_service import ScoreService +from app.service.student_service import StudentService + +router = APIRouter(prefix="/students", tags=["2.1 学生基本信息管理"]) + + +@router.get( + "", + response_model=Resp[PageResult[StudentOut]], + summary="学生列表(支持编号/姓名/班级/状态/性别/年龄区间筛选)", +) +def list_students( + db: DbSession, + _: ReadAccount, + page: Annotated[int, Query(ge=1, description="页码")] = 1, + page_size: Annotated[int, Query(ge=1, le=200, description="每页条数")] = 10, + keyword: Annotated[str | None, Query(description="模糊搜索:姓名/学号/电话/专业/院校/籍贯")] = None, + class_id: Annotated[int | None, Query(description="班级ID")] = None, + class_no: Annotated[str | None, Query(description="班级编号")] = None, + status: Annotated[int | None, Query(ge=1, le=3, description="状态 1=在读 2=进入就业 3=已就业")] = None, + gender: Annotated[int | None, Query(ge=1, le=2, description="性别 1=男 2=女")] = None, + advisor_id: Annotated[int | None, Query(description="顾问ID")] = None, + education: Annotated[str | None, Query(description="学历")] = None, + age_min: Annotated[int | None, Query(ge=1, le=100, description="年龄下限")] = None, + age_max: Annotated[int | None, Query(ge=1, le=100, description="年龄上限")] = None, + order_by: Annotated[str, Query(description="排序字段 id/stu_no/name/age/enroll_date/status")] = "id", + order: Annotated[str, Query(pattern="^(asc|desc)$", description="排序方向")] = "desc", +): + stmt = StudentDao.build_stmt( + keyword=keyword, class_id=class_id, class_no=class_no, status=status, gender=gender, + advisor_id=advisor_id, education=education, age_min=age_min, age_max=age_max, + 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.post("", response_model=Resp[StudentOut], summary="创建学生(学号按规则自动生成)") +def create_student(db: DbSession, account: WriteAccount, payload: StudentCreate): + student = StudentService.create(db, payload) + db.commit() + db.refresh(student) + return ok(StudentOut.model_validate(student), msg=f"学生 {student.name} 创建成功,学号 {student.stu_no}") + + +@router.get("/meta/options", summary="下拉选项:班级 / 顾问 / 学历 / 状态") +def student_options(db: DbSession, _: ReadAccount): + from app.dao.advisor_dao import AdvisorDao + + classes = ClazzDao.all(db, ClazzDao.build_stmt(order_by="class_no", order="asc")) + advisors = AdvisorDao.all(db, AdvisorDao.build_stmt(order_by="id", order="asc")) + return ok({ + "classes": [ + {"id": c.id, "name": c.name, "class_no": c.class_no, "status_text": c.status_text} + for c in classes + ], + "advisors": [{"id": a.id, "name": a.name} for a in advisors], + "educations": [e.value for e in Education], + "statuses": [{"value": k, "label": v} for k, v in sorted(STUDENT_STATUS_TEXT.items())], + }) + + +@router.get("/import/template", summary="下载 Excel 导入模板") +def download_template(_: ReadAccount): + content = StudentService.build_import_template() + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": 'attachment; filename="student_import_template.xlsx"'}, + ) + + +@router.post( + "/import", + response_model=Resp[StudentImportResult], + summary="Excel 批量导入学生(逐行校验,失败行有行号和原因)", +) +async def import_students( + db: DbSession, + _: WriteAccount, + file: Annotated[UploadFile, File(description="按模板填写的 .xlsx")], + dry_run: Annotated[bool, Query(description="只校验不落库")] = False, +): + if not file.filename or not file.filename.lower().endswith((".xlsx", ".xlsm")): + return Resp.fail("只支持 .xlsx 文件,请先用「下载模板」拿到标准格式") + content = await file.read() + result = StudentService.import_from_excel(db, content, dry_run=dry_run) + return ok(result, msg=f"导入完成:成功 {result.success} 行,失败 {result.failed} 行") + + +@router.get("/{stu_id}", response_model=Resp[StudentOut], summary="学生详情") +def get_student(db: DbSession, _: ReadAccount, stu_id: int): + student = StudentDao.get_or_404(db, stu_id, "学生") + return ok(StudentOut.model_validate(student)) + + +@router.get("/{stu_id}/scores", summary="学生成绩明细 + 汇总") +def get_student_scores(db: DbSession, _: ReadAccount, stu_id: int): + StudentDao.get_or_404(db, stu_id, "学生") + return ok(ScoreService.student_summary(db, stu_id)) + + +@router.put("/{stu_id}", response_model=Resp[StudentOut], summary="更新学生信息") +def update_student(db: DbSession, _: WriteAccount, stu_id: int, payload: StudentUpdate): + student = StudentDao.get_or_404(db, stu_id, "学生") + StudentService.update(db, student, payload) + db.commit() + db.refresh(student) + return ok(StudentOut.model_validate(student), msg="更新成功") + + +@router.delete("/{stu_id}", summary="逻辑删除学生(成绩与就业记录一并逻辑删除)") +def delete_student(db: DbSession, _: WriteAccount, stu_id: int): + student = StudentDao.get_or_404(db, stu_id, "学生") + StudentService.delete(db, student) + db.commit() + return ok(msg=f"已删除学生 {student.name}(可联系管理员恢复)") + + +@router.post("/{stu_id}/restore", summary="恢复被逻辑删除的学生") +def restore_student(db: DbSession, _: WriteAccount, stu_id: int): + student = StudentDao.get(db, stu_id, with_deleted=True) + if student is None: + return Resp.fail("学生不存在", 404) + if student.is_del == 0: + return Resp.fail("该学生未被删除,无需恢复") + student.is_del = 0 + db.commit() + return ok(msg=f"已恢复学生 {student.name}") + + +@router.post("/batch/delete", summary="批量逻辑删除") +def batch_delete(db: DbSession, _: WriteAccount, ids: Annotated[list[int], Body(embed=True, description="学生ID列表")]): + deleted = 0 + missing = [] + for stu_id in ids: + student = StudentDao.get(db, stu_id) + if student is None: + missing.append(stu_id) + continue + StudentService.delete(db, student) + deleted += 1 + db.commit() + return ok({"deleted": deleted, "missing": missing}, msg=f"已删除 {deleted} 名学生") diff --git a/app/api/teacher_api.py b/app/api/teacher_api.py new file mode 100644 index 0000000..81bf262 --- /dev/null +++ b/app/api/teacher_api.py @@ -0,0 +1,122 @@ +"""老师管理接口(需求 2.5)。""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Query + +from app.core.deps import DbSession, ReadAccount, WriteAccount +from app.core.exceptions import BusinessError, ConflictError, NotFoundError +from app.core.response import ok +from app.core.response import Resp +from app.core.utils import parse_date +from app.dao.clazz_dao import ClazzDao +from app.dao.teacher_dao import TeacherDao +from app.model import Teacher +from app.schema.common import PageResult +from app.schema.teacher_schema import TeacherCreate, TeacherOut, TeacherUpdate + +router = APIRouter(prefix="/teachers", tags=["2.5 老师管理"]) + + +@router.get("", response_model=Resp[PageResult[TeacherOut]], summary="老师列表(含带班信息)") +def list_teachers( + db: DbSession, + _: ReadAccount, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=200)] = 10, + keyword: Annotated[str | None, Query(description="姓名/工号/电话")] = None, + subject: Annotated[str | None, Query(description="授课方向")] = None, + title: Annotated[str | None, Query(description="职称")] = None, + gender: Annotated[int | None, Query(ge=1, le=2)] = None, + class_id: Annotated[int | None, Query(description="带某个班的老师")] = None, + order_by: Annotated[str, Query()] = "id", + order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", +): + stmt = TeacherDao.build_stmt( + keyword=keyword, subject=subject, title=title, gender=gender, + class_id=class_id, order_by=order_by, order=order, + ) + items, total, page, pages = TeacherDao.paginate(db, stmt, page, page_size) + return ok({ + "total": total, "page": page, "page_size": page_size, "pages": pages, + "items": [TeacherOut.model_validate(t) for t in items], + }) + + +@router.post("", response_model=Resp[TeacherOut], summary="新增老师") +def create_teacher(db: DbSession, _: WriteAccount, payload: TeacherCreate): + teacher_no = (payload.teacher_no or "").strip() + if not teacher_no: + from datetime import date + + year = (parse_date(payload.hire_date, "入职时间") or date.today()).year + teacher_no = TeacherDao.next_teacher_no(db, year) + if TeacherDao.get_by_no(db, teacher_no, with_deleted=True): + raise ConflictError(f"工号 {teacher_no} 已存在") + + teacher = Teacher( + teacher_no=teacher_no, + name=payload.name, + gender=payload.gender, + phone=payload.phone, + email=payload.email, + title=payload.title, + subject=payload.subject, + hire_date=parse_date(payload.hire_date, "入职时间"), + remark=payload.remark, + ) + if payload.class_ids: + teacher.classes = _resolve_classes(db, payload.class_ids) + + db.add(teacher) + db.commit() + db.refresh(teacher) + return ok(TeacherOut.model_validate(teacher), msg=f"老师 {teacher.name} 已加入,工号 {teacher.teacher_no}") + + +@router.get("/{teacher_id}", response_model=Resp[TeacherOut], summary="老师详情") +def get_teacher(db: DbSession, _: ReadAccount, teacher_id: int): + return ok(TeacherOut.model_validate(TeacherDao.get_or_404(db, teacher_id, "老师"))) + + +@router.put("/{teacher_id}", response_model=Resp[TeacherOut], summary="更新老师(含带班关系)") +def update_teacher(db: DbSession, _: WriteAccount, teacher_id: int, payload: TeacherUpdate): + teacher = TeacherDao.get_or_404(db, teacher_id, "老师") + data = payload.model_dump(exclude_unset=True) + if "hire_date" in data: + teacher.hire_date = parse_date(data.pop("hire_date"), "入职时间") + class_ids = data.pop("class_ids", None) + + for key, value in data.items(): + if value is not None and hasattr(teacher, key): + setattr(teacher, key, value) + + if class_ids is not None: + teacher.classes = _resolve_classes(db, class_ids) + + db.commit() + db.refresh(teacher) + return ok(TeacherOut.model_validate(teacher), msg="更新成功") + + +@router.delete("/{teacher_id}", summary="逻辑删除老师(仍带班时拒绝)") +def delete_teacher(db: DbSession, _: WriteAccount, teacher_id: int): + teacher = TeacherDao.get_or_404(db, teacher_id, "老师") + if teacher.class_names: + raise BusinessError( + f"老师「{teacher.name}」还在带 {len(teacher.class_names)} 个班:" + f"{'、'.join(teacher.class_names)}。请先解除带班关系" + ) + teacher.soft_delete() + db.commit() + return ok(msg=f"已删除老师 {teacher.name}") + + +def _resolve_classes(db, class_ids: list[int]) -> list: + all_classes = {c.id: c for c in ClazzDao.all(db, ClazzDao.build_stmt())} + missing = [cid for cid in class_ids if cid not in all_classes] + if missing: + raise NotFoundError(f"班级不存在:{missing}") + return [all_classes[cid] for cid in class_ids] diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..9926306 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""基础设施层:配置、数据库、鉴权、异常、响应、通用工具。""" diff --git a/app/core/__pycache__/__init__.cpython-311.pyc b/app/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..c76faaa Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/core/__pycache__/__init__.cpython-313.pyc b/app/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a88c350 Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/core/__pycache__/config.cpython-311.pyc b/app/core/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000..ce27f1d Binary files /dev/null and b/app/core/__pycache__/config.cpython-311.pyc differ diff --git a/app/core/__pycache__/config.cpython-313.pyc b/app/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..7094978 Binary files /dev/null and b/app/core/__pycache__/config.cpython-313.pyc differ diff --git a/app/core/__pycache__/database.cpython-311.pyc b/app/core/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000..844de44 Binary files /dev/null and b/app/core/__pycache__/database.cpython-311.pyc differ diff --git a/app/core/__pycache__/database.cpython-313.pyc b/app/core/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..6dc49ec Binary files /dev/null and b/app/core/__pycache__/database.cpython-313.pyc differ diff --git a/app/core/__pycache__/deps.cpython-311.pyc b/app/core/__pycache__/deps.cpython-311.pyc new file mode 100644 index 0000000..b1f1ae2 Binary files /dev/null and b/app/core/__pycache__/deps.cpython-311.pyc differ diff --git a/app/core/__pycache__/deps.cpython-313.pyc b/app/core/__pycache__/deps.cpython-313.pyc new file mode 100644 index 0000000..d25c54e Binary files /dev/null and b/app/core/__pycache__/deps.cpython-313.pyc differ diff --git a/app/core/__pycache__/exceptions.cpython-311.pyc b/app/core/__pycache__/exceptions.cpython-311.pyc new file mode 100644 index 0000000..8b8e3c7 Binary files /dev/null and b/app/core/__pycache__/exceptions.cpython-311.pyc differ diff --git a/app/core/__pycache__/exceptions.cpython-313.pyc b/app/core/__pycache__/exceptions.cpython-313.pyc new file mode 100644 index 0000000..e56e039 Binary files /dev/null and b/app/core/__pycache__/exceptions.cpython-313.pyc differ diff --git a/app/core/__pycache__/response.cpython-311.pyc b/app/core/__pycache__/response.cpython-311.pyc new file mode 100644 index 0000000..21ed00e Binary files /dev/null and b/app/core/__pycache__/response.cpython-311.pyc differ diff --git a/app/core/__pycache__/response.cpython-313.pyc b/app/core/__pycache__/response.cpython-313.pyc new file mode 100644 index 0000000..cd810a3 Binary files /dev/null and b/app/core/__pycache__/response.cpython-313.pyc differ diff --git a/app/core/__pycache__/security.cpython-311.pyc b/app/core/__pycache__/security.cpython-311.pyc new file mode 100644 index 0000000..24be47f Binary files /dev/null and b/app/core/__pycache__/security.cpython-311.pyc differ diff --git a/app/core/__pycache__/security.cpython-313.pyc b/app/core/__pycache__/security.cpython-313.pyc new file mode 100644 index 0000000..ee53ee3 Binary files /dev/null and b/app/core/__pycache__/security.cpython-313.pyc differ diff --git a/app/core/__pycache__/utils.cpython-311.pyc b/app/core/__pycache__/utils.cpython-311.pyc new file mode 100644 index 0000000..5efaa5f Binary files /dev/null and b/app/core/__pycache__/utils.cpython-311.pyc differ diff --git a/app/core/__pycache__/utils.cpython-313.pyc b/app/core/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000..6fb371f Binary files /dev/null and b/app/core/__pycache__/utils.cpython-313.pyc differ diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..998fdbb --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,74 @@ +"""全局配置:从 .env 读取,Pydantic v2 settings 风格。""" + +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + # ---------- 应用 ---------- + APP_NAME: str = "沃林学生管理系统" + APP_DESC: str = "基于 FastAPI + SQLAlchemy 2.0 + MySQL 的教务管理系统" + VERSION: str = "1.0.0" + DEBUG: bool = True + + # ---------- 数据库 ---------- + DB_DRIVER: str = "mysql+pymysql" # mysql+pymysql | sqlite + DB_HOST: str = "127.0.0.1" + DB_PORT: int = 3306 + DB_USER: str = "root" + DB_PASSWORD: str = "123456" + DB_NAME: str = "wolin_sms" + SQLITE_PATH: str = "./wolin_sms.db" + + DB_ECHO: bool = False + DB_POOL_SIZE: int = 10 + DB_POOL_RECYCLE: int = 3600 + + # ---------- 鉴权 ---------- + JWT_SECRET: str = "wolin-sms-dev-secret-change-me" + JWT_ALGORITHM: str = "HS256" + JWT_EXPIRE_MINUTES: int = 720 + AUTH_ENABLED: bool = True # 关闭后所有接口免登录(本地排错用) + + # ---------- 初始管理员 ---------- + ADMIN_USERNAME: str = "admin" + ADMIN_PASSWORD: str = "admin123" + + # ---------- 业务规则 ---------- + SCORE_PASS_LINE: float = 60.0 # 及格线 + SCORE_WARN_LINE: float = 60.0 # 红线预警线(低于即标记"需重点关注") + HIGH_SALARY_LINE: float = 10000.0 # 高薪线 + STU_NO_PREFIX: str = "WL" # 学号前缀 + + @property + def database_url(self) -> str: + if self.DB_DRIVER.startswith("sqlite"): + return f"sqlite:///{self.SQLITE_PATH}" + return ( + f"{self.DB_DRIVER}://{self.DB_USER}:{self.DB_PASSWORD}" + f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4" + ) + + @property + def server_url_without_db(self) -> str: + """用于首次自动建库(数据库还不存在时)。""" + return ( + f"{self.DB_DRIVER}://{self.DB_USER}:{self.DB_PASSWORD}" + f"@{self.DB_HOST}:{self.DB_PORT}/?charset=utf8mb4" + ) + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..e1a0ecc --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,66 @@ +"""数据库引擎 / 会话 / 依赖注入 / 自动建库。""" + +from __future__ import annotations + +import logging +from collections.abc import Generator + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +def _build_engine() -> Engine: + if settings.DB_DRIVER.startswith("sqlite"): + return create_engine( + settings.database_url, + echo=settings.DB_ECHO, + connect_args={"check_same_thread": False}, + ) + return create_engine( + settings.database_url, + echo=settings.DB_ECHO, + pool_size=settings.DB_POOL_SIZE, + pool_recycle=settings.DB_POOL_RECYCLE, + pool_pre_ping=True, + future=True, + ) + + +engine: Engine = _build_engine() +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, class_=Session) + + +def ensure_database_exists() -> None: + """MySQL 下若目标库不存在则自动创建,省去手工建库。""" + if settings.DB_DRIVER.startswith("sqlite"): + return + root_engine = create_engine(settings.server_url_without_db, future=True) + try: + with root_engine.connect() as conn: + conn.execute( + text( + f"CREATE DATABASE IF NOT EXISTS `{settings.DB_NAME}` " + f"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci" + ) + ) + conn.commit() + finally: + root_engine.dispose() + + +def get_db() -> Generator[Session, None, None]: + """FastAPI 依赖:每个请求一个会话,结束即关闭。""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def dialect_name() -> str: + return engine.dialect.name diff --git a/app/core/deps.py b/app/core/deps.py new file mode 100644 index 0000000..e2a1962 --- /dev/null +++ b/app/core/deps.py @@ -0,0 +1,94 @@ +"""FastAPI 依赖:数据库会话包装、当前登录用户、角色校验、分页参数。""" + +from __future__ import annotations + +from collections.abc import Generator +from typing import Annotated + +from fastapi import Depends, Header, Query +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.database import get_db as _get_db +from app.core.exceptions import AuthError, PermissionError_ +from app.core.security import decode_access_token +from app.dao.account_dao import AccountDao +from app.model import Account, Role + + +def get_db() -> Generator[Session, None, None]: + yield from _get_db() + + +DbSession = Annotated[Session, Depends(get_db)] + + +def get_current_account( + db: DbSession, + authorization: Annotated[str | None, Header(description="Bearer ")] = None, +) -> Account: + """从 Authorization 头解析登录用户。 + + AUTH_ENABLED=false 时直接放行(本地调试用),返回一个虚拟管理员。 + """ + if not settings.AUTH_ENABLED: + return Account(id=0, username="anonymous", real_name="调试模式", role=Role.ADMIN.value) + + if not authorization: + raise AuthError("请先登录(缺少 Authorization 头)") + + token = authorization[7:].strip() if authorization.lower().startswith("bearer ") else authorization.strip() + payload = decode_access_token(token) + account = AccountDao.get(db, int(payload.get("sub", 0))) + if account is None or not account.is_active: + raise AuthError("账号不存在或已停用") + return account + + +CurrentAccount = Annotated[Account, Depends(get_current_account)] + + +def require_admin(account: CurrentAccount) -> Account: + if account.role != Role.ADMIN.value: + raise PermissionError_(f"该操作需要管理员权限,当前角色:{account.role}") + return account + + +AdminAccount = Annotated[Account, Depends(require_admin)] + + +def require_write(account: CurrentAccount) -> Account: + """只读访客不允许写操作。""" + if account.role == Role.VIEWER.value: + raise PermissionError_("当前为只读账号,不能执行写操作") + return account + + +WriteAccount = Annotated[Account, Depends(require_write)] + + +def require_read(account: CurrentAccount) -> Account: + """读操作:只要能通过登录校验就行,三种角色都可读。 + + 这里必须单独有一个依赖,不能图省事复用 `require_write`。 + 早期版本把列表/详情/统计这些纯读接口全挂在了 WriteAccount 上, + 结果「只读账号」登录后连一条数据都看不到 —— 权限模型里叫只读, + 行为上却是「什么都不能做」,这个账号等于废的。 + """ + return account + + +ReadAccount = Annotated[Account, Depends(require_read)] + + +class Pagination: + def __init__( + self, + page: Annotated[int, Query(ge=1, description="页码,从 1 开始")] = 1, + page_size: Annotated[int, Query(ge=1, le=200, description="每页条数")] = 10, + ) -> None: + self.page = page + self.page_size = page_size + + +PageParams = Annotated[Pagination, Depends()] diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..dba6a46 --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,52 @@ +"""统一异常体系。 + +业务代码只抛 BusinessError,由 main.py 里的处理器转成统一响应, +避免把 traceback 直接甩给前端。 +""" + +from __future__ import annotations + +from typing import Any + + +class BusinessError(Exception): + """可预期的业务异常(参数不合法、状态不允许流转、数据不存在等)。""" + + def __init__(self, msg: str, code: int = 400, data: Any = None) -> None: + self.msg = msg + self.code = code + self.data = data + super().__init__(msg) + + +class NotFoundError(BusinessError): + def __init__(self, msg: str = "数据不存在") -> None: + super().__init__(msg, code=404) + + +class ConflictError(BusinessError): + """唯一键冲突 / 状态冲突。""" + + def __init__(self, msg: str = "数据冲突") -> None: + super().__init__(msg, code=409) + + +class AuthError(BusinessError): + def __init__(self, msg: str = "未登录或登录已过期") -> None: + super().__init__(msg, code=401) + + +class PermissionError_(BusinessError): + def __init__(self, msg: str = "没有操作权限") -> None: + super().__init__(msg, code=403) + + +class RuleError(BusinessError): + """高级筛选器规则不合法。""" + + def __init__(self, msg: str = "筛选规则不合法") -> None: + super().__init__(msg, code=422) + + +# 给 FastAPI 的 HTTPException 留个短别名,方便 api 层按需使用 +from fastapi import HTTPException as HTTPException # noqa: E402,F401 diff --git a/app/core/response.py b/app/core/response.py new file mode 100644 index 0000000..4f457d3 --- /dev/null +++ b/app/core/response.py @@ -0,0 +1,33 @@ +"""统一响应包。 + +Success: {"code": 0, "msg": "ok", "data": ...} +Failure: {"code": 404, "msg": "学生不存在", "data": null} +code=0 表示成功,其余为业务错误码(与 HTTP 状态码对齐,前端可统一判断 code !== 0)。 +""" + +from __future__ import annotations + +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class Resp(BaseModel, Generic[T]): + code: int = Field(0, description="0=成功,非 0=业务错误码") + msg: str = Field("ok", description="提示信息") + data: T | None = Field(None, description="业务数据") + + @classmethod + def ok(cls, data: Any = None, msg: str = "ok") -> dict: + return {"code": 0, "msg": msg, "data": data} + + @classmethod + def fail(cls, msg: str, code: int = 400, data: Any = None) -> dict: + return {"code": code, "msg": msg, "data": data} + + +def ok(data: Any = None, msg: str = "ok") -> dict: + """最常用的写法:return ok(result)""" + return {"code": 0, "msg": msg, "data": data} diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..33e36a9 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,47 @@ +"""密码哈希与 JWT 签发/校验。""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +import bcrypt +import jwt + +from app.core.config import settings +from app.core.exceptions import AuthError + +# ---------- 密码 ---------- + + +def hash_password(raw: str) -> str: + return bcrypt.hashpw(raw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(raw: str, hashed: str) -> bool: + if not raw or not hashed: + return False + try: + return bcrypt.checkpw(raw.encode("utf-8"), hashed.encode("utf-8")) + except ValueError: + return False + + +# ---------- JWT ---------- + + +def create_access_token(payload: dict[str, Any], expires_minutes: int | None = None) -> str: + minutes = expires_minutes or settings.JWT_EXPIRE_MINUTES + body = dict(payload) + body["exp"] = datetime.now(timezone.utc) + timedelta(minutes=minutes) + body["iat"] = datetime.now(timezone.utc) + return jwt.encode(body, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + + +def decode_access_token(token: str) -> dict[str, Any]: + try: + return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]) + except jwt.ExpiredSignatureError as exc: + raise AuthError("登录已过期,请重新登录") from exc + except jwt.PyJWTError as exc: + raise AuthError("令牌无效") from exc diff --git a/app/core/utils.py b/app/core/utils.py new file mode 100644 index 0000000..422f0ac --- /dev/null +++ b/app/core/utils.py @@ -0,0 +1,108 @@ +"""通用工具:年龄表达式、类型转换、日期解析、分页计算。""" + +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +from math import ceil +from typing import Any + +from sqlalchemy import Integer, func, text +from sqlalchemy.sql.elements import ColumnElement + +from app.core.database import dialect_name +from app.core.exceptions import BusinessError + + +# ---------------------------------------------------------------- 年龄 +def age_expression(birth_date_column, dialect: str | None = None) -> ColumnElement: + """跨方言的"按当前日期计算年龄"SQL 表达式。 + + 模型里不落 age 字段:年龄是随时间漂移的派生值,落库第二天就会错。 + 统计接口需要按年龄筛选,所以这里给出能在 WHERE / GROUP BY 里用的表达式。 + """ + dialect = dialect or dialect_name() + if dialect == "sqlite": + return func.cast( + (func.julianday(func.current_date()) - func.julianday(birth_date_column)) / 365.25, + Integer, + ) + # MySQL: TIMESTAMPDIFF(YEAR, birth_date, CURDATE()) + return func.timestampdiff(text("YEAR"), birth_date_column, func.curdate()) + + +def date_diff_days_expr(end_column, start_column, dialect: str | None = None) -> ColumnElement: + """两个日期列相减得到天数,跨方言。 + + MySQL 用 DATEDIFF(end, start);SQLite 用 julianday 差值取整。 + 就业时长(需求 2.6.3)用它直接在 SQL 里算,不用把整表拉到内存。 + """ + dialect = dialect or dialect_name() + if dialect == "sqlite": + return func.cast(func.julianday(end_column) - func.julianday(start_column), Integer) + return func.datediff(end_column, start_column) + + +def calc_age(birth: date | None, today: date | None = None) -> int | None: + """Python 侧算年龄(列表出参用)。""" + if not birth: + return None + today = today or date.today() + return today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day)) + + +def guess_birth_date(age: int, today: date | None = None) -> date: + """只给了年龄没有生日时的兜底推算(取当年某月 1 日,避免 2/29 问题)。""" + today = today or date.today() + return date(today.year - age, 1, 1) + + +# ---------------------------------------------------------------- 类型转换 +def to_float(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, Decimal): + return float(value) + try: + return float(value) + except (TypeError, ValueError): + return None + + +def parse_date(value: Any, field: str = "日期") -> date | None: + """schema 层日期一律用 str,转 date 在这一层做。""" + if value in (None, "", "null"): + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if isinstance(value, str): + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y%m%d"): + try: + return datetime.strptime(value.strip(), fmt).date() + except ValueError: + continue + raise BusinessError(f"{field}格式不正确,应为 YYYY-MM-DD") + + +def parse_amount(value: Any, field: str = "金额") -> Decimal | None: + if value in (None, ""): + return None + try: + return Decimal(str(value)) + except Exception as exc: # noqa: BLE001 + raise BusinessError(f"{field}必须是数字") from exc + + +# ---------------------------------------------------------------- 分页 +def page_count(total: int, page_size: int) -> int: + if page_size <= 0: + return 0 + return ceil(total / page_size) if total else 0 + + +def paginate_params(page: int, page_size: int) -> tuple[int, int]: + page = max(page or 1, 1) + page_size = min(max(page_size or 10, 1), 200) + return page, page_size diff --git a/app/dao/__init__.py b/app/dao/__init__.py new file mode 100644 index 0000000..3332c11 --- /dev/null +++ b/app/dao/__init__.py @@ -0,0 +1 @@ +"""数据访问层:所有 SQL 只出现在这里,业务层不写 query。""" diff --git a/app/dao/__pycache__/__init__.cpython-311.pyc b/app/dao/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..27408bb Binary files /dev/null and b/app/dao/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/dao/__pycache__/__init__.cpython-313.pyc b/app/dao/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..2f1fe9f Binary files /dev/null and b/app/dao/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/dao/__pycache__/account_dao.cpython-311.pyc b/app/dao/__pycache__/account_dao.cpython-311.pyc new file mode 100644 index 0000000..a32d849 Binary files /dev/null and b/app/dao/__pycache__/account_dao.cpython-311.pyc differ diff --git a/app/dao/__pycache__/account_dao.cpython-313.pyc b/app/dao/__pycache__/account_dao.cpython-313.pyc new file mode 100644 index 0000000..93550f5 Binary files /dev/null and b/app/dao/__pycache__/account_dao.cpython-313.pyc differ diff --git a/app/dao/__pycache__/advisor_dao.cpython-311.pyc b/app/dao/__pycache__/advisor_dao.cpython-311.pyc new file mode 100644 index 0000000..56014a0 Binary files /dev/null and b/app/dao/__pycache__/advisor_dao.cpython-311.pyc differ diff --git a/app/dao/__pycache__/advisor_dao.cpython-313.pyc b/app/dao/__pycache__/advisor_dao.cpython-313.pyc new file mode 100644 index 0000000..0007067 Binary files /dev/null and b/app/dao/__pycache__/advisor_dao.cpython-313.pyc differ diff --git a/app/dao/__pycache__/base_dao.cpython-311.pyc b/app/dao/__pycache__/base_dao.cpython-311.pyc new file mode 100644 index 0000000..d7483b5 Binary files /dev/null and b/app/dao/__pycache__/base_dao.cpython-311.pyc differ diff --git a/app/dao/__pycache__/base_dao.cpython-313.pyc b/app/dao/__pycache__/base_dao.cpython-313.pyc new file mode 100644 index 0000000..889bc96 Binary files /dev/null and b/app/dao/__pycache__/base_dao.cpython-313.pyc differ diff --git a/app/dao/__pycache__/clazz_dao.cpython-311.pyc b/app/dao/__pycache__/clazz_dao.cpython-311.pyc new file mode 100644 index 0000000..6c14253 Binary files /dev/null and b/app/dao/__pycache__/clazz_dao.cpython-311.pyc differ diff --git a/app/dao/__pycache__/clazz_dao.cpython-313.pyc b/app/dao/__pycache__/clazz_dao.cpython-313.pyc new file mode 100644 index 0000000..65464b5 Binary files /dev/null and b/app/dao/__pycache__/clazz_dao.cpython-313.pyc differ diff --git a/app/dao/__pycache__/employment_dao.cpython-311.pyc b/app/dao/__pycache__/employment_dao.cpython-311.pyc new file mode 100644 index 0000000..da5080a Binary files /dev/null and b/app/dao/__pycache__/employment_dao.cpython-311.pyc differ diff --git a/app/dao/__pycache__/employment_dao.cpython-313.pyc b/app/dao/__pycache__/employment_dao.cpython-313.pyc new file mode 100644 index 0000000..c258f2d Binary files /dev/null and b/app/dao/__pycache__/employment_dao.cpython-313.pyc differ diff --git a/app/dao/__pycache__/score_dao.cpython-311.pyc b/app/dao/__pycache__/score_dao.cpython-311.pyc new file mode 100644 index 0000000..82b3e99 Binary files /dev/null and b/app/dao/__pycache__/score_dao.cpython-311.pyc differ diff --git a/app/dao/__pycache__/score_dao.cpython-313.pyc b/app/dao/__pycache__/score_dao.cpython-313.pyc new file mode 100644 index 0000000..b7d8864 Binary files /dev/null and b/app/dao/__pycache__/score_dao.cpython-313.pyc differ diff --git a/app/dao/__pycache__/statistics_dao.cpython-311.pyc b/app/dao/__pycache__/statistics_dao.cpython-311.pyc new file mode 100644 index 0000000..f6fe0fd Binary files /dev/null and b/app/dao/__pycache__/statistics_dao.cpython-311.pyc differ diff --git a/app/dao/__pycache__/statistics_dao.cpython-313.pyc b/app/dao/__pycache__/statistics_dao.cpython-313.pyc new file mode 100644 index 0000000..4ac2f5a Binary files /dev/null and b/app/dao/__pycache__/statistics_dao.cpython-313.pyc differ diff --git a/app/dao/__pycache__/student_dao.cpython-311.pyc b/app/dao/__pycache__/student_dao.cpython-311.pyc new file mode 100644 index 0000000..38149f1 Binary files /dev/null and b/app/dao/__pycache__/student_dao.cpython-311.pyc differ diff --git a/app/dao/__pycache__/student_dao.cpython-313.pyc b/app/dao/__pycache__/student_dao.cpython-313.pyc new file mode 100644 index 0000000..1ee529d Binary files /dev/null and b/app/dao/__pycache__/student_dao.cpython-313.pyc differ diff --git a/app/dao/__pycache__/teacher_dao.cpython-311.pyc b/app/dao/__pycache__/teacher_dao.cpython-311.pyc new file mode 100644 index 0000000..724922d Binary files /dev/null and b/app/dao/__pycache__/teacher_dao.cpython-311.pyc differ diff --git a/app/dao/__pycache__/teacher_dao.cpython-313.pyc b/app/dao/__pycache__/teacher_dao.cpython-313.pyc new file mode 100644 index 0000000..915f637 Binary files /dev/null and b/app/dao/__pycache__/teacher_dao.cpython-313.pyc differ diff --git a/app/dao/account_dao.py b/app/dao/account_dao.py new file mode 100644 index 0000000..a9c08b9 --- /dev/null +++ b/app/dao/account_dao.py @@ -0,0 +1,20 @@ +"""账号 DAO。""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.dao.base_dao import BaseDao +from app.model import Account + + +class AccountDao(BaseDao[Account]): + model = Account + + @classmethod + def get_by_username(cls, db: Session, username: str, with_deleted: bool = False) -> Account | None: + stmt = select(Account).where(Account.username == username) + if not with_deleted: + stmt = stmt.where(Account.alive()) + return db.scalars(stmt).unique().first() diff --git a/app/dao/advisor_dao.py b/app/dao/advisor_dao.py new file mode 100644 index 0000000..24700ea --- /dev/null +++ b/app/dao/advisor_dao.py @@ -0,0 +1,54 @@ +"""顾问 DAO(需求第 5 节扩展模块,但学生表要用顾问编号,所以一并做了)。""" + +from __future__ import annotations + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.dao.base_dao import BaseDao +from app.model import Advisor, Student + + +class AdvisorDao(BaseDao[Advisor]): + model = Advisor + + @classmethod + def build_stmt( + cls, + keyword: str | None = None, + dept: str | None = None, + gender: int | None = None, + order_by: str = "id", + order: str = "desc", + ) -> Select: + stmt = select(Advisor).where(Advisor.alive()) + if keyword: + like = f"%{keyword.strip()}%" + stmt = stmt.where( + or_(Advisor.name.like(like), Advisor.advisor_no.like(like), Advisor.phone.like(like)) + ) + if dept: + stmt = stmt.where(Advisor.dept == dept) + if gender: + stmt = stmt.where(Advisor.gender == gender) + + sortable = {"id": Advisor.id, "advisor_no": Advisor.advisor_no, "name": Advisor.name} + column = sortable.get(order_by or "id", Advisor.id) + stmt = stmt.order_by(column.desc() if (order or "desc").lower() == "desc" else column.asc()) + return stmt + + @classmethod + def next_advisor_no(cls, db: Session) -> str: + prefix = "A" + last = db.scalar(select(func.max(Advisor.advisor_no)).where(Advisor.advisor_no.like(f"{prefix}%"))) + seq = int(last[len(prefix):]) + 1 if last and last[len(prefix):].isdigit() else 1 + return f"{prefix}{seq:04d}" + + @classmethod + def student_count_map(cls, db: Session) -> dict[int, int]: + stmt = ( + select(Student.advisor_id, func.count(Student.id)) + .where(Student.alive(), Student.advisor_id.is_not(None)) + .group_by(Student.advisor_id) + ) + return {row[0]: row[1] for row in db.execute(stmt).all()} diff --git a/app/dao/base_dao.py b/app/dao/base_dao.py new file mode 100644 index 0000000..8e5bbcd --- /dev/null +++ b/app/dao/base_dao.py @@ -0,0 +1,99 @@ +"""DAO 基类:把「取一条、取一页、软删、计数」这些重复动作收口。""" + +from __future__ import annotations + +from typing import Any, Generic, Sequence, TypeVar + +from sqlalchemy import Select, func, select +from sqlalchemy.orm import Session + +from app.core.exceptions import NotFoundError +from app.core.utils import page_count, paginate_params +from app.model.base import DEL_FLAG_NORMAL, Base + +T = TypeVar("T", bound=Base) + + +class BaseDao(Generic[T]): + model: type[T] + + # ------------------------------------------------------------ 单条 + @classmethod + def get(cls, db: Session, pk: Any, with_deleted: bool = False) -> T | None: + stmt = select(cls.model).where(cls.model.id == pk) + if not with_deleted: + stmt = stmt.where(cls.model.is_del == DEL_FLAG_NORMAL) + return db.scalars(stmt).unique().first() + + @classmethod + def get_or_404(cls, db: Session, pk: Any, label: str = "数据") -> T: + obj = cls.get(db, pk) + if obj is None: + raise NotFoundError(f"{label}不存在或已被删除(id={pk})") + return obj + + @classmethod + def first_by(cls, db: Session, **filters: Any) -> T | None: + stmt = select(cls.model).filter_by(**filters).where(cls.model.is_del == DEL_FLAG_NORMAL) + return db.scalars(stmt).unique().first() + + # ------------------------------------------------------------ 一页 + @classmethod + def paginate( + cls, db: Session, stmt: Select, page: int = 1, page_size: int = 10 + ) -> tuple[list[T], int, int, int]: + """返回 (items, total, page, pages),total 由子查询算,不受 limit 影响。""" + page, page_size = paginate_params(page, page_size) + count_stmt = select(func.count()).select_from(stmt.order_by(None).subquery()) + total = int(db.scalar(count_stmt) or 0) + items = db.scalars(stmt.limit(page_size).offset((page - 1) * page_size)).unique().all() + return list(items), total, page, page_count(total, page_size) + + @classmethod + def all(cls, db: Session, stmt: Select, limit: int | None = None) -> list[T]: + if limit: + stmt = stmt.limit(limit) + return list(db.scalars(stmt).unique().all()) + + @classmethod + def count(cls, db: Session, stmt: Select | None = None) -> int: + if stmt is None: + stmt = select(cls.model) + return int(db.scalar(select(func.count()).select_from(stmt.order_by(None).subquery())) or 0) + + # ------------------------------------------------------------ 写 + @classmethod + def add(cls, db: Session, obj: T, flush: bool = True) -> T: + db.add(obj) + if flush: + db.flush() + return obj + + @classmethod + def update(cls, db: Session, obj: T, data: dict[str, Any]) -> T: + for key, value in data.items(): + if value is not None and hasattr(obj, key): + setattr(obj, key, value) + db.flush() + return obj + + @classmethod + def soft_delete(cls, db: Session, obj: T) -> T: + obj.soft_delete() + db.flush() + return obj + + @classmethod + def exists(cls, db: Session, **filters: Any) -> bool: + stmt = select(cls.model.id).filter_by(**filters).limit(1) + return db.scalar(stmt) is not None + + # ------------------------------------------------------------ 辅助 + @staticmethod + def group_count(db: Session, stmt: Select) -> dict[Any, int]: + """把 (key, count) 结果转成 dict,用于给列表批量补统计字段。""" + return {row[0]: row[1] for row in db.execute(stmt).all()} + + @staticmethod + def scalar_list(db: Session, stmt: Select) -> Sequence[Any]: + return db.scalars(stmt).all() diff --git a/app/dao/clazz_dao.py b/app/dao/clazz_dao.py new file mode 100644 index 0000000..39949dc --- /dev/null +++ b/app/dao/clazz_dao.py @@ -0,0 +1,91 @@ +"""班级 DAO(需求 2.4)。""" + +from __future__ import annotations + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.core.utils import age_expression # noqa: F401 (保留给后续扩展) +from app.dao.base_dao import BaseDao +from app.model import Clazz, Student, class_teachers + + +class ClazzDao(BaseDao[Clazz]): + model = Clazz + + @classmethod + def build_stmt( + cls, + keyword: str | None = None, + status: int | None = None, + advisor_id: int | None = None, + head_teacher_id: int | None = None, + teacher_id: int | None = None, + order_by: str = "id", + order: str = "desc", + ) -> Select: + stmt = select(Clazz).where(Clazz.alive()) + + if keyword: + like = f"%{keyword.strip()}%" + stmt = stmt.where( + or_(Clazz.name.like(like), Clazz.class_no.like(like), Clazz.direction.like(like)) + ) + if status: + stmt = stmt.where(Clazz.status == status) + if advisor_id: + stmt = stmt.where(Clazz.advisor_id == advisor_id) + if head_teacher_id: + stmt = stmt.where(Clazz.head_teacher_id == head_teacher_id) + if teacher_id: + stmt = stmt.where( + Clazz.id.in_( + select(class_teachers.c.class_id).where(class_teachers.c.teacher_id == teacher_id) + ) + ) + + sortable = { + "id": Clazz.id, + "class_no": Clazz.class_no, + "name": Clazz.name, + "open_date": Clazz.open_date, + "close_date": Clazz.close_date, + "status": Clazz.status, + "capacity": Clazz.capacity, + } + column = sortable.get(order_by or "id", Clazz.id) + stmt = stmt.order_by(column.desc() if (order or "desc").lower() == "desc" else column.asc()) + return stmt + + @classmethod + def get_by_class_no(cls, db: Session, class_no: str, with_deleted: bool = False) -> Clazz | None: + stmt = select(Clazz).where(Clazz.class_no == class_no) + if not with_deleted: + stmt = stmt.where(Clazz.alive()) + return db.scalars(stmt).unique().first() + + @classmethod + def name_map(cls, db: Session) -> dict[int, str]: + stmt = select(Clazz.id, Clazz.name).where(Clazz.alive()) + return {row[0]: row[1] for row in db.execute(stmt).all()} + + @classmethod + def no_map(cls, db: Session) -> dict[int, str]: + stmt = select(Clazz.id, Clazz.class_no).where(Clazz.alive()) + return {row[0]: row[1] for row in db.execute(stmt).all()} + + @classmethod + def next_class_no(cls, db: Session, direction_prefix: str, year: int) -> str: + """班级编号规则:方向缩写 + 年份 + 两位序号,如 JAVA202601。""" + prefix = f"{direction_prefix}{year}" + stmt = select(func.max(Clazz.class_no)).where(Clazz.class_no.like(f"{prefix}%")) + last = db.scalar(stmt) + seq = int(last[len(prefix):]) + 1 if last and last[len(prefix):].isdigit() else 1 + return f"{prefix}{seq:02d}" + + @classmethod + def student_counts(cls, db: Session, only_alive: bool = True) -> dict[int, int]: + stmt = select(Student.class_id, func.count(Student.id)).where(Student.class_id.is_not(None)) + if only_alive: + stmt = stmt.where(Student.alive()) + return {row[0]: row[1] for row in db.execute(stmt.group_by(Student.class_id)).all()} diff --git a/app/dao/employment_dao.py b/app/dao/employment_dao.py new file mode 100644 index 0000000..4cb6a69 --- /dev/null +++ b/app/dao/employment_dao.py @@ -0,0 +1,110 @@ +"""就业 DAO(需求 2.3)。""" + +from __future__ import annotations + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.dao.base_dao import BaseDao +from app.model import Clazz, Employment, Student + + +class EmploymentDao(BaseDao[Employment]): + model = Employment + + @classmethod + def build_stmt( + cls, + stu_id: int | None = None, + class_id: int | None = None, + company: str | None = None, + salary_min: float | None = None, + salary_max: float | None = None, + has_offer: bool | None = None, + has_open: bool | None = None, + keyword: str | None = None, + order_by: str = "id", + order: str = "desc", + ) -> Select: + stmt = select(Employment).join(Student, Student.id == Employment.stu_id).where( + Employment.alive(), Student.alive() + ) + + if stu_id: + stmt = stmt.where(Employment.stu_id == stu_id) + if class_id: + stmt = stmt.where(Employment.class_id == class_id) + if company: + stmt = stmt.where(Employment.company.like(f"%{company.strip()}%")) + if salary_min is not None: + stmt = stmt.where(Employment.salary >= salary_min) + if salary_max is not None: + stmt = stmt.where(Employment.salary <= salary_max) + if has_offer is True: + stmt = stmt.where(Employment.offer_date.is_not(None)) + elif has_offer is False: + stmt = stmt.where(Employment.offer_date.is_(None)) + if has_open is True: + stmt = stmt.where(Employment.open_date.is_not(None)) + elif has_open is False: + stmt = stmt.where(Employment.open_date.is_(None)) + if keyword: + like = f"%{keyword.strip()}%" + stmt = stmt.where( + or_( + Student.name.like(like), + Student.stu_no.like(like), + Employment.company.like(like), + Employment.city.like(like), + ) + ) + + sortable = { + "id": Employment.id, + "salary": Employment.salary, + "open_date": Employment.open_date, + "offer_date": Employment.offer_date, + "company": Employment.company, + "student_name": Student.name, + "class_name": Clazz.name, + } + column = sortable.get(order_by or "id", Employment.id) + stmt = stmt.order_by(column.desc() if (order or "desc").lower() == "desc" else column.asc()) + return stmt + + @classmethod + def get_by_stu_id(cls, db: Session, stu_id: int, with_deleted: bool = False) -> Employment | None: + stmt = select(Employment).where(Employment.stu_id == stu_id) + if not with_deleted: + stmt = stmt.where(Employment.alive()) + return db.scalars(stmt).unique().first() + + @classmethod + def employed_count_by_class(cls, db: Session) -> dict[int, int]: + """已就业(拿到 offer)人数按班级汇总。""" + stmt = ( + select(Employment.class_id, func.count(Employment.id)) + .where(Employment.alive(), Employment.offer_date.is_not(None)) + .group_by(Employment.class_id) + ) + return {row[0]: row[1] for row in db.execute(stmt).all()} + + @classmethod + def salary_stats(cls, db: Session, high_line: float) -> dict[str, float | int | None]: + stmt = select( + func.count(Employment.id), + func.avg(Employment.salary), + func.max(Employment.salary), + ).where(Employment.alive(), Employment.salary.is_not(None)) + total, avg, mx = db.execute(stmt).first() + high = db.scalar( + select(func.count(Employment.id)).where( + Employment.alive(), Employment.salary >= high_line + ) + ) + return { + "count": int(total or 0), + "avg": float(avg) if avg is not None else None, + "max": float(mx) if mx is not None else None, + "high": int(high or 0), + } diff --git a/app/dao/score_dao.py b/app/dao/score_dao.py new file mode 100644 index 0000000..58ba66f --- /dev/null +++ b/app/dao/score_dao.py @@ -0,0 +1,97 @@ +"""成绩 DAO(需求 2.2)。""" + +from __future__ import annotations + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.dao.base_dao import BaseDao +from app.model import Clazz, Score, Student + + +class ScoreDao(BaseDao[Score]): + model = Score + + @classmethod + def build_stmt( + cls, + stu_id: int | None = None, + exam_seq: int | None = None, + class_id: int | None = None, + flag: int | None = None, + min_score: float | None = None, + max_score: float | None = None, + keyword: str | None = None, + order_by: str = "id", + order: str = "desc", + ) -> Select: + stmt = select(Score).join(Student, Student.id == Score.stu_id).where( + Score.alive(), Student.alive() + ) + + if stu_id: + stmt = stmt.where(Score.stu_id == stu_id) + if exam_seq: + stmt = stmt.where(Score.exam_seq == exam_seq) + if class_id: + stmt = stmt.where(Student.class_id == class_id) + if flag is not None: + stmt = stmt.where(Score.flag == flag) + if min_score is not None: + stmt = stmt.where(Score.score >= min_score) + if max_score is not None: + stmt = stmt.where(Score.score <= max_score) + if keyword: + like = f"%{keyword.strip()}%" + stmt = stmt.where(or_(Student.name.like(like), Student.stu_no.like(like))) + + sortable = { + "id": Score.id, + "exam_seq": Score.exam_seq, + "score": Score.score, + "exam_date": Score.exam_date, + "student_name": Student.name, + "class_name": Clazz.name, + "stu_no": Student.stu_no, + } + column = sortable.get(order_by or "id", Score.id) + stmt = stmt.order_by(column.desc() if (order or "desc").lower() == "desc" else column.asc()) + return stmt + + @classmethod + def get_by_stu_seq(cls, db: Session, stu_id: int, exam_seq: int, with_deleted: bool = False) -> Score | None: + """按 (学生, 序次) 取记录 —— 唯一键保证最多一条。""" + stmt = select(Score).where(Score.stu_id == stu_id, Score.exam_seq == exam_seq) + if not with_deleted: + stmt = stmt.where(Score.alive()) + return db.scalars(stmt).unique().first() + + @classmethod + def list_by_student(cls, db: Session, stu_id: int) -> list[Score]: + stmt = select(Score).where(Score.alive(), Score.stu_id == stu_id).order_by(Score.exam_seq.asc()) + return list(db.scalars(stmt).unique().all()) + + @classmethod + def max_exam_seq(cls, db: Session) -> int: + return int(db.scalar(select(func.max(Score.exam_seq)).where(Score.alive())) or 0) + + @classmethod + def student_score_map(cls, db: Session) -> dict[int, list[float]]: + """一次性取出所有学生的成绩列表,给波动分析/均分用,避免 N+1。""" + stmt = select(Score.stu_id, Score.exam_seq, Score.score).where(Score.alive()).order_by( + Score.stu_id, Score.exam_seq + ) + result: dict[int, list[float]] = {} + for stu_id, _seq, score in db.execute(stmt).all(): + result.setdefault(stu_id, []).append(float(score)) + return result + + @classmethod + def exam_seq_list(cls, db: Session) -> list[int]: + stmt = select(Score.exam_seq).where(Score.alive()).distinct().order_by(Score.exam_seq) + return [int(x) for x in db.scalars(stmt).all()] + + @classmethod + def count_by_student(cls, db: Session) -> dict[int, int]: + stmt = select(Score.stu_id, func.count(Score.id)).where(Score.alive()).group_by(Score.stu_id) + return {row[0]: row[1] for row in db.execute(stmt).all()} diff --git a/app/dao/statistics_dao.py b/app/dao/statistics_dao.py new file mode 100644 index 0000000..f28aa43 --- /dev/null +++ b/app/dao/statistics_dao.py @@ -0,0 +1,316 @@ +"""统计分析 DAO(需求 2.6 与 2.7.2 的 SQL 部分)。 + +这里只放聚合查询;需要在 Python 里做的二次计算(标准差、排名、趋势判断) +放在 ``app.service.statistics_service``。 + +原则: + 能用一次 SQL 出结果的,绝不拉全表到 Python 里 for 循环。 +""" + +from __future__ import annotations + +from sqlalchemy import Select, case, distinct, func, select +from sqlalchemy.orm import Session + +from app.core.utils import age_expression, date_diff_days_expr +from app.model import Clazz, Employment, Score, Student, class_teachers + + +class StatisticsDao: + # ================================================================ 2.6.1 + @classmethod + def class_overview(cls, db: Session) -> list[dict]: + """每个班的总人数 + 男女分布。LEFT JOIN 保证"挂零的班"也在结果里。""" + stmt = ( + select( + Clazz.id, + Clazz.class_no, + Clazz.name, + Clazz.status, + Clazz.head_teacher_id, + func.count(Student.id).label("total"), + func.sum(case((Student.gender == 1, 1), else_=0)).label("male"), + func.sum(case((Student.gender == 2, 1), else_=0)).label("female"), + func.sum(case((Student.gender.notin_((1, 2)), 1), else_=0)).label("other"), + ) + .select_from(Clazz) + .outerjoin(Student, (Student.class_id == Clazz.id) & (Student.is_del == 0)) + .where(Clazz.is_del == 0) + .group_by(Clazz.id, Clazz.class_no, Clazz.name, Clazz.status, Clazz.head_teacher_id) + .order_by(Clazz.id.asc()) + ) + return [dict(row._mapping) for row in db.execute(stmt).all()] + + @classmethod + def age_distribution(cls, db: Session) -> list[dict]: + """年龄段分布(给饼图用)。""" + age_col = age_expression(Student.birth_date).label("age") + stmt = ( + select(age_col, func.count(Student.id)) + .where(Student.alive(), Student.birth_date.is_not(None)) + .group_by(age_col) + .order_by(age_col) + ) + return [{"age": int(a), "count": c} for a, c in db.execute(stmt).all()] + + # ================================================================ 2.6.2 + @classmethod + def all_above(cls, db: Session, threshold: float, class_id: int | None = None) -> list[dict]: + """每场考核都在分数线以上的学生:GROUP BY + HAVING MIN(score) >= 阈值。""" + agg = ( + select( + Score.stu_id.label("stu_id"), + func.count(Score.id).label("exam_count"), + func.min(Score.score).label("min_score"), + func.avg(Score.score).label("avg_score"), + func.max(Score.score).label("max_score"), + ) + .where(Score.alive()) + .group_by(Score.stu_id) + .having(func.min(Score.score) >= threshold) + .subquery() + ) + stmt = ( + select( + Student.id.label("stu_id"), + Student.stu_no, + Student.name, + Clazz.name.label("class_name"), + agg.c.exam_count, + agg.c.min_score, + agg.c.avg_score, + agg.c.max_score, + ) + .join(agg, agg.c.stu_id == Student.id) + .outerjoin(Clazz, Clazz.id == Student.class_id) + .where(Student.alive()) + .order_by(agg.c.avg_score.desc()) + ) + if class_id: + stmt = stmt.where(Student.class_id == class_id) + return [dict(row._mapping) for row in db.execute(stmt).all()] + + @classmethod + def fail_students(cls, db: Session, threshold: float, min_times: int) -> list[dict]: + """不及格次数 >= N 的学生:SUM(CASE WHEN score < 阈值 THEN 1 ELSE 0 END) >= N。""" + fail_flag = case((Score.score < threshold, 1), else_=0) + agg = ( + select( + Score.stu_id.label("stu_id"), + func.sum(fail_flag).label("fail_times"), + func.count(Score.id).label("exam_count"), + func.avg(Score.score).label("avg_score"), + ) + .where(Score.alive()) + .group_by(Score.stu_id) + .having(func.sum(fail_flag) >= min_times) + .subquery() + ) + stmt = ( + select( + Student.id.label("stu_id"), + Student.stu_no, + Student.name, + Clazz.name.label("class_name"), + Clazz.class_no, + agg.c.fail_times, + agg.c.exam_count, + agg.c.avg_score, + ) + .join(agg, agg.c.stu_id == Student.id) + .outerjoin(Clazz, Clazz.id == Student.class_id) + .where(Student.alive()) + .order_by(agg.c.fail_times.desc()) + ) + return [dict(row._mapping) for row in db.execute(stmt).all()] + + @classmethod + def class_exam_avg( + cls, + db: Session, + exam_seq: int | None = None, + class_id: int | None = None, + order: str = "desc", + pass_line: float = 60.0, + ) -> list[dict]: + """每场考核每个班级的平均分,支持按平均分动态升降序。 + + 排序:`order` 是**主导**键 —— 需求 2.6.2 要的是「按分数从高到低/从低到高动态排序」, + 如果让 exam_seq 排在前面,整张表就只是「每个场次内部有序」, + 选「全部场次 + 从高到低」时看到的不是排名而是 5 个分块。 + 场次和班级 ID 只作次级稳定键。 + """ + pass_flag = case((Score.score >= pass_line, 1), else_=0) + avg_col = func.avg(Score.score).label("avg_score") + stmt = ( + select( + Score.exam_seq, + Clazz.id.label("class_id"), + Clazz.name.label("class_name"), + Clazz.class_no, + func.count(Score.id).label("student_count"), + avg_col, + func.max(Score.score).label("max_score"), + func.min(Score.score).label("min_score"), + (func.sum(pass_flag) * 100.0 / func.count(Score.id)).label("pass_rate"), + ) + .join(Student, Student.id == Score.stu_id) + .join(Clazz, Clazz.id == Student.class_id) + .where(Score.alive(), Student.alive(), Clazz.alive()) + ) + if exam_seq is not None: + stmt = stmt.where(Score.exam_seq == exam_seq) + if class_id is not None: + stmt = stmt.where(Clazz.id == class_id) + stmt = stmt.group_by(Score.exam_seq, Clazz.id, Clazz.name, Clazz.class_no) + stmt = stmt.order_by( + avg_col.desc() if order.lower() == "desc" else avg_col.asc(), + Score.exam_seq.asc(), + Clazz.id.asc(), + ) + return [dict(row._mapping) for row in db.execute(stmt).all()] + + # ================================================================ 2.6.3 + @classmethod + def salary_top(cls, db: Session, top_n: int) -> list[dict]: + """就业薪资 Top N:ORDER BY salary DESC LIMIT N。""" + stmt = ( + select( + Student.id.label("stu_id"), + Student.stu_no, + Student.name, + Clazz.name.label("class_name"), + Employment.company, + Employment.salary, + Employment.open_date, + Employment.offer_date, + Employment.city, + ) + .join(Employment, Employment.stu_id == Student.id) + .outerjoin(Clazz, Clazz.id == Student.class_id) + .where(Student.alive(), Employment.alive(), Employment.salary.is_not(None)) + .order_by(Employment.salary.desc()) + .limit(top_n) + ) + return [dict(row._mapping) for row in db.execute(stmt).all()] + + @classmethod + def student_durations(cls, db: Session, class_id: int | None = None) -> list[dict]: + """每个学生的就业时长(SQL 侧算天数)。""" + duration = date_diff_days_expr(Employment.offer_date, Employment.open_date).label("duration_days") + stmt = ( + select( + Student.id.label("stu_id"), + Student.stu_no, + Student.name, + Clazz.name.label("class_name"), + Employment.company, + Employment.open_date, + Employment.offer_date, + duration, + ) + .join(Employment, Employment.stu_id == Student.id) + .outerjoin(Clazz, Clazz.id == Student.class_id) + .where(Student.alive(), Employment.alive(), Employment.open_date.is_not(None)) + .order_by(duration.desc()) + ) + if class_id: + stmt = stmt.where(Student.class_id == class_id) + return [dict(row._mapping) for row in db.execute(stmt).all()] + + @classmethod + def class_avg_duration(cls, db: Session) -> list[dict]: + """每个班级的平均就业时长(只统计已开放就业的学生)。""" + duration = date_diff_days_expr(Employment.offer_date, Employment.open_date) + stmt = ( + select( + Clazz.id.label("class_id"), + Clazz.class_no, + Clazz.name.label("class_name"), + func.count(distinct(Student.id)).label("student_count"), + func.count(distinct(case((Employment.open_date.is_not(None), Student.id)))).label("open_count"), + func.count(distinct(case((Employment.offer_date.is_not(None), Student.id)))).label("offer_count"), + func.avg(case((Employment.offer_date.is_not(None), duration))).label("avg_duration_days"), + func.min(case((Employment.offer_date.is_not(None), duration))).label("min_duration_days"), + func.max(case((Employment.offer_date.is_not(None), duration))).label("max_duration_days"), + ) + .select_from(Clazz) + .outerjoin(Student, (Student.class_id == Clazz.id) & (Student.is_del == 0)) + .outerjoin(Employment, (Employment.stu_id == Student.id) & (Employment.is_del == 0)) + .where(Clazz.is_del == 0) + .group_by(Clazz.id, Clazz.class_no, Clazz.name) + .order_by(func.avg(case((Employment.offer_date.is_not(None), duration)))) + ) + return [dict(row._mapping) for row in db.execute(stmt).all()] + + # ================================================================ 2.7.2 + @classmethod + def class_funnel(cls, db: Session, high_line: float) -> list[dict]: + """班级就业漏斗:总人数 -> 已就业 -> 高薪,就业率在 Python 里算完排序。""" + stmt = ( + select( + Clazz.id.label("class_id"), + Clazz.class_no, + Clazz.name.label("class_name"), + func.count(distinct(Student.id)).label("total"), + func.count(distinct(case((Employment.offer_date.is_not(None), Student.id)))).label("employed"), + func.count(distinct(case((Employment.salary >= high_line, Student.id)))).label("high_salary"), + func.avg(case((Employment.offer_date.is_not(None), Employment.salary))).label("avg_salary"), + func.avg( + case( + (Employment.offer_date.is_not(None), + date_diff_days_expr(Employment.offer_date, Employment.open_date)), + ) + ).label("avg_duration_days"), + ) + .select_from(Clazz) + .outerjoin(Student, (Student.class_id == Clazz.id) & (Student.is_del == 0)) + .outerjoin(Employment, (Employment.stu_id == Student.id) & (Employment.is_del == 0)) + .where(Clazz.is_del == 0) + .group_by(Clazz.id, Clazz.class_no, Clazz.name) + ) + return [dict(row._mapping) for row in db.execute(stmt).all()] + + @classmethod + def score_stats_per_student(cls, db: Session) -> list[dict]: + """每个学生的成绩次数 / 平均 / 最高 / 最低 / 最大分差(SQL 聚合)。""" + stmt = ( + select( + Score.stu_id, + func.count(Score.id).label("exam_count"), + func.avg(Score.score).label("avg_score"), + func.max(Score.score).label("max_score"), + func.min(Score.score).label("min_score"), + (func.max(Score.score) - func.min(Score.score)).label("score_range"), + ) + .where(Score.alive()) + .group_by(Score.stu_id) + .having(func.count(Score.id) >= 2) # 只有一次成绩谈不上波动 + ) + return [dict(row._mapping) for row in db.execute(stmt).all()] + + @classmethod + def score_samples(cls, db: Session, stu_ids: list[int] | None = None) -> dict[int, list[dict]]: + """按学生取成绩明细(按序次升序),一次查完。""" + stmt = select(Score.stu_id, Score.exam_seq, Score.score, Score.flag).where(Score.alive()) + if stu_ids: + stmt = stmt.where(Score.stu_id.in_(stu_ids)) + stmt = stmt.order_by(Score.stu_id, Score.exam_seq) + result: dict[int, list[dict]] = {} + for stu_id, seq, score, flag in db.execute(stmt).all(): + result.setdefault(stu_id, []).append( + {"exam_seq": int(seq), "score": float(score), "flag": int(flag)} + ) + return result + + # ================================================================ 总览 + @classmethod + def teacher_class_load(cls, db: Session) -> list[dict]: + """老师带班数量(给概览用)。""" + stmt = ( + select(class_teachers.c.teacher_id, func.count(class_teachers.c.class_id)) + .select_from(class_teachers.join(Clazz, Clazz.id == class_teachers.c.class_id)) + .where(Clazz.alive()) + .group_by(class_teachers.c.teacher_id) + ) + return [{"teacher_id": t, "class_count": c} for t, c in db.execute(stmt).all()] diff --git a/app/dao/student_dao.py b/app/dao/student_dao.py new file mode 100644 index 0000000..628d6be --- /dev/null +++ b/app/dao/student_dao.py @@ -0,0 +1,127 @@ +"""学生 DAO(需求 2.1 的查询部分)。""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.core.utils import age_expression +from app.dao.base_dao import BaseDao +from app.model import Clazz, Score, Student + + +class StudentDao(BaseDao[Student]): + model = Student + + # ------------------------------------------------------------ 查询条件 + @classmethod + def build_stmt( + cls, + keyword: str | None = None, + class_id: int | None = None, + class_no: str | None = None, + status: int | None = None, + gender: int | None = None, + advisor_id: int | None = None, + education: str | None = None, + age_min: int | None = None, + age_max: int | None = None, + order_by: str = "id", + order: str = "desc", + ) -> Select: + stmt = select(Student).where(Student.alive()) + + if keyword: + like = f"%{keyword.strip()}%" + stmt = stmt.where( + or_( + Student.name.like(like), + Student.stu_no.like(like), + Student.phone.like(like), + Student.major.like(like), + Student.graduate_school.like(like), + Student.native_place.like(like), + ) + ) + if class_id: + stmt = stmt.where(Student.class_id == class_id) + if class_no: + stmt = stmt.where( + Student.class_id.in_(select(Clazz.id).where(Clazz.class_no == class_no, Clazz.alive())) + ) + if status: + stmt = stmt.where(Student.status == status) + if gender: + stmt = stmt.where(Student.gender == gender) + if advisor_id: + stmt = stmt.where(Student.advisor_id == advisor_id) + if education: + stmt = stmt.where(Student.education == education) + if age_min is not None: + stmt = stmt.where(age_expression(Student.birth_date) >= age_min) + if age_max is not None: + stmt = stmt.where(age_expression(Student.birth_date) <= age_max) + + # 排序:白名单映射,避免直接把前端字符串塞进 order_by + sortable = { + "id": Student.id, + "stu_no": Student.stu_no, + "name": Student.name, + "age": age_expression(Student.birth_date), + "enroll_date": Student.enroll_date, + "graduate_date": Student.graduate_date, + "status": Student.status, + "class_id": Student.class_id, + } + column = sortable.get(order_by or "id", Student.id) + stmt = stmt.order_by(column.desc() if (order or "desc").lower() == "desc" else column.asc()) + return stmt + + # ------------------------------------------------------------ 专用查询 + @classmethod + def get_by_stu_no(cls, db: Session, stu_no: str, with_deleted: bool = False) -> Student | None: + stmt = select(Student).where(Student.stu_no == stu_no) + if not with_deleted: + stmt = stmt.where(Student.alive()) + return db.scalars(stmt).unique().first() + + @classmethod + def count_by_class(cls, db: Session) -> dict[int, int]: + stmt = ( + select(Student.class_id, func.count(Student.id)) + .where(Student.alive(), Student.class_id.is_not(None)) + .group_by(Student.class_id) + ) + return cls.group_count(db, stmt) + + @classmethod + def count_by_status(cls, db: Session) -> dict[int, int]: + stmt = select(Student.status, func.count(Student.id)).where(Student.alive()).group_by(Student.status) + return cls.group_count(db, stmt) + + @classmethod + def last_stu_no(cls, db: Session, prefix: str) -> str | None: + """取同前缀下最大的学号(含已删除,避免复用导致唯一键撞车)。""" + stmt = ( + select(func.max(Student.stu_no)) + .where(Student.stu_no.like(f"{prefix}%")) + ) + return db.scalar(stmt) + + @classmethod + def get_with_relations(cls, db: Session, pk: int) -> Student | None: + return cls.get(db, pk) + + # ------------------------------------------------------------ 成绩相关 + @classmethod + def warning_student_ids(cls, db: Session) -> list[int]: + stmt = select(func.distinct(Score.stu_id)).where(Score.alive(), Score.flag == 1) + return list(db.scalars(stmt).all()) + + @classmethod + def birthday_stats(cls, db: Session) -> dict[str, date | None]: + stmt = select(func.min(Student.birth_date), func.max(Student.birth_date)).where(Student.alive()) + row = db.execute(stmt).first() + return {"min": row[0] if row else None, "max": row[1] if row else None} diff --git a/app/dao/teacher_dao.py b/app/dao/teacher_dao.py new file mode 100644 index 0000000..74bf865 --- /dev/null +++ b/app/dao/teacher_dao.py @@ -0,0 +1,79 @@ +"""老师 DAO(需求 2.5)。""" + +from __future__ import annotations + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.dao.base_dao import BaseDao +from app.model import Clazz, Teacher, class_teachers + + +class TeacherDao(BaseDao[Teacher]): + model = Teacher + + @classmethod + def build_stmt( + cls, + keyword: str | None = None, + subject: str | None = None, + title: str | None = None, + gender: int | None = None, + class_id: int | None = None, + order_by: str = "id", + order: str = "desc", + ) -> Select: + stmt = select(Teacher).where(Teacher.alive()) + + if keyword: + like = f"%{keyword.strip()}%" + stmt = stmt.where( + or_(Teacher.name.like(like), Teacher.teacher_no.like(like), Teacher.phone.like(like)) + ) + if subject: + stmt = stmt.where(Teacher.subject.like(f"%{subject}%")) + if title: + stmt = stmt.where(Teacher.title == title) + if gender: + stmt = stmt.where(Teacher.gender == gender) + if class_id: + stmt = stmt.where( + Teacher.id.in_( + select(class_teachers.c.teacher_id).where(class_teachers.c.class_id == class_id) + ) + ) + + sortable = { + "id": Teacher.id, + "teacher_no": Teacher.teacher_no, + "name": Teacher.name, + "hire_date": Teacher.hire_date, + "subject": Teacher.subject, + } + column = sortable.get(order_by or "id", Teacher.id) + stmt = stmt.order_by(column.desc() if (order or "desc").lower() == "desc" else column.asc()) + return stmt + + @classmethod + def get_by_no(cls, db: Session, teacher_no: str, with_deleted: bool = False) -> Teacher | None: + stmt = select(Teacher).where(Teacher.teacher_no == teacher_no) + if not with_deleted: + stmt = stmt.where(Teacher.alive()) + return db.scalars(stmt).unique().first() + + @classmethod + def next_teacher_no(cls, db: Session, year: int) -> str: + prefix = f"T{year}" + last = db.scalar(select(func.max(Teacher.teacher_no)).where(Teacher.teacher_no.like(f"{prefix}%"))) + seq = int(last[len(prefix):]) + 1 if last and last[len(prefix):].isdigit() else 1 + return f"{prefix}{seq:03d}" + + @classmethod + def class_count_map(cls, db: Session) -> dict[int, int]: + stmt = ( + select(class_teachers.c.teacher_id, func.count(class_teachers.c.class_id)) + .select_from(class_teachers.join(Clazz, Clazz.id == class_teachers.c.class_id)) + .where(Clazz.alive()) + .group_by(class_teachers.c.teacher_id) + ) + return {row[0]: row[1] for row in db.execute(stmt).all()} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..d8a0c4b --- /dev/null +++ b/app/main.py @@ -0,0 +1,167 @@ +"""应用入口。 + +启动方式: + uvicorn app.main:app --reload # 开发 + python -m app.main # 等价写法(会先自动建表) + +接口文档: + http://127.0.0.1:8000/docs # Swagger UI + http://127.0.0.1:8000/redoc # ReDoc + http://127.0.0.1:8000/ # 前端页面 +""" + +from __future__ import annotations + +import logging +import sys +import time +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from sqlalchemy.exc import IntegrityError, SQLAlchemyError + +# --- 让「直接运行本文件」也能工作 ------------------------------------------- +# `python app/main.py`(或 PyCharm 里右键 Run)时,Python 把 sys.path[0] +# 设成**脚本所在目录** app/,于是下面那行 `from app.api import ...` +# 找不到 app 包,报 ModuleNotFoundError: No module named 'app'。 +# 把项目根补进 sys.path,这条路才通。 +# 用 `python -m app.main` / `uvicorn app.main:app` / 项目根的 run.py 启动时, +# 项目根本来就在 sys.path 里,这几行是无害的。 +_ROOT = str(Path(__file__).resolve().parent.parent) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) +# --------------------------------------------------------------------------- + +from app.api import api_router +from app.core.config import settings +from app.core.database import engine, ensure_database_exists +from app.core.exceptions import BusinessError +from app.model import Base + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-7s %(name)s %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("wolin") + +STATIC_DIR = Path(__file__).parent / "static" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + ensure_database_exists() + Base.metadata.create_all(bind=engine) + logger.info("数据库就绪:%s", settings.DB_NAME) + logger.info("接口文档:http://127.0.0.1:8000/docs") + yield + engine.dispose() + + +app = FastAPI( + title=settings.APP_NAME, + description=( + f"{settings.APP_DESC}\n\n" + "**统一响应格式**:`{code, msg, data}`,`code=0` 表示成功。\n\n" + "**鉴权**:除 `/auth/login` 外都需要在请求头带 `Authorization: Bearer `。\n\n" + "**分层**:api(路由)/ service(业务)/ dao(数据访问)/ model(ORM)/ schema(校验)。" + ), + version=settings.VERSION, + lifespan=lifespan, + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ==================================================================== 异常处理 +@app.exception_handler(BusinessError) +async def business_error_handler(request: Request, exc: BusinessError): + return JSONResponse(status_code=200, content={"code": exc.code, "msg": exc.msg, "data": exc.data}) + + +@app.exception_handler(RequestValidationError) +async def validation_error_handler(request: Request, exc: RequestValidationError): + """把 Pydantic 的报错翻译成人话,前端可以直接弹。""" + problems = [] + for error in exc.errors(): + location = " → ".join(str(x) for x in error.get("loc", []) if x != "body") + problems.append(f"{location}: {error.get('msg')}") + return JSONResponse( + status_code=200, + content={"code": 422, "msg": "参数校验未通过:" + ";".join(problems), "data": {"errors": problems}}, + ) + + +@app.exception_handler(IntegrityError) +async def integrity_error_handler(request: Request, exc: IntegrityError): + logger.warning("数据库约束冲突:%s", exc) + return JSONResponse(status_code=200, content={"code": 409, "msg": "数据冲突(唯一键重复或外键不合法)", "data": None}) + + +@app.exception_handler(SQLAlchemyError) +async def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError): + logger.exception("数据库错误") + return JSONResponse(status_code=200, content={"code": 500, "msg": f"数据库操作失败:{exc}", "data": None}) + + +@app.exception_handler(Exception) +async def unhandled_error_handler(request: Request, exc: Exception): + logger.exception("未处理异常:%s %s", request.method, request.url.path) + return JSONResponse(status_code=200, content={"code": 500, "msg": f"服务器内部错误:{exc}", "data": None}) + + +# ==================================================================== 中间件 +@app.middleware("http") +async def add_process_time(request: Request, call_next): + start = time.perf_counter() + response = await call_next(request) + response.headers["X-Process-Time-ms"] = f"{(time.perf_counter() - start) * 1000:.1f}" + return response + + +# ==================================================================== 路由 +app.include_router(api_router) + + +@app.get("/api/health", tags=["系统"], summary="健康检查") +def health(): + return { + "code": 0, + "msg": "ok", + "data": { + "app": settings.APP_NAME, + "version": settings.VERSION, + "database": settings.DB_NAME, + "dialect": engine.dialect.name, + "auth_enabled": settings.AUTH_ENABLED, + }, + } + + +# ==================================================================== 前端 +if STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + @app.get("/", include_in_schema=False) + def index(): + return FileResponse(str(STATIC_DIR / "index.html")) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("app.main:app", host="127.0.0.1", port=8000, reload=False) diff --git a/app/model/__init__.py b/app/model/__init__.py new file mode 100644 index 0000000..37f6968 --- /dev/null +++ b/app/model/__init__.py @@ -0,0 +1,59 @@ +"""集中导出所有 ORM 模型。 + +Base.metadata 只有把模型都 import 进来才知道有哪些表, +所以 init_db / alembic 一律 `from app.model import *`。 +""" + +from app.model.account import Account +from app.model.advisor import Advisor +from app.model.assoc import class_teachers +from app.model.base import ( + DEL_FLAG_DELETED, + DEL_FLAG_NORMAL, + Base, + SoftDeleteMixin, + TimestampMixin, +) +from app.model.clazz import Clazz +from app.model.constants import ( + CLASS_STATUS_TEXT, + GENDER_TEXT, + ROLE_TEXT, + STUDENT_STATUS_TEXT, + ClassStatus, + Education, + Gender, + Role, + ScoreFlag, + StudentStatus, +) +from app.model.employment import Employment +from app.model.score import Score +from app.model.student import Student +from app.model.teacher import Teacher + +__all__ = [ + "Account", + "Advisor", + "Base", + "ClassStatus", + "Clazz", + "DEL_FLAG_DELETED", + "DEL_FLAG_NORMAL", + "Education", + "Employment", + "Gender", + "Role", + "Score", + "ScoreFlag", + "SoftDeleteMixin", + "Student", + "StudentStatus", + "Teacher", + "TimestampMixin", + "CLASS_STATUS_TEXT", + "GENDER_TEXT", + "ROLE_TEXT", + "STUDENT_STATUS_TEXT", + "class_teachers", +] diff --git a/app/model/__pycache__/__init__.cpython-311.pyc b/app/model/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..021817c Binary files /dev/null and b/app/model/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/model/__pycache__/__init__.cpython-313.pyc b/app/model/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..dc508b3 Binary files /dev/null and b/app/model/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/model/__pycache__/account.cpython-311.pyc b/app/model/__pycache__/account.cpython-311.pyc new file mode 100644 index 0000000..4995ace Binary files /dev/null and b/app/model/__pycache__/account.cpython-311.pyc differ diff --git a/app/model/__pycache__/account.cpython-313.pyc b/app/model/__pycache__/account.cpython-313.pyc new file mode 100644 index 0000000..4d15e0e Binary files /dev/null and b/app/model/__pycache__/account.cpython-313.pyc differ diff --git a/app/model/__pycache__/advisor.cpython-311.pyc b/app/model/__pycache__/advisor.cpython-311.pyc new file mode 100644 index 0000000..421c256 Binary files /dev/null and b/app/model/__pycache__/advisor.cpython-311.pyc differ diff --git a/app/model/__pycache__/advisor.cpython-313.pyc b/app/model/__pycache__/advisor.cpython-313.pyc new file mode 100644 index 0000000..324e388 Binary files /dev/null and b/app/model/__pycache__/advisor.cpython-313.pyc differ diff --git a/app/model/__pycache__/assoc.cpython-311.pyc b/app/model/__pycache__/assoc.cpython-311.pyc new file mode 100644 index 0000000..9ea9562 Binary files /dev/null and b/app/model/__pycache__/assoc.cpython-311.pyc differ diff --git a/app/model/__pycache__/assoc.cpython-313.pyc b/app/model/__pycache__/assoc.cpython-313.pyc new file mode 100644 index 0000000..589b835 Binary files /dev/null and b/app/model/__pycache__/assoc.cpython-313.pyc differ diff --git a/app/model/__pycache__/base.cpython-311.pyc b/app/model/__pycache__/base.cpython-311.pyc new file mode 100644 index 0000000..6eec2c7 Binary files /dev/null and b/app/model/__pycache__/base.cpython-311.pyc differ diff --git a/app/model/__pycache__/base.cpython-313.pyc b/app/model/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000..03ec7ea Binary files /dev/null and b/app/model/__pycache__/base.cpython-313.pyc differ diff --git a/app/model/__pycache__/clazz.cpython-311.pyc b/app/model/__pycache__/clazz.cpython-311.pyc new file mode 100644 index 0000000..1aa08b4 Binary files /dev/null and b/app/model/__pycache__/clazz.cpython-311.pyc differ diff --git a/app/model/__pycache__/clazz.cpython-313.pyc b/app/model/__pycache__/clazz.cpython-313.pyc new file mode 100644 index 0000000..fc2eaa8 Binary files /dev/null and b/app/model/__pycache__/clazz.cpython-313.pyc differ diff --git a/app/model/__pycache__/constants.cpython-311.pyc b/app/model/__pycache__/constants.cpython-311.pyc new file mode 100644 index 0000000..d31836b Binary files /dev/null and b/app/model/__pycache__/constants.cpython-311.pyc differ diff --git a/app/model/__pycache__/constants.cpython-313.pyc b/app/model/__pycache__/constants.cpython-313.pyc new file mode 100644 index 0000000..b93cf13 Binary files /dev/null and b/app/model/__pycache__/constants.cpython-313.pyc differ diff --git a/app/model/__pycache__/employment.cpython-311.pyc b/app/model/__pycache__/employment.cpython-311.pyc new file mode 100644 index 0000000..37e1dcd Binary files /dev/null and b/app/model/__pycache__/employment.cpython-311.pyc differ diff --git a/app/model/__pycache__/employment.cpython-313.pyc b/app/model/__pycache__/employment.cpython-313.pyc new file mode 100644 index 0000000..c196ad6 Binary files /dev/null and b/app/model/__pycache__/employment.cpython-313.pyc differ diff --git a/app/model/__pycache__/score.cpython-311.pyc b/app/model/__pycache__/score.cpython-311.pyc new file mode 100644 index 0000000..1234bed Binary files /dev/null and b/app/model/__pycache__/score.cpython-311.pyc differ diff --git a/app/model/__pycache__/score.cpython-313.pyc b/app/model/__pycache__/score.cpython-313.pyc new file mode 100644 index 0000000..f0d81b7 Binary files /dev/null and b/app/model/__pycache__/score.cpython-313.pyc differ diff --git a/app/model/__pycache__/student.cpython-311.pyc b/app/model/__pycache__/student.cpython-311.pyc new file mode 100644 index 0000000..25cd404 Binary files /dev/null and b/app/model/__pycache__/student.cpython-311.pyc differ diff --git a/app/model/__pycache__/student.cpython-313.pyc b/app/model/__pycache__/student.cpython-313.pyc new file mode 100644 index 0000000..50e89cf Binary files /dev/null and b/app/model/__pycache__/student.cpython-313.pyc differ diff --git a/app/model/__pycache__/teacher.cpython-311.pyc b/app/model/__pycache__/teacher.cpython-311.pyc new file mode 100644 index 0000000..41ad23b Binary files /dev/null and b/app/model/__pycache__/teacher.cpython-311.pyc differ diff --git a/app/model/__pycache__/teacher.cpython-313.pyc b/app/model/__pycache__/teacher.cpython-313.pyc new file mode 100644 index 0000000..03414d5 Binary files /dev/null and b/app/model/__pycache__/teacher.cpython-313.pyc differ diff --git a/app/model/account.py b/app/model/account.py new file mode 100644 index 0000000..4f7fa41 --- /dev/null +++ b/app/model/account.py @@ -0,0 +1,35 @@ +"""登录账号表(需求第 5 节的"用户认证模块",这版直接做进来了)。""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.model.base import Base, SoftDeleteMixin, TimestampMixin +from app.model.constants import ROLE_TEXT, Role + + +class Account(Base, TimestampMixin, SoftDeleteMixin): + __tablename__ = "account" + __table_args__ = {"comment": "登录账号表"} + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="登录名") + hashed_password: Mapped[str] = mapped_column(String(120), nullable=False, comment="bcrypt 哈希后的密码") + real_name: Mapped[str] = mapped_column(String(30), nullable=False, comment="姓名") + role: Mapped[str] = mapped_column(String(20), default=Role.ADVISOR.value, nullable=False, comment="角色 admin/advisor/viewer") + advisor_id: Mapped[int | None] = mapped_column(ForeignKey("advisor.id"), comment="关联顾问(顾问账号专用)") + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="是否启用") + last_login_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近登录时间") + + advisor: Mapped["Advisor"] = relationship(lazy="joined") # noqa: F821 + + @property + def role_text(self) -> str: + return ROLE_TEXT.get(self.role, self.role) + + @property + def is_admin(self) -> bool: + return self.role == Role.ADMIN.value diff --git a/app/model/advisor.py b/app/model/advisor.py new file mode 100644 index 0000000..c572efb --- /dev/null +++ b/app/model/advisor.py @@ -0,0 +1,22 @@ +"""顾问表(需求 2.1 学生表里的"顾问编号"指向它,也是第 5 节的扩展模块)。""" + +from __future__ import annotations + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from app.model.base import Base, SoftDeleteMixin, TimestampMixin + + +class Advisor(Base, TimestampMixin, SoftDeleteMixin): + __tablename__ = "advisor" + __table_args__ = {"comment": "顾问表"} + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + advisor_no: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, comment="顾问编号") + name: Mapped[str] = mapped_column(String(30), nullable=False, index=True, comment="顾问姓名") + gender: Mapped[int] = mapped_column(default=1, nullable=False, comment="性别 1=男 2=女") + phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话") + email: Mapped[str | None] = mapped_column(String(60), comment="邮箱") + dept: Mapped[str | None] = mapped_column(String(50), comment="所属部门") + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") diff --git a/app/model/assoc.py b/app/model/assoc.py new file mode 100644 index 0000000..d01222e --- /dev/null +++ b/app/model/assoc.py @@ -0,0 +1,19 @@ +"""班级 <-> 老师 多对多中间表。 + +单独放一个文件是为了让 clazz.py / teacher.py 都能引用它, +又不会互相 import 形成环。 +""" + +from __future__ import annotations + +from sqlalchemy import Column, ForeignKey, Table + +from app.model.base import Base + +class_teachers = Table( + "class_teachers", + Base.metadata, + Column("class_id", ForeignKey("clazz.id", ondelete="CASCADE"), primary_key=True, comment="班级ID"), + Column("teacher_id", ForeignKey("teacher.id", ondelete="CASCADE"), primary_key=True, comment="老师ID"), + comment="班级-授课老师 关联表(一个班多个授课老师,一个老师带多个班)", +) diff --git a/app/model/base.py b/app/model/base.py new file mode 100644 index 0000000..d9329e7 --- /dev/null +++ b/app/model/base.py @@ -0,0 +1,64 @@ +"""ORM 基类与公共 Mixin。 + +约定: + * 主键统一 id 自增 + * 所有表都带 created_at / updated_at + * 业务表一律逻辑删除:is_del = 0 表示正常,1 表示已删除 + * 查询正常数据统一写 `Model.alive()`,禁止在 DAO 里手写 `is_del == 1` + (上一版项目就是把方向写反导致了"查不到刚建的数据") +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, SmallInteger +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +DEL_FLAG_NORMAL: int = 0 +DEL_FLAG_DELETED: int = 1 + + +class Base(DeclarativeBase): + """SQLAlchemy 2.0 DeclarativeBase。""" + + def to_dict(self, exclude: set[str] | None = None) -> dict[str, Any]: + exclude = exclude or set() + return { + c.name: getattr(self, c.name) + for c in self.__table__.columns + if c.name not in exclude + } + + def __repr__(self) -> str: # pragma: no cover - 调试用 + pk = getattr(self, "id", None) + return f"<{self.__class__.__name__} id={pk}>" + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.now, nullable=False, comment="创建时间" + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment="更新时间" + ) + + +class SoftDeleteMixin: + is_del: Mapped[int] = mapped_column( + SmallInteger, + default=DEL_FLAG_NORMAL, + server_default="0", + nullable=False, + index=True, + comment="逻辑删除:0=正常 1=已删除", + ) + + @classmethod + def alive(cls): + """正常数据的过滤条件,供 select().where() 使用。""" + return cls.is_del == DEL_FLAG_NORMAL + + def soft_delete(self) -> None: + self.is_del = DEL_FLAG_DELETED diff --git a/app/model/clazz.py b/app/model/clazz.py new file mode 100644 index 0000000..1567466 --- /dev/null +++ b/app/model/clazz.py @@ -0,0 +1,68 @@ +"""班级表(需求 2.4)。""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy import Date, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.model.assoc import class_teachers +from app.model.base import Base, SoftDeleteMixin, TimestampMixin +from app.model.constants import CLASS_STATUS_TEXT, ClassStatus + + +class Clazz(Base, TimestampMixin, SoftDeleteMixin): + __tablename__ = "clazz" + __table_args__ = {"comment": "班级表"} + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + class_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False, comment="班级编号,如 JAVA2401") + name: Mapped[str] = mapped_column(String(50), nullable=False, index=True, comment="班级名称") + direction: Mapped[str | None] = mapped_column(String(50), comment="班级方向,如 Java 就业班") + open_date: Mapped[date | None] = mapped_column(Date, comment="开课时间") + close_date: Mapped[date | None] = mapped_column(Date, comment="结课时间") + classroom: Mapped[str | None] = mapped_column(String(30), comment="教室") + capacity: Mapped[int | None] = mapped_column(default=40, comment="计划招生人数") + status: Mapped[int] = mapped_column(default=int(ClassStatus.RUNNING), nullable=False, comment="班级状态 1=在读 2=已结课 3=已解散") + + head_teacher_id: Mapped[int | None] = mapped_column(ForeignKey("teacher.id"), comment="班主任(老师)") + advisor_id: Mapped[int | None] = mapped_column(ForeignKey("advisor.id"), comment="带班顾问") + + description: Mapped[str | None] = mapped_column(Text, comment="班级说明") + + # ---------------- 关系 ---------------- + head_teacher: Mapped["Teacher"] = relationship(lazy="joined", foreign_keys=[head_teacher_id]) # noqa: F821 + advisor: Mapped["Advisor"] = relationship(lazy="joined") # noqa: F821 + teachers: Mapped[list["Teacher"]] = relationship( # noqa: F821 + secondary=class_teachers, + back_populates="classes", + lazy="selectin", + ) + # viewonly:人数统计走 DAO 的聚合查询,这个关系只在班级详情里用来展开学生 + students: Mapped[list["Student"]] = relationship( # noqa: F821 + back_populates="klass", + viewonly=True, + lazy="select", + ) + + # ---------------- 出参派生字段 ---------------- + @property + def status_text(self) -> str: + return CLASS_STATUS_TEXT.get(self.status, "未知") + + @property + def head_teacher_name(self) -> str | None: + return self.head_teacher.name if self.head_teacher else None + + @property + def advisor_name(self) -> str | None: + return self.advisor.name if self.advisor else None + + @property + def teacher_names(self) -> list[str]: + return [t.name for t in self.teachers if t.is_del == 0] + + @property + def teacher_ids(self) -> list[int]: + return [t.id for t in self.teachers if t.is_del == 0] diff --git a/app/model/constants.py b/app/model/constants.py new file mode 100644 index 0000000..a696761 --- /dev/null +++ b/app/model/constants.py @@ -0,0 +1,92 @@ +"""全局枚举与文案映射。 + +数据库里一律存 int 代号(省得 MySQL ENUM 改起来要 ALTER), +对外出参把代号 + 中文文案一起给出去,前端不用再维护一份映射表。 +""" + +from __future__ import annotations + +from enum import Enum, IntEnum + + +class Gender(IntEnum): + MALE = 1 + FEMALE = 2 + + +GENDER_TEXT: dict[int, str] = {Gender.MALE: "男", Gender.FEMALE: "女"} + + +class StudentStatus(IntEnum): + STUDYING = 1 # 在读 + EMPLOYING = 2 # 进入就业(已开放就业,还没拿到 offer) + EMPLOYED = 3 # 已就业(已下发 offer) + + +STUDENT_STATUS_TEXT: dict[int, str] = { + StudentStatus.STUDYING: "在读", + StudentStatus.EMPLOYING: "进入就业", + StudentStatus.EMPLOYED: "已就业", +} + + +class ClassStatus(IntEnum): + RUNNING = 1 # 在读 + GRADUATED = 2 # 已结课 + DISBANDED = 3 # 已解散 + + +CLASS_STATUS_TEXT: dict[int, str] = { + ClassStatus.RUNNING: "在读", + ClassStatus.GRADUATED: "已结课", + ClassStatus.DISBANDED: "已解散", +} + + +class ScoreFlag(IntEnum): + NORMAL = 0 # 正常 + WARNING = 1 # 低于红线,需重点关注 + + +class Education(str, Enum): + """学历(自由文本也用这几个做下拉默认值)。""" + + JUNIOR = "初中" + SENIOR = "高中" + TECHNICAL = "中专" + COLLEGE = "大专" + BACHELOR = "本科" + MASTER = "硕士" + + +class Role(str, Enum): + ADMIN = "admin" # 教务管理员:全部权限 + ADVISOR = "advisor" # 顾问:日常录入 + VIEWER = "viewer" # 只读 + + +ROLE_TEXT: dict[str, str] = { + Role.ADMIN: "管理员", + Role.ADVISOR: "顾问", + Role.VIEWER: "只读访客", +} + + +def gender_to_code(value: int | str | None) -> int | None: + """把 "男"/"女"/1/2 统一转成代号,供查询参数使用。""" + if value in (None, ""): + return None + if isinstance(value, int): + return value if value in (1, 2) else None + text = str(value).strip() + if text in ("男", "M", "m", "male", "Male"): + return int(Gender.MALE) + if text in ("女", "F", "f", "female", "Female"): + return int(Gender.FEMALE) + if text.isdigit(): + return int(text) + return None + + +def gender_to_text(value: int | None) -> str | None: + return GENDER_TEXT.get(value) if value is not None else None diff --git a/app/model/employment.py b/app/model/employment.py new file mode 100644 index 0000000..831dc74 --- /dev/null +++ b/app/model/employment.py @@ -0,0 +1,90 @@ +"""就业信息表(需求 2.3)。 + +关于「学生姓名、学生班级属于冗余字段」的处理 +-------------------------------------------------------------------------- +需求点名要自行设计表结构来平衡查询效率与一致性。这里的取舍: + +* **学生姓名不落库。** 它是 student.name 的投影,1:1 关系, + JOIN 一次的成本可以忽略;而一旦落库,改名就会两处不一致,收益为零。查询接口 + 统一 `JOIN student` 取名字。 +* **班级落库(class_id 而非 class_name)。** 因为"按班级筛就业、按班级算就业率/ + 平均就业时长"是高频统计场景,每次 JOIN student 再 JOIN clazz 成本更高; + 而且存 id 不存名字,班级改名不会导致脏数据。 + 代价是学生转班时要同步——由 ``EmploymentService.sync_class_id()`` 在学生 + 接口里兜住,不给"记得改"留口子。 +* **就业时长不落库**,= offer_date - open_date,用 SQL DATEDIFF 或 Python 现算。 + +状态联动(需求 2.3 可选扩展,这里是必做): + 登记就业 -> 有 open_date 则学生 status=2(进入就业), + -> 有 offer_date 则学生 status=3(已就业) + 删除就业记录 -> 学生状态回退(3 -> 2 -> 1),不允许跨级跳。 +""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.model.base import Base, SoftDeleteMixin, TimestampMixin + + +class Employment(Base, TimestampMixin, SoftDeleteMixin): + __tablename__ = "employment" + __table_args__ = {"comment": "学生就业信息表"} + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + stu_id: Mapped[int] = mapped_column( + ForeignKey("student.id"), unique=True, nullable=False, index=True, + comment="学生ID(一学生一条就业记录)", + ) + class_id: Mapped[int | None] = mapped_column( + ForeignKey("clazz.id"), index=True, + comment="班级ID(冗余,用于按班统计,转班时由 service 同步)", + ) + + open_date: Mapped[date | None] = mapped_column(Date, comment="就业开放时间") + offer_date: Mapped[date | None] = mapped_column(Date, comment="offer 下发时间") + company: Mapped[str | None] = mapped_column(String(80), index=True, comment="就业公司名称") + salary: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), index=True, comment="就业薪资(元/月)") + position: Mapped[str | None] = mapped_column(String(50), comment="岗位名称") + city: Mapped[str | None] = mapped_column(String(30), comment="就业城市") + remark: Mapped[str | None] = mapped_column(Text, comment="备注") + + student: Mapped["Student"] = relationship(back_populates="employment", lazy="joined") # noqa: F821 + klass: Mapped["Clazz"] = relationship(lazy="joined", foreign_keys=[class_id]) # noqa: F821 + + # ---------------- 出参派生字段 ---------------- + @property + def student_name(self) -> str | None: + return self.student.name if self.student else None + + @property + def stu_no(self) -> str | None: + return self.student.stu_no if self.student else None + + @property + def student_status(self) -> int | None: + return self.student.status if self.student else None + + @property + def student_status_text(self) -> str | None: + return self.student.status_text if self.student else None + + @property + def class_name(self) -> str | None: + # 冗余字段只存了 id,名字实时取,保证与班级表一致 + return self.klass.name if self.klass and self.klass.is_del == 0 else None + + @property + def duration_days(self) -> int | None: + """就业时长 = offer 下发时间 - 就业开放时间(天)。""" + if self.open_date and self.offer_date: + return (self.offer_date - self.open_date).days + return None + + @property + def salary_wan(self) -> float | None: + return round(float(self.salary) / 10000, 2) if self.salary is not None else None diff --git a/app/model/score.py b/app/model/score.py new file mode 100644 index 0000000..d34d318 --- /dev/null +++ b/app/model/score.py @@ -0,0 +1,51 @@ +"""成绩表(需求 2.2):一个学生 N 次考核,一对多。 + +红线预警:录入分数低于及格线时 flag=1,并往学生备注里追加一条提醒, +统计模块的"多次不及格"直接按 flag 聚合,不用每次临时算阈值。 +""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Numeric, SmallInteger, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.model.base import Base, SoftDeleteMixin, TimestampMixin +from app.model.constants import ScoreFlag + + +class Score(Base, TimestampMixin, SoftDeleteMixin): + __tablename__ = "score" + __table_args__ = ( + UniqueConstraint("stu_id", "exam_seq", name="uk_score_stu_seq"), + {"comment": "学生考核成绩表"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + stu_id: Mapped[int] = mapped_column(ForeignKey("student.id"), nullable=False, index=True, comment="学生ID") + exam_seq: Mapped[int] = mapped_column(SmallInteger, nullable=False, index=True, comment="考核序次(第几次考核)") + exam_name: Mapped[str | None] = mapped_column(String(50), comment="考核名称,如 阶段一考试") + exam_date: Mapped[date | None] = mapped_column(Date, comment="考核日期") + score: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, comment="成绩") + flag: Mapped[int] = mapped_column( + SmallInteger, default=int(ScoreFlag.NORMAL), nullable=False, index=True, + comment="预警标记 0=正常 1=低于红线需重点关注", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + student: Mapped["Student"] = relationship(back_populates="scores", lazy="joined") # noqa: F821 + + # ---------------- 出参派生字段 ---------------- + @property + def is_warning(self) -> bool: + return self.flag == int(ScoreFlag.WARNING) + + @property + def student_name(self) -> str | None: + return self.student.name if self.student else None + + @property + def class_name(self) -> str | None: + return self.student.class_name if self.student else None diff --git a/app/model/student.py b/app/model/student.py new file mode 100644 index 0000000..6b3b18a --- /dev/null +++ b/app/model/student.py @@ -0,0 +1,113 @@ +"""学生表(需求 2.1),系统的主表。 + +关于「年龄」的一个设计取舍 +-------------------------------------------------------------------------- +需求里把年龄列成了学生字段。这里**不落 age 字段**,只存 birth_date: + +* 年龄 = 按今天算出来的派生值。今天是 24 岁,明年今天还是 24 就错了, + 必须靠定时任务去刷,多一个必定会脏的字段。 +* 需要"按年龄筛选"的场景(需求 2.6.1)用 SQL 表达式算: + ``app.core.utils.age_expression()`` 把 birth_date 折成年数参与 WHERE。 +* 出参同时给 birth_date 和算好的 age,前端拿来直接用。 + +唯一例外:录入时对方只知道年龄不知道生日,入参允许只传 age, +service 会用 ``guess_birth_date()`` 折算一个占位生日并标记 ``birth_date_estimated=True``。 +""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy import Date, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.model.base import Base, SoftDeleteMixin, TimestampMixin +from app.model.constants import GENDER_TEXT, STUDENT_STATUS_TEXT, StudentStatus +from app.core.utils import calc_age + + +class Student(Base, TimestampMixin, SoftDeleteMixin): + __tablename__ = "student" + __table_args__ = {"comment": "学生基本信息表"} + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + stu_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False, index=True, comment="学号(按规则生成,非自增)") + name: Mapped[str] = mapped_column(String(30), nullable=False, index=True, comment="姓名") + gender: Mapped[int] = mapped_column(default=1, nullable=False, index=True, comment="性别 1=男 2=女") + birth_date: Mapped[date | None] = mapped_column(Date, comment="出生日期(年龄由此实时计算,不落库)") + birth_date_estimated: Mapped[int] = mapped_column(default=0, nullable=False, comment="生日是否为按年龄推算的占位值 0=否 1=是") + + native_place: Mapped[str | None] = mapped_column(String(60), comment="籍贯") + graduate_school: Mapped[str | None] = mapped_column(String(80), comment="毕业院校") + major: Mapped[str | None] = mapped_column(String(60), comment="专业") + education: Mapped[str | None] = mapped_column(String(20), comment="学历") + + enroll_date: Mapped[date | None] = mapped_column(Date, comment="入学时间") + graduate_date: Mapped[date | None] = mapped_column(Date, comment="毕业时间") + + phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话") + id_card: Mapped[str | None] = mapped_column(String(30), comment="身份证号") + + class_id: Mapped[int | None] = mapped_column(ForeignKey("clazz.id"), index=True, comment="所属班级") + advisor_id: Mapped[int | None] = mapped_column(ForeignKey("advisor.id"), index=True, comment="顾问编号") + + status: Mapped[int] = mapped_column( + default=int(StudentStatus.STUDYING), nullable=False, index=True, + comment="状态 1=在读 2=进入就业 3=已就业", + ) + remark: Mapped[str | None] = mapped_column(Text, comment="备注(成绩预警会自动追加)") + + # ---------------- 关系 ---------------- + klass: Mapped["Clazz"] = relationship( # noqa: F821 + back_populates="students", lazy="joined", foreign_keys=[class_id] + ) + advisor: Mapped["Advisor"] = relationship(lazy="joined") # noqa: F821 + scores: Mapped[list["Score"]] = relationship( # noqa: F821 + back_populates="student", + lazy="selectin", + order_by="Score.exam_seq", + cascade="all, delete-orphan", + ) + employment: Mapped["Employment"] = relationship( # noqa: F821 + back_populates="student", + uselist=False, + lazy="joined", + ) + + # ---------------- 出参派生字段 ---------------- + @property + def age(self) -> int | None: + return calc_age(self.birth_date) + + @property + def gender_text(self) -> str | None: + return GENDER_TEXT.get(self.gender) + + @property + def status_text(self) -> str: + return STUDENT_STATUS_TEXT.get(self.status, "未知") + + @property + def class_name(self) -> str | None: + return self.klass.name if self.klass and self.klass.is_del == 0 else None + + @property + def class_no(self) -> str | None: + return self.klass.class_no if self.klass and self.klass.is_del == 0 else None + + @property + def advisor_name(self) -> str | None: + return self.advisor.name if self.advisor and self.advisor.is_del == 0 else None + + @property + def score_count(self) -> int: + return len([s for s in self.scores if s.is_del == 0]) + + @property + def avg_score(self) -> float | None: + valid = [float(s.score) for s in self.scores if s.is_del == 0 and s.score is not None] + return round(sum(valid) / len(valid), 2) if valid else None + + @property + def fail_count(self) -> int: + return len([s for s in self.scores if s.is_del == 0 and s.flag == 1]) diff --git a/app/model/teacher.py b/app/model/teacher.py new file mode 100644 index 0000000..24b1977 --- /dev/null +++ b/app/model/teacher.py @@ -0,0 +1,48 @@ +"""老师表(需求 2.5)。""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy import Date, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.model.assoc import class_teachers +from app.model.base import Base, SoftDeleteMixin, TimestampMixin +from app.model.constants import GENDER_TEXT + + +class Teacher(Base, TimestampMixin, SoftDeleteMixin): + __tablename__ = "teacher" + __table_args__ = {"comment": "授课老师表"} + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + teacher_no: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, comment="工号") + name: Mapped[str] = mapped_column(String(30), nullable=False, index=True, comment="姓名") + gender: Mapped[int] = mapped_column(default=1, nullable=False, comment="性别 1=男 2=女") + phone: Mapped[str | None] = mapped_column(String(20), comment="手机号") + email: Mapped[str | None] = mapped_column(String(60), comment="邮箱") + title: Mapped[str | None] = mapped_column(String(30), comment="职称,如 讲师/高级讲师") + subject: Mapped[str | None] = mapped_column(String(50), comment="授课方向,如 Java/前端/大数据") + hire_date: Mapped[date | None] = mapped_column(Date, comment="入职时间") + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + # 带班信息:多对多 + classes: Mapped[list["Clazz"]] = relationship( # noqa: F821 + secondary=class_teachers, + back_populates="teachers", + lazy="selectin", + ) + + # ---------------- 出参派生字段 ---------------- + @property + def gender_text(self) -> str | None: + return GENDER_TEXT.get(self.gender) + + @property + def class_names(self) -> list[str]: + return [c.name for c in self.classes if c.is_del == 0] + + @property + def class_count(self) -> int: + return len(self.class_names) diff --git a/app/schema/__init__.py b/app/schema/__init__.py new file mode 100644 index 0000000..9f7ab96 --- /dev/null +++ b/app/schema/__init__.py @@ -0,0 +1,33 @@ +"""Pydantic v2 出入参模型。""" + +from app.schema.common import ORMBase, PageResult, SortOrder +from app.schema.advisor_schema import AdvisorCreate, AdvisorOut, AdvisorUpdate +from app.schema.clazz_schema import ClazzCreate, ClazzOut, ClazzUpdate +from app.schema.teacher_schema import TeacherCreate, TeacherOut, TeacherUpdate +from app.schema.student_schema import ( + StudentCreate, + StudentOut, + StudentUpdate, + StudentImportResult, +) +from app.schema.score_schema import ScoreBatchCreate, ScoreCreate, ScoreOut, ScoreUpdate +from app.schema.employment_schema import EmploymentCreate, EmploymentOut, EmploymentUpdate +from app.schema.auth_schema import AccountOut, LoginParams, TokenOut +from app.schema.advanced_schema import ( + AggregationParams, + AggregationResult, + FilterRule, + QueryRequest, +) + +__all__ = [ + "ORMBase", "PageResult", "SortOrder", + "AdvisorCreate", "AdvisorOut", "AdvisorUpdate", + "ClazzCreate", "ClazzOut", "ClazzUpdate", + "TeacherCreate", "TeacherOut", "TeacherUpdate", + "StudentCreate", "StudentOut", "StudentUpdate", "StudentImportResult", + "ScoreCreate", "ScoreUpdate", "ScoreOut", "ScoreBatchCreate", + "EmploymentCreate", "EmploymentUpdate", "EmploymentOut", + "LoginParams", "TokenOut", "AccountOut", + "FilterRule", "QueryRequest", "AggregationParams", "AggregationResult", +] diff --git a/app/schema/__pycache__/__init__.cpython-311.pyc b/app/schema/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..8ca2d19 Binary files /dev/null and b/app/schema/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/schema/__pycache__/__init__.cpython-313.pyc b/app/schema/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b812b3b Binary files /dev/null and b/app/schema/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/schema/__pycache__/advanced_schema.cpython-311.pyc b/app/schema/__pycache__/advanced_schema.cpython-311.pyc new file mode 100644 index 0000000..de87c06 Binary files /dev/null and b/app/schema/__pycache__/advanced_schema.cpython-311.pyc differ diff --git a/app/schema/__pycache__/advanced_schema.cpython-313.pyc b/app/schema/__pycache__/advanced_schema.cpython-313.pyc new file mode 100644 index 0000000..5072668 Binary files /dev/null and b/app/schema/__pycache__/advanced_schema.cpython-313.pyc differ diff --git a/app/schema/__pycache__/advisor_schema.cpython-311.pyc b/app/schema/__pycache__/advisor_schema.cpython-311.pyc new file mode 100644 index 0000000..d3a0386 Binary files /dev/null and b/app/schema/__pycache__/advisor_schema.cpython-311.pyc differ diff --git a/app/schema/__pycache__/advisor_schema.cpython-313.pyc b/app/schema/__pycache__/advisor_schema.cpython-313.pyc new file mode 100644 index 0000000..a700f27 Binary files /dev/null and b/app/schema/__pycache__/advisor_schema.cpython-313.pyc differ diff --git a/app/schema/__pycache__/auth_schema.cpython-311.pyc b/app/schema/__pycache__/auth_schema.cpython-311.pyc new file mode 100644 index 0000000..5590be6 Binary files /dev/null and b/app/schema/__pycache__/auth_schema.cpython-311.pyc differ diff --git a/app/schema/__pycache__/auth_schema.cpython-313.pyc b/app/schema/__pycache__/auth_schema.cpython-313.pyc new file mode 100644 index 0000000..5fbecb9 Binary files /dev/null and b/app/schema/__pycache__/auth_schema.cpython-313.pyc differ diff --git a/app/schema/__pycache__/clazz_schema.cpython-311.pyc b/app/schema/__pycache__/clazz_schema.cpython-311.pyc new file mode 100644 index 0000000..f8ca3b3 Binary files /dev/null and b/app/schema/__pycache__/clazz_schema.cpython-311.pyc differ diff --git a/app/schema/__pycache__/clazz_schema.cpython-313.pyc b/app/schema/__pycache__/clazz_schema.cpython-313.pyc new file mode 100644 index 0000000..5748168 Binary files /dev/null and b/app/schema/__pycache__/clazz_schema.cpython-313.pyc differ diff --git a/app/schema/__pycache__/common.cpython-311.pyc b/app/schema/__pycache__/common.cpython-311.pyc new file mode 100644 index 0000000..4f7ddc8 Binary files /dev/null and b/app/schema/__pycache__/common.cpython-311.pyc differ diff --git a/app/schema/__pycache__/common.cpython-313.pyc b/app/schema/__pycache__/common.cpython-313.pyc new file mode 100644 index 0000000..cbf8739 Binary files /dev/null and b/app/schema/__pycache__/common.cpython-313.pyc differ diff --git a/app/schema/__pycache__/employment_schema.cpython-311.pyc b/app/schema/__pycache__/employment_schema.cpython-311.pyc new file mode 100644 index 0000000..67ff8e1 Binary files /dev/null and b/app/schema/__pycache__/employment_schema.cpython-311.pyc differ diff --git a/app/schema/__pycache__/employment_schema.cpython-313.pyc b/app/schema/__pycache__/employment_schema.cpython-313.pyc new file mode 100644 index 0000000..feb889b Binary files /dev/null and b/app/schema/__pycache__/employment_schema.cpython-313.pyc differ diff --git a/app/schema/__pycache__/score_schema.cpython-311.pyc b/app/schema/__pycache__/score_schema.cpython-311.pyc new file mode 100644 index 0000000..6627652 Binary files /dev/null and b/app/schema/__pycache__/score_schema.cpython-311.pyc differ diff --git a/app/schema/__pycache__/score_schema.cpython-313.pyc b/app/schema/__pycache__/score_schema.cpython-313.pyc new file mode 100644 index 0000000..0df81f9 Binary files /dev/null and b/app/schema/__pycache__/score_schema.cpython-313.pyc differ diff --git a/app/schema/__pycache__/statistics_schema.cpython-311.pyc b/app/schema/__pycache__/statistics_schema.cpython-311.pyc new file mode 100644 index 0000000..7aeab84 Binary files /dev/null and b/app/schema/__pycache__/statistics_schema.cpython-311.pyc differ diff --git a/app/schema/__pycache__/statistics_schema.cpython-313.pyc b/app/schema/__pycache__/statistics_schema.cpython-313.pyc new file mode 100644 index 0000000..af384f8 Binary files /dev/null and b/app/schema/__pycache__/statistics_schema.cpython-313.pyc differ diff --git a/app/schema/__pycache__/student_schema.cpython-311.pyc b/app/schema/__pycache__/student_schema.cpython-311.pyc new file mode 100644 index 0000000..86e54db Binary files /dev/null and b/app/schema/__pycache__/student_schema.cpython-311.pyc differ diff --git a/app/schema/__pycache__/student_schema.cpython-313.pyc b/app/schema/__pycache__/student_schema.cpython-313.pyc new file mode 100644 index 0000000..d40beb1 Binary files /dev/null and b/app/schema/__pycache__/student_schema.cpython-313.pyc differ diff --git a/app/schema/__pycache__/teacher_schema.cpython-311.pyc b/app/schema/__pycache__/teacher_schema.cpython-311.pyc new file mode 100644 index 0000000..3935b88 Binary files /dev/null and b/app/schema/__pycache__/teacher_schema.cpython-311.pyc differ diff --git a/app/schema/__pycache__/teacher_schema.cpython-313.pyc b/app/schema/__pycache__/teacher_schema.cpython-313.pyc new file mode 100644 index 0000000..235100c Binary files /dev/null and b/app/schema/__pycache__/teacher_schema.cpython-313.pyc differ diff --git a/app/schema/advanced_schema.py b/app/schema/advanced_schema.py new file mode 100644 index 0000000..bf883c8 --- /dev/null +++ b/app/schema/advanced_schema.py @@ -0,0 +1,111 @@ +"""通用高级筛选器出入参(需求 2.7.1)。 + +请求体形如: + + { + "model": "student", + "rules": [ + {"field": "age", "operator": ">", "value": 25}, + {"field": "gender", "operator": "=", "value": "男"}, + {"logic": "OR", "sub_rules": [ + {"field": "salary", "operator": ">=", "value": 15000}, + {"field": "class_name", "operator": "like", "value": "Java"} + ]} + ], + "order_by": "age", + "order": "desc", + "page": 1, + "page_size": 10 + } +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + +from app.schema.common import SortOrder + + +class FilterRule(BaseModel): + """一条规则 = 一个条件 或 一组子规则。""" + + field: str | None = Field(None, description="字段名,与 sub_rules 二选一") + operator: str | None = Field( + None, + description="操作符 > < = != >= <= like in not_like not_in between is_null not_null", + ) + value: Any = Field(None, description="比较值;in/not_in 传数组,between 传 [a, b]") + + logic: Literal["AND", "OR", "and", "or"] | None = Field(None, description="子规则之间的逻辑,AND / OR") + sub_rules: list["FilterRule"] | None = Field(None, description="嵌套子规则,可多层") + + @model_validator(mode="after") + def _check(self): + is_group = bool(self.sub_rules) + if is_group: + if self.logic is None: + self.logic = "AND" + return self + if not self.field or not self.operator: + raise ValueError("叶子规则必须同时提供 field 和 operator") + return self + + @field_validator("operator") + @classmethod + def _lower(cls, v): # noqa: ANN001, ANN201 + return v.strip().lower() if isinstance(v, str) else v + + +FilterRule.model_rebuild() + + +class QueryRequest(BaseModel): + model: str = Field("student", description="目标模型:student / class / teacher / score / employment / advisor") + rules: list[FilterRule] = Field(default_factory=list, description="筛选规则树") + order_by: str | None = Field(None, description="排序字段(同样受白名单限制)") + order: SortOrder = Field(SortOrder.ASC, description="排序方向 asc / desc") + page: int = Field(1, ge=1) + page_size: int = Field(10, ge=1, le=200) + fields: list[str] | None = Field(None, description="指定返回字段,不传返回全部可读字段") + with_explain: bool = Field(True, description="是否返回规则解析后的说明,方便调试规则") + + +class MetricSpec(BaseModel): + func: Literal["count", "count_distinct", "avg", "sum", "max", "min"] = Field(..., description="聚合函数") + field: str | None = Field(None, description="聚合字段;count 时可省略") + alias: str | None = Field(None, description="结果列名") + + +class AggregationParams(BaseModel): + model: str = Field(..., description="目标模型") + group_by: list[str] = Field(default_factory=list, description="分组字段,可为空=整体聚合") + metrics: list[MetricSpec] = Field(..., min_length=1, description="聚合指标") + rules: list[FilterRule] = Field(default_factory=list, description="筛选规则树") + having: list[FilterRule] = Field(default_factory=list, description="对聚合结果的过滤(HAVING)") + order_by: str | None = Field(None, description="按结果列名排序") + order: SortOrder = SortOrder.DESC + limit: int = Field(50, ge=1, le=500) + + +class AggregationResult(BaseModel): + columns: list[str] = Field(default_factory=list) + rows: list[dict[str, Any]] = Field(default_factory=list) + total: int = 0 + sql: str | None = Field(None, description="实际执行的 SQL(已参数化,便于讲解与排查)") + + +class FieldMeta(BaseModel): + field: str + type: str + desc: str + sortable: bool = True + filterable: bool = True + + +class ModelMeta(BaseModel): + model: str + label: str + fields: list[FieldMeta] = Field(default_factory=list) + relations: list[str] = Field(default_factory=list, description="可通过 JOIN 使用的关联字段") diff --git a/app/schema/advisor_schema.py b/app/schema/advisor_schema.py new file mode 100644 index 0000000..a6422df --- /dev/null +++ b/app/schema/advisor_schema.py @@ -0,0 +1,48 @@ +"""顾问模块出入参(需求第 5 节扩展模块)。""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from app.schema.common import GENDER_VALIDATOR, ORMBase, STRIP_VALIDATOR + + +class _AdvisorBase(BaseModel): + name: str = Field(..., min_length=1, max_length=30, description="顾问姓名") + gender: int = Field(1, description="性别 1=男 2=女") + phone: str | None = Field(None, max_length=20, description="联系电话") + email: str | None = Field(None, max_length=60, description="邮箱") + dept: str | None = Field(None, max_length=50, description="所属部门") + remark: str | None = Field(None, max_length=255, description="备注") + + _v_gender = GENDER_VALIDATOR + _v_strip = STRIP_VALIDATOR + + +class AdvisorCreate(_AdvisorBase): + advisor_no: str | None = Field(None, max_length=20, description="顾问编号,不传自动生成 A+序号") + + +class AdvisorUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=30) + gender: int | None = None + phone: str | None = None + email: str | None = None + dept: str | None = None + remark: str | None = None + + _v_gender = GENDER_VALIDATOR + _v_strip = STRIP_VALIDATOR + + +class AdvisorOut(ORMBase): + id: int + advisor_no: str + name: str + gender: int + gender_text: str | None = None + phone: str | None = None + email: str | None = None + dept: str | None = None + remark: str | None = None + student_count: int = Field(0, description="名下学生数(详情接口填充)") diff --git a/app/schema/auth_schema.py b/app/schema/auth_schema.py new file mode 100644 index 0000000..7d27343 --- /dev/null +++ b/app/schema/auth_schema.py @@ -0,0 +1,45 @@ +"""认证模块出入参。""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.schema.common import ORMBase + + +class LoginParams(BaseModel): + username: str = Field(..., min_length=2, max_length=50, description="登录名") + password: str = Field(..., min_length=4, max_length=64, description="密码") + + +class AccountOut(ORMBase): + id: int + username: str + real_name: str + role: str + role_text: str + advisor_id: int | None = None + is_active: bool = True + last_login_at: datetime | None = None + + +class TokenOut(BaseModel): + access_token: str + token_type: str = "Bearer" + expires_in: int = Field(..., description="有效期(秒)") + account: AccountOut + + +class AccountCreate(BaseModel): + username: str = Field(..., min_length=2, max_length=50) + password: str = Field(..., min_length=4, max_length=64) + real_name: str = Field(..., min_length=1, max_length=30) + role: str = Field("advisor", description="admin / advisor / viewer") + advisor_id: int | None = None + + +class PasswordChange(BaseModel): + old_password: str = Field(..., min_length=4, max_length=64) + new_password: str = Field(..., min_length=6, max_length=64, description="至少 6 位") diff --git a/app/schema/clazz_schema.py b/app/schema/clazz_schema.py new file mode 100644 index 0000000..3c63aff --- /dev/null +++ b/app/schema/clazz_schema.py @@ -0,0 +1,76 @@ +"""班级模块出入参(需求 2.4)。""" + +from __future__ import annotations + +from datetime import date + +from pydantic import BaseModel, Field, field_validator + +from app.schema.common import ORMBase, STRIP_VALIDATOR + + +class _ClazzBase(BaseModel): + name: str = Field(..., min_length=1, max_length=50, description="班级名称") + direction: str | None = Field(None, max_length=50, description="班级方向") + open_date: str | None = Field(None, description="开课时间 YYYY-MM-DD") + close_date: str | None = Field(None, description="结课时间 YYYY-MM-DD") + classroom: str | None = Field(None, max_length=30, description="教室") + capacity: int | None = Field(40, ge=1, le=500, description="计划招生人数") + status: int = Field(1, ge=1, le=3, description="状态 1=在读 2=已结课 3=已解散") + head_teacher_id: int | None = Field(None, description="班主任(老师ID)") + advisor_id: int | None = Field(None, description="带班顾问ID") + teacher_ids: list[int] = Field(default_factory=list, description="授课老师ID列表(多对多)") + description: str | None = Field(None, description="班级说明") + + _v_strip = STRIP_VALIDATOR + + @field_validator("open_date", "close_date", mode="before") + @classmethod + def _date_like(cls, v): # noqa: ANN001, ANN201 + if v is None or isinstance(v, str): + return v + if isinstance(v, date): + return v.isoformat() + return v + + +class ClazzCreate(_ClazzBase): + class_no: str | None = Field(None, max_length=30, description="班级编号,不传按 方向缩写+年份+序号 生成") + + +class ClazzUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=50, description="班级名称") + direction: str | None = None + open_date: str | None = None + close_date: str | None = None + classroom: str | None = None + capacity: int | None = Field(None, ge=1, le=500) + status: int | None = Field(None, ge=1, le=3, description="状态 1=在读 2=已结课 3=已解散") + head_teacher_id: int | None = None + advisor_id: int | None = None + teacher_ids: list[int] | None = Field(None, description="传了就整体替换授课老师") + description: str | None = None + + _v_strip = STRIP_VALIDATOR + + +class ClazzOut(ORMBase): + id: int + class_no: str = Field(..., description="班级编号") + name: str + direction: str | None = None + open_date: date | None = None + close_date: date | None = None + classroom: str | None = None + capacity: int | None = None + status: int + status_text: str + + head_teacher_id: int | None = None + head_teacher_name: str | None = None + advisor_id: int | None = None + advisor_name: str | None = None + teacher_ids: list[int] = Field(default_factory=list, description="授课老师ID") + teacher_names: list[str] = Field(default_factory=list, description="授课老师姓名") + description: str | None = None + student_count: int = Field(0, description="班级在读人数") diff --git a/app/schema/common.py b/app/schema/common.py new file mode 100644 index 0000000..54180ae --- /dev/null +++ b/app/schema/common.py @@ -0,0 +1,67 @@ +"""Pydantic 公共模型与校验小工具。""" + +from __future__ import annotations + +from datetime import date, datetime +from enum import Enum +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.model.constants import gender_to_code + +T = TypeVar("T") + + +class ORMBase(BaseModel): + """所有出参模型的基类:允许直接吃 ORM 对象(含 @property 派生字段)。""" + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class PageResult(BaseModel, Generic[T]): + total: int = Field(0, description="总条数") + page: int = Field(1, description="当前页") + page_size: int = Field(10, description="每页条数") + pages: int = Field(0, description="总页数") + items: list[T] = Field(default_factory=list, description="数据列表") + + +class SortOrder(str, Enum): + ASC = "asc" + DESC = "desc" + + +class DateRangeMixin(BaseModel): + start_date: str | None = Field(None, description="起始日期 YYYY-MM-DD") + end_date: str | None = Field(None, description="结束日期 YYYY-MM-DD") + + +# ---------------------------------------------------------------- 校验器 +def _normalize_gender(cls, v): # noqa: ANN001, ANN201 + if v is None: + return v + code = gender_to_code(v) + if code is None: + raise ValueError("性别只能是 1/2 或 男/女") + return code + + +def _strip_str(cls, v): # noqa: ANN001, ANN201 + return v.strip() if isinstance(v, str) else v + + +GENDER_VALIDATOR = field_validator("gender", mode="before", check_fields=False)(_normalize_gender) +STRIP_VALIDATOR = field_validator("*", mode="before", check_fields=False)(_strip_str) + + +def date_to_str(value: date | datetime | None) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.date().isoformat() + return value.isoformat() + + +def as_dict(model: Any) -> dict: + return model.model_dump(exclude_unset=True) diff --git a/app/schema/employment_schema.py b/app/schema/employment_schema.py new file mode 100644 index 0000000..f3e73e4 --- /dev/null +++ b/app/schema/employment_schema.py @@ -0,0 +1,74 @@ +"""就业模块出入参(需求 2.3)。""" + +from __future__ import annotations + +from datetime import date + +from pydantic import BaseModel, Field, model_validator + +from app.schema.common import ORMBase, STRIP_VALIDATOR + + +class _EmploymentBase(BaseModel): + open_date: str | None = Field(None, description="就业开放时间 YYYY-MM-DD") + offer_date: str | None = Field(None, description="offer 下发时间 YYYY-MM-DD") + company: str | None = Field(None, max_length=80, description="就业公司名称") + salary: float | None = Field(None, ge=0, description="就业薪资(元/月)") + position: str | None = Field(None, max_length=50, description="岗位") + city: str | None = Field(None, max_length=30, description="就业城市") + remark: str | None = Field(None, description="备注") + + _v_strip = STRIP_VALIDATOR + + +class EmploymentCreate(_EmploymentBase): + stu_id: int = Field(..., description="学生ID(一个学生只能有一条就业记录,重复登记=更新)") + + @model_validator(mode="after") + def _at_least_one(self): + if not any([self.open_date, self.offer_date, self.company, self.salary]): + raise ValueError("就业开放时间 / offer 时间 / 公司 / 薪资 至少要填一项") + return self + + +class EmploymentUpdate(BaseModel): + open_date: str | None = None + offer_date: str | None = None + company: str | None = Field(None, max_length=80) + salary: float | None = Field(None, ge=0) + position: str | None = None + city: str | None = None + remark: str | None = None + + _v_strip = STRIP_VALIDATOR + + +class EmploymentOut(ORMBase): + id: int + stu_id: int + stu_no: str | None = Field(None, description="学号(JOIN 学生表,不落库)") + student_name: str | None = Field(None, description="学生姓名(JOIN 学生表,不落库)") + class_id: int | None = Field(None, description="班级ID(冗余字段)") + class_name: str | None = Field(None, description="班级名称(冗余ID实时取名)") + + open_date: date | None = None + offer_date: date | None = None + company: str | None = None + salary: float | None = None + salary_wan: float | None = Field(None, description="薪资(万元/月)") + position: str | None = None + city: str | None = None + duration_days: int | None = Field(None, description="就业时长(天)= offer 时间 - 开放时间") + remark: str | None = None + + student_status: int | None = Field(None, description="学生当前状态(联动后的结果)") + student_status_text: str | None = None + + +class EmploymentRegisterResult(BaseModel): + employment: EmploymentOut + created: bool = Field(True, description="是新增还是更新已有记录") + status_changed: bool = Field(False, description="学生状态是否发生变化") + from_status_text: str | None = None + to_status_text: str | None = None + msg: str = Field("", description="给前端直接展示的一句话") diff --git a/app/schema/score_schema.py b/app/schema/score_schema.py new file mode 100644 index 0000000..b812b44 --- /dev/null +++ b/app/schema/score_schema.py @@ -0,0 +1,63 @@ +"""成绩模块出入参(需求 2.2)。""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from pydantic import BaseModel, Field, field_validator + +from app.schema.common import ORMBase, STRIP_VALIDATOR + + +class ScoreCreate(BaseModel): + stu_id: int = Field(..., description="学生ID") + exam_seq: int = Field(..., ge=1, le=99, description="考核序次,如 1=第一次考核") + score: Decimal = Field(..., ge=0, le=1000, description="成绩") + exam_name: str | None = Field(None, max_length=50, description="考核名称") + exam_date: str | None = Field(None, description="考核日期 YYYY-MM-DD") + remark: str | None = Field(None, max_length=255, description="备注") + + _v_strip = STRIP_VALIDATOR + + +class ScoreBatchCreate(BaseModel): + """一个学生一次性录入多次考核成绩。""" + + stu_id: int = Field(..., description="学生ID") + scores: list[ScoreCreate] = Field(..., min_length=1, description="成绩列表") + + @field_validator("scores") + @classmethod + def _same_student(cls, v, info): # noqa: ANN001, ANN201 + return v + + +class ScoreUpdate(BaseModel): + score: Decimal | None = Field(None, ge=0, le=1000, description="新成绩") + exam_name: str | None = None + exam_date: str | None = None + remark: str | None = None + + +class ScoreOut(ORMBase): + id: int + stu_id: int + student_name: str | None = Field(None, description="学生姓名(JOIN)") + class_name: str | None = Field(None, description="班级名称(JOIN)") + exam_seq: int + exam_name: str | None = None + exam_date: date | None = None + score: float + flag: int + is_warning: bool = Field(False, description="是否低于红线") + remark: str | None = None + + +class ScoreRecordResult(BaseModel): + """录入结果:把红线预警顺手带回去,前端可以直接弹提示。""" + + record: ScoreOut + warning: bool = Field(False, description="是否触发红线预警") + warning_msg: str | None = Field(None, description="预警文案") + avg_score: float | None = Field(None, description="录入后该生平均分") diff --git a/app/schema/statistics_schema.py b/app/schema/statistics_schema.py new file mode 100644 index 0000000..e5e17c6 --- /dev/null +++ b/app/schema/statistics_schema.py @@ -0,0 +1,151 @@ +"""统计分析模块出入参(需求 2.6)。""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +from pydantic import BaseModel, Field + + +class ClassOverviewItem(BaseModel): + """2.6.1 多维度班级统计:每班总人数 + 男女分布。""" + + class_id: int + class_no: str + class_name: str + status_text: str | None = None + head_teacher_name: str | None = None + total: int = Field(0, description="总人数") + male: int = Field(0, description="男生数") + female: int = Field(0, description="女生数") + other: int = Field(0, description="未标注性别人数") + + +class AllAboveItem(BaseModel): + """2.6.2 每次考试都过线的学生。""" + + stu_id: int + stu_no: str + name: str + class_name: str | None = None + exam_count: int = Field(0, description="参加考核次数") + min_score: float = Field(0, description="最低分(就是它决定了过线)") + avg_score: float = Field(0, description="平均分") + scores: list[dict[str, Any]] = Field(default_factory=list, description="各次成绩明细") + + +class FailDetailItem(BaseModel): + """2.6.2 不及格明细。""" + + stu_id: int + stu_no: str + name: str + class_name: str | None = None + fail_times: int = Field(0, description="不及格次数") + fail_details: list[dict[str, Any]] = Field(default_factory=list, description="不及格场次明细") + + +class ClassAvgScoreItem(BaseModel): + """2.6.2 每场考试每个班级的平均分。""" + + exam_seq: int + class_id: int | None = None + class_name: str | None = None + student_count: int = Field(0, description="参考人数") + avg_score: float = Field(0, description="平均分") + max_score: float = 0 + min_score: float = 0 + pass_rate: float = Field(0, description="及格率 %") + + +class SalaryTopItem(BaseModel): + """2.6.3 就业薪资 Top N。""" + + rank: int + stu_id: int + stu_no: str + name: str + class_name: str | None = None + company: str | None = None + salary: float = 0 + salary_wan: float = 0 + offer_date: date | None = None + duration_days: int | None = None + + +class StudentDurationItem(BaseModel): + """2.6.3 每个学生的就业时长。""" + + stu_id: int + stu_no: str + name: str + class_name: str | None = None + company: str | None = None + open_date: date | None = None + offer_date: date | None = None + duration_days: int | None = Field(None, description="offer 时间 - 开放时间(天)") + state: str = Field("", description="就业中 / 已拿到 offer") + + +class ClassAvgDurationItem(BaseModel): + """2.6.3 每个班级的平均就业时长。""" + + class_id: int | None = None + class_name: str | None = None + student_count: int = Field(0, description="班级总人数") + open_count: int = Field(0, description="已开放就业的人数(统计基数)") + offer_count: int = Field(0, description="已下发 offer 的人数") + avg_duration_days: float | None = Field(None, description="平均就业时长(天)") + min_duration_days: int | None = None + max_duration_days: int | None = None + + +class VolatilityItem(BaseModel): + """2.7.2 成绩波动分析。""" + + rank: int + stu_id: int + stu_no: str + name: str + class_name: str | None = None + exam_count: int = 0 + avg_score: float = 0 + stddev: float = Field(0, description="成绩标准差") + score_range: float = Field(0, description="最大分差 max-min") + trend: str = Field("", description="上升 / 下降 / 基本持平(按最小二乘斜率,阈值 ±1 分/场)") + trend_slope: float = Field(0, description="成绩趋势斜率,单位:分/场(对考核序次做最小二乘拟合)") + scores: list[float] = Field(default_factory=list, description="按序次排列的成绩") + + +class FunnelItem(BaseModel): + """2.7.2 班级就业漏斗。""" + + class_id: int + class_no: str + class_name: str + total: int = Field(0, description="总人数") + employed: int = Field(0, description="已就业人数(拿到 offer)") + high_salary: int = Field(0, description="高薪人数(>= 高薪线)") + employment_rate: float = Field(0, description="就业率 %") + high_salary_rate: float = Field(0, description="高薪占已就业比例 %") + avg_salary: float | None = Field(None, description="已就业学生平均薪资") + avg_duration_days: float | None = Field(None, description="平均就业时长(天)") + + +class OverviewOut(BaseModel): + """仪表盘概览。""" + + student_total: int = 0 + student_by_status: dict[str, int] = Field(default_factory=dict, description="各状态人数") + class_total: int = 0 + teacher_total: int = 0 + advisor_total: int = 0 + employment_total: int = 0 + employment_rate: float = Field(0, description="整体就业率 %") + avg_salary: float | None = None + high_salary_count: int = 0 + score_record_total: int = 0 + score_avg: float | None = None + warning_student_count: int = Field(0, description="存在红线预警的学生数") + top_classes: list[dict[str, Any]] = Field(default_factory=list, description="就业率前 5 班级") diff --git a/app/schema/student_schema.py b/app/schema/student_schema.py new file mode 100644 index 0000000..76fe9cf --- /dev/null +++ b/app/schema/student_schema.py @@ -0,0 +1,142 @@ +"""学生模块出入参(需求 2.1)。""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +from pydantic import BaseModel, Field, field_validator, model_validator + +from app.model.constants import Education +from app.schema.common import GENDER_VALIDATOR, ORMBase, STRIP_VALIDATOR + + +class _StudentBase(BaseModel): + name: str = Field(..., min_length=1, max_length=30, description="姓名") + gender: int = Field(1, description="性别 1=男 2=女(也接受 男/女)") + + birth_date: str | None = Field(None, description="出生日期 YYYY-MM-DD") + age: int | None = Field( + None, ge=10, le=80, + description="年龄。只在拿不到生日时用:填了 age 没填 birth_date,系统按当年推算一个占位生日", + ) + + native_place: str | None = Field(None, max_length=60, description="籍贯") + graduate_school: str | None = Field(None, max_length=80, description="毕业院校") + major: str | None = Field(None, max_length=60, description="专业") + education: str | None = Field(None, max_length=20, description="学历,如 大专/本科", examples=[Education.BACHELOR.value]) + + enroll_date: str | None = Field(None, description="入学时间 YYYY-MM-DD") + graduate_date: str | None = Field(None, description="毕业时间 YYYY-MM-DD") + + phone: str | None = Field(None, max_length=20, description="联系电话") + id_card: str | None = Field(None, max_length=30, description="身份证号") + + class_id: int | None = Field(None, description="所属班级ID") + advisor_id: int | None = Field(None, description="顾问ID") + + remark: str | None = Field(None, description="备注") + + _v_gender = GENDER_VALIDATOR + _v_strip = STRIP_VALIDATOR + + @field_validator("birth_date", "enroll_date", "graduate_date", mode="before") + @classmethod + def _date_like(cls, v): # noqa: ANN001, ANN201 + """把 date/datetime 统一成 str,真正的解析放在 API 层用 parse_date。""" + if v is None or isinstance(v, str): + return v + if isinstance(v, date): + return v.isoformat() + return v + + +class StudentCreate(_StudentBase): + stu_no: str | None = Field(None, max_length=30, description="学号。不传则按规则自动生成") + status: int | None = Field(None, ge=1, le=3, description="状态 1=在读 2=进入就业 3=已就业,默认在读") + + @model_validator(mode="after") + def _check_age_source(self): + if self.birth_date is None and self.age is None: + raise ValueError("birth_date 和 age 至少要填一个(用于年龄统计)") + return self + + +class StudentUpdate(BaseModel): + """全部字段可选,只更新传了的字段。""" + + name: str | None = Field(None, min_length=1, max_length=30, description="姓名") + gender: int | None = Field(None, description="性别 1=男 2=女") + birth_date: str | None = Field(None, description="出生日期 YYYY-MM-DD") + age: int | None = Field(None, description="年龄(会折算成占位生日)") + + native_place: str | None = Field(None, max_length=60, description="籍贯") + graduate_school: str | None = Field(None, max_length=80, description="毕业院校") + major: str | None = Field(None, max_length=60, description="专业") + education: str | None = Field(None, max_length=20, description="学历") + + enroll_date: str | None = Field(None, description="入学时间 YYYY-MM-DD") + graduate_date: str | None = Field(None, description="毕业时间 YYYY-MM-DD") + + phone: str | None = Field(None, max_length=20, description="联系电话") + id_card: str | None = Field(None, max_length=30, description="身份证号") + + class_id: int | None = Field(None, description="所属班级ID(转班会同步就业表的冗余班级)") + advisor_id: int | None = Field(None, description="顾问ID") + + remark: str | None = Field(None, description="备注") + + _v_gender = GENDER_VALIDATOR + _v_strip = STRIP_VALIDATOR + + +class StudentBrief(ORMBase): + id: int + stu_no: str + name: str + gender_text: str | None = None + class_name: str | None = None + status_text: str + + +class StudentOut(ORMBase): + id: int + stu_no: str = Field(..., description="学号") + name: str + gender: int + gender_text: str | None = Field(None, description="性别文案") + age: int | None = Field(None, description="年龄(按出生日期实时计算)") + birth_date: date | None = None + birth_date_estimated: int = Field(0, description="生日是否为推算值 1=是") + + native_place: str | None = None + graduate_school: str | None = None + major: str | None = None + education: str | None = None + + enroll_date: date | None = None + graduate_date: date | None = None + + phone: str | None = None + id_card: str | None = None + + class_id: int | None = None + class_no: str | None = Field(None, description="班级编号") + class_name: str | None = Field(None, description="班级名称(JOIN 得出)") + advisor_id: int | None = None + advisor_name: str | None = None + + status: int + status_text: str + remark: str | None = None + + score_count: int = Field(0, description="已录入的考核次数") + avg_score: float | None = Field(None, description="平均分") + fail_count: int = Field(0, description="低于红线的次数") + + +class StudentImportResult(BaseModel): + total: int = Field(0, description="文件里的数据行数") + success: int = Field(0, description="成功导入") + failed: int = Field(0, description="失败行数") + errors: list[dict[str, Any]] = Field(default_factory=list, description="失败明细:行号 + 原因") diff --git a/app/schema/teacher_schema.py b/app/schema/teacher_schema.py new file mode 100644 index 0000000..55389ff --- /dev/null +++ b/app/schema/teacher_schema.py @@ -0,0 +1,60 @@ +"""老师模块出入参(需求 2.5)。""" + +from __future__ import annotations + +from datetime import date + +from pydantic import BaseModel, Field + +from app.schema.common import GENDER_VALIDATOR, ORMBase, STRIP_VALIDATOR + + +class _TeacherBase(BaseModel): + name: str = Field(..., min_length=1, max_length=30, description="姓名") + gender: int = Field(1, description="性别 1=男 2=女") + phone: str | None = Field(None, max_length=20, description="手机号") + email: str | None = Field(None, max_length=60, description="邮箱") + title: str | None = Field(None, max_length=30, description="职称") + subject: str | None = Field(None, max_length=50, description="授课方向") + hire_date: str | None = Field(None, description="入职时间 YYYY-MM-DD") + remark: str | None = Field(None, max_length=255, description="备注") + + _v_gender = GENDER_VALIDATOR + _v_strip = STRIP_VALIDATOR + + +class TeacherCreate(_TeacherBase): + teacher_no: str | None = Field(None, max_length=20, description="工号,不传自动生成 T+年份+序号") + class_ids: list[int] = Field(default_factory=list, description="带的班级ID列表") + + +class TeacherUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=30) + gender: int | None = None + phone: str | None = None + email: str | None = None + title: str | None = None + subject: str | None = None + hire_date: str | None = None + remark: str | None = None + class_ids: list[int] | None = Field(None, description="传了就整体替换带班关系") + + _v_gender = GENDER_VALIDATOR + _v_strip = STRIP_VALIDATOR + + +class TeacherOut(ORMBase): + id: int + teacher_no: str = Field(..., description="工号") + name: str + gender: int + gender_text: str | None = None + phone: str | None = None + email: str | None = None + title: str | None = None + subject: str | None = None + hire_date: date | None = None + remark: str | None = None + class_ids: list[int] = Field(default_factory=list, description="带班ID") + class_names: list[str] = Field(default_factory=list, description="带班名称") + class_count: int = Field(0, description="带班数量") diff --git a/app/scripts/__init__.py b/app/scripts/__init__.py new file mode 100644 index 0000000..5df266b --- /dev/null +++ b/app/scripts/__init__.py @@ -0,0 +1 @@ +"""初始化脚本包。""" diff --git a/app/scripts/__pycache__/__init__.cpython-313.pyc b/app/scripts/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..ededce7 Binary files /dev/null and b/app/scripts/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/scripts/__pycache__/init_db.cpython-313.pyc b/app/scripts/__pycache__/init_db.cpython-313.pyc new file mode 100644 index 0000000..a950ad0 Binary files /dev/null and b/app/scripts/__pycache__/init_db.cpython-313.pyc differ diff --git a/app/scripts/__pycache__/seed_data.cpython-313.pyc b/app/scripts/__pycache__/seed_data.cpython-313.pyc new file mode 100644 index 0000000..6b35d74 Binary files /dev/null and b/app/scripts/__pycache__/seed_data.cpython-313.pyc differ diff --git a/app/scripts/init_db.py b/app/scripts/init_db.py new file mode 100644 index 0000000..85cd0c9 --- /dev/null +++ b/app/scripts/init_db.py @@ -0,0 +1,70 @@ +"""初始化数据库:建库 + 建表 + 建初始管理员。 + +用法: + python -m app.scripts.init_db # 建表(不会删数据) + python -m app.scripts.init_db --drop # 先删表再建(危险,会清空数据) +""" + +from __future__ import annotations + +import argparse +import sys + +from app.core.config import settings +from app.core.database import SessionLocal, engine, ensure_database_exists +from app.core.security import hash_password +from app.dao.account_dao import AccountDao +from app.model import Account, Base, Role + + +def init(drop: bool = False) -> None: + ensure_database_exists() + if drop: + confirm = input("这会删除 wolin 库里的全部表和数据,输入 yes 继续:") + if confirm.strip().lower() != "yes": + print("已取消") + return + Base.metadata.drop_all(bind=engine) + print("已删除所有表") + + Base.metadata.create_all(bind=engine) + tables = ", ".join(sorted(Base.metadata.tables.keys())) + print(f"建表完成:{tables}") + + with SessionLocal() as db: + if AccountDao.get_by_username(db, settings.ADMIN_USERNAME, with_deleted=True) is None: + db.add( + Account( + username=settings.ADMIN_USERNAME, + hashed_password=hash_password(settings.ADMIN_PASSWORD), + real_name="系统管理员", + role=Role.ADMIN.value, + ) + ) + db.commit() + print(f"初始管理员:{settings.ADMIN_USERNAME} / {settings.ADMIN_PASSWORD}") + else: + print(f"管理员 {settings.ADMIN_USERNAME} 已存在,跳过") + + if AccountDao.get_by_username(db, "viewer", with_deleted=True) is None: + db.add( + Account( + username="viewer", + hashed_password=hash_password("viewer123"), + real_name="只读访客", + role=Role.VIEWER.value, + ) + ) + db.commit() + print("只读账号:viewer / viewer123") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="初始化沃林学生管理系统数据库") + parser.add_argument("--drop", action="store_true", help="先删除所有表再重建(会丢数据)") + args = parser.parse_args() + try: + init(drop=args.drop) + except Exception as exc: # noqa: BLE001 + print(f"初始化失败:{exc}", file=sys.stderr) + raise diff --git a/app/scripts/seed_data.py b/app/scripts/seed_data.py new file mode 100644 index 0000000..a0d5609 --- /dev/null +++ b/app/scripts/seed_data.py @@ -0,0 +1,264 @@ +"""灌入演示数据:顾问 / 老师 / 班级 / 学生 / 成绩 / 就业。 + +用法: + python -m app.scripts.seed_data # 已有数据就跳过 + python -m app.scripts.seed_data --reset # 清空业务数据后重灌 + +数据是"设计过"的,不是随机糊上去的: + * 4 个班、每班 12~16 人,男女比例有差异,方便看 2.6.1 的性别分布; + * 5 次考核,成绩按"个人基础 + 波动"生成,必然产生: + - 每次都在线以上的学霸(2.6.2 有结果) + - 多次不及格的重点关注对象(红线预警有数据) + - 成绩大起大落的人(2.7.2 波动分析有排名) + * 就业数据让各班就业率、薪资区间都拉开档次(2.6.3 / 漏斗图有东西看)。 +""" + +from __future__ import annotations + +import argparse +import random +from datetime import date, timedelta + +from app.core.database import SessionLocal, engine, ensure_database_exists +from app.dao.advisor_dao import AdvisorDao +from app.dao.clazz_dao import ClazzDao +from app.dao.student_dao import StudentDao +from app.dao.teacher_dao import TeacherDao +from app.model import Advisor, Base, Clazz, Employment, Score, Student, Teacher +from app.service.student_service import StudentService + +random.seed(20260916) # 固定种子,每次灌出来的数据一样,便于对比 + +SURNAMES = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜" +GIVEN = [ + "伟", "芳", "娜", "敏", "静", "强", "磊", "洋", "艳", "勇", "军", "杰", "娟", "涛", "明", + "超", "秀英", "霞", "平", "刚", "桂英", "文轩", "雨欣", "子豪", "思远", "梓涵", "浩宇", + "若曦", "嘉怡", "天佑", "梦琪", "俊杰", "欣怡", "家豪", "雅静", +] +CITIES = ["广东深圳", "湖南长沙", "江西南昌", "广西南宁", "湖北武汉", "四川成都", "河南郑州", "福建福州", "安徽合肥", "山东济南"] +SCHOOLS = [ + "深圳职业技术学院", "长沙民政职业技术学院", "江西现代职业技术学院", "南宁职业技术学院", + "武汉船舶职业技术学院", "成都航空职业技术学院", "黄河水利职业技术学院", + "福建信息职业技术学院", "安徽机电职业技术学院", "山东商业职业技术学院", +] +MAJORS = ["软件技术", "计算机应用技术", "大数据技术", "人工智能技术应用", "计算机网络技术", "数字媒体技术", "电子商务"] +EDUCATIONS = ["大专", "大专", "大专", "本科", "中专"] +COMPANIES = [ + "深圳华为技术", "腾讯科技(深圳)", "字节跳动", "广州网易", "杭州阿里巴巴", "比亚迪股份", + "中兴通讯", "深圳大疆创新", "京东科技", "美团", "小米通讯", "海康威视", "OPPO 广东", + "深信服科技", "金蝶软件", "用友网络", "软通动力", "中软国际", +] +CITY_WORK = ["深圳", "广州", "杭州", "北京", "上海", "成都", "武汉", "东莞"] +POSITIONS = ["Java 开发工程师", "前端开发工程师", "测试工程师", "大数据开发工程师", "运维工程师", "算法工程师", "产品助理"] + +CLASS_PLAN = [ + {"class_no": "JAVA202601", "name": "Java 就业 2026 一班", "direction": "Java", "capacity": 40, "count": 16}, + {"class_no": "WEB202602", "name": "Web 前端 2026 一班", "direction": "Web", "capacity": 35, "count": 14}, + {"class_no": "BIGDATA202603", "name": "大数据 2026 一班", "direction": "BigData", "capacity": 30, "count": 12}, + {"class_no": "AI202604", "name": "人工智能 2026 一班", "direction": "AI", "capacity": 25, "count": 10}, +] + +TEACHER_PLAN = [ + ("陈志远", 1, "高级讲师", "Java", "2019-03-01"), + ("林晓雯", 2, "讲师", "Web 前端", "2020-07-15"), + ("黄建国", 1, "教研组长", "大数据", "2017-09-01"), + ("苏婉清", 2, "讲师", "人工智能", "2021-04-20"), + ("郑海涛", 1, "高级讲师", "Java", "2018-11-05"), +] + +ADVISOR_PLAN = [ + ("李慧敏", 2, "招生一部", "13900000001"), + ("王志强", 1, "招生二部", "13900000002"), + ("周雅丽", 2, "就业服务部", "13900000003"), +] + +EXAM_NAMES = ["阶段一考试", "阶段二考试", "阶段三考试", "阶段四考试", "结课答辩"] + + +def clear_business_data(db) -> None: + for model in (Employment, Score, Student, Clazz, Teacher, Advisor): + db.query(model).delete() + from app.model import class_teachers + + db.execute(class_teachers.delete()) + db.commit() + print("已清空业务数据(账号保留)") + + +def seed(reset: bool = False) -> None: + ensure_database_exists() + Base.metadata.create_all(bind=engine) + + with SessionLocal() as db: + if reset: + clear_business_data(db) + elif StudentDao.count(db) > 0: + print("已有学生数据,跳过灌数据(要重灌请加 --reset)") + return + + # ---------------- 顾问 ---------------- + advisors = [] + for idx, (name, gender, dept, phone) in enumerate(ADVISOR_PLAN, start=1): + advisor = Advisor( + advisor_no=f"A{idx:04d}", name=name, gender=gender, dept=dept, phone=phone, + email=f"advisor{idx}@wolin.com", + ) + db.add(advisor) + advisors.append(advisor) + db.flush() + + # ---------------- 老师 ---------------- + teachers = [] + for idx, (name, gender, title, subject, hire) in enumerate(TEACHER_PLAN, start=1): + teacher = Teacher( + teacher_no=f"T2026{idx:03d}", name=name, gender=gender, title=title, subject=subject, + hire_date=date.fromisoformat(hire), phone=f"1380000{idx:04d}", + email=f"teacher{idx}@wolin.com", + ) + db.add(teacher) + teachers.append(teacher) + db.flush() + + # ---------------- 班级 ---------------- + classes = [] + for idx, plan in enumerate(CLASS_PLAN): + klass = Clazz( + class_no=plan["class_no"], + name=plan["name"], + direction=plan["direction"], + open_date=date(2026, 3, 2) + timedelta(days=idx * 7), + close_date=date(2026, 9, 30) + timedelta(days=idx * 7), + classroom=f"A{201 + idx}", + capacity=plan["capacity"], + status=1, + head_teacher_id=teachers[idx % len(teachers)].id, + advisor_id=advisors[idx % len(advisors)].id, + description=f"{plan['direction']} 方向就业班,{plan['count']} 人", + ) + # 授课老师:本方向 + 一位公共课老师 + klass.teachers = [teachers[idx % len(teachers)], teachers[(idx + 1) % len(teachers)]] + db.add(klass) + classes.append(klass) + db.flush() + + # ---------------- 学生 ---------------- + students: list[Student] = [] + used_names: set[str] = set() + for c_idx, (klass, plan) in enumerate(zip(classes, CLASS_PLAN)): + for i in range(plan["count"]): + while True: + name = random.choice(SURNAMES) + random.choice(GIVEN) + if name not in used_names: + used_names.add(name) + break + gender = 1 if random.random() < 0.62 else 2 + age = random.randint(19, 27) + enroll = date(2026, 3, 2) + timedelta(days=c_idx * 7 + i) + student = Student( + stu_no=StudentService.build_stu_no(db, klass.id, enroll), + name=name, + gender=gender, + birth_date=date(2026 - age, random.randint(1, 12), random.randint(1, 28)), + birth_date_estimated=0, + native_place=random.choice(CITIES), + graduate_school=random.choice(SCHOOLS), + major=random.choice(MAJORS), + education=random.choice(EDUCATIONS), + enroll_date=enroll, + graduate_date=enroll + timedelta(days=random.randint(500, 900)), + phone=f"1{random.randint(3, 9)}{random.randint(10**8, 10**9 - 1)}", + class_id=klass.id, + advisor_id=advisors[(c_idx + i) % len(advisors)].id, + status=1, + ) + db.add(student) + db.flush() # 立刻落库,保证下一个学号能看到它 + students.append(student) + db.flush() + + # ---------------- 成绩 ---------------- + score_count = 0 + for student in students: + # 每个人的"基础水平",决定他是学霸还是重点关注对象 + base = random.gauss(76, 12) + volatility = random.choice([2, 3, 4, 6, 9, 13]) # 有人稳、有人大起大落 + for seq in range(1, 6): + value = max(20.0, min(100.0, random.gauss(base, volatility))) + if random.random() < 0.06: # 偶尔缺考/失手 + value = max(20.0, value - random.randint(15, 30)) + db.add( + Score( + stu_id=student.id, + exam_seq=seq, + exam_name=EXAM_NAMES[seq - 1], + exam_date=date(2026, 3, 2) + timedelta(days=seq * 45 + random.randint(-3, 3)), + score=round(value, 1), + flag=1 if value < 60 else 0, + remark="系统生成" if value < 60 else None, + ) + ) + score_count += 1 + db.flush() + + # ---------------- 就业 ---------------- + emp_count = 0 # 已拿到 offer + open_count = 0 # 只开放了就业、还没 offer + OFFER_RATE = [0.88, 0.79, 0.67, 0.50] # 就业率按班级拉开差距:Java 班最猛,AI 班最慢 + SALARY_BASE = [13500, 11500, 14000, 15500] + for student in students: + klass_idx = next(i for i, c in enumerate(classes) if c.id == student.class_id) + rate = OFFER_RATE[klass_idx % len(OFFER_RATE)] + if random.random() > rate: + # 没就业的学生里,一部分"已开放就业",状态推到"进入就业" + if random.random() < 0.6: + open_d = date(2026, 9, 30) + timedelta(days=random.randint(0, 20)) + db.add(Employment(stu_id=student.id, class_id=student.class_id, open_date=open_d)) + student.status = 2 + open_count += 1 + continue + + open_d = date(2026, 9, 30) + timedelta(days=random.randint(0, 25)) + wait = random.randint(5, 70) + offer_d = open_d + timedelta(days=wait) + + # 薪资:Java/大数据偏高,AI 班样本少但更极端 + salary_base = SALARY_BASE[klass_idx % len(SALARY_BASE)] + salary = max(6000, int(random.gauss(salary_base, 3200))) + if random.random() < 0.08: + salary = int(salary * random.uniform(1.3, 1.6)) # 少数高薪 offer + + db.add( + Employment( + stu_id=student.id, + class_id=student.class_id, + open_date=open_d, + offer_date=offer_d, + company=random.choice(COMPANIES), + salary=salary, + position=random.choice(POSITIONS), + city=random.choice(CITY_WORK), + remark="就业老师推荐" if random.random() < 0.3 else None, + ) + ) + student.status = 3 + emp_count += 1 + if random.random() < 0.75: + # 拿到 offer 的学生大多有对应阶段的成绩 + student.graduate_date = offer_d + timedelta(days=random.randint(20, 60)) + + db.commit() + + print("演示数据灌入完成:") + print(f" 顾问 {len(advisors)} 人") + print(f" 老师 {len(teachers)} 人") + print(f" 班级 {len(classes)} 个") + print(f" 学生 {len(students)} 人") + print(f" 成绩 {score_count} 条") + print(f" 就业记录 {emp_count + open_count} 条(已拿 offer {emp_count} / 仅开放就业 {open_count})") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="灌入沃林学生管理系统演示数据") + parser.add_argument("--reset", action="store_true", help="先清空业务数据再灌") + args = parser.parse_args() + seed(reset=args.reset) diff --git a/app/service/__init__.py b/app/service/__init__.py new file mode 100644 index 0000000..e1547fc --- /dev/null +++ b/app/service/__init__.py @@ -0,0 +1 @@ +"""业务规则层:状态流转、事务、预警、统计口径、规则引擎。""" diff --git a/app/service/__pycache__/__init__.cpython-311.pyc b/app/service/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..d1d7c27 Binary files /dev/null and b/app/service/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/service/__pycache__/__init__.cpython-313.pyc b/app/service/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..0ffcb10 Binary files /dev/null and b/app/service/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/service/__pycache__/advanced_service.cpython-311.pyc b/app/service/__pycache__/advanced_service.cpython-311.pyc new file mode 100644 index 0000000..799e963 Binary files /dev/null and b/app/service/__pycache__/advanced_service.cpython-311.pyc differ diff --git a/app/service/__pycache__/advanced_service.cpython-313.pyc b/app/service/__pycache__/advanced_service.cpython-313.pyc new file mode 100644 index 0000000..cbe002c Binary files /dev/null and b/app/service/__pycache__/advanced_service.cpython-313.pyc differ diff --git a/app/service/__pycache__/auth_service.cpython-311.pyc b/app/service/__pycache__/auth_service.cpython-311.pyc new file mode 100644 index 0000000..17fb21d Binary files /dev/null and b/app/service/__pycache__/auth_service.cpython-311.pyc differ diff --git a/app/service/__pycache__/auth_service.cpython-313.pyc b/app/service/__pycache__/auth_service.cpython-313.pyc new file mode 100644 index 0000000..a380874 Binary files /dev/null and b/app/service/__pycache__/auth_service.cpython-313.pyc differ diff --git a/app/service/__pycache__/employment_service.cpython-311.pyc b/app/service/__pycache__/employment_service.cpython-311.pyc new file mode 100644 index 0000000..18ab8b8 Binary files /dev/null and b/app/service/__pycache__/employment_service.cpython-311.pyc differ diff --git a/app/service/__pycache__/employment_service.cpython-313.pyc b/app/service/__pycache__/employment_service.cpython-313.pyc new file mode 100644 index 0000000..b2b8a11 Binary files /dev/null and b/app/service/__pycache__/employment_service.cpython-313.pyc differ diff --git a/app/service/__pycache__/score_service.cpython-311.pyc b/app/service/__pycache__/score_service.cpython-311.pyc new file mode 100644 index 0000000..ba2416d Binary files /dev/null and b/app/service/__pycache__/score_service.cpython-311.pyc differ diff --git a/app/service/__pycache__/score_service.cpython-313.pyc b/app/service/__pycache__/score_service.cpython-313.pyc new file mode 100644 index 0000000..72c4326 Binary files /dev/null and b/app/service/__pycache__/score_service.cpython-313.pyc differ diff --git a/app/service/__pycache__/statistics_service.cpython-311.pyc b/app/service/__pycache__/statistics_service.cpython-311.pyc new file mode 100644 index 0000000..9a1dcf2 Binary files /dev/null and b/app/service/__pycache__/statistics_service.cpython-311.pyc differ diff --git a/app/service/__pycache__/statistics_service.cpython-313.pyc b/app/service/__pycache__/statistics_service.cpython-313.pyc new file mode 100644 index 0000000..28ba9be Binary files /dev/null and b/app/service/__pycache__/statistics_service.cpython-313.pyc differ diff --git a/app/service/__pycache__/student_service.cpython-311.pyc b/app/service/__pycache__/student_service.cpython-311.pyc new file mode 100644 index 0000000..b701bef Binary files /dev/null and b/app/service/__pycache__/student_service.cpython-311.pyc differ diff --git a/app/service/__pycache__/student_service.cpython-313.pyc b/app/service/__pycache__/student_service.cpython-313.pyc new file mode 100644 index 0000000..2991071 Binary files /dev/null and b/app/service/__pycache__/student_service.cpython-313.pyc differ diff --git a/app/service/advanced_service.py b/app/service/advanced_service.py new file mode 100644 index 0000000..8cc3054 --- /dev/null +++ b/app/service/advanced_service.py @@ -0,0 +1,680 @@ +"""通用高级筛选规则引擎(需求 2.7.1)。 + +设计要点 +-------------------------------------------------------------------------- +1. **白名单**:调用方只能传字段名,不能传 SQL 片段。字段名先在 + ``MODEL_REGISTRY`` 里查表,查不到直接报错并把可用字段回给调用方。 + 这样即便把接口暴露出去也不存在注入问题——安全边界在建 SQL 之前就关掉了。 +2. **类型强转**:每个字段带类型(int/float/str/date/gender/status), + `"25"` 和 `25` 都能比较,`"男"` 自动转成 1,避免"字符串比数字"这种静默错误。 +3. **嵌套组合**:规则树递归解析,AND/OR 任意嵌套,带深度与节点数上限防炸。 +4. **可解释**:返回里带上规则解析说明和实际执行的 SQL(literal_binds), + 前端能直接把"你提交的条件翻译成了什么"展示给人看。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from typing import Any, Callable + +from sqlalchemy import Select, and_, distinct, func, not_, or_, select +from sqlalchemy.orm import Session +from sqlalchemy.sql import ColumnElement + +from app.core.exceptions import RuleError +from app.core.utils import age_expression, date_diff_days_expr, parse_date +from app.model import ( + Advisor, + Clazz, + Employment, + Score, + Student, + StudentStatus, + Teacher, + class_teachers, +) +from app.model.constants import CLASS_STATUS_TEXT, STUDENT_STATUS_TEXT, gender_to_code +from app.schema.advanced_schema import AggregationParams, FilterRule, QueryRequest + +MAX_DEPTH = 6 +MAX_NODES = 80 + +# ==================================================================== 元数据 + + +@dataclass(frozen=True) +class FieldSpec: + column: Any + type: str + label: str + joins: tuple[str, ...] = () + sortable: bool = True + + +@dataclass(frozen=True) +class JoinSpec: + target: Any + onclause: Any + outer: bool = True + + +@dataclass +class ModelSpec: + key: str + label: str + entity: type + fields: dict[str, FieldSpec] + joins: dict[str, JoinSpec] = field(default_factory=dict) + default_order: str = "id" + + +GENDER_TYPE = "gender" +STATUS_TYPE = "status" +CLASS_STATUS_TYPE = "class_status" + +STUDENT_FIELDS: dict[str, FieldSpec] = { + "id": FieldSpec(Student.id, "int", "学生ID"), + "stu_no": FieldSpec(Student.stu_no, "str", "学号"), + "name": FieldSpec(Student.name, "str", "姓名"), + "gender": FieldSpec(Student.gender, GENDER_TYPE, "性别"), + "age": FieldSpec(age_expression(Student.birth_date), "int", "年龄(实时计算)"), + "birth_date": FieldSpec(Student.birth_date, "date", "出生日期"), + "native_place": FieldSpec(Student.native_place, "str", "籍贯"), + "graduate_school": FieldSpec(Student.graduate_school, "str", "毕业院校"), + "major": FieldSpec(Student.major, "str", "专业"), + "education": FieldSpec(Student.education, "str", "学历"), + "enroll_date": FieldSpec(Student.enroll_date, "date", "入学时间"), + "graduate_date": FieldSpec(Student.graduate_date, "date", "毕业时间"), + "phone": FieldSpec(Student.phone, "str", "联系电话"), + "status": FieldSpec(Student.status, STATUS_TYPE, "状态"), + "remark": FieldSpec(Student.remark, "str", "备注"), + # ---- 跨表字段:靠 JOIN 拿 ---- + "class_id": FieldSpec(Clazz.id, "int", "班级ID", joins=("clazz",)), + "class_no": FieldSpec(Clazz.class_no, "str", "班级编号", joins=("clazz",)), + "class_name": FieldSpec(Clazz.name, "str", "班级名称", joins=("clazz",)), + "advisor_name": FieldSpec(Advisor.name, "str", "顾问姓名", joins=("advisor",)), + "company": FieldSpec(Employment.company, "str", "就业公司", joins=("employment",)), + "salary": FieldSpec(Employment.salary, "float", "就业薪资", joins=("employment",)), + "position": FieldSpec(Employment.position, "str", "就业岗位", joins=("employment",)), + "offer_date": FieldSpec(Employment.offer_date, "date", "offer 下发时间", joins=("employment",)), + "open_date": FieldSpec(Employment.open_date, "date", "就业开放时间", joins=("employment",)), + "duration_days": FieldSpec( + date_diff_days_expr(Employment.offer_date, Employment.open_date), + "int", + "就业时长(天)", + joins=("employment",), + ), +} + +CLASS_FIELDS: dict[str, FieldSpec] = { + "id": FieldSpec(Clazz.id, "int", "班级ID"), + "class_no": FieldSpec(Clazz.class_no, "str", "班级编号"), + "name": FieldSpec(Clazz.name, "str", "班级名称"), + "direction": FieldSpec(Clazz.direction, "str", "班级方向"), + "open_date": FieldSpec(Clazz.open_date, "date", "开课时间"), + "close_date": FieldSpec(Clazz.close_date, "date", "结课时间"), + "classroom": FieldSpec(Clazz.classroom, "str", "教室"), + "capacity": FieldSpec(Clazz.capacity, "int", "计划人数"), + "status": FieldSpec(Clazz.status, CLASS_STATUS_TYPE, "班级状态"), + "head_teacher_name": FieldSpec(Teacher.name, "str", "班主任", joins=("head_teacher",)), + "advisor_name": FieldSpec(Advisor.name, "str", "带班顾问", joins=("advisor",)), +} + +TEACHER_FIELDS: dict[str, FieldSpec] = { + "id": FieldSpec(Teacher.id, "int", "老师ID"), + "teacher_no": FieldSpec(Teacher.teacher_no, "str", "工号"), + "name": FieldSpec(Teacher.name, "str", "姓名"), + "gender": FieldSpec(Teacher.gender, GENDER_TYPE, "性别"), + "phone": FieldSpec(Teacher.phone, "str", "手机号"), + "email": FieldSpec(Teacher.email, "str", "邮箱"), + "title": FieldSpec(Teacher.title, "str", "职称"), + "subject": FieldSpec(Teacher.subject, "str", "授课方向"), + "hire_date": FieldSpec(Teacher.hire_date, "date", "入职时间"), +} + +ADVISOR_FIELDS: dict[str, FieldSpec] = { + "id": FieldSpec(Advisor.id, "int", "顾问ID"), + "advisor_no": FieldSpec(Advisor.advisor_no, "str", "顾问编号"), + "name": FieldSpec(Advisor.name, "str", "姓名"), + "gender": FieldSpec(Advisor.gender, GENDER_TYPE, "性别"), + "phone": FieldSpec(Advisor.phone, "str", "电话"), + "email": FieldSpec(Advisor.email, "str", "邮箱"), + "dept": FieldSpec(Advisor.dept, "str", "部门"), +} + +SCORE_FIELDS: dict[str, FieldSpec] = { + "id": FieldSpec(Score.id, "int", "成绩ID"), + "stu_id": FieldSpec(Score.stu_id, "int", "学生ID"), + "exam_seq": FieldSpec(Score.exam_seq, "int", "考核序次"), + "exam_name": FieldSpec(Score.exam_name, "str", "考核名称"), + "exam_date": FieldSpec(Score.exam_date, "date", "考核日期"), + "score": FieldSpec(Score.score, "float", "成绩"), + "flag": FieldSpec(Score.flag, "int", "预警标记"), + "student_name": FieldSpec(Student.name, "str", "学生姓名", joins=("student",)), + "stu_no": FieldSpec(Student.stu_no, "str", "学号", joins=("student",)), + "class_name": FieldSpec(Clazz.name, "str", "班级名称", joins=("student", "clazz")), +} + +EMPLOYMENT_FIELDS: dict[str, FieldSpec] = { + "id": FieldSpec(Employment.id, "int", "就业ID"), + "stu_id": FieldSpec(Employment.stu_id, "int", "学生ID"), + "open_date": FieldSpec(Employment.open_date, "date", "就业开放时间"), + "offer_date": FieldSpec(Employment.offer_date, "date", "offer 下发时间"), + "company": FieldSpec(Employment.company, "str", "就业公司"), + "salary": FieldSpec(Employment.salary, "float", "就业薪资"), + "position": FieldSpec(Employment.position, "str", "岗位"), + "city": FieldSpec(Employment.city, "str", "城市"), + "duration_days": FieldSpec( + date_diff_days_expr(Employment.offer_date, Employment.open_date), "int", "就业时长(天)" + ), + "student_name": FieldSpec(Student.name, "str", "学生姓名", joins=("student",)), + "stu_no": FieldSpec(Student.stu_no, "str", "学号", joins=("student",)), + "gender": FieldSpec(Student.gender, GENDER_TYPE, "性别", joins=("student",)), + "status": FieldSpec(Student.status, STATUS_TYPE, "学生状态", joins=("student",)), + "class_name": FieldSpec(Clazz.name, "str", "班级名称", joins=("clazz",)), + "class_no": FieldSpec(Clazz.class_no, "str", "班级编号", joins=("clazz",)), +} + +MODEL_REGISTRY: dict[str, ModelSpec] = { + "student": ModelSpec( + key="student", + label="学生", + entity=Student, + fields=STUDENT_FIELDS, + joins={ + "clazz": JoinSpec(Clazz, Clazz.id == Student.class_id), + "advisor": JoinSpec(Advisor, Advisor.id == Student.advisor_id), + "employment": JoinSpec( + Employment, + and_(Employment.stu_id == Student.id, Employment.is_del == 0), + ), + }, + default_order="id", + ), + "class": ModelSpec( + key="class", + label="班级", + entity=Clazz, + fields=CLASS_FIELDS, + joins={ + "head_teacher": JoinSpec(Teacher, Teacher.id == Clazz.head_teacher_id), + "advisor": JoinSpec(Advisor, Advisor.id == Clazz.advisor_id), + }, + default_order="id", + ), + "teacher": ModelSpec(key="teacher", label="老师", entity=Teacher, fields=TEACHER_FIELDS), + "advisor": ModelSpec(key="advisor", label="顾问", entity=Advisor, fields=ADVISOR_FIELDS), + "score": ModelSpec( + key="score", + label="成绩", + entity=Score, + fields=SCORE_FIELDS, + joins={ + "student": JoinSpec(Student, and_(Student.id == Score.stu_id, Student.is_del == 0)), + "clazz": JoinSpec(Clazz, Clazz.id == Student.class_id), + }, + default_order="id", + ), + "employment": ModelSpec( + key="employment", + label="就业", + entity=Employment, + fields=EMPLOYMENT_FIELDS, + joins={ + "student": JoinSpec(Student, and_(Student.id == Employment.stu_id, Student.is_del == 0)), + "clazz": JoinSpec(Clazz, Clazz.id == Employment.class_id), + }, + default_order="id", + ), +} + +MODEL_ALIASES = { + "students": "student", "clazz": "class", "classes": "class", "cls": "class", + "teacher": "teacher", "teachers": "teacher", "advisors": "advisor", + "scores": "score", "employments": "employment", "employment": "employment", +} + + +# ==================================================================== 类型转换 +def _to_int(value: Any, label: str) -> Any: + if isinstance(value, list): + return [_to_int(v, label) for v in value] + try: + return int(float(value)) + except (TypeError, ValueError) as exc: + raise RuleError(f"字段「{label}」需要整数,收到 {value!r}") from exc + + +def _to_float(value: Any, label: str) -> Any: + if isinstance(value, list): + return [_to_float(v, label) for v in value] + try: + return float(value) + except (TypeError, ValueError) as exc: + raise RuleError(f"字段「{label}」需要数字,收到 {value!r}") from exc + + +def _to_str(value: Any, label: str) -> Any: + if isinstance(value, list): + return [str(v) for v in value] + return str(value) + + +def _to_date(value: Any, label: str) -> Any: + if isinstance(value, list): + return [_to_date(v, label) for v in value] + return parse_date(value, label) + + +def _to_gender(value: Any, label: str) -> Any: + if isinstance(value, list): + return [_to_gender(v, label) for v in value] + code = gender_to_code(value) + if code is None: + raise RuleError(f"字段「{label}」只接受 男/女 或 1/2,收到 {value!r}") + return code + + +def _to_status(value: Any, label: str) -> Any: + if isinstance(value, list): + return [_to_status(v, label) for v in value] + if isinstance(value, int) or (isinstance(value, str) and value.strip().isdigit()): + return int(value) + for code, text in STUDENT_STATUS_TEXT.items(): + if str(value).strip() == text: + return int(code) + raise RuleError(f"字段「{label}」状态只接受 在读/进入就业/已就业 或 1/2/3,收到 {value!r}") + + +def _to_class_status(value: Any, label: str) -> Any: + if isinstance(value, list): + return [_to_class_status(v, label) for v in value] + if isinstance(value, int) or (isinstance(value, str) and value.strip().isdigit()): + return int(value) + for code, text in CLASS_STATUS_TEXT.items(): + if str(value).strip() == text: + return int(code) + raise RuleError(f"字段「{label}」状态只接受 在读/已结课/已解散 或 1/2/3,收到 {value!r}") + + +CASTERS: dict[str, Callable[[Any, str], Any]] = { + "int": _to_int, + "float": _to_float, + "str": _to_str, + "date": _to_date, + GENDER_TYPE: _to_gender, + STATUS_TYPE: _to_status, + CLASS_STATUS_TYPE: _to_class_status, +} + +OPERATORS = { + "=", "==", "!=", "<>", ">", ">=", "<", "<=", + "like", "not_like", "in", "not_in", "between", "not_between", + "is_null", "not_null", +} + +NULL_OPS = {"is_null", "not_null"} + + +# ==================================================================== 引擎 +class AdvancedQueryService: + # ---------------------------------------------------------------- 解析 + @classmethod + def get_spec(cls, model_key: str | None) -> ModelSpec: + key = (model_key or "student").strip().lower() + key = MODEL_ALIASES.get(key, key) + spec = MODEL_REGISTRY.get(key) + if spec is None: + raise RuleError( + f"不支持的数据模型「{model_key}」,可选:{'、'.join(MODEL_REGISTRY.keys())}" + ) + return spec + + @classmethod + def build_condition(cls, rule: FilterRule, spec: ModelSpec, depth: int, counter: list[int]) -> ColumnElement | None: + counter[0] += 1 + if counter[0] > MAX_NODES: + raise RuleError(f"规则节点过多(上限 {MAX_NODES}),请拆成多次查询") + if depth > MAX_DEPTH: + raise RuleError(f"规则嵌套过深(上限 {MAX_DEPTH} 层)") + + if rule.sub_rules: + conditions = [ + c for c in (cls.build_condition(sub, spec, depth + 1, counter) for sub in rule.sub_rules) if c is not None + ] + if not conditions: + return None + return or_(*conditions) if str(rule.logic).upper() == "OR" else and_(*conditions) + + assert rule.field is not None + field_key = rule.field.strip() + field_spec = spec.fields.get(field_key) + if field_spec is None: + similar = [k for k in spec.fields if field_key.lower() in k.lower()] + hint = f",你是不是想用:{'、'.join(similar[:5])}" if similar else "" + raise RuleError( + f"模型「{spec.label}」没有可筛选字段「{rule.field}」{hint}。" + f"可用字段:{'、'.join(spec.fields.keys())}" + ) + + op = (rule.operator or "=").strip().lower() + if op not in OPERATORS: + raise RuleError(f"不支持的操作符「{op}」,可选:{'、'.join(sorted(OPERATORS))}") + + column = field_spec.column + caster = CASTERS.get(field_spec.type, _to_str) + + # ---- 空值判定 ---- + if op in NULL_OPS: + return column.is_(None) if op == "is_null" else column.is_not(None) + + if rule.value is None: + raise RuleError(f"字段「{field_key}」的 {op} 操作缺少 value") + + # ---- 集合 / 区间 ---- + if op in ("in", "not_in"): + values = rule.value if isinstance(rule.value, list) else [rule.value] + if not values: + raise RuleError(f"字段「{field_key}」的 {op} 需要一个非空数组") + converted = [caster(v, field_spec.label) for v in values] + return column.in_(converted) if op == "in" else column.not_in(converted) + + if op in ("between", "not_between"): + if not isinstance(rule.value, list) or len(rule.value) != 2: + raise RuleError(f"字段「{field_key}」的 {op} 需要 [最小值, 最大值] 两个元素") + low = caster(rule.value[0], field_spec.label) + high = caster(rule.value[1], field_spec.label) + if low > high: + low, high = high, low # 顺序反了自动纠正,不让人白等一次报错 + expr = column.between(low, high) + return not_(expr) if op == "not_between" else expr + + value = caster(rule.value, field_spec.label) + + if op in ("like", "not_like"): + pattern = f"%{value}%" + return column.like(pattern) if op == "like" else column.not_like(pattern) + + if op in ("=", "=="): + return column == value + if op in ("!=", "<>"): + return column != value + if op == ">": + return column > value + if op == ">=": + return column >= value + if op == "<": + return column < value + if op == "<=": + return column <= value + raise RuleError(f"操作符「{op}」暂未实现") + + # ---------------------------------------------------------------- 组装 + @classmethod + def _collect_joins(cls, spec: ModelSpec, names: list[str], used_joins: set[str]) -> None: + for name in names: + field_spec = spec.fields.get(name) + if field_spec: + used_joins.update(field_spec.joins) + + @classmethod + def _rule_field_names(cls, rules: list[FilterRule]) -> list[str]: + names: list[str] = [] + for rule in rules: + if rule.sub_rules: + names.extend(cls._rule_field_names(rule.sub_rules)) + elif rule.field: + names.append(rule.field.strip()) + return names + + @classmethod + def build_stmt(cls, request: QueryRequest) -> tuple[Select, ModelSpec, list[str]]: + """返回 (可执行语句, 模型元信息, 实际会输出的字段名)。""" + spec = cls.get_spec(request.model) + + output_fields = request.fields or list(spec.fields.keys()) + unknown = [f for f in output_fields if f not in spec.fields] + if unknown: + raise RuleError( + f"模型「{spec.label}」没有字段 {unknown}。可用字段:{'、'.join(spec.fields.keys())}" + ) + if request.order_by and request.order_by not in spec.fields: + raise RuleError( + f"排序字段「{request.order_by}」不在可用字段内:{'、'.join(spec.fields.keys())}" + ) + + columns = [spec.fields[name].column.label(name) for name in output_fields] + stmt = select(*columns).select_from(spec.entity) + + used_joins: set[str] = set() + cls._collect_joins(spec, output_fields, used_joins) + cls._collect_joins(spec, cls._rule_field_names(request.rules), used_joins) + if request.order_by: + cls._collect_joins(spec, [request.order_by], used_joins) + + # 需要 join 时才 join,不需要就不加,省掉无谓的扫描 + for name in sorted(used_joins, key=lambda n: list(spec.joins).index(n) if n in spec.joins else 99): + join_spec = spec.joins.get(name) + if join_spec is None: + continue + stmt = ( + stmt.outerjoin(join_spec.target, join_spec.onclause) + if join_spec.outer + else stmt.join(join_spec.target, join_spec.onclause) + ) + + stmt = stmt.where(spec.entity.is_del == 0) + + counter = [0] + conditions = [ + c for c in (cls.build_condition(rule, spec, 1, counter) for rule in request.rules) if c is not None + ] + if conditions: + stmt = stmt.where(and_(*conditions)) + + order_name = request.order_by or spec.default_order + order_col = spec.fields[order_name].column + stmt = stmt.order_by(order_col.desc() if request.order.value == "desc" else order_col.asc()) + return stmt, spec, output_fields + + @classmethod + def execute(cls, db: Session, request: QueryRequest) -> dict: + from app.core.utils import page_count + + stmt, spec, output_fields = cls.build_stmt(request) + count_stmt = select(func.count()).select_from(stmt.order_by(None).subquery()) + total = int(db.scalar(count_stmt) or 0) + + page = max(request.page, 1) + page_size = min(max(request.page_size, 1), 200) + rows = db.execute(stmt.limit(page_size).offset((page - 1) * page_size)).all() + + items = [] + for row in rows: + record = dict(row._mapping) + for key, value in list(record.items()): + if isinstance(value, datetime): + record[key] = value.date().isoformat() + elif isinstance(value, date): + record[key] = value.isoformat() + elif hasattr(value, "as_tuple"): # Decimal + record[key] = float(value) + items.append(record) + + explain = None + if request.with_explain: + try: + explain = str( + stmt.compile(db.bind, compile_kwargs={"literal_binds": True}) + ).replace("\n", " ") + except Exception: # noqa: BLE001 + explain = None + + return { + "model": spec.key, + "model_label": spec.label, + "total": total, + "page": page, + "page_size": page_size, + "pages": page_count(total, page_size), + "fields": output_fields, + "items": items, + "explain": explain, + } + + # ---------------------------------------------------------------- 聚合 + @classmethod + def aggregate(cls, db: Session, params: AggregationParams) -> dict: + spec = cls.get_spec(params.model) + + used_joins: set[str] = set() + cls._collect_joins(spec, params.group_by, used_joins) + cls._collect_joins(spec, cls._rule_field_names(params.rules), used_joins) + + select_columns: list[Any] = [] + columns: list[str] = [] + for name in params.group_by: + field_spec = spec.fields.get(name) + if field_spec is None: + raise RuleError(f"分组字段「{name}」不在模型「{spec.label}」中") + select_columns.append(field_spec.column.label(name)) + columns.append(name) + + metric_exprs: dict[str, Any] = {} + for metric in params.metrics: + func_name = metric.func + if func_name == "count" and not metric.field: + expr = func.count() + alias = metric.alias or "count" + else: + if not metric.field or metric.field not in spec.fields: + raise RuleError( + f"聚合字段「{metric.field}」不在模型「{spec.label}」中。" + f"可用字段:{'、'.join(spec.fields.keys())}" + ) + column = spec.fields[metric.field].column + cls._collect_joins(spec, [metric.field], used_joins) + alias = metric.alias or f"{func_name}_{metric.field}" + if func_name == "count": + expr = func.count(column) + elif func_name == "count_distinct": + expr = func.count(distinct(column)) + else: + expr = getattr(func, func_name)(column) + + if alias in columns or alias in metric_exprs: + alias = f"{alias}_{len(metric_exprs) + 1}" + metric_exprs[alias] = expr + select_columns.append(expr.label(alias)) + columns.append(alias) + + stmt = select(*select_columns).select_from(spec.entity) + for name in sorted(used_joins, key=lambda n: list(spec.joins).index(n) if n in spec.joins else 99): + join_spec = spec.joins.get(name) + if join_spec is None: + continue + stmt = ( + stmt.outerjoin(join_spec.target, join_spec.onclause) + if join_spec.outer + else stmt.join(join_spec.target, join_spec.onclause) + ) + stmt = stmt.where(spec.entity.is_del == 0) + + counter = [0] + conditions = [ + c for c in (cls.build_condition(rule, spec, 1, counter) for rule in params.rules) if c is not None + ] + if conditions: + stmt = stmt.where(and_(*conditions)) + + if params.group_by: + stmt = stmt.group_by(*select_columns[: len(params.group_by)]) + + # HAVING 里的字段名优先按聚合别名解析(如 having: score_avg > 80) + for rule in params.having: + if rule.sub_rules: + raise RuleError("HAVING 暂不支持嵌套分组,请拆成多条规则") + alias = (rule.field or "").strip() + if alias in metric_exprs: + condition = cls._build_metric_condition(rule, metric_exprs[alias]) + stmt = stmt.having(condition) + elif alias in spec.fields: + condition = cls.build_condition(rule, spec, 1, counter) + if condition is not None: + stmt = stmt.having(condition) + else: + raise RuleError( + f"HAVING 字段「{alias}」既不是聚合别名也不是模型字段。" + f"可用聚合别名:{'、'.join(metric_exprs.keys())}" + ) + + order_name = params.order_by or (columns[-1] if columns else None) + if order_name: + match = None + if order_name in metric_exprs: + match = metric_exprs[order_name] + elif order_name in spec.fields: + match = spec.fields[order_name].column if order_name in params.group_by else None + if match is not None: + stmt = stmt.order_by(match.desc() if params.order.value == "desc" else match.asc()) + + stmt = stmt.limit(params.limit) + rows = db.execute(stmt).all() + + result_rows = [] + for row in rows: + record = dict(row._mapping) + for key, value in list(record.items()): + if hasattr(value, "as_tuple"): + record[key] = float(value) + elif isinstance(value, (date, datetime)): + record[key] = value.isoformat() + result_rows.append(record) + + sql = None + try: + sql = str(stmt.compile(db.bind, compile_kwargs={"literal_binds": True})).replace("\n", " ") + except Exception: # noqa: BLE001 + sql = None + + return {"columns": columns, "rows": result_rows, "total": len(result_rows), "sql": sql} + + @staticmethod + def _build_metric_condition(rule: FilterRule, expr: Any) -> ColumnElement: + op = (rule.operator or "=").strip().lower() + try: + value = float(rule.value) + except (TypeError, ValueError) as exc: + raise RuleError(f"HAVING 的值需要是数字,收到 {rule.value!r}") from exc + mapping = { + "=": expr == value, "==": expr == value, "!=": expr != value, "<>": expr != value, + ">": expr > value, ">=": expr >= value, "<": expr < value, "<=": expr <= value, + } + if op not in mapping: + raise RuleError(f"HAVING 不支持操作符「{op}」") + return mapping[op] + + # ---------------------------------------------------------------- 元信息 + @classmethod + def meta(cls) -> list[dict]: + result = [] + for spec in MODEL_REGISTRY.values(): + result.append( + { + "model": spec.key, + "label": spec.label, + "fields": [ + { + "field": name, + "type": f.type, + "desc": f.label, + "sortable": f.sortable, + "filterable": True, + } + for name, f in spec.fields.items() + ], + "relations": list(spec.joins.keys()), + } + ) + return result diff --git a/app/service/auth_service.py b/app/service/auth_service.py new file mode 100644 index 0000000..77de648 --- /dev/null +++ b/app/service/auth_service.py @@ -0,0 +1,61 @@ +"""认证业务:登录、建号、改密。""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.exceptions import AuthError, BusinessError, ConflictError +from app.core.security import create_access_token, hash_password, verify_password +from app.dao.account_dao import AccountDao +from app.model import Account, Role +from app.schema.auth_schema import AccountCreate, PasswordChange + + +class AuthService: + @classmethod + def login(cls, db: Session, username: str, password: str) -> tuple[Account, str, int]: + account = AccountDao.get_by_username(db, username) + if account is None or not verify_password(password, account.hashed_password): + # 不区分"用户不存在"和"密码错误",避免账号枚举 + raise AuthError("用户名或密码不正确") + if not account.is_active: + raise AuthError("该账号已被停用,请联系管理员") + + account.last_login_at = datetime.now() + db.flush() + + expires_seconds = settings.JWT_EXPIRE_MINUTES * 60 + token = create_access_token( + {"sub": str(account.id), "username": account.username, "role": account.role} + ) + return account, token, expires_seconds + + @classmethod + def create_account(cls, db: Session, payload: AccountCreate) -> Account: + if AccountDao.get_by_username(db, payload.username, with_deleted=True): + raise ConflictError(f"登录名「{payload.username}」已被占用") + if payload.role not in {r.value for r in Role}: + raise BusinessError(f"角色只能是 {'/'.join(r.value for r in Role)}") + + account = Account( + username=payload.username, + hashed_password=hash_password(payload.password), + real_name=payload.real_name, + role=payload.role, + advisor_id=payload.advisor_id, + ) + db.add(account) + db.flush() + return account + + @classmethod + def change_password(cls, db: Session, account: Account, payload: PasswordChange) -> None: + if not verify_password(payload.old_password, account.hashed_password): + raise BusinessError("原密码不正确") + if payload.old_password == payload.new_password: + raise BusinessError("新密码不能与原密码相同") + account.hashed_password = hash_password(payload.new_password) + db.flush() diff --git a/app/service/employment_service.py b/app/service/employment_service.py new file mode 100644 index 0000000..f0cdf84 --- /dev/null +++ b/app/service/employment_service.py @@ -0,0 +1,176 @@ +"""就业业务规则(需求 2.3):一个事务里完成"写就业表 + 改学生状态"。 + +为什么必须在一个事务里 +-------------------------------------------------------------------------- +需求写得很明白:"登记就业信息成功时,必须**同时**执行:就业表插入记录 + +学生表状态更新"。这两步是"要么都成、要么都不成"的关系—— +中间断电/异常时,如果就业记录写进去了而学生状态没改,班级就业率就会长期偏低, +而且是那种没人会发现的错。所以这里统一 flush、由调用方一次 commit, +任何一步抛异常都由 FastAPI 的会话依赖整体回滚。 + +返回值约定(不用字符串拼提示语,改由调用方组装): + register -> (employment, created, status_changed, from_text, to_text) + update -> (employment, status_changed, from_text, to_text) + delete -> (status_changed, from_text, to_text) +""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.exceptions import BusinessError, NotFoundError +from app.core.utils import parse_date +from app.dao.employment_dao import EmploymentDao +from app.dao.student_dao import StudentDao +from app.model import Employment, Student, StudentStatus +from app.model.constants import STUDENT_STATUS_TEXT +from app.schema.employment_schema import EmploymentCreate, EmploymentUpdate + + +class EmploymentService: + # ================================================================ 状态推导 + @staticmethod + def derive_status(open_date, offer_date) -> int: + """学生状态的唯一真源:有 offer = 已就业,只有开放时间 = 进入就业。""" + if offer_date is not None: + return int(StudentStatus.EMPLOYED) + if open_date is not None: + return int(StudentStatus.EMPLOYING) + return int(StudentStatus.STUDYING) + + @staticmethod + def status_text(code: int | None) -> str | None: + if code is None: + return None + return STUDENT_STATUS_TEXT.get(code, str(code)) + + @classmethod + def apply_status(cls, student: Student, employment: Employment) -> tuple[bool, str | None, str | None]: + """按就业记录推导并写回学生状态,返回 (是否变化, 原状态文案, 新状态文案)。""" + target = cls.derive_status(employment.open_date, employment.offer_date) + before_text = cls.status_text(student.status) + after_text = cls.status_text(target) + if student.status == target: + return False, before_text, after_text + student.status = target + return True, before_text, after_text + + # ================================================================ 登记/更新 + @classmethod + def register( + cls, db: Session, payload: EmploymentCreate + ) -> tuple[Employment, bool, bool, str | None, str | None]: + """登记就业信息(已存在则更新)。""" + student = StudentDao.get(db, payload.stu_id) + if student is None: + raise NotFoundError(f"学生不存在(id={payload.stu_id})") + + explicit = payload.model_fields_set + employment = EmploymentDao.get_by_stu_id(db, payload.stu_id, with_deleted=True) + created = employment is None or employment.is_del == 1 + if employment is None: + employment = Employment(stu_id=payload.stu_id) + db.add(employment) + + # 只覆盖"调用方这次确实传了的字段": + # 老记录里已有的 offer 时间不会因为这次只补了个公司名就被清空。 + open_date = ( + parse_date(payload.open_date, "就业开放时间") + if "open_date" in explicit + else employment.open_date + ) + offer_date = ( + parse_date(payload.offer_date, "offer 下发时间") + if "offer_date" in explicit + else employment.offer_date + ) + cls._check_dates(open_date, offer_date) + + # 复活被删过的记录时把 is_del 归零,让它重新参与统计 + employment.is_del = 0 + employment.class_id = student.class_id # 冗余班级:以学生当前班级为准 + employment.open_date = open_date + employment.offer_date = offer_date + for key in ("company", "salary", "position", "city", "remark"): + if key in explicit: + setattr(employment, key, getattr(payload, key)) + + db.flush() + changed, before, after = cls.apply_status(student, employment) + db.flush() + return employment, created, changed, before, after + + @classmethod + def update( + cls, db: Session, employment: Employment, payload: EmploymentUpdate + ) -> tuple[Employment, bool, str | None, str | None]: + data = payload.model_dump(exclude_unset=True) + + open_date = parse_date(data.get("open_date"), "就业开放时间") if "open_date" in data else employment.open_date + offer_date = parse_date(data.get("offer_date"), "offer 下发时间") if "offer_date" in data else employment.offer_date + cls._check_dates(open_date, offer_date) + + # open_date 被显式传空 = 清空(改回就业中);只传 offer_date 时保留原开放时间 + if "open_date" in data: + employment.open_date = open_date + if "offer_date" in data: + employment.offer_date = offer_date + for key in ("company", "salary", "position", "city", "remark"): + if key in data: + setattr(employment, key, data[key]) + + # 学生转过班的话,这里顺手把冗余班级对齐 + if employment.student and employment.class_id != employment.student.class_id: + employment.class_id = employment.student.class_id + + db.flush() + if employment.student is None: + return employment, False, None, None + changed, before, after = cls.apply_status(employment.student, employment) + db.flush() + return employment, changed, before, after + + # ================================================================ 删除 + @classmethod + def delete(cls, db: Session, employment: Employment) -> tuple[bool, str | None, str | None]: + """删除就业记录 -> 学生状态回退到"在读"。""" + student = employment.student + employment.soft_delete() + db.flush() + if student is None: + return False, None, None + before = cls.status_text(student.status) + if student.status == int(StudentStatus.STUDYING): + return False, before, before + student.status = int(StudentStatus.STUDYING) + db.flush() + return True, before, cls.status_text(int(StudentStatus.STUDYING)) + + # ================================================================ 冗余同步 + @staticmethod + def sync_class_id(db: Session, student: Student) -> None: + """学生转班时同步就业表的冗余班级字段。 + + 冗余字段的代价就在这儿:不一致必须由代码兜住,不能靠"记得改"。 + """ + employment = EmploymentDao.get_by_stu_id(db, student.id) + if employment and employment.class_id != student.class_id: + employment.class_id = student.class_id + db.flush() + + # ================================================================ 校验 + @staticmethod + def _check_dates(open_date, offer_date) -> None: + if open_date and offer_date and offer_date < open_date: + raise BusinessError( + f"offer 下发时间({offer_date})不能早于就业开放时间({open_date})" + ) + if offer_date and not open_date: + # 只给 offer 时间没给开放时间:能判定已就业,但就业时长算不出来。 + # 这里明确报错而不是默默按 0 天算,避免统计口径悄悄失真。 + raise BusinessError("填了 offer 下发时间就必须填就业开放时间,否则无法计算就业时长") + + @classmethod + def high_salary_line(cls) -> float: + return settings.HIGH_SALARY_LINE diff --git a/app/service/score_service.py b/app/service/score_service.py new file mode 100644 index 0000000..cc49a7e --- /dev/null +++ b/app/service/score_service.py @@ -0,0 +1,150 @@ +"""成绩业务规则(需求 2.2):红线预警、按序次唯一、逻辑删除后复活。""" + +from __future__ import annotations + +from decimal import Decimal + +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.exceptions import BusinessError, ConflictError, NotFoundError +from app.core.utils import parse_date +from app.dao.score_dao import ScoreDao +from app.dao.student_dao import StudentDao +from app.model import Score, Student +from app.schema.score_schema import ScoreCreate, ScoreUpdate + + +class ScoreService: + # ================================================================ 红线 + @staticmethod + def check_warning(score: Decimal | float) -> tuple[int, str | None]: + """低于红线返回 (1, 提示文案)。阈值来自配置,不写死 60。""" + value = float(score) + if value < settings.SCORE_WARN_LINE: + if value < 30: + level = "严重偏低" + elif value < settings.SCORE_WARN_LINE: + level = "低于及格线" + else: + level = "偏低" + msg = ( + f"成绩 {value:g} 分{level}(红线 {settings.SCORE_WARN_LINE:g} 分)," + f"已标记为需重点关注" + ) + return 1, msg + return 0, None + + # ================================================================ 录入 + @classmethod + def create(cls, db: Session, payload: ScoreCreate) -> tuple[Score, bool, str | None]: + student = StudentDao.get(db, payload.stu_id) + if student is None: + raise NotFoundError(f"学生不存在(id={payload.stu_id})") + + exam_date = parse_date(payload.exam_date, "考核日期") + flag, warning_msg = cls.check_warning(payload.score) + + # (学生, 序次) 是唯一键:已存在就报冲突并告诉调用方走修改; + # 若是被逻辑删除过的老记录,直接"复活"它,避免唯一键撞车。 + existing = ScoreDao.get_by_stu_seq(db, payload.stu_id, payload.exam_seq, with_deleted=True) + if existing is not None: + if existing.is_del == 0: + raise ConflictError( + f"{student.name} 的第 {payload.exam_seq} 次考核成绩已存在" + f"({float(existing.score):g} 分),请改用修改接口" + ) + existing.is_del = 0 + existing.score = payload.score + existing.flag = flag + existing.exam_name = payload.exam_name + existing.exam_date = exam_date + existing.remark = payload.remark + db.flush() + return existing, flag == 1, warning_msg + + score = Score( + stu_id=payload.stu_id, + exam_seq=payload.exam_seq, + exam_name=payload.exam_name, + exam_date=exam_date, + score=payload.score, + flag=flag, + remark=payload.remark, + ) + db.add(score) + db.flush() + + if flag == 1: + cls._append_student_note(student, f"第{payload.exam_seq}次考核 {float(payload.score):g} 分") + return score, flag == 1, warning_msg + + @classmethod + def batch_create(cls, db: Session, stu_id: int, items: list[ScoreCreate]) -> list[tuple[Score, bool, str | None]]: + results = [] + for item in items: + if item.stu_id != stu_id: + raise BusinessError("批量录入的成绩必须属于同一个学生") + results.append(cls.create(db, item)) + return results + + # ================================================================ 修改 + @classmethod + def update(cls, db: Session, score: Score, payload: ScoreUpdate) -> tuple[Score, bool, str | None]: + data = payload.model_dump(exclude_unset=True) + warning_msg = None + flag = score.flag + + if data.get("score") is not None: + score.score = data["score"] + flag, warning_msg = cls.check_warning(score.score) + score.flag = flag + if "exam_name" in data: + score.exam_name = data["exam_name"] + if "exam_date" in data: + score.exam_date = parse_date(data["exam_date"], "考核日期") + if "remark" in data: + score.remark = data["remark"] + + db.flush() + if flag == 1 and score.student is not None: + cls._append_student_note(score.student, f"第{score.exam_seq}次考核 {float(score.score):g} 分") + return score, flag == 1, warning_msg + + # ================================================================ 删除 + @classmethod + def delete(cls, db: Session, score: Score) -> None: + score.soft_delete() + db.flush() + + # ================================================================ 辅助 + @staticmethod + def _append_student_note(student: Student, note: str) -> None: + """把预警写进学生备注,顾问打开学生列表就能看到。""" + tag = f"[{note}]" + if student.remark and tag in student.remark: + return + prefix = "成绩预警:" + student.remark = f"{student.remark} {prefix}{note}".strip() if student.remark else f"{prefix}{note}" + + @staticmethod + def student_summary(db: Session, stu_id: int) -> dict: + scores = ScoreDao.list_by_student(db, stu_id) + values = [float(s.score) for s in scores] + return { + "count": len(values), + "avg": round(sum(values) / len(values), 2) if values else None, + "max": max(values) if values else None, + "min": min(values) if values else None, + "fail": len([s for s in scores if s.flag == 1]), + "scores": [ + { + "exam_seq": s.exam_seq, + "exam_name": s.exam_name, + "score": float(s.score), + "flag": s.flag, + "exam_date": s.exam_date.isoformat() if s.exam_date else None, + } + for s in scores + ], + } diff --git a/app/service/statistics_service.py b/app/service/statistics_service.py new file mode 100644 index 0000000..efe29c6 --- /dev/null +++ b/app/service/statistics_service.py @@ -0,0 +1,367 @@ +"""统计分析业务层(需求 2.6 / 2.7.2)。 + +DAO 出聚合结果,这里做二次计算:排名、比率、标准差、趋势、四舍五入。 +这样 SQL 保持"只取数",业务口径全部集中在 Python 里,改口径不用改 SQL。 +""" + +from __future__ import annotations + +import statistics +from statistics import StatisticsError + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.exceptions import BusinessError +from app.core.utils import age_expression +from app.dao.advisor_dao import AdvisorDao +from app.dao.clazz_dao import ClazzDao +from app.dao.employment_dao import EmploymentDao +from app.dao.score_dao import ScoreDao +from app.dao.statistics_dao import StatisticsDao +from app.dao.student_dao import StudentDao +from app.dao.teacher_dao import TeacherDao +from app.model import Score, Student +from app.model.constants import CLASS_STATUS_TEXT, STUDENT_STATUS_TEXT + +ROUND = 2 + + +def _r(value, digits: int = ROUND): + return round(value, digits) if value is not None else None + + +class StatisticsService: + # ============================================================ 2.6.1 + @classmethod + def age_query( + cls, + db: Session, + operator: str, + value: int | None = None, + value2: int | None = None, + **filters, + ): + """把"大于/小于/等于/区间"翻译成 age_min / age_max 两个参数。""" + op = (operator or "gte").strip().lower() + age_min = age_max = None + + if op in ("gt", ">"): + age_min = (value or 0) + 1 + elif op in ("gt_eq", "gte", ">="): + age_min = value + elif op in ("lt", "<"): + age_max = (value or 0) - 1 + elif op in ("lt_eq", "lte", "<="): + age_max = value + elif op in ("eq", "=", "=="): + age_min = age_max = value + elif op in ("ne", "!="): + # "不等于"用区间实现:<= v-1 或 >= v+1 + pass + elif op in ("between", "range"): + if value is None or value2 is None: + raise BusinessError("区间查询需要同时提供 value 和 value2") + age_min, age_max = min(value, value2), max(value, value2) + else: + raise BusinessError( + "operator 只支持 gt/gte/lt/lte/eq/between(也接受 > >= < <= = 写法)" + ) + + if age_min is None and age_max is None and op not in ("ne",): + raise BusinessError("请提供年龄阈值 value") + + stmt = StudentDao.build_stmt(age_min=age_min, age_max=age_max, **filters) + if op == "ne": + age_col = age_expression(Student.birth_date) + stmt = stmt.where(or_(age_col < value, age_col > value)) + return stmt + + @classmethod + def class_overview(cls, db: Session) -> list[dict]: + rows = StatisticsDao.class_overview(db) + teacher_map = {t.id: t.name for t in TeacherDao.all(db, TeacherDao.build_stmt())} + result = [] + for row in rows: + total = int(row["total"] or 0) + male = int(row["male"] or 0) + female = int(row["female"] or 0) + result.append( + { + "class_id": row["id"], + "class_no": row["class_no"], + "class_name": row["name"], + "status_text": {1: "在读", 2: "已结课", 3: "已解散"}.get(row["status"]), + "head_teacher_name": None, + "total": total, + "male": male, + "female": female, + "other": total - male - female, + } + ) + # 补班主任名字(一次查全表映射,避免 N+1) + for item, row in zip(result, rows): + item["head_teacher_name"] = teacher_map.get(row.get("head_teacher_id")) if "head_teacher_id" in row else None + return result + + # ============================================================ 2.6.2 + @classmethod + def all_above(cls, db: Session, threshold: float, class_id: int | None = None) -> list[dict]: + rows = StatisticsDao.all_above(db, threshold, class_id) + samples = StatisticsDao.score_samples(db, [r["stu_id"] for r in rows]) + for row in rows: + row["min_score"] = _r(row["min_score"]) + row["avg_score"] = _r(row["avg_score"]) + row["scores"] = samples.get(row["stu_id"], []) + return rows + + @classmethod + def fail_students(cls, db: Session, threshold: float, min_times: int) -> list[dict]: + rows = StatisticsDao.fail_students(db, threshold, min_times) + samples = StatisticsDao.score_samples(db, [r["stu_id"] for r in rows]) + for row in rows: + row["avg_score"] = _r(row["avg_score"]) + row["fail_details"] = [ + s for s in samples.get(row["stu_id"], []) if s["score"] < threshold + ] + return rows + + @classmethod + def class_exam_avg( + cls, db: Session, exam_seq: int | None, class_id: int | None, order: str, pass_line: float + ) -> list[dict]: + rows = StatisticsDao.class_exam_avg(db, exam_seq, class_id, order, pass_line) + for row in rows: + row["avg_score"] = _r(row["avg_score"]) + row["max_score"] = _r(row["max_score"]) + row["min_score"] = _r(row["min_score"]) + row["pass_rate"] = _r(row["pass_rate"], 1) + return rows + + # ============================================================ 2.6.3 + @classmethod + def salary_top(cls, db: Session, top_n: int) -> list[dict]: + if top_n <= 0: + raise BusinessError("top_n 必须为正整数") + rows = StatisticsDao.salary_top(db, top_n) + result = [] + for index, row in enumerate(rows, start=1): + salary = float(row["salary"]) if row["salary"] is not None else 0.0 + duration = None + if row["open_date"] and row["offer_date"]: + duration = (row["offer_date"] - row["open_date"]).days + result.append( + { + "rank": index, + "stu_id": row["stu_id"], + "stu_no": row["stu_no"], + "name": row["name"], + "class_name": row["class_name"], + "company": row["company"], + "salary": salary, + "salary_wan": _r(salary / 10000), + "offer_date": row["offer_date"], + "duration_days": duration, + } + ) + return result + + @classmethod + def student_durations(cls, db: Session, class_id: int | None = None) -> list[dict]: + rows = StatisticsDao.student_durations(db, class_id) + for row in rows: + row["duration_days"] = int(row["duration_days"]) if row["duration_days"] is not None else None + row["state"] = "已拿到 offer" if row["offer_date"] else "就业中(未收到 offer)" + return rows + + @classmethod + def class_avg_duration(cls, db: Session) -> list[dict]: + rows = StatisticsDao.class_avg_duration(db) + result = [] + for row in rows: + open_count = int(row["open_count"] or 0) + result.append( + { + "class_id": row["class_id"], + "class_name": row["class_name"], + "student_count": int(row["student_count"] or 0), + "open_count": open_count, + "offer_count": int(row["offer_count"] or 0), + "avg_duration_days": _r(row["avg_duration_days"], 1), + "min_duration_days": int(row["min_duration_days"]) if row["min_duration_days"] is not None else None, + "max_duration_days": int(row["max_duration_days"]) if row["max_duration_days"] is not None else None, + } + ) + # 平均时长按"有数据"的排前面,没数据的沉底 + result.sort(key=lambda x: (x["avg_duration_days"] is None, -(x["avg_duration_days"] or 0))) + return result + + # ============================================================ 2.7.2 + @classmethod + def score_volatility(cls, db: Session, top_n: int = 5, metric: str = "stddev") -> list[dict]: + """成绩波动 Top N。 + + 标准差在 Python 里算(statistics.pstdev),最大分差在 SQL 里算, + 两种方式都在这一份代码里做了,答辩时可以直接对比口径差异。 + """ + stats_rows = StatisticsDao.score_stats_per_student(db) + if not stats_rows: + return [] + samples = StatisticsDao.score_samples(db, [r["stu_id"] for r in stats_rows]) + + student_map = { + s.id: s + for s in StudentDao.all(db, StudentDao.build_stmt(order_by="id", order="asc")) + } + + items = [] + for row in stats_rows: + values = [s["score"] for s in samples.get(row["stu_id"], [])] + if len(values) < 2: + continue + try: + stddev = statistics.pstdev(values) + except StatisticsError: + stddev = 0.0 + score_range = float(row["score_range"] or 0) + faculty = { + "stddev": stddev, + "range": score_range, + "max_minus_min": score_range, + } + if metric not in faculty: + raise BusinessError("metric 只支持 stddev(标准差)或 range(最大分差)") + trend_slope = _r(cls._trend(values)) or 0.0 # 先定稿到出参精度,再据它判方向 + student = student_map.get(row["stu_id"]) + items.append( + { + "stu_id": row["stu_id"], + "stu_no": student.stu_no if student else None, + "name": student.name if student else None, + "class_name": student.class_name if student else None, + "exam_count": int(row["exam_count"]), + "avg_score": _r(row["avg_score"]), + "stddev": _r(stddev), + "score_range": _r(score_range), + "trend": cls._trend_text(trend_slope, len(values)), + "trend_slope": trend_slope, + "scores": values, + "_key": faculty[metric], + } + ) + + items.sort(key=lambda x: x["_key"], reverse=True) + for index, item in enumerate(items[:top_n], start=1): + item["rank"] = index + item.pop("_key", None) + return items[:top_n] + + @staticmethod + def _trend(values: list[float]) -> float: + """成绩趋势:对「考核序次」做最小二乘拟合,返回斜率(分/场)。 + + 为什么不用「首次 vs 末次」:单次考试成绩噪声很大,首末两点一比, + 89 -> 88 这种正常抖动会被说成"下降",而 32 -> 82 这种真实回升又可能因为 + 末次恰好回落而被说成"下降"。 + 为什么不用「前半段 vs 后半段均值的差」:那是阶跃比较,看不出变化幅度, + 也没法排序。而且它对 5 次成绩的分段是不均的(前 2 后 3), + 会出现"首考 87.8、末考 82.0 却判为上升"这种让人不信服的结果。 + + 最小二乘斜率是趋势的标准定义:用上全部数据点、抗噪声、可正可负、单位可解释。 + """ + n = len(values) + if n < 2: + return 0.0 + x_mean = (n - 1) / 2 + y_mean = sum(values) / n + numerator = sum((i - x_mean) * (v - y_mean) for i, v in enumerate(values)) + denominator = sum((i - x_mean) ** 2 for i in range(n)) + if denominator == 0: + return 0.0 + return numerator / denominator + + @staticmethod + def _trend_text(slope: float, n: int) -> str: + """把斜率翻成中文。阈值 1 分/场:一学期 5 次考核看下来涨跌不到 4 分,算原地踏步。 + + 注意:调用方必须先把斜率四舍五入到出参精度(2 位小数)再传进来, + 否则会出现「标签写 基本持平、旁边数字写 +1.0/场」这种自相矛盾的显示。 + """ + if n < 2: + return "数据不足" + if slope >= 1.0: + return "上升" + if slope <= -1.0: + return "下降" + return "基本持平" + + @classmethod + def employment_funnel(cls, db: Session, high_line: float | None = None) -> list[dict]: + high_line = high_line or settings.HIGH_SALARY_LINE + rows = StatisticsDao.class_funnel(db, high_line) + result = [] + for row in rows: + total = int(row["total"] or 0) + employed = int(row["employed"] or 0) + high = int(row["high_salary"] or 0) + result.append( + { + "class_id": row["class_id"], + "class_no": row["class_no"], + "class_name": row["class_name"], + "total": total, + "employed": employed, + "high_salary": high, + "employment_rate": _r(employed / total * 100, 1) if total else 0.0, + "high_salary_rate": _r(high / employed * 100, 1) if employed else 0.0, + "avg_salary": _r(row["avg_salary"]), + "avg_duration_days": _r(row["avg_duration_days"], 1), + "high_line": high_line, + } + ) + result.sort(key=lambda x: x["employment_rate"], reverse=True) + return result + + # ============================================================ 总览 + @classmethod + def overview(cls, db: Session) -> dict: + status_map = StudentDao.count_by_status(db) + student_total = sum(status_map.values()) + + emp_stats = EmploymentDao.salary_stats(db, settings.HIGH_SALARY_LINE) + + score_total = int(db.scalar(select(func.count(Score.id)).where(Score.alive())) or 0) + score_avg = db.scalar(select(func.avg(Score.score)).where(Score.alive())) + warning_ids = StudentDao.warning_student_ids(db) + + funnel = cls.employment_funnel(db) + + return { + "student_total": student_total, + "student_by_status": { + STUDENT_STATUS_TEXT.get(code, str(code)): count + for code, count in sorted(status_map.items()) + }, + "class_total": ClazzDao.count(db, ClazzDao.build_stmt()), + "teacher_total": TeacherDao.count(db, TeacherDao.build_stmt()), + "advisor_total": AdvisorDao.count(db, AdvisorDao.build_stmt()), + "employment_total": emp_stats["count"], + "employment_rate": _r(emp_stats["count"] / student_total * 100, 1) if student_total else 0.0, + "avg_salary": _r(emp_stats["avg"]), + "high_salary_count": emp_stats["high"], + "score_record_total": score_total, + "score_avg": _r(float(score_avg)) if score_avg is not None else None, + "warning_student_count": len(warning_ids), + "top_classes": [ + { + "class_name": item["class_name"], + "class_no": item["class_no"], + "total": item["total"], + "employed": item["employed"], + "employment_rate": item["employment_rate"], + } + for item in funnel[:5] + ], + "volatility_top": cls.score_volatility(db, 5)[:5], + } diff --git a/app/service/student_service.py b/app/service/student_service.py new file mode 100644 index 0000000..c652630 --- /dev/null +++ b/app/service/student_service.py @@ -0,0 +1,367 @@ +"""学生业务规则:学号生成、录入校验、转班同步、Excel 批量导入。""" + +from __future__ import annotations + +import logging +from datetime import date +from io import BytesIO +from typing import Any + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.exceptions import BusinessError, ConflictError, NotFoundError +from app.core.utils import guess_birth_date, parse_date +from app.dao.advisor_dao import AdvisorDao +from app.dao.clazz_dao import ClazzDao +from app.dao.student_dao import StudentDao +from app.model import Student +from app.schema.student_schema import StudentCreate, StudentImportResult, StudentUpdate + +logger = logging.getLogger(__name__) + + +class StudentService: + # ================================================================ 学号 + @staticmethod + def build_stu_no( + db: Session, class_id: int | None, enroll_date: date | None, fallback_year: int | None = None + ) -> str: + """学号规则:前缀(2) + 入学年份(4) + 班级序号(2) + 班内序号(3),共 11 位。 + + 例:WL + 2026 + 01 + 007 -> WL202601007 + + * 前缀固定 WL(沃林),出问题一眼能看出是哪个系统的学号; + * 入学年份取 enroll_date,没填就用当前年; + * 班级序号取班级编号尾部两位(JAVA2026**01** -> 01),没班级就 00; + * 班内序号在该"年份+班级"范围内自增,不会因为全表自增而穿透班级。 + + 注意:会话是 autoflush=False 的,如果调用方已经 add 了几个学生但没 flush, + 这里查 max(stu_no) 是看不见它们的,算出来的序号会撞车。所以先 flush 一次。 + """ + db.flush() + + if enroll_date: + year = enroll_date.year + else: + year = fallback_year or date.today().year + + class_part = "00" + if class_id: + klass = ClazzDao.get(db, class_id) + if klass: + digits = "".join(ch for ch in klass.class_no if ch.isdigit()) + class_part = digits[-2:].zfill(2) if len(digits) >= 2 else f"{klass.id % 100:02d}" + + prefix = f"{settings.STU_NO_PREFIX}{year}{class_part}" + last = StudentDao.last_stu_no(db, prefix) + seq = int(last[len(prefix):]) + 1 if last and last[len(prefix):].isdigit() else 1 + + # 兜底:万一同前缀下已经有人占了这个号(并发 / 手工导入的历史数据), + # 往后顺延,绝不把"学号重复"这种错甩给调用方。 + for _ in range(1000): + candidate = f"{prefix}{seq:03d}" + if StudentDao.get_by_stu_no(db, candidate, with_deleted=True) is None: + return candidate + seq += 1 + raise ConflictError(f"学号前缀 {prefix} 下的编号已用尽,请检查班级编号规则") + + @staticmethod + def ensure_unique_stu_no(db: Session, stu_no: str, exclude_id: int | None = None) -> None: + exist = StudentDao.get_by_stu_no(db, stu_no, with_deleted=True) + if exist and exist.id != exclude_id: + raise ConflictError(f"学号 {stu_no} 已存在") + + # ================================================================ 创建 + @classmethod + def create(cls, db: Session, payload: StudentCreate) -> Student: + cls._check_class_advisor(db, payload.class_id, payload.advisor_id) + + birth = parse_date(payload.birth_date, "出生日期") + estimated = 0 + if birth is None and payload.age is not None: + birth = guess_birth_date(payload.age) + estimated = 1 + + enroll = parse_date(payload.enroll_date, "入学时间") + graduate = parse_date(payload.graduate_date, "毕业时间") + if enroll and graduate and graduate < enroll: + raise BusinessError("毕业时间不能早于入学时间") + + stu_no = (payload.stu_no or "").strip() or cls.build_stu_no(db, payload.class_id, enroll) + cls.ensure_unique_stu_no(db, stu_no) + + student = Student( + stu_no=stu_no, + name=payload.name, + gender=payload.gender, + birth_date=birth, + birth_date_estimated=estimated, + native_place=payload.native_place, + graduate_school=payload.graduate_school, + major=payload.major, + education=payload.education, + enroll_date=enroll, + graduate_date=graduate, + phone=payload.phone, + id_card=payload.id_card, + class_id=payload.class_id, + advisor_id=payload.advisor_id, + status=payload.status or 1, + remark=payload.remark, + ) + db.add(student) + try: + db.flush() + except IntegrityError as exc: + # 注意:这里**不要** db.rollback()。 + # 批量导入时外面套着 SAVEPOINT,一旦整条事务回滚, + # 同一次导入里前面已经成功的行会跟着一起没(踩过一次)。 + # 交给外层的 savepoint / 请求结束时的会话关闭去处理。 + raise ConflictError(f"学号 {stu_no} 冲突,请检查是否重复导入") from exc + return student + + # ================================================================ 更新 + @classmethod + def update(cls, db: Session, student: Student, payload: StudentUpdate) -> Student: + data = payload.model_dump(exclude_unset=True) + if "class_id" in data: + cls._check_class_advisor(db, data["class_id"], None) + if "advisor_id" in data: + cls._check_class_advisor(db, None, data["advisor_id"]) + + old_class_id = student.class_id + + if "birth_date" in data and data["birth_date"]: + student.birth_date = parse_date(data.pop("birth_date"), "出生日期") + student.birth_date_estimated = 0 + if "age" in data and data["age"] and not student.birth_date: + student.birth_date = guess_birth_date(data.pop("age")) + student.birth_date_estimated = 1 + + for field in ("enroll_date", "graduate_date"): + if field in data and data[field]: + setattr(student, field, parse_date(data.pop(field), field)) + if student.enroll_date and student.graduate_date and student.graduate_date < student.enroll_date: + raise BusinessError("毕业时间不能早于入学时间") + + for key, value in data.items(): + if value is not None and hasattr(student, key): + setattr(student, key, value) + + db.flush() + + # 转班了 -> 同步就业表里的冗余班级字段,避免就业统计按旧班算 + if "class_id" in payload.model_dump(exclude_unset=True) and student.class_id != old_class_id: + from app.service.employment_service import EmploymentService + + EmploymentService.sync_class_id(db, student) + + return student + + @staticmethod + def _check_class_advisor(db: Session, class_id: int | None, advisor_id: int | None) -> None: + if class_id is not None and ClazzDao.get(db, class_id) is None: + raise NotFoundError(f"班级不存在(id={class_id})") + if advisor_id is not None and AdvisorDao.get(db, advisor_id) is None: + raise NotFoundError(f"顾问不存在(id={advisor_id})") + + # ================================================================ 删除 + @classmethod + def delete(cls, db: Session, student: Student) -> None: + """逻辑删除学生:成绩、就业一并逻辑删除,避免统计里留下孤儿数据。""" + from app.dao.employment_dao import EmploymentDao + from app.dao.score_dao import ScoreDao + + for score in ScoreDao.list_by_student(db, student.id): + score.soft_delete() + emp = EmploymentDao.get_by_stu_id(db, student.id) + if emp: + emp.soft_delete() + student.soft_delete() + db.flush() + + # ================================================================ Excel 导入 + HEADER_MAP: dict[str, str] = { + "姓名": "name", + "性别": "gender", + "出生日期": "birth_date", + "年龄": "age", + "籍贯": "native_place", + "毕业院校": "graduate_school", + "专业": "major", + "学历": "education", + "入学时间": "enroll_date", + "毕业时间": "graduate_date", + "联系电话": "phone", + "身份证号": "id_card", + "班级": "class_name", + "班级编号": "class_no", + "顾问": "advisor_name", + "备注": "remark", + } + + @classmethod + def import_from_excel(cls, db: Session, content: bytes, dry_run: bool = False) -> StudentImportResult: + """Excel 批量导入。 + + * 表头按中文名识别,顺序随意; + * 班级列支持"班级编号"或"班级名称",顾问列支持姓名;找不到就报错而不是默默丢掉; + * 逐行独立事务语义:某一行失败不影响其它行,最后把失败行号+原因整体返回。 + """ + try: + from openpyxl import load_workbook + + wb = load_workbook(BytesIO(content), data_only=True) + except Exception as exc: # noqa: BLE001 + raise BusinessError(f"Excel 文件解析失败:{exc}") from exc + + ws = wb.active + rows = list(ws.iter_rows(values_only=True)) + if not rows: + raise BusinessError("Excel 内容为空") + + header = [str(c).strip() if c is not None else "" for c in rows[0]] + known = {h for h in header if h in cls.HEADER_MAP} + if "姓名" not in known: + raise BusinessError( + "表头缺少必填列「姓名」。可用的列:" + "、".join(cls.HEADER_MAP.keys()) + ) + + from app.dao.advisor_dao import AdvisorDao + + class_by_no = {class_no: cid for cid, class_no in ClazzDao.no_map(db).items()} + class_by_name = {v: k for k, v in ClazzDao.name_map(db).items()} + advisor_by_name = { + a.name: a.id for a in AdvisorDao.all(db, AdvisorDao.build_stmt(order_by="id", order="asc")) + } + + result = StudentImportResult(total=0, success=0, failed=0) + seen_nos: set[str] = set() + + for idx, raw in enumerate(rows[1:], start=2): + if raw is None or all(v is None or str(v).strip() == "" for v in raw): + continue + result.total += 1 + data: dict[str, Any] = {} + for col_idx, cell in enumerate(raw): + if col_idx >= len(header): + break + key = cls.HEADER_MAP.get(header[col_idx]) + if key: + data[key] = cell + + try: + class_id = None + if data.get("class_no"): + class_id = class_by_no.get(str(data["class_no"]).strip()) + if class_id is None: + raise BusinessError(f"班级编号 {data['class_no']} 不存在") + elif data.get("class_name"): + class_id = class_by_name.get(str(data["class_name"]).strip()) + if class_id is None: + raise BusinessError(f"班级 {data['class_name']} 不存在,请先建班") + + advisor_id = None + if data.get("advisor_name"): + advisor_id = advisor_by_name.get(str(data["advisor_name"]).strip()) + if advisor_id is None: + raise BusinessError(f"顾问 {data['advisor_name']} 不存在") + + payload = StudentCreate( + name=str(data.get("name") or "").strip(), + gender=data.get("gender") or 1, + birth_date=cls._cell_to_date_str(data.get("birth_date")), + age=int(data["age"]) if data.get("age") not in (None, "") else None, + native_place=cls._cell_str(data.get("native_place")), + graduate_school=cls._cell_str(data.get("graduate_school")), + major=cls._cell_str(data.get("major")), + education=cls._cell_str(data.get("education")), + enroll_date=cls._cell_to_date_str(data.get("enroll_date")), + graduate_date=cls._cell_to_date_str(data.get("graduate_date")), + phone=cls._cell_str(data.get("phone")), + id_card=cls._cell_str(data.get("id_card")), + class_id=class_id, + advisor_id=advisor_id, + remark=cls._cell_str(data.get("remark")), + ) + + if dry_run: + cls.build_stu_no(db, class_id, parse_date(payload.enroll_date, "入学时间")) + result.success += 1 + else: + # 每行一个 SAVEPOINT:这一行出错只回滚这一行,前面已成功的行不受影响。 + # 千万不要在这里写 db.rollback() —— 那是整条事务回滚, + # 会把之前所有成功的行一起丢掉(这个坑真踩过一次)。 + with db.begin_nested(): + student = cls.create(db, payload) + if student.stu_no in seen_nos: + raise ConflictError(f"学号 {student.stu_no} 在文件内重复") + seen_nos.add(student.stu_no) + result.success += 1 + except Exception as exc: # noqa: BLE001 + result.failed += 1 + result.errors.append( + {"row": idx, "name": cls._cell_str(data.get("name")), "reason": str(exc)} + ) + if len(result.errors) >= 50: # 错误太多就截断,避免响应体爆炸 + result.errors.append({"row": "-", "name": "-", "reason": "错误过多,已截断"}) + break + + if not dry_run: + db.commit() + return result + + @staticmethod + def _cell_str(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + @classmethod + def _cell_to_date_str(cls, value: Any) -> str | None: + if value is None: + return None + if isinstance(value, date): + return value.isoformat() + text = str(value).strip() + if not text: + return None + # Excel 里经常写成 2026/9/1 + for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y%m%d"): + try: + from datetime import datetime + + return datetime.strptime(text, fmt).date().isoformat() + except ValueError: + continue + raise BusinessError(f"日期「{text}」格式不对,应为 YYYY-MM-DD") + + # ================================================================ 模板 + @staticmethod + def build_import_template() -> bytes: + from openpyxl import Workbook + from openpyxl.styles import Alignment, Font, PatternFill + + wb = Workbook() + ws = wb.active + ws.title = "学生导入模板" + headers = list(StudentService.HEADER_MAP.keys()) + ws.append(headers) + for cell in ws[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="4C6EF5") + cell.alignment = Alignment(horizontal="center") + ws.append([ + "张三", "男", "2003-05-12", "", "广东深圳", "深圳职业技术学院", "软件技术", "大专", + "2024-09-01", "2027-06-30", "13800000000", "", "", "JAVA202601", "李顾问", "示例行,导入前删掉", + ]) + widths = [10, 8, 14, 8, 16, 22, 16, 10, 14, 14, 16, 20, 14, 16, 12, 24] + for i, w in enumerate(widths, start=1): + ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w + buffer = BytesIO() + wb.save(buffer) + return buffer.getvalue() diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..81c94d2 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,1953 @@ +/* ============================================================ + 沃林学生管理系统 - 前端(原生 JS,零依赖) + ============================================================ */ + +/* ---------------- 基础工具 ---------------- */ +const $ = (sel, root = document) => root.querySelector(sel); +const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); +const esc = (s) => (s === null || s === undefined ? '' : String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, ''')); +const nz = (v, alt = '—') => (v === null || v === undefined || v === '' ? alt : v); +const num = (v, d = 0) => (v === null || v === undefined || v === '' ? '—' : Number(v).toFixed(d)); +const money = (v) => (v === null || v === undefined || v === '' ? '—' : '¥' + Number(v).toLocaleString('zh-CN')); +const today = () => new Date().toISOString().slice(0, 10); + +/* ---------------- 提示 ---------------- */ +function toast(msg, type = 'ok', ms = 3200) { + const icons = { ok: '✅', err: '⛔', warn: '⚠️', info: 'ℹ️' }; + const box = document.createElement('div'); + box.className = 'toast ' + type; + box.innerHTML = `${icons[type] || 'ℹ️'}${esc(msg)}`; + $('#toasts').appendChild(box); + setTimeout(() => { box.style.opacity = '0'; setTimeout(() => box.remove(), 200); }, ms); +} + +/* ---------------- API ---------------- */ +const API = { + token: localStorage.getItem('wolin_token') || '', + + setToken(t) { this.token = t || ''; t ? localStorage.setItem('wolin_token', t) : localStorage.removeItem('wolin_token'); }, + + async req(method, path, opts = {}) { + const headers = {}; + if (this.token) headers['Authorization'] = 'Bearer ' + this.token; + let body; + if (opts.form) { + body = opts.form; + } else if (opts.body !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(opts.body); + } + let url = path; + if (opts.query) { + const qs = new URLSearchParams(); + Object.entries(opts.query).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') qs.append(k, v); + }); + const s = qs.toString(); + if (s) url += (url.includes('?') ? '&' : '?') + s; + } + const res = await fetch(url, { method, headers, body }); + let data; + try { data = await res.json(); } catch (e) { throw new Error('服务返回格式异常:' + res.status); } + if (data && data.code !== 0) { + if (data.code === 401) { doLogout(true); } + const err = new Error(data.msg || '请求失败'); + err.code = data.code; + throw err; + } + return data.data; + }, + get(p, query) { return this.req('GET', p, { query }); }, + post(p, body) { return this.req('POST', p, { body }); }, + put(p, body) { return this.req('PUT', p, { body }); }, + del(p) { return this.req('DELETE', p); }, +}; + +/* ---------------- 当前登录者与写权限 ---------------- + 只读账号的写入口靠 CSS 藏(见 style.css 的 .readonly-mode .w-act)。 + 故意只用这一套机制:如果同一件事既有"条件渲染"又有"CSS 隐藏"两条路, + 以后改动的人很容易只改一边,留下一个既不渲染又藏不住的怪状态。 + 注意 CSS 只是体验层的遮羞布,真正的门禁在后端 —— 只读账号即使把 + class 摘掉、按钮点出来,请求照样会被 403 拦下。 */ +let Me = null; +const canWrite = () => !!Me && Me.role !== 'viewer'; + +/* ---------------- 弹窗 ---------------- */ +function openModal({ title, bodyHtml, footerHtml = '', width = '', onMount, onOk, okText = '保存' }) { + const root = $('#modal-root'); + const mask = document.createElement('div'); + mask.className = 'modal-mask'; + mask.innerHTML = ` + `; + root.appendChild(mask); + + const close = () => mask.remove(); + mask.addEventListener('click', (e) => { + if (e.target === mask || e.target.hasAttribute('data-close')) close(); + }); + const okBtn = mask.querySelector('[data-ok]'); + if (okBtn && onOk) { + okBtn.addEventListener('click', async () => { + okBtn.disabled = true; + try { const r = await onOk(mask); if (r !== false) close(); } + catch (err) { toast(err.message, 'err'); } + finally { okBtn.disabled = false; } + }); + } + if (onMount) onMount(mask, close); + const first = mask.querySelector('input,select,textarea'); + if (first) setTimeout(() => first.focus(), 60); + return { mask, close }; +} + +function confirmBox(title, message, onYes, danger = true) { + openModal({ + title, + width: 'narrow', + bodyHtml: `
${message}
`, + footerHtml: ` + `, + onOk: async () => onYes(), + }); +} + +/* ---------------- 表格 / 分页 ---------------- */ +function renderTable(columns, rows, opts = {}) { + if (!rows || !rows.length) { + return `
${opts.emptyText || '暂无数据'}
`; + } + const head = columns.map((c) => `${esc(c.title)}`).join(''); + const body = rows.map((row, i) => { + const cls = opts.rowClass ? opts.rowClass(row) : ''; + const tds = columns.map((c) => { + const v = c.render ? c.render(row, i) : esc(nz(row[c.key])); + return `${v}`; + }).join(''); + return `${tds}`; + }).join(''); + return `
${head}${body}
`; +} + +function renderPager(page, pages, total, onGo) { + const id = 'pager-' + Math.random().toString(36).slice(2, 8); + window.__pagers = window.__pagers || {}; + window.__pagers[id] = onGo; + return `
+ 共 ${total} 条,第 ${page} / ${Math.max(pages, 1)} 页 + + + + + +
`; +} + +document.addEventListener('click', (e) => { + const b = e.target.closest('[data-pager]'); + if (b && !b.disabled) { + const fn = (window.__pagers || {})[b.dataset.pager]; + if (fn) fn(Number(b.dataset.page)); + } +}); + +/* ---------------- 条形图 ---------------- */ +function barChart(items, opts = {}) { + if (!items || !items.length) return `
暂无数据
`; + const max = Math.max(...items.map((i) => Number(i.value) || 0), 1); + const color = opts.color || 'g'; + return items.map((it) => { + const pct = Math.max((Number(it.value) || 0) / max * 100, 1.5); + return `
+ ${esc(it.label)} + + ${esc(it.text !== undefined ? it.text : it.value)} +
`; + }).join(''); +} + +/* ---------------- 状态映射 ---------------- */ +const STU_STATUS = { 1: ['在读', 'blue'], 2: ['进入就业', 'orange'], 3: ['已就业', 'green'] }; +const CLS_STATUS = { 1: ['在读', 'blue'], 2: ['已结课', 'gray'], 3: ['已解散', 'red'] }; +const stuTag = (v, t) => `${esc(t || (STU_STATUS[v] || ['未知'])[0])}`; +const clsTag = (v, t) => `${esc(t || (CLS_STATUS[v] || ['未知'])[0])}`; +// 成绩趋势:后端给的是最小二乘斜率(分/场),标签 + 斜率一起显示,方便对账 +const TREND_COLOR = { 上升: 'green', 下降: 'red', 基本持平: 'gray', 数据不足: 'gray' }; +const trendTag = (r) => { + const color = TREND_COLOR[r.trend] || 'gray'; + const slope = typeof r.trend_slope === 'number' + ? `${r.trend_slope > 0 ? '+' : ''}${r.trend_slope}/场` : ''; + return `${esc(r.trend)}${slope}`; +}; + +/* ---------------- 全局缓存 ---------------- */ +const Cache = { options: null }; + +async function loadOptions(force = false) { + if (Cache.options && !force) return Cache.options; + Cache.options = await API.get('/students/meta/options'); + return Cache.options; +} + +const classOptionsHtml = (list, selected, placeholder = '— 不选 —') => + `` + list.map((c) => + ``).join(''); + +/* ============================================================ + 页面:概览 + ============================================================ */ +async function pageOverview(view) { + const d = await API.get('/statistics/overview'); + const funnel = await API.get('/statistics/employment/funnel'); + const vol = d.volatility_top || []; + + const statusItems = Object.entries(d.student_by_status || {}).map(([k, v]) => ({ label: k, value: v, text: v + ' 人' })); + const funnelItems = (funnel || []).slice(0, 8).map((f) => ({ + label: f.class_name, value: f.employment_rate, + text: f.employment_rate + '%(' + f.employed + '/' + f.total + ')', + color: f.employment_rate >= 80 ? 'gr' : f.employment_rate >= 60 ? 'g' : 'o', + })); + + view.innerHTML = ` +
+
+
在册学生
+
${d.student_total}
+
${Object.entries(d.student_by_status || {}).map(([k, v]) => k + ' ' + v).join(' · ') || '暂无'}
+
+
+
整体就业率
+
${d.employment_rate}%
+
已就业 ${d.employment_total} 人 · 高薪(≥1万) ${d.high_salary_count} 人
+
+
+
平均就业薪资
+
${money(d.avg_salary)}
+
按已登记薪资的学生统计
+
+
+
成绩预警
+
${d.warning_student_count}
+
存在低于红线成绩的学生 · 均分 ${nz(d.score_avg)}
+
+
+ +
+
+

班级就业率

按 offer 下发人数 / 班级总人数
+
${barChart(funnelItems, { color: 'gr' })}
+
+
+

学生状态分布

在读 / 进入就业 / 已就业
+
${barChart(statusItems, { color: 'g' })}
+
+
+ +
+
+

成绩波动最大的 5 名学生

+ 标准差排序,警惕"高分掉下来"
+
+ ${renderTable([ + { title: '#', cls: 'center', render: (r) => `${r.rank}` }, + { title: '姓名', render: (r) => `${esc(r.name)}
${esc(r.stu_no)}
` }, + { title: '班级', render: (r) => esc(nz(r.class_name)) }, + { title: '成绩走势', render: (r) => (r.scores || []).map((s) => `${s}`).join('') }, + { title: '标准差', cls: 'num', render: (r) => `${num(r.stddev, 1)}` }, + { title: '趋势', cls: 'center', render: (r) => trendTag(r) }, + ], vol, { emptyText: '暂未生成波动数据' })} +
+
+
+

上课与带班

基础数据总览
+
+
+
班级数
${d.class_total}
+
老师数
${d.teacher_total}
+
顾问数
${d.advisor_total}
+
成绩记录
${d.score_record_total}
+
成绩均分
${nz(d.score_avg)}
+
+
+
就业率前 5 的班级
+ ${barChart((d.top_classes || []).map((c) => ({ + label: c.class_name, value: c.employment_rate, + text: c.employment_rate + '%(' + c.employed + '/' + c.total + ')', + })), { color: 'gr' })} +
+
+
+
`; +} + +/* ============================================================ + 页面:学生管理 + ============================================================ */ +// 列表筛选状态。**故意**做成模块级:同一会话里在几个页面之间来回切, +// 不该把刚筛好的条件弄丢。但同一份"故意"也意味着登出时必须手动清, +// 否则上一个账号筛剩下的条件会被下一个人继承 —— 见 resetFilters()。 +const STU_DEFAULTS = { page: 1, page_size: 10, keyword: '', class_id: '', status: '', gender: '', age_min: '', age_max: '' }; +const stuState = { ...STU_DEFAULTS }; + +async function pageStudents(view) { + const opts = await loadOptions(); + const q = Object.fromEntries(Object.entries(stuState).filter(([, v]) => v !== '' && v !== null)); + + view.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+
+
+ + - + +
+ + + + + +
+
+
+
`; + + const render = async (page = stuState.page) => { + stuState.page = page; + const query = Object.fromEntries(Object.entries(stuState).filter(([, v]) => v !== '' && v !== null)); + const d = await API.get('/students', query); + $('#stu-table').innerHTML = renderTable([ + { title: '学号', render: (r) => `${esc(r.stu_no)}` }, + { title: '姓名', render: (r) => `${esc(r.name)}` }, + { title: '性别', cls: 'center', render: (r) => esc(nz(r.gender_text)) }, + { title: '年龄', cls: 'center', render: (r) => (r.age === null ? '—' : r.age + (r.birth_date_estimated ? '*' : '')) }, + { title: '班级', render: (r) => r.class_name ? `${esc(r.class_name)}
${esc(nz(r.class_no))}
` : '未分班' }, + { title: '学历/专业', render: (r) => `${esc(nz(r.education))}
${esc(nz(r.major))}
` }, + { title: '顾问', render: (r) => esc(nz(r.advisor_name)) }, + { title: '成绩', cls: 'center', render: (r) => r.score_count + ? `${r.score_count} 次 均分 ${nz(r.avg_score)}${r.fail_count ? ` 红线 ${r.fail_count}` : ''}` + : '未录入' }, + { title: '状态', cls: 'center', render: (r) => stuTag(r.status, r.status_text) }, + // 注意:这一列不能整列隐藏 —— 「详情」是只读账号也要用的功能。 + // 所以 w-act 只加在写链接上,靠 CSS 逐链接隐藏;分隔符也一并藏掉, + // 否则只读账号会看到「详情 | | | 」这种光秃秃的竖线。 + { title: '操作', cls: 'nowrap', render: (r) => ` + 详情 + | + 编辑 + | + 录成绩 + | + 删除` }, + ], d.items, { emptyText: '没有符合条件的学生' }); + $('#stu-pager').innerHTML = renderPager(d.page, d.pages, d.total, render); + }; + + const readFilters = () => { + stuState.keyword = $('#f-kw').value.trim(); + stuState.class_id = $('#f-class').value; + stuState.status = $('#f-status').value; + stuState.gender = $('#f-gender').value; + stuState.age_min = $('#f-amin').value; + stuState.age_max = $('#f-amax').value; + }; + + $('#f-search').onclick = () => { readFilters(); render(1); }; + $('#f-reset').onclick = () => { + Object.assign(stuState, { page: 1, keyword: '', class_id: '', status: '', gender: '', age_min: '', age_max: '' }); + pageStudents(view); + }; + $('#f-kw').onkeydown = (e) => { if (e.key === 'Enter') { readFilters(); render(1); } }; + $('#btn-add').onclick = () => studentForm(null, () => render(1)); + $('#btn-import').onclick = () => importDialog(() => render(1)); + + view.onclick = async (e) => { + const t = e.target.closest('[data-detail],[data-edit],[data-del],[data-score]'); + if (!t) return; + const id = t.dataset.detail || t.dataset.edit || t.dataset.del || t.dataset.score; + const stu = await API.get('/students/' + id); + if (t.dataset.detail) return studentDetail(stu); + if (t.dataset.edit) return studentForm(stu, () => render()); + if (t.dataset.score) return scoreForm(null, stu, () => render()); + if (t.dataset.del) { + confirmBox('删除学生', `确定删除 ${esc(stu.name)}(${esc(stu.stu_no)})吗?
+ 逻辑删除:他的成绩与就业记录会一并标记为删除,可在数据库层面恢复。`, async () => { + await API.del('/students/' + id); + toast('已删除 ' + stu.name); + render(); + }); + } + }; + await render(1); +} + +function studentForm(stu, done) { + const edit = !!stu; + loadOptions().then((opts) => { + openModal({ + title: edit ? `编辑学生 · ${stu.name}` : '新增学生', + width: 'wide', + bodyHtml: `
+
+
+
+
+ 年龄由生日实时算出,不用单独维护
+
+ 只知道年龄、拿不到生日时用,系统按当年推算(列表里带 * 号)
+
+
+
+
+
+
+
+
+
+
+
+
+ ${edit ? '' : `
+ 例:WL202601007
`} +
`, + onOk: async () => { + const body = { + name: $('#s-name').value.trim(), + gender: Number($('#s-gender').value), + birth_date: $('#s-birth').value || null, + age: $('#s-age').value ? Number($('#s-age').value) : null, + class_id: $('#s-class').value ? Number($('#s-class').value) : null, + advisor_id: $('#s-advisor').value ? Number($('#s-advisor').value) : null, + native_place: $('#s-native').value.trim() || null, + graduate_school: $('#s-school').value.trim() || null, + major: $('#s-major').value.trim() || null, + education: $('#s-edu').value || null, + enroll_date: $('#s-enroll').value || null, + graduate_date: $('#s-graduate').value || null, + phone: $('#s-phone').value.trim() || null, + id_card: $('#s-idcard').value.trim() || null, + remark: $('#s-remark').value.trim() || null, + }; + if (!body.name) { toast('请填姓名', 'warn'); return false; } + if (edit) { + const r = await API.put('/students/' + stu.id, body); + toast('已更新 ' + r.name); + } else { + body.stu_no = $('#s-stuno').value.trim() || null; + const r = await API.post('/students', body); + toast(`创建成功,学号 ${r.stu_no}`); + } + done && done(); + }, + }); + }); +} + +async function studentDetail(stu) { + const summary = await API.get(`/scores/student/${stu.id}`); + let emp = null; + try { emp = await API.get('/employments/student/' + stu.id); } catch (e) { emp = null; } + + openModal({ + title: `学生详情 · ${stu.name}`, + width: 'wide', + footerHtml: ``, + bodyHtml: ` +
+
学号
${esc(stu.stu_no)}
+
姓名 / 性别
${esc(stu.name)} · ${esc(nz(stu.gender_text))}
+
年龄
${stu.age === null ? '—' : stu.age + ' 岁'} ${stu.birth_date_estimated ? '生日为按年龄推算' : ''} + (出生日期 ${esc(nz(stu.birth_date))})
+
班级
${esc(nz(stu.class_name))} ${esc(nz(stu.class_no))}
+
顾问
${esc(nz(stu.advisor_name))}
+
籍贯 / 院校
${esc(nz(stu.native_place))} · ${esc(nz(stu.graduate_school))}
+
专业 / 学历
${esc(nz(stu.major))} · ${esc(nz(stu.education))}
+
入学 / 毕业
${esc(nz(stu.enroll_date))} → ${esc(nz(stu.graduate_date))}
+
联系电话
${esc(nz(stu.phone))}
+
状态
${stuTag(stu.status, stu.status_text)}
+
备注
${esc(nz(stu.remark))}
+
+ +
+

考核成绩

+ ${summary.count} 次 · 均分 ${nz(summary.avg)} · 最高 ${nz(summary.max)} · 最低 ${nz(summary.min)} + ${summary.fail ? `· 红线 ${summary.fail} 次` : ''}
+
+ ${renderTable([ + { title: '序次', cls: 'center', render: (r) => '第 ' + r.exam_seq + ' 次' }, + { title: '考核名称', render: (r) => esc(nz(r.exam_name)) }, + { title: '日期', render: (r) => esc(nz(r.exam_date)) }, + { title: '成绩', cls: 'num', render: (r) => `${r.score}` }, + { title: '标记', cls: 'center', render: (r) => r.flag ? '需重点关注' : '正常' }, + ], summary.scores, { emptyText: '还没有成绩记录' })} +
+
+ +
+

就业信息

+
+ ${emp ? `
+
就业开放
${esc(nz(emp.open_date))}
+
offer 下发
${esc(nz(emp.offer_date))}
+
就业时长
${emp.duration_days === null ? '—' : emp.duration_days + ' 天'}
+
公司 / 岗位
${esc(nz(emp.company))} · ${esc(nz(emp.position))}
+
薪资
${money(emp.salary)} ${emp.salary_wan ? emp.salary_wan + ' 万/月' : ''}
+
城市
${esc(nz(emp.city))}
+
` : '
该学生还没有登记就业信息。
'} +
+
`, + }); +} + +function importDialog(done) { + openModal({ + title: 'Excel 批量导入学生', + bodyHtml: ` +
表头用中文列名,顺序随意。必备列:姓名; + 班级可填「班级编号」或「班级名称」,顾问填姓名。逐行校验,出错的行会告诉你第几行、为什么。
+
+
+
`, + okText: '开始导入', + onOk: async (mask) => { + const f = $('#imp-file').files[0]; + if (!f) { toast('请选择文件', 'warn'); return false; } + const form = new FormData(); + form.append('file', f); + const dry = $('#imp-dry').checked; + const r = await API.req('POST', '/students/import' + (dry ? '?dry_run=true' : ''), { form }); + $('#imp-result').innerHTML = ` +
+ 共 ${r.total} 行:成功 ${r.success},失败 ${r.failed}${dry ? '(仅校验,未写库)' : ''} +
+ ${r.errors.length ? `
${renderTable([ + { title: '行号', cls: 'center', render: (x) => x.row }, + { title: '姓名', render: (x) => esc(x.name) }, + { title: '原因', render: (x) => `${esc(x.reason)}` }, + ], r.errors)}
` : ''}`; + if (!r.failed) { toast(`导入成功 ${r.success} 行`); done && done(); return true; } + return false; + }, + onMount: (mask) => { + mask.querySelector('[data-foot-extra]'); + }, + }); + // 模板下载按钮插到弹窗脚部 + setTimeout(() => { + const foot = $('#modal-root .modal-foot'); + if (foot && !foot.querySelector('#btn-tpl')) { + const b = document.createElement('button'); + b.className = 'btn'; b.id = 'btn-tpl'; b.textContent = '下载模板'; + b.onclick = () => { + fetch('/students/import/template', { headers: { Authorization: 'Bearer ' + API.token } }) + .then((r) => r.blob()).then((blob) => { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'student_import_template.xlsx'; + a.click(); + }); + }; + foot.insertBefore(b, foot.firstChild); + } + }, 30); +} + +/* ============================================================ + 页面:成绩管理 + ============================================================ */ +const SCORE_DEFAULTS = { page: 1, page_size: 10, keyword: '', exam_seq: '', flag: '' }; +const scoreState = { ...SCORE_DEFAULTS }; + +async function pageScores(view) { + const meta = await API.get('/scores/meta'); + view.innerHTML = ` +
+
+
+
+
+ + + + 及格线 ${meta.warn_line} 分,低于自动触发预警 + +
+
+
+
`; + + const render = async (page = scoreState.page) => { + scoreState.page = page; + const query = Object.fromEntries(Object.entries(scoreState).filter(([, v]) => v !== '' && v !== null)); + const d = await API.get('/scores', query); + $('#c-table').innerHTML = renderTable([ + { title: '学生', render: (r) => `${esc(nz(r.student_name))}
${esc(nz(r.class_name))}
` }, + { title: '序次', cls: 'center', render: (r) => '第 ' + r.exam_seq + ' 次' }, + { title: '考核名称', render: (r) => esc(nz(r.exam_name)) }, + { title: '日期', render: (r) => esc(nz(r.exam_date)) }, + { title: '成绩', cls: 'num', render: (r) => `${r.score}` }, + { title: '标记', cls: 'center', render: (r) => (r.flag ? '需重点关注' : '正常') }, + { title: '操作', cls: 'nowrap w-act', render: (r) => ` + 修改 + | 删除` }, + ], d.items, { rowClass: (r) => (r.flag ? 'row-danger' : ''), emptyText: '暂无成绩记录' }); + $('#c-pager').innerHTML = renderPager(d.page, d.pages, d.total, render); + }; + + const read = () => { + scoreState.keyword = $('#c-kw').value.trim(); + scoreState.exam_seq = $('#c-seq').value; + scoreState.flag = $('#c-flag').value; + }; + $('#c-search').onclick = () => { read(); render(1); }; + $('#c-reset').onclick = () => { Object.assign(scoreState, { page: 1, keyword: '', exam_seq: '', flag: '' }); pageScores(view); }; + $('#c-kw').onkeydown = (e) => { if (e.key === 'Enter') { read(); render(1); } }; + $('#c-add').onclick = () => scoreForm(null, null, () => { loadOptions(true); pageScores(view); }); + + view.onclick = (e) => { + const ed = e.target.closest('[data-sedit]'); + const dl = e.target.closest('[data-sdel]'); + if (ed) { + openModal({ + title: '修改成绩', + width: 'narrow', + bodyHtml: `
+
+
+
+
分数改到红线以上后,预警标记会自动撤销。
`, + onOk: async () => { + const r = await API.put('/scores/' + ed.dataset.sedit, { + score: Number($('#e-score').value), + exam_name: $('#e-name').value.trim() || null, + exam_date: $('#e-date').value || null, + }); + toast(r.warning_msg || '修改成功', r.warning ? 'warn' : 'ok'); + render(); + }, + }); + } + if (dl) { + confirmBox('删除成绩', `确定删除 ${dl.dataset.sname} 第 ${dl.dataset.seq} 次考核成绩吗?`, async () => { + await API.del('/scores/' + dl.dataset.sdel); + toast('已删除'); + render(); + }); + } + }; + await render(1); +} + +async function scoreForm(score, presetStudent, done) { + const meta = await API.get('/scores/meta'); + if (!presetStudent) { + // 先选学生 + openModal({ + title: '录入成绩 · 选择学生', + bodyHtml: `
先按姓名/学号找到学生,再录成绩。一次可以连续录入多次考核。
+
+
输入关键词开始搜索…
`, + footerHtml: ``, + onMount: (mask, close) => { + const search = async () => { + const kw = $('#pick-kw').value.trim(); + if (!kw) return; + const d = await API.get('/students', { keyword: kw, page_size: 20 }); + $('#pick-list').innerHTML = d.items.length ? d.items.map((s) => ` +
+ ${esc(s.name)} + ${esc(s.stu_no)} + ${esc(nz(s.class_name))} + ${s.score_count} 次成绩 + 选他 +
`).join('') : '
没找到学生
'; + }; + $('#pick-kw').onkeydown = (e) => { if (e.key === 'Enter') search(); }; + mask.addEventListener('click', async (e) => { + const p = e.target.closest('[data-pick]'); + if (!p) return; + const stu = await API.get('/students/' + p.dataset.pick); + close(); + scoreForm(null, stu, done); + }); + }, + }); + return; + } + + const stu = presetStudent; + const used = (await API.get('/scores', { stu_id: stu.id, page_size: 100 })).items.map((s) => s.exam_seq); + const next = used.length ? Math.max(...used) + 1 : 1; + + const rowHtml = (seq, val, name) => `
+ + + + × +
`; + + openModal({ + title: `录入成绩 · ${stu.name}(${stu.stu_no})`, + bodyHtml: ` +
当前已录入 ${used.length} 次(序次:${used.length ? used.join('、') : '无'})。 + 同一序次重复提交会提示改用「修改」。低于 ${meta.warn_line} 分自动触发红线预警。
+
${rowHtml(next, '', meta.exam_names[next - 1] || '')}
+ `, + okText: '提交', + onOk: async () => { + const rows = $$('#rows [data-row]').map((r) => ({ + stu_id: stu.id, + exam_seq: Number($('[data-seq]', r).value), + score: Number($('[data-val]', r).value), + exam_name: $('[data-cname]', r).value.trim() || null, + })).filter((r) => r.exam_seq && !Number.isNaN(r.score) && $('[data-val]', $$('#rows [data-row]')[0])); + if (!rows.length) { toast('至少填一条成绩', 'warn'); return false; } + let okCount = 0, warns = []; + for (const r of rows) { + try { + const res = await API.post('/scores', r); + okCount++; + if (res.warning) warns.push(`第${r.exam_seq}次 ${r.score} 分`); + } catch (err) { toast(err.message, 'err', 5000); } + } + if (okCount) { + toast(`成功录入 ${okCount} 条` + (warns.length ? `,其中 ${warns.length} 条触发红线:${warns.join('、')}` : ''), + warns.length ? 'warn' : 'ok', 5000); + done && done(); + } + return true; + }, + onMount: (mask, close) => { + $('#add-row').onclick = () => { + const cur = $$('#rows [data-row]').length + 1; + $('#rows').insertAdjacentHTML('beforeend', rowHtml(cur, '', '')); + }; + mask.addEventListener('click', (e) => { + if (e.target.closest('[data-delrow]')) e.target.closest('[data-row]').remove(); + }); + }, + }); +} + +/* ============================================================ + 页面:就业管理 + ============================================================ */ +const EMP_DEFAULTS = { page: 1, page_size: 10, keyword: '', salary_min: '', salary_max: '', has_offer: '' }; +const empState = { ...EMP_DEFAULTS }; + +/* 把「会话级 UI 状态」复位。每次进入应用(登录、或带 token 重开页面)时调用。 + 这些全是模块级变量,目的是让同一会话里来回切页不丢状态;代价就是登出时 + 必须手动清,否则会跨账号活下来。走查时实际撞见了两处: + ① 管理员筛完学生、退出;只读账号登进来看到空列表 + 一个自己没输过的关键词。 + ② 管理员切到「分组聚合」标签、退出;下一个登录的人一进高级筛选就停在聚合页, + 而他的意图是来写筛选规则的。 + 注意 advMeta 不在这里清 —— 它是字段白名单的服务端缓存,不是用户状态。 */ +function resetSessionState() { + Object.assign(stuState, STU_DEFAULTS); + Object.assign(scoreState, SCORE_DEFAULTS); + Object.assign(empState, EMP_DEFAULTS); + advTab = 'query'; +} + +async function pageEmployments(view) { + view.innerHTML = ` +
+
+
+
+
+ + - +
+
+ + + + +
+
+
+
`; + + const render = async (page = empState.page) => { + empState.page = page; + const query = Object.fromEntries(Object.entries(empState).filter(([, v]) => v !== '' && v !== null)); + const d = await API.get('/employments', query); + $('#e-table').innerHTML = renderTable([ + { title: '学生', render: (r) => `${esc(nz(r.student_name))}
${esc(nz(r.stu_no))}
` }, + { title: '班级', render: (r) => esc(nz(r.class_name)) }, + { title: '公司 / 岗位', render: (r) => `${esc(nz(r.company))}
${esc(nz(r.position))}${r.city ? ' · ' + esc(r.city) : ''}
` }, + { title: '薪资', cls: 'num', render: (r) => `${money(r.salary)}
${r.salary_wan ? r.salary_wan + ' 万/月' : ''}
` }, + { title: '开放 / offer', render: (r) => `${esc(nz(r.open_date))}
${esc(nz(r.offer_date))}
` }, + { title: '就业时长', cls: 'center', render: (r) => (r.duration_days === null ? '—' : `${r.duration_days} 天`) }, + { title: '学生状态', cls: 'center', render: (r) => stuTag(r.student_status, r.student_status_text) }, + { title: '操作', cls: 'nowrap w-act', render: (r) => ` + 编辑 + | + 删除` }, + ], d.items, { emptyText: '暂无就业记录' }); + $('#e-pager').innerHTML = renderPager(d.page, d.pages, d.total, render); + }; + + const read = () => { + empState.keyword = $('#e-kw').value.trim(); + empState.salary_min = $('#e-smin').value; + empState.salary_max = $('#e-smax').value; + empState.has_offer = $('#e-offer').value; + }; + $('#e-search').onclick = () => { read(); render(1); }; + $('#e-reset').onclick = () => { Object.assign(empState, { page: 1, keyword: '', salary_min: '', salary_max: '', has_offer: '' }); pageEmployments(view); }; + $('#e-kw').onkeydown = (e) => { if (e.key === 'Enter') { read(); render(1); } }; + $('#e-add').onclick = () => pickStudent('登记就业', (stu) => employmentForm(null, stu, () => render(1))); + + view.onclick = async (e) => { + const ed = e.target.closest('[data-eedit]'); + const dl = e.target.closest('[data-edel]'); + if (ed) { + const list = await API.get('/employments', { page_size: 200 }); + const emp = list.items.find((x) => String(x.id) === ed.dataset.eedit); + if (emp) employmentForm(emp, null, () => render()); + } + if (dl) { + confirmBox('删除就业记录', `确定删除 ${dl.dataset.ename} 的就业信息吗?
+ 删除后该学生状态会回退到「在读」,班级就业率统计也会同步变化。`, async () => { + const r = await API.del('/employments/' + dl.dataset.edel); + toast('已删除' + (r.msg ? ':' + r.msg : '')); + render(); + }); + } + }; + await render(1); +} + +function pickStudent(title, onPick) { + openModal({ + title, + bodyHtml: `
按姓名或学号搜索学生。
+
+
输入关键词开始搜索…
`, + footerHtml: ``, + onMount: (mask, close) => { + const search = async () => { + const kw = $('#pk-kw').value.trim(); + if (!kw) return; + const d = await API.get('/students', { keyword: kw, page_size: 20 }); + $('#pk-list').innerHTML = d.items.length ? d.items.map((s) => ` +
+ ${esc(s.name)} + ${esc(s.stu_no)} + ${esc(nz(s.class_name))} + ${stuTag(s.status, s.status_text)} + 选他 +
`).join('') : '
没找到学生
'; + }; + $('#pk-kw').onkeydown = (e) => { if (e.key === 'Enter') search(); }; + mask.addEventListener('click', async (e) => { + const p = e.target.closest('[data-pick]'); + if (!p) return; + const stu = await API.get('/students/' + p.dataset.pick); + close(); + onPick(stu); + }); + }, + }); +} + +function employmentForm(emp, stu, done) { + const edit = !!emp; + const name = edit ? emp.student_name : stu.name; + openModal({ + title: edit ? `编辑就业信息 · ${name}` : `登记就业 · ${name}`, + bodyHtml: ` +
登记/修改就业信息时,系统会在同一个事务里同步学生状态: + 填了就业开放时间 → 进入就业;填了 offer 下发时间 → 已就业。
+ offer 时间不能早于开放时间,且两者必须成对填写(否则算不出就业时长)。
+
+
+
+
+
+
+
+
+
`, + onOk: async () => { + const body = { + open_date: $('#m-open').value || null, + offer_date: $('#m-offer').value || null, + company: $('#m-company').value.trim() || null, + salary: $('#m-salary').value ? Number($('#m-salary').value) : null, + position: $('#m-position').value.trim() || null, + city: $('#m-city').value.trim() || null, + remark: $('#m-remark').value.trim() || null, + }; + const r = edit + ? await API.put('/employments/' + emp.id, body) + : await API.post('/employments', Object.assign({ stu_id: stu.id }, body)); + toast(r.msg || '操作成功', r.status_changed ? 'ok' : 'ok', 4200); + done && done(); + }, + }); +} + +/* ============================================================ + 页面:班级 / 老师 / 顾问(结构相近) + ============================================================ */ +async function pageClasses(view) { + const opts = await loadOptions(); + view.innerHTML = ` +
+
+
+
+ + + +
+
+
`; + let page = 1; + const st = { keyword: '', status: '' }; + const render = async (p = 1) => { + page = p; + const d = await API.get('/classes', Object.assign({ page, page_size: 10 }, st)); + $('#k-table').innerHTML = renderTable([ + { title: '班级编号', render: (r) => `${esc(r.class_no)}` }, + { title: '班级名称', render: (r) => `${esc(r.name)}
${esc(nz(r.direction))}
` }, + { title: '开课 / 结课', render: (r) => `${esc(nz(r.open_date))}
${esc(nz(r.close_date))}
` }, + { title: '班主任', render: (r) => esc(nz(r.head_teacher_name)) }, + { title: '授课老师', render: (r) => (r.teacher_names || []).length ? r.teacher_names.map((t) => `${esc(t)}`).join('') : '未安排' }, + { title: '顾问', render: (r) => esc(nz(r.advisor_name)) }, + { title: '人数', cls: 'center', render: (r) => `${r.student_count || 0} / ${nz(r.capacity)}` }, + { title: '状态', cls: 'center', render: (r) => clsTag(r.status, r.status_text) }, + { title: '操作', cls: 'nowrap w-act', render: (r) => ` + 编辑 | + 删除` }, + ], d.items); + $('#k-pager').innerHTML = renderPager(d.page, d.pages, d.total, render); + view.__rows = d.items; + }; + $('#k-search').onclick = () => { st.keyword = $('#k-kw').value.trim(); st.status = $('#k-status').value; render(1); }; + $('#k-add').onclick = () => classForm(null, opts, () => { loadOptions(true).then(() => pageClasses(view)); }); + view.onclick = (e) => { + const ed = e.target.closest('[data-cedit]'); + const dl = e.target.closest('[data-cdel]'); + if (ed) { + const row = (view.__rows || []).find((x) => String(x.id) === ed.dataset.cedit); + if (row) classForm(row, opts, () => pageClasses(view)); + } + if (dl) confirmBox('删除班级', `确定删除班级 ${dl.dataset.cname} 吗?
班内还有学生时会被拒绝,避免学生"无班可归"。`, async () => { + await API.del('/classes/' + dl.dataset.cdel); + toast('已删除'); render(page); + }); + }; + await render(1); +} + +function classForm(c, opts, done) { + const edit = !!c; + openModal({ + title: edit ? `编辑班级 · ${c.name}` : '新增班级', + width: 'wide', + bodyHtml: `
+
+
+
+
+
+
+
+
+
+
+
+
+ 一个班可以有多个授课老师,一个老师也能带多个班
+
+
`, + onOk: async () => { + const body = { + name: $('#z-name').value.trim(), + direction: $('#z-dir').value.trim() || null, + classroom: $('#z-room').value.trim() || null, + open_date: $('#z-open').value || null, + close_date: $('#z-close').value || null, + capacity: $('#z-cap').value ? Number($('#z-cap').value) : 40, + status: Number($('#z-status').value), + head_teacher_id: $('#z-head').value ? Number($('#z-head').value) : null, + advisor_id: $('#z-adv').value ? Number($('#z-adv').value) : null, + teacher_ids: $$('#z-teachers option:checked').map((o) => Number(o.value)), + description: $('#z-desc').value.trim() || null, + }; + if (!body.name) { toast('请填班级名称', 'warn'); return false; } + if (edit) { await API.put('/classes/' + c.id, body); toast('已更新'); } + else { const r = await API.post('/classes', body); toast('创建成功,编号 ' + r.class_no); } + done && done(); + }, + }); +} + +async function pageTeachers(view) { + view.innerHTML = ` +
+
+
+
+ + + +
+
+
`; + const st = { keyword: '', subject: '' }; + const render = async (p = 1) => { + const d = await API.get('/teachers', Object.assign({ page: p, page_size: 10 }, st)); + $('#t-table').innerHTML = renderTable([ + { title: '工号', render: (r) => `${esc(r.teacher_no)}` }, + { title: '姓名', render: (r) => `${esc(r.name)}` }, + { title: '性别', cls: 'center', render: (r) => esc(nz(r.gender_text)) }, + { title: '职称 / 方向', render: (r) => `${esc(nz(r.title))}
${esc(nz(r.subject))}
` }, + { title: '带班', render: (r) => (r.class_names || []).length ? r.class_names.map((n) => `${esc(n)}`).join('') : '暂无' }, + { title: '入职时间', render: (r) => esc(nz(r.hire_date)) }, + { title: '联系电话', render: (r) => esc(nz(r.phone)) }, + { title: '操作', cls: 'nowrap w-act', render: (r) => ` + 编辑 | + 删除` }, + ], d.items); + $('#t-pager').innerHTML = renderPager(d.page, d.pages, d.total, render); + view.__rows = d.items; + }; + $('#t-search').onclick = () => { st.keyword = $('#t-kw').value.trim(); st.subject = $('#t-sub').value.trim(); render(1); }; + const refresh = async () => { await loadOptions(true); pageTeachers(view); }; + $('#t-add').onclick = () => teacherForm(null, () => refresh()); + view.onclick = (e) => { + const ed = e.target.closest('[data-tedit]'); + const dl = e.target.closest('[data-tdel]'); + if (ed) { const row = (view.__rows || []).find((x) => String(x.id) === ed.dataset.tedit); if (row) teacherForm(row, () => refresh()); } + if (dl) confirmBox('删除老师', `确定删除老师 ${dl.dataset.tname} 吗?
还在带班的老师会被拒绝删除。`, async () => { + await API.del('/teachers/' + dl.dataset.tdel); toast('已删除'); refresh(); + }); + }; + await render(1); +} + +async function teacherForm(t, done) { + const edit = !!t; + const classes = (await API.get('/classes', { page_size: 200 })).items; + openModal({ + title: edit ? `编辑老师 · ${t.name}` : '新增老师', + bodyHtml: `
+
+
+
+
+
+
+
+
+
+
+
+
`, + onOk: async () => { + const body = { + name: $('#w-name').value.trim(), + gender: Number($('#w-gender').value), + title: $('#w-title').value.trim() || null, + subject: $('#w-sub').value.trim() || null, + hire_date: $('#w-hire').value || null, + phone: $('#w-phone').value.trim() || null, + email: $('#w-email').value.trim() || null, + remark: $('#w-remark').value.trim() || null, + class_ids: $$('#w-classes option:checked').map((o) => Number(o.value)), + }; + if (!body.name) { toast('请填姓名', 'warn'); return false; } + if (edit) { await API.put('/teachers/' + t.id, body); toast('已更新'); } + else { body.teacher_no = $('#w-no').value.trim() || null; const r = await API.post('/teachers', body); toast('已新增,工号 ' + r.teacher_no); } + done && done(); + }, + }); +} + +async function pageAdvisors(view) { + view.innerHTML = ` +
+
+
+
+ + + +
+
+
`; + const st = { keyword: '', dept: '' }; + const render = async (p = 1) => { + const d = await API.get('/advisors', Object.assign({ page: p, page_size: 10 }, st)); + $('#a-table').innerHTML = renderTable([ + { title: '顾问编号', render: (r) => `${esc(r.advisor_no)}` }, + { title: '姓名', render: (r) => `${esc(r.name)}` }, + { title: '性别', cls: 'center', render: (r) => esc(nz(r.gender_text)) }, + { title: '部门', render: (r) => esc(nz(r.dept)) }, + { title: '电话', render: (r) => esc(nz(r.phone)) }, + { title: '名下学生', cls: 'center', render: (r) => `${r.student_count || 0} 人` }, + { title: '操作', cls: 'nowrap w-act', render: (r) => ` + 编辑 | + 删除` }, + ], d.items); + $('#a-pager').innerHTML = renderPager(d.page, d.pages, d.total, render); + view.__rows = d.items; + }; + $('#a-search').onclick = () => { st.keyword = $('#a-kw').value.trim(); st.dept = $('#a-dept').value.trim(); render(1); }; + const refresh = async () => { await loadOptions(true); pageAdvisors(view); }; + $('#a-add').onclick = () => advisorForm(null, () => refresh()); + view.onclick = (e) => { + const ed = e.target.closest('[data-aedit]'); + const dl = e.target.closest('[data-adel]'); + if (ed) { const row = (view.__rows || []).find((x) => String(x.id) === ed.dataset.aedit); if (row) advisorForm(row, () => refresh()); } + if (dl) confirmBox('删除顾问', `确定删除顾问 ${dl.dataset.aname} 吗?
名下还有学生时会被拒绝。`, async () => { + await API.del('/advisors/' + dl.dataset.adel); toast('已删除'); refresh(); + }); + }; + await render(1); +} + +function advisorForm(a, done) { + const edit = !!a; + openModal({ + title: edit ? `编辑顾问 · ${a.name}` : '新增顾问', + bodyHtml: `
+
+
+
+
+
+
+
`, + onOk: async () => { + const body = { + name: $('#v-name').value.trim(), + gender: Number($('#v-gender').value), + dept: $('#v-dept').value.trim() || null, + phone: $('#v-phone').value.trim() || null, + email: $('#v-email').value.trim() || null, + }; + if (!body.name) { toast('请填姓名', 'warn'); return false; } + if (edit) { await API.put('/advisors/' + a.id, body); toast('已更新'); } + else { body.advisor_no = $('#v-no').value.trim() || null; const r = await API.post('/advisors', body); toast('已新增,编号 ' + r.advisor_no); } + done && done(); + }, + }); +} + +/* ============================================================ + 页面:统计分析 + ============================================================ */ +const STAT_TABS = [ + { key: 'class', label: '班级人数与性别分布' }, + { key: 'age', label: '动态年龄查询' }, + { key: 'allabove', label: '每次考核都达标' }, + { key: 'fail', label: '多次不及格' }, + { key: 'classavg', label: '班级平均分' }, + { key: 'topsalary', label: '薪资 Top N' }, + { key: 'duration', label: '就业时长' }, + { key: 'avgduration', label: '班级平均就业时长' }, + { key: 'volatility', label: '成绩波动' }, + { key: 'funnel', label: '就业漏斗' }, +]; +let statTab = 'class'; + +async function pageStatistics(view) { + const opts = await loadOptions(); + view.innerHTML = ` +
+
+ ${STAT_TABS.map((t) => `
${t.label}
`).join('')} +
+
+
`; + $('#st-tabs').onclick = (e) => { + const t = e.target.closest('[data-tab]'); + if (!t) return; + statTab = t.dataset.tab; + $$('#st-tabs .tab').forEach((x) => x.classList.toggle('active', x.dataset.tab === statTab)); + renderStat(); + }; + + const renderStat = async () => { + const body = $('#st-body'); + body.innerHTML = '
加载中…
'; + try { + body.innerHTML = await STAT_RENDER[statTab](opts); + if (STAT_MOUNT[statTab]) STAT_MOUNT[statTab](); + } catch (err) { body.innerHTML = `
${esc(err.message)}
`; } + }; + await renderStat(); +} + +const STAT_MOUNT = {}; +const STAT_RENDER = { + async class() { + const d = await API.get('/statistics/classes/overview'); + const total = d.reduce((s, x) => s + x.total, 0); + return `
+

各班人数与性别分布

需求 2.6.1
+
${renderTable([ + { title: '班级', render: (r) => `${esc(r.class_name)}
${esc(r.class_no)}
` }, + { title: '班主任', render: (r) => esc(nz(r.head_teacher_name)) }, + { title: '总人数', cls: 'num', render: (r) => `${r.total}` }, + { title: '男', cls: 'num', render: (r) => `${r.male}` }, + { title: '女', cls: 'num', render: (r) => `${r.female}` }, + { title: '未标注', cls: 'num', render: (r) => r.other }, + { title: '男女比', cls: 'center', render: (r) => r.total ? `${(r.male / r.total * 100).toFixed(0)}% : ${(r.female / r.total * 100).toFixed(0)}%` : '—' }, + ], d)}
+

人数对比

合计 ${total} 人
+
${barChart(d.map((x) => ({ label: x.class_name, value: x.total, text: `${x.total} 人(男${x.male}/女${x.female})` })))}
+
`; + }, + + async age() { + const opts = await loadOptions(); + return `
+
+
+
+
+
+
+ +
+
`; + }, + + async allabove() { + return `
+
+
+
查询"每一场考核都在分数线以上"的学生
+ +
`; + }, + + async fail() { + return `
+
+
+
+ +
`; + }, + + async classavg() { + const meta = await API.get('/scores/meta'); + return `
+
+
+
+ +
`; + }, + + async topsalary() { + return `
+
+
+ +
`; + }, + + async duration() { + const opts = await loadOptions(); + return `
+
+
+ + 就业时长 = offer 下发时间 − 就业开放时间 +
`; + }, + + async avgduration() { + return `

各班平均就业时长

+ 只统计已开放就业的学生 · 需求 2.6.3
+
`; + }, + + async volatility() { + return `
+
+
+
+ +
`; + }, + + async funnel() { + return `
+
+
+ + 总人数 → 已就业 → 高薪,按就业率降序 +
`; + }, +}; + +/* --- 各统计项的挂载(绑定按钮 + 首次拉数据) --- */ +STAT_MOUNT.class = null; +STAT_MOUNT.age = async () => { + const go = async () => { + const op = $('#g-op').value; + const d = await API.get('/statistics/students/by-age', { + operator: op, value: $('#g-v').value, value2: op === 'between' ? $('#g-v2').value : '', + class_id: $('#g-class').value, gender: $('#g-gender').value, page_size: 100, + }); + $('#g-res').innerHTML = renderTable([ + { title: '学号', render: (r) => `${esc(r.stu_no)}` }, + { title: '姓名', render: (r) => `${esc(r.name)}` }, + { title: '年龄', cls: 'center', render: (r) => `${r.age} 岁` }, + { title: '班级', render: (r) => esc(nz(r.class_name)) }, + { title: '性别', cls: 'center', render: (r) => esc(nz(r.gender_text)) }, + { title: '学历', render: (r) => esc(nz(r.education)) }, + { title: '状态', cls: 'center', render: (r) => stuTag(r.status, r.status_text) }, + ], d.items, { emptyText: '没有符合条件的学生' }) + `
共 ${d.total} 人
`; + }; + $('#g-go').onclick = go; + $('#g-op').onchange = () => { $('#g-v2').disabled = $('#g-op').value !== 'between'; }; + await go(); +}; +STAT_MOUNT.allabove = async () => { + const go = async () => { + const d = await API.get('/statistics/scores/all-above', { threshold: $('#ab-t').value }); + $('#ab-res').innerHTML = renderTable([ + { title: '学号', render: (r) => `${esc(r.stu_no)}` }, + { title: '姓名', render: (r) => `${esc(r.name)}` }, + { title: '班级', render: (r) => esc(nz(r.class_name)) }, + { title: '考核次数', cls: 'center', render: (r) => r.exam_count }, + { title: '最低分', cls: 'num', render: (r) => `${num(r.min_score, 1)}` }, + { title: '平均分', cls: 'num', render: (r) => num(r.avg_score, 2) }, + { title: '各次成绩', render: (r) => (r.scores || []).map((s) => `${s.exam_seq}·${s.score}`).join('') }, + ], d, { emptyText: `没有人在所有考核中都达到 ${$('#ab-t').value} 分` }) + + `
共 ${d.length} 人达标
`; + }; + $('#ab-go').onclick = go; + await go(); +}; +STAT_MOUNT.fail = async () => { + const go = async () => { + const d = await API.get('/statistics/scores/failures', { threshold: $('#fl-t').value, min_times: $('#fl-n').value }); + $('#fl-res').innerHTML = renderTable([ + { title: '学号', render: (r) => `${esc(r.stu_no)}` }, + { title: '姓名', render: (r) => `${esc(r.name)}` }, + { title: '班级', render: (r) => esc(nz(r.class_name)) }, + { title: '不及格次数', cls: 'center', render: (r) => `${r.fail_times} 次` }, + { title: '参考次数', cls: 'center', render: (r) => r.exam_count }, + { title: '均分', cls: 'num', render: (r) => num(r.avg_score, 2) }, + { title: '不及格明细', render: (r) => (r.fail_details || []).map((s) => `第${s.exam_seq}次 ${s.score}`).join('') }, + ], d, { rowClass: () => 'row-danger', emptyText: '没有达到条件的学生' }); + }; + $('#fl-go').onclick = go; + await go(); +}; +STAT_MOUNT.classavg = async () => { + const go = async () => { + const d = await API.get('/statistics/scores/class-average', { exam_seq: $('#ca-seq').value, order: $('#ca-order').value }); + $('#ca-res').innerHTML = renderTable([ + { title: '考核', cls: 'center', render: (r) => `第 ${r.exam_seq} 次` }, + { title: '班级', render: (r) => `${esc(nz(r.class_name))}` }, + { title: '参考人数', cls: 'center', render: (r) => r.student_count }, + { title: '平均分', cls: 'num', render: (r) => `${num(r.avg_score, 2)}` }, + { title: '最高 / 最低', cls: 'num', render: (r) => `${num(r.max_score, 1)} / ${num(r.min_score, 1)}` }, + { title: '及格率', cls: 'center', render: (r) => `${num(r.pass_rate, 1)}%` }, + ], d, { emptyText: '暂无成绩数据' }); + }; + $('#ca-go').onclick = go; + await go(); +}; +STAT_MOUNT.topsalary = async () => { + const go = async () => { + const d = await API.get('/statistics/employment/top-salary', { top_n: $('#ts-n').value }); + $('#ts-res').innerHTML = renderTable([ + { title: '排名', cls: 'center', render: (r) => `${r.rank}` }, + { title: '姓名', render: (r) => `${esc(r.name)}
${esc(r.stu_no)}
` }, + { title: '班级', render: (r) => esc(nz(r.class_name)) }, + { title: '公司', render: (r) => esc(nz(r.company)) }, + { title: '薪资', cls: 'num', render: (r) => `${money(r.salary)}
${r.salary_wan} 万/月
` }, + { title: 'offer 时间', render: (r) => esc(nz(r.offer_date)) }, + { title: '就业时长', cls: 'center', render: (r) => (r.duration_days === null ? '—' : r.duration_days + ' 天') }, + ], d, { emptyText: '暂无就业数据' }); + }; + $('#ts-go').onclick = go; + await go(); +}; +STAT_MOUNT.duration = async () => { + const go = async () => { + const d = await API.get('/statistics/employment/durations', { class_id: $('#du-class').value }); + $('#du-res').innerHTML = renderTable([ + { title: '姓名', render: (r) => `${esc(r.name)}
${esc(r.stu_no)}
` }, + { title: '班级', render: (r) => esc(nz(r.class_name)) }, + { title: '公司', render: (r) => esc(nz(r.company)) }, + { title: '开放 → offer', render: (r) => `${esc(nz(r.open_date))} → ${esc(nz(r.offer_date))}` }, + { title: '就业时长', cls: 'num', render: (r) => (r.duration_days === null ? '—' : `${r.duration_days} 天`) }, + { title: '状态', cls: 'center', render: (r) => `${esc(r.state)}` }, + ], d, { emptyText: '暂无就业数据' }); + }; + $('#du-go').onclick = go; + await go(); +}; +STAT_MOUNT.avgduration = async () => { + const d = await API.get('/statistics/employment/class-avg-duration'); + $('#ad-res').innerHTML = renderTable([ + { title: '班级', render: (r) => `${esc(nz(r.class_name))}` }, + { title: '班级人数', cls: 'center', render: (r) => r.student_count }, + { title: '已开放就业', cls: 'center', render: (r) => r.open_count }, + { title: '已拿 offer', cls: 'center', render: (r) => r.offer_count }, + { title: '平均就业时长', cls: 'num', render: (r) => (r.avg_duration_days === null ? '无数据' : `${r.avg_duration_days} 天`) }, + { title: '最短 / 最长', cls: 'num', render: (r) => `${nz(r.min_duration_days)} / ${nz(r.max_duration_days)}` }, + ], d, { emptyText: '暂无数据' }); +}; +STAT_MOUNT.volatility = async () => { + const go = async () => { + const d = await API.get('/statistics/scores/volatility', { top_n: $('#vo-n').value, metric: $('#vo-m').value }); + $('#vo-res').innerHTML = renderTable([ + { title: '排名', cls: 'center', render: (r) => r.rank }, + { title: '姓名', render: (r) => `${esc(r.name)}
${esc(r.stu_no)}
` }, + { title: '班级', render: (r) => esc(nz(r.class_name)) }, + { title: '成绩走势', render: (r) => (r.scores || []).map((s) => `${s}`).join('') }, + { title: '均分', cls: 'num', render: (r) => num(r.avg_score, 2) }, + { title: '标准差', cls: 'num', render: (r) => `${num(r.stddev, 2)}` }, + { title: '最大分差', cls: 'num', render: (r) => num(r.score_range, 1) }, + { title: '趋势', cls: 'center', render: (r) => trendTag(r) }, + ], d, { emptyText: '需要至少 2 次成绩才能计算波动' }); + }; + $('#vo-go').onclick = go; + await go(); +}; +STAT_MOUNT.funnel = async () => { + const go = async () => { + const d = await API.get('/statistics/employment/funnel', { high_line: $('#fu-h').value }); + const line = Number($('#fu-h').value) || 10000; + $('#fu-res').innerHTML = ` +
${barChart(d.map((x) => ({ + label: x.class_name, value: x.employment_rate, + text: `${x.employment_rate}%(${x.employed}/${x.total})`, + color: x.employment_rate >= 80 ? 'gr' : x.employment_rate >= 60 ? 'g' : 'o', + })), { color: 'gr' })}
+ ${renderTable([ + { title: '班级', render: (r) => `${esc(r.class_name)}` }, + { title: '总人数', cls: 'num', render: (r) => r.total }, + { title: '已就业', cls: 'num', render: (r) => `${r.employed}` }, + { title: `高薪(≥${line})`, cls: 'num', render: (r) => `${r.high_salary}` }, + { title: '就业率', cls: 'num', render: (r) => `${r.employment_rate}%` }, + { title: '高薪占已就业', cls: 'num', render: (r) => r.high_salary_rate + '%' }, + { title: '平均薪资', cls: 'num', render: (r) => money(r.avg_salary) }, + { title: '平均就业时长', cls: 'num', render: (r) => (r.avg_duration_days === null ? '—' : r.avg_duration_days + ' 天') }, + ], d)}`; + }; + $('#fu-go').onclick = go; + await go(); +}; + +/* ============================================================ + 页面:高级筛选(规则构建器) + ============================================================ */ +let advMeta = null; + +const OP_LABEL = { '=': '等于', '!=': '不等于', '>': '大于', '>=': '大于等于', '<': '小于', '<=': '小于等于', like: '包含 like', not_like: '不包含', in: '在集合 in', not_in: '不在集合', between: '区间 between', is_null: '为空', not_null: '不为空' }; +const opLabel = (o) => OP_LABEL[o] || o; + +function buildFieldOptions(modelKey, selected) { + const m = (advMeta || []).find((x) => x.model === modelKey); + if (!m) return ''; + const main = m.fields.filter((f) => !['class_id', 'stu_id'].includes(f.field)); + return main.map((f) => ``).join(''); +} + +function newLeaf(modelKey) { + const m = (advMeta || []).find((x) => x.model === modelKey); + const first = m ? m.fields.find((f) => f.field !== 'class_id') : null; + return { field: first ? first.field : 'id', operator: '=', value: '' }; +} + +function leafDom(modelKey, rule) { + const wrap = document.createElement('div'); + wrap.className = 'rule-leaf'; + wrap.dataset.leaf = '1'; + wrap.innerHTML = ` + + + + ×`; + return wrap; +} + +function groupDom(modelKey, group) { + const wrap = document.createElement('div'); + wrap.className = 'rule-group'; + wrap.dataset.group = '1'; + wrap.innerHTML = ` +
+ + + + + 这组条件之间 + + + + × +
+
`; + const children = $('[data-children]', wrap); + (group.rules || []).forEach((r) => { + if (r.sub_rules) children.appendChild(groupDom(modelKey, { logic: r.logic || 'AND', rules: r.sub_rules })); + else children.appendChild(leafDom(modelKey, r)); + }); + return wrap; +} + +function readRules(node) { + // node 可能是「分组元素本身」,也可能是分组的 [data-children] 容器。 + // 叶子/子分组都在容器的直接 children 里,分组元素本身的第一层是 head + children, + // 所以传分组元素时必须先下沉一层,否则会读出一个空规则数组(曾经的真实 bug)。 + const container = node && node.dataset && node.dataset.group ? $('[data-children]', node) : node; + if (!container) return []; + const out = []; + Array.from(container.children).forEach((child) => { + if (child.dataset.group) { + const logic = $('[data-logic].on', child)?.dataset.logic || 'AND'; + out.push({ logic, sub_rules: readRules($('[data-children]', child)) }); + } else if (child.dataset.leaf) { + const field = $('.f-field', child).value; + const operator = $('.f-op', child).value; + const raw = $('.f-val', child).value.trim(); + if (!field || !operator) return; + const rule = { field, operator }; + if (operator === 'is_null' || operator === 'not_null') { out.push(rule); return; } + if (operator === 'in' || operator === 'not_in') { + rule.value = raw.split(',').map((s) => s.trim()).filter((s) => s !== ''); + } else if (operator === 'between') { + rule.value = raw.split(',').map((s) => s.trim()).filter((s) => s !== ''); + if (rule.value.length !== 2) { rule.value = []; } + } else { + rule.value = raw; + } + out.push(rule); + } + }); + return out; +} + +let advTab = 'query'; + +async function pageAdvanced(view) { + advMeta = await API.get('/advanced/meta'); + view.innerHTML = ` +
+
+
通用筛选器
+
分组聚合
+
示例
+
+
+
`; + $('.tabs', view).onclick = (e) => { + const t = e.target.closest('[data-atab]'); + if (!t) return; + advTab = t.dataset.atab; + $$('.tab', view).forEach((x) => x.classList.toggle('active', x.dataset.atab === advTab)); + renderAdvBody(); + }; + + const renderAdvBody = () => { + if (advTab === 'query') advQueryUI(); + else if (advTab === 'agg') advAggUI(); + else advExamples(); + }; + renderAdvBody(); +} + +/* 库里存的是 int 代号,高级查询结果要还原成中文,否则「性别」列显示 1/2 没人看得懂 */ +const ADV_CODE_TEXT = { + gender: { 1: '男', 2: '女' }, + status: { 1: '在读', 2: '进入就业', 3: '已就业' }, + class_status: { 1: '在读', 2: '已结课', 3: '已解散' }, +}; + +function advFmtCell(meta, value) { + if (value === null || value === undefined || value === '') return '—'; + const type = meta && meta.type; + if (ADV_CODE_TEXT[type]) return ADV_CODE_TEXT[type][value] || String(value); + if (type === 'float' && typeof value === 'number') { + return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 }); + } + return String(value); +} + +function advQueryUI() { + const model = 'student'; + $('#adv-body').innerHTML = ` +
+

规则构建器

+ 支持 AND / OR 嵌套、13 种操作符;字段全部走白名单,传不认识的字段会直接告诉你可用字段有哪些
+
+
+
+ +
+
+
+
+
+
+
+ + + +
+
+
+
`; + + const mountBuilder = (rules) => { + $('#adv-builder').innerHTML = ''; + $('#adv-builder').appendChild(groupDom($('#adv-model').value, { logic: 'AND', rules: rules || [{ field: 'age', operator: '>', value: 25 }] })); + }; + mountBuilder(); + + $('#adv-model').onchange = () => { + const m = $('#adv-model').value; + $('#adv-order').innerHTML = '' + buildFieldOptions(m, ''); + mountBuilder([newLeaf(m)]); + }; + $('#adv-reset').onclick = () => mountBuilder([newLeaf($('#adv-model').value)]); + $('#adv-demo').onclick = () => { + $('#adv-model').value = 'student'; + mountBuilder([]); + const root = $('#adv-builder [data-group]'); + const children = $('[data-children]', root); + children.appendChild(leafDom('student', { field: 'age', operator: '>', value: 25 })); + children.appendChild(leafDom('student', { field: 'gender', operator: '=', value: '男' })); + const sub = groupDom('student', { logic: 'OR', rules: [] }); + $('[data-children]', sub).appendChild(leafDom('student', { field: 'salary', operator: '>=', value: 15000 })); + $('[data-children]', sub).appendChild(leafDom('student', { field: 'class_name', operator: 'like', value: 'Java' })); + children.appendChild(sub); + }; + $('#adv-run').onclick = async () => { + const rules = readRules($('#adv-builder [data-group]')); + const body = { + model: $('#adv-model').value, + rules, + order_by: $('#adv-order').value || null, + order: $('#adv-dir').value, + page: 1, + page_size: Number($('#adv-size').value) || 10, + with_explain: true, + }; + try { + const d = await API.post('/advanced/query', body); + const cols = (d.fields || []).map((f) => { + const m = advMeta.find((x) => x.model === d.model); + const meta = m ? m.fields.find((x) => x.field === f) : null; + return { + title: meta ? meta.desc : f, + render: (r) => { + const shown = advFmtCell(meta, r[f]); + return `${esc(shown)}`; + }, + }; + }); + $('#adv-res').innerHTML = ` +
+

查询结果

命中 ${d.total} 条
+
${renderTable(cols, d.items, { emptyText: '没有命中的数据' })}
+
+

实际执行的 SQL

+ 已参数化渲染,可直接对照规则看
+
${esc(d.explain || '(未生成)')}
`; + } catch (err) { toast(err.message, 'err', 6000); } + }; +} + +function advAggUI() { + $('#adv-body').innerHTML = ` +
+

分组聚合

count / avg / sum / max / min,可带 HAVING 过滤聚合结果
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
`; + + const refreshFields = () => { + const m = $('#ag-model').value; + const meta = advMeta.find((x) => x.model === m); + const opts = (sel) => meta.fields.map((f) => ``).join(''); + $('#ag-group').innerHTML = opts(); + $('#ag-field').innerHTML = opts(); + }; + $('#ag-model').onchange = refreshFields; + refreshFields(); + + $('#ag-run').onclick = async () => { + const func = $('#ag-func').value; + const field = $('#ag-field').value; + const metrics = [{ func, field: func === 'count' ? null : field, alias: (func === 'count' ? '记录数' : func + '_' + field) }]; + const having = []; + if ($('#ag-hop').value && $('#ag-hval').value) { + having.push({ field: metrics[0].alias, operator: $('#ag-hop').value, value: Number($('#ag-hval').value) }); + } + try { + const d = await API.post('/advanced/aggregate', { + model: $('#ag-model').value, + group_by: [$('#ag-group').value], + metrics, + having, + order_by: metrics[0].alias, + order: $('#ag-dir').value, + limit: Number($('#ag-limit').value) || 20, + }); + $('#ag-res').innerHTML = ` +

聚合结果

${d.total} 行
+
${renderTable(d.columns.map((c) => ({ + title: c, cls: typeof d.rows[0]?.[c] === 'number' ? 'num' : '', + render: (r) => esc(nz(r[c])), + })), d.rows, { emptyText: '没有结果' })}
+

生成的 SQL

+
${esc(d.sql || '')}
`; + } catch (err) { toast(err.message, 'err', 6000); } + }; +} + +async function advExamples() { + const list = await API.get('/advanced/examples'); + $('#adv-body').innerHTML = list.map((x, i) => ` +
+

${i + 1}. ${esc(x.title)}

+ +
+
${esc(JSON.stringify(x.body, null, 2))}
+
`).join(''); + $('#adv-body').onclick = (e) => { + const b = e.target.closest('[data-copy]'); + if (!b) return; + const text = $('#ex-' + b.dataset.copy).textContent; + navigator.clipboard?.writeText(text).then(() => toast('已复制到剪贴板'), () => toast('复制失败,请手动选择', 'warn')); + }; +} + +/* ============================================================ + 路由与启动 + ============================================================ */ +const PAGES = [ + { group: '日常业务' }, + { key: 'overview', label: '概览', icon: '📊', title: '概览', desc: '关键指标与预警', render: pageOverview }, + { key: 'students', label: '学生管理', icon: '🎓', title: '学生管理', desc: '增删改查 / 批量导入 / 学号规则', render: pageStudents }, + { key: 'scores', label: '成绩管理', icon: '📝', title: '成绩管理', desc: '按考核序次录入 · 红线预警', render: pageScores }, + { key: 'employments', label: '就业管理', icon: '💼', title: '就业管理', desc: '登记就业 · 状态联动', render: pageEmployments }, + { group: '基础数据' }, + { key: 'classes', label: '班级管理', icon: '🏫', title: '班级管理', desc: '开课信息 / 班主任 / 授课老师', render: pageClasses }, + { key: 'teachers', label: '老师管理', icon: '👨‍🏫', title: '老师管理', desc: '老师信息与带班', render: pageTeachers }, + { key: 'advisors', label: '顾问管理', icon: '🧑‍💼', title: '顾问管理', desc: '顾问信息与名下学生', render: pageAdvisors }, + { group: '数据分析' }, + { key: 'statistics', label: '统计分析', icon: '📈', title: '统计分析', desc: '需求 2.6 全部统计口径', render: pageStatistics }, + { key: 'advanced', label: '高级筛选', icon: '🧪', title: '高级筛选与聚合', desc: '需求 2.7 规则引擎', render: pageAdvanced }, +]; + +let currentPage = null; + +/* 导航序号:每跳一次页领一个号。 + 渲染是异步的,接口慢的那一页回得晚。没有这个号的时候会发生这样的事: + 用户点了「学生管理」,学生页已经画好了,紧接着概览页那两次接口才返回, + 于是概览的 `view.innerHTML = ...` 把学生页整个覆盖掉 —— 菜单高亮着 + 「学生管理」,屏幕上是概览。概览页接口最多最慢,所以这个错几乎必现。 + 做法:这一页先渲染进一个"影子容器",只有号还是最新的才把它搬进 #view。 + 过期的渲染连 #view 都碰不到,自然覆盖不了别人。 */ +let navSeq = 0; + +async function goto(key) { + const page = PAGES.find((p) => p.key === key); + if (!page) return; + + const seq = ++navSeq; + currentPage = key; + $('#page-title').textContent = page.title; + $('#page-desc').textContent = page.desc; + $$('.nav-item').forEach((n) => n.classList.toggle('active', n.dataset.key === key)); + + const host = $('#view'); + host.onclick = null; + + // 影子容器要挂在 #view 里面,页面函数里的 $('#stu-table') 这类 document 级 + // 查询才找得到自己的节点;一旦有更新的跳页发生,host 会换上新的影子容器, + // 旧的那个被摘下来。此后它的写入都落在一个没人看的游离节点上, + // 查询返回 null 抛出的错会被下面的 catch 吞掉 —— 反正 seq 已过期,不该报错。 + const shadow = document.createElement('div'); + shadow.innerHTML = '
加载中…
'; + host.replaceChildren(shadow); + + try { + await page.render(shadow); + } catch (err) { + if (seq !== navSeq) return; // 过期导航的异常不打扰用户 + host.innerHTML = `
+ 加载失败:${esc(err.message)}
`; + return; + } + if (seq !== navSeq) return; // 已经有更新的跳页了,这一页作废 + + // 搬进 #view。页面的行级点击处理器是挂在渲染容器上的,得一起转交, + // 否则行里的「详情 / 编辑 / 删除」点上去毫无反应。 + host.onclick = shadow.onclick; + host.replaceChildren(...Array.from(shadow.childNodes)); + history.replaceState(null, '', '#' + key); +} + +function renderNav() { + $('#nav').innerHTML = PAGES.map((p) => { + if (p.group) return ``; + return ``; + }).join(''); + $('#nav').onclick = (e) => { + const item = e.target.closest('.nav-item'); + if (item) goto(item.dataset.key); + }; +} + +function doLogout(silent) { + API.setToken(''); + Me = null; + $('#app').style.display = 'none'; + $('#app').classList.remove('readonly-mode'); + const badge = $('#ro-badge'); + if (badge) badge.style.display = 'none'; + $('#login').style.display = 'flex'; + if (!silent) toast('已退出登录'); +} + +async function boot() { + renderNav(); + $('#btn-logout').onclick = () => doLogout(); + $('#btn-docs').onclick = () => window.open('/docs', '_blank'); + $('#login-btn').onclick = doLogin; + $('#login-pass').onkeydown = (e) => { if (e.key === 'Enter') doLogin(); }; + $('#login-user').onkeydown = (e) => { if (e.key === 'Enter') $('#login-pass').focus(); }; + + if (API.token) { + try { + const me = await API.get('/auth/me'); + await enterApp(me); + return; + } catch (e) { API.setToken(''); } + } + $('#login').style.display = 'flex'; +} + +async function doLogin() { + const username = $('#login-user').value.trim(); + const password = $('#login-pass').value; + if (!username || !password) { toast('请输入账号和密码', 'warn'); return; } + const btn = $('#login-btn'); + btn.disabled = true; + try { + const d = await API.post('/auth/login', { username, password }); + API.setToken(d.access_token); + toast(d.msg || '登录成功'); + await enterApp(d.account); + } catch (err) { + toast(err.message, 'err'); + } finally { btn.disabled = false; } +} + +async function enterApp(account) { + Me = account || null; + $('#login').style.display = 'none'; + $('#app').style.display = 'flex'; + $('#user-name').textContent = `${account.real_name}(${account.role_text})`; + $('#user-avatar').textContent = (account.real_name || 'U').slice(0, 1); + // 只读账号:顶栏挂个标记,并把所有写入口收起来。 + // 后端本来就拦得住(403),但让用户点了才被拒是糟糕的体验 —— + // 看不见的按钮比点了报错的按钮友好。 + $('#app').classList.toggle('readonly-mode', !canWrite()); + const badge = $('#ro-badge'); + if (badge) badge.style.display = canWrite() ? 'none' : ''; + // 新会话从干净的界面状态开始,别把上一个账号筛剩的、切剩的东西带进来 + resetSessionState(); + const hash = location.hash.replace('#', ''); + await goto(PAGES.some((p) => p.key === hash) ? hash : 'overview'); +} + +boot(); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..4873b2d --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,68 @@ + + + + + + 沃林学生管理系统 + + + + + + + + + +
+ + + + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..60a5f80 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,394 @@ +/* ============================================================ + 沃林学生管理系统 - 样式 + 纯手写,无任何外部依赖(离线也能开) + ============================================================ */ + +:root { + --brand: #3b5bdb; + --brand-dark: #2f4bc0; + --brand-light: #edf2ff; + --bg: #f5f7fb; + --surface: #ffffff; + --border: #e6e9f0; + --border-strong: #d5dae6; + --text: #1f2430; + --text-2: #5b6478; + --text-3: #8b93a7; + --success: #12b76a; + --success-bg: #e9f9f0; + --warn: #f79009; + --warn-bg: #fff6e6; + --danger: #e5484d; + --danger-bg: #fdeced; + --info: #4c6ef5; + --info-bg: #edf2ff; + --radius: 10px; + --radius-sm: 7px; + --shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 6px 18px rgba(16, 24, 40, .06); + --shadow-lg: 0 12px 40px rgba(16, 24, 40, .16); + --sidebar-w: 214px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + font-size: 14px; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +button, input, select, textarea { font-family: inherit; font-size: inherit; } +a { color: var(--brand); text-decoration: none; } + +/* ---------------- 登录页 ---------------- */ +.login-wrap { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #eef2ff 0%, #f7f9fc 45%, #e8f4ff 100%); + padding: 20px; +} +.login-card { + width: 100%; + max-width: 400px; + background: var(--surface); + border-radius: 16px; + padding: 36px 32px 28px; + box-shadow: var(--shadow-lg); +} +.login-logo { + width: 46px; height: 46px; border-radius: 13px; + background: var(--brand); color: #fff; + display: flex; align-items: center; justify-content: center; + font-size: 20px; font-weight: 700; margin-bottom: 18px; +} +.login-card h1 { font-size: 21px; margin: 0 0 6px; } +.login-card .sub { color: var(--text-3); margin: 0 0 24px; font-size: 13px; } +.login-hint { + margin-top: 18px; padding: 10px 12px; border-radius: var(--radius-sm); + background: var(--brand-light); color: var(--brand-dark); font-size: 12.5px; line-height: 1.7; +} + +/* ---------------- 布局 ---------------- */ +.layout { display: flex; min-height: 100vh; } + +.sidebar { + width: var(--sidebar-w); + flex: 0 0 var(--sidebar-w); + background: #1c2333; + color: #c8d0e0; + display: flex; flex-direction: column; + position: fixed; top: 0; bottom: 0; left: 0; + overflow-y: auto; +} +.sidebar .brand { + padding: 18px 18px 16px; + display: flex; align-items: center; gap: 10px; + border-bottom: 1px solid rgba(255,255,255,.07); +} +.sidebar .brand .mark { + width: 30px; height: 30px; border-radius: 8px; background: var(--brand); + display: flex; align-items: center; justify-content: center; + color: #fff; font-weight: 700; font-size: 13px; +} +.sidebar .brand .name { color: #fff; font-weight: 600; font-size: 14.5px; } +.sidebar .brand .ver { color: #6d778c; font-size: 11px; } + +.nav { padding: 10px 8px 24px; } +.nav-group-title { + padding: 14px 10px 6px; font-size: 11px; letter-spacing: .08em; + color: #6d778c; text-transform: uppercase; +} +.nav-item { + display: flex; align-items: center; gap: 9px; + padding: 8.5px 10px; border-radius: var(--radius-sm); + cursor: pointer; color: #c8d0e0; font-size: 13.5px; + transition: background .12s, color .12s; +} +.nav-item:hover { background: rgba(255,255,255,.06); color: #fff; } +.nav-item.active { background: var(--brand); color: #fff; font-weight: 500; } +.nav-item .ico { width: 17px; text-align: center; opacity: .9; font-size: 14px; } +.nav-item .badge-dot { + margin-left: auto; background: var(--danger); color: #fff; + border-radius: 9px; padding: 0 6px; font-size: 11px; line-height: 17px; +} + +.main { + flex: 1; margin-left: var(--sidebar-w); + display: flex; flex-direction: column; min-width: 0; +} + +.topbar { + height: 56px; background: var(--surface); + border-bottom: 1px solid var(--border); + display: flex; align-items: center; padding: 0 22px; gap: 14px; + position: sticky; top: 0; z-index: 20; +} +.topbar h2 { font-size: 15.5px; margin: 0; font-weight: 600; } +.topbar .crumb { color: var(--text-3); font-size: 12.5px; } +.topbar .spacer { flex: 1; } +.user-chip { + display: flex; align-items: center; gap: 8px; + padding: 5px 10px; border-radius: 20px; background: var(--bg); + font-size: 13px; color: var(--text-2); +} +.user-chip .av { + width: 24px; height: 24px; border-radius: 50%; background: var(--brand); + color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11.5px; +} + +.view { padding: 20px 22px 60px; } + +/* ---------------- 卡片 ---------------- */ +.card { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); box-shadow: var(--shadow); + margin-bottom: 16px; +} +.card-head { + padding: 14px 16px; border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 10px; +} +.card-head h3 { margin: 0; font-size: 14.5px; font-weight: 600; } +.card-head .desc { color: var(--text-3); font-size: 12.5px; } +.card-head .spacer { flex: 1; } +.card-body { padding: 16px; } +.card-body.tight { padding: 0; } + +.grid { display: grid; gap: 16px; } +.grid.c2 { grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); } +.grid.c3 { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } +.grid.c4 { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); } + +/* KPI */ +.kpi { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); +} +.kpi .label { color: var(--text-3); font-size: 12.5px; display: flex; align-items: center; gap: 6px; } +.kpi .value { font-size: 27px; font-weight: 650; margin: 7px 0 3px; letter-spacing: -.02em; } +.kpi .foot { color: var(--text-3); font-size: 12px; } +.kpi.accent { background: linear-gradient(135deg, var(--brand) 0%, #5c7cfa 100%); border: none; color: #fff; } +.kpi.accent .label, .kpi.accent .foot { color: rgba(255,255,255,.8); } + +/* ---------------- 表单 ---------------- */ +.form-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 13px; } +.form-row label { font-size: 12.5px; color: var(--text-2); font-weight: 500; } +.form-row label .req { color: var(--danger); margin-left: 2px; } +.form-row .hint { font-size: 11.5px; color: var(--text-3); } + +input[type=text], input[type=number], input[type=password], input[type=date], +input[type=email], select, textarea { + width: 100%; padding: 8px 10px; border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); background: #fff; color: var(--text); + transition: border-color .12s, box-shadow .12s; outline: none; +} +input:focus, select:focus, textarea:focus { + border-color: var(--brand); box-shadow: 0 0 0 3px rgba(59,91,219,.12); +} +textarea { resize: vertical; min-height: 62px; } +select[multiple] { min-height: 92px; padding: 4px; } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + padding: 7.5px 14px; border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); background: #fff; color: var(--text); + cursor: pointer; transition: all .12s; white-space: nowrap; font-size: 13px; +} +.btn:hover { border-color: #b9c1d4; background: #fafbff; } +.btn:active { transform: translateY(1px); } +.btn.primary { background: var(--brand); border-color: var(--brand); color: #fff; } +.btn.primary:hover { background: var(--brand-dark); border-color: var(--brand-dark); } +.btn.danger { color: var(--danger); border-color: #f3c6c8; } +.btn.danger:hover { background: var(--danger-bg); } +.btn.ghost { border-color: transparent; background: transparent; color: var(--text-2); } +.btn.ghost:hover { background: var(--bg); } +.btn.sm { padding: 5px 10px; font-size: 12.5px; } +.btn:disabled { opacity: .5; cursor: not-allowed; } + +/* 筛选栏 */ +.toolbar { + display: flex; flex-wrap: wrap; gap: 9px; align-items: flex-end; + padding: 14px 16px; border-bottom: 1px solid var(--border); +} +.toolbar .field { display: flex; flex-direction: column; gap: 5px; } +.toolbar .field label { font-size: 11.5px; color: var(--text-3); } +.toolbar .field input, .toolbar .field select { min-width: 132px; padding: 7px 9px; } +.toolbar .spacer { flex: 1; } + +/* ---------------- 表格 ---------------- */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 13px; } +thead th { + text-align: left; padding: 10px 14px; background: #fafbfe; + color: var(--text-2); font-weight: 600; font-size: 12.5px; + border-bottom: 1px solid var(--border); white-space: nowrap; position: sticky; top: 0; +} +tbody td { padding: 10px 14px; border-bottom: 1px solid #f1f3f8; vertical-align: middle; } +tbody tr:hover { background: #fafbff; } +tbody tr.row-danger { background: #fffafa; } +tbody tr.row-danger:hover { background: #fff4f4; } +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } +td.center, th.center { text-align: center; } +.empty { text-align: center; color: var(--text-3); padding: 38px 0; } + +.tag { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 8px; border-radius: 20px; font-size: 12px; line-height: 18px; + background: var(--bg); color: var(--text-2); white-space: nowrap; +} +.tag.blue { background: var(--info-bg); color: #3b5bdb; } +.tag.green { background: var(--success-bg); color: #087443; } +.tag.orange { background: var(--warn-bg); color: #b54708; } +.tag.red { background: var(--danger-bg); color: #b42318; } +.tag.gray { background: #f1f3f8; color: var(--text-2); } + +.mono { font-variant-numeric: tabular-nums; font-family: ui-monospace, Consolas, monospace; font-size: 12.5px; } +.muted { color: var(--text-3); } +.strong { font-weight: 600; } +.nowrap { white-space: nowrap; } +.link { color: var(--brand); cursor: pointer; } +.link:hover { text-decoration: underline; } + +/* 只读账号:所有写入口隐藏。 + w-act 有两个挂法,都是有意为之: + 1) 挂在列的 cls 上 → 整列(th + td)一起隐藏,适用于「操作」列里全是写链接的表 + (成绩/就业/班级/老师/顾问)。 + 2) 挂在单个 span 上 → 逐链接隐藏,适用于学生表的「操作」列 —— 那列里还有 + 「详情」链接,只读账号需要它,整列藏掉等于把查看功能也砍了。 + #ro-badge 由 app.js 在进入应用时按角色显隐,这里只兜底样式。 */ +.readonly-mode .w-act { display: none !important; } +#ro-badge { margin-right: 8px; } + +/* 分页 */ +.pager { display: flex; align-items: center; gap: 10px; padding: 12px 16px; } +.pager .info { color: var(--text-3); font-size: 12.5px; } +.pager .spacer { flex: 1; } +.pager .btns { display: flex; gap: 6px; align-items: center; } + +/* ---------------- 弹窗 ---------------- */ +.modal-mask { + position: fixed; inset: 0; background: rgba(20, 26, 40, .45); + display: flex; align-items: flex-start; justify-content: center; + padding: 56px 16px; z-index: 100; overflow-y: auto; + animation: fadeIn .14s ease; +} +@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } +.modal { + background: var(--surface); border-radius: 13px; width: 100%; max-width: 620px; + box-shadow: var(--shadow-lg); animation: slideUp .18s ease; +} +.modal.wide { max-width: 880px; } +.modal.narrow { max-width: 440px; } +@keyframes slideUp { from { transform: translateY(10px); opacity: .6 } to { transform: none; opacity: 1 } } +.modal-head { + padding: 16px 20px; border-bottom: 1px solid var(--border); + display: flex; align-items: center; +} +.modal-head h3 { margin: 0; font-size: 15px; } +.modal-head .x { margin-left: auto; cursor: pointer; color: var(--text-3); font-size: 20px; line-height: 1; } +.modal-head .x:hover { color: var(--text); } +.modal-body { padding: 18px 20px; max-height: 62vh; overflow-y: auto; } +.modal-foot { + padding: 14px 20px; border-top: 1px solid var(--border); + display: flex; justify-content: flex-end; gap: 9px; +} +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; } +.form-grid .full { grid-column: 1 / -1; } + +/* ---------------- 提示 ---------------- */ +#toasts { + position: fixed; top: 18px; right: 18px; z-index: 200; + display: flex; flex-direction: column; gap: 9px; max-width: 380px; +} +.toast { + background: var(--surface); border: 1px solid var(--border); + border-left: 3px solid var(--brand); + border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); + padding: 11px 14px; font-size: 13px; animation: slideIn .18s ease; + display: flex; gap: 9px; align-items: flex-start; line-height: 1.55; +} +@keyframes slideIn { from { transform: translateX(16px); opacity: 0 } to { transform: none; opacity: 1 } } +.toast.ok { border-left-color: var(--success); } +.toast.err { border-left-color: var(--danger); } +.toast.warn { border-left-color: var(--warn); } +.toast .ic { flex: 0 0 auto; } + +/* ---------------- 图表 ---------------- */ +.chart { width: 100%; overflow: visible; } +.bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 9px; font-size: 12.5px; } +.bar-row .nm { flex: 0 0 138px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); } +.bar-row .bar { flex: 1; height: 17px; background: #f1f3f8; border-radius: 5px; overflow: hidden; position: relative; } +.bar-row .bar > i { display: block; height: 100%; border-radius: 5px; background: var(--brand); transition: width .35s; } +.bar-row .bar > i.g { background: linear-gradient(90deg, #4c6ef5, #748ffc); } +.bar-row .bar > i.r { background: linear-gradient(90deg, #e5484d, #f2777a); } +.bar-row .bar > i.o { background: linear-gradient(90deg, #f79009, #fbbf5c); } +.bar-row .bar > i.gr { background: linear-gradient(90deg, #12b76a, #4fd18b); } +.bar-row .vl { flex: 0 0 74px; text-align: right; font-variant-numeric: tabular-nums; } + +.stat-line { display: flex; flex-wrap: wrap; gap: 22px; padding: 4px 0 2px; } +.stat-line .it .k { color: var(--text-3); font-size: 12px; } +.stat-line .it .v { font-size: 18px; font-weight: 600; margin-top: 2px; } + +/* ---------------- Tab ---------------- */ +.tabs { display: flex; gap: 3px; border-bottom: 1px solid var(--border); padding: 0 16px; flex-wrap: wrap; } +.tab { + padding: 10px 13px; cursor: pointer; color: var(--text-2); font-size: 13px; + border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; +} +.tab:hover { color: var(--text); } +.tab.active { color: var(--brand); border-bottom-color: var(--brand); font-weight: 500; } + +/* ---------------- 规则构建器 ---------------- */ +.rule-group { + border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); + padding: 8px; margin: 6px 0; background: #fcfdff; +} +.rule-group-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } +.logic-toggle { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 6px; overflow: hidden; } +.logic-toggle button { + border: none; background: #fff; padding: 3px 11px; cursor: pointer; + font-size: 12px; color: var(--text-2); +} +.logic-toggle button.on { background: var(--brand); color: #fff; } +.rule-leaf { + display: flex; gap: 7px; align-items: center; padding: 5px 0; flex-wrap: wrap; +} +.rule-leaf select, .rule-leaf input { padding: 5px 8px; } +.rule-leaf .f-field { flex: 0 0 156px; } +.rule-leaf .f-op { flex: 0 0 116px; } +.rule-leaf .f-val { flex: 1; min-width: 110px; } +.rule-leaf .del { color: var(--text-3); cursor: pointer; padding: 0 3px; font-size: 15px; } +.rule-leaf .del:hover { color: var(--danger); } + +pre.code { + background: #1c2333; color: #d7dce8; border-radius: var(--radius-sm); + padding: 12px 14px; overflow-x: auto; font-size: 12px; line-height: 1.65; + font-family: ui-monospace, Consolas, monospace; margin: 0; white-space: pre-wrap; word-break: break-all; +} + +.tip { + padding: 10px 13px; border-radius: var(--radius-sm); background: var(--info-bg); + color: #33418f; font-size: 12.5px; line-height: 1.7; margin-bottom: 14px; +} +.tip.warn { background: var(--warn-bg); color: #8a4b08; } +.tip.danger { background: var(--danger-bg); color: #912018; } + +.detail-list { display: grid; grid-template-columns: 108px 1fr; gap: 7px 12px; font-size: 13px; } +.detail-list dt { color: var(--text-3); } +.detail-list dd { margin: 0; } + +.scroll-y { max-height: 380px; overflow-y: auto; } + +@media (max-width: 900px) { + .sidebar { width: 62px; flex-basis: 62px; } + .sidebar .brand .name, .sidebar .brand .ver, .nav-item span:not(.ico), .nav-group-title { display: none; } + .main { margin-left: 62px; } + .form-grid { grid-template-columns: 1fr; } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d0c6ba --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,88 @@ +# 沃林学生管理系统 —— Docker Desktop 一键部署 +# +# 启动: docker compose up -d --build +# 日志: docker compose logs -f app +# 停止: docker compose down +# 清库: docker compose down -v (会删掉 MySQL 数据卷) +# +# 说明: +# * 独立 project name = wolin_sms,不会和已有的 dify / milvus 项目混在一起 +# * 宿主 3306 已被本机 MySQL 占用,所以容器 MySQL 映射到宿主 3307 +# * 应用映射宿主 8010(与 start.bat 保持一致) +name: wolin_sms + +services: + mysql: + image: mysql:8.0 + container_name: wolin-mysql + restart: unless-stopped + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --default-authentication-plugin=mysql_native_password + environment: + MYSQL_ROOT_PASSWORD: root123456 + MYSQL_DATABASE: wolin_sms + MYSQL_USER: wolin + MYSQL_PASSWORD: wolin123456 + TZ: Asia/Shanghai + ports: + - "3307:3306" # 宿主机 3307 -> 容器 3306 + volumes: + - wolin_mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot123456 --silent"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 30s + networks: + - wolin-net + + app: + build: + context: . + dockerfile: Dockerfile + image: wolin-sms:1.0.0 + container_name: wolin-app + restart: unless-stopped + depends_on: + mysql: + condition: service_healthy + environment: + APP_PORT: "8000" + TZ: Asia/Shanghai + + APP_NAME: 沃林学生管理系统 + DEBUG: "true" + + DB_DRIVER: mysql+pymysql + DB_HOST: mysql + DB_PORT: "3306" + DB_USER: wolin + DB_PASSWORD: wolin123456 + DB_NAME: wolin_sms + + JWT_SECRET: wolin-sms-docker-secret-change-me + JWT_EXPIRE_MINUTES: "720" + AUTH_ENABLED: "true" + + ADMIN_USERNAME: admin + ADMIN_PASSWORD: admin123 + + INIT_DB: "true" + SEED_DATA: "true" + DB_WAIT_TIMEOUT: "120" + ports: + - "8010:8000" # 宿主机 8010 -> 容器 8000 + networks: + - wolin-net + +volumes: + wolin_mysql_data: + name: wolin_sms_mysql_data + +networks: + wolin-net: + name: wolin_sms_net + driver: bridge diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..7ae13b7 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# 沃林学生管理系统 —— 容器启动脚本 +# 顺序:等 MySQL → (可选)建表建账号 → (可选)灌演示数据 → 起 uvicorn +set -e + +echo "==========================================================" +echo " 沃林学生管理系统 · 容器启动" +echo "==========================================================" +echo " 工作目录 : $(pwd)" +echo " 数据库 : ${DB_DRIVER}://${DB_USER}@${DB_HOST}:${DB_PORT}/${DB_NAME}" +echo " 监听端口 : 0.0.0.0:${APP_PORT:-8000}" +echo "----------------------------------------------------------" + +wait_for_db() { + if [ "${INIT_DB}" != "true" ]; then + echo "[wait] INIT_DB=false,跳过数据库等待" + return 0 + fi + echo "[wait] 等待 MySQL (${DB_HOST}:${DB_PORT}) 就绪,最长 ${DB_WAIT_TIMEOUT:-120}s ..." + i=0 + while [ "$i" -lt "${DB_WAIT_TIMEOUT:-120}" ]; do + if python -c " +import sys +from sqlalchemy import create_engine, text +from app.core.config import settings +try: + e = create_engine(settings.server_url_without_db, future=True) + with e.connect() as c: + c.execute(text('SELECT 1')) + e.dispose() +except Exception: + sys.exit(1) +sys.exit(0) +" 2>/dev/null; then + echo "[wait] MySQL 已就绪(用时 ${i}s)" + return 0 + fi + i=$((i + 2)) + sleep 2 + done + echo "[wait] 错误:等待 ${DB_WAIT_TIMEOUT:-120}s 后 MySQL 仍不可用" >&2 + return 1 +} + +wait_for_db + +if [ "${INIT_DB}" = "true" ]; then + echo "[init] 建库 / 建表 / 建初始账号 ..." + python -m app.scripts.init_db +else + echo "[init] INIT_DB=false,跳过建表" +fi + +if [ "${SEED_DATA}" = "true" ]; then + echo "[seed] 灌入演示数据(已有数据会自动跳过)..." + python -m app.scripts.seed_data || echo "[seed] 灌数据失败,继续启动(不影响服务)" +else + echo "[seed] SEED_DATA=false,跳过演示数据" +fi + +echo "----------------------------------------------------------" +echo " 首页 http://<宿主机>:<映射端口>/" +echo " 接口文档 http://<宿主机>:<映射端口>/docs" +echo " 管理员 admin / admin123" +echo " 只读账号 viewer / viewer123" +echo "==========================================================" +echo "[run] uvicorn 启动中 ..." + +exec uvicorn app.main:app --host 0.0.0.0 --port "${APP_PORT:-8000}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a43c908 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +# 沃林学生管理系统 - 运行依赖 +fastapi>=0.115 +uvicorn[standard]>=0.30 +SQLAlchemy>=2.0.30 +PyMySQL>=1.1.0 +cryptography>=42.0 +pydantic>=2.7 +pydantic-settings>=2.3 +PyJWT>=2.8 +bcrypt>=4.1 +openpyxl>=3.1 +python-multipart>=0.0.9 +httpx>=0.27 diff --git a/run.py b/run.py new file mode 100644 index 0000000..7940f6a --- /dev/null +++ b/run.py @@ -0,0 +1,27 @@ +"""沃林学生管理系统 —— 启动入口。 + +在 PyCharm 里右键本文件 → Run 即可启动(或用命令行 `python run.py`)。 + +为什么入口要放在项目根,而不是直接跑 app/main.py: +Python 执行某个脚本时,会把**脚本所在目录**(不是工作目录)放到 sys.path[0]。 +跑 app/main.py 时那个目录是 app/,于是它里面的 +`from app.api import api_router` 就找不到 app 包,直接 +`ModuleNotFoundError: No module named 'app'`。 +本文件位于项目根,sys.path[0] 正好是项目根,包内绝对导入才成立。 + +启动后: + 首页 http://127.0.0.1:8000 + 接口文档 http://127.0.0.1:8000/docs + 管理员 admin / admin123 + 只读账号 viewer / viewer123 +""" + +import uvicorn + +if __name__ == "__main__": + uvicorn.run( + "app.main:app", + host="127.0.0.1", + port=8000, + reload=False, # 想要改代码自动重启用 True(但调试时建议关掉) + ) diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..28d0424 --- /dev/null +++ b/start.bat @@ -0,0 +1,33 @@ +@echo off +chcp 65001 >nul +title Wolin SMS - 沃林学生管理系统 +cd /d "%~dp0" + +REM 解释器查找顺序:项目自带的 .venv → 上一级工作区的 .venv → 系统 python +set "PY=%~dp0.venv\Scripts\python.exe" +if not exist "%PY%" set "PY=%~dp0..\.venv\Scripts\python.exe" +if not exist "%PY%" set "PY=python" + +echo. +echo ========================================================== +echo 沃林学生管理系统 +echo ========================================================== +echo. +echo 解释器 : %PY% +echo 入口 : http://127.0.0.1:8010 +echo. +echo 演示账号 admin / admin123 (全部权限) +echo 只读账号 viewer / viewer123 (只能看,用来演示权限) +echo. +echo 接口文档 http://127.0.0.1:8010/docs +echo 停止服务 按 Ctrl+C +echo ========================================================== +echo. + +"%PY%" -m uvicorn app.main:app --host 127.0.0.1 --port 8010 + +echo. +echo 服务已停止。若提示端口被占用,说明已经有一个实例在跑, +echo 直接打开 http://127.0.0.1:8010 即可。 +echo. +pause diff --git a/tests/__pycache__/api_check.cpython-313.pyc b/tests/__pycache__/api_check.cpython-313.pyc new file mode 100644 index 0000000..db9a6b8 Binary files /dev/null and b/tests/__pycache__/api_check.cpython-313.pyc differ diff --git a/tests/api_check.py b/tests/api_check.py new file mode 100644 index 0000000..62de48c --- /dev/null +++ b/tests/api_check.py @@ -0,0 +1,629 @@ +"""端到端自检脚本(不依赖 pytest,直接 python tests/api_check.py 就能跑)。 + +它按"一个教务老师一天的活儿"的顺序把系统走一遍: +建班 -> 建老师/顾问 -> 录学生(含 Excel 批量)-> 录成绩(触发红线)-> +登记就业(触发状态流转)-> 转班(验证冗余字段同步)-> 删就业(状态回退)-> +各项统计 -> 高级筛选 -> 权限与异常分支。 + +用法: + python tests/api_check.py [--base http://127.0.0.1:8000] +""" + +from __future__ import annotations + +import argparse +import json +import sys +import traceback +from datetime import date, timedelta +from pathlib import Path + +import httpx + +BASE = "http://127.0.0.1:8000" + +PASS, FAIL, WARN = [], [], [] + + +class Ctx: + token: str | None = None + client: httpx.Client + + +def call(method: str, path: str, *, expect_code: int = 0, **kw): + """发一个请求并断言业务 code。""" + headers = kw.pop("headers", {}) + if Ctx.token: + headers["Authorization"] = f"Bearer {Ctx.token}" + response = Ctx.client.request(method, path, headers=headers, **kw) + try: + body = response.json() + except Exception: # noqa: BLE001 + body = {"code": -1, "msg": response.text[:200], "data": None} + if expect_code is not None and body.get("code") != expect_code: + raise AssertionError( + f"{method} {path} 期望 code={expect_code},实际 code={body.get('code')} msg={body.get('msg')}" + ) + return body + + +def step(title: str): + """装饰器:包一层异常捕获,定义即执行。""" + + def decorator(func): + def wrapper(): + try: + detail = func() + PASS.append((title, detail)) + print(f" [PASS] {title} {detail or ''}") + except AssertionError as exc: + FAIL.append((title, str(exc))) + print(f" [FAIL] {title}\n {exc}") + except Exception as exc: # noqa: BLE001 + FAIL.append((title, f"{type(exc).__name__}: {exc}")) + print(f" [FAIL] {title}\n {type(exc).__name__}: {exc}") + + wrapper() + return func + + return decorator + + +# ==================================================================== 只读接口 +def check_reads(): + print("\n== 第一部分:登录与只读接口 ==") + + @step("健康检查") + def _(): + data = call("GET", "/api/health")["data"] + return f"dialect={data['dialect']}" + + @step("未登录访问受保护接口应被拒绝") + def _(): + token = Ctx.token + Ctx.token = None + body = call("GET", "/students", expect_code=None) + Ctx.token = token + assert body["code"] == 401, f"期望 401,实际 {body['code']}" + return body["msg"] + + @step("错误密码登录") + def _(): + body = call("POST", "/auth/login", expect_code=None, json={"username": "admin", "password": "wrong"}) + assert body["code"] == 401, f"期望 401,实际 {body['code']}" + return body["msg"] + + @step("学生列表 + 分页") + def _(): + data = call("GET", "/students?page=1&page_size=5")["data"] + assert len(data["items"]) == 5 + assert data["pages"] >= 1 + return f"total={data['total']} pages={data['pages']}" + + @step("学生按班级编号筛选") + def _(): + data = call("GET", "/students?class_no=JAVA202601&page_size=100")["data"] + assert data["total"] > 0 + assert all(i["class_no"] == "JAVA202601" for i in data["items"]) + return f"{data['total']} 人" + + @step("学生按年龄区间筛选") + def _(): + data = call("GET", "/students?age_min=20&age_max=24&page_size=100")["data"] + assert all(20 <= i["age"] <= 24 for i in data["items"]) + return f"{data['total']} 人" + + for path, name in [ + ("/classes?page_size=100", "班级列表"), + ("/teachers?page_size=100", "老师列表(含带班)"), + ("/advisors?page_size=100", "顾问列表"), + ("/scores?page_size=5", "成绩列表"), + ("/employments?page_size=5", "就业列表"), + ("/students/meta/options", "学生下拉选项"), + ("/classes/meta/options", "班级下拉选项"), + ("/scores/meta", "成绩元信息"), + ("/employments?salary_min=15000&salary_max=8000&page_size=5", "薪资区间写反自动纠正"), + ]: + path_local, name_local = path, name + + @step(name_local) + def _(p=path_local): + body = call("GET", p) + return f"code={body['code']}" + + @step("就业按公司模糊 + 薪资区间查询") + def _(): + data = call("GET", "/employments?company=科技&salary_min=10000&page_size=100")["data"] + return f"{data['total']} 条" + + +# ==================================================================== 写流程 +def check_write_flow(): + print("\n== 第二部分:完整业务链路 ==") + stamp = date.today().strftime("%m%d") + + ctx: dict = {} + + @step("新增顾问") + def _(): + body = call("POST", "/advisors", json={"name": f"自检顾问{stamp}", "gender": 2, "dept": "自检部"}) + ctx["advisor_id"] = body["data"]["id"] + return f"编号 {body['data']['advisor_no']}" + + @step("新增老师(工号自动生成 T+年份+序号)") + def _(): + body = call("POST", "/teachers", json={"name": f"自检老师{stamp}", "gender": 1, "subject": "自检方向"}) + ctx["teacher_id"] = body["data"]["id"] + assert body["data"]["teacher_no"].startswith("T") + return f"工号 {body['data']['teacher_no']}" + + @step("新增班级(编号自动生成为 方向+年份+序号)") + def _(): + body = call( + "POST", "/classes", + json={ + "name": f"自检班{stamp}", "direction": "SelfTest", "capacity": 30, + "open_date": "2026-09-01", "head_teacher_id": ctx["teacher_id"], + "advisor_id": ctx["advisor_id"], "teacher_ids": [ctx["teacher_id"]], + }, + ) + data = body["data"] + ctx["class_id"] = data["id"] + assert data["teacher_ids"] == [ctx["teacher_id"]], "多对多老师没保存上" + return f"编号 {data['class_no']}" + + @step("新增学生(学号按规则生成)") + def _(): + body = call( + "POST", "/students", + json={ + "name": f"自检学生A{stamp}", "gender": "女", "birth_date": "2003-04-05", + "native_place": "广东广州", "graduate_school": "自检学院", "major": "软件", + "education": "大专", "enroll_date": "2026-09-01", "class_id": ctx["class_id"], + "advisor_id": ctx["advisor_id"], "phone": "13000000001", + }, + ) + ctx["stu_a"] = body["data"]["id"] + ctx["stu_a_no"] = body["data"]["stu_no"] + assert body["data"]["age"] and body["data"]["class_name"] + return f"学号 {body['data']['stu_no']},年龄 {body['data']['age']},班级 {body['data']['class_name']}" + + @step("只给年龄不给生日也能建学生(birth_date_estimated=1)") + def _(): + body = call( + "POST", "/students", + json={"name": f"自检学生B{stamp}", "gender": 1, "age": 21, "class_id": ctx["class_id"]}, + ) + ctx["stu_b"] = body["data"]["id"] + assert body["data"]["birth_date_estimated"] == 1 + assert body["data"]["age"] == 21 + return f"学号 {body['data']['stu_no']},推算生日 {body['data']['birth_date']}" + + @step("学号重复应被拒绝") + def _(): + body = call( + "POST", "/students", expect_code=None, + json={"name": "重号测试", "gender": 1, "age": 20, "stu_no": ctx["stu_a_no"]}, + ) + assert body["code"] == 409, f"期望 409,实际 {body['code']} {body['msg']}" + return body["msg"] + + @step("毕业时间早于入学时间应被拒绝") + def _(): + body = call( + "POST", "/students", expect_code=None, + json={"name": "日期测试", "gender": 1, "age": 20, "enroll_date": "2026-09-01", "graduate_date": "2025-01-01"}, + ) + assert body["code"] == 400, f"期望 400,实际 {body['code']}" + return body["msg"] + + @step("录入正常成绩") + def _(): + body = call("POST", "/scores", json={"stu_id": ctx["stu_a"], "exam_seq": 1, "score": 88, "exam_name": "自检一考"}) + assert body["data"]["warning"] is False + return f"均分 {body['data']['avg_score']}" + + @step("录入低分成绩应触发红线预警") + def _(): + body = call("POST", "/scores", json={"stu_id": ctx["stu_a"], "exam_seq": 2, "score": 45, "exam_name": "自检二考"}) + data = body["data"] + assert data["warning"] is True, "没触发预警" + assert data["record"]["is_warning"] is True + return data["warning_msg"] + + @step("预警会写进学生备注") + def _(): + body = call("GET", f"/students/{ctx['stu_a']}") + remark = body["data"]["remark"] or "" + assert "成绩预警" in remark, f"备注里没有预警:{remark}" + return remark[:60] + + @step("同一序次重复录入应提示改用修改") + def _(): + body = call( + "POST", "/scores", expect_code=None, + json={"stu_id": ctx["stu_a"], "exam_seq": 2, "score": 70}, + ) + assert body["code"] == 409, f"期望 409,实际 {body['code']} {body['msg']}" + return body["msg"] + + @step("修改成绩后预警标记自动撤销") + def _(): + rows = call("GET", f"/scores?stu_id={ctx['stu_a']}&exam_seq=2")["data"]["items"] + assert rows, "没查到第 2 次考核成绩" + score_id = rows[0]["id"] + body = call("PUT", f"/scores/{score_id}", json={"score": 91}) + assert body["data"]["warning"] is False, f"分数改到 91 分还在预警:{body['data']}" + return f"均分更新为 {body['data']['avg_score']}" + + @step("批量录入同一学生多次成绩") + def _(): + body = call( + "POST", "/scores/batch", + json={"stu_id": ctx["stu_b"], "scores": [ + {"stu_id": ctx["stu_b"], "exam_seq": 1, "score": 55}, + {"stu_id": ctx["stu_b"], "exam_seq": 2, "score": 92}, + {"stu_id": ctx["stu_b"], "exam_seq": 3, "score": 58}, + ]}, + ) + data = body["data"] + assert data["count"] == 3 + assert data["warning_count"] == 2, f"应预警 2 条,实际 {data['warning_count']}" + return body["msg"] + + @step("登记就业:只填开放时间 -> 学生状态变「进入就业」") + def _(): + body = call( + "POST", "/employments", + json={"stu_id": ctx["stu_a"], "open_date": "2026-09-10", "company": "自检科技", "salary": 12000}, + ) + data = body["data"] + assert data["status_changed"] is True, f"状态没变化:{data}" + assert data["to_status_text"] == "进入就业", data + ctx["emp_id"] = data["employment"]["id"] + return body["msg"] + + @step("补填 offer 时间 -> 学生状态变「已就业」") + def _(): + body = call( + "PUT", f"/employments/{ctx['emp_id']}", + json={"offer_date": "2026-09-25"}, + ) + data = body["data"] + assert data["to_status_text"] == "已就业", data + assert data["employment"]["duration_days"] == 15, data["employment"]["duration_days"] + return body["msg"] + + @step("offer 时间早于开放时间应被拒绝") + def _(): + body = call("PUT", f"/employments/{ctx['emp_id']}", expect_code=None, json={"offer_date": "2026-09-01"}) + assert body["code"] == 400, f"期望 400,实际 {body['code']} {body['msg']}" + return body["msg"] + + @step("只填 offer 不填开放时间应被拒绝(否则就业时长算不出来)") + def _(): + body = call( + "POST", "/employments", expect_code=None, + json={"stu_id": ctx["stu_b"], "offer_date": "2026-09-20", "company": "X", "salary": 9000}, + ) + assert body["code"] == 400, f"期望 400,实际 {body['code']} {body['msg']}" + return body["msg"] + + @step("学生转班 -> 就业表冗余班级同步") + def _(): + classes = call("GET", "/classes?page_size=100")["data"]["items"] + other = next(c for c in classes if c["id"] != ctx["class_id"]) + call("PUT", f"/students/{ctx['stu_a']}", json={"class_id": other["id"]}) + emp = call("GET", f"/employments/student/{ctx['stu_a']}")["data"] + assert emp["class_id"] == other["id"], f"冗余班级没同步:{emp['class_id']} != {other['id']}" + ctx["other_class_id"] = other["id"] + return f"已同步到「{emp['class_name']}」" + + @step("删除就业记录 -> 学生状态回退到「在读」") + def _(): + body = call("DELETE", f"/employments/{ctx['emp_id']}") + assert body["data"]["status_changed"] is True + student = call("GET", f"/students/{ctx['stu_a']}")["data"] + assert student["status"] == 1, student + return body["msg"] + + @step("有学生的班级不能被删") + def _(): + body = call("DELETE", f"/classes/{ctx['other_class_id']}", expect_code=None) + assert body["code"] == 400, f"期望 400,实际 {body['code']} {body['msg']}" + return body["msg"] + + @step("Excel 模板可下载") + def _(): + headers = {"Authorization": f"Bearer {Ctx.token}"} + response = Ctx.client.get("/students/import/template", headers=headers) + assert response.status_code == 200 and len(response.content) > 1000 + ctx["template"] = response.content + return f"{len(response.content)} 字节" + + @step("Excel 批量导入(3 行有效 + 1 行班级不存在)") + def _(): + from io import BytesIO + + from openpyxl import Workbook + + wb = Workbook() + ws = wb.active + ws.append(["姓名", "性别", "出生日期", "班级编号", "顾问", "学历", "籍贯", "备注"]) + classes = call("GET", "/classes?page_size=100")["data"]["items"] + class_no = classes[0]["class_no"] + ws.append([f"导入甲{stamp}", "男", "2004-01-02", class_no, "", "大专", "湖南", "自检"]) + ws.append([f"导入乙{stamp}", "女", "2003-02-03", class_no, "", "本科", "湖北", "自检"]) + ws.append([f"导入丙{stamp}", "男", "2002-03-04", "", "", "大专", "江西", "自检"]) + ws.append([f"导入丁{stamp}", "男", "2002-03-04", "NOT-EXIST-999", "", "大专", "江西", ""]) + buffer = BytesIO() + wb.save(buffer) + result = call( + "POST", "/students/import?dry_run=false", + files={"file": ("import.xlsx", buffer.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, + )["data"] + assert result["success"] == 3, f"应成功 3 行,实际 {result['success']}:{result['errors']}" + assert result["failed"] == 1, f"应失败 1 行,实际 {result['failed']}" + ctx["imported"] = [f"导入甲{stamp}", f"导入乙{stamp}", f"导入丙{stamp}"] + return f"成功 {result['success']},失败 {result['failed']},失败原因:{result['errors'][0]['reason']}" + + @step("导入的学生确实入库了") + def _(): + data = call("GET", f"/students?keyword=导入&page_size=100")["data"] + assert data["total"] >= 3, f"只查到 {data['total']} 个" + return f"{data['total']} 人" + + @step("逻辑删除学生") + def _(): + call("DELETE", f"/students/{ctx['stu_b']}") + body = call("GET", f"/students/{ctx['stu_b']}", expect_code=None) + assert body["code"] == 404, f"删除后还能查到:{body}" + return body["msg"] + + @step("恢复被删除的学生") + def _(): + call("POST", f"/students/{ctx['stu_b']}/restore") + body = call("GET", f"/students/{ctx['stu_b']}") + return f"已恢复 {body['data']['name']}" + + return ctx + + +# ==================================================================== 统计与筛选 +def check_statistics(ctx: dict): + print("\n== 第三部分:统计分析与高级筛选 ==") + + checks = [ + ("2.6.1 动态年龄查询 gte", "/statistics/students/by-age?operator=gte&value=24&page_size=100"), + ("2.6.1 动态年龄查询 between", "/statistics/students/by-age?operator=between&value=20&value2=24&page_size=100"), + ("2.6.1 班级人数与性别分布", "/statistics/classes/overview"), + ("2.6.1 年龄分布", "/statistics/students/age-distribution"), + ("2.6.2 每考都在 80 分以上", "/statistics/scores/all-above?threshold=80"), + ("2.6.2 不及格 2 次以上", "/statistics/scores/failures?threshold=60&min_times=2"), + ("2.6.2 各班各场平均分(降序)", "/statistics/scores/class-average?order=desc"), + ("2.6.2 各班各场平均分(升序)", "/statistics/scores/class-average?order=asc&exam_seq=1"), + ("2.6.3 薪资 Top 5", "/statistics/employment/top-salary?top_n=5"), + ("2.6.3 每人就业时长", "/statistics/employment/durations"), + ("2.6.3 各班平均就业时长", "/statistics/employment/class-avg-duration"), + ("2.7.2 成绩波动 Top 5(标准差)", "/statistics/scores/volatility?top_n=5&metric=stddev"), + ("2.7.2 成绩波动 Top 5(最大分差)", "/statistics/scores/volatility?top_n=5&metric=range"), + ("2.7.2 班级就业漏斗", "/statistics/employment/funnel"), + ("总览", "/statistics/overview"), + ] + for name, path in checks: + path_local, name_local = path, name + + @step(name_local) + def _(p=path_local): + body = call("GET", p) + data = body["data"] + count = len(data) if isinstance(data, list) else len(data.get("items", [])) if isinstance(data, dict) else "-" + return f"code={body['code']} 返回 {count} 项" + + # ---------------- 高级筛选 + @step("2.7.1 需求文档原始示例(年龄+性别+嵌套 OR 薪资/班级)") + def _(): + body = call("POST", "/advanced/query", json={ + "model": "student", + "rules": [ + {"field": "age", "operator": ">", "value": 25}, + {"field": "gender", "operator": "=", "value": "男"}, + {"logic": "OR", "sub_rules": [ + {"field": "salary", "operator": ">=", "value": 15000}, + {"field": "class_name", "operator": "like", "value": "Java"}, + ]}, + ], + "order_by": "salary", "order": "desc", "page": 1, "page_size": 10, + }) + data = body["data"] + assert data["explain"], "没有返回 SQL 说明" + return f"命中 {data['total']} 条" + + @step("2.7.1 嵌套两组 AND/OR 三层") + def _(): + body = call("POST", "/advanced/query", json={ + "model": "student", + "rules": [ + {"logic": "AND", "sub_rules": [ + {"field": "age", "operator": "between", "value": [19, 26]}, + {"logic": "OR", "sub_rules": [ + {"field": "education", "operator": "in", "value": ["大专", "本科"]}, + {"logic": "AND", "sub_rules": [ + {"field": "major", "operator": "like", "value": "软件"}, + {"field": "gender", "operator": "=", "value": 2}, + ]}, + ]}, + ]}, + ], + "page_size": 5, + }) + return f"命中 {body['data']['total']} 条" + + @step("2.7.1 操作符 != / not_like / is_null / not_null / not_in") + def _(): + total = 0 + for rule in [ + {"field": "gender", "operator": "!=", "value": "男"}, + {"field": "major", "operator": "not_like", "value": "软件"}, + {"field": "remark", "operator": "is_null"}, + {"field": "phone", "operator": "not_null"}, + {"field": "education", "operator": "not_in", "value": ["中专"]}, + ]: + body = call("POST", "/advanced/query", json={"model": "student", "rules": [rule], "page_size": 1}) + total += body["data"]["total"] + return f"五种操作符均可用,命中数合计 {total}" + + @step("2.7.1 非法字段给出可用字段提示") + def _(): + body = call("POST", "/advanced/query", expect_code=422, json={ + "model": "student", "rules": [{"field": "salary2", "operator": ">", "value": 1}] + }) + assert "可用字段" in body["msg"] + return body["msg"][:50] + + @step("2.7.1 非法操作符被拒") + def _(): + return call("POST", "/advanced/query", expect_code=422, json={ + "model": "student", "rules": [{"field": "age", "operator": "; drop table", "value": 1}] + })["msg"][:50] + + @step("2.7.1 嵌套过深被拒") + def _(): + rule = {"field": "age", "operator": ">", "value": 1} + for _i in range(10): + rule = {"logic": "AND", "sub_rules": [rule]} + body = call("POST", "/advanced/query", expect_code=422, json={"model": "student", "rules": [rule]}) + return body["msg"][:50] + + @step("2.7.2 通用聚合:按班级统计人数/平均分/最高分") + def _(): + body = call("POST", "/advanced/aggregate", json={ + "model": "score", "group_by": ["class_name"], + "metrics": [{"func": "count", "alias": "成绩数"}, + {"func": "avg", "field": "score", "alias": "平均分"}, + {"func": "max", "field": "score", "alias": "最高分"}], + "order_by": "平均分", "order": "desc", "limit": 10, + }) + return f"{len(body['data']['rows'])} 行,列:{body['data']['columns']}" + + @step("2.7.2 聚合 + HAVING 过滤") + def _(): + body = call("POST", "/advanced/aggregate", json={ + "model": "employment", "group_by": ["class_name"], + "metrics": [{"func": "count", "alias": "就业人数"}, + {"func": "avg", "field": "salary", "alias": "平均薪资"}], + "having": [{"field": "平均薪资", "operator": ">", "value": 13000}], + "order_by": "平均薪资", "order": "desc", "limit": 10, + }) + assert all(r["平均薪资"] > 13000 for r in body["data"]["rows"]) + return f"{len(body['data']['rows'])} 个班达标" + + # ---------------- 权限 + @step("只读账号不能写") + def _(): + admin_token = Ctx.token + Ctx.token = None + viewer = call("POST", "/auth/login", json={"username": "viewer", "password": "viewer123"})["data"]["access_token"] + token = Ctx.token + Ctx.token = viewer + body = call("POST", "/students", expect_code=None, json={"name": "越权", "gender": 1, "age": 20}) + Ctx.token = admin_token + assert body["code"] == 403, f"期望 403,实际 {body['code']} {body['msg']}" + return body["msg"] + + +def cleanup_selftest() -> None: + """删掉自检造出来的业务数据,避免污染演示库。 + + 自检用的名字都带 `自检` / `导入` 前缀 + 当天日期戳(如 自检学生A0916), + 这里按「前缀 + 日期戳」精确匹配删除,不会误伤正常的演示数据。 + + 走 ORM 而不是 HTTP:要按外键顺序连删 成绩/就业/学生/班级/老师/顾问 + 中间表, + 用 SQL 一次搞定最可靠。导入失败(比如脚本被拷到别的机器上跑)只提示一句,不算测试失败。 + """ + stamp = date.today().strftime("%m%d") + # 以 `python tests/api_check.py` 方式运行时,sys.path[0] 是 tests/, + # 直接 import app 会失败 —— 这里把项目根补进搜索路径。 + project_root = str(Path(__file__).resolve().parent.parent) + if project_root not in sys.path: + sys.path.insert(0, project_root) + try: + from sqlalchemy import or_ + + from app.core.database import SessionLocal + from app.model import Advisor, Clazz, Employment, Score, Student, Teacher, class_teachers + except Exception as exc: # noqa: BLE001 + print(f" [SKIP] 连不上数据库,跳过清理(要清理请跑 seed_data.py --reset):{exc}") + return + + def name_hit(model): + return or_(model.name.like(f"%自检%{stamp}%"), model.name.like(f"%导入%{stamp}%")) + + counts: dict[str, int] = {} + with SessionLocal() as db: + stu_ids = [r[0] for r in db.query(Student.id).filter(name_hit(Student)).all()] + cls_ids = [r[0] for r in db.query(Clazz.id).filter(name_hit(Clazz)).all()] + tea_ids = [r[0] for r in db.query(Teacher.id).filter(name_hit(Teacher)).all()] + adv_ids = [r[0] for r in db.query(Advisor.id).filter(name_hit(Advisor)).all()] + + if stu_ids: + counts["成绩"] = db.query(Score).filter(Score.stu_id.in_(stu_ids)).delete(synchronize_session=False) + counts["就业"] = db.query(Employment).filter(Employment.stu_id.in_(stu_ids)).delete( + synchronize_session=False + ) + counts["学生"] = db.query(Student).filter(Student.id.in_(stu_ids)).delete(synchronize_session=False) + if cls_ids: + db.execute(class_teachers.delete().where(class_teachers.c.class_id.in_(cls_ids))) + counts["班级"] = db.query(Clazz).filter(Clazz.id.in_(cls_ids)).delete(synchronize_session=False) + if tea_ids: + db.execute(class_teachers.delete().where(class_teachers.c.teacher_id.in_(tea_ids))) + counts["老师"] = db.query(Teacher).filter(Teacher.id.in_(tea_ids)).delete(synchronize_session=False) + if adv_ids: + counts["顾问"] = db.query(Advisor).filter(Advisor.id.in_(adv_ids)).delete(synchronize_session=False) + db.commit() + + hit = ",".join(f"{k} {v} 条" for k, v in counts.items() if v) + print(f" [清理] 自检数据已删除:{hit or '无残留'}") + + +def main(): + global BASE + parser = argparse.ArgumentParser() + parser.add_argument("--base", default=BASE) + parser.add_argument("--keep", action="store_true", help="保留自检造的数据(默认跑完会清掉)") + args = parser.parse_args() + BASE = args.base + + Ctx.client = httpx.Client(base_url=BASE, timeout=60) + + print("\n== 登录 ==") + try: + body = call("POST", "/auth/login", json={"username": "admin", "password": "admin123"}) + Ctx.token = body["data"]["access_token"] + print(f" [PASS] 管理员登录:{body['msg']}") + PASS.append(("管理员登录", body["msg"])) + except Exception as exc: # noqa: BLE001 + print(f" [FAIL] 登录失败:{exc}") + FAIL.append(("管理员登录", str(exc))) + sys.exit(1) + + check_reads() + ctx = check_write_flow() + check_statistics(ctx) + + if not args.keep: + print("\n== 收尾清理 ==") + cleanup_selftest() + + print("\n" + "=" * 70) + print(f"通过 {len(PASS)} 项,失败 {len(FAIL)} 项") + if FAIL: + print("\n失败明细:") + for title, reason in FAIL: + print(f" - {title}\n {reason}") + sys.exit(2) + print("全部通过 ✅") + + +if __name__ == "__main__": + main() diff --git a/verify/frontend_check.js b/verify/frontend_check.js new file mode 100644 index 0000000..9b3797b --- /dev/null +++ b/verify/frontend_check.js @@ -0,0 +1,640 @@ +/* 前端行为验证(jsdom + 真实后端) + 用法:node verify/frontend_check.js [base] + + 设计要点: + - 相对路径 fetch 打到真实后端,所以这是货真价实的端到端,不是 mock。 + - 必须等 app.js 执行完(win.doLogin 成为函数)才点按钮,否则是测试自己的竞态。 + - 记录每个 code!=0 的响应,用来抓住被 try/catch 吞掉的页面级错误。 + - 只读账号段落用的是 getComputedStyle 算出来的 display,不是类名 —— + 类名只是手段,"用户看不见"才是目的。 + + 收尾:本脚本会走完整 UI 新增/删除一个学生,删除是逻辑删除(这是被测功能的 + 正确行为),所以跑完库里会多一行 is_del=1。想清掉就执行 + `python verify/purge_selftest.py --yes`。 +*/ +const { JSDOM, VirtualConsole } = require('jsdom'); + +const BASE = process.argv[2] || 'http://127.0.0.1:8010'; +const PASS = []; +const FAIL = []; +const errors = []; // 未捕获 JS 异常 +const apiErrors = []; // 后端返回 code != 0 的请求 + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const text = (el) => { + if (el === null || el === undefined || el === false || el === true) return ''; + const s = typeof el === 'string' ? el : (el.textContent || ''); + return String(s).replace(/\s+/g, ' ').trim(); +}; + +function check(title, cond, detail = '') { + if (cond) { PASS.push(title); console.log(` [PASS] ${title}${detail ? ' ' + detail : ''}`); } + else { FAIL.push(title + (detail ? ' — ' + detail : '')); console.log(` [FAIL] ${title} ${detail}`); } +} +function section(name) { console.log(`\n== ${name} ==`); } + +async function waitFor(fn, timeout = 12000, interval = 60) { + const t0 = Date.now(); + while (Date.now() - t0 < timeout) { + try { if (await fn()) return true; } catch (e) { /* 继续等 */ } + await sleep(interval); + } + return false; +} + +(async () => { + section('加载页面'); + const vc = new VirtualConsole(); + vc.on('jsdomError', (e) => errors.push('jsdomError: ' + (e.message || e))); + vc.on('error', (...a) => errors.push('console.error: ' + a.join(' '))); + + const dom = await JSDOM.fromURL(BASE + '/', { + runScripts: 'dangerously', + resources: 'usable', + pretendToBeVisual: true, + virtualConsole: vc, + beforeParse(w) { + w.fetch = async (input, init) => { + const url = typeof input === 'string' && !/^https?:/.test(input) ? BASE + input : input; + const res = await fetch(url, init); + const method = (init && init.method) || 'GET'; + const path = String(url).replace(BASE, ''); + // 只读副本,避免消费掉 body + res.clone().json().then((b) => { + if (b && b.code !== 0) apiErrors.push(`${method} ${path} -> code=${b.code} ${b.msg}`); + }).catch(() => {}); + return res; + }; + w.URL.createObjectURL = () => 'blob:fake'; + w.HTMLAnchorElement.prototype.click = function () {}; + w.addEventListener('error', (e) => errors.push('window.error: ' + (e.message || e.error))); + w.addEventListener('unhandledrejection', (e) => errors.push('unhandled: ' + ((e.reason && e.reason.message) || e.reason))); + }, + }); + const win = dom.window; + const doc = win.document; + + const ready = await waitFor(() => text(doc.querySelector('.sub')).includes('学生')); + check('index.html 首屏渲染出登录卡', ready, text(doc.querySelector('.sub'))); + + // 关键:等脚本真正执行完 + const scriptReady = await waitFor(() => typeof win.doLogin === 'function' && doc.querySelector('#login-btn').onclick); + check('app.js 加载并完成事件绑定', scriptReady); + + section('登录'); + const loginInput = doc.querySelector('#login-user'); + const passInput = doc.querySelector('#login-pass'); + const loginBtn = doc.querySelector('#login-btn'); + + loginInput.value = 'admin'; + passInput.value = 'definitely-wrong'; + loginBtn.click(); + const gotErr = await waitFor(() => doc.querySelector('#toasts .toast.err')); + check('错误密码给出错误提示', !!gotErr, gotErr ? text(doc.querySelector('#toasts .toast.err')) : '无提示'); + check('错误密码后仍停在登录页', + doc.querySelector('#app').style.display === 'none', + 'app.display=' + doc.querySelector('#app').style.display); + + loginInput.value = 'admin'; + passInput.value = 'admin123'; + loginBtn.click(); + const entered = await waitFor(() => doc.querySelector('#app').style.display === 'flex'); + check('正确账号登录成功并进入主界面', entered); + if (!entered) { console.log('\n登录失败:', errors.slice(0, 5), apiErrors.slice(0, 5)); process.exit(1); } + await waitFor(() => text(doc.querySelector('#view')).length > 10); + + section('导航与页面渲染'); + const navItems = Array.from(doc.querySelectorAll('.nav-item')); + check('侧边栏渲染 9 个业务菜单', navItems.length === 9, '实际 ' + navItems.length); + check('侧边栏有 3 个分组标题', doc.querySelectorAll('.nav-group-title').length === 3, + '实际 ' + doc.querySelectorAll('.nav-group-title').length); + check('顶栏显示当前账号身份', /管理员/.test(text(doc.querySelector('#user-name'))), + text(doc.querySelector('#user-name'))); + + const PAGES = ['overview', 'students', 'scores', 'employments', 'classes', 'teachers', 'advisors', 'statistics', 'advanced']; + // 每个页面在视图区里应当出现的业务词 + const EXPECT = { + overview: ['学生', '就业'], + students: ['学号', '姓名'], + scores: ['学生', '考核'], + employments: ['薪资', '公司'], + classes: ['班级'], + teachers: ['老师'], + advisors: ['顾问'], + statistics: ['班级', '就业'], + advanced: ['规则', '字段'], + }; + + const pageTexts = {}; + for (const key of PAGES) { + const jsBefore = errors.length; + const apiBefore = apiErrors.length; + await win.eval(`goto('${key}')`); + await waitFor(() => !text(doc.querySelector('#view')).startsWith('加载中'), 10000); + await sleep(200); + const t = text(doc.querySelector('#view')); + pageTexts[key] = t; + check(`页面 ${key} 渲染成功`, t.length > 20 && !t.includes('加载失败'), t.length + ' 字'); + check(`页面 ${key} 无未捕获 JS 异常`, errors.length === jsBefore, errors.slice(jsBefore).join(' | ').slice(0, 160)); + check(`页面 ${key} 无后端报错`, apiErrors.length === apiBefore, apiErrors.slice(apiBefore).join(' | ').slice(0, 160)); + const missing = (EXPECT[key] || []).filter((w) => !t.includes(w)); + check(`页面 ${key} 含预期业务字段`, missing.length === 0, missing.length ? '缺 ' + missing.join('/') : ''); + } + + section('概览指标'); + const numsInCards = (pageTexts.overview.match(/\d+/g) || []).length; + check('概览页出现多项数字指标', numsInCards >= 8, '数字个数 ' + numsInCards); + + section('导航竞态:慢页面不能覆盖新页面'); + // 这条回归对应一个真实 bug:goto() 原来没有任何并发保护,而各页面的 + // `view.innerHTML = ...` 都写在 await 之后。于是「登录后立刻点学生管理」 + // 会发生:学生页已画好,概览那两次慢接口随后返回,把学生页整个盖掉 —— + // 菜单停在学生管理,屏幕上是概览。 + // + // 测试不能靠"概览恰好更慢"这种运气,否则哪天接口快了这条就形同虚设。 + // 做法是临时给概览用到的接口注入固定延时,让"慢页面后返回"成为必然。 + const realFetch = win.fetch; + const SLOW = ['/statistics/overview', '/statistics/employment/funnel']; + win.fetch = async (input, init) => { + const url = String(typeof input === 'string' ? input : (input && input.url) || ''); + if (SLOW.some((p) => url.includes(p))) await sleep(900); + return realFetch(input, init); + }; + + const raceP1 = win.eval(`goto('overview')`); // 故意不 await:模拟用户等不及就点了别的 + await win.eval(`goto('students')`); + await raceP1.catch(() => {}); // 让过期的那次导航跑完(它应当自行作废) + await sleep(1600); // 再等一会儿,确保被延时的慢接口全部回完 + + const raceText = text(doc.querySelector('#view')); + check('竞态后仍停在学生页(未被概览覆盖)', + !!doc.querySelector('#stu-table') && /关键词|重置/.test(raceText) && !/在册学生/.test(raceText), + 'view 前 80 字:' + raceText.slice(0, 80)); + check('竞态后菜单高亮与学生页一致', + text(doc.querySelector('.nav-item.active')) === '学生管理' || /学生/.test(text(doc.querySelector('.nav-item.active'))), + '高亮=' + text(doc.querySelector('.nav-item.active'))); + check('竞态后学生表格真的渲染出了数据行', + doc.querySelectorAll('#stu-table tbody tr').length > 0, + doc.querySelectorAll('#stu-table tbody tr').length + ' 行'); + + win.fetch = realFetch; // 立刻还原,后面的用例沿用真实时延 + await win.eval(`goto('students')`); + await waitFor(() => doc.querySelector('#view table'), 10000); + + section('交互:学生列表筛选'); + await win.eval(`goto('students')`); + await waitFor(() => doc.querySelector('#view table'), 10000); + const rowCount = doc.querySelectorAll('#view tbody tr').length; + check('学生列表默认渲染出数据行', rowCount > 0, rowCount + ' 行'); + + const kw = doc.querySelector('#f-kw'); + check('存在关键词输入框', !!kw); + kw.value = '张'; + doc.querySelector('#f-search').click(); + await sleep(900); + const rowsAfter = Array.from(doc.querySelectorAll('#view tbody tr')).map(text); + check('关键字筛选后无报错', !text(doc.querySelector('#view')).includes('加载失败'), rowsAfter.length + ' 行'); + check('筛选结果只含匹配项', rowsAfter.length === 0 || rowsAfter.every((n) => n.includes('张')), + rowsAfter.slice(0, 3).join(' / ').slice(0, 120)); + + doc.querySelector('#f-reset').click(); + await sleep(900); + check('重置按钮恢复全量列表', doc.querySelectorAll('#view tbody tr').length > 0, + doc.querySelectorAll('#view tbody tr').length + ' 行'); + + section('交互:新增学生 → 编辑 → 删除(走完整 UI,结束后清理数据)'); + const apiBeforeWrite = apiErrors.length; + doc.querySelector('#btn-add').click(); + const modalReady = await waitFor(() => doc.querySelector('#modal-root .modal')); + check('「新增学生」打开弹窗', !!modalReady); + + let createdId = null; + let createdNo = null; + if (modalReady) { + const form = doc.querySelector('#modal-root'); + const set = (sel, val) => { const el = form.querySelector(sel); if (el) { el.value = val; el.dispatchEvent(new win.Event('input', { bubbles: true })); } return !!el; }; + const nameOk = set('#s-name', '自检临时生'); + const clsSel = form.querySelector('#s-class'); + if (clsSel && clsSel.options.length > 1) clsSel.value = clsSel.options[1].value; + set('#s-gender', '1'); + set('#s-birth', '2004-05-06'); + check('弹窗表单含姓名输入框', nameOk); + + // 点确定 + const okBtn = form.querySelector('.modal-foot [data-ok]'); + check('弹窗存在保存按钮', !!okBtn, okBtn ? text(okBtn) : '未找到 [data-ok]'); + if (okBtn) { + okBtn.click(); + const closed = await waitFor(() => !doc.querySelector('#modal-root .modal'), 12000); + check('保存后弹窗关闭', closed); + await sleep(600); + const apiErr = apiErrors.slice(apiBeforeWrite); + check('新增学生无后端报错', apiErr.length === 0, apiErr.join(' | ').slice(0, 200)); + + // 在列表里找出来 + await win.eval(`goto('students')`); + await waitFor(() => doc.querySelector('#view table'), 10000); + const searchInput = doc.querySelector('#f-kw'); + searchInput.value = '自检临时生'; + doc.querySelector('#f-search').click(); + await sleep(900); + const rows = Array.from(doc.querySelectorAll('#view tbody tr')); + const hit = rows.find((r) => r.textContent.includes('自检临时生')); + check('新增的学生能在列表里按姓名查到', !!hit, hit ? text(hit).slice(0, 100) : `${rows.length} 行无匹配`); + if (hit) { + const noMatch = text(hit).match(/(WL|ZK|XL)?[A-Z]*\d{4,}/); + createdNo = noMatch ? noMatch[0] : null; + check('新增学生自动生成学号', !!createdNo, createdNo || '未识别到学号'); + + // 删除,保持数据干净 + const delBtn = hit.querySelector('[data-del]'); + createdId = delBtn ? delBtn.dataset.del : null; + check('列表行带删除操作', !!delBtn); + if (delBtn) { + delBtn.click(); + const confirmReady = await waitFor(() => doc.querySelector('#modal-root .modal-foot [data-ok]')); + const yes = confirmReady ? doc.querySelector('#modal-root .modal-foot [data-ok]') : null; + check('删除前弹出二次确认', !!yes, yes ? text(yes) : ''); + if (yes) { + yes.click(); + await sleep(1200); + await win.eval(`goto('students')`); + await waitFor(() => doc.querySelector('#view table'), 10000); + const si2 = doc.querySelector('#f-kw'); + si2.value = '自检临时生'; + doc.querySelector('#f-search').click(); + await sleep(900); + const left = Array.from(doc.querySelectorAll('#view tbody tr')).filter((r) => r.textContent.includes('自检临时生')); + check('删除后列表中不再出现(数据已清理)', left.length === 0, left.length + ' 行残留'); + } + } + } + } + } + + section('交互:成绩红线预警筛选'); + await win.eval(`goto('scores')`); + await waitFor(() => doc.querySelector('#c-flag'), 10000); + const flagSel = doc.querySelector('#c-flag'); + check('成绩页有「是否预警」筛选控件', !!flagSel); + if (flagSel) { + const apiBefore2 = apiErrors.length; + flagSel.value = '1'; + doc.querySelector('#c-search').click(); + await sleep(900); + const rows = Array.from(doc.querySelectorAll('#view tbody tr')).map(text); + const t = text(doc.querySelector('#view')); + check('筛选红线预警无报错', !t.includes('加载失败') && apiErrors.length === apiBefore2, + apiErrors.slice(apiBefore2).join(' | ').slice(0, 150)); + check('预警筛选结果都带"需重点关注"标记', + rows.length === 0 || rows.every((r) => r.includes('需重点关注')), + rows.length ? rows.length + ' 行' : '0 行(无预警数据,可接受)'); + // 筛选出 0 行时,至少要有空态提示,不能让用户面对空白 + if (rows.length === 0) check('预警筛选空结果有空态文案', /暂无/.test(t), t.slice(0, 80)); + flagSel.value = ''; + doc.querySelector('#c-search').click(); + await sleep(800); + } + + section('交互:Excel 导入弹窗'); + await win.eval(`goto('students')`); + await waitFor(() => doc.querySelector('#btn-import'), 10000); + doc.querySelector('#btn-import').click(); + const impModal = await waitFor(() => doc.querySelector('#modal-root .modal')); + check('Excel 导入弹窗可打开', !!impModal); + if (impModal) { + const t = text(doc.querySelector('#modal-root')); + check('导入弹窗说明了必需列', /模板|表头|列/.test(t), t.slice(0, 110)); + const cancel = doc.querySelector('#modal-root .modal-foot [data-close]'); + if (cancel) cancel.click(); + await sleep(300); + } + + section('交互:统计分析 10 个标签页'); + await win.eval(`goto('statistics')`); + await waitFor(() => doc.querySelector('#st-tabs'), 10000); + const tabs = Array.from(doc.querySelectorAll('#st-tabs .tab')); + check('统计分析渲染 10 个标签页', tabs.length === 10, tabs.length + ' 个'); + + const tabResults = []; + for (const tab of tabs) { + const apiBefore3 = apiErrors.length; + const label = text(tab); + tab.click(); + await sleep(800); + const body = text(doc.querySelector('#st-body')); + const bad = apiErrors.slice(apiBefore3); + tabResults.push({ label, len: body.length, bad }); + check(`统计-${label} 渲染出内容且无后端报错`, body.length > 10 && bad.length === 0, + bad.length ? bad.join(' | ').slice(0, 140) : body.length + ' 字'); + } + + section('交互:高级筛选规则引擎(真实提交)'); + await win.eval(`goto('advanced')`); + await waitFor(() => doc.querySelector('#adv-run'), 10000); + await sleep(600); + check('高级筛选页渲染无异常', !text(doc.querySelector('#view')).includes('加载失败')); + + const selects = Array.from(doc.querySelectorAll('#view select')); + check('高级筛选有字段与操作符下拉', selects.length >= 2, selects.length + ' 个 select'); + const ruleFields = doc.querySelectorAll('#adv-builder select').length; + check('规则构建器渲染出条件行', ruleFields >= 2, ruleFields + ' 个下拉'); + + // 1) 默认条件直接执行 + const apiA = apiErrors.length; + doc.querySelector('#adv-res').innerHTML = ''; + doc.querySelector('#adv-run').click(); + await waitFor(() => doc.querySelector('#adv-res .card'), 12000); + await sleep(400); + check('默认条件执行无后端报错', apiErrors.length === apiA, apiErrors.slice(apiA).join(' | ').slice(0, 180)); + check('执行后展示了结果卡片', !!doc.querySelector('#adv-res .card')); + const resTxt = text(doc.querySelector('#adv-res')); + check('结果区展示了命中数与 SQL', /命中\s*\d+\s*条/.test(resTxt) && /SELECT/i.test(resTxt), + resTxt.slice(0, 100)); + const hitA = Number((resTxt.match(/命中\s*(\d+)\s*条/) || [])[1]); + check('默认条件 age>25 命中条数可达', Number.isFinite(hitA), '命中 ' + hitA); + + // 2) 需求文档里的嵌套 AND/OR 示例 + doc.querySelector('#adv-demo').click(); + await sleep(300); + const nested = doc.querySelectorAll('#adv-builder [data-group]').length; + check('「载入需求文档示例」产出嵌套 OR 分组', nested >= 2, nested + ' 个分组'); + const apiB = apiErrors.length; + doc.querySelector('#adv-res').innerHTML = ''; // 关键:清空,否则会读到上一轮的残留 + doc.querySelector('#adv-run').click(); + const repainted = await waitFor(() => doc.querySelector('#adv-res .card'), 12000); + await sleep(500); + check('嵌套示例结果区已重新渲染', repainted); + check('嵌套 AND/OR 示例执行无后端报错', apiErrors.length === apiB, + apiErrors.slice(apiB).join(' | ').slice(0, 180)); + + const nestedTxt = text(doc.querySelector('#adv-res')); + const hitB = Number((nestedTxt.match(/命中\s*(\d+)\s*条/) || [])[1]); + check('嵌套示例返回了命中条数', Number.isFinite(hitB), '命中 ' + hitB); + // 语义校验:加了「性别=男 且 (薪资≥15000 或 班级含Java)」后,命中数必须比单独 age>25 更少 + check('嵌套条件确实收紧了结果(命中数 < 单条件命中数)', + Number.isFinite(hitA) && Number.isFinite(hitB) && hitB < hitA, + `age>25 命中 ${hitA} → 加 AND/OR 组合后命中 ${hitB}`); + + // 与后端独立对账:同样的规则直接打接口,看条数是否一致 + const direct = await win.fetch(BASE + '/advanced/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + win.localStorage.getItem('wolin_token') }, + body: JSON.stringify({ + model: 'student', + rules: [ + { field: 'age', operator: '>', value: 25 }, + { field: 'gender', operator: '=', value: '男' }, + { logic: 'OR', sub_rules: [ + { field: 'salary', operator: '>=', value: 15000 }, + { field: 'class_name', operator: 'like', value: 'Java' }, + ] }, + ], + fields: ['stu_no', 'name', 'age', 'gender', 'salary', 'class_name'], + page: 1, page_size: 5, + }), + }).then((r) => r.json()); + check('同一规则直连接口返回成功', direct.code === 0, 'code=' + direct.code + ' ' + String(direct.msg).slice(0, 80)); + if (direct.code === 0) { + check('UI 命中数与接口 total 一致', direct.data.total === hitB, + `UI ${hitB} vs 接口 ${direct.data.total}`); + const items = direct.data.items || []; + // 注意:接口里 gender 是 int 代号(1=男),不是中文 + const badRows = items.filter((r) => !(r.age > 25 && r.gender === 1 + && (Number(r.salary) >= 15000 || String(r.class_name || '').includes('Java')))); + check('返回的每一行都真实满足全部条件(含嵌套 OR)', badRows.length === 0, + badRows.length ? JSON.stringify(badRows[0]) : `${items.length} 行全部合规(age>25 且 性别=男 且 (薪资≥15000 或 班级含Java))`); + check('返回的字段就是请求的字段', JSON.stringify(direct.data.fields) === + JSON.stringify(['stu_no', 'name', 'age', 'gender', 'salary', 'class_name']), + JSON.stringify(direct.data.fields)); + + // UI 侧:结果表里「性别」必须是中文,不能把 1/2 直接摆给用户看 + const uiCols = Array.from(doc.querySelectorAll('#adv-res thead th')).map(text); + const genderIdx = uiCols.findIndex((c) => c === '性别'); + check('结果表存在「性别」列', genderIdx >= 0, uiCols.slice(0, 12).join(' / ')); + if (genderIdx >= 0) { + const cells = Array.from(doc.querySelectorAll('#adv-res tbody tr')) + .map((tr) => text(tr.children[genderIdx])); + check('「性别」列已还原成中文(非 1/2 代号)', + cells.length > 0 && cells.every((c) => c === '男' || c === '女' || c === '—'), + cells.join(' / ') || '无数据行'); + } + // UI 命中条数应当与接口一致 + const uiHit = Number((text(doc.querySelector('#adv-res')).match(/命中\s*(\d+)\s*条/) || [])[1]); + check('UI 展示的命中数与接口 total 一致', uiHit === direct.data.total, + `UI ${uiHit} vs 接口 ${direct.data.total}`); + } + + // 3) 聚合标签页 + const advTabs = Array.from(doc.querySelectorAll('#view .tab')); + check('高级筛选有分组聚合入口', advTabs.length >= 2, advTabs.length + ' 个标签'); + const aggTab = advTabs.find((t) => /聚合/.test(t.textContent)); + if (aggTab) { + aggTab.click(); + await sleep(600); + const aggRun = Array.from(doc.querySelectorAll('#view button')).find((b) => /执行|分组|聚合/.test(b.textContent)); + check('聚合页有执行按钮', !!aggRun, aggRun ? text(aggRun) : ''); + if (aggRun) { + const apiC = apiErrors.length; + aggRun.click(); + await sleep(1800); + check('聚合执行无后端报错', apiErrors.length === apiC, apiErrors.slice(apiC).join(' | ').slice(0, 180)); + } + } + + section('退出登录'); + doc.querySelector('#btn-logout').click(); + await sleep(400); + check('退出后回到登录页', + doc.querySelector('#login').style.display === 'flex' && doc.querySelector('#app').style.display === 'none', + `login=${doc.querySelector('#login').style.display} app=${doc.querySelector('#app').style.display}`); + check('退出后本地 token 已清除', !win.localStorage.getItem('wolin_token')); + check('退出后只读标记一并收起', doc.querySelector('#ro-badge').style.display === 'none', + 'ro-badge.display=' + JSON.stringify(doc.querySelector('#ro-badge').style.display)); + + // ================================================================ + // 只读账号走查:这是本轮新增的核心场景。 + // 起因是走查时发现 viewer 登录后连一条数据都读不出来(所有读接口都错挂在 + // WriteAccount 上,返回 403)。修完之后必须有人盯着「只读用户到底能不能 + // 正常用」,否则同类问题还会再犯。 + // + // 这里断言的不是「按钮有 w-act 这个类」,而是 getComputedStyle 算出来的 + // display —— 类名只是手段,用户看不见才是目的。只想类名的话,CSS 规则被 + // 写错或漏掉时测试照样绿。 + // ================================================================ + section('只读账号(viewer)走查'); + // jsdom 对 外部样式表是异步加载的,不等它算出来的样式是空的 + const cssReady = await waitFor(() => { + for (const sheet of Array.from(doc.styleSheets)) { + try { + const rules = sheet.cssRules; + if (rules && Array.from(rules).some((r) => r.selectorText === '.readonly-mode .w-act')) return true; + } catch (e) { /* 跨域表跳过 */ } + } + return false; + }, 10000); + check('外部样式表加载完成(能读到 .readonly-mode .w-act 规则)', cssReady); + + const displayOf = (el) => (el ? win.getComputedStyle(el).display : 'MISSING'); + // 关键:元素"根本不存在"绝不能算成"不可见"。 + // 之前这里只判 `display !== 'none'`,而查不到元素时 displayOf 返回 'MISSING', + // 于是页面渲染失败的场景反倒让"写入口已隐藏"通过了 —— 最该红的时候绿了。 + // 所以凡是断言"藏起来了",都必须先要求元素确实存在。 + const hidden = (el) => !!el && win.getComputedStyle(el).display === 'none'; + const shown = (el) => !!el && win.getComputedStyle(el).display !== 'none'; + + loginInput.value = 'viewer'; + passInput.value = 'viewer123'; + loginBtn.click(); + const vEntered = await waitFor(() => doc.querySelector('#app').style.display === 'flex'); + check('viewer 账号能登录进主界面', vEntered); + if (vEntered) { + await waitFor(() => text(doc.querySelector('#view')).length > 10); + check('顶栏身份显示为「只读」', /只读/.test(text(doc.querySelector('#user-name'))), + text(doc.querySelector('#user-name'))); + check('顶栏亮出「只读模式」标记', shown(doc.querySelector('#ro-badge')), + 'display=' + displayOf(doc.querySelector('#ro-badge'))); + check('根节点带上 readonly-mode', doc.querySelector('#app').classList.contains('readonly-mode')); + + // ---- 读能力:只读账号走遍所有页面,一次 403 都不该撞到 ---- + const p403 = () => apiErrors.filter((e) => /code=403/.test(e)); + const v403Before = p403().length; + for (const key of PAGES) { + if (key === 'students' || key === 'scores' || key === 'classes') continue; // 下面单独细看 + await win.eval(`goto('${key}')`); + await waitFor(() => !text(doc.querySelector('#view')).startsWith('加载中'), 10000); + await sleep(150); + const t = text(doc.querySelector('#view')); + check(`只读-页面 ${key} 正常渲染出内容`, t.length > 20 && !t.includes('加载失败'), t.length + ' 字'); + } + check('只读账号浏览各页期间未触发任何 403', p403().length === v403Before, + p403().slice(v403Before).join(' | ').slice(0, 200)); + + // ---- 写入口必须不可见 ---- + await win.eval(`goto('students')`); + await waitFor(() => doc.querySelector('#view table'), 10000); + await sleep(200); + + // 顺带回归另一个走查撞出来的真 bug:三个列表的筛选条件是模块级状态, + // 会跨登出活下来。管理员上一轮按「自检临时生」筛过学生,若不复位, + // viewer 登进来看到的是一个空列表 + 一个自己从没输过的关键词。 + check('只读-学生页筛选条件未被上一会话残留污染', + doc.querySelector('#f-kw').value === '', + '关键词框=' + JSON.stringify(doc.querySelector('#f-kw').value)); + check('只读-学生页默认就能看到数据(未被残留条件筛成空表)', + doc.querySelectorAll('#stu-table tbody tr').length > 0, + doc.querySelectorAll('#stu-table tbody tr').length + ' 行'); + + check('只读-学生页「新增学生」按钮不可见', hidden(doc.querySelector('#btn-add')), + 'display=' + displayOf(doc.querySelector('#btn-add'))); + check('只读-学生页「Excel 导入」按钮不可见', hidden(doc.querySelector('#btn-import')), + 'display=' + displayOf(doc.querySelector('#btn-import'))); + + // 按钮仍然在 DOM 里(只是被 CSS 藏了),因此断言"不存在"是错的,必须断言"不可见" + check('只读-写按钮仍在 DOM 中但有 w-act 标记', + doc.querySelector('#btn-add').classList.contains('w-act'), + 'class=' + doc.querySelector('#btn-add').className); + + check('只读-学生行「编辑」不可见', hidden(doc.querySelector('[data-edit]')), + 'display=' + displayOf(doc.querySelector('[data-edit]'))); + check('只读-学生行「录成绩」不可见', hidden(doc.querySelector('[data-score]')), + 'display=' + displayOf(doc.querySelector('[data-score]'))); + check('只读-学生行「删除」不可见', hidden(doc.querySelector('[data-del]')), + 'display=' + displayOf(doc.querySelector('[data-del]'))); + + // 这几条是防「一刀切隐藏整列」的回归 —— 曾经差点把只读用户唯一能用的「详情」也藏掉 + check('只读-学生行「详情」仍然可见', shown(doc.querySelector('[data-detail]')), + 'display=' + displayOf(doc.querySelector('[data-detail]'))); + check('只读-姓名列的可点链接仍然可见', shown(doc.querySelector('#view tbody [data-detail]')), + 'display=' + displayOf(doc.querySelector('#view tbody [data-detail]'))); + + // 真的点一下,证明只读用户的查看功能是通的,不是"看起来有个链接" + const detailLink = doc.querySelector('#view tbody [data-detail]'); + if (detailLink) { + const v403b2 = p403().length; + detailLink.click(); + const detailOpen = await waitFor(() => doc.querySelector('#modal-root .modal'), 10000); + check('只读-点「详情」能打开学生详情弹窗', detailOpen); + if (detailOpen) { + const dt = text(doc.querySelector('#modal-root')); + check('只读-详情弹窗里能看到成绩与就业信息', /考核成绩/.test(dt) && /就业/.test(dt), dt.slice(0, 120)); + const closeBtn = doc.querySelector('#modal-root .modal-foot [data-close]'); + if (closeBtn) closeBtn.click(); + await sleep(300); + } + check('只读-查看详情未触发 403', p403().length === v403b2, + p403().slice(v403b2).join(' | ').slice(0, 160)); + } + + // ---- 全是写链接的「操作」列:整列(含表头)一起隐藏,不留空壳 ---- + await win.eval(`goto('scores')`); + await waitFor(() => doc.querySelector('#view table'), 10000); + await sleep(200); + check('只读-成绩页「录入成绩」按钮不可见', hidden(doc.querySelector('#c-add')), + 'display=' + displayOf(doc.querySelector('#c-add'))); + const scoreOpTh = Array.from(doc.querySelectorAll('#view thead th')) + .find((th) => th.classList.contains('w-act') && text(th) === '操作'); + check('只读-成绩页「操作」列表头一并隐藏(不留空列)', hidden(scoreOpTh), + scoreOpTh ? 'display=' + displayOf(scoreOpTh) : '未找到 w-act 表头'); + check('只读-成绩页其余列仍然可见(别把整张表藏了)', + shown(doc.querySelector('#view thead th')), + '首列表头 display=' + displayOf(doc.querySelector('#view thead th'))); + + await win.eval(`goto('classes')`); + await waitFor(() => doc.querySelector('#view table'), 10000); + await sleep(200); + check('只读-班级页「新增班级」按钮不可见', hidden(doc.querySelector('#k-add')), + 'display=' + displayOf(doc.querySelector('#k-add'))); + + // ---- 高级筛选是 POST,但语义是「读」,只读账号必须能用 ---- + await win.eval(`goto('advanced')`); + await waitFor(() => doc.querySelector('#adv-run'), 10000); + await sleep(400); + // 先把"页面到底渲出来没有"单独断言一次。不这么写的话,下面那句 + // `#adv-res` 一旦为 null 就会把整个脚本崩掉,失败原因还查不到。 + check('只读-高级筛选页渲染出规则构建器与结果区', + !!doc.querySelector('#adv-run') && !!doc.querySelector('#adv-res'), + 'view=' + text(doc.querySelector('#view')).slice(0, 120)); + // 上一会话(管理员)切到过「分组聚合」,标签状态同样是模块级的; + // 不复位的话,下一个登录的人一进来就停在聚合页,而他是来写筛选规则的。 + check('只读-高级筛选默认停在「通用筛选器」标签(未被上一会话的标签残留带走)', + /通用筛选器/.test(text(doc.querySelector('#view .tab.active'))), + '当前标签=' + text(doc.querySelector('#view .tab.active'))); + if (doc.querySelector('#adv-res')) { + const v403b3 = p403().length; + doc.querySelector('#adv-res').innerHTML = ''; + doc.querySelector('#adv-run').click(); + const advRendered = await waitFor(() => doc.querySelector('#adv-res .card'), 12000); + check('只读-高级筛选(POST 读接口)可用', advRendered); + check('只读-高级筛选未触发 403', p403().length === v403b3, + p403().slice(v403b3).join(' | ').slice(0, 160)); + } + + // ---- 后端仍然兜得住:直接绕过 UI 发写请求必须被拒 ---- + const vtok = win.localStorage.getItem('wolin_token'); + const bypass = await win.fetch(BASE + '/students', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + vtok }, + body: JSON.stringify({ name: '只读越权探针', gender: 1 }), + }).then((r) => r.json()); + check('只读-绕过 UI 直接发写请求被后端拒绝', bypass.code === 403, + 'code=' + bypass.code + ' ' + String(bypass.msg).slice(0, 80)); + const readProbe = await win.fetch(BASE + '/statistics/overview', { + headers: { Authorization: 'Bearer ' + vtok }, + }).then((r) => r.json()); + check('只读-同一 token 读接口仍然放行', readProbe.code === 0, 'code=' + readProbe.code); + } + + section('运行期 JS 异常汇总'); + check('全程无未捕获 JS 异常', errors.length === 0, errors.slice(0, 4).join(' | ')); + + console.log(`\n${'='.repeat(60)}`); + console.log(`前端验证结果:通过 ${PASS.length} 项,失败 ${FAIL.length} 项`); + if (FAIL.length) { console.log('\n失败明细:'); FAIL.forEach((f, i) => console.log(` ${i + 1}. ${f}`)); } + if (apiErrors.length) { + console.log(`\n过程中出现过的后端非零返回(含预期内的错误提示用例):`); + Array.from(new Set(apiErrors)).slice(0, 12).forEach((e) => console.log(' - ' + e)); + } + win.close(); + process.exit(FAIL.length ? 1 : 0); +})().catch((e) => { console.error('验证脚本自身崩溃:', e); process.exit(2); }); diff --git a/verify/perm_check.py b/verify/perm_check.py new file mode 100644 index 0000000..2c7809f --- /dev/null +++ b/verify/perm_check.py @@ -0,0 +1,298 @@ +"""只读账号 / 管理员的权限边界对账。 + +做三件事: +1. 用 viewer 登录,把「读接口」逐个打一遍 —— 必须全部 code=0。 +2. 用 admin 登录,同样的读接口再打一遍 —— 结果必须与 viewer 逐项一致, + 以此证明只读账号没有被削掉任何读取能力。 + (只读 bug 的典型形态是「某人少了一个口子」,只有拿两个角色横向对比才看得出来。) +3. 用 viewer 打所有写接口 —— 必须全部被拒,且拒绝理由必须是 403(权限), + 不能是 404/405 这种「路由压根不存在」的巧合。 + +设计上的两个讲究: +- 所有业务 ID 都在运行时从列表接口里现取,不写死 1 号。硬编码 ID 一旦数据库 + 重新播种就会全线 404,届时看起来像权限炸了,实际只是测试自己过期了。 +- 写接口的 body 一律填足必要字段。若因缺参数返回 422,虽然也是「被拒」, + 但那证明不了权限生效 —— 所以额外断言「必须是 403」。 + +用法:python verify/perm_check.py [--base http://127.0.0.1:8010] +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +BASE = "http://127.0.0.1:8010" + +PROBE_NAME = "权限对账临时顾问" + +passed = 0 +failed: list[str] = [] + + +def purge_probe() -> None: + """收尾:把本脚本造的探针尸体物理删掉。 + + 第 4 节的写权限验证真的建了一条顾问记录(否则证明不了 admin 没被门禁拦住), + 然后走接口把它删了 —— 但接口删的是逻辑删除,每跑一次就多留一行 is_del=1。 + 演示库是给人看的,不能越跑越脏,所以这里复用 purge_selftest 的清理逻辑硬删。 + """ + try: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import purge_selftest # noqa: PLC0415 + + findings = purge_selftest.collect() + deleted = purge_selftest.purge(findings) + n = sum(deleted.values()) + print(f" [清理] 探针数据已物理删除 {n} 行" if n else " [清理] 无探针残留") + except Exception as exc: # noqa: BLE001 + print(f" [WARN] 探针清理失败(不影响权限结论):{exc}") + + +def call(method: str, path: str, token: str, body: dict | None = None) -> tuple[int, dict]: + url = BASE + path + data = None + headers = {"Authorization": f"Bearer {token}"} + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + return e.code, json.loads(raw) + except Exception: + return e.code, {"raw": raw[:200]} + except Exception as exc: # noqa: BLE001 + return -1, {"raw": f"{type(exc).__name__}: {exc}"} + + +def check(cond: bool, label: str, extra: str = "") -> None: + global passed + if cond: + passed += 1 + print(f" [OK] {label}") + else: + failed.append(label) + print(f" [FAIL] {label} {extra}") + + +def call_raw(method: str, path: str, token: str) -> tuple[int, bytes, str]: + """打二进制接口(比如 xlsx 模板下载),返回 (状态码, 原始字节, content-type)。""" + req = urllib.request.Request(BASE + path, headers={"Authorization": f"Bearer {token}"}, method=method) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + return resp.status, resp.read(), resp.headers.get("content-type", "") + except urllib.error.HTTPError as e: + return e.code, e.read(), e.headers.get("content-type", "") + except Exception as exc: # noqa: BLE001 + return -1, str(exc).encode(), "" + + +def login(username: str, password: str) -> str: + status, body = call("POST", "/auth/login", "", {"username": username, "password": password}) + if status != 200 or body.get("code") != 0: + print(f"登录失败 {username}: HTTP {status} {body}") + sys.exit(1) + return body["data"]["access_token"] + + +def first_id(token: str, path: str, id_key: str = "id") -> int | None: + status, body = call("GET", path, token) + data = body.get("data") or {} + items = data.get("items") if isinstance(data, dict) else data + if not items: + return None + row = items[0] + return row.get(id_key) + + +def main() -> int: + global BASE + ap = argparse.ArgumentParser() + ap.add_argument("--base", default=BASE) + args = ap.parse_args() + BASE = args.base + + print("=" * 74) + print(f"权限边界对账 @ {BASE}") + print("=" * 74) + + vtoken = login("viewer", "viewer123") + atoken = login("admin", "admin123") + + # ---------- 运行时取真实 ID(用 admin,它权限最全)---------- + sid = first_id(atoken, "/students?page=1&page_size=1") + cid = first_id(atoken, "/classes?page=1&page_size=1") + tid = first_id(atoken, "/teachers?page=1&page_size=1") + aid = first_id(atoken, "/advisors?page=1&page_size=1") + eid = first_id(atoken, "/employments?page=1&page_size=1") + scid = first_id(atoken, "/scores?page=1&page_size=1") + print(f"\n运行时取样 ID:student={sid} class={cid} teacher={tid} advisor={aid} employment={eid} score={scid}") + missing = [n for n, v in [("student", sid), ("class", cid), ("teacher", tid), ("advisor", aid), ("employment", eid), ("score", scid)] if v is None] + if missing: + print(f"数据库缺少样本数据:{missing} —— 请先跑 app/scripts/seed_data.py 再对账。") + return 2 + + # ---------- 读接口清单(按真实路由表)---------- + read_targets: list[tuple[str, str, str]] = [ + ("学生列表", "GET", "/students?page=1&page_size=5"), + ("学生下拉", "GET", "/students/meta/options"), + ("学生详情", "GET", f"/students/{sid}"), + ("学生成绩明细", "GET", f"/students/{sid}/scores"), + ("成绩列表", "GET", "/scores?page=1&page_size=5"), + ("成绩口径", "GET", "/scores/meta"), + ("某生成绩汇总", "GET", f"/scores/student/{sid}"), + ("红线名单", "GET", "/scores/warnings"), + ("班级列表", "GET", "/classes?page=1&page_size=5"), + ("班级下拉", "GET", "/classes/meta/options"), + ("班级详情", "GET", f"/classes/{cid}"), + ("老师列表", "GET", "/teachers?page=1&page_size=5"), + ("老师详情", "GET", f"/teachers/{tid}"), + ("顾问列表", "GET", "/advisors?page=1&page_size=5"), + ("顾问详情", "GET", f"/advisors/{aid}"), + ("就业列表", "GET", "/employments?page=1&page_size=5"), + ("某生就业", "GET", f"/employments/student/{sid}"), + ("某班就业", "GET", f"/employments/class/{cid}"), + ("总览", "GET", "/statistics/overview"), + ("班级概览", "GET", "/statistics/classes/overview"), + ("年龄分布", "GET", "/statistics/students/age-distribution"), + ("按年龄筛", "GET", "/statistics/students/by-age?op=gt&value=20"), + ("班级均分", "GET", "/statistics/scores/class-average"), + ("班级均分排序", "GET", "/statistics/scores/class-average?order=desc"), + ("全科达标", "GET", "/statistics/scores/all-above"), + ("多次不及格", "GET", "/statistics/scores/failures?min_times=2"), + ("成绩波动", "GET", "/statistics/scores/volatility?top=5"), + ("就业时长", "GET", "/statistics/employment/durations"), + ("班级均时长", "GET", "/statistics/employment/class-avg-duration"), + ("薪资 TopN", "GET", "/statistics/employment/top-salary?top=5"), + ("就业漏斗", "GET", "/statistics/employment/funnel"), + ("规则字段", "GET", "/advanced/meta"), + ("规则示例", "GET", "/advanced/examples"), + ("健康检查", "GET", "/api/health"), + ("[兼容]某生就业", "GET", f"/employment/students/{sid}"), + ("[兼容]某班就业", "GET", f"/employment/class/{cid}"), + ] + + # POST 但语义是「读」的接口,必须与上表同等对待(viewer 要能用) + read_post: list[tuple[str, str, dict]] = [ + ("高级查询", "/advanced/query", {"model": "student", "rules": [], "page": 1, "page_size": 5}), + ("高级聚合", "/advanced/aggregate", {"model": "student", "group_by": ["class_id"], "metrics": [{"func": "count", "alias": "人数"}]}), + ] + + write_targets: list[tuple[str, str, str, dict | None]] = [ + ("新增学生", "POST", "/students", {"name": "权限探针", "gender": 1}), + ("改学生", "PUT", f"/students/{sid}", {"name": "权限探针"}), + ("删学生", "DELETE", f"/students/{sid}", None), + ("恢复学生", "POST", f"/students/{sid}/restore", None), + ("批量删学生", "POST", "/students/batch/delete", {"ids": [sid]}), + ("导入学生", "POST", "/students/import", None), + ("录成绩", "POST", "/scores", {"stu_id": sid, "exam_seq": 99, "score": 60}), + ("批量录成绩", "POST", "/scores/batch", {"items": [{"stu_id": sid, "exam_seq": 99, "score": 60}]}), + ("改成绩", "PUT", f"/scores/{scid}", {"score": 61}), + ("删成绩", "DELETE", f"/scores/{scid}", None), + ("清空成绩", "POST", f"/scores/student/{sid}/clear", None), + ("新增班级", "POST", "/classes", {"name": "权限探针班"}), + ("改班级", "PUT", f"/classes/{cid}", {"name": "权限探针班"}), + ("删班级", "DELETE", f"/classes/{cid}", None), + ("新增老师", "POST", "/teachers", {"name": "权限探针师"}), + ("改老师", "PUT", f"/teachers/{tid}", {"name": "权限探针师"}), + ("删老师", "DELETE", f"/teachers/{tid}", None), + ("新增顾问", "POST", "/advisors", {"name": "权限探针顾"}), + ("改顾问", "PUT", f"/advisors/{aid}", {"name": "权限探针顾"}), + ("删顾问", "DELETE", f"/advisors/{aid}", None), + ("登记就业", "POST", "/employments", {"stu_id": sid, "open_date": "2024-01-01"}), + ("改就业", "PUT", f"/employments/{eid}", {"city": "深圳"}), + ("删就业", "DELETE", f"/employments/{eid}", None), + ("[兼容]加就业", "POST", f"/employment/students/{sid}", {"city": "深圳"}), + ("新增账号", "POST", "/auth/accounts", {"username": "permprobe", "password": "permprobe1", "real_name": "权限探针"}), + ] + + # ---------- 1. viewer 读 ---------- + print(f"\n--- 1. viewer 读权限({len(read_targets)} 个 GET + {len(read_post)} 个读语义 POST,应全部 code=0)---") + v_read_ok: dict[str, bool] = {} + for name, method, path in read_targets: + status, body = call(method, path, vtoken) + good = status == 200 and body.get("code") == 0 + v_read_ok[name] = good + check(good, f"viewer 读 {name}", f"→ HTTP {status} {str(body)[:130]}") + for name, path, payload in read_post: + status, body = call("POST", path, vtoken, payload) + good = status == 200 and body.get("code") == 0 + v_read_ok[name] = good + check(good, f"viewer 读 {name}", f"→ HTTP {status} {str(body)[:150]}") + + # ---------- 2. admin 读(逐项一致性)---------- + print(f"\n--- 2. admin 读权限(与 viewer 逐项一致)---") + for name, method, path in read_targets: + status, body = call(method, path, atoken) + good = status == 200 and body.get("code") == 0 + check(good, f"admin 读 {name}", f"→ HTTP {status} {str(body)[:130]}") + check(good == v_read_ok[name], f"admin/viewer 一致 · {name}", f"→ admin={good} viewer={v_read_ok[name]}") + for name, path, payload in read_post: + status, body = call("POST", path, atoken, payload) + good = status == 200 and body.get("code") == 0 + check(good, f"admin 读 {name}", f"→ HTTP {status} {str(body)[:150]}") + check(good == v_read_ok[name], f"admin/viewer 一致 · {name}", f"→ admin={good} viewer={v_read_ok[name]}") + + # ---------- 2b. 二进制下载接口(xlsx 模板)---------- + print("\n--- 2b. 导入模板下载(xlsx 二进制,避开 JSON 解析)---") + for who, tok in [("viewer", vtoken), ("admin", atoken)]: + status, raw, ctype = call_raw("GET", "/students/import/template", tok) + is_xlsx = raw[:2] == b"PK" # xlsx 本质是 zip,魔数 PK + check(status == 200 and is_xlsx, f"{who} 能下载导入模板(xlsx)", f"→ HTTP {status} type={ctype} len={len(raw)}") + + # ---------- 3. viewer 写(必须 403)---------- + print(f"\n--- 3. viewer 写权限({len(write_targets)} 个写接口,必须 403)---") + for name, method, path, payload in write_targets: + status, body = call(method, path, vtoken, payload) + code = body.get("code") + is403 = status == 403 or code == 403 + check(is403, f"viewer 写 {name} → 403", f"→ HTTP {status} code={code} {str(body)[:110]}") + + # ---------- 4. admin 写(抽样放行 + 回滚)---------- + print("\n--- 4. admin 写权限(抽样放行,跑完回滚)---") + status, body = call("POST", "/advisors", atoken, {"name": PROBE_NAME}) + made = body.get("code") == 0 + check(made, "admin 能新增顾问", f"→ HTTP {status} {str(body)[:150]}") + if made: + probe_id = body["data"]["id"] + status, body = call("DELETE", f"/advisors/{probe_id}", atoken) + check(body.get("code") == 0, "admin 能删除刚建的顾问(逻辑删除)", f"→ {str(body)[:150]}") + + # ---------- 5. 管理员专属 ---------- + print("\n--- 5. 管理员专属接口边界 ---") + status, body = call("GET", "/auth/accounts", vtoken) + check(body.get("code") == 403, "viewer 读账号列表 → 403", f"→ HTTP {status} {str(body)[:120]}") + status, body = call("GET", "/auth/accounts", atoken) + check(body.get("code") == 0, "admin 读账号列表 → 放行", f"→ HTTP {status} {str(body)[:120]}") + + # 未登录也不该漏 + print("\n--- 6. 无 token 访问 ---") + for name, method, path in [("学生列表", "GET", "/students?page=1&page_size=1"), ("总览", "GET", "/statistics/overview")]: + status, body = call(method, path, "") + check(body.get("code") == 401, f"无 token 读 {name} → 401", f"→ HTTP {status} {str(body)[:120]}") + + print("\n--- 7. 收尾清理 ---") + purge_probe() + + print("\n" + "=" * 74) + total = passed + len(failed) + print(f"结果:{passed}/{total} 通过") + if failed: + print("失败项:") + for f in failed: + print(" -", f) + print("=" * 74) + return 0 if not failed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/verify/purge_selftest.py b/verify/purge_selftest.py new file mode 100644 index 0000000..cb4057e --- /dev/null +++ b/verify/purge_selftest.py @@ -0,0 +1,116 @@ +"""清理验证脚本留下的「软删垃圾」。 + +背景:三个验证脚本为了证明功能真的能用,必须往库里写真实数据。 +它们跑完都会把数据删掉,但删的是**逻辑删除**(is_del=1)—— 这恰恰是 +被测功能的正确行为,不该为了测试去改它。代价是每跑一轮,表里就多几行 +名字带「自检 / 权限对账」的尸体。 + +这些尸体对业务没影响(所有查询都带 is_del=0),但演示库是要给人看的, +不该越跑越脏。所以单独开一个工具,把它们的**物理行**清掉。 + +默认干跑(只列不删),确认没问题再加 --yes。 + +用法: + python verify/purge_selftest.py # 干跑,看看会删什么 + python verify/purge_selftest.py --yes # 真的删 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +PROJECT_ROOT = str(Path(__file__).resolve().parent.parent) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + +from sqlalchemy import or_, select, text # noqa: E402 + +from app.core.database import engine # noqa: E402 +from app.model import Advisor, Clazz, Employment, Score, Student, Teacher # noqa: E402 + +# 只认验证脚本自己造的命名,宁可漏清也不能误伤真实数据 +NAME_PATTERNS = ["自检%", "%权限对账%", "%只读越权%", "%权限探针%"] + + +def _like(col): + return or_(*[col.like(p) for p in NAME_PATTERNS]) + + +def collect() -> dict[str, list[tuple[int, str]]]: + out: dict[str, list[tuple[int, str]]] = {} + with engine.connect() as conn: + for model in (Student, Clazz, Teacher, Advisor): + rows = conn.execute(select(model.id, model.name).where(_like(model.name))).all() + out[model.__tablename__] = [(r[0], r[1]) for r in rows] + return out + + +def purge(findings: dict[str, list[tuple[int, str]]]) -> dict[str, int]: + deleted: dict[str, int] = {} + with engine.begin() as conn: + stu_ids = [i for i, _ in findings.get("student", [])] + clazz_ids = [i for i, _ in findings.get("clazz", [])] + teacher_ids = [i for i, _ in findings.get("teacher", [])] + + if stu_ids: + # 先清依附于学生的行,再删学生本身,避免外键悬空 + deleted["score"] = _del_in(conn, "score", "stu_id", stu_ids) + deleted["employment"] = _del_in(conn, "employment", "stu_id", stu_ids) + if clazz_ids: + deleted["class_teachers(按班级)"] = _del_in(conn, "class_teachers", "class_id", clazz_ids) + if teacher_ids: + deleted["class_teachers(按老师)"] = _del_in(conn, "class_teachers", "teacher_id", teacher_ids) + + for table in ("student", "clazz", "teacher", "advisor"): + ids = [i for i, _ in findings.get(table, [])] + if ids: + deleted[table] = _del_in(conn, table, "id", ids) + return deleted + + +def _del_in(conn, table: str, col: str, ids: list[int]) -> int: + """按主键列表硬删。SQLAlchemy 的 IN 展开走 bindparam,避免手拼 SQL。""" + stmt = text(f"DELETE FROM {table} WHERE {col} IN :ids").bindparams(ids=tuple(ids)) + return conn.execute(stmt).rowcount + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--yes", action="store_true", help="真的执行删除(默认只干跑)") + args = ap.parse_args() + + findings = collect() + total = sum(len(v) for v in findings.values()) + + print("=" * 70) + print(f"扫描验证残留 @ {engine.url.render_as_string(hide_password=True)}") + print("=" * 70) + for table, rows in findings.items(): + if not rows: + continue + print(f"\n{table}({len(rows)} 条)") + for i, name in rows: + print(f" id={i:<6} {name}") + + if not total: + print("\n没有残留,库是干净的。") + return 0 + + print(f"\n合计 {total} 条待清理。") + if not args.yes: + print("这是干跑。确认无误后加 --yes 真删。") + return 0 + + deleted = purge(findings) + print("\n已物理删除:") + for table, n in deleted.items(): + if n: + print(f" {table:<24} {n} 行") + print("\n清理完成。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/verify/shots.js b/verify/shots.js new file mode 100644 index 0000000..2afdf2e --- /dev/null +++ b/verify/shots.js @@ -0,0 +1,141 @@ +/* 真实浏览器视觉验证:用本机 Edge 无头模式给每个页面截图。 + 做法是把 index.html 复制成变体(放回 static 目录,保证相对路径能打到后端), + 在 前注入「自动登录 + 等渲染」脚本,跳页交给应用自己的 hash 路由 + (enterApp 会读 location.hash)。 + + 为什么用 hash 路由而不是登录后再调一次 goto(): + enterApp 自己已经按 hash 跳了一次,自己再调一次就是两次导航并发。应用侧现在 + 有导航序号保护(慢的那次会自行作废),但让浏览器直接带着目标 hash 打开页面 + 只有一次导航,最干净,也顺便验证了 hash 深链是通的。 + + 哈希去重是必须的:只要跳页静默失效,所有截图会变成同一张(历史上真出现过 + 「11 张全是概览」),文件大小和分辨率都正常,光看清单看不出问题。 + + 用法:node verify/shots.js [base] [outDir] +*/ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { execFileSync } = require('child_process'); + +const BASE = process.argv[2] || 'http://127.0.0.1:8010'; +const ROOT = path.resolve(__dirname, '..'); +const STATIC = path.join(ROOT, 'app', 'static'); +const OUT = process.argv[3] || path.join(ROOT, 'verify', 'shots'); +const EDGE = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'; +const PROFILE = path.join(ROOT, 'verify', '_edge_profile'); + +const ACCOUNTS = { + admin: { user: 'admin', pass: 'admin123' }, + viewer: { user: 'viewer', pass: 'viewer123' }, +}; + +// page 为 null 表示登录页原样截图;account 省略时用 admin +const JOBS = [ + { name: 'login', page: null, label: '登录页' }, + { name: 'overview', page: 'overview', label: '概览' }, + { name: 'students', page: 'students', label: '学生管理' }, + { name: 'scores', page: 'scores', label: '成绩管理' }, + { name: 'employments', page: 'employments', label: '就业管理' }, + { name: 'classes', page: 'classes', label: '班级管理' }, + { name: 'teachers', page: 'teachers', label: '老师管理' }, + { name: 'advisors', page: 'advisors', label: '顾问管理' }, + { name: 'statistics', page: 'statistics', label: '统计分析' }, + { name: 'advanced', page: 'advanced', label: '高级筛选' }, + { name: 'students-modal', page: 'students', label: '新增学生弹窗', after: "document.querySelector('#btn-add').click();" }, + // 只读账号:写入口应当整片消失,只留「详情」这类读操作 + { name: 'viewer-students', page: 'students', label: '学生管理(只读)', account: 'viewer' }, + { name: 'viewer-scores', page: 'scores', label: '成绩管理(只读)', account: 'viewer' }, +]; + +function inject(html, job) { + if (job.page === null) return html; // 登录页:原样 + const acc = ACCOUNTS[job.account || 'admin']; + const after = job.after || ''; + const auto = ``; + return html.replace('', auto + '\n'); +} + +function pngInfo(file) { + const buf = fs.readFileSync(file); + return { + w: buf.readUInt32BE(16), + h: buf.readUInt32BE(20), + bytes: buf.length, + md5: crypto.createHash('md5').update(buf).digest('hex'), + }; +} + +(function main() { + fs.mkdirSync(OUT, { recursive: true }); + try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) {} + const src = fs.readFileSync(path.join(STATIC, 'index.html'), 'utf8'); + + const results = []; + const variants = []; + + try { + for (const job of JOBS) { + const file = path.join(STATIC, `_shot_${job.name}.html`); + fs.writeFileSync(file, inject(src, job), 'utf8'); + variants.push(file); + + const out = path.join(OUT, `${job.name}.png`); + const url = `${BASE}/static/_shot_${job.name}.html` + (job.page ? `#${job.page}` : ''); + try { + execFileSync(EDGE, [ + '--headless=new', '--disable-gpu', '--hide-scrollbars', '--no-first-run', + '--force-device-scale-factor=1', + '--user-data-dir=' + PROFILE, + '--window-size=1680,1120', + '--virtual-time-budget=25000', + '--screenshot=' + out, + url, + ], { stdio: 'pipe', timeout: 150000 }); + const info = pngInfo(out); + results.push({ name: job.name, label: job.label, ok: true, ...info }); + } catch (e) { + results.push({ name: job.name, label: job.label, ok: false, err: String(e.message || e).slice(0, 160) }); + } + } + } finally { + variants.forEach((f) => { try { fs.unlinkSync(f); } catch (e) {} }); + try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) {} + } + + // 逐张报告,并把「哈希重复」当作失败 —— 重复说明跳页没生效 + const seen = new Map(); + for (const r of results) { + if (!r.ok) { console.log(` [FAIL] ${r.label.padEnd(14)} ${r.err}`); continue; } + const dup = seen.get(r.md5); + seen.set(r.md5, r.label); + if (dup) { + r.ok = false; + console.log(` [FAIL] ${r.label.padEnd(14)} 与「${dup}」截图完全相同(hash ${r.md5.slice(0, 8)})→ 跳页未生效`); + } else { + console.log(` [OK] ${r.label.padEnd(14)} ${r.w}x${r.h} ${(r.bytes / 1024).toFixed(0)}KB hash=${r.md5.slice(0, 8)}`); + } + } + + const bad = results.filter((r) => !r.ok); + console.log(`\n截图:成功 ${results.length - bad.length}/${results.length},输出目录 ${OUT}`); + process.exit(bad.length ? 1 : 0); +})(); diff --git a/verify/shots/advanced.png b/verify/shots/advanced.png new file mode 100644 index 0000000..9b15b3b Binary files /dev/null and b/verify/shots/advanced.png differ diff --git a/verify/shots/advisors.png b/verify/shots/advisors.png new file mode 100644 index 0000000..8d796c6 Binary files /dev/null and b/verify/shots/advisors.png differ diff --git a/verify/shots/classes.png b/verify/shots/classes.png new file mode 100644 index 0000000..49c77a3 Binary files /dev/null and b/verify/shots/classes.png differ diff --git a/verify/shots/employments.png b/verify/shots/employments.png new file mode 100644 index 0000000..da5ea6a Binary files /dev/null and b/verify/shots/employments.png differ diff --git a/verify/shots/login.png b/verify/shots/login.png new file mode 100644 index 0000000..38e1c4b Binary files /dev/null and b/verify/shots/login.png differ diff --git a/verify/shots/overview.png b/verify/shots/overview.png new file mode 100644 index 0000000..ca7a4c5 Binary files /dev/null and b/verify/shots/overview.png differ diff --git a/verify/shots/scores.png b/verify/shots/scores.png new file mode 100644 index 0000000..264dc60 Binary files /dev/null and b/verify/shots/scores.png differ diff --git a/verify/shots/statistics.png b/verify/shots/statistics.png new file mode 100644 index 0000000..00bc8bd Binary files /dev/null and b/verify/shots/statistics.png differ diff --git a/verify/shots/students-modal.png b/verify/shots/students-modal.png new file mode 100644 index 0000000..28f6d93 Binary files /dev/null and b/verify/shots/students-modal.png differ diff --git a/verify/shots/students.png b/verify/shots/students.png new file mode 100644 index 0000000..53dff40 Binary files /dev/null and b/verify/shots/students.png differ diff --git a/verify/shots/teachers.png b/verify/shots/teachers.png new file mode 100644 index 0000000..6e9b86d Binary files /dev/null and b/verify/shots/teachers.png differ diff --git a/verify/shots/viewer-scores.png b/verify/shots/viewer-scores.png new file mode 100644 index 0000000..41f99c8 Binary files /dev/null and b/verify/shots/viewer-scores.png differ diff --git a/verify/shots/viewer-students.png b/verify/shots/viewer-students.png new file mode 100644 index 0000000..1be5849 Binary files /dev/null and b/verify/shots/viewer-students.png differ diff --git a/verify/statistics_crosscheck.py b/verify/statistics_crosscheck.py new file mode 100644 index 0000000..58549e2 --- /dev/null +++ b/verify/statistics_crosscheck.py @@ -0,0 +1,428 @@ +"""统计口径独立对账。 + +后端自检只校验了 `code == 0` 和「返回非空」,口径算错了它发现不了。 +这里把每个统计接口的结果,和**用 Python 直接从原始列重算一遍**的值对比, +其中年龄/标准差/就业率/及格率都在 Python 侧独立实现,不借用被测代码的 SQL 表达式。 + +用法: + python verify/statistics_crosscheck.py [--base http://127.0.0.1:8010] +""" + +from __future__ import annotations + +import argparse +import statistics +import sys +from datetime import date +from pathlib import Path + +import httpx +from sqlalchemy import text + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from app.core.database import engine # noqa: E402 + +PASS: list[str] = [] +FAIL: list[str] = [] +BASE = "http://127.0.0.1:8010" + + +def check(title: str, ok: bool, detail: str = "") -> None: + if ok: + PASS.append(title) + print(f" [PASS] {title}" + (f" {detail}" if detail else "")) + else: + FAIL.append(f"{title} — {detail}") + print(f" [FAIL] {title} {detail}") + + +def section(name: str) -> None: + print(f"\n== {name} ==") + + +def today() -> date: + return date.today() + + +def full_years(birth: date, ref: date) -> int: + """完整周岁数:生日还没到就减一。这是「年龄」的正确算法。""" + return ref.year - birth.year - ((ref.month, ref.day) < (birth.month, birth.day)) + + +# ============================================================ 原始数据 +def load_raw() -> dict: + """注意:MySQL 的 DECIMAL 列取回来是 Decimal,跟 float 混算会 TypeError, + 这里在入口统一转成 float,后面的比较就干净了。""" + with engine.connect() as c: + classes = [dict(r._mapping) for r in c.execute(text( + "SELECT id, class_no, name, capacity, status FROM clazz WHERE is_del=0"))] + students = [dict(r._mapping) for r in c.execute(text( + "SELECT id, stu_no, name, gender, birth_date, class_id, status FROM student WHERE is_del=0"))] + scores = [dict(r._mapping) for r in c.execute(text( + "SELECT id, stu_id, exam_seq, score, flag FROM score WHERE is_del=0"))] + emps = [dict(r._mapping) for r in c.execute(text( + "SELECT id, stu_id, class_id, open_date, offer_date, salary FROM employment WHERE is_del=0"))] + + for s in scores: + s["score"] = float(s["score"]) + for e in emps: + e["salary"] = float(e["salary"]) if e["salary"] is not None else None + return {"classes": classes, "students": students, "scores": scores, "emps": emps} + + +def main() -> None: + global BASE + parser = argparse.ArgumentParser() + parser.add_argument("--base", default=BASE) + args = parser.parse_args() + BASE = args.base + + raw = load_raw() + students, scores, emps, classes = raw["students"], raw["scores"], raw["emps"], raw["classes"] + stu_by_id = {s["id"]: s for s in students} + cls_by_id = {c["id"]: c for c in classes} + + http = httpx.Client(base_url=BASE, timeout=60) + tok = http.post("/auth/login", json={"username": "admin", "password": "admin123"}).json()["data"]["access_token"] + H = {"Authorization": f"Bearer {tok}"} + + def api(path: str): + b = http.get(path, headers=H).json() + assert b.get("code") == 0, f"{path} 返回 code={b.get('code')} {b.get('msg')}" + return b["data"] + + # ---------------- 年龄 ---------------- + section("2.6.1 动态年龄查询(Python 独立算年龄对账)") + age_expected = {} + for s in students: + age_expected[s["id"]] = full_years(s["birth_date"], today()) if s["birth_date"] else None + + for op, val, val2 in [("gte", 25, None), ("gt", 25, None), ("lt", 22, None), + ("lte", 22, None), ("eq", 24, None), ("between", 23, 26)]: + q = f"/statistics/students/by-age?operator={op}&value={val}&page_size=1" + if val2 is not None: + q += f"&value2={val2}" + d = api(q) + if op == "between": + lo, hi = min(val, val2), max(val, val2) + exp = sum(1 for a in age_expected.values() if a is not None and lo <= a <= hi) + else: + fn = {"gte": lambda a: a >= val, "gt": lambda a: a > val, "lt": lambda a: a < val, + "lte": lambda a: a <= val, "eq": lambda a: a == val}[op] + exp = sum(1 for a in age_expected.values() if a is not None and fn(a)) + check(f"age {op} {val}{('/' + str(val2)) if val2 else ''} 命中数一致", + d["total"] == exp, f"接口 {d['total']} / 独立算 {exp}") + + # 逐条核对返回行里的 age 字段 + d = api("/statistics/students/by-age?operator=gte&value=18&page_size=200") + bad = [r for r in d["items"] if r.get("age") != age_expected.get(r["id"])] + check("返回行里的 age 与 Python 周岁算法逐一一致", not bad, + f"{len(d['items'])} 行中有 {len(bad)} 行不符" + (f" 例:{bad[0]['name']} 接口={bad[0].get('age')} 应为={age_expected[bad[0]['id']]}" if bad else "")) + + # ---------------- 班级性别分布 ---------------- + section("2.6.1 班级人数与性别分布") + ov = api("/statistics/classes/overview") + check("班级数与库一致", len(ov) == len(classes), f"接口 {len(ov)} / 库 {len(classes)}") + for row in ov: + mine = [s for s in students if s["class_id"] == row["class_id"]] + exp_total = len(mine) + exp_male = sum(1 for s in mine if s["gender"] == 1) + exp_female = sum(1 for s in mine if s["gender"] == 2) + exp_other = exp_total - exp_male - exp_female + ok = (row["total"], row["male"], row["female"], row["other"]) == (exp_total, exp_male, exp_female, exp_other) + check(f"「{row['class_name']}」总/男/女/未填 一致", ok, + f"接口 {row['total']}/{row['male']}/{row['female']}/{row['other']} vs 独立 {exp_total}/{exp_male}/{exp_female}/{exp_other}") + check(f"「{row['class_name']}」男+女+未填 == 总数", + row["male"] + row["female"] + row["other"] == row["total"]) + check("全库男女人数之和 == 学生总数", + sum(r["male"] + r["female"] + r["other"] for r in ov) == len(students), + f"分布合计 {sum(r['male'] + r['female'] + r['other'] for r in ov)} / 学生 {len(students)}") + + # ---------------- 年龄分布 ---------------- + section("年龄分布直方图") + dist = api("/statistics/students/age-distribution") + hist: dict[int, int] = {} + for a in age_expected.values(): + if a is not None: + hist[a] = hist.get(a, 0) + 1 + check("直方图各桶计数与独立统计一致", + {r["age"]: r["count"] for r in dist} == hist, + f"接口 {len(dist)} 桶 / 独立 {len(hist)} 桶") + check("直方图人数合计 == 学生总数", sum(r["count"] for r in dist) == len(students), + f"{sum(r['count'] for r in dist)} / {len(students)}") + + # ---------------- 每次考核都达标 ---------------- + section("2.6.2 每次考核都在分数线以上") + TH = 80.0 + above = api(f"/statistics/scores/all-above?threshold={TH}") + per_stu: dict[int, list[float]] = {} + for sc in scores: + per_stu.setdefault(sc["stu_id"], []).append(sc["score"]) + exp_ids = {sid for sid, vals in per_stu.items() if vals and min(vals) >= TH} + got_ids = {r["stu_id"] for r in above} + check(f"{TH:g} 分以上(每次考核都达标)学生集合完全一致", got_ids == exp_ids, + f"接口 {len(got_ids)} 人 / 独立 {len(exp_ids)} 人" + + (f" 差集 {got_ids ^ exp_ids}" if got_ids != exp_ids else "")) + bad = [] + for r in above: + vals = per_stu[r["stu_id"]] + if r["exam_count"] != len(vals) or abs(r["min_score"] - min(vals)) > 1e-6 or abs(r["avg_score"] - sum(vals) / len(vals)) > 0.01: + bad.append(r["name"]) + check("每人考核次数/最低分/平均分 一致", not bad, f"不符:{bad[:3]}") + + # ---------------- 多次不及格 ---------------- + section("2.6.2 不及格次数达到 N 次") + LINE, MIN_T = 60.0, 2 + fails = api(f"/statistics/scores/failures?threshold={LINE}&min_times={MIN_T}") + exp_fail = {sid: [v for v in vals if v < LINE] for sid, vals in per_stu.items()} + exp_fail = {sid: v for sid, v in exp_fail.items() if len(v) >= MIN_T} + got_fail = {r["stu_id"]: r for r in fails} + check("不及格学生集合完全一致", set(got_fail) == set(exp_fail), + f"接口 {len(got_fail)} 人 / 独立 {len(exp_fail)} 人" + + (f" 差集 {set(got_fail) ^ set(exp_fail)}" if set(got_fail) != set(exp_fail) else "")) + bad = [r["name"] for r in fails if r["fail_times"] != len(exp_fail.get(r["stu_id"], []))] + check("每人不及格次数一致", not bad, f"不符:{bad[:3]}") + bad = [r["name"] for r in fails + if len(r["fail_details"]) != r["fail_times"] + or any(x["score"] >= LINE for x in r["fail_details"])] + check("不及格明细条数正确且每条都低于红线", not bad, f"不符:{bad[:3]}") + + # ---------------- 班级平均分 ---------------- + section("2.6.2 每场考核每个班级的平均分") + ca = api("/statistics/scores/class-average?order=desc") + exp_groups: dict[tuple[int, int], list[float]] = {} + for sc in scores: + stu = stu_by_id.get(sc["stu_id"]) + if not stu or stu["class_id"] is None: + continue + exp_groups.setdefault((sc["exam_seq"], stu["class_id"]), []).append(sc["score"]) + check("(场次 × 班级) 组合数一致", len(ca) == len(exp_groups), + f"接口 {len(ca)} / 独立 {len(exp_groups)}") + bad = [] + for row in ca: + key = (row["exam_seq"], row["class_id"]) + vals = exp_groups.get(key) + if vals is None: + bad.append(f"多余 {key}") + continue + exp_avg = round(sum(vals) / len(vals), 2) + exp_pass = round(sum(1 for v in vals if v >= 60) / len(vals) * 100, 1) + if abs(row["avg_score"] - exp_avg) > 0.011 or row["max_score"] != max(vals) \ + or row["min_score"] != min(vals) or row["student_count"] != len(vals) \ + or abs(row["pass_rate"] - exp_pass) > 0.11: + bad.append(f"{row['class_name']} 第{row['exam_seq']}次 接口({row['avg_score']}/{row['max_score']}" + f"/{row['min_score']}/{row['student_count']}/{row['pass_rate']}) 独立({exp_avg}/{max(vals)}" + f"/{min(vals)}/{len(vals)}/{exp_pass})") + check("每组的 均分/最高/最低/人数/及格率 全部一致", not bad, f"问题 {len(bad)} 组;例:{bad[:1]}") + avgs = [r["avg_score"] for r in ca] + check("默认按平均分降序排列", avgs == sorted(avgs, reverse=True), f"前 5:{avgs[:5]}") + asc = api("/statistics/scores/class-average?order=asc") + avgs_asc = [r["avg_score"] for r in asc] + check("order=asc 时升序排列", avgs_asc == sorted(avgs_asc), f"前 5:{avgs_asc[:5]}") + + # 回归守卫:曾经 ORDER BY 把 exam_seq 放在主位,导致「按平均分排序」只在每个场次内部生效, + # 选「全部场次 + 从高到低」看到的不是排名而是 5 个分块。这里专门盯住这个坑。 + all_avgs = sorted({r["avg_score"] for r in ca}, reverse=True) + check("降序时第一名就是全局最高平均分", ca[0]["avg_score"] == all_avgs[0], + f"实际首行 {ca[0]['avg_score']}({ca[0]['class_name']} 第{ca[0]['exam_seq']}次) / 全局最高 {all_avgs[0]}") + seqs = [r["exam_seq"] for r in ca] + check("降序列表里场次是交叉出现的(说明场均分是主导键,不是按场次分块)", + len(set(seqs[:4])) > 1, f"前 4 行场次:{seqs[:4]}") + # 不做「互为逆序」的严格断言:平均分相同的组之间,SQL 不保证次序, + # 这种断言迟早会 flaky。只钉住「集合相同 + 各自单调」。 + key_of = lambda rs: sorted((r["exam_seq"], r["class_id"]) for r in rs) # noqa: E731 + check("升降序返回的是同一组数据(只是方向不同)", + key_of(ca) == key_of(asc) and len(ca) == len(asc), + f"desc {len(ca)} 行 / asc {len(asc)} 行") + check("降序非递增、升序非递减", + all(avgs[i] >= avgs[i + 1] for i in range(len(avgs) - 1)) + and all(avgs_asc[i] <= avgs_asc[i + 1] for i in range(len(avgs_asc) - 1))) + + one = api("/statistics/scores/class-average?exam_seq=1") + check("exam_seq=1 只返回第 1 次考试", all(r["exam_seq"] == 1 for r in one) and len(one) == len(classes), + f"{len(one)} 行,场次 {sorted({r['exam_seq'] for r in one})}") + + # ---------------- 薪资 Top N ---------------- + section("2.6.3 就业薪资 Top N") + with_offer = [e for e in emps if e["offer_date"] and e["salary"] is not None] + exp_top = sorted(with_offer, key=lambda e: (-e["salary"], e["id"]))[:5] + top = api("/statistics/employment/top-salary?top_n=5") + check("Top5 条数正确", len(top) == 5, f"{len(top)} 条") + check("Top5 薪资与独立排序完全一致", + [r["salary"] for r in top] == [e["salary"] for e in exp_top], + f"接口 {[r['salary'] for r in top]} vs 独立 {[e['salary'] for e in exp_top]}") + check("Top5 学生与独立排序一致", + [r["stu_id"] for r in top] == [e["stu_id"] for e in exp_top]) + check("名次从 1 开始连续", [r["rank"] for r in top] == [1, 2, 3, 4, 5]) + check("薪资降序", [r["salary"] for r in top] == sorted([r["salary"] for r in top], reverse=True)) + bad = [r["name"] for r in top + if abs(r["salary_wan"] - round(r["salary"] / 10000, 2)) > 0.011] + check("薪资(万) 换算正确", not bad, f"不符:{bad[:3]}") + + # ---------------- 每人就业时长 ---------------- + section("2.6.3 每个学生的就业时长") + durs = api("/statistics/employment/durations") + check("条数 == 就业记录数", len(durs) == len(emps), f"接口 {len(durs)} / 库 {len(emps)}") + emp_by_stu = {e["stu_id"]: e for e in emps} + bad, missing = [], [] + for r in durs: + e = emp_by_stu.get(r["stu_id"]) + if e is None: + missing.append(r["stu_id"]) + continue + if e["offer_date"] and e["open_date"]: + exp = (e["offer_date"] - e["open_date"]).days # Python 直接算天数 + if r["duration_days"] != exp: + bad.append(f"{r['name']} 接口 {r['duration_days']} 应 {exp}") + elif r["duration_days"] is not None: + bad.append(f"{r['name']} 无 offer 却有时长 {r['duration_days']}") + check("每人时长 == offer 日 - 开放日(Python 独立算)", not bad and not missing, + f"不符 {len(bad)} 条,缺 {len(missing)} 条;例:{bad[:2]}") + + # ---------------- 班级平均就业时长 ---------------- + section("2.6.3 每个班级的平均就业时长") + cad = api("/statistics/employment/class-avg-duration") + check("班级数一致", len(cad) == len(classes), f"{len(cad)} / {len(classes)}") + bad = [] + for row in cad: + mine = [e for e in emps if e["class_id"] == row["class_id"]] + cls_students = [s for s in students if s["class_id"] == row["class_id"]] + with_d = [e for e in mine if e["open_date"] and e["offer_date"]] + exp_avg = round(sum((e["offer_date"] - e["open_date"]).days for e in with_d) / len(with_d), 1) if with_d else None + probs = [] + if row["student_count"] != len(cls_students): + probs.append(f"人数 {row['student_count']}≠{len(cls_students)}") + if row["offer_count"] != len(with_d): + probs.append(f"offer数 {row['offer_count']}≠{len(with_d)}") + if exp_avg is not None and (row["avg_duration_days"] is None or abs(row["avg_duration_days"] - exp_avg) > 0.06): + probs.append(f"均时长 {row['avg_duration_days']}≠{exp_avg}") + if probs: + bad.append(f"{row['class_name']}: " + ";".join(probs)) + check("每班 人数/offer数/平均时长 全部一致", not bad, f"问题 {len(bad)} 个班;例:{bad[:1]}") + + # ---------------- 成绩波动 ---------------- + section("2.7.2 成绩波动 Top N") + mult = {sid: vals for sid, vals in per_stu.items() if len(vals) >= 2} + exp_std = {sid: statistics.pstdev(vals) for sid, vals in mult.items()} # 总体标准差 + exp_rng = {sid: max(vals) - min(vals) for sid, vals in mult.items()} + + vol = api("/statistics/scores/volatility?top_n=5&metric=stddev") + order = sorted(exp_std, key=lambda sid: (-exp_std[sid], sid))[:5] + check("标准差 Top5 人数正确", len(vol) == 5, f"{len(vol)} 条") + check("标准差 Top5 排序一致", + [r["stu_id"] for r in vol] == order, + f"接口 {[r['stu_id'] for r in vol]} vs 独立 {order}") + bad = [f"{r['name']} {r['stddev']}≠{round(exp_std[r['stu_id']], 2)}" + for r in vol if abs(r["stddev"] - exp_std[r["stu_id"]]) > 0.011] + check("标准差数值与 Python pstdev 一致(总体标准差)", not bad, f"不符:{bad[:3]}") + + volr = api("/statistics/scores/volatility?top_n=5&metric=range") + order_r = sorted(exp_rng, key=lambda sid: (-exp_rng[sid], sid))[:5] + check("最大分差 Top5 排序一致", [r["stu_id"] for r in volr] == order_r, + f"接口 {[r['stu_id'] for r in volr]} vs 独立 {order_r}") + bad = [f"{r['name']} {r['score_range']}≠{round(exp_rng[r['stu_id']], 2)}" + for r in volr if abs(r["score_range"] - exp_rng[r["stu_id"]]) > 0.011] + check("最大分差数值一致", not bad, f"不符:{bad[:3]}") + + # 趋势方向:接口用「对考核序次做最小二乘拟合」的斜率,阈值 ±1 分/场 + # 这里用另一条代数路径算斜率(nΣxy − ΣxΣy)/(nΣx² − (Σx)²),避开均值形式, + # 这样才构成真正的独立对账,而不是把同一个公式再抄一遍。 + def lsq_slope(vals: list[float]) -> float: + n = len(vals) + if n < 2: + return 0.0 + sx, sy = n * (n - 1) / 2, sum(vals) + sxy = sum(i * v for i, v in enumerate(vals)) + sxx = sum(i * i for i in range(n)) + den = n * sxx - sx * sx + return (n * sxy - sx * sy) / den if den else 0.0 + + # 关键不变量:出参里的 trend 必须与出参里的 trend_slope 自洽。 + # 前端会把「标签 + 斜率」并排显示,出现「基本持平 +1.0/场」这种组合就是自相矛盾。 + def label_from(s: float, n: int) -> str: + if n < 2: + return "数据不足" + return "上升" if s >= 1.0 else ("下降" if s <= -1.0 else "基本持平") + + bad = [] + allvol = api("/statistics/scores/volatility?top_n=50&metric=stddev") + for r in allvol: + vals = [x for _, x in sorted( + (s["exam_seq"], s["score"]) for s in scores if s["stu_id"] == r["stu_id"])] + exp_slope = round(lsq_slope(vals), 2) + if abs(r["trend_slope"] - exp_slope) > 0.011: + bad.append(f"{r['name']} 斜率 {r['trend_slope']} ≠ 独立算 {exp_slope}") + elif r["trend"] != label_from(r["trend_slope"], len(vals)): + bad.append(f"{r['name']} 标签 {r['trend']} 与自身斜率 {r['trend_slope']} 不自洽(应为 " + f"{label_from(r['trend_slope'], len(vals))})") + check("趋势斜率与独立最小二乘一致,且标签与斜率自洽", not bad, f"不符:{bad[:3]}") + check("波动榜里若出现上升/下降,斜率方向必须与标签同号", + all((r["trend"] == "上升") == (r["trend_slope"] > 0) or r["trend"] == "基本持平" + for r in allvol)) + + # ---------------- 就业漏斗 ---------------- + section("2.7.2 班级就业漏斗") + funnel = api("/statistics/employment/funnel") + check("班级数一致", len(funnel) == len(classes), f"{len(funnel)} / {len(classes)}") + bad = [] + for row in funnel: + cls_students = [s for s in students if s["class_id"] == row["class_id"]] + stu_ids = {s["id"] for s in cls_students} + got_offer = [e for e in emps if e["stu_id"] in stu_ids and e["offer_date"]] + with_sal = [e["salary"] for e in got_offer if e["salary"] is not None] + exp_rate = round(len(got_offer) / len(cls_students) * 100, 1) if cls_students else 0.0 + probs = [] + if row["total"] != len(cls_students): + probs.append(f"总数 {row['total']}≠{len(cls_students)}") + if row["employed"] != len(got_offer): + probs.append(f"就业 {row['employed']}≠{len(got_offer)}") + if abs(row["employment_rate"] - exp_rate) > 0.06: + probs.append(f"就业率 {row['employment_rate']}≠{exp_rate}") + if with_sal and abs(row["avg_salary"] - round(sum(with_sal) / len(with_sal), 2)) > 0.02: + probs.append(f"均薪 {row['avg_salary']}≠{round(sum(with_sal)/len(with_sal),2)}") + if probs: + bad.append(f"{row['class_name']}: " + ";".join(probs)) + check("每班 总数/就业数/就业率/平均薪资 一致", not bad, f"问题 {len(bad)} 个班;例:{bad[:1]}") + rates = [r["employment_rate"] for r in funnel] + check("按就业率降序", rates == sorted(rates, reverse=True), f"{rates}") + check("就业率都在 0~100", all(0 <= r <= 100 for r in rates)) + + # ---------------- 总览 ---------------- + section("仪表盘总览") + ovw = api("/statistics/overview") + check("学生总数", ovw["student_total"] == len(students), f"{ovw['student_total']} / {len(students)}") + check("班级总数", ovw["class_total"] == len(classes), f"{ovw['class_total']} / {len(classes)}") + exp_emp_total = len([e for e in emps if e["offer_date"]]) + check("就业总数(只算已拿 offer)", ovw["employment_total"] == exp_emp_total, + f"{ovw['employment_total']} / {exp_emp_total}") + exp_rate = round(exp_emp_total / len(students) * 100, 1) + check("整体就业率", abs(ovw["employment_rate"] - exp_rate) < 0.06, + f"{ovw['employment_rate']} / {exp_rate}") + sal = [e["salary"] for e in emps if e["salary"] is not None] + check("平均薪资", abs(ovw["avg_salary"] - round(sum(sal) / len(sal), 2)) < 0.02, + f"{ovw['avg_salary']} / {round(sum(sal)/len(sal),2)}") + check("成绩记录总数", ovw["score_record_total"] == len(scores), f"{ovw['score_record_total']} / {len(scores)}") + exp_avg = round(sum(s["score"] for s in scores) / len(scores), 2) + check("全部成绩平均分", abs(ovw["score_avg"] - exp_avg) < 0.011, f"{ovw['score_avg']} / {exp_avg}") + exp_warn = len({s["stu_id"] for s in scores if s["score"] < 60}) + check("红线预警学生数(至少一次低于红线)", ovw["warning_student_count"] == exp_warn, + f"{ovw['warning_student_count']} / {exp_warn}") + bad = [r["class_name"] for r in ovw["top_classes"] + if r["employed"] > r["total"]] + check("top_classes 就业数不超过班级总人数", not bad, f"异常:{bad}") + check("student_by_status 合计 == 学生总数", + sum(ovw["student_by_status"].values()) == len(students), + f"{sum(ovw['student_by_status'].values())} / {len(students)}") + + # ---------------- 收尾 ---------------- + print("\n" + "=" * 66) + print(f"统计口径对账:通过 {len(PASS)} 项,失败 {len(FAIL)} 项") + if FAIL: + print("\n失败明细:") + for i, f in enumerate(FAIL, 1): + print(f" {i}. {f}") + sys.exit(1 if FAIL else 0) + + +if __name__ == "__main__": + main() diff --git a/走查与改进记录.md b/走查与改进记录.md new file mode 100644 index 0000000..2053bed --- /dev/null +++ b/走查与改进记录.md @@ -0,0 +1,413 @@ +# 沃林学生管理系统 · 走查与改进记录 + +> 项目位置:`wolin_sms/` 运行方式:`uvicorn app.main:app --port 8010` +> 数据库:MySQL `wolin_sms`(与练习用的 `student_management_system` 完全隔离) +> 本文记录的是「按需求文档实现完之后,切到真实用户视角把系统用一遍」的过程与结论。 + +--- + +## 一、走查是怎么做的 + +不是「看一眼觉得还行」,而是把自己当成三种真实使用者,各走一遍: + +| 角色 | 账号 | 关注点 | +|---|---|---| +| 教务管理员 | `admin / admin123` | 增删改查、批量导入、统计口径准不准 | +| 只读访客 | `viewer / viewer123` | 登录后到底能看到什么、能做什么 | +| 换人使用者 | 前者用完切后者 | 会话之间会不会串味 | + +走查手段分四层,**互相不信任**: + +| 层 | 脚本 | 项数 | 它擅长抓什么 | +|---|---|---|---| +| 后端端到端 | `tests/api_check.py` | 66 | 业务链路、约束校验、状态流转 | +| 统计口径独立对账 | `verify/statistics_crosscheck.py` | 64 | 数字算错(用 Python 独立重算,不借被测 SQL) | +| 前端行为 | `verify/frontend_check.js` | 140 | UI 层 bug、权限体验、会话状态 | +| 真实浏览器视觉 | `verify/shots.js` | 13 张 | 布局、只读模式是否真的看不见按钮 | +| 权限边界 | `verify/perm_check.py` | 147 | 两个角色的读写能力是否划对 | + +合计 **417 项断言 + 13 张浏览器截图**,当前全绿。 + +> 为什么需要这么多层:同一类 bug 只在某一层现形。 +> 例如「高级筛选永远提交空规则」是纯 UI 层 bug —— 后端 66 项自检直接打 JSON, +> 绕过了界面,所以**一项都没报**。反过来「统计口径算错」UI 层也看不出来, +> 因为数据长得像模像样。少了任何一层都有盲区。 + +--- + +## 二、走查发现并修复的 8 个真实缺陷 + +### 1. 高级筛选:UI 永远提交空规则(最严重) + +**现象** +在界面上设「年龄 > 25」执行,命中 **71** 条 —— 正好是学生全表条数。 +换成需求文档里的嵌套 AND/OR 条件,命中还是 **71** 条。 + +**定位** +用独立 SQL 直接数:`SELECT COUNT(*) FROM student WHERE is_del=0` = 71。 +正确的答案应该是 13(age>25)和 2(嵌套条件)。 +说明条件根本没被提交上去,请求发的是空规则数组。 + +追到 `app/static/app.js` 的 `readRules()`:调用方传的是**分组元素本身**, +而这个元素的第一层 children 是 `.rule-group-head` 和 `div[data-children]`, +真正的叶子条件在 `[data-children]` 里面。于是每一轮都从一个没有条件子的节点 +上读,读出空数组,一路静默通过。 + +**修复**(`app/static/app.js`) +```javascript +const container = node && node.dataset && node.dataset.group ? $('[data-children]', node) : node; +if (!container) return []; +``` + +**验证** +修复后 UI 与接口完全一致:单条件 13 条、嵌套条件 2 条,且断言 +「加了 AND/OR 后命中数必须比单条件更少」也能过了。 + +--- + +### 2. 高级筛选把性别代号 1/2 直接摆给用户看 + +**现象** +结果表的「性别」列显示 `1`,不是「男」。 + +**根因** +`/advanced/query` 返回的是数据库代号(`gender=1`),前端原样输出。 + +**修复** +前端按字段元信息还原中文(`app/static/app.js` 的 `advFmtCell`): +```javascript +const ADV_CODE_TEXT = { + gender: { 1: '男', 2: '女' }, + status: { 1: '在读', 2: '进入就业', 3: '已就业' }, + class_status: { 1: '在读', 2: '已结课', 3: '已解散' }, +}; +``` + +--- + +### 3. 班级平均分「按分数排序」没有生效 + +**现象** +选「平均分从高到低」、不限场次,前 5 名是 +`[80.07, 76.36, 75.14, 69.96, 81.01]` —— 81.01 排在最后。 + +**根因** +明细读起来是「按场次分块、块内才有序」。需求文档原文要求 +> 统计每次考试每个班级的平均分,并支持按分数从高到低或从低到高**动态排序** + +前端下拉也明确写着「平均分从高到低」,用户期待的是全局排名。 +`statistics_dao.py` 里排序主键写成了 `Score.exam_seq.asc(), avg_col...`, +`exam_seq` 抢了主位。 + +**修复**(`app/dao/statistics_dao.py`) +```python +stmt = stmt.order_by( + avg_col.desc() if order.lower() == "desc" else avg_col.asc(), + Score.exam_seq.asc(), + Clazz.id.asc(), +) +``` + +**验证** +修复后前 5 名 `[82.05, 81.01, 80.07, 78.6, 78.31]`,首行就是全局最高分; +前 4 行的场次是 `[5, 2, 1, 3]` —— 交叉出现,证明均分才是主导键。 + +--- + +### 4. 成绩趋势标签与数据自相矛盾 + +**现象** +界面上出现「张子豪 · 接口 · **上升**」,可他的分数是 87.8 → 82.0,明明在跌。 + +**根因** +原算法是「前半段均值 vs 后半段均值,差 5 分以上算变化」。 +这个阶跃判据在样本分布不均时会翻车:5 次成绩被切成前 2 后 3, +切点两边的均值完全可能反向于首末变化。 + +还有一个边界问题:斜率正好 1.00 分/场时,接口判「基本持平」, +独立实现对账判「上升」—— 界面上会显示「基本持平 +1.0/场」,自己打自己脸。 + +**修复**(`app/service/statistics_service.py`) +改成对「考核序次」做最小二乘拟合,返回斜率;并且**先把斜率定稿到出参精度 +(2 位小数),再拿它判方向**,这样标签和数字就不会互相矛盾: +```python +trend_slope = _r(cls._trend(values)) or 0.0 # 先定稿精度,再据它判方向 +... +"trend": cls._trend_text(trend_slope, len(values)), +"trend_slope": trend_slope, +``` +出参新增 `trend_slope`,让「标签是否与斜率自洽」变成可断言的不变量。 + +**验证** +对账脚本改为断言「标签方向必须与自身斜率同号」,并且用 +**代数等价式**(`(nΣxy−ΣxΣy)/(nΣx²−(Σx)²)`)而不是均值形式来独立重算 —— +避免和被测代码用同一个公式,那就不叫独立对账了。 + +--- + +### 5. 只读账号什么都读不到(功能等于废的) + +**现象** +`viewer` 登录后,`GET /students`、`/statistics/overview`、`/advanced/meta` +等**全部**读取接口返回 `403 当前为只读账号,不能执行写操作`。 +而登录卡片上还在向用户宣传: + +> 只读账号:`viewer / viewer123`(可读不可写,用来演示权限) + +**根因** +权限依赖只有 `require_write` / `WriteAccount` 一个门禁, +所有读取接口图省事全挂在了它上面。权限模型里叫「只读」, +行为上却是「什么都做不了」—— 这个账号等于是废的。 + +**修复**(`app/core/deps.py`) +```python +def require_read(account: CurrentAccount) -> Account: + """读操作:只要能通过登录校验就行,三种角色都可读。 + + 这里必须单独有一个依赖,不能图省事复用 require_write。 + 早期版本把列表/详情/统计这些纯读接口全挂在了 WriteAccount 上, + 结果「只读账号」登录后连一条数据都看不到。 + """ + return account + +ReadAccount = Annotated[Account, Depends(require_read)] +``` +然后把 8 个 API 文件里的 **37 处**纯读接口从 `WriteAccount` 切到 `ReadAccount` +(`/advanced/query`、`/advanced/aggregate` 是 POST 但语义是读,也一样要放行)。 + +**验证**(新建 `verify/perm_check.py`,147 项) +- viewer 的 38 个读接口 **逐个** 与 admin 结果比对,**必须完全一致**; +- viewer 的 25 个写接口 **全部** 必须是 403(不是 404/405 这种「路由压根不存在」的巧合); +- 管理员专属的 `/auth/accounts`:viewer 403、admin 放行; +- 无 token 访问:401。 + +> 设计上的一个讲究:对账脚本里所有业务 ID 都在**运行时从列表接口现取**, +> 不写死「1 号」。硬编码 ID 一旦数据库重新播种就全线 404, +> 届时看起来像权限炸了,实际只是测试自己过期了 —— 这类假警报最耗人。 + +--- + +### 6. 慢页面会覆盖新页面(登录后点菜单点不动) + +**现象** +登录后立刻点「学生管理」,**屏幕上却是概览**。 +菜单高亮着「学生管理」,内容区显示的是概览 KPI。 + +**根因** +`goto()` 没有任何并发保护,而各页面的 `view.innerHTML = ...` 都写在 +`await` **之后**。于是两次导航重叠时,谁后返回谁说了算。 +概览页要串行打两个接口(`/statistics/overview` + `/employment/funnel`), +是最慢的一页 —— 它几乎必然最后返回,也就必然把用户想看的那页顶掉。 + +`enterApp()` 里那句 `goto('overview')` 还没有 await,等于主动制造了这次重叠。 + +**修复**(`app/static/app.js`) +引入导航序号 + 影子容器:这一页先渲染进一个游离容器,只有「还是最新一次导航」 +才把它搬进 `#view`。过期的渲染连 `#view` 都碰不到,自然覆盖不了别人。 + +```javascript +let navSeq = 0; + +async function goto(key) { + const seq = ++navSeq; + ... + const shadow = document.createElement('div'); + shadow.innerHTML = '
加载中…
'; + host.replaceChildren(shadow); + try { + await page.render(shadow); + } catch (err) { + if (seq !== navSeq) return; // 过期导航的异常不打扰用户 + ... + } + if (seq !== navSeq) return; // 已经有更新的跳页了,这一页作废 + host.onclick = shadow.onclick; // 行级点击处理器要一起转交 + host.replaceChildren(...Array.from(shadow.childNodes)); +} +``` + +这个改法把复杂度收在 `goto` 一个函数里,**10 个页面函数一行都没动**。 +`enterApp` 里的 `goto` 也补上了 `await`。 + +**顺带解释了一个历史谜团** +之前用无头浏览器截图时出现过「11 张截图里 9 张完全一样,全是概览」, +当初以为是截图脚本的问题。现在清楚了:**同一个 bug**。 +截图脚本自动跳页,与 `enterApp` 那次未 await 的 `goto('overview')` 并发, +概览最后返回、把所有页面都盖成了它自己。 + +**验证**(`verify/frontend_check.js` 新增段落) +测试不能靠「概览恰好更慢」的运气,否则哪天接口变快这条就形同虚设。 +做法是**临时给概览用到的接口注入固定延时**,让「慢页面后返回」成为必然, +再断言「等慢接口全部回完之后,页面仍然是学生页」。 + +--- + +### 7. 筛选条件跨登出残留(换个账号就一脸问号) + +**现象** +管理员按「自检临时生」筛过学生 → 退出 → `viewer` 登进来点「学生管理」: +看到一个**空列表**,而且关键词框里躺着一个自己从没输过的名字。 + +**根因** +`stuState` / `scoreState` / `empState` 是**模块级**变量。 +做成模块级是**故意的** —— 同一会话里来回切页不该把刚筛好的条件弄丢。 +但同一份「故意」也意味着登出时必须手动清,代码里没清。 + +> 顺带一提:班级 / 老师 / 顾问三个页面用的是页面内的局部 `st`, +> 切页自然重置。同一个项目里两种写法并存,本身就是容易踩坑的信号。 + +**修复**(`app/static/app.js`) +给默认值一个单一来源,新增复位函数,在进入应用时调用: +```javascript +const STU_DEFAULTS = { page: 1, page_size: 10, keyword: '', class_id: '', ... }; +const stuState = { ...STU_DEFAULTS }; + +function resetSessionState() { + Object.assign(stuState, STU_DEFAULTS); + Object.assign(scoreState, SCORE_DEFAULTS); + Object.assign(empState, EMP_DEFAULTS); + advTab = 'query'; +} +``` +在 `enterApp()` 里调 `resetSessionState()` —— 「谁登录进来都从干净状态开始」。 + +--- + +### 8. 高级筛选的标签页也跨登出残留 + +**现象** +管理员在高级筛选切到「分组聚合」标签 → 退出 → 下一个登录的人一进高级筛选, +**直接停在聚合页**。而他是来写筛选规则的。 + +**根因** 同上,`let advTab = 'query'` 也是模块级的。 + +**修复** 并入 `resetSessionState()`。 + +> 这两个是同一个病根。第一次撞见(筛选条件)时我只是就地修掉, +> 直到撞见第二次才意识到该建立**统一的会话状态复位**,而不是哪里冒出来补哪里。 +> 走查的价值正在这里 —— 单点 bug 往往是一类问题的第一个样本。 + +--- + +## 三、只读模式的界面处理 + +后端把门禁修好之后,还有一个体验问题:只读用户能看到一堆点了会报 403 的按钮。 +**看不见的按钮比点了报错的按钮友好。** + +做法是 CSS 隐藏,两个挂法各有原因: + +```css +.readonly-mode .w-act { display: none !important; } +``` + +- **挂在列的 `cls` 上** → 整列(`` + ``)一起隐藏。 + 适用于「操作」列里全是写链接的表:成绩 / 就业 / 班级 / 老师 / 顾问。 +- **挂在单个 `span` 上** → 逐链接隐藏。只用于学生表的「操作」列 —— + 那列里还有「**详情**」链接,只读账号需要它,整列藏掉等于把查看功能也砍了。 + +> 这里踩过一次坑:一开始图省事把 6 个「操作」列全部整列隐藏, +> 结果学生表里只读用户唯一能用的「详情」也被藏了。已撤回并改为逐链接标注。 + +顶栏加了「只读模式」标记,让用户清楚自己现在是什么身份。 +`index.html` 的登录卡片也保留了 `viewer / viewer123` 的说明 —— +现在这个账号真的是「可读」了,说明和实际对得上了。 + +**验证**(`frontend_check.js`) +断言用的是 `getComputedStyle` 算出来的 `display`,不是类名 —— +类名只是手段,「用户看不见」才是目的。而且: +- 断言「藏起来了」时必须**先要求元素存在**。 + 一开始写成 `displayOf(el) !== 'none'`,而元素不存在时 `displayOf` 返回 `'MISSING'`, + 于是页面渲染失败反而让「写入口已隐藏」通过了 —— **最该红的时候绿了**,已修正。 +- 断言了反面:只读账号走遍 9 个页面**一次 403 都不该撞到**; + 同时绕过 UI 直接发写请求,必须仍被后端拒绝(`viewer` 拿自己的 token POST `/students` → 403)。 + 前者保证没把读能力削掉,后者保证门禁不是只靠 CSS。 + +--- + +## 四、验证脚本自身的问题也一并修了 + +走查工具出错会给出**假信号**,比没有工具更危险。这一轮修掉的: + +| 问题 | 后果 | 处理 | +|---|---|---| +| `displayOf(null)` 返回 `'MISSING'` | 元素不存在被当成「已隐藏」→ 假通过 | 加 `hidden()` / `shown()`,先要求元素存在 | +| 权限对账硬编码 `student_id=1` | 库里没有 1 号 → 全线 404,看着像权限炸了 | 改成运行时从列表接口现取 ID | +| 权限对账用了错的路由路径 | 一堆假 FAIL,掩盖真结论 | 从 `/openapi.json` 导出真实路由表后重写 | +| 把 xlsx 模板当 JSON 解析 | `UnicodeDecodeError`,报成读取失败 | 单独用二进制通道校验 xlsx 魔数 `PK` | +| 截图只报告分辨率/大小 | 「11 张全是概览」这种静默失效看不出来 | 对 PNG 做 MD5,哈希重复即判失败 | +| 验证残留不清(逻辑删除堆积) | 演示库里越跑越多 `自检临时生`/`权限对账临时顾问` 的尸体 | 新增 `verify/purge_selftest.py`(默认干跑),`perm_check.py` 跑完自动物理清理 | + +清理工具特意做成**默认干跑**:先列出「要删哪些、为什么」,确认无误差再 `--yes`。 +演示库的洁净度现在可以随时核对 —— 当前各表有效行数与总行数**完全相等**, +也就是说连一行逻辑删除的尸体都不剩。 + +--- + +## 五、当前验证结果 + +``` +后端端到端自检 tests/api_check.py 66 / 66 ✅ +统计口径独立对账 verify/statistics_crosscheck.py 64 / 64 ✅ +前端行为验证 verify/frontend_check.js 140 / 140 ✅ +权限边界对账 verify/perm_check.py 147 / 147 ✅ + ───────── + 417 项断言全绿 +浏览器视觉截图 verify/shots.js 13 / 13 ✅ +``` + +演示数据基线(跑完所有验证后核对,零污染): + +``` +班级 4 老师 5 顾问 3 学生 52 成绩 260 就业 46 账号 2 +``` + +--- + +## 六、还没做的 / 已知取舍 + +- **逻辑删除的行会一直留着**。这是设计选择(可恢复),但没有任何界面入口 + 能看到或恢复已删除的记录 —— `POST /students/{id}/restore` 只有接口没有按钮。 + 仅当作练手接口保留。 +- **`advisor_no` / `teacher_no` / `class_no` 的自动编号依赖当年级别计数**, + 跨年时若历史数据被物理清理,编号可能重复。当前靠数据库唯一约束兜底(返回 409), + 不做自增序列。 +- **权限只到「三个角色 + 读写」这一层**,没有做「顾问只能看自己名下学生」 + 这种数据行级隔离。需求文档里没提,就没加。 +- **前端是零依赖原生 JS(`app.js` 1954 行 + `style.css` 395 行)**,没有构建步骤。 + 好处是打开就能改、没有工具链;代价是页面多了以后 `#view` 的 innerHTML 拼接 + 会越来越难维护,而且每个页面自己 `view.onclick = ...`,新增页面时容易漏。 + +--- + +## 七、怎么跑起来 + +```bash +cd wolin_sms + +# 1) 建表 + 建初始账号(默认不会删任何数据;要重来才加 --drop) +.venv/Scripts/python.exe -m app.scripts.init_db + +# 2) 灌演示数据(已有数据会跳过;要重灌加 --reset,它会清空业务数据) +.venv/Scripts/python.exe -m app.scripts.seed_data + +# 3) 启动 +.venv/Scripts/python.exe -m uvicorn app.main:app --port 8010 + +# 4) 浏览器打开 http://127.0.0.1:8010 → admin / admin123 +# → viewer / viewer123(只读) +``` + +> 演示数据不是随机糊的:固定随机种子(`random.seed(20260916)`), +> 每次灌出来一样,便于对比;成绩按「个人基础 + 波动」生成, +> 必然产生学霸、多次不及格、大起大落三类人 —— 保证每个统计口径都有东西看。 + +验证脚本: + +```bash +.venv/Scripts/python.exe tests/api_check.py --base http://127.0.0.1:8010 +.venv/Scripts/python.exe verify/statistics_crosscheck.py +.venv/Scripts/python.exe verify/perm_check.py +node verify/frontend_check.js http://127.0.0.1:8010 # 需要 NODE_PATH 指向 jsdom +node verify/shots.js http://127.0.0.1:8010 # 需要本机 Edge +.venv/Scripts/python.exe verify/purge_selftest.py --yes # 清掉验证残留 +```