1
0
forked from erp-dev/erp

feat: mdy staging api filters

This commit is contained in:
2025-12-29 15:36:00 +08:00
parent ce0cf3256c
commit 9bb2eab269
5 changed files with 151 additions and 25 deletions

View File

@@ -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})
# 默认 -ctimectime 为空的放最后,避免老数据(未回填)跑到前面
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 一样传递:
# - <field>=...
# - <field>__icontains=...
# - <field>__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_fieldplate_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

View File

@@ -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):
# 支持 <internal_field>__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)

View File

@@ -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`

View File

@@ -15,8 +15,8 @@ class LimitedLimitOffsetPagination(LimitOffsetPagination):
- 最大每页返回 100 条
- 防止前端请求过多数据
"""
default_limit = 100
max_limit = 100
default_limit = 300
max_limit = 300
class LimitedModelViewSet(viewsets.ModelViewSet):