befly 3.9.12 → 3.9.14

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.
@@ -0,0 +1,618 @@
1
+ # Validator 参数验证
2
+
3
+ > 请求参数验证与类型转换
4
+
5
+ ## 目录
6
+
7
+ - [概述](#概述)
8
+ - [验证器类](#验证器类)
9
+ - [验证规则](#验证规则)
10
+ - [类型转换](#类型转换)
11
+ - [正则别名](#正则别名)
12
+ - [验证钩子](#验证钩子)
13
+ - [API 字段定义](#api-字段定义)
14
+ - [验证结果](#验证结果)
15
+ - [使用示例](#使用示例)
16
+ - [FAQ](#faq)
17
+
18
+ ---
19
+
20
+ ## 概述
21
+
22
+ Validator 是 Befly 的参数验证系统,提供:
23
+
24
+ - **数据验证**:根据字段定义验证数据
25
+ - **类型转换**:自动转换为目标类型
26
+ - **规则检查**:长度、范围、正则等
27
+ - **钩子集成**:自动验证 API 请求参数
28
+
29
+ ---
30
+
31
+ ## 验证器类
32
+
33
+ ### 基本结构
34
+
35
+ ```typescript
36
+ import { Validator } from '../lib/validator.js';
37
+
38
+ class Validator {
39
+ // 验证数据对象
40
+ static validate(data: Record<string, any>, rules: TableDefinition, required: string[]): ValidateResult;
41
+
42
+ // 验证单个值(带类型转换)
43
+ static single(value: any, fieldDef: FieldDefinition): SingleResult;
44
+ }
45
+ ```
46
+
47
+ ### validate 方法
48
+
49
+ 批量验证数据对象:
50
+
51
+ ```typescript
52
+ const data = {
53
+ email: 'test@example.com',
54
+ age: 25,
55
+ name: 'John'
56
+ };
57
+
58
+ const rules = {
59
+ email: { name: '邮箱', type: 'string', min: 5, max: 100, regexp: '@email' },
60
+ age: { name: '年龄', type: 'number', min: 0, max: 150 },
61
+ name: { name: '姓名', type: 'string', min: 2, max: 50 }
62
+ };
63
+
64
+ const result = Validator.validate(data, rules, ['email', 'name']);
65
+
66
+ if (result.failed) {
67
+ console.log(result.firstError); // 第一条错误信息
68
+ console.log(result.errors); // 所有错误信息
69
+ console.log(result.errorFields); // 出错字段列表
70
+ console.log(result.fieldErrors); // 字段->错误映射
71
+ }
72
+ ```
73
+
74
+ ### single 方法
75
+
76
+ 验证单个值并进行类型转换:
77
+
78
+ ```typescript
79
+ const fieldDef = {
80
+ name: '年龄',
81
+ type: 'number',
82
+ min: 0,
83
+ max: 150,
84
+ default: 0
85
+ };
86
+
87
+ const result = Validator.single('25', fieldDef);
88
+
89
+ if (!result.error) {
90
+ console.log(result.value); // 25 (已转换为 number)
91
+ } else {
92
+ console.log(result.error); // 错误信息
93
+ }
94
+ ```
95
+
96
+ ---
97
+
98
+ ## 验证规则
99
+
100
+ ### 字段定义格式
101
+
102
+ 字段定义包含以下属性:
103
+
104
+ | 属性 | 类型 | 必填 | 说明 |
105
+ | ---------- | ------- | ---- | ------------------------ |
106
+ | `name` | string | 是 | 字段标签(用于错误提示) |
107
+ | `type` | string | 是 | 数据类型 |
108
+ | `min` | number | 否 | 最小值/长度 |
109
+ | `max` | number | 否 | 最大值/长度 |
110
+ | `default` | any | 否 | 默认值 |
111
+ | `regexp` | string | 否 | 正则表达式或别名 |
112
+ | `required` | boolean | 否 | 是否必填 |
113
+
114
+ ### 支持的类型
115
+
116
+ | 类型 | 说明 | min/max 含义 |
117
+ | -------------- | ---------- | ------------ |
118
+ | `string` | 字符串 | 字符长度 |
119
+ | `text` | 长文本 | 字符长度 |
120
+ | `number` | 数字 | 数值范围 |
121
+ | `array_string` | 字符串数组 | 元素数量 |
122
+ | `array_text` | 文本数组 | 元素数量 |
123
+
124
+ ### 验证逻辑
125
+
126
+ ```
127
+ ┌─────────────────────────────────────────────────────┐
128
+ │ 验证流程 │
129
+ ├─────────────────────────────────────────────────────┤
130
+ │ 1. 参数检查 │
131
+ │ └── 确保 data 和 rules 是有效对象 │
132
+ │ ↓ │
133
+ │ 2. 必填字段检查 │
134
+ │ └── 检查 required 数组中的字段是否有值 │
135
+ │ ↓ │
136
+ │ 3. 类型转换 │
137
+ │ └── 将值转换为目标类型 │
138
+ │ ↓ │
139
+ │ 4. 规则验证 │
140
+ │ ├── 数字:检查 min/max 范围 │
141
+ │ ├── 字符串:检查长度 min/max │
142
+ │ ├── 数组:检查元素数量 │
143
+ │ └── 正则:检查格式是否匹配 │
144
+ │ ↓ │
145
+ │ 5. 构建结果 │
146
+ │ └── 返回 ValidateResult 对象 │
147
+ └─────────────────────────────────────────────────────┘
148
+ ```
149
+
150
+ ---
151
+
152
+ ## 类型转换
153
+
154
+ ### 转换规则
155
+
156
+ | 目标类型 | 输入 | 输出 |
157
+ | -------------- | ------------ | ------------ |
158
+ | `number` | `"123"` | `123` |
159
+ | `number` | `123` | `123` |
160
+ | `number` | `"abc"` | **错误** |
161
+ | `string` | `"hello"` | `"hello"` |
162
+ | `string` | `123` | **错误** |
163
+ | `array_string` | `["a", "b"]` | `["a", "b"]` |
164
+ | `array_string` | `"abc"` | **错误** |
165
+
166
+ ### 空值处理
167
+
168
+ 空值(`undefined`、`null`、`""`)时返回默认值:
169
+
170
+ ```typescript
171
+ // 有 default 属性时使用 default
172
+ { type: 'number', default: 10 } → 10
173
+ { type: 'string', default: '默认' } → '默认'
174
+
175
+ // 无 default 时使用类型默认值
176
+ { type: 'number' } → 0
177
+ { type: 'string' } → ''
178
+ { type: 'array_string' } → []
179
+ ```
180
+
181
+ ### 数组默认值
182
+
183
+ 数组类型的默认值可以是字符串格式:
184
+
185
+ ```typescript
186
+ // 字符串格式的数组默认值
187
+ { type: 'array_string', default: '[]' } → []
188
+ { type: 'array_string', default: '["a","b"]' } → ['a', 'b']
189
+ ```
190
+
191
+ ---
192
+
193
+ ## 正则别名
194
+
195
+ ### 使用方式
196
+
197
+ 正则可以使用别名(以 `@` 开头)或直接写正则表达式:
198
+
199
+ ```typescript
200
+ // 使用别名
201
+ {
202
+ regexp: '@email';
203
+ } // 邮箱格式
204
+ {
205
+ regexp: '@phone';
206
+ } // 手机号格式
207
+ {
208
+ regexp: '@url';
209
+ } // URL 格式
210
+
211
+ // 直接使用正则
212
+ {
213
+ regexp: '^[a-z]+$';
214
+ } // 小写字母
215
+ ```
216
+
217
+ ### 内置别名
218
+
219
+ | 别名 | 说明 | 正则 |
220
+ | ----------- | --------------- | ------------------------ | -------- | ----- |
221
+ | `@number` | 正整数 | `^\d+$` |
222
+ | `@integer` | 整数(含负数) | `^-?\d+$` |
223
+ | `@float` | 浮点数 | `^-?\d+(\.\d+)?$` |
224
+ | `@positive` | 正整数(不含0) | `^[1-9]\d*$` |
225
+ | `@email` | 邮箱 | `^[a-zA-Z0-9._%+-]+@...` |
226
+ | `@phone` | 手机号 | `^1[3-9]\d{9}$` |
227
+ | `@url` | URL | `^https?://` |
228
+ | `@ip` | IPv4 | `^((25[0-5] | 2[0-4]\d | ...)` |
229
+ | `@uuid` | UUID | `^[0-9a-f]{8}-...` |
230
+ | `@date` | 日期 | `^\d{4}-\d{2}-\d{2}$` |
231
+ | `@time` | 时间 | `^\d{2}:\d{2}:\d{2}$` |
232
+ | `@datetime` | 日期时间 | `^\d{4}-\d{2}-\d{2}T...` |
233
+
234
+ ### 完整别名列表
235
+
236
+ **数字类**:
237
+
238
+ - `@number` - 正整数
239
+ - `@integer` - 整数(含负数)
240
+ - `@float` - 浮点数
241
+ - `@positive` - 正整数(不含0)
242
+ - `@negative` - 负整数
243
+ - `@zero` - 零
244
+
245
+ **字符类**:
246
+
247
+ - `@word` - 纯字母
248
+ - `@alphanumeric` - 字母和数字
249
+ - `@alphanumeric_` - 字母、数字和下划线
250
+ - `@lowercase` - 小写字母
251
+ - `@uppercase` - 大写字母
252
+ - `@chinese` - 纯中文
253
+ - `@chineseWord` - 中文和字母
254
+
255
+ **网络类**:
256
+
257
+ - `@email` - 邮箱
258
+ - `@url` - URL
259
+ - `@ip` - IPv4
260
+ - `@ipv6` - IPv6
261
+ - `@domain` - 域名
262
+
263
+ **编码类**:
264
+
265
+ - `@uuid` - UUID
266
+ - `@hex` - 十六进制
267
+ - `@base64` - Base64
268
+ - `@md5` - MD5
269
+ - `@sha1` - SHA1
270
+ - `@sha256` - SHA256
271
+
272
+ **日期时间**:
273
+
274
+ - `@date` - 日期 YYYY-MM-DD
275
+ - `@time` - 时间 HH:MM:SS
276
+ - `@datetime` - ISO 日期时间
277
+ - `@year` - 年份
278
+ - `@month` - 月份
279
+ - `@day` - 日期
280
+
281
+ **标识符**:
282
+
283
+ - `@variable` - 变量名
284
+ - `@constant` - 常量名
285
+ - `@package` - 包名
286
+ - `@username` - 用户名
287
+ - `@nickname` - 昵称
288
+
289
+ **账号类**:
290
+
291
+ - `@phone` - 手机号
292
+ - `@telephone` - 固定电话
293
+ - `@idCard` - 身份证号
294
+ - `@bankCard` - 银行卡号
295
+ - `@qq` - QQ号
296
+ - `@wechat` - 微信号
297
+
298
+ **密码类**:
299
+
300
+ - `@passwordWeak` - 弱密码(6位以上)
301
+ - `@passwordMedium` - 中等密码(8位,含字母数字)
302
+ - `@passwordStrong` - 强密码(8位,含大小写、数字、特殊字符)
303
+
304
+ ---
305
+
306
+ ## 验证钩子
307
+
308
+ ### 自动验证
309
+
310
+ Validator Hook 自动验证 API 请求参数:
311
+
312
+ ```typescript
313
+ // hooks/validator.ts
314
+ const hook: Hook = {
315
+ order: 6, // 在 parser 之后执行
316
+ handler: async (befly, ctx) => {
317
+ if (!ctx.api?.fields) return;
318
+
319
+ const result = Validator.validate(ctx.body, ctx.api.fields, ctx.api.required || []);
320
+
321
+ if (result.code !== 0) {
322
+ ctx.response = ErrorResponse(ctx, result.firstError || '参数验证失败', 1, null, result.fieldErrors);
323
+ }
324
+ }
325
+ };
326
+ ```
327
+
328
+ ### 执行顺序
329
+
330
+ ```
331
+ 请求 → parser (解析) → validator (验证) → API handler
332
+
333
+ 验证失败则返回错误响应
334
+ ```
335
+
336
+ ---
337
+
338
+ ## API 字段定义
339
+
340
+ ### 在 API 中定义字段
341
+
342
+ ```typescript
343
+ // apis/user/login.ts
344
+ export default {
345
+ name: '用户登录',
346
+ method: 'POST',
347
+ auth: false,
348
+ fields: {
349
+ email: { name: '邮箱', type: 'string', min: 5, max: 100, regexp: '@email' },
350
+ password: { name: '密码', type: 'string', min: 6, max: 100 }
351
+ },
352
+ required: ['email', 'password'],
353
+ handler: async (befly, ctx) => {
354
+ // ctx.body.email 和 ctx.body.password 已验证
355
+ return Yes('登录成功');
356
+ }
357
+ } as ApiRoute;
358
+ ```
359
+
360
+ ### 引用表字段
361
+
362
+ 可以引用表定义中的字段:
363
+
364
+ ```typescript
365
+ import { adminTable } from '../../../tables/admin.js';
366
+
367
+ export default {
368
+ name: '创建管理员',
369
+ fields: {
370
+ email: adminTable.email, // 引用表字段
371
+ password: adminTable.password,
372
+ nickname: adminTable.nickname
373
+ },
374
+ required: ['email', 'password'],
375
+ handler: async (befly, ctx) => {
376
+ // ...
377
+ }
378
+ } as ApiRoute;
379
+ ```
380
+
381
+ ### 使用公共字段
382
+
383
+ ```typescript
384
+ import { Fields } from '../../../config/fields.js';
385
+
386
+ export default {
387
+ name: '获取列表',
388
+ fields: {
389
+ ...Fields.page, // 分页字段
390
+ ...Fields.limit,
391
+ keyword: { name: '关键词', type: 'string', max: 50 }
392
+ },
393
+ handler: async (befly, ctx) => {
394
+ // ...
395
+ }
396
+ } as ApiRoute;
397
+ ```
398
+
399
+ ---
400
+
401
+ ## 验证结果
402
+
403
+ ### ValidateResult 结构
404
+
405
+ ```typescript
406
+ interface ValidateResult {
407
+ code: number; // 0=成功,1=失败
408
+ failed: boolean; // 是否失败
409
+ firstError: string | null; // 第一条错误信息
410
+ errors: string[]; // 所有错误信息
411
+ errorFields: string[]; // 出错字段名列表
412
+ fieldErrors: Record<string, string>; // 字段->错误映射
413
+ }
414
+ ```
415
+
416
+ ### SingleResult 结构
417
+
418
+ ```typescript
419
+ interface SingleResult {
420
+ value: any; // 转换后的值
421
+ error: string | null; // 错误信息
422
+ }
423
+ ```
424
+
425
+ ### 结果示例
426
+
427
+ **验证成功**:
428
+
429
+ ```typescript
430
+ {
431
+ code: 0,
432
+ failed: false,
433
+ firstError: null,
434
+ errors: [],
435
+ errorFields: [],
436
+ fieldErrors: {}
437
+ }
438
+ ```
439
+
440
+ **验证失败**:
441
+
442
+ ```typescript
443
+ {
444
+ code: 1,
445
+ failed: true,
446
+ firstError: '邮箱为必填项',
447
+ errors: ['邮箱为必填项', '密码长度不能少于6个字符'],
448
+ errorFields: ['email', 'password'],
449
+ fieldErrors: {
450
+ email: '邮箱为必填项',
451
+ password: '密码长度不能少于6个字符'
452
+ }
453
+ }
454
+ ```
455
+
456
+ ---
457
+
458
+ ## 使用示例
459
+
460
+ ### 示例 1:基本验证
461
+
462
+ ```typescript
463
+ const data = {
464
+ username: 'john',
465
+ age: 25,
466
+ email: 'john@example.com'
467
+ };
468
+
469
+ const rules = {
470
+ username: { name: '用户名', type: 'string', min: 2, max: 20 },
471
+ age: { name: '年龄', type: 'number', min: 0, max: 150 },
472
+ email: { name: '邮箱', type: 'string', regexp: '@email' }
473
+ };
474
+
475
+ const result = Validator.validate(data, rules, ['username', 'email']);
476
+ // result.code === 0
477
+ ```
478
+
479
+ ### 示例 2:类型转换
480
+
481
+ ```typescript
482
+ const data = {
483
+ age: '25', // 字符串
484
+ score: '98.5' // 字符串
485
+ };
486
+
487
+ const rules = {
488
+ age: { name: '年龄', type: 'number', min: 0 },
489
+ score: { name: '分数', type: 'number', min: 0, max: 100 }
490
+ };
491
+
492
+ // 验证通过,'25' 会被转换为 25
493
+ const result = Validator.validate(data, rules);
494
+ ```
495
+
496
+ ### 示例 3:数组验证
497
+
498
+ ```typescript
499
+ const data = {
500
+ tags: ['vue', 'react', 'angular'],
501
+ ids: [1, 2, 3]
502
+ };
503
+
504
+ const rules = {
505
+ tags: { name: '标签', type: 'array_string', min: 1, max: 10 },
506
+ ids: { name: 'ID列表', type: 'array_string', min: 1 }
507
+ };
508
+
509
+ const result = Validator.validate(data, rules, ['tags']);
510
+ ```
511
+
512
+ ### 示例 4:正则验证
513
+
514
+ ```typescript
515
+ const data = {
516
+ phone: '13812345678',
517
+ email: 'test@example.com',
518
+ code: 'ABC123'
519
+ };
520
+
521
+ const rules = {
522
+ phone: { name: '手机号', type: 'string', regexp: '@phone' },
523
+ email: { name: '邮箱', type: 'string', regexp: '@email' },
524
+ code: { name: '验证码', type: 'string', regexp: '^[A-Z0-9]{6}$' }
525
+ };
526
+
527
+ const result = Validator.validate(data, rules);
528
+ ```
529
+
530
+ ### 示例 5:单值验证
531
+
532
+ ```typescript
533
+ // 验证并转换单个值
534
+ const ageResult = Validator.single('25', {
535
+ name: '年龄',
536
+ type: 'number',
537
+ min: 0,
538
+ max: 150
539
+ });
540
+
541
+ if (!ageResult.error) {
542
+ console.log(ageResult.value); // 25 (number)
543
+ }
544
+
545
+ // 空值使用默认值
546
+ const emptyResult = Validator.single('', {
547
+ name: '数量',
548
+ type: 'number',
549
+ default: 10
550
+ });
551
+ console.log(emptyResult.value); // 10
552
+ ```
553
+
554
+ ---
555
+
556
+ ## FAQ
557
+
558
+ ### Q: 如何自定义错误信息?
559
+
560
+ A: 目前错误信息由验证器自动生成,格式为 `{字段标签}{错误描述}`。可以在 API handler 中捕获验证结果后自定义处理。
561
+
562
+ ### Q: 如何跳过某些字段的验证?
563
+
564
+ A: 不在 `fields` 中定义的字段不会被验证。如果字段不在 `required` 数组中且值为空,也会跳过验证。
565
+
566
+ ### Q: 验证失败后请求参数还能用吗?
567
+
568
+ A: 验证失败时 Hook 会直接返回错误响应,不会执行 API handler。如果需要在 handler 中手动验证,可以禁用 validator hook。
569
+
570
+ ### Q: 如何验证嵌套对象?
571
+
572
+ A: 目前只支持扁平对象验证。嵌套对象需要在 handler 中手动验证。
573
+
574
+ ### Q: 正则别名可以扩展吗?
575
+
576
+ A: 正则别名定义在 `befly-shared/regex` 中,可以直接使用自定义正则字符串,不需要扩展别名。
577
+
578
+ ### Q: 类型转换失败会怎样?
579
+
580
+ A: 类型转换失败会在 `errors` 中记录错误,如 "年龄必须是数字"。
581
+
582
+ ### Q: 数组元素如何验证?
583
+
584
+ A: 数组类型会验证:
585
+
586
+ 1. 值是否为数组
587
+ 2. 元素数量是否在 min/max 范围内
588
+ 3. 如果有 regexp,每个元素都会进行正则验证
589
+
590
+ ### Q: 如何在验证前清理数据中的 null/undefined 值?
591
+
592
+ A: 使用 `befly.tool.cleanFields` 方法:
593
+
594
+ ```typescript
595
+ // 在 API handler 中使用
596
+ handler: async (befly, ctx) => {
597
+ const { nickname, phone, address } = ctx.body;
598
+
599
+ // 清理 null 和 undefined 值
600
+ const cleanData = befly.tool.cleanFields({
601
+ nickname: nickname,
602
+ phone: phone,
603
+ address: address
604
+ });
605
+
606
+ // cleanData 只包含有效值
607
+ await befly.db.updData({
608
+ table: 'user',
609
+ data: cleanData,
610
+ where: { id: ctx.user.userId }
611
+ });
612
+
613
+ return Yes('更新成功');
614
+ };
615
+ ```
616
+
617
+ > **注意**:数据库操作(insData、updData 等)会自动过滤 null/undefined 值,通常不需要手动调用 cleanFields。
618
+ > 详见 [database.md](./database.md#nullundefined-值自动过滤)。
@@ -20,42 +20,39 @@ import { projectApiDir } from '../paths.js';
20
20
  import type { ApiRoute } from '../types/api.js';
21
21
 
22
22
  /**
23
- * API 默认字段定义
24
- * 这些字段会自动合并到所有 API 的 fields 中
25
- * API 自定义的同名字段可以覆盖这些默认值
23
+ * 预定义的默认字段
26
24
  */
27
- const DEFAULT_API_FIELDS = {
28
- id: {
29
- name: 'ID',
30
- type: 'number',
31
- min: 1,
32
- max: null
33
- },
34
- page: {
35
- name: '页码',
36
- type: 'number',
37
- min: 1,
38
- max: 9999
39
- },
40
- limit: {
41
- name: '每页数量',
42
- type: 'number',
43
- min: 1,
44
- max: 100
45
- },
46
- keyword: {
47
- name: '关键词',
48
- type: 'string',
49
- min: 1,
50
- max: 50
51
- },
52
- state: {
53
- name: '状态',
54
- type: 'number',
55
- min: 0,
56
- max: 2
25
+ const PRESET_FIELDS: Record<string, any> = {
26
+ '@id': { name: 'ID', type: 'number', min: 1, max: null },
27
+ '@page': { name: '页码', type: 'number', min: 1, max: 9999 },
28
+ '@limit': { name: '每页数量', type: 'number', min: 1, max: 100 },
29
+ '@keyword': { name: '关键词', type: 'string', min: 1, max: 50 },
30
+ '@state': { name: '状态', type: 'number', min: 0, max: 2 }
31
+ };
32
+
33
+ /**
34
+ * 处理字段定义,将 @ 符号引用替换为实际字段定义
35
+ */
36
+ function processFields(fields: Record<string, any>): Record<string, any> {
37
+ if (!fields || typeof fields !== 'object') return fields;
38
+
39
+ const processed: Record<string, any> = {};
40
+ for (const [key, value] of Object.entries(fields)) {
41
+ // 如果值是字符串且以 @ 开头,则查找预定义字段
42
+ if (typeof value === 'string' && value.startsWith('@')) {
43
+ if (PRESET_FIELDS[value]) {
44
+ processed[key] = PRESET_FIELDS[value];
45
+ } else {
46
+ // 未找到预定义字段,保持原值
47
+ processed[key] = value;
48
+ }
49
+ } else {
50
+ // 普通字段定义,保持原样
51
+ processed[key] = value;
52
+ }
57
53
  }
58
- } as const;
54
+ return processed;
55
+ }
59
56
 
60
57
  /**
61
58
  * 加载所有 API 路由
@@ -113,8 +110,8 @@ export async function loadApis(apis: Map<string, ApiRoute>): Promise<void> {
113
110
  // 设置默认值
114
111
  const methodStr = (api.method || 'POST').toUpperCase();
115
112
  api.auth = api.auth !== undefined ? api.auth : true;
116
- // 合并默认字段:默认字段作为基础,API 自定义字段优先级更高
117
- api.fields = { ...DEFAULT_API_FIELDS, ...(api.fields || {}) };
113
+ // 处理字段定义,将 @ 引用替换为实际字段定义
114
+ api.fields = processFields(api.fields || {});
118
115
  api.required = api.required || [];
119
116
 
120
117
  // 构建路由路径(不含方法)