chaimi-keep-mcp 3.1.44 → 3.1.45-beta.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chaimi-keep-mcp",
3
- "version": "3.1.44",
3
+ "version": "3.1.45-beta.1",
4
4
  "description": "柴米记账 MCP Server - 支持 Claude、Cursor、OpenClaw、WorkBuddy 等 AI 工具直接记账",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
+ "utils/",
11
12
  "server.js",
12
13
  "oauth.js",
13
14
  "SKILL.md",
package/server.js CHANGED
@@ -5,11 +5,27 @@
5
5
  * 支持 Claude Desktop、Cursor、WorkBuddy、OpenClaw
6
6
  */
7
7
 
8
- // 加载 .env 文件
8
+ // 加载环境变量 - 根据 NODE_ENV 自动选择配置文件
9
+ const NODE_ENV = process.env.NODE_ENV || 'development';
10
+ const envFile = `.env.${NODE_ENV}`;
11
+
9
12
  try {
10
- require('dotenv').config();
13
+ const dotenv = require('dotenv');
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const envPath = path.join(__dirname, envFile);
18
+
19
+ if (fs.existsSync(envPath)) {
20
+ dotenv.config({ path: envPath });
21
+ console.log(`✅ 已加载 ${NODE_ENV} 环境配置`);
22
+ } else {
23
+ console.warn(`⚠️ 警告:${envFile} 文件不存在,请运行 setup.js 生成配置`);
24
+ // 尝试加载默认 .env 文件
25
+ dotenv.config();
26
+ }
11
27
  } catch (e) {
12
- // dotenv 未安装,忽略
28
+ console.warn('⚠️ 警告:dotenv 未安装或加载失败');
13
29
  }
14
30
 
15
31
  const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
@@ -30,11 +46,106 @@ const MCP_VERSION = packageJson.version;
30
46
  // 导入 OAuth 模块
31
47
  const { OAuthManager, FileTokenStorage } = require('./oauth.js');
32
48
 
49
+ // 导入工具函数
50
+ const { validateDate } = require('./utils/validators.js');
51
+
52
+ // ==================== 密钥管理 ====================
53
+
54
+ // Token 加密密钥文件路径
55
+ const SECRET_KEY_FILE = path.join(__dirname, '.mcp-secret-key');
56
+
57
+ // 自动生成或加载 Token 加密密钥
58
+ function getOrCreateSecretKey() {
59
+ // 1. 优先使用环境变量(高级用户可以手动配置)
60
+ if (process.env.MCP_SECRET_KEY) {
61
+ console.error('✅ 使用环境变量中的 Token 加密密钥');
62
+ return process.env.MCP_SECRET_KEY;
63
+ }
64
+
65
+ // 2. 尝试从文件加载
66
+ if (fs.existsSync(SECRET_KEY_FILE)) {
67
+ const key = fs.readFileSync(SECRET_KEY_FILE, 'utf8').trim();
68
+ console.error('✅ 从本地文件加载 Token 加密密钥');
69
+ return key;
70
+ }
71
+
72
+ // 3. 自动生成新密钥(64 字符,256 位)
73
+ const newKey = crypto.randomBytes(32).toString('hex');
74
+ fs.writeFileSync(SECRET_KEY_FILE, newKey, { mode: 0o600 }); // 只有当前用户可读写
75
+ console.error('✅ 已自动生成 Token 加密密钥,保存在 .mcp-secret-key 文件中');
76
+ console.error('⚠️ 请勿删除此文件,否则已授权的 Token 将无法解密');
77
+ return newKey;
78
+ }
79
+
80
+ // Token 加密密钥(自动生成或加载)
81
+ const SECRET_KEY = getOrCreateSecretKey();
82
+
33
83
  // API 签名密钥(用于验证请求来自合法 MCP Server)
34
- const CHAIMI_API_SECRET = 'chaimi-mcp-secret-2024';
84
+ const CHAIMI_API_SECRET = process.env.CHAIMI_API_SECRET;
35
85
 
36
86
  // URL 加密密钥
37
- const URL_ENCRYPT_KEY = 'chaimi-url-key-2024';
87
+ const URL_ENCRYPT_KEY = process.env.URL_ENCRYPT_KEY;
88
+
89
+ // 验证必需的环境变量
90
+ if (!CHAIMI_API_SECRET || !URL_ENCRYPT_KEY) {
91
+ console.error('❌ 错误:缺少必需的环境变量');
92
+ console.error(' 请运行以下命令生成配置:');
93
+ console.error(' node setup.js');
94
+ console.error('');
95
+ console.error(' 然后使用以下命令启动:');
96
+ console.error(' NODE_ENV=development node server.js # 开发环境');
97
+ console.error(' NODE_ENV=production node server.js # 生产环境');
98
+ process.exit(1);
99
+ }
100
+
101
+ // ==================== 加密/解密函数 ====================
102
+
103
+ // AES-256-GCM 加密(用于 Token)
104
+ function encrypt(text, secretKey) {
105
+ const iv = crypto.randomBytes(16); // 随机初始化向量
106
+ const cipher = crypto.createCipheriv(
107
+ 'aes-256-gcm',
108
+ Buffer.from(secretKey, 'hex'),
109
+ iv
110
+ );
111
+
112
+ let encrypted = cipher.update(text, 'utf8', 'hex');
113
+ encrypted += cipher.final('hex');
114
+
115
+ const authTag = cipher.getAuthTag(); // 认证标签(防篡改)
116
+
117
+ // 返回格式:iv:authTag:encrypted
118
+ return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
119
+ }
120
+
121
+ // AES-256-GCM 解密(用于 Token)
122
+ function decrypt(encryptedText, secretKey) {
123
+ const [ivHex, authTagHex, encrypted] = encryptedText.split(':');
124
+
125
+ const decipher = crypto.createDecipheriv(
126
+ 'aes-256-gcm',
127
+ Buffer.from(secretKey, 'hex'),
128
+ Buffer.from(ivHex, 'hex')
129
+ );
130
+
131
+ decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
132
+
133
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8');
134
+ decrypted += decipher.final('utf8');
135
+
136
+ return decrypted;
137
+ }
138
+
139
+ // 旧版 XOR 解密(向后兼容)
140
+ function decryptOldXOR(encrypted, key) {
141
+ const hex = encrypted.replace('enc:v1:', '');
142
+ let result = '';
143
+ for (let i = 0; i < hex.length; i += 2) {
144
+ const byte = parseInt(hex.substr(i, 2), 16);
145
+ result += String.fromCharCode(byte ^ key.charCodeAt((i / 2) % key.length));
146
+ }
147
+ return result;
148
+ }
38
149
 
39
150
  // 加密的云函数 URL
40
151
  const ENCRYPTED_URLS = {
@@ -43,15 +154,30 @@ const ENCRYPTED_URLS = {
43
154
  prompt: 'enc:v1:0b1c15191e53025a1100421e0148000057545156020903080f1d431054180f484819030203035158595043085d5801044c0502114c5b1e53441346150a01065811100d5e0e4b1a425f1f5f5713381306001959'
44
155
  };
45
156
 
46
- // 解密 URL 函数
157
+ // 解密 URL 函数(兼容新旧版本)
47
158
  function decryptUrl(encrypted, key) {
48
- const hex = encrypted.replace('enc:v1:', '');
49
- let result = '';
50
- for (let i = 0; i < hex.length; i += 2) {
51
- const byte = parseInt(hex.substr(i, 2), 16);
52
- result += String.fromCharCode(byte ^ key.charCodeAt((i / 2) % key.length));
159
+ // 新版本(AES-256-GCM)
160
+ if (encrypted.startsWith('enc:v2:')) {
161
+ const parts = encrypted.replace('enc:v2:', '').split(':');
162
+ const iv = Buffer.from(parts[0], 'hex');
163
+ const authTag = Buffer.from(parts[1], 'hex');
164
+ const ciphertext = parts[2];
165
+
166
+ const keyBuffer = crypto.scryptSync(key, 'salt', 32);
167
+ const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer, iv);
168
+ decipher.setAuthTag(authTag);
169
+
170
+ let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
171
+ decrypted += decipher.final('utf8');
172
+ return decrypted;
53
173
  }
54
- return result;
174
+
175
+ // 旧版本(XOR)- 向后兼容
176
+ if (encrypted.startsWith('enc:v1:')) {
177
+ return decryptOldXOR(encrypted, key);
178
+ }
179
+
180
+ throw new Error('不支持的加密格式');
55
181
  }
56
182
 
57
183
  // 配置 - 微信云函数(运行时解密)
@@ -59,8 +185,8 @@ const MCP_HUB_URL = process.env.MCP_HUB_URL || decryptUrl(ENCRYPTED_URLS.hub, UR
59
185
  const MCP_PROMPT_URL = process.env.MCP_PROMPT_URL || decryptUrl(ENCRYPTED_URLS.prompt, URL_ENCRYPT_KEY);
60
186
  const MCP_OAUTH_URL = process.env.MCP_OAUTH_URL || decryptUrl(ENCRYPTED_URLS.oauth, URL_ENCRYPT_KEY);
61
187
 
62
- // Token 缓存
63
- let cachedToken = null;
188
+ // Token 缓存(加密存储)
189
+ let cachedEncryptedToken = null;
64
190
  let tokenExpireTime = 0;
65
191
 
66
192
  // OAuth 管理器实例
@@ -108,8 +234,8 @@ async function initOAuthManager() {
108
234
  // 检查 Token 是否过期(预留5分钟缓冲)
109
235
  const expiresAt = new Date(existingToken.expiresAt).getTime();
110
236
  if (expiresAt > Date.now() + 5 * 60 * 1000) {
111
- // Token 有效,设置授权状态
112
- cachedToken = existingToken.accessToken;
237
+ // Token 有效,加密后缓存
238
+ cachedEncryptedToken = encrypt(existingToken.accessToken, SECRET_KEY);
113
239
  tokenExpireTime = expiresAt;
114
240
  authState.isAuthorized = true;
115
241
  console.error('✅ 已从文件加载有效 Token,无需重新授权');
@@ -371,15 +497,103 @@ const toolMapping = {
371
497
  'export_data': 'exportData',
372
498
  };
373
499
 
500
+ // ==================== 请求频率限制 ====================
501
+
502
+ // 限流配置
503
+ const RATE_LIMITS = {
504
+ // 记账工具:1 分钟 10 次
505
+ write: {
506
+ tools: ['save_expense', 'save_receipt', 'save_income'],
507
+ maxRequests: 10,
508
+ windowMs: 60 * 1000, // 1 分钟
509
+ },
510
+ // 查询工具:1 天 10 次
511
+ read: {
512
+ tools: ['get_expenses', 'get_receipt_list', 'get_statistics', 'get_insights', 'export_data'],
513
+ maxRequests: 10,
514
+ windowMs: 24 * 60 * 60 * 1000, // 1 天
515
+ },
516
+ // 配置读取工具:1 分钟 10 次
517
+ config: {
518
+ tools: ['get_skill', 'get_parse_prompt', 'get_text_parse_prompt'],
519
+ maxRequests: 10,
520
+ windowMs: 60 * 1000, // 1 分钟
521
+ },
522
+ // 反馈提交:1 天 5 次
523
+ feedback: {
524
+ tools: ['submit_feedback'],
525
+ maxRequests: 5,
526
+ windowMs: 24 * 60 * 60 * 1000, // 1 天
527
+ },
528
+ };
529
+
530
+ // 限流记录:{ toolName: [timestamp1, timestamp2, ...] }
531
+ const rateLimitRecords = {};
532
+
533
+ // 检查是否超过频率限制
534
+ function checkRateLimit(toolName) {
535
+ // 查找工具所属的限流组
536
+ let limitConfig = null;
537
+ let limitType = null;
538
+
539
+ for (const [type, config] of Object.entries(RATE_LIMITS)) {
540
+ if (config.tools.includes(toolName)) {
541
+ limitConfig = config;
542
+ limitType = type;
543
+ break;
544
+ }
545
+ }
546
+
547
+ // 如果工具不在限流列表中,直接通过
548
+ if (!limitConfig) {
549
+ return { allowed: true };
550
+ }
551
+
552
+ const now = Date.now();
553
+ const windowStart = now - limitConfig.windowMs;
554
+
555
+ // 初始化记录
556
+ if (!rateLimitRecords[toolName]) {
557
+ rateLimitRecords[toolName] = [];
558
+ }
559
+
560
+ // 清理过期记录
561
+ rateLimitRecords[toolName] = rateLimitRecords[toolName].filter(
562
+ timestamp => timestamp > windowStart
563
+ );
564
+
565
+ // 检查是否超过限制
566
+ if (rateLimitRecords[toolName].length >= limitConfig.maxRequests) {
567
+ const oldestRequest = rateLimitRecords[toolName][0];
568
+ const resetTime = oldestRequest + limitConfig.windowMs;
569
+ const waitSeconds = Math.ceil((resetTime - now) / 1000);
570
+
571
+ return {
572
+ allowed: false,
573
+ limitType,
574
+ maxRequests: limitConfig.maxRequests,
575
+ windowMs: limitConfig.windowMs,
576
+ resetTime,
577
+ waitSeconds,
578
+ };
579
+ }
580
+
581
+ // 记录本次请求
582
+ rateLimitRecords[toolName].push(now);
583
+
584
+ return { allowed: true };
585
+ }
586
+
374
587
  // 获取或刷新 Token(OAuth 2.0 Device Flow)
375
588
  const TOKEN_REFRESH_INTERVAL = 2 * 60 * 60 * 1000;
376
589
  let lastRefreshTime = 0;
377
590
 
378
591
  async function getToken() {
379
592
  // 步骤1:检查内存缓存 Token(最快路径)
380
- if (cachedToken && tokenExpireTime > Date.now() + 5 * 60 * 1000) {
593
+ if (cachedEncryptedToken && tokenExpireTime > Date.now() + 5 * 60 * 1000) {
381
594
  if (Date.now() - lastRefreshTime < TOKEN_REFRESH_INTERVAL) {
382
- return cachedToken;
595
+ // 解密后返回
596
+ return decrypt(cachedEncryptedToken, SECRET_KEY);
383
597
  }
384
598
  }
385
599
 
@@ -390,12 +604,14 @@ async function getToken() {
390
604
  // 步骤2:尝试从文件加载并刷新 Token
391
605
  try {
392
606
  const oauthToken = await oauthManager.getValidToken();
393
- cachedToken = oauthToken.accessToken;
607
+ // 加密后缓存
608
+ cachedEncryptedToken = encrypt(oauthToken.accessToken, SECRET_KEY);
394
609
  tokenExpireTime = oauthToken.expiresAt;
395
610
  lastRefreshTime = Date.now();
396
611
  authState.isAuthorized = true;
397
612
  await oauthManager.clearAuthState();
398
- return cachedToken;
613
+ // 解密后返回
614
+ return decrypt(cachedEncryptedToken, SECRET_KEY);
399
615
  } catch (err) {
400
616
  // Token 无效,需要重新授权
401
617
  console.error('⏰ Token 无效,需要重新授权:', err.message);
@@ -666,7 +882,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
666
882
  const startTime = Date.now();
667
883
  const traceId = generateTraceId();
668
884
  const osInfo = getOSInfo();
669
-
885
+
670
886
  // 提取元数据字段
671
887
  const agentType = args.agentType || process.env.AGENT_TYPE || process.env.MCP_AGENT_TYPE || '';
672
888
  const apiProvider = args.apiProvider || process.env.API_PROVIDER || process.env.MCP_API_PROVIDER || '';
@@ -686,6 +902,44 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
686
902
  logSource: 'mcp'
687
903
  });
688
904
 
905
+ // 检查频率限制
906
+ const rateLimitResult = checkRateLimit(name);
907
+ if (!rateLimitResult.allowed) {
908
+ const windowName = rateLimitResult.windowMs === 60 * 1000 ? '1 分钟' : '1 天';
909
+ const waitTime = rateLimitResult.waitSeconds < 60
910
+ ? `${rateLimitResult.waitSeconds} 秒`
911
+ : `${Math.ceil(rateLimitResult.waitSeconds / 60)} 分钟`;
912
+
913
+ // 记录限流
914
+ logMcpCall({
915
+ traceId,
916
+ stage: 'rate_limited',
917
+ toolName: name,
918
+ error: 'RATE_LIMIT_EXCEEDED',
919
+ duration: Date.now() - startTime,
920
+ agentType,
921
+ apiProvider,
922
+ mcpVersion: MCP_VERSION,
923
+ osType: osInfo.osType,
924
+ osVersion: osInfo.osVersion,
925
+ timestamp: new Date().toISOString(),
926
+ logSource: 'mcp'
927
+ });
928
+
929
+ return {
930
+ content: [
931
+ {
932
+ type: 'text',
933
+ text: JSON.stringify({
934
+ success: false,
935
+ error: `请求过于频繁,${windowName}内最多调用 ${rateLimitResult.maxRequests} 次,请 ${waitTime} 后再试`,
936
+ code: 429
937
+ })
938
+ }
939
+ ]
940
+ };
941
+ }
942
+
689
943
  // 强制检查:记账工具必须先调用 get_skill
690
944
  if (name === 'save_expense' || name === 'save_receipt' || name === 'save_income') {
691
945
  const now = Date.now();
@@ -739,7 +993,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
739
993
  if (processedArgs.items && typeof processedArgs.items === 'string') {
740
994
  try {
741
995
  processedArgs.items = JSON.parse(processedArgs.items);
996
+
997
+ // 验证解析后的类型
998
+ if (!Array.isArray(processedArgs.items)) {
999
+ throw new Error('items 必须是数组');
1000
+ }
742
1001
  } catch (e) {
1002
+ throw new Error(`items 参数格式错误:${e.message}`);
743
1003
  }
744
1004
  }
745
1005
 
@@ -805,41 +1065,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
805
1065
  userMessage = '❌ 记账失败:金额必须是正数';
806
1066
  break;
807
1067
  }
808
-
809
- // P1: 日期合理性检查
810
- if (processedArgs.date) {
811
- const inputDate = new Date(processedArgs.date);
812
- const now = new Date();
813
- const sixtyYearsAgo = new Date();
814
- sixtyYearsAgo.setFullYear(now.getFullYear() - 60);
815
-
816
- if (isNaN(inputDate.getTime())) {
817
- result = {
818
- success: false,
819
- error: '日期格式无效',
820
- code: 400
821
- };
822
- userMessage = '❌ 记账失败:日期格式无效,请使用毫秒级时间戳(13位数字,如:xxxxxxxxxxxxx)';
823
- break;
824
- }
825
1068
 
826
- if (inputDate > now) {
827
- result = {
828
- success: false,
829
- error: '日期不能是未来时间',
830
- code: 400
831
- };
832
- userMessage = '❌ 记账失败:日期不能是未来时间';
833
- break;
834
- }
835
-
836
- if (inputDate < sixtyYearsAgo) {
1069
+ // 日期合理性检查
1070
+ if (processedArgs.date) {
1071
+ const validation = validateDate(processedArgs.date, '消费时间');
1072
+ if (!validation.valid) {
837
1073
  result = {
838
1074
  success: false,
839
- error: '日期不能是60年前',
1075
+ error: validation.message,
840
1076
  code: 400
841
1077
  };
842
- userMessage = '❌ 记账失败:日期不能是60年前';
1078
+ userMessage = `❌ 记账失败:${validation.message}`;
843
1079
  break;
844
1080
  }
845
1081
  }
@@ -968,41 +1204,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
968
1204
  userMessage = `❌ 保存失败\n\n错误:商品数据格式不完整\n\n${invalidItems.join('\n')}\n\n💡 解决方案:\n1. 请更新您的柴米记账 MCP Skill\n2. 确保每个商品包含完整的5个字段:name, amount, price, quantity, category\n3. category 是记账的基本信息,不能为空`;
969
1205
  break;
970
1206
  }
971
-
972
- // P1: 日期合理性检查
1207
+
1208
+ // 日期合理性检查
973
1209
  if (processedArgs.date) {
974
- const inputDate = new Date(processedArgs.date);
975
- const now = new Date();
976
- const sixtyYearsAgo = new Date();
977
- sixtyYearsAgo.setFullYear(now.getFullYear() - 60);
978
-
979
- if (isNaN(inputDate.getTime())) {
1210
+ const validation = validateDate(processedArgs.date, '小票日期');
1211
+ if (!validation.valid) {
980
1212
  result = {
981
1213
  success: false,
982
- error: '日期格式无效',
1214
+ error: validation.message,
983
1215
  code: 400
984
1216
  };
985
- userMessage = '❌ 保存失败:日期格式无效,请使用毫秒级时间戳(13位数字,如:xxxxxxxxxxxxx)';
986
- break;
987
- }
988
-
989
- if (inputDate > now) {
990
- result = {
991
- success: false,
992
- error: '日期不能是未来时间',
993
- code: 400
994
- };
995
- userMessage = '❌ 保存失败:日期不能是未来时间,请检查小票日期是否正确';
996
- break;
997
- }
998
-
999
- if (inputDate < sixtyYearsAgo) {
1000
- result = {
1001
- success: false,
1002
- error: '日期不能是60年前',
1003
- code: 400
1004
- };
1005
- userMessage = '❌ 保存失败:日期不能是60年前,请检查小票日期是否正确';
1217
+ userMessage = `❌ 保存失败:${validation.message},请检查小票日期是否正确`;
1006
1218
  break;
1007
1219
  }
1008
1220
  }
@@ -1440,18 +1652,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1440
1652
  userMessage = `❌ 操作失败:${result.error || '未知错误'}`;
1441
1653
  }
1442
1654
 
1443
- // 构建完整的返回内容:userMessage + 完整的 result 数据
1444
- const fullResponse = {
1445
- userMessage: userMessage,
1446
- result: result,
1447
- mcpVersion: MCP_VERSION
1448
- };
1449
-
1450
1655
  return {
1451
1656
  content: [
1452
1657
  {
1453
1658
  type: 'text',
1454
- text: `${userMessage}\n\n---\n📦 柴米记账 MCP v${MCP_VERSION}\n\n## 完整数据\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``,
1659
+ text: `${userMessage}\n\n---\n📦 柴米记账 MCP v${MCP_VERSION}\n\n## 完整数据\n\`\`\`json\n${safeStringify(result)}\n\`\`\``,
1455
1660
  },
1456
1661
  ],
1457
1662
  };
@@ -1558,11 +1763,24 @@ ${'='.repeat(60)}
1558
1763
  };
1559
1764
  }
1560
1765
 
1766
+ // 区分开发/生产环境,避免泄露内部错误信息
1767
+ const errorMessage = process.env.NODE_ENV === 'development'
1768
+ ? `Error: ${error.message}`
1769
+ : '操作失败,请稍后重试或联系客服';
1770
+
1771
+ // 记录完整错误到日志(用于调试)
1772
+ console.error('Tool execution error:', {
1773
+ tool: request.params.name,
1774
+ error: error.message,
1775
+ stack: error.stack,
1776
+ timestamp: new Date().toISOString()
1777
+ });
1778
+
1561
1779
  return {
1562
1780
  content: [
1563
1781
  {
1564
1782
  type: 'text',
1565
- text: `Error: ${error.message}`,
1783
+ text: errorMessage,
1566
1784
  },
1567
1785
  ],
1568
1786
  isError: true,
@@ -1570,8 +1788,31 @@ ${'='.repeat(60)}
1570
1788
  }
1571
1789
  });
1572
1790
 
1791
+ // 安全的 JSON 序列化函数,防止循环引用和超大数据导致内存溢出
1792
+ function safeStringify(obj, maxLength = 100000) {
1793
+ try {
1794
+ const str = JSON.stringify(obj, null, 2);
1795
+ if (str.length > maxLength) {
1796
+ return JSON.stringify({
1797
+ ...obj,
1798
+ data: '[数据过大,已省略]',
1799
+ _note: `完整数据大小:${str.length} 字符`
1800
+ }, null, 2);
1801
+ }
1802
+ return str;
1803
+ } catch (error) {
1804
+ return JSON.stringify({ error: '数据序列化失败' });
1805
+ }
1806
+ }
1807
+
1573
1808
  function sanitizeString(str, maxLength = 200) {
1574
1809
  if (!str || typeof str !== 'string') return '';
1810
+
1811
+ // 硬性限制:输入不能超过 maxLength 的 10 倍,防止超大字符串导致内存溢出
1812
+ if (str.length > maxLength * 10) {
1813
+ throw new Error(`输入过长,最大允许 ${maxLength} 字符`);
1814
+ }
1815
+
1575
1816
  return str.replace(/<[^>]*>/g, '').substring(0, maxLength);
1576
1817
  }
1577
1818
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 数据校验工具函数
3
+ */
4
+
5
+ /**
6
+ * 校验日期合理性
7
+ * @param {number|string} dateInput - 日期(毫秒级时间戳或日期字符串)
8
+ * @param {string} fieldName - 字段名称(用于错误提示)
9
+ * @returns {Date} 校验通过后的 Date 对象
10
+ * @throws {Error} 校验失败时抛出错误
11
+ */
12
+ function validateDate(dateInput, fieldName = '日期') {
13
+ if (!dateInput) {
14
+ return new Date(); // 默认当前时间
15
+ }
16
+
17
+ const inputDate = new Date(dateInput);
18
+ const now = new Date();
19
+ const sixtyYearsAgo = new Date();
20
+ sixtyYearsAgo.setFullYear(now.getFullYear() - 60);
21
+
22
+ // 校验 1:日期格式是否有效
23
+ if (isNaN(inputDate.getTime())) {
24
+ throw new Error(`${fieldName}格式无效,请使用毫秒级时间戳(13位数字,如:${Date.now()})`);
25
+ }
26
+
27
+ // 校验 2:日期不能是未来时间
28
+ if (inputDate > now) {
29
+ throw new Error(`${fieldName}不能是未来时间`);
30
+ }
31
+
32
+ // 校验 3:日期不能是 60 年前
33
+ if (inputDate < sixtyYearsAgo) {
34
+ throw new Error(`${fieldName}不能是60年前`);
35
+ }
36
+
37
+ return inputDate;
38
+ }
39
+
40
+ module.exports = {
41
+ validateDate
42
+ };