Selaa lähdekoodia

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

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

Made-with: Cursor
zlwu 6 kuukautta sitten
vanhempi
commit
3557d21340

+ 21 - 0
.cursorrules

@@ -657,3 +657,24 @@ def list_parameters(...):
   - 必须同步更新 `docs/web-admin-接口性能测试报告-*.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 fastapi import APIRouter, Depends, Request, Query
 from sqlalchemy.orm import Session
+from sqlalchemy import and_
 
 from app.core.database import get_db
 from app.core.auth import get_current_user, get_client_ip
 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.user import user_crud
 from app.crud.region import region_crud
@@ -286,8 +296,33 @@ def get_current_price_list(
     if not price_list:
         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)
@@ -296,31 +331,18 @@ def get_current_price_list(
     supplier = user_crud.get(db, price_list.supplier_id)
     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,
-            "productName": product.name if product else None,
-            "categoryName": category_name,
+            "productName": item.product_name,
+            "categoryName": item.category_name,
             "unitId": item.unit_id,
-            "unitName": unit_name,
+            "unitName": item.unit_name,
             "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({
         "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
 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.address import address_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)
     if not pl:
         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 = []
-    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
-        if categoryId and product.category_id != categoryId:
+        if rootCategoryId and _root_category_id_from_cache(item.category_id) != rootCategoryId:
             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
-        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({
-            "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,
-            "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,
             "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:
         cid = r.get("categoryId")
         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 个接口可作为后续性能优化的“模板实现”参考。
 
+## 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%`)
+