Ver código fonte

优化小程序客户端核心接口性能并固化执行规范。

本次完成 mini/client/products 与 mini/client/price-list/current 的 N+1 消除与批量查询优化,补充小程序接口性能排查报告与原始采样数据,并将 mini/client 慢接口闭环流程纳入 .cursorrules 强制规范。

Made-with: Cursor
zlwu 6 meses atrás
pai
commit
3557d21340

+ 21 - 0
.cursorrules

@@ -657,3 +657,24 @@ def list_parameters(...):
   - 必须同步更新 `docs/web-admin-接口性能测试报告-*.md`
   - 必须同步更新 `docs/web-admin-接口性能测试报告-*.md`
   - 必须同步更新 `docs/后端接口全覆盖测试与数据库校验实施跟踪.md`
   - 必须同步更新 `docs/后端接口全覆盖测试与数据库校验实施跟踪.md`
   - 未更新文档视为性能优化流程未完成。
   - 未更新文档视为性能优化流程未完成。
+
+---
+
+## 小程序慢接口优化执行规范(强制)
+
+- 当识别到 `mini/client/*` 慢接口(建议阈值:`p95 >= 800ms`)后,必须执行完整闭环:
+  1. 基线采样(固定口径:账号、参数、采样次数,记录 `avg/p95/p99/max`)
+  2. 慢点定位(代码 N+1、循环查库、字段冗余;必要时 SQL EXPLAIN)
+  3. 优化落地(批量查询 + 字段投影 + count 优化 + 必要索引/缓存)
+  4. 同口径复测(输出前后对比,确认收益)
+  5. 文档留痕(报告 + 跟踪文档同步)
+- 列表接口强约束:
+  - 禁止循环内逐条查询商品/分类/单位等关联数据
+  - 必须优先使用批量查询与 map 回填
+  - 必须按需字段投影,避免无差别整行读取
+- 规则适用范围:
+  - 至少覆盖 `mini/client/products`、`mini/client/price-list/current`、`mini/client/cart*`、`mini/client/orders*` 等高频接口
+- 留痕要求:
+  - 必须同步更新 `docs/sqxp-wx-接口性能排查报告-*.md`
+  - 必须同步更新 `docs/后端接口全覆盖测试与数据库校验实施跟踪.md`
+  - 未同步文档视为优化流程未完成。

+ 47 - 25
backend/app/api/mini_client.py

@@ -3,11 +3,21 @@ import re
 from typing import Optional
 from typing import Optional
 from fastapi import APIRouter, Depends, Request, Query
 from fastapi import APIRouter, Depends, Request, Query
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
+from sqlalchemy import and_
 
 
 from app.core.database import get_db
 from app.core.database import get_db
 from app.core.auth import get_current_user, get_client_ip
 from app.core.auth import get_current_user, get_client_ip
 from app.core.response import success_response, error_response
 from app.core.response import success_response, error_response
-from app.models.models import BasAddress, BasProduct, BizDeliveryTask, BizOrderItem, SysUser
+from app.models.models import (
+    BasAddress,
+    BasProduct,
+    BasProductCategory,
+    BasProductUnit,
+    BizDeliveryTask,
+    BizOrderItem,
+    BizPriceListItem,
+    SysUser,
+)
 from app.crud.address import address_crud
 from app.crud.address import address_crud
 from app.crud.user import user_crud
 from app.crud.user import user_crud
 from app.crud.region import region_crud
 from app.crud.region import region_crud
@@ -286,8 +296,33 @@ def get_current_price_list(
     if not price_list:
     if not price_list:
         return success_response(None)
         return success_response(None)
 
 
-    # 获取价格项
-    items = price_list_item_crud.get_by_price_list(db, price_list.id)
+    # 批量拉取价格项 + 商品/分类/单位,避免循环 N+1
+    items = (
+        db.query(
+            BizPriceListItem.product_id,
+            BizPriceListItem.unit_id,
+            BizPriceListItem.price,
+            BasProduct.name.label("product_name"),
+            BasProduct.image_url.label("image_url"),
+            BasProduct.category_id.label("category_id"),
+            BasProductCategory.name.label("category_name"),
+            BasProductUnit.name.label("unit_name"),
+        )
+        .outerjoin(
+            BasProduct,
+            and_(BizPriceListItem.product_id == BasProduct.id, BasProduct.is_del == 0),
+        )
+        .outerjoin(
+            BasProductCategory,
+            and_(BasProduct.category_id == BasProductCategory.id, BasProductCategory.is_del == 0),
+        )
+        .outerjoin(
+            BasProductUnit,
+            and_(BizPriceListItem.unit_id == BasProductUnit.id, BasProductUnit.is_del == 0),
+        )
+        .filter(BizPriceListItem.price_list_id == price_list.id, BizPriceListItem.is_del == 0)
+        .all()
+    )
 
 
     # 获取关联信息
     # 获取关联信息
     region = region_crud.get(db, price_list.region_id)
     region = region_crud.get(db, price_list.region_id)
@@ -296,31 +331,18 @@ def get_current_price_list(
     supplier = user_crud.get(db, price_list.supplier_id)
     supplier = user_crud.get(db, price_list.supplier_id)
     supplier_name = supplier.real_name if supplier else None
     supplier_name = supplier.real_name if supplier else None
 
 
-    item_details = []
-    for item in items:
-        from app.crud.product import product_crud
-        from app.crud.category import category_crud
-        from app.crud.unit import unit_crud
-
-        product = product_crud.get(db, item.product_id)
-        category_name = None
-        if product and product.category_id:
-            category = category_crud.get(db, product.category_id)
-            category_name = category.name if category else None
-        unit_name = None
-        if item.unit_id:
-            u = unit_crud.get(db, item.unit_id)
-            unit_name = u.name if u else None
-
-        item_details.append({
+    item_details = [
+        {
             "productId": item.product_id,
             "productId": item.product_id,
-            "productName": product.name if product else None,
-            "categoryName": category_name,
+            "productName": item.product_name,
+            "categoryName": item.category_name,
             "unitId": item.unit_id,
             "unitId": item.unit_id,
-            "unitName": unit_name,
+            "unitName": item.unit_name,
             "price": float(item.price) if item.price else 0,
             "price": float(item.price) if item.price else 0,
-            "imageUrl": product.image_url if product else None
-        })
+            "imageUrl": item.image_url,
+        }
+        for item in items
+    ]
 
 
     return success_response({
     return success_response({
         "id": price_list.id,
         "id": price_list.id,

+ 84 - 44
backend/app/api/mini_client_extra.py

@@ -13,7 +13,7 @@ from app.core.utils import generate_id
 from datetime import datetime
 from datetime import datetime
 import random
 import random
 
 
-from app.models.models import BizInvoice, BizWechatPay
+from app.models.models import BizInvoice, BizWechatPay, BizPriceListItem, BasProductUnit
 from app.crud.user import user_crud
 from app.crud.user import user_crud
 from app.crud.address import address_crud
 from app.crud.address import address_crud
 from app.crud.client_cart import client_cart_crud
 from app.crud.client_cart import client_cart_crud
@@ -576,60 +576,100 @@ def list_products(
     pl = price_list_crud.get_active(db, user.region_id)
     pl = price_list_crud.get_active(db, user.region_id)
     if not pl:
     if not pl:
         return success_response({"list": [], "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0})
         return success_response({"list": [], "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0})
-    items = price_list_item_crud.get_by_price_list(db, pl.id)
+    item_rows = (
+        db.query(
+            BizPriceListItem.product_id,
+            BizPriceListItem.unit_id,
+            BizPriceListItem.price,
+            BasProduct.id.label("pid"),
+            BasProduct.name.label("product_name"),
+            BasProduct.category_id.label("category_id"),
+            BasProduct.measure_value.label("measure_value"),
+            BasProduct.description.label("description"),
+            BasProduct.image_url.label("image_url"),
+            BasProductUnit.name.label("unit_name"),
+        )
+        .join(
+            BasProduct,
+            and_(BizPriceListItem.product_id == BasProduct.id, BasProduct.is_del == 0),
+        )
+        .outerjoin(
+            BasProductUnit,
+            and_(BizPriceListItem.unit_id == BasProductUnit.id, BasProductUnit.is_del == 0),
+        )
+        .filter(BizPriceListItem.price_list_id == pl.id, BizPriceListItem.is_del == 0)
+        .all()
+    )
+
+    category_ids = {r.category_id for r in item_rows if getattr(r, "category_id", None)}
+    cat_rows = (
+        db.query(BasProductCategory)
+        .filter(BasProductCategory.id.in_(category_ids), BasProductCategory.is_del == 0)
+        .all()
+        if category_ids
+        else []
+    )
+    cat_cache: Dict[str, Optional[BasProductCategory]] = {c.id: c for c in cat_rows}
+
+    # 补齐父链缓存,避免排序/根分类判断时再次打 DB
+    pending = [c for c in cat_rows if getattr(c, "parent_id", None)]
+    while pending:
+        parent_ids = {c.parent_id for c in pending if c.parent_id and c.parent_id not in cat_cache}
+        if not parent_ids:
+            break
+        parents = (
+            db.query(BasProductCategory)
+            .filter(BasProductCategory.id.in_(parent_ids), BasProductCategory.is_del == 0)
+            .all()
+        )
+        if not parents:
+            break
+        for p in parents:
+            cat_cache[p.id] = p
+        pending = [p for p in parents if getattr(p, "parent_id", None)]
+
+    def _root_category_id_from_cache(cid: Optional[str]) -> Optional[str]:
+        if not cid:
+            return None
+        cur = cid
+        seen = set()
+        for _ in range(32):
+            if not cur or cur in seen:
+                break
+            seen.add(cur)
+            c = cat_cache.get(cur)
+            if not c:
+                return cur
+            if not getattr(c, "parent_id", None):
+                return c.id
+            cur = c.parent_id
+        return cur
 
 
-    root_memo: Dict[str, Optional[str]] = {}
     rows = []
     rows = []
-    for item in items:
-        product = product_crud.get(db, item.product_id)
-        if not product:
+    kw = keyword.lower() if keyword else None
+    for item in item_rows:
+        if categoryId and item.category_id != categoryId:
             continue
             continue
-        if categoryId and product.category_id != categoryId:
+        if rootCategoryId and _root_category_id_from_cache(item.category_id) != rootCategoryId:
             continue
             continue
-        if rootCategoryId:
-            rid = _root_category_id_for_leaf(db, product.category_id, root_memo)
-            if rid != rootCategoryId:
-                continue
-        if keyword and keyword.lower() not in (product.name or "").lower():
+        if kw and kw not in (item.product_name or "").lower():
             continue
             continue
-        unit = unit_crud.get(db, item.unit_id) if item.unit_id else None
-        cat_name = ""
-        if product.category_id:
-            cat = category_crud.get(db, product.category_id)
-            cat_name = cat.name if cat else ""
+        cat = cat_cache.get(item.category_id) if item.category_id else None
         rows.append({
         rows.append({
-            "id": product.id,
-            "name": product.name,
-            "categoryId": product.category_id,
-            "categoryName": cat_name,
+            "id": item.pid,
+            "name": item.product_name,
+            "categoryId": item.category_id,
+            "categoryName": cat.name if cat else "",
             "unitId": item.unit_id,
             "unitId": item.unit_id,
-            "unitName": unit.name if unit else "",
-            "measureValue": float(product.measure_value) if getattr(product, "measure_value", None) is not None else None,
-            "description": product.description,
-            "imageUrl": product.image_url,
+            "unitName": item.unit_name or "",
+            "measureValue": float(item.measure_value) if item.measure_value is not None else None,
+            "description": item.description,
+            "imageUrl": item.image_url,
             "price": float(item.price) if item.price else 0,
             "price": float(item.price) if item.price else 0,
             "isActive": True,
             "isActive": True,
-            "sortOrder": int(getattr(product, "sort_order", None) or 0),
+            "sortOrder": 0,
         })
         })
 
 
-    cat_cache: Dict[str, Optional[BasProductCategory]] = {}
-
-    def _ensure_category_chain_cached(cid: Optional[str]) -> None:
-        cur = cid
-        for _ in range(32):
-            if not cur:
-                break
-            if cur in cat_cache:
-                c = cat_cache[cur]
-                cur = c.parent_id if c else None
-                continue
-            c = category_crud.get(db, cur)
-            cat_cache[cur] = c
-            cur = c.parent_id if c else None
-
-    for row in rows:
-        _ensure_category_chain_cached(row.get("categoryId"))
-
     def _sort_tuple(r: dict) -> Tuple:
     def _sort_tuple(r: dict) -> Tuple:
         cid = r.get("categoryId")
         cid = r.get("categoryId")
         first_ord = 0
         first_ord = 0

+ 459 - 0
docs/sqxp-wx-接口性能排查-raw-2026-04-25.json

@@ -0,0 +1,459 @@
+{
+  "endpoint_total": 30,
+  "get_total": 20,
+  "results": [
+    {
+      "method": "GET",
+      "url": "/mini/auth/current-user",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 147.93,
+        "p95_ms": 196.96,
+        "p99_ms": 196.96,
+        "max_ms": 210.87,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'id': 'U_CLIENT_20260330002', 'username': 'client02', 'realName': '演示客户乙', 'phone': '13900003002', 'role': 'client', 'regionId': '3349ED3D7C2B44A6B944AA2916542785', 'regionName': '兰州西区1', 'isActi",
+      "files": [
+        "stores/user.js",
+        "unpackage/dist/dev/mp-weixin/stores/user.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/client/addresses",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 149.23,
+        "p95_ms": 197.38,
+        "p99_ms": 197.38,
+        "max_ms": 243.01,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': [{'id': '2CD4DDD67677457398B98B92CA7ED03E', 'userId': 'U_CLIENT_20260330002', 'addressType': 'delivery', 'contactPerson': '12', 'contactPhone': '13877112232', 'province': '北京市', 'city': '北京市辖区', '",
+      "files": [
+        "pages/client/address/address.vue",
+        "pages/client/address/select.vue",
+        "pages/client/cart/cart.vue",
+        "pages/client/order/create/create.vue",
+        "unpackage/dist/dev/mp-weixin/pages/client/address/address.js",
+        "unpackage/dist/dev/mp-weixin/pages/client/address/select.js",
+        "unpackage/dist/dev/mp-weixin/pages/client/cart/cart.js",
+        "unpackage/dist/dev/mp-weixin/pages/client/order/create/create.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/client/cart",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 226.93,
+        "p95_ms": 273.72,
+        "p99_ms": 273.72,
+        "max_ms": 274.18,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'list': [{'id': '9DDE0CF5D97945C99940C1904F6D18B5', 'productId': '3D4D221AAA4E45C795174A98BD914D54', 'productName': '测试001', 'categoryId': 'A072C766C3024856B86ED00118B23654', 'firstCategoryId': '",
+      "files": [
+        "pages/client/cart/cart.vue",
+        "stores/cart.js",
+        "unpackage/dist/dev/mp-weixin/pages/client/cart/cart.js",
+        "unpackage/dist/dev/mp-weixin/stores/cart.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/client/cart/summary",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 186.7,
+        "p95_ms": 239.33,
+        "p99_ms": 239.33,
+        "max_ms": 254.4,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'lineCount': 1, 'totalQuantity': 2.0, 'totalAmount': 30.0}, 'timestamp': 1777126545356}",
+      "files": [
+        "stores/cart.js",
+        "unpackage/dist/dev/mp-weixin/stores/cart.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/client/orders",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 219.16,
+        "p95_ms": 264.1,
+        "p99_ms": 264.1,
+        "max_ms": 268.78,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'list': [{'id': 'C62B59298F7F4153BD36A024DF6492C5', 'orderNo': 'ORD202604230001', 'totalAmount': 15.0, 'paidAmount': 0, 'status': 'cancelled', 'orderStatus': 'cancelled', 'deliveryStatus': 'pendi",
+      "files": [
+        "pages/client/order/order.vue",
+        "unpackage/dist/dev/mp-weixin/pages/client/order/order.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/client/price-list/active-version",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 156.94,
+        "p95_ms": 220.12,
+        "p99_ms": 220.12,
+        "max_ms": 225.65,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'priceListId': '9833CE385008430ABB8F6A24722CEB14', 'priceVersion': 26, 'publishedTime': '2026-04-23 19:06:31'}, 'timestamp': 1777126548499}",
+      "files": [
+        "utils/price-realtime.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/client/price-list/current",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 724.0,
+        "p95_ms": 785.51,
+        "p99_ms": 785.51,
+        "max_ms": 841.48,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'id': '9833CE385008430ABB8F6A24722CEB14', 'regionId': '3349ED3D7C2B44A6B944AA2916542785', 'regionName': '兰州西区1', 'supplierId': '7B6DB4DFC20B4C888ACDABC7D52E5362', 'supplierName': '兰州西区供应商1', 'ver",
+      "files": [
+        "pages/client/order/create/create.vue",
+        "unpackage/dist/dev/mp-weixin/pages/client/order/create/create.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/client/product-categories/first-level",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 438.38,
+        "p95_ms": 456.66,
+        "p99_ms": 456.66,
+        "max_ms": 477.97,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'list': [{'id': 'A072C766C3024856B86ED00118B23654', 'name': '蔬菜', 'imageUrl': 'https://www.chengyantek.cn/sanqiang/categories/202604/02/6075a5fe46b5480cb8f2d464ee26948a.jpeg'}, {'id': 'A78283EDD9",
+      "files": [
+        "pages/client/cart/cart.vue",
+        "pages/client/home/home.vue",
+        "unpackage/dist/dev/mp-weixin/pages/client/cart/cart.js",
+        "unpackage/dist/dev/mp-weixin/pages/client/home/home.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/client/product-categories/sidebar",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 427.62,
+        "p95_ms": 456.67,
+        "p99_ms": 456.67,
+        "max_ms": 462.92,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'list': [{'id': 'all', 'name': '全部'}, {'id': 'A072C766C3024856B86ED00118B23654', 'name': '蔬菜'}, {'id': 'A78283EDD93C459F9DC907548552ACF8', 'name': '厨房用品'}, {'id': '5CFCB4050E5D4FD0A767CDC4D28983A",
+      "files": [
+        "pages/client/home/home.vue",
+        "unpackage/dist/dev/mp-weixin/pages/client/home/home.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/client/products",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 759.1,
+        "p95_ms": 798.84,
+        "p99_ms": 798.84,
+        "max_ms": 868.6,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'list': [{'id': '90EE58E1372D4EC5A80353FA3FEF9498', 'name': '测试007', 'categoryId': 'A072C766C3024856B86ED00118B23654', 'categoryName': '蔬菜', 'unitId': 'UNIT_005', 'unitName': '个', 'measureValue':",
+      "files": [
+        "pages/client/home/home.vue",
+        "unpackage/dist/dev/mp-weixin/pages/client/home/home.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/common/china-region-tree",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 178.75,
+        "p95_ms": 214.57,
+        "p99_ms": 214.57,
+        "max_ms": 429.86,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'tree': [{'value': '110000', 'label': '北京市', 'children': [{'value': '110100', 'label': '北京市辖区', 'children': [{'value': '110101', 'label': '东城区'}, {'value': '110102', 'label': '西城区'}, {'value': '1",
+      "files": [
+        "unpackage/dist/dev/mp-weixin/utils/chinaRegion.js",
+        "utils/chinaRegion.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/common/service-contact",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 287.35,
+        "p95_ms": 325.87,
+        "p99_ms": 325.87,
+        "max_ms": 330.76,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'servicePhone': '13866751937'}, 'timestamp': 1777126570111}",
+      "files": [
+        "unpackage/dist/dev/mp-weixin/utils/service-contact.js",
+        "utils/service-contact.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/deliverer/snatch/pending?page=1&pageSize=20",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 184.61,
+        "p95_ms": 238.05,
+        "p99_ms": 238.05,
+        "max_ms": 240.54,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'list': [], 'total': 0, 'page': 1, 'pageSize': 20, 'totalPages': 0}, 'timestamp': 1777126572318}",
+      "files": [
+        "pages/deliverer/snatch/snatch.vue",
+        "unpackage/dist/dev/mp-weixin/pages/deliverer/snatch/snatch.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/deliverer/tasks/statistics",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 348.69,
+        "p95_ms": 385.91,
+        "p99_ms": 385.91,
+        "max_ms": 386.81,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'todayPending': 0, 'todayDelivering': 0, 'todayCompleted': 0, 'weekCompleted': 0, 'monthCompleted': 0, 'rating': 0.0, 'ratingCount': 0}, 'timestamp': 1777126573942}",
+      "files": [
+        "pages/deliverer/home/home.vue",
+        "unpackage/dist/dev/mp-weixin/pages/deliverer/home/home.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/supplier/dashboard/statistics",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 319.85,
+        "p95_ms": 353.01,
+        "p99_ms": 353.01,
+        "max_ms": 373.51,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'pendingInquiries': 2, 'myQuotations': 0, 'pendingTasks': 0, 'todayDeliveries': 0}, 'timestamp': 1777126576799}",
+      "files": [
+        "pages/supplier/home/home.vue",
+        "unpackage/dist/dev/mp-weixin/pages/supplier/home/home.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/supplier/inquiries",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "400": 8
+      },
+      "metrics": {
+        "avg_ms": null,
+        "p95_ms": null,
+        "p99_ms": null,
+        "max_ms": null,
+        "success_samples": 0
+      },
+      "sample": "{'code': 400, 'message': '用户未绑定区域', 'data': None, 'timestamp': 1777126579119}",
+      "files": [
+        "pages/supplier/inquiry/inquiry.vue",
+        "unpackage/dist/dev/mp-weixin/pages/supplier/inquiry/inquiry.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/supplier/pickup-addresses",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 162.43,
+        "p95_ms": 211.64,
+        "p99_ms": 211.64,
+        "max_ms": 214.51,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': [], 'timestamp': 1777126580433}",
+      "files": [
+        "pages/supplier/address/address.vue",
+        "unpackage/dist/dev/mp-weixin/pages/supplier/address/address.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/supplier/quotations",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "404": 8
+      },
+      "metrics": {
+        "avg_ms": null,
+        "p95_ms": null,
+        "p99_ms": null,
+        "max_ms": null,
+        "success_samples": 0
+      },
+      "sample": "{'code': 404, 'message': '用户不存在', 'data': None, 'timestamp': 1777126581650}",
+      "files": [
+        "pages/supplier/price-list/price-list.vue",
+        "pages/supplier/quotation/quotation.vue",
+        "unpackage/dist/dev/mp-weixin/pages/supplier/price-list/price-list.js",
+        "unpackage/dist/dev/mp-weixin/pages/supplier/quotation/quotation.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/mini/supplier/tasks",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "404": 8
+      },
+      "metrics": {
+        "avg_ms": null,
+        "p95_ms": null,
+        "p99_ms": null,
+        "max_ms": null,
+        "success_samples": 0
+      },
+      "sample": "{'code': 404, 'message': '用户不存在', 'data': None, 'timestamp': 1777126582873}",
+      "files": [
+        "pages/supplier/delivery/delivery.vue",
+        "pages/supplier/home/home.vue",
+        "unpackage/dist/dev/mp-weixin/pages/supplier/delivery/delivery.js",
+        "unpackage/dist/dev/mp-weixin/pages/supplier/home/home.js"
+      ]
+    },
+    {
+      "method": "GET",
+      "url": "/web/admin/system-config/get/auto_cancel_minutes",
+      "status_counts": {
+        "200": 8
+      },
+      "biz_counts": {
+        "200": 8
+      },
+      "metrics": {
+        "avg_ms": 100.96,
+        "p95_ms": 96.75,
+        "p99_ms": 96.75,
+        "max_ms": 172.94,
+        "success_samples": 8
+      },
+      "sample": "{'code': 200, 'message': 'success', 'data': {'key': 'auto_cancel_minutes', 'value': '40', 'type': 'number'}, 'timestamp': 1777126584127}",
+      "files": [
+        "pages/client/order/create/create.vue",
+        "unpackage/dist/dev/mp-weixin/pages/client/order/create/create.js"
+      ]
+    }
+  ]
+}

+ 118 - 0
docs/sqxp-wx-接口性能排查报告-2026-04-25.md

@@ -0,0 +1,118 @@
+# sqxp-wx 微信小程序接口性能排查报告(2026-04-25)
+
+## 1. 排查目标
+
+- 对 `sqxp-wx` 小程序界面实际调用的后端接口做一次整体性能摸排。
+- 识别高延迟接口与潜在风险点,输出可直接执行的后续优化建议。
+
+## 2. 排查范围与口径
+
+- 代码来源:`sqxp-wx` 业务代码(`pages/`、`stores/`、`utils/`,排除 `uni_modules/`、`wxcomponents/`)。
+- 自动提取到接口定义:`30` 个(含 `GET/POST/PUT/DELETE`)。
+- 本轮压测对象:`GET` 接口 `20` 个(避免写接口污染数据)。
+- 认证方式:使用 `client02` 登录态 Token(小程序登录流程,RSA 加密)。
+- 采样方式:串行采样,每接口 `N=8`。
+- 成功判定:`HTTP 200` 且业务 `code=200`(或无业务码)。
+
+> 原始采样明细见:`docs/sqxp-wx-接口性能排查-raw-2026-04-25.json`
+
+## 3. 总体结果
+
+- `GET` 接口总数:`20`
+- 可成功压测:`17`
+- 未成功压测:`3`(均为供应商侧接口,当前账号权限/数据前置不满足)
+- `p95 >= 800ms` 的接口:`0`
+
+结论:本轮未发现“高风险慢接口”(按 `p95 >= 800ms` 标准),但有 2 个接口接近阈值,建议列为重点观察对象。
+
+## 4. 重点观察接口(按 p95 降序)
+
+| 接口 | 平均耗时 | P95 | P99 | Max | 结论 |
+|---|---:|---:|---:|---:|---|
+| `GET /mini/client/products` | 759.10ms | 798.84ms | 798.84ms | 868.60ms | 接近风险阈值,建议优先专项优化 |
+| `GET /mini/client/price-list/current` | 724.00ms | 785.51ms | 785.51ms | 841.48ms | 接近风险阈值,建议优先专项优化 |
+| `GET /mini/client/product-categories/sidebar` | 427.62ms | 456.67ms | 456.67ms | 462.92ms | 中等延迟,可持续观察 |
+| `GET /mini/client/product-categories/first-level` | 438.38ms | 456.66ms | 456.66ms | 477.97ms | 中等延迟,可持续观察 |
+| `GET /mini/deliverer/tasks/statistics` | 348.69ms | 385.91ms | 385.91ms | 386.81ms | 可接受 |
+
+## 5. 压测失败/待补测接口
+
+以下接口在当前 `client02` 登录态下无法获得成功样本,属于“权限/数据前置不匹配”,不代表性能异常:
+
+| 接口 | 结果 | 返回码分布 | 说明 |
+|---|---|---|---|
+| `GET /mini/supplier/inquiries` | 失败 | `HTTP 200, biz_code=400` | 提示“用户未绑定区域” |
+| `GET /mini/supplier/quotations` | 失败 | `HTTP 200, biz_code=404` | 提示“用户不存在” |
+| `GET /mini/supplier/tasks` | 失败 | `HTTP 200, biz_code=404` | 提示“用户不存在” |
+
+建议后续补齐供应商账号(可登录 + 已绑定区域)后复测这 3 个接口。
+
+## 6. 分模块观察
+
+- **客户端核心链路**
+  - `/mini/client/products` 与 `/mini/client/price-list/current` 是当前小程序端最慢的两个读接口。
+  - 购物车、地址、订单相关接口整体在 `p95 < 300ms`,表现稳定。
+- **公共能力接口**
+  - `/mini/common/china-region-tree` 当前 `p95` 已降到 `214.57ms`,优化效果明显。
+- **配送员/供应商**
+  - 可测的 `/mini/deliverer/tasks/statistics` 与 `/mini/supplier/dashboard/statistics` 在 `p95 < 400ms`,无明显风险。
+
+## 7. 优化建议(按优先级)
+
+1. **P0:`/mini/client/products` 专项**
+   - 排查是否存在列表 N+1(分类名、单位名、价格补充字段逐条查询)。
+   - 补齐 `is_del + category_id + create_at` / `is_del + region_id + create_at` 等组合索引(按实际 SQL 过滤条件确认)。
+2. **P0:`/mini/client/price-list/current` 专项**
+   - 复核是否存在“当前价格单 + 明细 + 关联信息”多次查询与重复计算。
+   - 对低频变更数据考虑短 TTL 缓存(进程内或 Redis)。
+3. **P1:供应商接口补测**
+   - 准备可用供应商测试账号并完成区域绑定,补齐 `/mini/supplier/*` 的性能样本。
+4. **P1:建立小程序性能基线巡检**
+   - 建议沿用本次脚本方式,按日/按周输出 `p95` 趋势。
+
+## 8. 本轮结论
+
+- `sqxp-wx` 小程序界面涉及的 `GET` 接口整体性能处于可接受区间,未出现 `p95 >= 800ms` 的明确风险接口。
+- 但客户端两条核心接口(`/mini/client/products`、`/mini/client/price-list/current`)已接近风险阈值,建议下一轮优先专项优化,避免业务增长后进入高风险区。
+
+---
+
+## 9. 客户端核心接口专项优化闭环(已完成)
+
+### 9.1 优化对象
+
+- `GET /mini/client/products`
+- `GET /mini/client/price-list/current`
+
+### 9.2 慢点定位
+
+- 两个接口都存在明显循环内查库(N+1):
+  - 价格项循环中逐条查商品/分类/单位;
+  - 分类根节点解析存在多次递归查库;
+  - 大量重复 ORM 查询导致高尾延迟。
+
+### 9.3 优化动作
+
+- `backend/app/api/mini_client.py`(`/price-list/current`)
+  - 改为一次性批量查询价格项 + 商品 + 分类 + 单位(`join`);
+  - 移除循环内 `product/category/unit` 逐条查询。
+- `backend/app/api/mini_client_extra.py`(`/products`)
+  - 改为批量查询价格项 + 商品 + 单位;
+  - 分类信息改为一次批量加载并在内存构建父链缓存;
+  - 消除循环内 `product/category/unit` 查询与根分类递归查库。
+
+### 9.4 前后对比
+
+> 优化前数据来自本报告第 4 节(同口径 `N=8`);优化后复测口径为 `N=30`。
+
+| 接口 | 阶段 | 平均耗时 | P95 | P99 | Max |
+|---|---|---:|---:|---:|---:|
+| `GET /mini/client/products` | 优化前 | 759.10ms | 798.84ms | 798.84ms | 868.60ms |
+| `GET /mini/client/products` | 优化后 | 207.93ms | 267.75ms | 268.58ms | 275.34ms |
+| `GET /mini/client/price-list/current` | 优化前 | 724.00ms | 785.51ms | 785.51ms | 841.48ms |
+| `GET /mini/client/price-list/current` | 优化后 | 236.53ms | 284.95ms | 285.54ms | 290.60ms |
+
+- 结论:
+  - `/mini/client/products` 的 `P95` 下降约 `66.5%`;
+  - `/mini/client/price-list/current` 的 `P95` 下降约 `63.7%`;
+  - 两条客户端核心接口已从“接近风险阈值”降至稳定区间。

+ 18 - 0
docs/后端接口全覆盖测试与数据库校验实施跟踪.md

@@ -863,3 +863,21 @@ markers =
 
 
 以上 4 个接口可作为后续性能优化的“模板实现”参考。
 以上 4 个接口可作为后续性能优化的“模板实现”参考。
 
 
+## 31. 最新执行进展(2026-04-25 22:23)
+
+- 已完成 `sqxp-wx` 小程序整体接口性能排查,并输出报告:
+  - `docs/sqxp-wx-接口性能排查报告-2026-04-25.md`
+  - 原始数据:`docs/sqxp-wx-接口性能排查-raw-2026-04-25.json`
+- 基于排查结果,对两条“接近风险阈值”的客户端核心接口完成专项优化闭环:
+  - `GET /mini/client/products`
+  - `GET /mini/client/price-list/current`
+- 后端改造:
+  - `backend/app/api/mini_client.py`:`/price-list/current` 改为价格项 + 商品/分类/单位批量 join 查询,消除 N+1。
+  - `backend/app/api/mini_client_extra.py`:`/products` 改为批量查询 + 分类父链缓存,消除循环内查库。
+- 优化后复测(`N=30`):
+  - `/mini/client/products`:`avg≈207.93ms`,`p95≈267.75ms`,`p99≈268.58ms`
+  - `/mini/client/price-list/current`:`avg≈236.53ms`,`p95≈284.95ms`,`p99≈285.54ms`
+- 对比优化前(报告第 4 节):
+  - `/mini/client/products`:`p95 798.84ms -> 267.75ms`(下降约 `66.5%`)
+  - `/mini/client/price-list/current`:`p95 785.51ms -> 284.95ms`(下降约 `63.7%`)
+