# 万能搜索接口文档

> 模块：`ES`
> 
> 最后更新：2026\-06\-05
> 
> Channel：Shop
> 
> Base Path：`/shop/es`
> 
> 



## **1\. 接口清单**

|接口|方法|路径|说明|
|---|---|---|---|
|聚合搜索|`POST`|`/shop/es/search`|一次请求查询多个模块，按 `module` 分组返回|
|单模块搜索|`POST`|`/shop/es/search/{module}`|查询指定模块，支持筛选、排序、分页|

## **2\. 通用说明**



### **鉴权**



商户后台登录态接口，沿用现有 Shop 端鉴权。



|Header|类型|必填|说明|
|---|---|---|---|
|`Authorization`|string|是|`Bearer <token>`|
|`Content-Type`|string|是|`application/json`，仅 POST 接口需要|



`shop_id` 不允许前端传入，后端从登录态注入，避免跨店铺查询。



当前 `sales` 模块已接入真实索引查询；其他模块接入真实索引前仍返回稳定空结构。



`sales` 模块当前已对齐 Sales Projection v1\.1：ES 内部索引保留 `items`、`customer_search_text`、`order_number_keywords` 等搜索投影字段；接口响应中的 `list[]` 会转换为《统一 Sales 数据协议设计》定义的 Sales 结构，例如 `order_id`、`type`、`products`、`payments`、`summary`。请求参数尽量与 `/shop/order/sales` 保持一致，兼容 `num`、`skip`、`order`、`sort`、`select`、`with` 和常用 `OrderFilter` 字段。金额字段沿用当前 ES 投影的整数分值。



`sales` 使用以下 ES 投影字段支持搜索创建人和最后修改人；这些字段默认不直接进入统一 Sales 协议 `list[]`：



|字段|类型|说明|
|---|---|---|
|`create_account_id`|int|订单创建账号 ID，来源于 `order.create_account_id`|
|`create_account_name`|string|订单创建账号姓名，来源于 `account.display_name`|
|`last_edit_account_id`|int|订单最新时间线操作账号 ID，来源于该订单最新一条 `order_timeline.operator_id`|
|`last_edit_account_name`|string|订单最新时间线操作账号姓名，来源于 `account.display_name`；若无对应账号则为空字符串|



### **模块名\`module\`**



|module|说明|新索引|
|---|---|---|
|`sales`|订单 / 销售单|`pisell_search_sales`|
|`sku`|商品 / SKU|`pisell_search_sku`|
|`customers`|客户 / 联系人|`pisell_search_customers`|
|`wallet_pass`|Wallet Pass，对应 `voucher`|`pisell_search_wallet_pass`|



## **3\. 请求参数完整说明**



本节把两个入口能传的参数放在一起说明，避免混淆“聚合入口的模块级参数”和“单模块入口的根级参数”。



### **3\.1 参数生效范围**



|使用场景|接口|参数写法|说明|
|---|---|---|---|
|聚合搜索默认查订单|`POST /shop/es/search`|不传 `modules`，直接传 `keyword`、`num`、`skip`、`order`、`sort`、筛选字段等|后端默认查询 `sales`，根级参数会作为 sales 模块参数|
|聚合搜索指定多个模块|`POST /shop/es/search`|`modules[].module` \+ `modules[].data`|每个模块的筛选、返回字段、排序、分页都写在自己的 `data` 下；`modules[].data` 内部结构与单模块搜索一致|
|单模块搜索|`POST /shop/es/search/{module}`|根级直接传 `keyword`、`num`、`skip`、`order`、`sort`、`select`、`with`、筛选字段等|路径里的 `{module}` 决定查询哪个模块|



注意：聚合入口不传 `modules` 时，根级参数用于默认的 `sales` 搜索；一旦传了 `modules`，各模块自己的筛选、返回字段、排序、分页必须写在对应 `modules[].data` 里。顶层 `keyword` 会作为默认关键词传给未设置 `data.keyword` 的模块。



### **3\.2 根级参数**



|字段名|类型|必填|适用接口|说明|
|---|---|---|---|---|
|`keyword`|string|否|聚合搜索、单模块搜索|全局搜索关键词，最大 255 字符；空字符串或不传时只按筛选、排序、分页查询|
|`modules`|object\[\]|否|聚合搜索|要查询的模块列表；不传时默认查 `sales`|
|`modules[].module`|string|是|聚合搜索|模块名，仅支持 `sales`、`sku`、`customers`、`wallet_pass`|
|`modules[].data`|object|否|聚合搜索|当前模块的查询参数容器|
|`num`|int|否|聚合默认 sales、单模块搜索|每页条数，1\-100；与 `/shop/order/sales` 一致|
|`skip`|int|否|聚合默认 sales、单模块搜索|当前页码，默认 1；与 `/shop/order/sales` 的 `skipTake` 语义一致|
|`order`|string|否|聚合默认 sales、单模块搜索|排序字段，例如 `created_at`、`id`|
|`sort`|string / object\[\]|否|聚合默认 sales、单模块搜索|Sales 风格可传 `asc` / `desc`；旧 ES 风格可传排序数组|
|`select`|string / string\[\]|否|聚合默认 sales、单模块搜索|主体返回字段，支持逗号分隔字符串或数组|
|`with`|string / string\[\]|否|聚合默认 sales、单模块搜索|关联返回字段，支持 `products`、`bookings`、`payments`、`contacts_info` 等|
|`filters`|object|否|聚合默认 sales、单模块搜索|旧 ES 风格筛选对象，仍兼容|
|`page`|int|否|聚合默认 sales、单模块搜索|旧分页字段，兼容 `skip`|
|`per_page`|int|否|聚合默认 sales、单模块搜索|旧每页条数字段，兼容 `num`|
|`limit`|int|否|聚合默认 sales、单模块搜索|旧每页条数字段，兼容 `num`|
|其他 `OrderFilter` 字段|mixed|否|聚合默认 sales、单模块搜索|可直接传 `status`、`payment_status`、`shipping_status`、`platform`、`order_sales_channel`、`created_at_start`、`created_at_end` 等|



### **3\.3 模块级\`data\` 参数**



聚合搜索中每个 `modules[]` 都可以带自己的 `data`，结构与单模块搜索根级参数一致：



|字段名|类型|必填|说明|
|---|---|---|---|
|`modules[].data.keyword`|string|否|当前模块关键词，优先级高于顶层 `keyword`|
|`modules[].data.num`|int|否|每页条数，1\-100；兼容 `per_page`、`limit`|
|`modules[].data.skip`|int|否|当前页码，默认 1；兼容 `page`|
|`modules[].data.order`|string|否|排序字段，例如 `created_at`、`id`|
|`modules[].data.sort`|string / object\[\]|否|Sales 风格可传 `asc` / `desc`；旧 ES 风格可传排序数组|
|`modules[].data.select`|string / string\[\]|否|主体返回字段|
|`modules[].data.with`|string / string\[\]|否|关联返回字段|
|`modules[].data.filters`|object|否|旧 ES 风格筛选对象|
|其他 `OrderFilter` 字段|mixed|否|直接放在 `data` 内，例如 `status`、`payment_status`、`created_at_start`、`created_at_end`|



### **3\.4\`keyword\` 可搜索字段**



`keyword` 搜索字段由后端按模块配置决定，不由请求里的 `select` / `with` 决定。当前只有 `sales` 接入真实 ES 查询，搜索字段如下：



|类型|字段|说明|
|---|---|---|
|精确匹配|`order_number_keywords`、`shop_order_number`、`shop_full_order_number`、`order_number`|订单号相关字段，权重最高|
|精确匹配|`voucher_codes`|订单生成码 / Voucher 码|
|精确匹配|`pay_numbers`|支付交易号 / 支付流水号|
|文本匹配|`customer_search_text`、`customer_search_text_english`|客户姓名、电话、邮箱等客户搜索文本|
|文本匹配|`contacts_search_text`|联系人搜索文本|
|文本匹配|`item_search_text`、`item_search_text_english`|商品明细搜索文本|
|文本匹配|`note_text`|订单备注搜索文本|
|文本匹配|`create_account_name`、`last_edit_account_name`|创建人、最后修改人姓名|
|文本匹配|`tag_names`、`location_name`|标签名、门店位置名|
|数字精确匹配|`entity_id`、`customer_id`|仅当 `keyword` 是数字时追加匹配|



### **3\.5\`select\` / \`with\` 返回字段**



`select` 和 `with` 对齐 `/shop/order/sales` 的请求习惯：`select` 控制主体字段，`with` 控制关联字段。ES 内部会把它们转换为 Sales 协议字段，不控制 `keyword` 搜索字段，也不直接暴露 ES 原始字段。



|想要的数据|正确传法|不建议 / 无效传法|
|---|---|---|
|客户姓名|`customer_name`|`customer`、`order.customer`|
|客户电话|`phone`|`customer.phone`|
|客户邮箱|`email`|`customer.email`|
|联系人完整快照|`contacts_info`|`order.contacts_info`|
|商品明细|`products`|`items`|
|支付明细|`payments`|`order.payments`|
|金额汇总|`summary`|`total_amount`、`paid_amount`|



当前 `sales` 支持的协议字段如下：



```Plain Text
order_id, order_number, shop_order_number, shop_full_order_number,
external_sale_number, type, business_code, platform, sales_channel,
order_sales_channel, status, payment_status, shipping_status, delivery_type,
customer_id, customer_name, country_calling_code, phone, email,
is_price_include_tax, tax_title, tax_country_code, currency_code,
currency_symbol, currency_format, is_deposit, deposit_amount, shop_discount,
surcharge_fee, note, schedule_date, created_at, updated_at, products,
bookings, payments, surcharges, relation_forms, contacts_info, holder,
summary, metadata
```



如果 `select` 和 `with` 都不传，返回默认全量协议字段；如果传入的字段全部不是协议字段，后端会回退为默认全量协议字段。



如果 `with` 传 `all` 或不传，返回默认完整结构；如果只传部分 `select` / `with`，后端会尽量裁剪 ES `_source` 和最终响应字段。



### **3\.6 筛选条件**



`sales` 支持两种筛选写法：



1. Sales 列表风格：筛选字段直接放在 Body 根级或 `modules[].data` 内，例如 `status`、`payment_status`。

2. 旧 ES 风格：放在 `filters` 对象里，例如 `"filters": { "payment_status": ["paid"] }`。

常见写法如下：



|写法|示例|生成的查询|
|---|---|---|
|单值精确匹配|`"payment_status": "paid"`|`term`|
|多值精确匹配|`"payment_status": ["paid", "partial_paid"]`|`terms`|
|范围筛选|`"created_at": { "from": "2026-05-01 00:00:00", "to": "2026-05-30 23:59:59" }`|`range`，`from` 等同 `gte`，`to` 等同 `lte`|
|范围筛选|`"total_amount": { "gte": 1000, "lt": 5000 }`|`range`|



空字符串、`null`、空数组会被忽略。筛选字段可使用 Sales 列表常用字段名，camelCase 会先转成 snake\_case，例如 `paymentStatus` 会按 `payment_status` 处理。



常见时间范围字段会转换成 ES range：



|请求字段|ES 条件|
|---|---|
|`created_at_start` / `createdAtStart`|`created_at >=`|
|`created_at_end` / `createdAtEnd`|`created_at <`|
|`payment_time_start` / `paymentTimeStart`|`payment_time >=`|
|`payment_time_end` / `paymentTimeEnd`|`payment_time <`|
|`start_time` / `startTime`|`created_at >=`|
|`end_time` / `endTime`|`created_at <=`|
|`delivery_start_at` / `deliveryStartAt`|`delivery_start_at >=`|
|`delivery_end_at` / `deliveryEndAt`|`delivery_end_at <=`|



旧 ES 投影字段别名也仍兼容：



|请求字段|ES 字段|
|---|---|
|`order_id`|`entity_id`|
|`type`|`order_type`|
|`booking_ids`|`identifiers.booking_ids`|
|`voucher_codes`|`identifiers.voucher_codes`|
|`pay_numbers`|`identifiers.pay_numbers`|
|`tag_ids`|`tags.id`|
|`location_name`|`location.name.keyword`|
|`create_account_name`|`create_account.display_name.keyword`|
|`item_search_text`|`search_context.order_details`|



### **3\.7\`sort\` 排序规则**



推荐使用 Sales 列表风格的 `order` \+ `sort`：



```JSON
{
  "order": "created_at",
  "sort": "desc"
}
```



旧 ES 风格仍兼容，支持多个排序字段：



```JSON
[
  { "field": "created_at", "direction": "desc" },
  { "field": "id", "direction": "desc" }
]
```



如果不传排序，`sales` 默认排序为：



1. 有 `keyword` 时先按 `_score desc`；

2. 再按 `created_at desc`；

3. 最后按 `id desc` 保证结果稳定。

## **4\. 聚合搜索**

\- **方法**：\`POST\`

\- **路径**：\`/shop/es/search\`

\- **说明**：按 \`modules\` 指定多个模块，统一搜索后按模块分组返回。

### **Body 参数**

|字段名|类型|必填|说明|
|---|---|---|---|
|`keyword`|string|否|搜索关键词，最大 255 字符|
|`modules`|array|否|要查询的模块列表；不传时默认查询 `sales`|
|`modules[].module`|string|是|模块名，仅支持 `sales`、`sku`、`customers`、`wallet_pass`|
|`modules[].data`|object|否|当前模块的查询条件；`sales` 模块内参数与单模块搜索根级 Body 一致|
|`modules[].data.num`|int|否|每页条数，1\-100；兼容 `per_page`、`limit`|
|`modules[].data.skip`|int|否|当前页码，默认 1；兼容 `page`|
|`modules[].data.order`|string|否|排序字段|
|`modules[].data.sort`|string / object\[\]|否|Sales 风格方向 `asc` / `desc`；或旧 ES 排序数组|
|`modules[].data.select`|string / string\[\]|否|主体返回字段，如 `order_id,order_number,status`|
|`modules[].data.with`|string / string\[\]|否|关联返回字段，如 `products`、`payments`、`bookings`|
|`modules[].data.filters`|object|否|旧 ES 风格筛选条件，仍兼容|
|`modules[].data.<OrderFilter字段>`|mixed|否|Sales 列表风格筛选字段，如 `status`、`payment_status`、`created_at_start`|



### **Demo：请求示例**



```HTTP
POST /shop/es/search HTTP/1.1
Host: your-domain.com
Authorization: Bearer <token>
Content-Type: application/json

{
  "keyword": "ABC123",
  "modules": [
    {
      "module": "sales",
      "data": {
        "num": 15,
        "skip": 1,
        "order": "created_at",
        "sort": "desc",
        "status": ["open"],
        "payment_status": ["paid"],
        "created_at_start": "2026-06-01 00:00:00",
        "created_at_end": "2026-06-05 23:59:59",
        "select": "order_id,order_number,status,payment_status,created_at",
        "with": ["products", "payments", "bookings"]
      }
    },
    { "module": "sku", "data": { "limit": 10 } },
    { "module": "customers", "data": { "limit": 10 } }
  ]
}
```



### **Demo：成功响应（200）**



当前已接入 `sales` 订单模块真实 ES 查询；`sku`、`customers`、`wallet_pass` 暂时返回稳定空结构，后续按模块接入。



```JSON
{
  "status": true,
  "data": {
    "keyword": "ABC123",
    "results": [
      {
        "module": "sales",
        "list": [
          {
            "order_id": 123456,
            "order_number": "ABC123",
            "shop_order_number": "S000123",
            "shop_full_order_number": "SHOP-S000123",
            "type": "appointment_booking",
            "business_code": "appointment_booking",
            "platform": "pos",
            "sales_channel": "my_pisel",
            "order_sales_channel": "pos",
            "status": "open",
            "payment_status": "paid",
            "shipping_status": "fulfilled",
            "customer_id": 88,
            "customer_name": "Alex Chen",
            "country_calling_code": "61",
            "phone": "400000000",
            "email": "alex@example.com",
            "currency_code": "AUD",
            "currency_symbol": "$",
            "is_deposit": false,
            "contacts_info": {
              "first_name": "Alex",
              "last_name": "Chen",
              "display_name": "Alex Chen",
              "email": "alex@example.com",
              "phone": "400000000",
              "country_calling_code": "61"
            },
            "products": [
              {
                "order_detail_id": 501,
                "product_id": 1001,
                "num": 1,
                "product_quantity": 1,
                "product_variant_id": 2001,
                "product_sku": "Adult",
                "selling_price": 19900,
                "original_price": 19900,
                "payment_price": 19900,
                "payment_status": "paid",
                "shipping_status": "fulfilled",
                "discount_list": [],
                "product_bundle": [],
                "metadata": {}
              }
            ],
            "bookings": [],
            "payments": [
              {
                "order_payment_id": 701,
                "code": "credit_card",
                "payment_method": "credit_card",
                "amount": 19900,
                "status": "paid",
                "payment_time": "2026-05-30 15:51:00",
                "show_pay_number": "TXN123",
                "pay_number": "PAY123"
              }
            ],
            "surcharges": [],
            "relation_forms": [],
            "holder": null,
            "summary": {
              "total_amount": 19900,
              "paid_amount": 19900,
              "total_refund_amount": 0
            },
            "metadata": {},
            "created_at": "2026-05-30 15:50:00",
            "updated_at": "2026-05-30 15:51:00"
          }
        ],
        "count": 1,
        "skip": 2,
        "size": 15
      },
      {
        "module": "sku",
        "list": [],
        "count": 0,
        "skip": 2,
        "size": 10
      },
      {
        "module": "customers",
        "list": [],
        "count": 0,
        "skip": 2,
        "size": 10
      }
    ]
  }
}
```



### **订单索引初始化**



首次使用前需要创建并回填订单搜索索引：



```Bash
php artisan es-index:search-sales
```



本次 Sales Projection v1\.1 将部分金额字段 mapping 从 `double` 调整为 `integer`，字段类型发生变化时不能只执行 `--mapping-only` 或 `--update` 的 putMapping。已有旧索引时应重建索引并全量回填：



```Bash
php artisan es-index:search-sales
```



若索引结构已经是 v1\.1，仅需要重新灌数据，可执行：



```Bash
php artisan es-index:search-sales --data-only
```



## **5\. 单模块搜索**



\- **方法**：\`POST\`

\- **路径**：\`/shop/es/search/\{module\}\`

\- **说明**：查询指定模块。可用于 \`Search in all\`、模块列表页、分页加载下一页。



### **Path 参数**



|参数名|类型|必填|说明|
|---|---|---|---|
|`module`|string|是|模块名，如 `sales`、`sku`、`customers`、`wallet_pass`|



### **Body 参数**



|字段名|类型|必填|说明|
|---|---|---|---|
|`keyword`|string|否|搜索关键词|
|`num`|int|否|每页条数，1\-100；兼容 `per_page`、`limit`|
|`skip`|int|否|当前页码，默认 1；兼容 `page`|
|`order`|string|否|排序字段，例如 `created_at`、`id`|
|`sort`|string / object\[\]|否|Sales 风格方向 `asc` / `desc`；或旧 ES 排序数组|
|`select`|string / string\[\]|否|主体返回字段，如 `order_id,order_number,status`|
|`with`|string / string\[\]|否|关联返回字段，如 `products`、`payments`、`bookings`|
|`filters`|object|否|旧 ES 风格筛选条件，仍兼容|
|`<OrderFilter字段>`|mixed|否|Sales 列表风格筛选字段，如 `status`、`payment_status`、`created_at_start`|



### **Demo：请求示例**



```HTTP
POST /shop/es/search/sales HTTP/1.1
Host: your-domain.com
Authorization: Bearer <token>
Content-Type: application/json

{
  "keyword": "ABC123",
  "num": 20,
  "skip": 2,
  "order": "created_at",
  "sort": "desc",
  "status": ["open"],
  "payment_status": ["paid"],
  "created_at_start": "2026-06-01 00:00:00",
  "created_at_end": "2026-06-05 23:59:59",
  "select": "order_id,order_number,status,payment_status,created_at",
  "with": ["products", "payments"]
}
```



### **Demo：成功响应（200）**



```JSON
{
  "status": true,
  "data": {
    "list": [
      {
        "order_id": 123456,
        "order_number": "ABC123",
        "status": "open",
        "payment_status": "paid",
        "created_at": "2026-06-05 08:00:00",
        "products": [],
        "payments": []
      }
    ],
    "count": 1,
    "skip": 3,
    "size": 20
  }
}
```



## **6\. 最近搜索关键词 / 最近记录**



最近搜索关键词、最近访问记录由前端自行处理，本期后端不提供对应读写接口，也不在搜索接口中写入 Redis。



建议前端在本地状态或浏览器缓存中维护：



- 最近搜索关键词：搜索请求成功后，在前端按 `keyword + modules` 记录并去重。

- 最近记录：点击搜索结果或进入详情页时，按 `module + order_id` 等模块业务主键记录并去重。

- 展示字段：直接复用搜索结果中的 `list[]` 轻量字段，避免再次请求后端。

## **7\. 后端 Action 对接：订单搜索索引更新**



订单、订单聚合子数据、以及少量当前态冗余字段发生变化后，业务代码应在变更完成点触发 Action，由 ES 模块异步刷新 `pisell_search_sales`。



\- **Action 名称**：\`search\.sales\.changed\`

\- **监听器**：\`Modules\\ES\\Hooks\\SearchSalesChangedAction\`

\- **异步 Job**：\`Modules\\ES\\Jobs\\SyncSearchSalesIndexJob\`

\- **队列**：\`es\-sync\`

\- **事务行为**：Job 使用 \`afterCommit\(\)\`，避免读取未提交数据

\- **主流程影响**：Action 只派发 Job，不直接写 ES，不阻塞订单主流程



### **调用示例**



订单主表创建或更新后：



```PHP
use App\Support\Hook\Facades\Action;

Action::fire('search.sales.changed', [
    'order_ids' => [$orderId],
]);
```



订单删除或软删后：



```PHP
use App\Support\Hook\Facades\Action;

Action::fire('search.sales.changed', [
    'delete_order_ids' => [$orderId],
]);
```



订单聚合子数据变更后：



```PHP
use App\Support\Hook\Facades\Action;

Action::fire('search.sales.changed', [
    'detail_ids' => $detailIds,
    'payment_ids' => $paymentIds,
    'voucher_ids' => $voucherIds,
]);
```



### **Payload 字段**



|字段|类型|说明|
|---|---|---|
|`order_ids`|int\[\]|订单主表创建、更新、编辑后重建对应订单索引|
|`delete_order_ids`|int\[\]|订单删除或软删后删除对应 ES 文档|
|`detail_ids`|int\[\]|订单明细自身变化后，通过 `order_detail.order_id` 回查订单；商品主数据变化不触发|
|`payment_ids`|int\[\]|支付记录自身变化后，通过 `order_payment.order_id` 回查订单|
|`voucher_ids`|int\[\]|订单生成码或支付使用码自身变化后，通过 `voucher.order_id` 或 `order_payment.voucher_id` 回查订单|
|`metadata_ids`|int\[\]|订单元数据自身变化后，通过 `order_metadata.order_id` 回查订单；客户资料变化不触发|
|`tag_ids`|int\[\]|订单标签名变化后，通过 `order_tag_relation.order_tag_id` 回查订单，属于当前态冗余字段级联|
|`tag_relation_ids`|int\[\]|订单标签关系增删后，通过 `order_tag_relation.order_id` 回查订单，属于订单自身关系变化|
|`location_ids`|int\[\]|门店位置名变化后，通过 `order.location_id` 回查订单，属于当前态冗余字段级联|
|`customer_ids`|int\[\]|客户姓名、电话、邮箱、会员号等客户快照字段变化后，通过 `order.customer_id` 回查订单|



### **字段性质与触发边界**



|字段/数据|性质|是否级联刷新|触发入口|
|---|---|---|---|
|`order` 主表状态、金额、时间、渠道等|订单自身事实|是，订单自身变化即重建|`order_ids`|
|`customer` 客户快照|当前态冗余字段|客户显示名、姓名、电话、邮箱、会员号变化时刷新引用订单|`customer_ids`|
|`contactsInfo` 联系人|订单历史快照|客户主数据变化不刷；订单元数据自身变化才刷|`metadata_ids`|
|`detail` 商品明细|下单时明细快照|商品主数据变化不刷；订单明细自身字段变化才刷|`detail_ids` / `order_ids`|
|`payment` 支付方式、金额、状态、交易号|订单支付事实|支付记录自身变化时刷新|`payment_ids` / `order_ids`|
|`voucher` 订单生成码、支付使用码|订单相关事实|码值或支付绑定变化时刷新|`voucher_ids` / `payment_ids`|
|`tag_names`|当前态冗余字段|标签名变化时级联刷新引用订单|`tag_ids`|
|`location_name`|当前态冗余字段|门店位置名变化时级联刷新引用订单|`location_ids`|



### **对接原则**



- 不依赖 Observer 作为唯一入口，因为 `insert()`、批量 `update()`、原生 SQL、pivot 批量操作可能绕过模型事件。

- 业务方在明确完成订单聚合自身事实或当前态冗余字段变更后触发一次 Action 即可。

- 同一个流程里多个订单子数据同时变化时，可以合并在一次 payload 中触发。

- 客户资料更新会刷新 `customer` 当前态快照；订单 `contacts_info` 联系人仍按下单时快照处理，不随客户资料回写。

- 商品主数据更新不需要反刷历史订单明细；订单明细商品名和规格按下单时快照处理。

## **8\. 校验与错误**



### **常见错误**



|场景|说明|
|---|---|
|`num` / `per_page` / `limit` 超过 100|参数校验失败|
|旧 ES 风格 `sort[].direction` 不是 `asc` / `desc`|参数校验失败|
|未登录或 token 无效|鉴权失败|



### **错误响应示例**



```JSON
{
  "status": false,
  "message": "The given data was invalid."
}
```





