From 9bb2eab269e204126ba1ff38657be02c3bb0b68c Mon Sep 17 00:00:00 2001 From: colaftc Date: Mon, 29 Dec 2025 15:36:00 +0800 Subject: [PATCH] feat: mdy staging api filters --- .../views/mingdaoyun/plate_order_staging.py | 123 ++++++++++++++++-- .../test_plate_order_staging_api.py | 30 ++++- business/tests.py | 6 +- docs/api_v1_mdy_plate_order_staging.md | 13 +- flower/viewsets.py | 4 +- 5 files changed, 151 insertions(+), 25 deletions(-) diff --git a/api_v1/views/mingdaoyun/plate_order_staging.py b/api_v1/views/mingdaoyun/plate_order_staging.py index b0abc10..7a6b759 100644 --- a/api_v1/views/mingdaoyun/plate_order_staging.py +++ b/api_v1/views/mingdaoyun/plate_order_staging.py @@ -2,7 +2,7 @@ 需求: - 默认按 -ctime 排序 -- 支持 raw__62d52f4b8d2972284492dd0e(设计编号)过滤 +- 支持按“内部字段名”的 query param 过滤(对前端隐藏 JSONField 实现细节) - 支持 LimitOffset 分页 - 返回时把 raw / related 两个 JSONField 做“可读化/碾平” """ @@ -11,21 +11,41 @@ from __future__ import annotations from typing import Any +from django.core.exceptions import FieldError from django.core.cache import cache from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page from django.db.models import F +from django.db.models import Q from rest_framework import serializers, viewsets from rest_framework.permissions import DjangoModelPermissions +from rest_framework.exceptions import ValidationError from flower.viewsets import LimitedReadOnlyModelViewSet from api_v1.models import MDYPlateOrderStaging -from flower.utils.mingdaoyun.mappings import plate_order_field_definitions +from flower.utils.mingdaoyun.mappings import plate_order_field_definitions, plate_order_type_map from flower.utils.mingdaoyun.relations import flatten_row_by_field_definitions, normalize_mdy_value _PLATE_ORDER_DESIGN_NO_CONTROL_ID = "62d52f4b8d2972284492dd0e" +_PUBLIC_RAW_FIELDS: set[str] = set(plate_order_type_map.keys()) - {"created_at", "rowid"} + +# 允许的 lookup(尽量收敛能力,降低风险) +_ALLOWED_LOOKUPS: set[str] = {"exact", "icontains", "isnull"} + + +def _parse_bool(value: str | None) -> bool: + s = (value or "").strip().lower() + return s in {"1", "true", "yes", "y", "t"} + + +def _coerce_value_for_lookup(lookup: str, raw_value: str) -> Any: + lookup = (lookup or "exact").lower() + if lookup == "isnull": + return _parse_bool(raw_value) + return raw_value + class MDYPlateOrderStagingSerializer(serializers.ModelSerializer): """把 raw/related 输出为扁平的可读结构。""" @@ -94,19 +114,96 @@ class MDYPlateOrderStagingViewSet(LimitedReadOnlyModelViewSet): # permission_classes = [DjangoModelPermissions] def get_queryset(self): - qs = MDYPlateOrderStaging.objects.all() - - # 支持按 raw 中的“设计编号/订单id”过滤(按你要求的 query param 名) - design_no = self.request.query_params.get(f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}") - if design_no: - qs = qs.filter(**{f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}": design_no}) - # 默认 -ctime;ctime 为空的放最后,避免老数据(未回填)跑到前面 - return qs.order_by(F("ctime").desc(nulls_last=True), F("id").desc()) + return MDYPlateOrderStaging.objects.all().order_by(F("ctime").desc(nulls_last=True), F("id").desc()) def filter_queryset(self, queryset): qs = super().filter_queryset(queryset) - design_no = self.request.query_params.get(f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}") - if design_no: - qs = qs.filter(**{f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}": design_no}) + + # 对前端暴露的过滤参数:只允许使用 “内部字段名”,像普通 query param 一样传递: + # - =... + # - __icontains=... + # - __isnull=true/false + # + # 额外支持: + # - mdy_rowid=...(精确匹配模型字段) + # - related_source=...(按关联表来源筛选,隐藏 related__contains 实现) + # + # 安全策略: + # - 禁止直接使用 raw__/related__ 前缀(避免绕过白名单) + # - 未在白名单内的过滤参数一律 400 + errors: dict[str, str] = {} + + for key, values in self.request.query_params.lists(): + if key in {"limit", "offset", "format"}: + continue + if not values: + continue + + # 明确禁止内部实现细节参数(即使存在也不处理) + if key.startswith("raw__") or key.startswith("related__"): + errors[key] = "not allowed" + continue + + # related_source:对外的“关联来源”过滤(等价于 related__contains=[{"source": "..."}]) + if key == "related_source": + q = Q() + for raw_value in values: + if raw_value is None or str(raw_value).strip() == "": + continue + q |= Q(related__contains=[{"source": str(raw_value)}]) + if q: + qs = qs.filter(q) + continue + + # mdy_rowid:模型字段过滤 + if key == "mdy_rowid": + q = Q() + for raw_value in values: + q |= Q(mdy_rowid=str(raw_value)) + qs = qs.filter(q) + continue + + # raw 字段过滤:仅允许 internal_field(plate_order_type_map 的 key) + parts = key.split("__") + base = parts[0] + if len(parts) == 1: + lookup = "exact" + elif len(parts) == 2: + lookup = parts[1] + else: + errors[key] = "unsupported lookup" + continue + + lookup = (lookup or "exact").lower() + if lookup not in _ALLOWED_LOOKUPS: + errors[key] = "unsupported lookup" + continue + + if base not in _PUBLIC_RAW_FIELDS: + errors[key] = "unsupported field" + continue + + control_id = plate_order_type_map.get(base) + if not control_id: + errors[key] = "unsupported field" + continue + + mapped_key = f"raw__{control_id}" + if lookup != "exact": + mapped_key += f"__{lookup}" + + q = Q() + for raw_value in values: + coerced = _coerce_value_for_lookup(lookup, raw_value) + q |= Q(**{mapped_key: coerced}) + + try: + qs = qs.filter(q) + except FieldError as exc: + errors[key] = str(exc) + + if errors: + raise ValidationError({"detail": "invalid filters", "filters": errors}) + return qs diff --git a/api_v1/views/mingdaoyun/test_plate_order_staging_api.py b/api_v1/views/mingdaoyun/test_plate_order_staging_api.py index 2f04ba2..04d9ddf 100644 --- a/api_v1/views/mingdaoyun/test_plate_order_staging_api.py +++ b/api_v1/views/mingdaoyun/test_plate_order_staging_api.py @@ -87,9 +87,9 @@ class MDYPlateOrderStagingAPITestCase(TestCase): self.assertIn("related", first) self.assertEqual(first["related"], []) - def test_filter_by_raw_design_no(self): + def test_filter_by_design_no(self): resp = self.client.get( - "/api/v1/mdy-plate-order-staging/?limit=10&offset=0&raw__62d52f4b8d2972284492dd0e=A001" + "/api/v1/mdy-plate-order-staging/?limit=10&offset=0&design_no=A001" ) self.assertEqual(resp.status_code, 200) @@ -99,3 +99,29 @@ class MDYPlateOrderStagingAPITestCase(TestCase): self.assertEqual(payload["results"][0]["raw"]["设计编号"], "A001") self.assertEqual(payload["results"][0]["related"][0]["source"], "drawing") + + def test_filter_by_style_name_icontains(self): + # 支持 __icontains=...(不暴露 raw__ 前缀) + resp = self.client.get( + "/api/v1/mdy-plate-order-staging/?limit=10&offset=0&style_name__icontains=款号" + ) + self.assertEqual(resp.status_code, 200) + payload = resp.json() + self.assertEqual(payload["count"], 2) + + def test_filter_by_related_source(self): + # related_source 是对外参数(隐藏 related__contains 实现) + resp = self.client.get( + "/api/v1/mdy-plate-order-staging/?limit=10&offset=0&related_source=drawing" + ) + self.assertEqual(resp.status_code, 200) + payload = resp.json() + self.assertEqual(payload["count"], 1) + self.assertEqual(payload["results"][0]["mdy_rowid"], "row1") + + def test_rejects_raw_prefix_params(self): + # 对外 API 禁止直接使用 raw__/related__ 前缀 + resp = self.client.get( + "/api/v1/mdy-plate-order-staging/?limit=10&offset=0&raw__design_no=A001" + ) + self.assertEqual(resp.status_code, 400) diff --git a/business/tests.py b/business/tests.py index 4501169..dbcbc6d 100644 --- a/business/tests.py +++ b/business/tests.py @@ -1245,9 +1245,9 @@ class SalesOrderConcurrencyTestCase(TransactionTestCase): close_old_connections() try: services.review_sales_order( - sales_order_id=self.sales_order.id, - target_status=business_models.SalesOrderStatusEnum.APPROVED, - reviewed_by=self.user, + sales_order_id=self.sales_order.id, + target_status=business_models.SalesOrderStatusEnum.APPROVED, + reviewed_by=self.user, ) finally: connections.close_all() diff --git a/docs/api_v1_mdy_plate_order_staging.md b/docs/api_v1_mdy_plate_order_staging.md index 8d52613..2b35326 100644 --- a/docs/api_v1_mdy_plate_order_staging.md +++ b/docs/api_v1_mdy_plate_order_staging.md @@ -6,7 +6,7 @@ - **只读**:仅提供 `list/retrieve`(不支持创建/更新/删除) - **分页**:LimitOffset(`limit` / `offset`) - **默认排序**:按 `ctime` 倒序(`-ctime`),并将 `ctime = null` 的记录排在最后 -- **过滤**:支持按明道云字段 `62d52f4b8d2972284492dd0e`(设计编号/订单id)精确匹配 +- **过滤**:支持按“内部字段名”的普通 query param 过滤(对前端隐藏 JSONField 实现细节) - **返回结构**:`raw` 与 `related` 两个 JSONField 在输出时会做“可读化/碾平”处理 ### Endpoint @@ -23,12 +23,15 @@ - **offset**:偏移量 #### 过滤 -- **raw__62d52f4b8d2972284492dd0e**:设计编号/订单id(精确匹配) +- **design_no**:设计编号(精确匹配) +- **style_name__icontains**:款号名称(模糊匹配) + +> 该接口会拒绝任何以 `raw__` / `related__` 开头的参数(避免前端绕过白名单)。 示例: ```http -GET /api/v1/mdy-plate-order-staging/?limit=20&offset=0&raw__62d52f4b8d2972284492dd0e=82724 +GET /api/v1/mdy-plate-order-staging/?limit=20&offset=0&design_no=82724 ``` ### 默认排序规则 @@ -43,7 +46,7 @@ GET /api/v1/mdy-plate-order-staging/?limit=20&offset=0&raw__62d52f4b8d2972284492 - **ctime**:明道云系统字段 ctime(模型字段,便于排序) - **utime**:明道云系统字段 utime(模型字段) - **created_at / updated_at**:本地记录创建/更新时间(ModelBase) -- **design_no**:便捷字段,等价于 `raw["62d52f4b8d2972284492dd0e"]` +- **design_no**:便捷字段,等价于 `raw` 中的设计编号值 - **raw**:对 `MDYPlateOrderStaging.raw` 的“可读化/碾平”输出 - **related**:对 `MDYPlateOrderStaging.related` 的“碾平”输出(list[dict]) @@ -103,7 +106,7 @@ GET /api/v1/mdy-plate-order-staging/?limit=20&offset=0&raw__62d52f4b8d2972284492 ``` ### 性能说明 -- 过滤字段 `raw__62d52f4b8d2972284492dd0e` 对应 PostgreSQL 表达式索引(见迁移 `api_v1/migrations/0006_mdy_plate_order_staging_raw_id_index.py`),用于加速按设计编号检索。 +- `design_no` 过滤对应 PostgreSQL 表达式索引(见迁移 `api_v1/migrations/0006_mdy_plate_order_staging_raw_id_index.py`),用于加速按设计编号检索。 ### 实现位置 - View/Serializer:`api_v1/views/mingdaoyun/plate_order_staging.py` diff --git a/flower/viewsets.py b/flower/viewsets.py index 1f8719e..ce5974a 100644 --- a/flower/viewsets.py +++ b/flower/viewsets.py @@ -15,8 +15,8 @@ class LimitedLimitOffsetPagination(LimitOffsetPagination): - 最大每页返回 100 条 - 防止前端请求过多数据 """ - default_limit = 100 - max_limit = 100 + default_limit = 300 + max_limit = 300 class LimitedModelViewSet(viewsets.ModelViewSet):