"""通用高级筛选规则引擎(需求 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