@pylonts/dsl 1.1.5 → 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 +35 -0
- package/dist/controller.js +17 -0
- package/dist/convert.d.ts +37 -7
- package/dist/convert.js +23 -2
- package/dist/curd.d.ts +7 -12
- package/dist/curd.js +10 -1
- package/dist/dao.d.ts +140 -3
- package/dist/dao.js +307 -2
- package/dist/db.d.ts +6 -0
- package/dist/db.js +13 -0
- package/dist/domain-event.d.ts +48 -0
- package/dist/domain-event.js +24 -0
- package/dist/dsl.d.ts +32 -2
- package/dist/dsl.js +13 -0
- package/dist/dto.d.ts +8 -2
- package/dist/dto.js +21 -9
- package/dist/endpoint.d.ts +15 -0
- package/dist/endpoint.js +3 -0
- package/dist/entity.d.ts +29 -0
- package/dist/entity.js +13 -0
- package/dist/exception.d.ts +20 -0
- package/dist/exception.js +33 -0
- package/dist/expr.d.ts +45 -0
- package/dist/expr.js +32 -0
- package/dist/field-rule.d.ts +20 -0
- package/dist/field-rule.js +19 -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 +309 -10
- package/dist/flow.js +819 -22
- package/dist/index.d.ts +12 -1
- package/dist/index.js +14 -1
- package/dist/mermaid-driver.js +278 -9
- package/dist/method.d.ts +11 -0
- package/dist/method.js +3 -0
- package/dist/mysql-driver.js +7 -0
- package/dist/project.d.ts +5 -4
- package/dist/project.js +14 -2
- package/dist/provider.d.ts +6 -11
- package/dist/provider.js +2 -2
- package/dist/repository.d.ts +26 -0
- package/dist/repository.js +8 -0
- package/dist/service.d.ts +30 -8
- package/dist/service.js +62 -2
- package/dist/third-service.d.ts +80 -0
- package/dist/third-service.js +97 -0
- package/dist/typebox-driver.d.ts +6 -0
- package/dist/typebox-driver.js +77 -12
- package/dist/utils.d.ts +32 -10
- package/dist/utils.js +32 -11
- 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/dto.md +73 -66
- package/docs/keyword-matcher.md +182 -0
- package/docs/third-service.md +122 -0
- package/docs/token.md +327 -0
- package/docs/trans-reentrant.md +85 -0
- package/package.json +27 -5
- package/src/action.ts +51 -10
- package/src/aggregate.ts +104 -0
- package/src/business-flow.ts +80 -0
- package/src/controller.ts +54 -0
- package/src/convert.ts +78 -15
- package/src/curd.ts +104 -93
- package/src/dao.ts +486 -13
- package/src/db.ts +199 -181
- package/src/domain-event.ts +74 -0
- package/src/dsl.ts +48 -2
- package/src/dto.ts +266 -247
- package/src/entity.ts +43 -0
- package/src/exception.ts +53 -0
- package/src/expr.ts +65 -0
- package/src/field-rule.ts +47 -0
- package/src/filter.ts +70 -0
- package/src/flow-script.ts +696 -0
- package/src/flow.ts +1226 -103
- package/src/index.ts +47 -33
- package/src/mermaid-driver.ts +339 -84
- package/src/method.ts +20 -0
- package/src/mysql-driver.ts +7 -0
- package/src/project.ts +114 -97
- package/src/repository.ts +35 -0
- package/src/service.ts +107 -20
- package/src/third-service.ts +192 -0
- package/src/typebox-driver.ts +86 -11
- package/src/utils.ts +74 -26
- package/dist/check-inheritance.d.ts +0 -9
- package/dist/check-inheritance.js +0 -58
- package/src/provider.ts +0 -73
|
@@ -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` + 启动扫描注册订阅者。
|
package/docs/dto.md
CHANGED
|
@@ -1,67 +1,74 @@
|
|
|
1
|
-
# 定义 DTO(四种方向)
|
|
2
|
-
|
|
3
|
-
DTO 描述接口出入参。方向决定可选性规则,由各方向工厂设置;**规则统一只在 `field.optional === undefined` 时生效**(作者已显式设置的跳过):
|
|
4
|
-
|
|
5
|
-
| 构建器 | 方向 | 可选性规则 |
|
|
6
|
-
|---|---|---|
|
|
7
|
-
| `buildInput` | input | 按 DB 列规则:主键 → 必填;可空(未写 `optional` 或 `optional: true`)/ 有默认 → 可选;`optional: false` 无默认 → 必填 |
|
|
8
|
-
| `buildOutput` | output | 不设置(保持字段构建器/作者设置) |
|
|
9
|
-
| `buildQuery` | query | 全部可选 |
|
|
10
|
-
| `buildPk` | pk | 主键字段必填,其他字段可选 |
|
|
11
|
-
|
|
12
|
-
##
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
##
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
- `
|
|
1
|
+
# 定义 DTO(四种方向)
|
|
2
|
+
|
|
3
|
+
DTO 描述接口出入参。方向决定可选性规则,由各方向工厂设置;**规则统一只在 `field.optional === undefined` 时生效**(作者已显式设置的跳过):
|
|
4
|
+
|
|
5
|
+
| 构建器 | 方向 | 可选性规则 |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `buildInput` | input | 按 DB 列规则:主键 → 必填;可空(未写 `optional` 或 `optional: true`)/ 有默认 → 可选;`optional: false` 无默认 → 必填 |
|
|
8
|
+
| `buildOutput` | output | 不设置(保持字段构建器/作者设置) |
|
|
9
|
+
| `buildQuery` | query | 全部可选 |
|
|
10
|
+
| `buildPk` | pk | 主键字段必填,其他字段可选 |
|
|
11
|
+
|
|
12
|
+
## 从字段集合投影
|
|
13
|
+
|
|
14
|
+
`from(source, fields)` 接受两类字段集合源:**表**(`TableSchema`)或**第三方方法消息**(`ThirdMethodSchema`,见 [third-service.md](./third-service.md)),投影字段包装为 DTO 字段,字段实例与源共享,`name/schema` 保持指向源。
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { buildInput, buildQuery, dtoField, from } from '@pylonts/dsl';
|
|
18
|
+
|
|
19
|
+
// 输入:新增订单
|
|
20
|
+
buildInput('OrderAddRequest', { ...from(order, [order.columns.mer_id, order.columns.amount]) });
|
|
21
|
+
|
|
22
|
+
// 输出:订单行
|
|
23
|
+
buildOutput('OrderRow', from(order, [order.columns.id, order.columns.order_no]));
|
|
24
|
+
|
|
25
|
+
// 查询:分页 + 过滤(query 字段恒为可选,.op() 声明比较操作符)
|
|
26
|
+
buildQuery('OrderPageQuery', {
|
|
27
|
+
keyword: dtoField(stringField({ maxLength: 32 })).setOperator('like'),
|
|
28
|
+
...from(order, [order.columns.mer_id]),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// 主键:按 id 取详情
|
|
32
|
+
buildPk('OrderDetailRequest', from(order, [order.columns.id]));
|
|
33
|
+
|
|
34
|
+
// 转发:透传第三方方法消息(wire-format 字段名保持协议原样,不转 camelCase)
|
|
35
|
+
buildOutput('BalanceResult', from(queryBalance.results, [queryBalance.results.fields.balance]));
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- **表源**:DTO 字段名转 camelCase(`mer_id` → `merId`),与 DB 列名(snake_case)分离。
|
|
39
|
+
- **第三方方法源**:字段名即线格式协议名(`out_trade_no`、`appId`),保持不变。
|
|
40
|
+
- `from()` 本身不做任何可选性推断——推断在各方向工厂,且按字段的 schema 判断:共享实体列按列规则推断(Rule A),wire 自有字段保留声明值。
|
|
41
|
+
|
|
42
|
+
## 独立字段
|
|
43
|
+
|
|
44
|
+
不来自表的内联字段直接用 `dtoField(...)` 包装任意字段构建器,可加 `pattern`、`optional`、`operator`、`default`。
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
dtoField(stringField({ maxLength: 32 })).setOperator('like')
|
|
48
|
+
dtoField(intField()).setDefault(0) // TypeBox default 注解
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 默认值
|
|
52
|
+
|
|
53
|
+
- `setDefault(v)` 设 DTO 层默认值,渲染为 TypeBox `default:` 注解(`Type.String({ default: 'PENDING' })`、`Type.Enum(OrderStatus, { default: OrderStatus.PENDING })`)。
|
|
54
|
+
- 枚举默认值必须是该枚举的成员值(value),渲染时解析为成员引用;非成员值直接报错。
|
|
55
|
+
- 未显式设置时,fallback 到字段构建器的 `default`(DB 默认值,string),`from()` 提取的字段自动带出。
|
|
56
|
+
|
|
57
|
+
## 继承基础 schema
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
buildQuery('OrderPageQuery', { ... })
|
|
61
|
+
.include({ from: '@pylonts/core', name: 'PageRequest' }); // 渲染 Type.Intersect([PageRequest, ...])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 关键语义
|
|
65
|
+
|
|
66
|
+
- **字段两层名**:`DtoField.name` 是接口字段名(DTO map key 反写);`field.name` 是数据库列名(表反写)。
|
|
67
|
+
- **可选性优先级**:DTO 层 `optional` 优先于字段层;query 方向所有字段强制可选。
|
|
68
|
+
- **HTTP 传 string**:bigint / decimal / date / time 在接口层渲染为 `Type.String()`,保证精度与序列化语义。
|
|
69
|
+
|
|
70
|
+
## 文件组织
|
|
71
|
+
|
|
72
|
+
- `dto_schema/{module}/*.dto.ts`:DTO 源文件,一个文件可导出多个 DtoMessage;`module` = `project.config.ts` 的 `apps.name`,另加 `common/` 放跨 app 共享 DTO。
|
|
73
|
+
- `pylonts gen dto` 按 module 逐个扫描:`dto_schema/{module}/*.dto.ts` → 生成 `dto/{module}/{Name}.dto.ts`。
|
|
67
74
|
- 枚举字段引用 `enums/` 下生成的枚举源文件,DTO 文件通过 `../../enums/{JsName}.enum` 导入。
|
|
@@ -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)?
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# 定义第三方服务(ThirdServiceSchema)
|
|
2
|
+
|
|
3
|
+
第三方服务适配器契约(如微信支付 tenpay、短信、文件存储)。`defineThirdService` 声明适配器类契约:构造函数配置 + 方法列表。
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { defineFieldRule, defineThirdService } from '@pylonts/dsl';
|
|
7
|
+
import { wx } from '../project.config';
|
|
8
|
+
|
|
9
|
+
const balance = intField({ optional: false, label: '余额(分)' });
|
|
10
|
+
|
|
11
|
+
// Rule = name + two named ends. One rule per semantic (defining fenYuan twice throws).
|
|
12
|
+
const fenYuan = defineFieldRule({
|
|
13
|
+
name: 'fenYuan',
|
|
14
|
+
ends: { fen: {}, yuan: {} },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const wxPayService = defineThirdService({
|
|
18
|
+
schema: wx,
|
|
19
|
+
name: 'WxPayService',
|
|
20
|
+
description: '微信支付服务(tenpay APIv2)',
|
|
21
|
+
methods: [
|
|
22
|
+
{
|
|
23
|
+
name: 'queryBalance',
|
|
24
|
+
args: { name: 'QueryBalanceArgs', fields: { openid: user.columns.openid } },
|
|
25
|
+
results: {
|
|
26
|
+
name: 'QueryBalanceResult',
|
|
27
|
+
fields: {
|
|
28
|
+
balance,
|
|
29
|
+
},
|
|
30
|
+
refs: [
|
|
31
|
+
{ field: balance, ref: order.columns.amount, convert: { rule: fenYuan, end: fenYuan.ends.fen } },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## ThirdMethodSchema:方法的 args / results
|
|
40
|
+
|
|
41
|
+
每个方法的 `args` / `results` 各是一个 `ThirdMethodSchema`——**字段集合容器**,与 `TableSchema.columns` 同构,但字段是线格式(wire-format)字段。结构上它是 `DtoMessage` 的字段集合对应物。
|
|
42
|
+
|
|
43
|
+
| 属性 | 说明 |
|
|
44
|
+
|------|------|
|
|
45
|
+
| `name` | 消息名(如 `QueryBalanceResult`),生成产物的类型名 |
|
|
46
|
+
| `fields` | 线格式字段 map,key 即协议字段名(`out_trade_no`、`appId` 原样保留) |
|
|
47
|
+
| `refs` | 同事实变体链接(见下) |
|
|
48
|
+
| `schema` | 反向指针,指向所属 method(构建器写入) |
|
|
49
|
+
|
|
50
|
+
`defineThirdMethod` 写回自有字段的 `name/schema`(与 `defineTable` 同一惯例)。
|
|
51
|
+
|
|
52
|
+
## 字段与本地实体的关系
|
|
53
|
+
|
|
54
|
+
两个通道,按"同一事实"的表达方式选择:
|
|
55
|
+
|
|
56
|
+
### 同一概念且类型一致 → 共享实例
|
|
57
|
+
|
|
58
|
+
直接复用本地实体列实例,类型/语义/默认值自动跟随实体,DTO 投影继承全部语义(与 `from(table)` 完全同构):
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
args: {
|
|
62
|
+
name: 'QueryBalanceArgs',
|
|
63
|
+
fields: {
|
|
64
|
+
openid: user.columns.openid, // 共享实例,schema 仍指向 t_user
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
前提是线格式字段名与实体列名一致(协议恰好也叫 `openid`)。名字不同时(协议叫 `userId` 而列叫 `user_id`),不共享实例,走 refs。
|
|
70
|
+
|
|
71
|
+
### 同一概念但类型/格式不同(变体)→ 自有字段 + refs
|
|
72
|
+
|
|
73
|
+
声明自有线格式类型,再挂一条 `refs` 链接指向实体列。规则先定义一次:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// 规则 = 名称 + 两端(具名 map)。同一语义全局只允许一条(重复定义抛错)。
|
|
77
|
+
const fenYuan = defineFieldRule({
|
|
78
|
+
name: 'fenYuan',
|
|
79
|
+
ends: { fen: {}, yuan: {} },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const totalFee = intField({ optional: false, label: '金额(分)' });
|
|
83
|
+
|
|
84
|
+
fields: {
|
|
85
|
+
total_fee: totalFee,
|
|
86
|
+
},
|
|
87
|
+
refs: [
|
|
88
|
+
{
|
|
89
|
+
field: totalFee, // 本地定义(本消息的 wire 字段)
|
|
90
|
+
ref: order.columns.amount, // 其他定义(表列或其他消息字段)
|
|
91
|
+
convert: { rule: fenYuan, end: fenYuan.ends.fen },
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- `field`:本消息的 wire 字段实例(构建器校验必须是本消息字段)
|
|
97
|
+
- `ref`:其他 schema 的字段实例(表列或其他消息字段,构建器校验不得是本消息字段)
|
|
98
|
+
- `convert`(可选):绑定一条规则到这对字段——仅当两字段需要转化时声明,纯关联不需要
|
|
99
|
+
- `rule`:`FieldRuleSchema`——规则 = 名称 + 两端(如 `fenYuan` 的 `fen`/`yuan` 端)。加密/脱敏/换算统一为规则名维度,`defineFieldRule` 按名称查重,同一语义只声明一次
|
|
100
|
+
- `end`:`field` 所站的端——引用 `rule.ends.fen` / `rule.ends.yuan`(具名引用,无索引魔法;构建器按实例校验),`ref` 自动占另一端——不再重复声明 from/to
|
|
101
|
+
- 生成器将来为这对字段产出两个方向的函数(field 端→ref 端 与 ref 端→field 端)
|
|
102
|
+
|
|
103
|
+
## 嵌套字段
|
|
104
|
+
|
|
105
|
+
线格式字段支持递归嵌套,用 `arrayField` / `objectField`(Field 体系,非表列):
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
fields: {
|
|
109
|
+
payer_info: objectField({
|
|
110
|
+
properties: {
|
|
111
|
+
openid: stringField({ optional: false, maxLength: 64 }),
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
coupons: arrayField({ items: intField() }),
|
|
115
|
+
},
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
表列不支持这两个类型(`buildCreateTableSql` 直接报错)。
|
|
119
|
+
|
|
120
|
+
## DTO 转发
|
|
121
|
+
|
|
122
|
+
DTO 通过 `from(thirdMethod, fields)` 投影第三方消息字段,构建转发引用(透传不复制)。线格式字段名保持协议原样,不做 camelCase。见 [dto.md](./dto.md#从字段集合投影)。
|