From 25587dce21f1576c47399fd287c637b52e9d54f4 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 27 Apr 2026 09:34:29 +0800 Subject: [PATCH] =?UTF-8?q?perf(h5):=20=E5=90=88=E5=B9=B6=E4=B8=BA?= =?UTF-8?q?=E5=8D=95=E6=AC=A1=20category-tree=20=E6=8E=A5=E5=8F=A3=20+=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 /material/category-tree/ 一次性聚合 (大类→种类→子类),使用 Django cache 全局缓存(30 分钟) - Material post_save / post_delete 信号自动失效缓存,保证一致性 - H5 首页从 4 次并行请求降为 1 次;命中缓存时不打 DB Co-Authored-By: Claude Opus 4.7 --- backend/apps/material/apps.py | 9 +++++ backend/apps/material/signals.py | 21 +++++++++++ backend/apps/material/views.py | 55 +++++++++++++++++++++++++++ frontend-h5/src/api/material.js | 1 + frontend-h5/src/views/Home.vue | 64 +++++++++++++------------------- 5 files changed, 111 insertions(+), 39 deletions(-) create mode 100644 backend/apps/material/apps.py create mode 100644 backend/apps/material/signals.py diff --git a/backend/apps/material/apps.py b/backend/apps/material/apps.py new file mode 100644 index 0000000..40305c1 --- /dev/null +++ b/backend/apps/material/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class MaterialConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.material' + + def ready(self): + from . import signals # noqa: F401 diff --git a/backend/apps/material/signals.py b/backend/apps/material/signals.py new file mode 100644 index 0000000..6ce182e --- /dev/null +++ b/backend/apps/material/signals.py @@ -0,0 +1,21 @@ +from django.core.cache import cache +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from .models import Material + +CATEGORY_TREE_CACHE_KEY = 'material:category_tree:approved' + + +def invalidate_category_tree_cache(): + cache.delete(CATEGORY_TREE_CACHE_KEY) + + +@receiver(post_save, sender=Material) +def _on_material_saved(sender, instance, **kwargs): + invalidate_category_tree_cache() + + +@receiver(post_delete, sender=Material) +def _on_material_deleted(sender, instance, **kwargs): + invalidate_category_tree_cache() diff --git a/backend/apps/material/views.py b/backend/apps/material/views.py index 6149457..17c2fd6 100644 --- a/backend/apps/material/views.py +++ b/backend/apps/material/views.py @@ -15,9 +15,11 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from rest_framework.exceptions import PermissionDenied from rest_framework.parsers import MultiPartParser +from django.core.cache import cache from .models import Material, MaterialCategory, MaterialSubcategory from .serializers import MaterialSerializer, MaterialListSerializer, MaterialCategorySerializer, MaterialSubcategorySerializer from .importers import import_materials_plan_excel +from .signals import CATEGORY_TREE_CACHE_KEY from apps.factory.models import COOPERATION_MODE_CHOICES @@ -484,6 +486,59 @@ class MaterialViewSet(ModelViewSet): return Response(result) + @action(detail=False, methods=['get'], url_path='category-tree', permission_classes=[IsAuthenticated]) + def category_tree(self, request): + """ + H5 浏览端的全量种类树:一次返回所有大类 → 材料种类 → 子类的聚合结果。 + 与用户无关(仅依赖 status='approved'),故走全局缓存;Material 变更时由 signals 失效。 + """ + cached = cache.get(CATEGORY_TREE_CACHE_KEY) + if cached is not None: + return Response(cached) + + rows = (Material.objects + .filter(status='approved') + .exclude(major_category__isnull=True).exclude(major_category__exact='') + .exclude(material_category__isnull=True).exclude(material_category__exact='') + .values('major_category', 'material_category', 'material_subcategory') + .annotate(count=Count('id')) + .order_by('major_category', 'material_category', 'material_subcategory')) + + majors = {} + for row in rows: + mj = row['major_category'] + cat = row['material_category'] + sub = row['material_subcategory'] or '' + cnt = row['count'] + + major_node = majors.setdefault(mj, {}) + cat_node = major_node.setdefault(cat, {'count': 0, 'subs': {}}) + cat_node['count'] += cnt + if sub: + cat_node['subs'][sub] = cat_node['subs'].get(sub, 0) + cnt + + major_display = dict(Material.MAJOR_CATEGORY_CHOICES) + data = [] + for mj_value, cats in majors.items(): + data.append({ + 'value': mj_value, + 'label': major_display.get(mj_value, mj_value), + 'categories': [ + { + 'value': cat, + 'count': node['count'], + 'subcategories': [ + {'value': s, 'count': c} + for s, c in sorted(node['subs'].items()) + ], + } + for cat, node in sorted(cats.items()) + ], + }) + + cache.set(CATEGORY_TREE_CACHE_KEY, data, timeout=60 * 30) + return Response(data) + @action(detail=False, methods=['get'], url_path='categories-by-major') def categories_by_major(self, request): major = request.query_params.get('major_category') diff --git a/frontend-h5/src/api/material.js b/frontend-h5/src/api/material.js index 60c277d..b0c0cb4 100644 --- a/frontend-h5/src/api/material.js +++ b/frontend-h5/src/api/material.js @@ -2,6 +2,7 @@ import api from './client' export const fetchMaterials = async (params) => (await api.get('/material/', { params })).data export const fetchMaterialDetail = async (id) => (await api.get(`/material/${id}/`)).data +export const fetchCategoryTree = async () => (await api.get('/material/category-tree/')).data export const fetchCategoriesByMajor = async (major_category) => (await api.get('/material/categories-by-major/', { params: { major_category } })).data export const fetchSubcategoriesByCategory = async (major_category, material_category) => diff --git a/frontend-h5/src/views/Home.vue b/frontend-h5/src/views/Home.vue index cc0549c..7da8536 100644 --- a/frontend-h5/src/views/Home.vue +++ b/frontend-h5/src/views/Home.vue @@ -1,60 +1,51 @@