befly 3.9.34 → 3.9.36
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/docs/api.md +227 -0
- package/hooks/parser.ts +12 -13
- package/package.json +3 -3
- package/util.ts +2 -2
package/docs/api.md
CHANGED
|
@@ -575,6 +575,233 @@ interface FieldDefinition {
|
|
|
575
575
|
|
|
576
576
|
---
|
|
577
577
|
|
|
578
|
+
## rawBody 原始请求体
|
|
579
|
+
|
|
580
|
+
### 概述
|
|
581
|
+
|
|
582
|
+
`rawBody` 参数用于控制 `parser` 钩子的行为。当设置 `rawBody: true` 时,框架会**完全跳过请求体解析**,`ctx.body` 为空对象 `{}`,handler 可以通过 `ctx.req` 获取原始请求自行处理。
|
|
583
|
+
|
|
584
|
+
这对于需要**手动验签、解密**的场景非常重要,如微信支付回调需要原始请求体来验证 RSA 签名。
|
|
585
|
+
|
|
586
|
+
### 工作原理
|
|
587
|
+
|
|
588
|
+
#### 默认行为(rawBody: false)
|
|
589
|
+
|
|
590
|
+
```typescript
|
|
591
|
+
// API 定义
|
|
592
|
+
export default {
|
|
593
|
+
name: '更新用户',
|
|
594
|
+
fields: {
|
|
595
|
+
id: '@id',
|
|
596
|
+
username: { name: '用户名', type: 'string', max: 50 }
|
|
597
|
+
},
|
|
598
|
+
handler: async (befly, ctx) => {
|
|
599
|
+
// ctx.body 只包含 fields 中定义的字段
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// 请求参数
|
|
604
|
+
{ id: 1, username: 'test', email: 'test@qq.com', role: 'admin' }
|
|
605
|
+
|
|
606
|
+
// ctx.body 实际值(过滤后)
|
|
607
|
+
{ id: 1, username: 'test' }
|
|
608
|
+
// email 和 role 被过滤掉了
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
#### rawBody 模式(rawBody: true)
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
// API 定义
|
|
615
|
+
export default {
|
|
616
|
+
name: '微信支付回调',
|
|
617
|
+
method: 'POST',
|
|
618
|
+
auth: false,
|
|
619
|
+
rawBody: true, // 跳过解析,保留原始请求
|
|
620
|
+
handler: async (befly, ctx) => {
|
|
621
|
+
// ctx.body 为空对象 {}
|
|
622
|
+
// 通过 ctx.req 获取原始请求自行处理
|
|
623
|
+
|
|
624
|
+
// 获取原始请求体(用于验签)
|
|
625
|
+
const rawBody = await ctx.req.text();
|
|
626
|
+
|
|
627
|
+
// 解析数据
|
|
628
|
+
const data = JSON.parse(rawBody);
|
|
629
|
+
|
|
630
|
+
// 处理业务逻辑...
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
### 处理流程
|
|
636
|
+
|
|
637
|
+
| rawBody | parser 钩子行为 | ctx.body | 使用场景 |
|
|
638
|
+
| -------------- | ------------------------------- | ------------ | ------------------ |
|
|
639
|
+
| `false` (默认) | 解析 JSON/XML,根据 fields 过滤 | 解析后的对象 | 普通 CRUD |
|
|
640
|
+
| `true` | **完全跳过**,不解析请求体 | `{}` 空对象 | 回调验签、手动解密 |
|
|
641
|
+
|
|
642
|
+
### 适用场景
|
|
643
|
+
|
|
644
|
+
| 场景 | rawBody | 说明 |
|
|
645
|
+
| ------------ | ------- | ------------------------------ |
|
|
646
|
+
| 微信支付回调 | `true` | 需要原始请求体验证 RSA 签名 |
|
|
647
|
+
| 支付宝回调 | `true` | 需要原始请求体验证签名 |
|
|
648
|
+
| Webhook | `true` | 需要原始请求体验证 HMAC 签名 |
|
|
649
|
+
| 加密数据接口 | `true` | 需要手动解密请求体 |
|
|
650
|
+
| 文件上传 | `true` | 需要原始请求体处理二进制数据 |
|
|
651
|
+
| 普通 CRUD | `false` | 标准增删改查(默认) |
|
|
652
|
+
| 批量操作 | `false` | 使用空 fields 即可保留所有字段 |
|
|
653
|
+
|
|
654
|
+
### 示例代码
|
|
655
|
+
|
|
656
|
+
#### 微信支付 V3 回调(需要验签和解密)
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
// apis/webhook/wechatPayV3.ts
|
|
660
|
+
export default {
|
|
661
|
+
name: '微信支付V3回调',
|
|
662
|
+
method: 'POST',
|
|
663
|
+
auth: false,
|
|
664
|
+
rawBody: true, // 跳过解析,保留原始请求
|
|
665
|
+
handler: async (befly, ctx) => {
|
|
666
|
+
// 1. 获取原始请求体(用于验签)
|
|
667
|
+
const rawBody = await ctx.req.text();
|
|
668
|
+
|
|
669
|
+
// 2. 获取微信签名头
|
|
670
|
+
const signature = ctx.req.headers.get('Wechatpay-Signature');
|
|
671
|
+
const timestamp = ctx.req.headers.get('Wechatpay-Timestamp');
|
|
672
|
+
const nonce = ctx.req.headers.get('Wechatpay-Nonce');
|
|
673
|
+
const serial = ctx.req.headers.get('Wechatpay-Serial');
|
|
674
|
+
|
|
675
|
+
// 3. 验证签名
|
|
676
|
+
const verifyMessage = `${timestamp}\n${nonce}\n${rawBody}\n`;
|
|
677
|
+
if (!verifyRsaSign(verifyMessage, signature, serial)) {
|
|
678
|
+
return { code: 'FAIL', message: '签名验证失败' };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// 4. 解析并解密数据
|
|
682
|
+
const notification = JSON.parse(rawBody);
|
|
683
|
+
const { ciphertext, nonce: decryptNonce, associated_data } = notification.resource;
|
|
684
|
+
const decrypted = decryptAesGcm(ciphertext, decryptNonce, associated_data);
|
|
685
|
+
const payResult = JSON.parse(decrypted);
|
|
686
|
+
|
|
687
|
+
// 5. 处理支付结果
|
|
688
|
+
if (payResult.trade_state === 'SUCCESS') {
|
|
689
|
+
await befly.db.updData({
|
|
690
|
+
table: 'order',
|
|
691
|
+
where: { orderNo: payResult.out_trade_no },
|
|
692
|
+
data: {
|
|
693
|
+
payStatus: 1,
|
|
694
|
+
payTime: Date.now(),
|
|
695
|
+
transactionId: payResult.transaction_id
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return { code: 'SUCCESS', message: '' };
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
#### GitHub Webhook(HMAC 签名验证)
|
|
706
|
+
|
|
707
|
+
```typescript
|
|
708
|
+
// apis/webhook/github.ts
|
|
709
|
+
import { createHmac } from 'crypto';
|
|
710
|
+
|
|
711
|
+
export default {
|
|
712
|
+
name: 'GitHub Webhook',
|
|
713
|
+
method: 'POST',
|
|
714
|
+
auth: false,
|
|
715
|
+
rawBody: true,
|
|
716
|
+
handler: async (befly, ctx) => {
|
|
717
|
+
// 获取原始请求体
|
|
718
|
+
const rawBody = await ctx.req.text();
|
|
719
|
+
|
|
720
|
+
// 获取 GitHub 签名
|
|
721
|
+
const signature = ctx.req.headers.get('X-Hub-Signature-256');
|
|
722
|
+
if (!signature) {
|
|
723
|
+
return befly.tool.No('缺少签名');
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// 验证 HMAC 签名
|
|
727
|
+
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
|
728
|
+
const hmac = createHmac('sha256', secret);
|
|
729
|
+
const expectedSignature = 'sha256=' + hmac.update(rawBody).digest('hex');
|
|
730
|
+
|
|
731
|
+
if (signature !== expectedSignature) {
|
|
732
|
+
return befly.tool.No('签名验证失败');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// 解析数据
|
|
736
|
+
const payload = JSON.parse(rawBody);
|
|
737
|
+
const event = ctx.req.headers.get('X-GitHub-Event');
|
|
738
|
+
|
|
739
|
+
// 处理不同事件
|
|
740
|
+
if (event === 'push') {
|
|
741
|
+
befly.logger.info({ ref: payload.ref }, 'GitHub Push 事件');
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return befly.tool.Yes('处理成功');
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
#### 加密数据接口
|
|
750
|
+
|
|
751
|
+
```typescript
|
|
752
|
+
// apis/secure/receive.ts
|
|
753
|
+
export default {
|
|
754
|
+
name: '接收加密数据',
|
|
755
|
+
auth: false,
|
|
756
|
+
rawBody: true,
|
|
757
|
+
handler: async (befly, ctx) => {
|
|
758
|
+
// 获取加密的请求体
|
|
759
|
+
const encryptedBody = await ctx.req.text();
|
|
760
|
+
|
|
761
|
+
// 解密数据
|
|
762
|
+
const decrypted = befly.cipher.decrypt(encryptedBody);
|
|
763
|
+
const data = JSON.parse(decrypted);
|
|
764
|
+
|
|
765
|
+
// 处理解密后的数据
|
|
766
|
+
// ...
|
|
767
|
+
|
|
768
|
+
return befly.tool.Yes('处理成功');
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
### 批量操作的替代方案
|
|
774
|
+
|
|
775
|
+
对于批量导入等场景,**不需要使用 rawBody**,使用空 `fields` 即可保留所有字段:
|
|
776
|
+
|
|
777
|
+
```typescript
|
|
778
|
+
// apis/user/batchImport.ts
|
|
779
|
+
export default {
|
|
780
|
+
name: '批量导入用户',
|
|
781
|
+
// 不定义 fields,或 fields: {},会保留所有请求参数
|
|
782
|
+
handler: async (befly, ctx) => {
|
|
783
|
+
const { users } = ctx.body; // 正常解析
|
|
784
|
+
|
|
785
|
+
if (!Array.isArray(users) || users.length === 0) {
|
|
786
|
+
return befly.tool.No('用户列表不能为空');
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// 批量插入...
|
|
790
|
+
return befly.tool.Yes('导入成功');
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
### 注意事项
|
|
796
|
+
|
|
797
|
+
1. **请求体只能读取一次**:`ctx.req.text()` 或 `ctx.req.json()` 只能调用一次,第二次调用会返回空
|
|
798
|
+
2. **ctx.body 为空**:`rawBody: true` 时 `ctx.body = {}`,所有数据需要从 `ctx.req` 获取
|
|
799
|
+
3. **validator 钩子**:如果定义了 `required` 字段,validator 仍会检查 `ctx.body`(此时为空),建议 rawBody 接口不定义 required
|
|
800
|
+
4. **安全性**:手动处理请求时务必做好验签和数据验证
|
|
801
|
+
5. **Content-Type 无限制**:rawBody 模式不检查 Content-Type,支持任意格式
|
|
802
|
+
|
|
803
|
+
---
|
|
804
|
+
|
|
578
805
|
## 实际案例
|
|
579
806
|
|
|
580
807
|
### 案例一:公开接口(无需认证)
|
package/hooks/parser.ts
CHANGED
|
@@ -16,20 +16,25 @@ const xmlParser = new XMLParser();
|
|
|
16
16
|
* - GET 请求:解析 URL 查询参数
|
|
17
17
|
* - POST 请求:解析 JSON 或 XML 请求体
|
|
18
18
|
* - 根据 API 定义的 fields 过滤字段
|
|
19
|
+
* - rawBody: true 时跳过解析,由 handler 自行处理原始请求
|
|
19
20
|
*/
|
|
20
21
|
const hook: Hook = {
|
|
21
22
|
order: 4,
|
|
22
23
|
handler: async (befly, ctx) => {
|
|
23
24
|
if (!ctx.api) return;
|
|
24
25
|
|
|
26
|
+
// rawBody 模式:跳过解析,保留原始请求供 handler 自行处理
|
|
27
|
+
// 适用于:微信回调、支付回调、webhook 等需要手动解密/验签的场景
|
|
28
|
+
if (ctx.api.rawBody) {
|
|
29
|
+
ctx.body = {};
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
// GET 请求:解析查询参数
|
|
26
34
|
if (ctx.req.method === 'GET') {
|
|
27
35
|
const url = new URL(ctx.req.url);
|
|
28
36
|
const params = Object.fromEntries(url.searchParams);
|
|
29
|
-
|
|
30
|
-
if (ctx.api.rawBody) {
|
|
31
|
-
ctx.body = params;
|
|
32
|
-
} else if (isPlainObject(ctx.api.fields) && !isEmpty(ctx.api.fields)) {
|
|
37
|
+
if (isPlainObject(ctx.api.fields) && !isEmpty(ctx.api.fields)) {
|
|
33
38
|
ctx.body = pickFields(params, Object.keys(ctx.api.fields));
|
|
34
39
|
} else {
|
|
35
40
|
ctx.body = params;
|
|
@@ -37,7 +42,7 @@ const hook: Hook = {
|
|
|
37
42
|
} else if (ctx.req.method === 'POST') {
|
|
38
43
|
// POST 请求:解析请求体
|
|
39
44
|
const contentType = ctx.req.headers.get('content-type') || '';
|
|
40
|
-
// 获取 URL 查询参数(POST
|
|
45
|
+
// 获取 URL 查询参数(POST 请求也可能带参数)
|
|
41
46
|
const url = new URL(ctx.req.url);
|
|
42
47
|
const queryParams = Object.fromEntries(url.searchParams);
|
|
43
48
|
|
|
@@ -47,10 +52,7 @@ const hook: Hook = {
|
|
|
47
52
|
const body = (await ctx.req.json()) as Record<string, any>;
|
|
48
53
|
// 合并 URL 参数和请求体(请求体优先)
|
|
49
54
|
const merged = { ...queryParams, ...body };
|
|
50
|
-
|
|
51
|
-
if (ctx.api.rawBody) {
|
|
52
|
-
ctx.body = merged;
|
|
53
|
-
} else if (isPlainObject(ctx.api.fields) && !isEmpty(ctx.api.fields)) {
|
|
55
|
+
if (isPlainObject(ctx.api.fields) && !isEmpty(ctx.api.fields)) {
|
|
54
56
|
ctx.body = pickFields(merged, Object.keys(ctx.api.fields));
|
|
55
57
|
} else {
|
|
56
58
|
ctx.body = merged;
|
|
@@ -64,10 +66,7 @@ const hook: Hook = {
|
|
|
64
66
|
const body = rootKey && typeof parsed[rootKey] === 'object' ? parsed[rootKey] : parsed;
|
|
65
67
|
// 合并 URL 参数和请求体(请求体优先)
|
|
66
68
|
const merged = { ...queryParams, ...body };
|
|
67
|
-
|
|
68
|
-
if (ctx.api.rawBody) {
|
|
69
|
-
ctx.body = merged;
|
|
70
|
-
} else if (isPlainObject(ctx.api.fields) && !isEmpty(ctx.api.fields)) {
|
|
69
|
+
if (isPlainObject(ctx.api.fields) && !isEmpty(ctx.api.fields)) {
|
|
71
70
|
ctx.body = pickFields(merged, Object.keys(ctx.api.fields));
|
|
72
71
|
} else {
|
|
73
72
|
ctx.body = merged;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "befly",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.36",
|
|
4
4
|
"description": "Befly - 为 Bun 专属打造的 TypeScript API 接口框架核心引擎",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -69,12 +69,12 @@
|
|
|
69
69
|
"chalk": "^5.6.2",
|
|
70
70
|
"es-toolkit": "^1.42.0",
|
|
71
71
|
"fast-jwt": "^6.1.0",
|
|
72
|
-
"fast-xml-parser": "^5.3.
|
|
72
|
+
"fast-xml-parser": "^5.3.3",
|
|
73
73
|
"pathe": "^2.0.3",
|
|
74
74
|
"pino": "^10.1.0",
|
|
75
75
|
"pino-roll": "^4.0.0"
|
|
76
76
|
},
|
|
77
|
-
"gitHead": "
|
|
77
|
+
"gitHead": "ac556f0341ab02b800afd8ba0639a5754fcbaf4a",
|
|
78
78
|
"devDependencies": {
|
|
79
79
|
"typescript": "^5.9.3"
|
|
80
80
|
}
|
package/util.ts
CHANGED
|
@@ -90,7 +90,7 @@ export function ErrorResponse(ctx: RequestContext, msg: string, code: number = 1
|
|
|
90
90
|
// 记录拦截日志
|
|
91
91
|
if (ctx.requestId) {
|
|
92
92
|
const duration = Date.now() - ctx.now;
|
|
93
|
-
const user = ctx.user?.id ? `[User:${ctx.user.id}]` : '[Guest]';
|
|
93
|
+
const user = ctx.user?.id ? `[User:${ctx.user.id} ${ctx.user.nickname}]` : '[Guest]';
|
|
94
94
|
Logger.info(`[${ctx.requestId}] ${ctx.route} ${user} ${duration}ms [${msg}]`);
|
|
95
95
|
}
|
|
96
96
|
|
|
@@ -117,7 +117,7 @@ export function FinalResponse(ctx: RequestContext): Response {
|
|
|
117
117
|
// 记录请求日志
|
|
118
118
|
if (ctx.api && ctx.requestId) {
|
|
119
119
|
const duration = Date.now() - ctx.now;
|
|
120
|
-
const user = ctx.user?.id ? `[User:${ctx.user.id}]` : '[Guest]';
|
|
120
|
+
const user = ctx.user?.id ? `[User:${ctx.user.id} ${ctx.user.nickname}]` : '[Guest]';
|
|
121
121
|
Logger.info(`[${ctx.requestId}] ${ctx.route} ${user} ${duration}ms`);
|
|
122
122
|
}
|
|
123
123
|
|