@pylonts/dsl 1.1.6 → 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/action.d.ts +32 -0
- package/dist/action.js +14 -0
- package/dist/aggregate.d.ts +38 -0
- package/dist/aggregate.js +46 -0
- package/dist/business-flow.d.ts +9 -0
- package/dist/business-flow.js +72 -0
- package/dist/controller.d.ts +17 -9
- package/dist/controller.js +8 -2
- package/dist/convert.d.ts +28 -10
- package/dist/convert.js +16 -5
- package/dist/curd.d.ts +7 -10
- package/dist/curd.js +3 -1
- package/dist/dao.d.ts +81 -53
- package/dist/dao.js +291 -12
- package/dist/db.d.ts +6 -0
- package/dist/db.js +10 -0
- package/dist/domain-event.d.ts +48 -0
- package/dist/domain-event.js +24 -0
- package/dist/dsl.d.ts +17 -2
- package/dist/dsl.js +7 -0
- package/dist/dto.d.ts +6 -4
- package/dist/dto.js +5 -4
- package/dist/entity.d.ts +29 -0
- package/dist/entity.js +13 -0
- package/dist/exception.d.ts +9 -3
- package/dist/exception.js +25 -1
- package/dist/expr.d.ts +45 -0
- package/dist/expr.js +32 -0
- package/dist/filter.d.ts +45 -0
- package/dist/filter.js +21 -0
- package/dist/flow-script.d.ts +108 -0
- package/dist/flow-script.js +505 -0
- package/dist/flow.d.ts +294 -17
- package/dist/flow.js +803 -18
- package/dist/index.d.ts +6 -2
- package/dist/index.js +6 -2
- package/dist/mermaid-driver.js +264 -24
- package/dist/mysql-driver.js +3 -0
- package/dist/project.d.ts +5 -4
- package/dist/project.js +14 -2
- package/dist/repository.d.ts +26 -0
- package/dist/repository.js +8 -0
- package/dist/service.d.ts +14 -2
- package/dist/service.js +49 -0
- package/dist/third-service.d.ts +5 -0
- package/dist/third-service.js +1 -0
- package/dist/typebox-driver.js +4 -0
- package/dist/utils.d.ts +9 -2
- package/dist/utils.js +4 -0
- package/docs/aggregate.md +110 -0
- package/docs/dao-generation.md +478 -0
- package/docs/ddd-principles.md +75 -0
- package/docs/domain-event.md +137 -0
- package/docs/keyword-matcher.md +182 -0
- package/docs/token.md +327 -0
- package/docs/trans-reentrant.md +85 -0
- package/package.json +25 -6
- package/src/action.ts +51 -10
- package/src/aggregate.ts +104 -0
- package/src/business-flow.ts +80 -0
- package/src/controller.ts +25 -11
- package/src/convert.ts +51 -15
- package/src/curd.ts +12 -6
- package/src/dao.ts +377 -63
- package/src/db.ts +13 -0
- package/src/domain-event.ts +74 -0
- package/src/dsl.ts +23 -2
- package/src/dto.ts +9 -6
- package/src/entity.ts +43 -0
- package/src/exception.ts +30 -5
- package/src/expr.ts +65 -0
- package/src/filter.ts +70 -0
- package/src/flow-script.ts +696 -0
- package/src/flow.ts +1129 -46
- package/src/index.ts +6 -2
- package/src/mermaid-driver.ts +256 -29
- package/src/mysql-driver.ts +3 -0
- package/src/project.ts +114 -97
- package/src/repository.ts +35 -0
- package/src/service.ts +68 -3
- package/src/third-service.ts +6 -0
- package/src/typebox-driver.ts +4 -0
- package/src/utils.ts +13 -2
- package/src/endpoint.ts +0 -18
- package/src/provider.ts +0 -68
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# DDD 落地原则(DDD Principles)
|
|
2
|
+
|
|
3
|
+
> 状态:**已决策**
|
|
4
|
+
> 背景:ts-libs 会话「dd DDD 扩展讨论」;姊妹篇:[aggregate.md](./aggregate.md)(聚合/仓储规划)、[domain-event.md](./domain-event.md)(领域事件规划)
|
|
5
|
+
|
|
6
|
+
## 1. 决策摘要
|
|
7
|
+
|
|
8
|
+
1. **不纠结实体胖瘦**——胖瘦是结果,复用是判据;
|
|
9
|
+
2. **领域对象全部是 plain POJO**——纯数据、无行为(贫血模型,但规则不散落);
|
|
10
|
+
3. **不纠结代码写在哪里**——`order.cancel()` 与 `service.cancel(order)` 没有实质区别;
|
|
11
|
+
4. **规则归属只有两条路**:
|
|
12
|
+
- **不需要调用外部**(纯函数、单值校验)→ **utils 谓词**(已落地);
|
|
13
|
+
- **需要做流程**(编排、跨步骤、跨聚合)→ **service 方法**(已支持)。
|
|
14
|
+
|
|
15
|
+
**推论**:DDD 落地**不需要新增"实体方法"概念**——现有 `service_schema + flow` 已是规则复用归属地,`AggregateSchema` / 领域事件仍按各自规划做。
|
|
16
|
+
|
|
17
|
+
## 2. 为什么这样定
|
|
18
|
+
|
|
19
|
+
### 2.1 胖瘦是假问题,复用才是本质
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
一个规则 → 被几处用?
|
|
23
|
+
被 1 处用 → 留在 flow 里,不为理论正确而挪
|
|
24
|
+
被 ≥2 处用 → 提取到归属地(utils / service),一处维护
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
"取消"进归属地,不是因为实体该胖,而是因为用户取消、超时自动取消、客服取消三个流程复用同一套取消规则。没有复用,留在 flow 完全合理。
|
|
28
|
+
|
|
29
|
+
### 2.2 归属地之争是假问题
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
order.cancel() service.cancel(order)
|
|
33
|
+
─── ────────── ─── ──────────────────
|
|
34
|
+
规则住在 Order 对象 规则住在 Service 对象
|
|
35
|
+
flow: invoke(order.cancel) flow: invoke(service.cancel, order)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
两者都是"规则提取 + 复用",无实质区别。**分水岭不在 order.cancel vs service.cancel,在"有没有 cancel 这个提取物"**——有就是规则内聚,没有就是散落。
|
|
39
|
+
|
|
40
|
+
### 2.3 复用 ≠ 抽象
|
|
41
|
+
|
|
42
|
+
- 抽象/预测(坏):"未来可能用到,先抽出来" → 过度设计;
|
|
43
|
+
- 复用/实际(好):"现在被三个流程用到,提取" → 唯一正确触发条件。
|
|
44
|
+
|
|
45
|
+
遵循"三行相似胜过过早抽象"——用到了才内聚,不预测。
|
|
46
|
+
|
|
47
|
+
### 2.4 主观标准不可执行(最硬的理由)
|
|
48
|
+
|
|
49
|
+
**胖瘦标准无法统一**:什么样的实体算"胖"、算"瘦",每个人判断都不同——有的人觉得状态机进实体是对的,有的人觉得一个方法就算胖。没有客观刻度,团队协作时必然争论,代码评审无法给出确定性结论。
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
实体胖瘦 = 主观判断 → 无法统一标准 → 不可执行、不可 lint
|
|
53
|
+
复用归属 = 客观判据 → 标准统一可执行 → 机器可校验
|
|
54
|
+
|
|
55
|
+
POJO + utils/service 归属的每个判断都是确定性的:
|
|
56
|
+
调不调用外部?→ 是纯函数还是流程?→ 归 utils 还是 service
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
这符合 pylon 的 DSL 哲学:**声明式、机器校验**(lint 是权威事实来源)。"这条规则该放哪"必须能由一个确定的判据回答,而不是靠某个人对"胖瘦"的品味。**规则归属是"放 utils 还是 service"的二分,机器可判;实体胖瘦是"放多少进实体"的连续光谱,人各执一词。** 选二分、弃光谱。
|
|
60
|
+
|
|
61
|
+
## 3. 规则归属决策表
|
|
62
|
+
|
|
63
|
+
| 场景 | 归属 | 现状 |
|
|
64
|
+
|------|------|------|
|
|
65
|
+
| 纯函数/单值校验(如金额两位小数) | utils 谓词 | ✅ 已落地(BLE:AmtUtils 等) |
|
|
66
|
+
| 跨步骤业务流程(下单、取消) | service 方法 + flow | ✅ 已支持 |
|
|
67
|
+
| 跨聚合协作规则(定价、扣库存) | service 方法(领域服务角色) | ✅ 已支持 |
|
|
68
|
+
| 状态机规则(取消校验 + 流转) | service 方法 | ✅ 已支持 |
|
|
69
|
+
| 实体方法(order.cancel) | **不采用**(与 service 等价,选零成本) | 不需要 |
|
|
70
|
+
|
|
71
|
+
## 4. 对扩展规划的影响
|
|
72
|
+
|
|
73
|
+
- **聚合/仓储(aggregate.md)**:照做——解决多表一致性,不引入实体行为;
|
|
74
|
+
- **领域事件(domain-event.md)**:照做——`publish` 由 service(flow)发起,与"规则归属 service"一致;
|
|
75
|
+
- **实体方法/defineEntity**:**不立项**——`service.cancel(order)` 已覆盖,避免为一个无实质区别的语法糖增加 DSL 面。
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# 领域事件 DSL 扩展规划(Domain Event)
|
|
2
|
+
|
|
3
|
+
> 状态:**部分落地(步骤 1 声明层 ✅ / 步骤 3 @Trans afterCommit ✅ / 步骤 4 outbox DDL ✅ / 发布 API ✅,其余待实现)**
|
|
4
|
+
> 关联代码:`dsl/src/event.ts`(UI 事件,非领域事件)、`dsl/src/flow.ts`(flow IR)、`pylon-dao/src/trans.ts`(`@Trans()`)
|
|
5
|
+
> 背景对话:ts-libs 会话「dd DDD 扩展讨论」;姊妹篇:[aggregate.md](./aggregate.md)(聚合/仓储规划)
|
|
6
|
+
|
|
7
|
+
## 1. 背景:pylon 事件基础设施现状
|
|
8
|
+
|
|
9
|
+
- 整个 pylon 体系目前**零事件基础设施**:flow.ts 无 event,仓库无 publish/subscribe/EventEmitter;
|
|
10
|
+
- `dsl/src/event.ts` 是 **UI 组件事件**(Tab onChange 的 `e.detail`),与领域事件完全无关;
|
|
11
|
+
- flow 只有 `invoke`(调用方法)/ `write`(写槽)/ `THROW` / `RETURN`,**没有"发布事件"动作,也没有事件声明**。
|
|
12
|
+
|
|
13
|
+
**命令 vs 事件**(必须分清):
|
|
14
|
+
|
|
15
|
+
| | Command(命令) | Event(事件) |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| 语义 | "去做某事"(意图) | "某事已经发生"(事实) |
|
|
18
|
+
| 时态 | 未来/祈使 | 过去/陈述 |
|
|
19
|
+
| 命名 | `PlaceOrder`、`CancelOrder` | `OrderPlaced`、`OrderCancelled` |
|
|
20
|
+
| 接收方 | 有且一个(命令处理器) | 零到多个订阅者 |
|
|
21
|
+
| 失败处理 | 调用方要感知失败 | 发布方不关心谁处理 |
|
|
22
|
+
| dsl 对应 | flow 的 `invoke(m)` | **新概念** |
|
|
23
|
+
|
|
24
|
+
**事件驱动的解耦价值**:命令式让 flow 依赖所有下游(扣库存、发短信、加积分都要在 flow 里 invoke);事件式让 flow 只依赖"事实"(事件名 + 数据),下游变化不影响 flow。
|
|
25
|
+
|
|
26
|
+
## 2. 事件生命周期(五步)
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
① 发生:Order.cancel() 状态 CREATED → CANCELLED
|
|
30
|
+
② 收集:聚合根在内存记录"我取消了"
|
|
31
|
+
③ 发布:事务提交成功后投递 OrderCancelled
|
|
32
|
+
④ 订阅:库存上下文、通知服务各自注册监听
|
|
33
|
+
⑤ 处理:各订阅者做自己的事(扣库存、发短信)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
关键在 ②→③ 的**发布时机**,决定一致性。
|
|
37
|
+
|
|
38
|
+
## 3. 发布时机:三种策略
|
|
39
|
+
|
|
40
|
+
| 策略 | 做法 | 问题 |
|
|
41
|
+
|------|------|------|
|
|
42
|
+
| A. 事务内发 | tx { 改状态; 发事件; } | 订阅者失败回滚主事务;跨服务时事务管不到对方 |
|
|
43
|
+
| B. 事务后发 | tx { 改状态; } → 发事件 | 发的时候进程崩了 → 事件丢失,状态改了没人知道 |
|
|
44
|
+
| **C. Transactional Outbox(生产标准)** | tx { 改状态; INSERT outbox; } → 后台 relay 投递 | **保证:状态和事件要么都成要么都败,事件最终必达** |
|
|
45
|
+
|
|
46
|
+
**pylon 现状对照**:knex 透明代理 + `@Trans()` 让"更新状态 + 写 outbox 表"同事务天然可行(都是 DAO 调用),outbox 在 pylon 技术上现成,缺的是声明层。
|
|
47
|
+
|
|
48
|
+
## 4. dsl 扩展方向:三件新东西
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// ① 事件声明(一等 schema,携带数据快照)
|
|
52
|
+
defineDomainEvent({
|
|
53
|
+
name: 'OrderCancelled',
|
|
54
|
+
fields: { orderNo: str, reason: str }, // 快照,不是引用
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ② flow 里加 publish action(编译成同事务写 outbox 表)
|
|
58
|
+
flowScript('cancelOrder', { args, slots }, ({ next, slots }) => {
|
|
59
|
+
next(
|
|
60
|
+
invoke(orderService.load, { id: slots.orderId }, 'order'),
|
|
61
|
+
invoke(order.cancel), // 状态流转
|
|
62
|
+
publish('OrderCancelled', { orderNo: slots.order.orderNo, reason: 'user' }), // 新动作
|
|
63
|
+
);
|
|
64
|
+
return flowEnd.ok;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ③ 订阅声明(复用 flow 编排处理逻辑)
|
|
68
|
+
defineEventHandler({
|
|
69
|
+
name: 'onOrderCancelled',
|
|
70
|
+
event: 'OrderCancelled',
|
|
71
|
+
flow: handleOrderCancelledFlow, // 处理流程复用现有 flow 模型
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- 发布是**声明不是副作用**:`publish(...)` 在 flow 里显式可见,审查时一眼看到"此流程发什么事件";
|
|
76
|
+
- 不用引入消息队列:outbox 表 + 后台 relay 即够(BLE 案例规模不需要 RabbitMQ);
|
|
77
|
+
- 订阅:启动时扫描 handler 注册到总线 / 消费组。
|
|
78
|
+
|
|
79
|
+
## 5. 发布技术选型(结论)
|
|
80
|
+
|
|
81
|
+
**发布侧:已定死 —— MySQL outbox 表**(与业务同事务,knex 现成):
|
|
82
|
+
|
|
83
|
+
```sql
|
|
84
|
+
CREATE TABLE event_outbox (
|
|
85
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
86
|
+
event_name VARCHAR(100) NOT NULL, -- 'OrderCancelled'
|
|
87
|
+
payload JSON NOT NULL, -- 事件数据快照
|
|
88
|
+
status ENUM('pending','delivered') DEFAULT 'pending',
|
|
89
|
+
created_at DATETIME DEFAULT NOW()
|
|
90
|
+
);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**投递侧:按部署形态分档**:
|
|
94
|
+
|
|
95
|
+
| 方案 | 传输介质 | 延迟 | 依赖 | 适用 |
|
|
96
|
+
|------|---------|------|------|------|
|
|
97
|
+
| **A. 进程内总线** | Node 内存(EventEmitter) | 0 | 无 | **单进程(pylon 默认)** |
|
|
98
|
+
| **B. MySQL 轮询 relay** | 扫 outbox 表 | ~轮询间隔 | 无(现成) | 单进程但要持久化兜底 |
|
|
99
|
+
| **C. Redis Stream** | Redis | 毫秒 | ioredis(sign-redis-driver 已有) | 多进程/多服务 |
|
|
100
|
+
| **D. 消息队列** | RabbitMQ / Kafka | 毫秒 | 重型中间件 | 生产大规模,超出 pylon 定位 |
|
|
101
|
+
|
|
102
|
+
**推荐路径**:
|
|
103
|
+
|
|
104
|
+
- **单进程(pylon 默认,先做)**:A + B 结合,零新依赖——事务提交后(afterCommit)进程内直接分发(延迟 0),outbox 兜底补投失败/崩溃遗留的 pending 记录(不丢事件)。`@Trans()` 加 afterCommit 钩子即可:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// trans.ts 扩展:事务提交成功后执行注册的回调
|
|
108
|
+
export function Trans() {
|
|
109
|
+
return function (target, key, descriptor) {
|
|
110
|
+
const original = descriptor.value;
|
|
111
|
+
descriptor.value = async function (...args) {
|
|
112
|
+
return knex.transaction(async (trx) => {
|
|
113
|
+
const afterCommits: Array<() => Promise<void>> = [];
|
|
114
|
+
return txStorage.run(trx, async () => {
|
|
115
|
+
const result = await original.apply(this, args);
|
|
116
|
+
await trx.executionPromise; // 等事务真正提交
|
|
117
|
+
await Promise.all(afterCommits.map(fn => fn()));
|
|
118
|
+
return result;
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- **多进程/多服务(未来升级)**:方案 C Redis Stream——pylon 已有 ioredis(sign-redis-driver),Redis 不算新基建;outbox relay 从进程内分发换成 `XADD`,订阅服务 `XREADGROUP` 消费。
|
|
127
|
+
- **方案 D 不做**:超出 pylon"薄封装、快速开发"定位。
|
|
128
|
+
|
|
129
|
+
**订阅侧**:`defineEventHandler` 声明 + 启动扫描注册;复用 flow 编排处理逻辑,不需要新执行模型。
|
|
130
|
+
|
|
131
|
+
## 6. 落地步骤(待办,未开工)
|
|
132
|
+
|
|
133
|
+
1. `DomainEventSchema` 类型 + `defineDomainEvent` + 定义期校验(字段类型、事件名唯一)✅;
|
|
134
|
+
2. flow 加 `publish` action:编译成"同事务插 outbox 表"的 DAO 调用 + afterCommit 回调注册;
|
|
135
|
+
3. `@Trans()` 扩展 afterCommit 钩子(`pylon-dao/src/trans.ts`)✅;4. `event_outbox` 表:DDL 由 gen 生成(`defineDomainEvent` 自动建表)✅(独立包 `@pylonts/event` 已含发布 API:`publishEvent` + `@EventNotifier`,outbox DDL 由 `gen-outbox.ts` + `pylonts gen sql init` 追加);
|
|
136
|
+
5. relay:单进程(进程内分发 + 启动/定时补投)→ 跨进程(Redis Stream);
|
|
137
|
+
6. `defineEventHandler` + 启动扫描注册订阅者。
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Keyword 智能匹配扩展规划(Keyword Matcher)
|
|
2
|
+
|
|
3
|
+
> 状态:**规划中(未实现)**
|
|
4
|
+
> 关联代码:`dsl/src/filter.ts`(FilterSchema.keyword)、`gen/src/filter-render.ts`(keyword OR-like 渲染)、`curd/src/service.ts`(keyword query 端点)
|
|
5
|
+
> 背景:curd 搜索框输一个值,需要判断它"像什么"(手机号 / 订单号 / 商户号),再决定精确查询哪一列;不像任何已知形状才回退模糊匹配。
|
|
6
|
+
|
|
7
|
+
## 1. 背景:问题与现状
|
|
8
|
+
|
|
9
|
+
**问题**:管理页搜索框只有一个 keyword 输入,业务上常见输入是"可直接定位到一行的值":
|
|
10
|
+
|
|
11
|
+
| 用户输入 | 业务含义 | 期望查询 |
|
|
12
|
+
|---------|---------|---------|
|
|
13
|
+
| `13800138000` | 手机号(11 位,1 开头) | `user.phone = '13800138000'`(精确) |
|
|
14
|
+
| `ORD17230000001234` | 订单号(ORD 前缀) | `order.order_no = ...`(精确) |
|
|
15
|
+
| `M17230001234` | 商户号(M 前缀) | `merchant.mer_no = ...`(精确) |
|
|
16
|
+
| `张三` | 人名 | 名称列 LIKE(模糊) |
|
|
17
|
+
|
|
18
|
+
**现状**:`FilterSchema.keyword = { columns: Field[] }` 只有一种行为——对声明列 OR 拼接 `LIKE %kw%`:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// 现状声明(filter_schema/examples-api/admin/filter/user-list.filter.ts)
|
|
22
|
+
keyword: { columns: [user.columns.username] },
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// 现状产物(api/src/modules/admin/filter/UserListFilter.ts)
|
|
27
|
+
if (args.keyword) {
|
|
28
|
+
q.where(function (this: QueryBuilder) {
|
|
29
|
+
this.where('user.username', 'like', `%${args.keyword}%`);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
手机号按 LIKE 查 `username` 永远查不到(username 是昵称),用户输入"138..."期望按手机号定位用户却得到空结果——**输入形状与查询列不匹配**。
|
|
35
|
+
|
|
36
|
+
**形状知识现状**:`@pylonts/mock` 的生成器已经持有形状约定(生成侧),识别侧没有:
|
|
37
|
+
|
|
38
|
+
| 生成器 | 形状 | 位置 |
|
|
39
|
+
|--------|------|------|
|
|
40
|
+
| `mockPhone` | 11 位,运营商前缀段(134/135/... 白名单) | `pylon-mock/src/generators/phone.ts` |
|
|
41
|
+
| `mockMerchantId` | `M` + 8 位时间戳 + 2 位随机 | `pylon-mock/src/generators/id.ts` |
|
|
42
|
+
| `mockOrderId` | `ORD` + 时间戳 + 2 位随机 | `pylon-mock/src/generators/id.ts` |
|
|
43
|
+
| 身份证 / 银行卡 | 生成规则已存在 | `pylon-mock/src/generators/identity.ts`、`data/bankcard.json` |
|
|
44
|
+
|
|
45
|
+
生成与识别是同一形状的两面,识别器应从同一形状知识推导,而不是在 filter 声明里再写一遍。
|
|
46
|
+
|
|
47
|
+
## 2. 方案:keyword.rules 形状规则
|
|
48
|
+
|
|
49
|
+
`FilterSchema.keyword` 扩展 `rules`,声明"keyword 像什么时精确查哪一列":
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
keyword: {
|
|
53
|
+
columns: [user.columns.username], // fallback:仍按模糊 OR-like
|
|
54
|
+
rules: [
|
|
55
|
+
{ field: user.columns.phone, when: 'phone' }, // 内置语义名
|
|
56
|
+
{ field: order.columns.order_no, when: /^ORD\d+$/ }, // 项目自定义正则
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// dsl/src/filter.ts 类型扩展
|
|
63
|
+
export interface KeywordRule {
|
|
64
|
+
/** 命中后精确查询的列。 */
|
|
65
|
+
field: Field;
|
|
66
|
+
/** 形状判定:内置语义名(字符串)或项目正则(RegExp 字面量)。 */
|
|
67
|
+
when: string | RegExp;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface FilterSchema {
|
|
71
|
+
// ...
|
|
72
|
+
keyword?: {
|
|
73
|
+
columns: Field[];
|
|
74
|
+
/** 形状规则,按声明顺序判定,首个命中短路(可选)。 */
|
|
75
|
+
rules?: KeywordRule[];
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**判定语义**:
|
|
81
|
+
|
|
82
|
+
1. `keyword` 为空 → 整个 keyword 块跳过(现状不变);
|
|
83
|
+
2. 遍历 `rules`(**声明顺序即判定顺序**,确定性):
|
|
84
|
+
- `when` 是字符串 → 查内置语义 matcher 表,命中则 `where(field, '=', kw)` 并**短路**;
|
|
85
|
+
- `when` 是 RegExp → `pattern.test(kw)` 命中则 `where(field, '=', kw)` 并**短路**;
|
|
86
|
+
3. 所有 rules 未命中 → 原 `columns` 的 OR `LIKE %kw%` fallback(现状行为)。
|
|
87
|
+
|
|
88
|
+
**命中即短路**:形状与列是一对一映射(手机号形状只可能精确查手机号列),不需要多规则 OR 合并。若同一列要接受多种形状(如手机号列同时收 11 位手机号与座机号),声明多条 rule 指向同列即可。
|
|
89
|
+
|
|
90
|
+
**生成物**:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { isPhone } from '@pylonts/mock/matcher';
|
|
94
|
+
import type { QueryBuilder } from '@pylonts/dao';
|
|
95
|
+
|
|
96
|
+
export const userListFilter = (args: UserListFilterArgs) =>
|
|
97
|
+
(q: QueryBuilder) => {
|
|
98
|
+
if (args.keyword) {
|
|
99
|
+
if (isPhone(args.keyword)) {
|
|
100
|
+
q.where('user.phone', '=', args.keyword);
|
|
101
|
+
} else if (/^ORD\d+$/.test(args.keyword)) {
|
|
102
|
+
q.where('order.order_no', '=', args.keyword);
|
|
103
|
+
} else {
|
|
104
|
+
q.where(function (this: QueryBuilder) {
|
|
105
|
+
this.where('user.username', 'like', `%${args.keyword}%`);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return q;
|
|
110
|
+
};
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- 内置语义名 → 产物 import `@pylonts/mock` 的 matcher 纯函数(`isPhone` 等),不内联正则——形状知识单点维护,与生成器共享常量;
|
|
114
|
+
- 项目正则 → 产物内联 `pattern.test(kw)`(`RegExp.toString()` 直出字面量,每次调用新建,无 `lastIndex` 状态问题);
|
|
115
|
+
- 精确查询走 `eq`(`=`),值经 knex 参数绑定,无注入面。
|
|
116
|
+
|
|
117
|
+
## 3. 内置语义 matcher 注册表(@pylonts/mock)
|
|
118
|
+
|
|
119
|
+
**依赖方向**:mock 已是纯 JS 无依赖包,api 项目产物可直接 import。matcher 与 generator 同文件维护,形状常量共享:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
pylon-mock/src/generators/phone.ts // mockPhone(生成) + isPhone(识别),共享 MOBILE_PREFIXES 等常量
|
|
123
|
+
pylon-mock/src/generators/id.ts // mockMerchantId/mockOrderId + isMerchantId/isOrderId
|
|
124
|
+
pylon-mock/src/generators/identity.ts // + isIdCard
|
|
125
|
+
pylon-mock/src/generators/finance.ts // + isBankCard
|
|
126
|
+
pylon-mock/src/matcher.ts // 注册表 + 导出(子路径 '@pylonts/mock/matcher')
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**注册表**(mirror 现有 MockRegistry 风格,`name → matcher fn`):
|
|
130
|
+
|
|
131
|
+
| 语义名 | 判定 | 对应生成器 |
|
|
132
|
+
|--------|------|-----------|
|
|
133
|
+
| `phone` | 11 位、前缀在白名单段 | `mockPhone` |
|
|
134
|
+
| `merchantId` | `M` 前缀 + 数字 | `mockMerchantId` |
|
|
135
|
+
| `orderId` | `ORD` 前缀 + 数字 | `mockOrderId` |
|
|
136
|
+
| `idcard` | 18 位 / 15 位校验 | `mockIdCard` |
|
|
137
|
+
| `bankcard` | Luhn 校验 | `mockBankCard` |
|
|
138
|
+
|
|
139
|
+
名单在 dsl 侧也需要可见(声明期校验语义名是否存在)。dsl 不依赖 mock(依赖方向不成立),处理:
|
|
140
|
+
|
|
141
|
+
- **方案 A**:dsl 硬编码语义名单(`KEYWORD_MATCHERS = ['phone', 'merchantId', ...] as const`),defineFilter 校验名字 ∈ 名单;mock 的注册表与之对齐(mock 侧的名单即权威实现)。dsl 只持"名字",实现永远在 mock。
|
|
142
|
+
- **方案 B**:dsl 不校验,gen loadFilters 校验(查 mock 注册表)——校验时机后移,声明错误要到生成才发现。
|
|
143
|
+
|
|
144
|
+
**推荐 A**:`defineFilter` 当场报错(与 conditions/keyword 非空校验一致),名单不长且是封闭枚举。mock 已依赖 `@pylonts/dsl`(^1.1.5),注册表 `Record<语义名, (s: string) => boolean>` 可直接用 dsl 导出的名单类型驱动,名单一处定义。
|
|
145
|
+
|
|
146
|
+
## 4. 端点与前后端一致性(不变量)
|
|
147
|
+
|
|
148
|
+
- **keyword 端点不变**:`query(keyword)` 统一入口,响应类型仍 `Row[]`,分页/tenant 语义不变;
|
|
149
|
+
- **前端无感知**:判定全在后端 filter 产物,前端搜索框仍只传一个字符串——"前后一样"由生成链路保证(页面 search model、DTO、controller、service、DAO 全部不变,只换 filter 产物内部行为);
|
|
150
|
+
- **conditions 不变**:rules 命中与否,声明的 AND 条件仍照常拼接。
|
|
151
|
+
|
|
152
|
+
## 5. 校验与错误
|
|
153
|
+
|
|
154
|
+
| 校验点 | 时机 | 行为 |
|
|
155
|
+
|--------|------|------|
|
|
156
|
+
| 语义名 ∈ 内置名单 | `defineFilter` | 抛错(与 conditions/keyword 非空校验同风格) |
|
|
157
|
+
| `rules` 非空 | `defineFilter` | 空数组等价于不声明,不报错 |
|
|
158
|
+
| 正则无 `g`/`y` 标志 | `defineFilter` | 抛错(带全局标志的 RegExp 有 lastIndex 状态,test 结果不确定) |
|
|
159
|
+
| rule.field 跨表 | `loadFilters` | 复用现有跨表 filter 机制(usage-site JOIN),不新增规则 |
|
|
160
|
+
| 语义名注册表漂移 | mock 单测 | 名单与实现一一对应(缺实现/多名均报错) |
|
|
161
|
+
|
|
162
|
+
## 6. 安全
|
|
163
|
+
|
|
164
|
+
- **SQL**:精确查询值与 LIKE 值同走 knex 参数绑定,无拼接;
|
|
165
|
+
- **ReDoS**:内置 matcher 是库作者维护的白名单正则(无嵌套量词陷阱);项目自定义正则由 schema 作者负责,文档提示避免 `(a+)+` 类回溯炸弹(只 `test` 用户输入,恶意输入可触发正则回溯——风险与任何服务端正则相同);
|
|
166
|
+
- **无执行面**:matcher 只返回 boolean,无回调 / 无用户代码注入。
|
|
167
|
+
|
|
168
|
+
## 7. 分阶段落地(未开始)
|
|
169
|
+
|
|
170
|
+
| 阶段 | 内容 | 产出 |
|
|
171
|
+
|------|------|------|
|
|
172
|
+
| 1 | dsl:`KeywordRule` + `keyword.rules` 声明 + defineFilter 校验(语义名单、正则标志) | `dsl/src/filter.ts` + 测试 |
|
|
173
|
+
| 2 | mock:matcher 注册表(phone/merchantId/orderId 起步,与生成器共享形状常量)+ 子路径导出 | `pylon-mock/src/matcher.ts` + 测试 |
|
|
174
|
+
| 3 | gen:keyword 块渲染 if/else-if 链(语义名 → import matcher;正则 → 内联 test;fallback → 现有 OR like) | `gen/src/filter-render.ts` + 测试 |
|
|
175
|
+
| 4 | examples:user-list.filter 加 `{ field: phone, when: 'phone' }`,验证产物与 api tsc | examples 回归 |
|
|
176
|
+
|
|
177
|
+
## 8. 待决策问题
|
|
178
|
+
|
|
179
|
+
1. **内置语义名单范围**:起步只做 `phone`(最常见)还是 phone/merchantId/orderId 三件套?身份证/银行卡的校验规则重(校验位),是否首期引入?
|
|
180
|
+
2. **matcher 实现归属**:`@pylonts/mock` 是"Mock 数据生成器"定位,形状识别放这里是否符合包边界?备选是新建 `@pylonts/matcher` 或放 `@pylonts/core`(框架无关标准层,依赖方向更干净,但形状常量与 mock 生成器要共享——共享方式:mock 依赖 core,或 matcher 独立包被 mock 依赖)。
|
|
181
|
+
3. **命中后是否允许继续 like**:本方案短路(命中即纯精确)。备选:命中规则后仍追加 OR like 扩大召回(多返回行),由 schema 作者选择。首期短路即可,若业务要"模糊召回"可后续加 `rule.fallbackLike?: boolean`。
|
|
182
|
+
4. **精确查询的操作符**:本方案固定 `eq`。订单号/商户号等唯一键列 eq 合理;若出现"前缀精确"需求(如输入 `ORD17` 查今年订单),是否允许 rule 声明 `op: 'like'`(精确列上的前缀 LIKE)?
|