nicot 1.2.3 → 1.2.4

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/api-cn.md ADDED
@@ -0,0 +1,749 @@
1
+ # NICOT API 参考
2
+
3
+ > 本文是 **NICOT 的高级 API / 行为说明**,偏向“干货手册”而不是教程。
4
+ > 面向已经大致看过 README 的使用者。
5
+
6
+ ---
7
+
8
+ ## 0. 术语约定
9
+
10
+ - **Entity / 实体类**:TypeORM 的实体类,也是 NICOT 的“单一事实源”(字段、校验、查询能力都挂在这里)。
11
+ - **Factory**:`new RestfulFactory(Entity, options)` 的实例。
12
+ - **CrudService**:由 `Factory.crudService()` 生成的 Service 基类。
13
+ - **BaseRestfulController**:`Factory.baseController()` 内部使用的基类。
14
+ - **Query DTO**:`findAllDto` / `findAllCursorPaginatedDto`。
15
+ - **Result DTO**:`entityResultDto` / `entityCreateResultDto`。
16
+
17
+ 下文中 `T` 代表一个实体类的 TypeScript 类型。
18
+
19
+ ---
20
+
21
+ ## 1. 实体基类 & Hook
22
+
23
+ ### 1.1 IdBase / StringIdBase 行为
24
+
25
+ **IdBase(idOptions?: { description?: string; noOrderById?: boolean; })**
26
+
27
+ - 字段:
28
+ - `id: number`
29
+ - 数据库列:`bigint unsigned` + primary + auto increment
30
+ - 默认排序:
31
+ - 若 `noOrderById !== true`,则在 `applyQuery()` 中追加
32
+ `ORDER BY <alias>.id DESC`
33
+ - 校验 / 查询能力:
34
+ - 视为“有默认值”的字段(创建时可以不传);
35
+ - 支持 `?id=123` 这样的等值查询;
36
+ - Create/Update DTO 中不会暴露(不可写)。
37
+
38
+ **StringIdBase(options: { length?: number; uuid?: boolean; noOrderById?: boolean; … })**
39
+
40
+ - 字段:
41
+ - `id: string`(primary)
42
+ - 行为分两类:
43
+ - `uuid: true`:
44
+ - 由数据库自动生成 UUID,Create/Update DTO 不允许写入。
45
+ - `uuid: false / 未指定`:
46
+ - 使用固定长度的 `varchar`,创建时必须填写,且创建后不可修改。
47
+ - 默认排序:
48
+ - 若 `noOrderById !== true`,则追加
49
+ `ORDER BY <alias>.id ASC`。
50
+
51
+ 可以理解为:
52
+
53
+ - **IdBase**:自增数字主键,默认“新记录在前”。
54
+ - **StringIdBase(uuid)**:字符串/UUID 主键,默认“按字典顺序”排。
55
+
56
+ ### 1.2 Entity Hook 生命周期
57
+
58
+ 实体类可以实现下列“约定方法”,由 CrudService 调用:
59
+
60
+ - 校验:
61
+ - `isValidInCreate(): string | undefined`
62
+ - `isValidInUpdate(): string | undefined`
63
+ - 返回非空字符串 → 以 `400` 抛出统一错误响应。
64
+ - 生命周期:
65
+ - `beforeCreate()`, `afterCreate()`
66
+ - `beforeUpdate()`, `afterUpdate()`
67
+ - `beforeGet()`, `afterGet()`
68
+ - 查询扩展:
69
+ - `applyQuery(qb, entityAliasName)`
70
+ (IdBase / TimeBase 默认会在这里加排序等,业务也可以 override)
71
+
72
+ 查询时大致顺序:
73
+
74
+ 1. 构造实体实例 `ent = new EntityClass()`
75
+ 2. 将 DTO 填充到 `ent`
76
+ 3. `ent.beforeGet?.()`
77
+ 4. `ent.applyQuery(qb, alias)`(默认排序)
78
+ 5. 应用 relations(见第 7 节)
79
+ 6. 应用字段上的 Query 系列装饰器
80
+ 7. 应用分页(offset / cursor)
81
+ 8. 执行 SQL,拿到结果
82
+ 9. 对每条记录调用 `afterGet?.()`
83
+ 10. 删除标记为“不应出现在结果中的字段”(见第 2 节)
84
+
85
+ ---
86
+
87
+ ## 2. 访问控制装饰器 & 字段裁剪
88
+
89
+ ### 2.1 字段访问装饰器总览
90
+
91
+ NICOT 把“一个字段在哪些阶段可见/可写”统一交给装饰器 + Factory 配置管理。
92
+
93
+ 典型装饰器:
94
+
95
+ | 装饰器 | 作用阶段 | 效果概述 |
96
+ |------------------------|------------------------------------|--------------------------------------------------------------------------|
97
+ | `@NotWritable()` | Create / Update | 永远不可在入参中写入此字段 |
98
+ | `@NotCreatable()` | Create | 仅在创建时不可写(更新时可写) |
99
+ | `@NotChangeable()` | Update | 仅在更新时不可写(创建时可写) |
100
+ | `@NotQueryable()` | GET Query DTO | 从查询参数 DTO 中剔除(不能用来筛选) |
101
+ | `@NotInResult()` | Result DTO | 从所有返回结果中剔除(包括嵌套 relation 中同名字段) |
102
+ | `@NotColumn()` | DB 层 | 不映射数据库列,适合“计算后回填”的字段 |
103
+ | `@QueryColumn()` | GET Query DTO | 声明一个“只用于查询、不写入 DB”的字段,通常要搭配 QueryCondition 使用 |
104
+ | `@RelationComputed()` | Result DTO / relations 剪枝 | 声明此字段由 relations 计算得出,配合 relations 配置避免无限递归 |
105
+
106
+ 这些标记会在 Factory / CrudService 中统一被读取,然后决定:
107
+
108
+ - Create DTO 有哪些字段
109
+ - Update DTO 有哪些字段
110
+ - GET 的 Query DTO 有哪些字段
111
+ - Result DTO 要剔除哪些字段
112
+
113
+ ### 2.2 DTO 裁剪规则(优先级)
114
+
115
+ 从逻辑上可以概括为:
116
+
117
+ - **Create DTO**:
118
+ - 剔除:所有 `NotColumn`、所有 relations、`NotWritable`、`NotCreatable`,以及工厂里显式声明要 omit 的字段。
119
+ - **Update DTO**:
120
+ - 剔除:所有 `NotColumn`、所有 relations、`NotWritable`、`NotChangeable`,以及工厂级 omit。
121
+ - **FindAll DTO(查询入参)**:
122
+ - 剔除:
123
+ - 所有 `NotColumn`
124
+ - 所有 relations
125
+ - `NotQueryable`
126
+ - 那些“声明了需要 mutator 但没有真正提供 mutator”的字段
127
+ - 工厂级 omit
128
+ - **Result DTO**:
129
+ - 剔除:
130
+ - 所有标记为“结果中不应出现”的字段(包含部分时间戳/版本字段)
131
+ - 工厂级 `outputFieldsToOmit`
132
+ - 未出现在 relations 白名单中的 relations(见第 7 节)
133
+
134
+ 可以很粗暴地记成一条:
135
+
136
+ > **一个字段要出现在某个阶段(Create / Update / 查询参数 / 结果),必须同时被“装饰器”和“Factory 配置”放行。**
137
+
138
+ 作为框架使用者,你只需要决定:
139
+
140
+ - 给字段挂哪个访问装饰器;
141
+ - Factory 上是否进一步配置 `fieldsToOmit` / `outputFieldsToOmit` 等。
142
+
143
+ ---
144
+
145
+ ## 3. Query 系列:QueryCondition / QueryXXX 组合
146
+
147
+ ### 3.1 QueryCondition 的角色
148
+
149
+ `QueryCondition` 是一类装饰器的“底层”,用于描述:
150
+
151
+ > “这个字段出现在查询 DTO 里时,应当如何映射到 SQL 的 WHERE 条件。”
152
+
153
+ NICOT 会在所有 GET 查询中:
154
+
155
+ 1. 收集实体上所有带有 QueryCondition 的字段;
156
+ 2. 如果请求 DTO 对该字段提供了值;
157
+ 3. 通过字段上挂的逻辑,给 `SelectQueryBuilder` 追加条件。
158
+
159
+ **只作用于 GET 查询**,不会影响:
160
+
161
+ - 创建 / 更新;
162
+ - 删除。
163
+
164
+ ### 3.2 常用 Query 包装器
165
+
166
+ NICOT 提供了一系列工厂方法,方便你通过装饰器描述常见的 SQL 形式。例如:
167
+
168
+ - `QueryEqual()`:
169
+ - `?status=1` → `status = :status`。
170
+ - `QueryLike()`:
171
+ - `?name=ab` → `name LIKE 'ab%'`。
172
+ - `QuerySearch()`:
173
+ - `?name=ab` → `name LIKE '%ab%'`。
174
+ - `QueryIn()`:
175
+ - `?ids=1,2,3` 或 `?ids[]=1&ids[]=2` → `id IN (:...ids)`。
176
+ - `QueryNotIn()`:
177
+ - 对应 `NOT IN`。
178
+ - `QueryMatchBoolean()`:
179
+ - 将 `true / false / 1 / 0 / 'true' / 'false'` 等解析成真正布尔,然后生成 `= TRUE/FALSE`。
180
+ - `QueryEqualZeroNullable()`:
181
+ - `?foo=0` / `?foo=0,0` → 将 0 解释为 “NULL”,生成 `IS NULL`;否则 `=`。
182
+
183
+ 这些都属于“查询表达式模板”,通过装饰器挂在实体字段上:
184
+
185
+ ```ts
186
+ class User {
187
+ @QueryEqual()
188
+ status: number;
189
+
190
+ @QueryLike()
191
+ name: string;
192
+
193
+ @QueryIn()
194
+ ids: number[];
195
+ }
196
+ ```
197
+
198
+ GET 时只要 query DTO 带上这些字段,就会自动映射为对应的 SQL 条件。
199
+
200
+ ### 3.3 组合:QueryAnd / QueryOr
201
+
202
+ 有些场景你希望“一个字段挂多个查询逻辑”,例如:
203
+
204
+ - 一个字段同时满足“模糊匹配”和“大小写无关”的组合;
205
+ - 或者使用 OR 把多个 QueryCondition 拼成“多种搜索方式之一”。
206
+
207
+ NICOT 提供了:
208
+
209
+ - `QueryAnd(A, B, C...)`:
210
+ - 按顺序把多个 QueryCondition 对应的条件全部 AND 在一起。
211
+ - `QueryOr(A, B, C...)`:
212
+ - 每个条件内部保持自己的 AND 结构,然后整个语句以 OR 连接。
213
+
214
+ 用法示例(伪代码):
215
+
216
+ ```ts
217
+ class Article {
218
+ // 同一个字段上,同时支持 A/B 两套 QueryCondition,
219
+ // 但最终把 A 和 B 的表达式 OR 起来
220
+ @QueryOr(QueryLike(), QueryEqual())
221
+ title: string;
222
+ }
223
+ ```
224
+
225
+ 你可以把它当成“逻辑积木”,但无需关心内部如何生成括号和参数名——只要知道:
226
+
227
+ - A 和 B 依然各自依赖字段值;
228
+ - A 与 B 的表达式组合方式由 `QueryAnd/QueryOr` 决定。
229
+
230
+ ---
231
+
232
+ ## 4. PostgreSQL 全文搜索(QueryFullText)
233
+
234
+ ### 4.1 使用场景
235
+
236
+ `QueryFullText` 是一个专门面向 **PostgreSQL** 的查询装饰器,适用于:
237
+
238
+ - 标记某个文本列为“全文搜索字段”;
239
+ - 让 GET Query DTO 支持“`?q=关键字`”这样的查询;
240
+ - 可选按照匹配度 `ts_rank` 排序。
241
+
242
+ 核心能力包括:
243
+
244
+ - 自动创建全文索引(GIN + `to_tsvector(...)`);
245
+ - 自动创建/配置 text search configuration(若指定了 parser,例如中文分词);
246
+ - 在查询时自动生成 `to_tsvector(...) @@ websearch_to_tsquery(...)` 之类的表达式;
247
+ - 可选地根据匹配度降序排序。
248
+
249
+ ### 4.2 配置要点
250
+
251
+ 装饰器形态大致为:
252
+
253
+ ```ts
254
+ class Article {
255
+ @QueryFullText({
256
+ // parser / configuration 二选一:
257
+ // - 指定 parser(例如 'zhparser')会为当前实体创建 nicot 自己的配置
258
+ // - 或直接指定 configuration 名(比如 'english')
259
+ parser?: string;
260
+ configuration?: string;
261
+
262
+ // tsQuery 函数名称,默认 'websearch_to_tsquery'
263
+ tsQueryFunction?: string;
264
+
265
+ // 是否在结果中按相似度排序
266
+ orderBySimilarity?: boolean;
267
+ })
268
+ content: string;
269
+ }
270
+ ```
271
+
272
+ 行为概览:
273
+
274
+ 1. **模块初始化阶段**:
275
+ - NICOT 会扫描所有实体上带 `QueryFullText` 的字段;
276
+ - 为这些字段生成必要的 extension / configuration / index。
277
+ 2. **GET 查询阶段**:
278
+ - 当 Query DTO 上该字段有值时:
279
+ - 追加全文搜索条件;
280
+ - 如果 `orderBySimilarity: true`,则自动插入一个“相似度虚拟字段”到排序序列的最前面。
281
+
282
+ > 非 PostgreSQL 环境下,这个装饰器不保证可用。
283
+ > 建议在你的项目文档中注明:**这些功能仅支持 PG**。
284
+
285
+ ---
286
+
287
+ ## 5. GetMutator:查询参数的 wire-format 转换
288
+
289
+ NICOT 支持为某些字段定义“GET 查询时的转换逻辑”,典型场景:
290
+
291
+ - 前端永远以 `string` 形式传参(`?tags=1,2,3`);
292
+ - 控制器内希望拿到的已经是类型安全的结构(`number[]` / 自定义对象等);
293
+ - OpenAPI 上依旧展示为 `string`,方便文档对齐 URL wire-format。
294
+
295
+ ### 5.1 行为总结
296
+
297
+ 当某个字段标记了类似 `getMutator` 的元数据时,NICOT 会:
298
+
299
+ 1. 在 GET 的 Query DTO 生成阶段:
300
+ - 将该字段在 OpenAPI 中标注为 `string` 类型;
301
+ - 可以携带 `example` / `enum` / 其他描述字段;
302
+ - 去除默认值(避免 Swagger 表单里自动填入默认 filter)。
303
+ 2. 在真正进入 controller 之前:
304
+ - 对整个 query DTO 做一次浅拷贝;
305
+ - 针对所有声明过 `getMutator` 的字段:
306
+ - 若值非空,调用对应 mutator,将 `string` 转换为目标结构;
307
+ - 控制器收到的参数对象中,该字段就是“转换后”的类型。
308
+
309
+ 你可以把它当成:
310
+
311
+ > “GET 查询参数 **永远按 string 形式传进来**,在进入 controller 前,NICOT 帮你做了一次类型转换;OpenAPI 上展示仍然以 string 为主。”
312
+
313
+ ### 5.2 使用建议
314
+
315
+ - 适合以下模式:
316
+ - `?ids=1,2,3` → 变成 `number[]`;
317
+ - `?range=2024-01-01,2024-02-01` → 变成 `{ from: Date; to: Date }`;
318
+ - `?country=us` → 映射到枚举类型。
319
+ - 一旦字段上启用了 mutator:
320
+ - 请按“转换后的类型”来定义 DTO 字段类型;
321
+ - 不要在控制器中再去自己 parse 字符串。
322
+
323
+ ---
324
+
325
+ ## 6. skipNonQueryableFields:只暴露“可用来过滤”的字段
326
+
327
+ 在创建 `RestfulFactory` 时可以传入:
328
+
329
+ ```ts
330
+ new RestfulFactory(Entity, {
331
+ skipNonQueryableFields: true,
332
+ })
333
+ ```
334
+
335
+ 其效果:
336
+
337
+ - GET 查询 DTO(`findAllDto` / `findAllCursorPaginatedDto`)中:
338
+ - **只会包含挂了查询装饰器(QueryCondition 系列)的字段**;
339
+ - 其他字段(即使存在于实体中)不会出现在 Query DTO 里。
340
+ - 控制器入参解析时:
341
+ - 所有“不在查询白名单”的 query 参数会被悄悄丢弃。
342
+
343
+ 推荐使用场景:
344
+
345
+ - 对外开放的接口 / 多租户环境:
346
+ - 打开 `skipNonQueryableFields`,让“可用来过滤的字段”显式白名单化。
347
+ - 内部调试接口:
348
+ - 可以关闭该开关以获得更多灵活性(但需要自己约束哪些字段可以暴露)。
349
+
350
+ ---
351
+
352
+ ## 7. relations & @RelationComputed
353
+
354
+ ### 7.1 relations 配置的双重角色
355
+
356
+ 在 NICOT 中,“要不要联表查询”由两处配置共同影响:
357
+
358
+ 1. **Factory / CrudService 的 `relations` 参数**:
359
+ - 控制 Service 层是否在 SQL 中 join 这些 relations;
360
+ - 也控制 `entityResultDto` / `entityCreateResultDto` 中是否保留这些字段。
361
+ 2. **实体上的 `@RelationComputed()`**:
362
+ - 用于声明:某个字段是通过 relations 计算得出;
363
+ - 当你在 `relations` 里关闭某条链路时,可以避免递归计算此类字段,从而保证结果结构可控。
364
+
365
+ 默认行为:
366
+
367
+ - 如果没有指定 `relations`:
368
+ - Service 层不会自动 join 任何 relations(只查主表);
369
+ - Result DTO 中也会剔除所有 relations 字段。
370
+ - 当指定了 `relations`:
371
+ - 例如 `['user', 'user.profile']`:
372
+ - Service 只 join 这些链路;
373
+ - Result DTO 只保留对应 relations 字段,其余 relations 自动剔除。
374
+
375
+ `@RelationComputed()` 适合以下场景:
376
+
377
+ - 某个字段依赖多级 relations 计算:
378
+ - 例如 `post.user.profile.nickname`;
379
+ - 你希望通过 relations 配置剪枝时,不会错误地留下一个“失去依赖”的字段:
380
+ - 这类字段应当在“被裁掉 relations”时也一起被排除,避免前端读到半残数据。
381
+
382
+ ### 7.2 Service vs Factory 的 relations
383
+
384
+ - **Service**(CrudOptions):
385
+ - 控制 **查询层面**:join 哪些表、select 哪些列。
386
+ - **Factory**:
387
+ - 除了影响查询,也影响 Result DTO 的字段结构和 Swagger 文档。
388
+
389
+ **推荐做法**:
390
+
391
+ - 不要在 Service 上随意单独写一套 relations;
392
+ - 统一通过 Factory 的 `relations` 管理,然后用 `Factory.crudService()` 生成 Service:
393
+ - 这样 Service / Controller / DTO 在“数据结构”和“查询行为”上是一致的;
394
+ - 避免“Service 查了,但 Result DTO 给你裁掉了”的不一致。
395
+
396
+ ---
397
+
398
+ ## 8. CrudService 选项 & 删除/导入行为
399
+
400
+ ### 8.1 CrudOptions 回顾
401
+
402
+ ```ts
403
+ interface CrudOptions<T> {
404
+ relations?: (string | RelationDef)[];
405
+ extraGetQuery?: (qb: SelectQueryBuilder<T>) => void;
406
+ hardDelete?: boolean;
407
+ createOrUpdate?: boolean;
408
+ keepEntityVersioningDates?: boolean;
409
+ outputFieldsToOmit?: (keyof T)[];
410
+ }
411
+ ```
412
+
413
+ 简要说明:
414
+
415
+ - `relations`:
416
+ - 与第 7 节一致:决定 join 哪些 relations。
417
+ - `extraGetQuery`:
418
+ - 所有 GET 操作(findOne / findAll / findAllCursorPaginated)都会在内部逻辑之后调用这个回调;
419
+ - 你可以在这里统一追加“租户约束”等条件。
420
+ - `hardDelete`:
421
+ - 默认:如果实体有 `deleteDateColumn`,优先 soft delete;否则 hard delete;
422
+ - 设为 `true`:强制 hard delete(直接 `DELETE`)。
423
+ - `createOrUpdate`:
424
+ - 为 `create()` 和批量导入提供“幂等写入行为”:
425
+ - 同 id 记录不存在 → 插入;
426
+ - 同 id 记录存在且未被软删除 → 更新;
427
+ - 同 id 记录存在但已软删除 → 删除旧记录后插入新记录。
428
+ - `keepEntityVersioningDates`:
429
+ - 控制是否在 Result DTO 中保留一些“版本字段/时间字段”;
430
+ - 搭配 `getNotInResultFields()` 使用。
431
+ - `outputFieldsToOmit`:
432
+ - 在 Result DTO 基础上再额外剔除一些字段(比实体上的 `NotInResult` 更细粒度)。
433
+
434
+ ### 8.2 导入行为(importEntities)
435
+
436
+ `CrudBase.importEntities()` 的高层逻辑:
437
+
438
+ 1. 将传入的“类实体对象”转成真正的实体实例(忽略 relations 字段);
439
+ 2. 对每个实体:
440
+ - 调用 `isValidInCreate()`;
441
+ - 若提供了 `extraChecking(ent)`,也会调用;
442
+ - 收集所有不通过的记录,记录错误原因;
443
+ 3. 对剩余的实体:
444
+ - 执行 `beforeCreate()`;
445
+ - 批量插入 / 更新(受 `createOrUpdate` 影响);
446
+ - 执行 `afterCreate()`;
447
+ 4. 构造 ImportEntry DTO 列表:
448
+ - 每条记录包含 `entry` + `result`(`OK` / 错误原因);
449
+ - 最终包在统一的 ReturnMessageDto 中返回。
450
+
451
+ 导入的错误处理模式属于“部分成功”,而非事务式“要么全成,要么全失败”。
452
+
453
+ ---
454
+
455
+ ## 9. RestfulFactory API 细节
456
+
457
+ ### 9.1 Options 回顾
458
+
459
+ ```ts
460
+ interface RestfulFactoryOptions<T, O, W, C, U, F, R> {
461
+ fieldsToOmit?: O[];
462
+ writeFieldsToOmit?: W[];
463
+ createFieldsToOmit?: C[];
464
+ updateFieldsToOmit?: U[];
465
+ findAllFieldsToOmit?: F[];
466
+ outputFieldsToOmit?: R[];
467
+ prefix?: string;
468
+ keepEntityVersioningDates?: boolean;
469
+ entityClassName?: string;
470
+ relations?: (string | RelationDef)[];
471
+ skipNonQueryableFields?: boolean;
472
+ }
473
+ ```
474
+
475
+ 要点:
476
+
477
+ - `entityClassName`:
478
+ - 用于 DTO 的类名重命名,避免多 Factory 复用同一个实体时的命名冲突。
479
+ - `prefix`:
480
+ - 影响所有自动生成路由的前缀:
481
+ - 例如 `prefix: 'admin'` → `GET /admin`、`GET /admin/:id` 等。
482
+ - 其余字段与第 2 / 6 / 7 节的裁剪和 relations 逻辑一致,不再赘述。
483
+
484
+ ### 9.2 自动生成的 DTO
485
+
486
+ Factory 自动生成以下 DTO 类型(以 `Post` 为例):
487
+
488
+ - `PostFactory.createDto`:
489
+ - `CreatePostDto`,用于 `POST` 创建;
490
+ - `PostFactory.updateDto`:
491
+ - `UpdatePostDto`,用于 `PATCH` 更新;
492
+ - `PostFactory.findAllDto`:
493
+ - `FindPostDto`,用于 offset 分页查询;
494
+ - `PostFactory.findAllCursorPaginatedDto`:
495
+ - `FindPostCursorPaginatedDto`,用于 cursor 分页查询;
496
+ - `PostFactory.entityResultDto`:
497
+ - `PostResultDto`,完整结果结构(包含 relations 白名单);
498
+ - `PostFactory.entityCreateResultDto`:
499
+ - `PostCreateResultDto`,创建时返回用的结果结构(通常剔除了 relations 和一些计算字段)。
500
+
501
+ 在实际 Controller 中推荐写一个显式 class 派生,便于在代码里有强类型名可用,例如:
502
+
503
+ ```ts
504
+ // post.factory.ts
505
+ export const PostFactory = new RestfulFactory(Post, {
506
+ relations: ['user', 'comments'],
507
+ skipNonQueryableFields: true,
508
+ });
509
+ ```
510
+
511
+ ```ts
512
+ // post.controller.ts
513
+ class FindAllPostDto extends PostFactory.findAllDto {}
514
+
515
+ @Controller('posts')
516
+ export class PostController {
517
+ constructor(private readonly service: PostService) {}
518
+
519
+ @PostFactory.findAll({ summary: 'List posts of current user' })
520
+ async findAll(
521
+ @PostFactory.findAllParam() dto: FindAllPostDto,
522
+ @PutUser() user: User, // 业务层装饰器,不属于 NICOT
523
+ ) {
524
+ // 通过 extraQuery 注入业务限制(例如按 userId 过滤)
525
+ return this.service.findAll(dto, qb =>
526
+ qb.andWhere('post.userId = :uid', { uid: user.id }),
527
+ );
528
+ }
529
+ }
530
+ ```
531
+
532
+ > 最佳实践:Factory 独立放在 `*.factory.ts` 文件中,不和 Entity / Controller 写在一起,便于复用和阅读。
533
+
534
+ ---
535
+
536
+ ## 10. Cursor 分页:契约与边界
537
+
538
+ Cursor 分页由 NICOT 内部的一套工具实现,对使用者暴露的是:
539
+
540
+ - **入参**:在 Query DTO 上多了一个 `paginationCursor` 字段;
541
+ - **返回值**:统一封装在 `CursorPaginationReturnMessageDto` 中。
542
+
543
+ ### 10.1 调用方式与返回结构
544
+
545
+ 通过 Factory:
546
+
547
+ - `Factory.findAllCursorPaginated()` 作为装饰器;
548
+ - `Factory.findAllParam()` 自动生成包括 cursor 的 Query DTO。
549
+
550
+ 返回结构(精简版):
551
+
552
+ ```ts
553
+ {
554
+ statusCode: 200,
555
+ success: true,
556
+ message: 'success',
557
+ data: T[], // 本页数据
558
+ pagination: {
559
+ nextCursor?: string;
560
+ previousCursor?: string;
561
+ }
562
+ }
563
+ ```
564
+
565
+ 约定:
566
+
567
+ - 当 `nextCursor` 存在时,使用它作为下一次请求的 `paginationCursor` 可以向后翻页;
568
+ - 当 `previousCursor` 存在时,使用它作为 `paginationCursor` 可以向前翻页。
569
+
570
+ ### 10.2 ORDER BY 的来源与约束
571
+
572
+ Cursor 分页的前提是:**当前查询有一个稳定的排序定义**。
573
+
574
+ 排序来源包括:
575
+
576
+ 1. 实体的 `applyQuery()`(如 IdBase 默认的按 id 排序);
577
+ 2. CrudOptions 中的 `extraGetQuery(qb)`;
578
+ 3. Controller 调用 Service 时传入的 `extraQuery(qb)`。
579
+
580
+ **重要约束:**
581
+
582
+ - 使用同一个 cursor 链时,这些排序设置必须保持一致:
583
+ - 如果第二页与第一页使用了不同的排序字段集合,cursor 内保存的“排序边界值”会和当前 SQL 不匹配;
584
+ - NICOT 会尽量过滤掉不再存在的字段,但行为将退化为“不完全稳定的分页”,有可能出现重复或缺页。
585
+ - 你可以自由通过 `extraGetQuery / extraQuery` 覆盖默认排序,但建议:
586
+ - 在一个用户的“翻页会话”中不要随意切换排序逻辑;
587
+ - 如果切换了排序,请不要复用旧的 cursor,而是从头开始请求。
588
+
589
+ ### 10.3 多字段排序下的 cursor 结构(概念层面)
590
+
591
+ NICOT 内部会将当前排序字段集合记为有序列表:
592
+
593
+ ```ts
594
+ orderKeys = [
595
+ '"post"."createdAt"',
596
+ '"post"."id"',
597
+ // ...可能还有更多,比如全文搜索时追加的“相似度虚拟列”
598
+ ]
599
+ ```
600
+
601
+ cursor 内部大致保存:
602
+
603
+ ```ts
604
+ {
605
+ type: 'next' | 'prev',
606
+ payload: {
607
+ '"post"."createdAt"': '2024-01-01T00:00:00.000Z',
608
+ '"post"."id"': 123,
609
+ // 其他排序字段...
610
+ }
611
+ }
612
+ ```
613
+
614
+ 在下一次请求中:
615
+
616
+ - NICOT 会根据这些字段的排序方向、NULLS FIRST/LAST 等规则,
617
+ - 构造一个对等的 SQL 条件,语义类似:
618
+
619
+ ```sql
620
+ (
621
+ (createdAt > :createdAt)
622
+ OR (createdAt = :createdAt AND id > :id)
623
+ -- ...
624
+ )
625
+ ```
626
+
627
+ 同时对 `NULL` 的处理会根据排序方向做“是否在末尾”的判断,以尽量保证:
628
+
629
+ - 即使字段为 `NULL`,游标也不会无限循环或跳过。
630
+
631
+ 你不需要直接操作这些 payload;只要当作不透明字符串使用即可。
632
+
633
+ ### 10.4 “越界查询”与数据变动
634
+
635
+ **问题 1:解码 cursor 后,是否允许“越界查询”?**
636
+
637
+ - NICOT 不会阻止你使用任何字符串作为 cursor。
638
+ - 如果 WHERE 条件相比原来变得更严格:
639
+ - 有可能直接得到空数组(即“你已经翻出边界”)。
640
+ - 如果 WHERE 条件相比原来更宽松:
641
+ - 有可能出现“之前没见过的数据”出现在中间;
642
+ - 或者部分数据重复出现在不同页。
643
+
644
+ **问题 2:数据在翻页过程中发生变化怎么办?**
645
+
646
+ - NICOT 不做快照;
647
+ - 换言之,cursor 分页和绝大多数线上服务一样,本质上只是一个“位置提示”:
648
+ - 当数据整体集在翻页过程中被插入/删除/修改时,分页结果的稳定性自然会下降;
649
+ - NICOT 不保证“无重复、无缺失”,仅在数据相对稳定时尽量做到“顺序一致”。
650
+
651
+ 如果你的业务需要“强一致的分页体验”,建议结合:
652
+
653
+ - “固定时点快照”的业务设计;
654
+ - 或者在 cursor 中额外携带版本号 / 时间戳,在服务端主动拒绝跨版本 cursor。
655
+
656
+ ---
657
+
658
+ ## 11. PostgreSQL 特化能力汇总
659
+
660
+ 以下能力目前主要针对 **PostgreSQL** 设计与优化:
661
+
662
+ - `QueryFullText`:
663
+ - 使用 PG 的全文搜索设施(`to_tsvector`、`websearch_to_tsquery` 等);
664
+ - 自动维护 GIN 索引与 text search configuration。
665
+ - JSONB 相关查询装饰器:
666
+ - 如基于 jsonb 的 `?` 运算符的等价封装。
667
+ - 各类针对 `jsonb` 列的 Column 装饰器。
668
+
669
+ 在 MySQL / SQLite / 其他数据库环境下:
670
+
671
+ - 这些装饰器要么不可用,要么行为退化为普通字符串字段;
672
+ - 建议在你的项目说明中显式注明:**这些特性仅在 PostgreSQL 上启用**。
673
+
674
+ ---
675
+
676
+ ## 12. 手写 Controller / 自定义逻辑建议
677
+
678
+ ### 12.1 推荐模式:Factory + CrudService
679
+
680
+ 推荐使用方式:
681
+
682
+ 1. 定义 Factory(独立文件):
683
+
684
+ ```ts
685
+ // post.factory.ts
686
+ export const PostFactory = new RestfulFactory(Post, {
687
+ relations: ['user', 'comments'],
688
+ skipNonQueryableFields: true,
689
+ });
690
+ ```
691
+
692
+ 2. 定义 Service(可选,也可组合自己的业务 Service):
693
+
694
+ ```ts
695
+ // post.service.ts
696
+ export class PostService extends PostFactory.crudService() {}
697
+ ```
698
+
699
+ 3. 在 Controller 中使用 Factory 的装饰器 + CrudService 的方法:
700
+
701
+ ```ts
702
+ // post.controller.ts
703
+ class FindAllPostDto extends PostFactory.findAllDto {}
704
+
705
+ @Controller('posts')
706
+ export class PostController {
707
+ constructor(private readonly service: PostService) {}
708
+
709
+ @PostFactory.findAll({ summary: 'List posts of current user' })
710
+ async findAll(
711
+ @PostFactory.findAllParam() dto: FindAllPostDto,
712
+ @PutUser() user: User, // 业务装饰器,不属于 NICOT
713
+ ) {
714
+ return this.service.findAll(dto, qb =>
715
+ qb.andWhere('post.userId = :uid', { uid: user.id }),
716
+ );
717
+ }
718
+ }
719
+ ```
720
+
721
+ 这样可以确保:
722
+
723
+ - DTO 自动生成(含 Query DTO / Result DTO);
724
+ - Hook / 访问装饰器 / relations 剪枝 全部生效;
725
+ - Swagger 文档和真实 API 行为同步。
726
+
727
+ ### 12.2 直接使用 TypeORM Repository 的注意事项
728
+
729
+ 你当然可以在业务服务里直接注入 TypeORM `Repository<T>` 来做一些特殊查询,例如:
730
+
731
+ - 复杂统计;
732
+ - 聚合报表;
733
+ - 性能敏感的自定义 SQL。
734
+
735
+ 需要注意的是:
736
+
737
+ - 这类查询 **不会** 自动触发 NICOT 的 Entity Hook;
738
+ - 也 **不会** 自动应用 `NotInResult` 之类的剪枝逻辑;
739
+ - 也不会自动使用 Factory 的 relations 白名单 / cursor 分页等能力。
740
+
741
+ 如果你希望这类自定义逻辑与 NICOT 的行为对齐,可以考虑:
742
+
743
+ - 查询后手动调用 `CrudService.cleanEntityNotInResultFields()`;
744
+ - 或者包装成一个“内部 API”,与 NICOT 的公开 CRUD 分离。
745
+
746
+ ---
747
+
748
+ 以上就是 NICOT 的核心 API / 行为说明。
749
+ 更偏“哲学与入门”的内容,请参考 `README-CN.md`。