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 +337 -0
- package/hooks/parser.ts +12 -13
- package/package.json +4 -4
- package/plugins/tool.ts +77 -1
- package/util.ts +2 -2
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
|
-
|
|
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.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.
|
|
68
|
+
"befly-shared": "^1.2.8",
|
|
69
69
|
"chalk": "^5.6.2",
|
|
70
|
-
"es-toolkit": "^1.
|
|
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": "
|
|
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
|
|