diff --git a/backend/app/services/crm/list_sort.py b/backend/app/services/crm/list_sort.py new file mode 100644 index 000000000..61c77a181 --- /dev/null +++ b/backend/app/services/crm/list_sort.py @@ -0,0 +1,27 @@ +"""CRM 列表日期列排序:白名单 prop → SQLAlchemy order 表达式。""" +from __future__ import annotations + +from typing import Any, Mapping, Optional + + +def resolve_datetime_order_by( + sort_field: Optional[str], + sort_order: Optional[str], + whitelist: Mapping[str, Any], + default, +): + """ + sort_field: 前端 camelCase prop(如 createTime) + sort_order: asc | desc(大小写不敏感) + whitelist: prop → Column / InstrumentedAttribute + 非法或缺参 → 返回 default(通常 create_time.desc()) + MySQL 对 NULL:ASC 前、DESC 后,与产品决策一致,无需额外 IS NULL。 + """ + field = (sort_field or "").strip() + order = (sort_order or "").strip().lower() + if not field or order not in ("asc", "desc"): + return default + col = whitelist.get(field) + if col is None: + return default + return col.asc() if order == "asc" else col.desc() diff --git a/backend/tests/test_crm_list_sort.py b/backend/tests/test_crm_list_sort.py new file mode 100644 index 000000000..4594707ef --- /dev/null +++ b/backend/tests/test_crm_list_sort.py @@ -0,0 +1,52 @@ +# backend/tests/test_crm_list_sort.py +from sqlalchemy import Column, DateTime, MetaData, String, Table + +from app.services.crm.list_sort import resolve_datetime_order_by + + +def _fake_columns(): + md = MetaData() + t = Table( + "t", + md, + Column("id", String(64)), + Column("create_time", DateTime), + Column("deal_time", DateTime), + ) + return { + "createTime": t.c.create_time, + "dealTime": t.c.deal_time, + }, t.c.create_time.desc() + + +def test_valid_asc(): + wl, default = _fake_columns() + expr = resolve_datetime_order_by("createTime", "asc", wl, default) + assert "ASC" in str(expr).upper() or getattr(expr, "modifier", None) is not None + # 更稳:比较 clause 指向白名单列 + assert expr.element is wl["createTime"] or list(expr.element.base_columns)[0] is list(wl["createTime"].base_columns)[0] + + +def test_valid_desc(): + wl, default = _fake_columns() + expr = resolve_datetime_order_by("dealTime", "desc", wl, default) + assert expr.element.key == "deal_time" or "deal_time" in str(expr) + + +def test_invalid_field_falls_back(): + wl, default = _fake_columns() + expr = resolve_datetime_order_by("hack", "asc", wl, default) + assert expr is default + + +def test_invalid_order_falls_back(): + wl, default = _fake_columns() + expr = resolve_datetime_order_by("createTime", "up", wl, default) + assert expr is default + + +def test_missing_falls_back(): + wl, default = _fake_columns() + assert resolve_datetime_order_by(None, None, wl, default) is default + assert resolve_datetime_order_by("createTime", None, wl, default) is default + assert resolve_datetime_order_by(None, "asc", wl, default) is default