# Keyword 智能匹配扩展规划（Keyword Matcher）

> 状态：**规划中（未实现）**
> 关联代码：`dsl/src/filter.ts`（FilterSchema.keyword）、`gen/src/filter-render.ts`（keyword OR-like 渲染）、`curd/src/service.ts`（keyword query 端点）
> 背景：curd 搜索框输一个值，需要判断它"像什么"（手机号 / 订单号 / 商户号），再决定精确查询哪一列；不像任何已知形状才回退模糊匹配。

## 1. 背景：问题与现状

**问题**：管理页搜索框只有一个 keyword 输入，业务上常见输入是"可直接定位到一行的值"：

| 用户输入 | 业务含义 | 期望查询 |
|---------|---------|---------|
| `13800138000` | 手机号（11 位，1 开头） | `user.phone = '13800138000'`（精确） |
| `ORD17230000001234` | 订单号（ORD 前缀） | `order.order_no = ...`（精确） |
| `M17230001234` | 商户号（M 前缀） | `merchant.mer_no = ...`（精确） |
| `张三` | 人名 | 名称列 LIKE（模糊） |

**现状**：`FilterSchema.keyword = { columns: Field[] }` 只有一种行为——对声明列 OR 拼接 `LIKE %kw%`：

```ts
// 现状声明（filter_schema/examples-api/admin/filter/user-list.filter.ts）
keyword: { columns: [user.columns.username] },
```

```ts
// 现状产物（api/src/modules/admin/filter/UserListFilter.ts）
if (args.keyword) {
  q.where(function (this: QueryBuilder) {
    this.where('user.username', 'like', `%${args.keyword}%`);
  });
}
```

手机号按 LIKE 查 `username` 永远查不到（username 是昵称），用户输入"138..."期望按手机号定位用户却得到空结果——**输入形状与查询列不匹配**。

**形状知识现状**：`@pylonts/mock` 的生成器已经持有形状约定（生成侧），识别侧没有：

| 生成器 | 形状 | 位置 |
|--------|------|------|
| `mockPhone` | 11 位，运营商前缀段（134/135/... 白名单） | `pylon-mock/src/generators/phone.ts` |
| `mockMerchantId` | `M` + 8 位时间戳 + 2 位随机 | `pylon-mock/src/generators/id.ts` |
| `mockOrderId` | `ORD` + 时间戳 + 2 位随机 | `pylon-mock/src/generators/id.ts` |
| 身份证 / 银行卡 | 生成规则已存在 | `pylon-mock/src/generators/identity.ts`、`data/bankcard.json` |

生成与识别是同一形状的两面，识别器应从同一形状知识推导，而不是在 filter 声明里再写一遍。

## 2. 方案：keyword.rules 形状规则

`FilterSchema.keyword` 扩展 `rules`，声明"keyword 像什么时精确查哪一列"：

```ts
keyword: {
  columns: [user.columns.username],           // fallback：仍按模糊 OR-like
  rules: [
    { field: user.columns.phone, when: 'phone' },              // 内置语义名
    { field: order.columns.order_no, when: /^ORD\d+$/ },       // 项目自定义正则
  ],
},
```

```ts
// dsl/src/filter.ts 类型扩展
export interface KeywordRule {
  /** 命中后精确查询的列。 */
  field: Field;
  /** 形状判定：内置语义名（字符串）或项目正则（RegExp 字面量）。 */
  when: string | RegExp;
}

export interface FilterSchema {
  // ...
  keyword?: {
    columns: Field[];
    /** 形状规则，按声明顺序判定，首个命中短路（可选）。 */
    rules?: KeywordRule[];
  };
}
```

**判定语义**：

1. `keyword` 为空 → 整个 keyword 块跳过（现状不变）；
2. 遍历 `rules`（**声明顺序即判定顺序**，确定性）：
   - `when` 是字符串 → 查内置语义 matcher 表，命中则 `where(field, '=', kw)` 并**短路**；
   - `when` 是 RegExp → `pattern.test(kw)` 命中则 `where(field, '=', kw)` 并**短路**；
3. 所有 rules 未命中 → 原 `columns` 的 OR `LIKE %kw%` fallback（现状行为）。

**命中即短路**：形状与列是一对一映射（手机号形状只可能精确查手机号列），不需要多规则 OR 合并。若同一列要接受多种形状（如手机号列同时收 11 位手机号与座机号），声明多条 rule 指向同列即可。

**生成物**：

```ts
import { isPhone } from '@pylonts/mock/matcher';
import type { QueryBuilder } from '@pylonts/dao';

export const userListFilter = (args: UserListFilterArgs) =>
  (q: QueryBuilder) => {
  if (args.keyword) {
    if (isPhone(args.keyword)) {
      q.where('user.phone', '=', args.keyword);
    } else if (/^ORD\d+$/.test(args.keyword)) {
      q.where('order.order_no', '=', args.keyword);
    } else {
      q.where(function (this: QueryBuilder) {
        this.where('user.username', 'like', `%${args.keyword}%`);
      });
    }
  }
  return q;
  };
```

- 内置语义名 → 产物 import `@pylonts/mock` 的 matcher 纯函数（`isPhone` 等），不内联正则——形状知识单点维护，与生成器共享常量；
- 项目正则 → 产物内联 `pattern.test(kw)`（`RegExp.toString()` 直出字面量，每次调用新建，无 `lastIndex` 状态问题）；
- 精确查询走 `eq`（`=`），值经 knex 参数绑定，无注入面。

## 3. 内置语义 matcher 注册表（@pylonts/mock）

**依赖方向**：mock 已是纯 JS 无依赖包，api 项目产物可直接 import。matcher 与 generator 同文件维护，形状常量共享：

```
pylon-mock/src/generators/phone.ts    // mockPhone（生成） + isPhone（识别），共享 MOBILE_PREFIXES 等常量
pylon-mock/src/generators/id.ts       // mockMerchantId/mockOrderId + isMerchantId/isOrderId
pylon-mock/src/generators/identity.ts // + isIdCard
pylon-mock/src/generators/finance.ts  // + isBankCard
pylon-mock/src/matcher.ts             // 注册表 + 导出（子路径 '@pylonts/mock/matcher'）
```

**注册表**（mirror 现有 MockRegistry 风格，`name → matcher fn`）：

| 语义名 | 判定 | 对应生成器 |
|--------|------|-----------|
| `phone` | 11 位、前缀在白名单段 | `mockPhone` |
| `merchantId` | `M` 前缀 + 数字 | `mockMerchantId` |
| `orderId` | `ORD` 前缀 + 数字 | `mockOrderId` |
| `idcard` | 18 位 / 15 位校验 | `mockIdCard` |
| `bankcard` | Luhn 校验 | `mockBankCard` |

名单在 dsl 侧也需要可见（声明期校验语义名是否存在）。dsl 不依赖 mock（依赖方向不成立），处理：

- **方案 A**：dsl 硬编码语义名单（`KEYWORD_MATCHERS = ['phone', 'merchantId', ...] as const`），defineFilter 校验名字 ∈ 名单；mock 的注册表与之对齐（mock 侧的名单即权威实现）。dsl 只持"名字"，实现永远在 mock。
- **方案 B**：dsl 不校验，gen loadFilters 校验（查 mock 注册表）——校验时机后移，声明错误要到生成才发现。

**推荐 A**：`defineFilter` 当场报错（与 conditions/keyword 非空校验一致），名单不长且是封闭枚举。mock 已依赖 `@pylonts/dsl`（^1.1.5），注册表 `Record<语义名, (s: string) => boolean>` 可直接用 dsl 导出的名单类型驱动，名单一处定义。

## 4. 端点与前后端一致性（不变量）

- **keyword 端点不变**：`query(keyword)` 统一入口，响应类型仍 `Row[]`，分页/tenant 语义不变；
- **前端无感知**：判定全在后端 filter 产物，前端搜索框仍只传一个字符串——"前后一样"由生成链路保证（页面 search model、DTO、controller、service、DAO 全部不变，只换 filter 产物内部行为）；
- **conditions 不变**：rules 命中与否，声明的 AND 条件仍照常拼接。

## 5. 校验与错误

| 校验点 | 时机 | 行为 |
|--------|------|------|
| 语义名 ∈ 内置名单 | `defineFilter` | 抛错（与 conditions/keyword 非空校验同风格） |
| `rules` 非空 | `defineFilter` | 空数组等价于不声明，不报错 |
| 正则无 `g`/`y` 标志 | `defineFilter` | 抛错（带全局标志的 RegExp 有 lastIndex 状态，test 结果不确定） |
| rule.field 跨表 | `loadFilters` | 复用现有跨表 filter 机制（usage-site JOIN），不新增规则 |
| 语义名注册表漂移 | mock 单测 | 名单与实现一一对应（缺实现/多名均报错） |

## 6. 安全

- **SQL**：精确查询值与 LIKE 值同走 knex 参数绑定，无拼接；
- **ReDoS**：内置 matcher 是库作者维护的白名单正则（无嵌套量词陷阱）；项目自定义正则由 schema 作者负责，文档提示避免 `(a+)+` 类回溯炸弹（只 `test` 用户输入，恶意输入可触发正则回溯——风险与任何服务端正则相同）；
- **无执行面**：matcher 只返回 boolean，无回调 / 无用户代码注入。

## 7. 分阶段落地（未开始）

| 阶段 | 内容 | 产出 |
|------|------|------|
| 1 | dsl：`KeywordRule` + `keyword.rules` 声明 + defineFilter 校验（语义名单、正则标志） | `dsl/src/filter.ts` + 测试 |
| 2 | mock：matcher 注册表（phone/merchantId/orderId 起步，与生成器共享形状常量）+ 子路径导出 | `pylon-mock/src/matcher.ts` + 测试 |
| 3 | gen：keyword 块渲染 if/else-if 链（语义名 → import matcher；正则 → 内联 test；fallback → 现有 OR like） | `gen/src/filter-render.ts` + 测试 |
| 4 | examples：user-list.filter 加 `{ field: phone, when: 'phone' }`，验证产物与 api tsc | examples 回归 |

## 8. 待决策问题

1. **内置语义名单范围**：起步只做 `phone`（最常见）还是 phone/merchantId/orderId 三件套？身份证/银行卡的校验规则重（校验位），是否首期引入？
2. **matcher 实现归属**：`@pylonts/mock` 是"Mock 数据生成器"定位，形状识别放这里是否符合包边界？备选是新建 `@pylonts/matcher` 或放 `@pylonts/core`（框架无关标准层，依赖方向更干净，但形状常量与 mock 生成器要共享——共享方式：mock 依赖 core，或 matcher 独立包被 mock 依赖）。
3. **命中后是否允许继续 like**：本方案短路（命中即纯精确）。备选：命中规则后仍追加 OR like 扩大召回（多返回行），由 schema 作者选择。首期短路即可，若业务要"模糊召回"可后续加 `rule.fallbackLike?: boolean`。
4. **精确查询的操作符**：本方案固定 `eq`。订单号/商户号等唯一键列 eq 合理；若出现"前缀精确"需求（如输入 `ORD17` 查今年订单），是否允许 rule 声明 `op: 'like'`（精确列上的前缀 LIKE）？