"""端到端自检脚本(不依赖 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()