befly 3.9.35 → 3.9.37

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 CHANGED
@@ -282,6 +282,116 @@ befly.tool.No(msg: string, data?: any, other?: Record<string, any>)
282
282
  }
283
283
  ```
284
284
 
285
+ ### Raw - 原始响应
286
+
287
+ 用于第三方回调等需要自定义响应格式的场景,直接返回 `Response` 对象。自动识别数据类型并设置正确的 Content-Type。
288
+
289
+ ```typescript
290
+ befly.tool.Raw(ctx: RequestContext, data: Record<string, any> | string, options?: ResponseOptions)
291
+ ```
292
+
293
+ **参数说明:**
294
+
295
+ | 参数 | 类型 | 必填 | 默认值 | 说明 |
296
+ | ------- | ----------------------------- | ---- | ------ | ------------------------ |
297
+ | ctx | RequestContext | 是 | - | 请求上下文 |
298
+ | data | Record<string, any> \| string | 是 | - | 响应数据(对象或字符串) |
299
+ | options | ResponseOptions | 否 | {} | 响应选项 |
300
+
301
+ **ResponseOptions 选项:**
302
+
303
+ | 属性 | 类型 | 默认值 | 说明 |
304
+ | ----------- | ---------------------- | -------- | ---------------------------------------- |
305
+ | status | number | 200 | HTTP 状态码 |
306
+ | contentType | string | 自动判断 | Content-Type,默认根据 data 类型自动判断 |
307
+ | headers | Record<string, string> | {} | 额外的响应头 |
308
+
309
+ **Content-Type 自动判断规则:**
310
+
311
+ | data 类型 | 自动 Content-Type |
312
+ | --------------------- | ----------------- |
313
+ | 对象 | application/json |
314
+ | 字符串(以 `<` 开头) | application/xml |
315
+ | 字符串(其他) | text/plain |
316
+
317
+ **使用示例:**
318
+
319
+ ```typescript
320
+ // JSON 响应(自动)
321
+ return befly.tool.Raw(ctx, { code: 'SUCCESS', message: '成功' });
322
+
323
+ // 纯文本响应(自动)- 支付宝回调
324
+ return befly.tool.Raw(ctx, 'success');
325
+
326
+ // XML 响应(自动判断)
327
+ return befly.tool.Raw(ctx, '<xml><return_code>SUCCESS</return_code></xml>');
328
+
329
+ // XML 响应(手动指定)
330
+ return befly.tool.Raw(ctx, xmlString, { contentType: 'application/xml' });
331
+
332
+ // 自定义状态码
333
+ return befly.tool.Raw(ctx, { error: 'Not Found' }, { status: 404 });
334
+
335
+ // 自定义响应头
336
+ return befly.tool.Raw(
337
+ ctx,
338
+ { code: 'SUCCESS' },
339
+ {
340
+ headers: { 'X-Custom-Header': 'value' }
341
+ }
342
+ );
343
+ ```
344
+
345
+ **完整回调示例:**
346
+
347
+ ```typescript
348
+ // 微信支付回调
349
+ export default {
350
+ name: '微信支付回调',
351
+ auth: false,
352
+ rawBody: true,
353
+ handler: async (befly, ctx) => {
354
+ if (!befly.weixin) {
355
+ return befly.tool.Raw(ctx, { code: 'SYSTEM_ERROR', message: 'weixin 插件未配置' });
356
+ }
357
+
358
+ // 处理成功
359
+ return befly.tool.Raw(ctx, { code: 'SUCCESS', message: '' });
360
+ }
361
+ };
362
+
363
+ // 支付宝回调
364
+ export default {
365
+ name: '支付宝回调',
366
+ auth: false,
367
+ rawBody: true,
368
+ handler: async (befly, ctx) => {
369
+ // 支付宝要求返回纯文本 "success"
370
+ return befly.tool.Raw(ctx, 'success');
371
+ }
372
+ };
373
+
374
+ // 微信公众号 XML 回调
375
+ export default {
376
+ name: '微信公众号回调',
377
+ auth: false,
378
+ rawBody: true,
379
+ handler: async (befly, ctx) => {
380
+ // 返回 XML 格式响应
381
+ const xml = `<xml>
382
+ <ToUserName><![CDATA[${fromUser}]]></ToUserName>
383
+ <FromUserName><![CDATA[${toUser}]]></FromUserName>
384
+ <CreateTime>${Date.now()}</CreateTime>
385
+ <MsgType><![CDATA[text]]></MsgType>
386
+ <Content><![CDATA[收到]]></Content>
387
+ </xml>`;
388
+ return befly.tool.Raw(ctx, xml);
389
+ }
390
+ };
391
+ ```
392
+
393
+ **注意**:`Raw` 返回的是 `Response` 对象,会直接作为 HTTP 响应返回,不经过 `FinalResponse` 处理。
394
+
285
395
  ### ErrorResponse - Hook 中断响应
286
396
 
287
397
  在 Hook 中使用,用于提前拦截请求:
@@ -575,6 +685,233 @@ interface FieldDefinition {
575
685
 
576
686
  ---
577
687
 
688
+ ## rawBody 原始请求体
689
+
690
+ ### 概述
691
+
692
+ `rawBody` 参数用于控制 `parser` 钩子的行为。当设置 `rawBody: true` 时,框架会**完全跳过请求体解析**,`ctx.body` 为空对象 `{}`,handler 可以通过 `ctx.req` 获取原始请求自行处理。
693
+
694
+ 这对于需要**手动验签、解密**的场景非常重要,如微信支付回调需要原始请求体来验证 RSA 签名。
695
+
696
+ ### 工作原理
697
+
698
+ #### 默认行为(rawBody: false)
699
+
700
+ ```typescript
701
+ // API 定义
702
+ export default {
703
+ name: '更新用户',
704
+ fields: {
705
+ id: '@id',
706
+ username: { name: '用户名', type: 'string', max: 50 }
707
+ },
708
+ handler: async (befly, ctx) => {
709
+ // ctx.body 只包含 fields 中定义的字段
710
+ }
711
+ };
712
+
713
+ // 请求参数
714
+ { id: 1, username: 'test', email: 'test@qq.com', role: 'admin' }
715
+
716
+ // ctx.body 实际值(过滤后)
717
+ { id: 1, username: 'test' }
718
+ // email 和 role 被过滤掉了
719
+ ```
720
+
721
+ #### rawBody 模式(rawBody: true)
722
+
723
+ ```typescript
724
+ // API 定义
725
+ export default {
726
+ name: '微信支付回调',
727
+ method: 'POST',
728
+ auth: false,
729
+ rawBody: true, // 跳过解析,保留原始请求
730
+ handler: async (befly, ctx) => {
731
+ // ctx.body 为空对象 {}
732
+ // 通过 ctx.req 获取原始请求自行处理
733
+
734
+ // 获取原始请求体(用于验签)
735
+ const rawBody = await ctx.req.text();
736
+
737
+ // 解析数据
738
+ const data = JSON.parse(rawBody);
739
+
740
+ // 处理业务逻辑...
741
+ }
742
+ };
743
+ ```
744
+
745
+ ### 处理流程
746
+
747
+ | rawBody | parser 钩子行为 | ctx.body | 使用场景 |
748
+ | -------------- | ------------------------------- | ------------ | ------------------ |
749
+ | `false` (默认) | 解析 JSON/XML,根据 fields 过滤 | 解析后的对象 | 普通 CRUD |
750
+ | `true` | **完全跳过**,不解析请求体 | `{}` 空对象 | 回调验签、手动解密 |
751
+
752
+ ### 适用场景
753
+
754
+ | 场景 | rawBody | 说明 |
755
+ | ------------ | ------- | ------------------------------ |
756
+ | 微信支付回调 | `true` | 需要原始请求体验证 RSA 签名 |
757
+ | 支付宝回调 | `true` | 需要原始请求体验证签名 |
758
+ | Webhook | `true` | 需要原始请求体验证 HMAC 签名 |
759
+ | 加密数据接口 | `true` | 需要手动解密请求体 |
760
+ | 文件上传 | `true` | 需要原始请求体处理二进制数据 |
761
+ | 普通 CRUD | `false` | 标准增删改查(默认) |
762
+ | 批量操作 | `false` | 使用空 fields 即可保留所有字段 |
763
+
764
+ ### 示例代码
765
+
766
+ #### 微信支付 V3 回调(需要验签和解密)
767
+
768
+ ```typescript
769
+ // apis/webhook/wechatPayV3.ts
770
+ export default {
771
+ name: '微信支付V3回调',
772
+ method: 'POST',
773
+ auth: false,
774
+ rawBody: true, // 跳过解析,保留原始请求
775
+ handler: async (befly, ctx) => {
776
+ // 1. 获取原始请求体(用于验签)
777
+ const rawBody = await ctx.req.text();
778
+
779
+ // 2. 获取微信签名头
780
+ const signature = ctx.req.headers.get('Wechatpay-Signature');
781
+ const timestamp = ctx.req.headers.get('Wechatpay-Timestamp');
782
+ const nonce = ctx.req.headers.get('Wechatpay-Nonce');
783
+ const serial = ctx.req.headers.get('Wechatpay-Serial');
784
+
785
+ // 3. 验证签名
786
+ const verifyMessage = `${timestamp}\n${nonce}\n${rawBody}\n`;
787
+ if (!verifyRsaSign(verifyMessage, signature, serial)) {
788
+ return { code: 'FAIL', message: '签名验证失败' };
789
+ }
790
+
791
+ // 4. 解析并解密数据
792
+ const notification = JSON.parse(rawBody);
793
+ const { ciphertext, nonce: decryptNonce, associated_data } = notification.resource;
794
+ const decrypted = decryptAesGcm(ciphertext, decryptNonce, associated_data);
795
+ const payResult = JSON.parse(decrypted);
796
+
797
+ // 5. 处理支付结果
798
+ if (payResult.trade_state === 'SUCCESS') {
799
+ await befly.db.updData({
800
+ table: 'order',
801
+ where: { orderNo: payResult.out_trade_no },
802
+ data: {
803
+ payStatus: 1,
804
+ payTime: Date.now(),
805
+ transactionId: payResult.transaction_id
806
+ }
807
+ });
808
+ }
809
+
810
+ return { code: 'SUCCESS', message: '' };
811
+ }
812
+ };
813
+ ```
814
+
815
+ #### GitHub Webhook(HMAC 签名验证)
816
+
817
+ ```typescript
818
+ // apis/webhook/github.ts
819
+ import { createHmac } from 'crypto';
820
+
821
+ export default {
822
+ name: 'GitHub Webhook',
823
+ method: 'POST',
824
+ auth: false,
825
+ rawBody: true,
826
+ handler: async (befly, ctx) => {
827
+ // 获取原始请求体
828
+ const rawBody = await ctx.req.text();
829
+
830
+ // 获取 GitHub 签名
831
+ const signature = ctx.req.headers.get('X-Hub-Signature-256');
832
+ if (!signature) {
833
+ return befly.tool.No('缺少签名');
834
+ }
835
+
836
+ // 验证 HMAC 签名
837
+ const secret = process.env.GITHUB_WEBHOOK_SECRET;
838
+ const hmac = createHmac('sha256', secret);
839
+ const expectedSignature = 'sha256=' + hmac.update(rawBody).digest('hex');
840
+
841
+ if (signature !== expectedSignature) {
842
+ return befly.tool.No('签名验证失败');
843
+ }
844
+
845
+ // 解析数据
846
+ const payload = JSON.parse(rawBody);
847
+ const event = ctx.req.headers.get('X-GitHub-Event');
848
+
849
+ // 处理不同事件
850
+ if (event === 'push') {
851
+ befly.logger.info({ ref: payload.ref }, 'GitHub Push 事件');
852
+ }
853
+
854
+ return befly.tool.Yes('处理成功');
855
+ }
856
+ };
857
+ ```
858
+
859
+ #### 加密数据接口
860
+
861
+ ```typescript
862
+ // apis/secure/receive.ts
863
+ export default {
864
+ name: '接收加密数据',
865
+ auth: false,
866
+ rawBody: true,
867
+ handler: async (befly, ctx) => {
868
+ // 获取加密的请求体
869
+ const encryptedBody = await ctx.req.text();
870
+
871
+ // 解密数据
872
+ const decrypted = befly.cipher.decrypt(encryptedBody);
873
+ const data = JSON.parse(decrypted);
874
+
875
+ // 处理解密后的数据
876
+ // ...
877
+
878
+ return befly.tool.Yes('处理成功');
879
+ }
880
+ };
881
+ ```
882
+
883
+ ### 批量操作的替代方案
884
+
885
+ 对于批量导入等场景,**不需要使用 rawBody**,使用空 `fields` 即可保留所有字段:
886
+
887
+ ```typescript
888
+ // apis/user/batchImport.ts
889
+ export default {
890
+ name: '批量导入用户',
891
+ // 不定义 fields,或 fields: {},会保留所有请求参数
892
+ handler: async (befly, ctx) => {
893
+ const { users } = ctx.body; // 正常解析
894
+
895
+ if (!Array.isArray(users) || users.length === 0) {
896
+ return befly.tool.No('用户列表不能为空');
897
+ }
898
+
899
+ // 批量插入...
900
+ return befly.tool.Yes('导入成功');
901
+ }
902
+ };
903
+ ```
904
+
905
+ ### 注意事项
906
+
907
+ 1. **请求体只能读取一次**:`ctx.req.text()` 或 `ctx.req.json()` 只能调用一次,第二次调用会返回空
908
+ 2. **ctx.body 为空**:`rawBody: true` 时 `ctx.body = {}`,所有数据需要从 `ctx.req` 获取
909
+ 3. **validator 钩子**:如果定义了 `required` 字段,validator 仍会检查 `ctx.body`(此时为空),建议 rawBody 接口不定义 required
910
+ 4. **安全性**:手动处理请求时务必做好验签和数据验证
911
+ 5. **Content-Type 无限制**:rawBody 模式不检查 Content-Type,支持任意格式
912
+
913
+ ---
914
+
578
915
  ## 实际案例
579
916
 
580
917
  ### 案例一:公开接口(无需认证)
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
- // rawBody 模式:保留完整请求参数,不过滤字段
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
- // rawBody 模式:保留完整请求体,不过滤字段
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
- // rawBody 模式:保留完整请求体,不过滤字段
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.35",
3
+ "version": "3.9.37",
4
4
  "description": "Befly - 为 Bun 专属打造的 TypeScript API 接口框架核心引擎",
5
5
  "type": "module",
6
6
  "private": false,
@@ -65,16 +65,16 @@
65
65
  "bun": ">=1.3.0"
66
66
  },
67
67
  "dependencies": {
68
- "befly-shared": "^1.2.7",
68
+ "befly-shared": "^1.2.8",
69
69
  "chalk": "^5.6.2",
70
- "es-toolkit": "^1.42.0",
70
+ "es-toolkit": "^1.43.0",
71
71
  "fast-jwt": "^6.1.0",
72
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": "4b7ea2e7f6d7b5c7454f34c30794473d8bb021de",
77
+ "gitHead": "e078b94f087ddf21f289b55ab86c0b8dce105807",
78
78
  "devDependencies": {
79
79
  "typescript": "^5.9.3"
80
80
  }
package/plugins/tool.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  // 类型导入
7
7
  import type { Plugin } from '../types/plugin.js';
8
+ import type { RequestContext } from '../types/context.js';
8
9
 
9
10
  /**
10
11
  * 成功响应
@@ -38,11 +39,86 @@ export function No(msg: string, data: any = null, other: Record<string, any> = {
38
39
  };
39
40
  }
40
41
 
42
+ /**
43
+ * 响应选项
44
+ */
45
+ interface ResponseOptions {
46
+ /** HTTP 状态码,默认 200 */
47
+ status?: number;
48
+ /** Content-Type,默认根据 data 类型自动判断 */
49
+ contentType?: string;
50
+ /** 额外的响应头 */
51
+ headers?: Record<string, string>;
52
+ }
53
+
54
+ /**
55
+ * 统一响应函数
56
+ *
57
+ * 自动识别数据类型并设置正确的 Content-Type:
58
+ * - 对象 → application/json
59
+ * - 字符串 → text/plain
60
+ * - 可通过 options.contentType 手动指定
61
+ *
62
+ * @param ctx 请求上下文
63
+ * @param data 响应数据(对象或字符串)
64
+ * @param options 响应选项
65
+ * @returns Response 对象
66
+ *
67
+ * @example
68
+ * // JSON 响应(自动)
69
+ * return Raw(ctx, { code: 'SUCCESS', message: '成功' });
70
+ *
71
+ * // 纯文本响应(自动)
72
+ * return Raw(ctx, 'success');
73
+ *
74
+ * // XML 响应(手动指定)
75
+ * return Raw(ctx, xmlString, { contentType: 'application/xml' });
76
+ *
77
+ * // 自定义状态码和额外头
78
+ * return Raw(ctx, { error: 'Not Found' }, {
79
+ * status: 404,
80
+ * headers: { 'X-Custom': 'value' }
81
+ * });
82
+ */
83
+ export function Raw(ctx: RequestContext, data: Record<string, any> | string, options: ResponseOptions = {}): Response {
84
+ const { status = 200, contentType, headers = {} } = options;
85
+
86
+ // 自动判断 Content-Type
87
+ let finalContentType = contentType;
88
+ let body: string;
89
+
90
+ if (typeof data === 'string') {
91
+ // 字符串类型
92
+ body = data;
93
+ if (!finalContentType) {
94
+ // 自动判断:XML 或纯文本
95
+ finalContentType = data.trim().startsWith('<') ? 'application/xml' : 'text/plain';
96
+ }
97
+ } else {
98
+ // 对象类型,JSON 序列化
99
+ body = JSON.stringify(data);
100
+ finalContentType = finalContentType || 'application/json';
101
+ }
102
+
103
+ // 合并响应头
104
+ const responseHeaders = {
105
+ ...ctx.corsHeaders,
106
+ 'Content-Type': finalContentType,
107
+ ...headers
108
+ };
109
+
110
+ return new Response(body, {
111
+ status: status,
112
+ headers: responseHeaders
113
+ });
114
+ }
115
+
41
116
  const plugin: Plugin = {
42
117
  handler: () => {
43
118
  return {
44
119
  Yes: Yes,
45
- No: No
120
+ No: No,
121
+ Raw: Raw
46
122
  };
47
123
  }
48
124
  };
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