jacky-proxy 1.0.0 → 1.0.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.
@@ -38,7 +38,7 @@ program
38
38
  .option('-p, --port <port>', '监听端口', '5000')
39
39
  .option('-m, --mock-id <id>', '接口集 ID(如果未在命令中指定)')
40
40
  .option('-c, --config <path>', '配置文件路径', 'proxy.config.json')
41
- .option('-s, --scenario <name>', '场景名称(当启动 Raw 文件夹时使用)', '场景1')
41
+ .option('-s, --scenario <name>', '场景名称(当启动 Raw 文件夹时使用)', '默认场景')
42
42
  .option('-t, --target <path>', '目标文件夹路径(当启动 Raw 文件夹时使用)', 'mocks/test-folder')
43
43
  .option('--ignore <interfaces>', '要忽略的接口(逗号分隔,当启动 Raw 文件夹时使用)', '')
44
44
  .option('--no-migrate', '不自动迁移,直接启动(如果指定的是 Raw 文件夹)')
@@ -132,6 +132,75 @@ rulesCommand
132
132
  await rulesTestCommand(options);
133
133
  });
134
134
 
135
+ // 在解析之前,检查第一个参数是否是 Raw 文件夹路径
136
+ // 如果是,自动调用 start 命令
137
+ const args = process.argv.slice(2);
138
+ if (args.length > 0) {
139
+ const firstArg = args[0];
140
+ const fs = require('fs');
141
+ const path = require('path');
142
+
143
+ // 检查是否是已知命令
144
+ const knownCommands = ['migrate', 'start', 'config', 'rules'];
145
+ const isKnownCommand = knownCommands.includes(firstArg);
146
+
147
+ // 如果不是已知命令,检查是否是 Raw 文件夹路径
148
+ if (!isKnownCommand) {
149
+ // 规范化路径,确保正确处理相对路径(包括 ./ 开头的路径)
150
+ const fullPath = path.isAbsolute(firstArg)
151
+ ? path.resolve(firstArg)
152
+ : path.resolve(process.cwd(), firstArg);
153
+
154
+ // 检查是否是 Raw 文件夹路径:路径必须以 .folder 结尾,且路径存在且是目录
155
+ const isRawFolderPath = (firstArg.endsWith('.folder') || path.basename(fullPath).endsWith('.folder')) &&
156
+ fs.existsSync(fullPath) &&
157
+ fs.statSync(fullPath).isDirectory();
158
+
159
+ if (isRawFolderPath) {
160
+ // 将路径作为 start 命令的参数(使用规范化后的路径)
161
+ const startCommand = require('../src/commands/start');
162
+ const startOptions = {};
163
+
164
+ // 解析其他选项(使用 commander 的选项解析逻辑)
165
+ for (let i = 1; i < args.length; i++) {
166
+ const arg = args[i];
167
+ if (arg === '--port' || arg === '-p') {
168
+ startOptions.port = args[++i];
169
+ } else if (arg === '--scenario' || arg === '-s') {
170
+ startOptions.scenario = args[++i];
171
+ } else if (arg === '--target' || arg === '-t') {
172
+ startOptions.target = args[++i];
173
+ } else if (arg === '--ignore') {
174
+ startOptions.ignore = args[++i];
175
+ } else if (arg === '--debug') {
176
+ startOptions.debug = true;
177
+ } else if (arg === '--no-migrate') {
178
+ startOptions.noMigrate = true;
179
+ } else if (arg === '--config' || arg === '-c') {
180
+ startOptions.config = args[++i];
181
+ } else if (arg.startsWith('--port=')) {
182
+ startOptions.port = arg.split('=')[1];
183
+ } else if (arg.startsWith('--scenario=')) {
184
+ startOptions.scenario = arg.split('=')[1];
185
+ } else if (arg.startsWith('--target=')) {
186
+ startOptions.target = arg.split('=')[1];
187
+ } else if (arg.startsWith('--ignore=')) {
188
+ startOptions.ignore = arg.split('=')[1];
189
+ } else if (arg.startsWith('--config=')) {
190
+ startOptions.config = arg.split('=')[1];
191
+ }
192
+ }
193
+
194
+ // 调用 start 命令,传递原始参数(startCommand 内部会再次解析)
195
+ startCommand(firstArg, startOptions).catch((error) => {
196
+ console.error('启动失败:', error);
197
+ process.exit(1);
198
+ });
199
+ return;
200
+ }
201
+ }
202
+ }
203
+
135
204
  // 解析命令行参数
136
205
  program.parse();
137
206
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "jacky-proxy",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "通用 Mock 服务器 - 适用于任意网站的 HTTP 请求拦截和 Mock 响应系统",
5
5
  "main": "server.js",
6
6
  "bin": {
7
- "jacky-proxy": "bin/jacky-proxy.js"
7
+ "jacky-proxy": "./bin/jacky-proxy.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "npm run config:generate && node server.js",
@@ -29,7 +29,7 @@
29
29
  "license": "MIT",
30
30
  "repository": {
31
31
  "type": "git",
32
- "url": "git+https://github.com/wangjs-jacky/jacky-proxy.git"
32
+ "url": "https://github.com/wangjs-jacky/jacky-proxy.git"
33
33
  },
34
34
  "bugs": {
35
35
  "url": "https://github.com/wangjs-jacky/jacky-proxy/issues"
@@ -49,16 +49,17 @@
49
49
  "README.md"
50
50
  ],
51
51
  "dependencies": {
52
+ "chokidar": "^3.5.3",
52
53
  "commander": "^14.0.2",
53
54
  "express": "^4.18.2",
54
- "lodash": "^4.17.21"
55
+ "lodash": "^4.17.21",
56
+ "ts-node": "^10.9.2",
57
+ "typescript": "^5.3.3"
55
58
  },
56
59
  "devDependencies": {
57
60
  "@types/express": "^4.17.21",
58
61
  "@types/lodash": "^4.14.202",
59
62
  "@types/node": "^20.10.0",
60
- "nodemon": "^3.0.1",
61
- "ts-node": "^10.9.2",
62
- "typescript": "^5.3.3"
63
+ "nodemon": "^3.0.1"
63
64
  }
64
65
  }
package/server.js CHANGED
@@ -6,6 +6,7 @@
6
6
  const express = require('express');
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
+ const chokidar = require('chokidar');
9
10
 
10
11
  // 使用 ts-node 支持 TypeScript 文件
11
12
  require('ts-node').register({
@@ -160,12 +161,24 @@ function loadMockConfig() {
160
161
  configPath = path.join(workDir, 'proxy.config.json');
161
162
  }
162
163
 
164
+ // 确保路径是绝对路径
165
+ if (!path.isAbsolute(configPath)) {
166
+ configPath = path.resolve(configPath);
167
+ }
168
+
163
169
  if (fs.existsSync(configPath)) {
164
170
  const configContent = fs.readFileSync(configPath, 'utf-8');
165
171
  mockConfig = JSON.parse(configContent);
172
+ console.log(`✅ 已加载配置文件: ${configPath}`);
166
173
  return mockConfig;
167
174
  } else {
168
175
  console.warn(`配置文件不存在: ${configPath}`);
176
+ if (process.env.CONFIG_PATH) {
177
+ console.warn(` CONFIG_PATH 环境变量: ${process.env.CONFIG_PATH}`);
178
+ }
179
+ if (process.env.WORK_DIR) {
180
+ console.warn(` WORK_DIR 环境变量: ${process.env.WORK_DIR}`);
181
+ }
169
182
  return null;
170
183
  }
171
184
  } catch (error) {
@@ -290,6 +303,204 @@ let mockScenarios = {};
290
303
  // 格式: Set(['getProductInfo', 'productSearch']) 表示这些接口被禁用
291
304
  let disabledInterfaces = new Set();
292
305
 
306
+ // 文件监听器
307
+ let fileWatcher = null;
308
+
309
+ /**
310
+ * 清除模块及其所有依赖的缓存
311
+ */
312
+ function clearModuleCache(modulePath) {
313
+ const resolvedPath = require.resolve(modulePath);
314
+
315
+ // 清除该模块的缓存
316
+ if (require.cache[resolvedPath]) {
317
+ const module = require.cache[resolvedPath];
318
+
319
+ // 递归清除所有子模块的缓存
320
+ if (module.children) {
321
+ module.children.forEach(child => {
322
+ if (child.filename && (child.filename.endsWith('.json') || child.filename.includes('base-data'))) {
323
+ delete require.cache[child.filename];
324
+ }
325
+ });
326
+ }
327
+
328
+ delete require.cache[resolvedPath];
329
+ }
330
+ }
331
+
332
+ /**
333
+ * 清除 base-data 目录下所有 JSON 文件的缓存
334
+ */
335
+ function clearBaseDataCache(interfaceName) {
336
+ const workDir = getWorkDir();
337
+ const baseDataDir = path.join(workDir, 'base-data', interfaceName);
338
+
339
+ if (fs.existsSync(baseDataDir)) {
340
+ const files = fs.readdirSync(baseDataDir);
341
+ files.forEach(file => {
342
+ if (file.endsWith('.json')) {
343
+ const jsonPath = path.join(baseDataDir, file);
344
+ try {
345
+ const resolvedPath = require.resolve(jsonPath);
346
+ delete require.cache[resolvedPath];
347
+ } catch (e) {
348
+ // 如果文件还未被 require,忽略错误
349
+ }
350
+ }
351
+ });
352
+ }
353
+ }
354
+
355
+ /**
356
+ * 重新加载单个 Mock 文件
357
+ */
358
+ function reloadMockFile(filePath, interfaceName) {
359
+ try {
360
+ // 清除 base-data 中该接口的所有 JSON 文件缓存
361
+ clearBaseDataCache(interfaceName);
362
+
363
+ // 清除 Mock 文件及其所有依赖的缓存
364
+ clearModuleCache(filePath);
365
+
366
+ // 清除 ts-node 的编译缓存(如果存在)
367
+ if (require.extensions['.ts']) {
368
+ // ts-node 可能会缓存编译结果,清除相关缓存
369
+ const tsNodeCache = require.cache;
370
+ Object.keys(tsNodeCache).forEach(key => {
371
+ if (key.includes(filePath) || key.includes(interfaceName)) {
372
+ delete tsNodeCache[key];
373
+ }
374
+ });
375
+ }
376
+
377
+ // 重新加载
378
+ const mockModule = require(filePath);
379
+ const mockHandler = mockModule.default || mockModule;
380
+
381
+ if (typeof mockHandler === 'function') {
382
+ cachedMockFiles[interfaceName] = mockHandler;
383
+ mockFilePaths[interfaceName] = filePath;
384
+ console.log(`${colors.green}🔄${colors.reset} ${colors.dim}热更新 Mock 文件:${colors.reset} ${colors.cyan}${path.basename(filePath)}${colors.reset} ${colors.gray}->${colors.reset} ${colors.bright}${interfaceName}${colors.reset}`);
385
+ return true;
386
+ } else {
387
+ console.warn(`${colors.yellow}⚠${colors.reset} ${colors.yellow}Mock 文件 ${colors.cyan}${path.basename(filePath)}${colors.yellow} 未导出函数${colors.reset}`);
388
+ return false;
389
+ }
390
+ } catch (error) {
391
+ console.error(`${colors.red}✗${colors.reset} ${colors.red}热更新 Mock 文件失败:${colors.reset} ${colors.cyan}${path.basename(filePath)}${colors.reset} ${colors.red}${error.message}${colors.reset}`);
392
+ if (error.stack) {
393
+ console.error(error.stack);
394
+ }
395
+ return false;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * 重新加载所有 Mock 文件(当 base-data 变化时)
401
+ */
402
+ function reloadAllMockFiles() {
403
+ if (!cachedMockId) return;
404
+
405
+ const folderPath = getMockFolderPath(cachedMockId);
406
+ if (!folderPath) return;
407
+
408
+ console.log(`\n${colors.blue}🔄${colors.reset} ${colors.bright}热更新所有 Mock 文件...${colors.reset}`);
409
+ cachedMockFiles = loadMockFiles(folderPath);
410
+ console.log(`${colors.green}✅${colors.reset} ${colors.bright}热更新完成,共 ${colors.green}${Object.keys(cachedMockFiles).length}${colors.reset} ${colors.bright}个 Mock 接口${colors.reset}\n`);
411
+ }
412
+
413
+ /**
414
+ * 设置文件监听
415
+ */
416
+ function setupFileWatcher(mockId) {
417
+ // 清除旧的监听器
418
+ if (fileWatcher) {
419
+ fileWatcher.close();
420
+ fileWatcher = null;
421
+ }
422
+
423
+ const folderPath = getMockFolderPath(mockId);
424
+ if (!folderPath) return;
425
+
426
+ const workDir = getWorkDir();
427
+ const baseDataPath = path.join(workDir, 'base-data');
428
+ const mocksPath = folderPath;
429
+
430
+ // 要监听的路径
431
+ const watchPaths = [];
432
+ if (fs.existsSync(baseDataPath)) {
433
+ watchPaths.push(baseDataPath);
434
+ }
435
+ if (fs.existsSync(mocksPath)) {
436
+ watchPaths.push(mocksPath);
437
+ }
438
+
439
+ if (watchPaths.length === 0) return;
440
+
441
+ // 使用 chokidar 监听文件变化
442
+ fileWatcher = chokidar.watch(watchPaths, {
443
+ ignored: /(^|[\/\\])\../, // 忽略隐藏文件
444
+ persistent: true,
445
+ ignoreInitial: true,
446
+ awaitWriteFinish: {
447
+ stabilityThreshold: 200,
448
+ pollInterval: 100
449
+ }
450
+ });
451
+
452
+ fileWatcher
453
+ .on('change', (filePath) => {
454
+ // 延迟处理,避免文件写入未完成
455
+ setTimeout(() => {
456
+ const ext = path.extname(filePath);
457
+ const fileName = path.basename(filePath);
458
+
459
+ // 如果是 Mock 文件变化
460
+ if (filePath.endsWith('.mock.ts') || filePath.endsWith('.mock.js')) {
461
+ const interfaceName = fileName.replace(/\.mock\.(ts|js)$/, '');
462
+ reloadMockFile(filePath, interfaceName);
463
+ }
464
+ // 如果是 base-data 中的 JSON 文件变化
465
+ else if (ext === '.json' && filePath.includes('base-data')) {
466
+ // 找到对应的接口名(从路径中提取)
467
+ const baseDataMatch = filePath.match(/base-data[\/\\]([^\/\\]+)/);
468
+ if (baseDataMatch) {
469
+ const interfaceName = baseDataMatch[1];
470
+
471
+ // 先清除该 JSON 文件的缓存
472
+ try {
473
+ const resolvedPath = require.resolve(filePath);
474
+ delete require.cache[resolvedPath];
475
+ } catch (e) {
476
+ // 如果文件还未被 require,忽略错误
477
+ }
478
+
479
+ // 清除该接口的所有 base-data 缓存
480
+ clearBaseDataCache(interfaceName);
481
+
482
+ // 重新加载该接口的 Mock 文件(因为 Mock 文件会 import base-data)
483
+ const mockFilePath = path.join(mocksPath, `${interfaceName}.mock.ts`);
484
+ if (fs.existsSync(mockFilePath)) {
485
+ reloadMockFile(mockFilePath, interfaceName);
486
+ } else {
487
+ // 如果找不到对应的 Mock 文件,重新加载所有(以防有依赖关系)
488
+ reloadAllMockFiles();
489
+ }
490
+ } else {
491
+ // 无法确定接口名,重新加载所有
492
+ reloadAllMockFiles();
493
+ }
494
+ }
495
+ }, 100);
496
+ })
497
+ .on('error', (error) => {
498
+ console.error(`${colors.red}✗${colors.reset} ${colors.red}文件监听错误:${colors.reset} ${error.message}`);
499
+ });
500
+
501
+ console.log(`${colors.blue}👁️${colors.reset} ${colors.dim}已启用文件热更新监听${colors.reset}`);
502
+ }
503
+
293
504
  /**
294
505
  * 获取或加载 Mock 文件
295
506
  */
@@ -309,6 +520,10 @@ function getMockFiles(mockId) {
309
520
  cachedMockId = mockId;
310
521
 
311
522
  console.log(`${colors.green}✅${colors.reset} ${colors.bright}共加载 ${colors.green}${Object.keys(cachedMockFiles).length}${colors.reset} ${colors.bright}个 Mock 接口${colors.reset}\n`);
523
+
524
+ // 设置文件监听
525
+ setupFileWatcher(mockId);
526
+
312
527
  return cachedMockFiles;
313
528
  }
314
529
 
@@ -473,7 +688,10 @@ async function handleRequest(req, res) {
473
688
 
474
689
 
475
690
  // 检查是否匹配失败
476
- if (result && result.body && result.body.error) {
691
+ // matchResponse 返回错误时的格式:{ error: true, message: '...', ... }
692
+ // 需要检查 error 是否为布尔值 true,而不是仅仅检查 error 字段是否存在
693
+ // 因为响应数据中可能包含 error 字段(如 { error: { code: '', message: '' } })
694
+ if (result && result.body && result.body.error === true) {
477
695
  console.log(`${getTimestamp()} ${colors.red}❌ ${actualInterfaceName} 匹配失败${colors.reset}`);
478
696
  return res.status(404).json(result.body);
479
697
  }
@@ -39,30 +39,194 @@ const SOA_FILE_PATTERN = /.*soa2_\d+_\w+\.txt$/;
39
39
  const SOA_EXTRACT_PATTERN = /soa2_(\d+)_(\w+)/;
40
40
  const INDEX_PATTERN = /^\[(\d+)\]\s+(Request|Response)/;
41
41
 
42
+ // Proxyman 标准格式文件模式
43
+ const PROXYMAN_FILE_PATTERN = /^\[\d+\]\s+(Request|Response)\s+-\s+.+\.txt$/;
44
+ const PROXYMAN_EXTRACT_PATTERN = /^\[(\d+)\]\s+(Request|Response)\s+-\s+(.+?)\.txt$/;
45
+
42
46
  /**
43
47
  * 从文件中提取 JSON 数据
48
+ * 支持 Request 和 Response 文件
44
49
  */
45
50
  function extractJsonFromFile(filePath) {
46
51
  try {
47
52
  const content = fs.readFileSync(filePath, 'utf-8');
48
53
  const lines = content.split('\n');
54
+ const filename = path.basename(filePath);
55
+
56
+ // 判断是 Request 还是 Response 文件
57
+ const isRequest = filename.includes('Request');
58
+ const isResponse = filename.includes('Response');
59
+
60
+ // 对于 Request 文件,检查是否是 GET 请求(没有 body)
61
+ if (isRequest) {
62
+ const firstLine = lines[0] || '';
63
+ const methodMatch = firstLine.match(/^(GET|POST|PUT|DELETE|PATCH)\s+/);
64
+
65
+ if (methodMatch) {
66
+ const method = methodMatch[1];
67
+
68
+ // GET 请求通常没有 body,从 URL query 参数中提取
69
+ if (method === 'GET') {
70
+ const urlMatch = firstLine.match(/\s+([^\s?]+)\??([^\s]*)/);
71
+ if (urlMatch) {
72
+ const queryString = urlMatch[2];
73
+ if (queryString) {
74
+ // 解析 query 参数
75
+ const params = {};
76
+ queryString.split('&').forEach(param => {
77
+ const [key, value] = param.split('=');
78
+ if (key) {
79
+ try {
80
+ params[key] = value ? decodeURIComponent(value) : '';
81
+ } catch (e) {
82
+ params[key] = value || '';
83
+ }
84
+ }
85
+ });
86
+ return Object.keys(params).length > 0 ? params : {};
87
+ }
88
+ }
89
+ // GET 请求没有 query 参数,返回空对象
90
+ return {};
91
+ }
92
+
93
+ // POST/PUT/DELETE/PATCH 请求,尝试从 body 中提取 JSON
94
+ // 找到第一个空行后的第一个 {
95
+ let jsonStartIndex = -1;
96
+ for (let i = 0; i < lines.length; i++) {
97
+ if (lines[i].trim() === '' && i + 1 < lines.length) {
98
+ const nextLine = lines[i + 1].trim();
99
+ if (nextLine.startsWith('{') || nextLine.startsWith('[')) {
100
+ jsonStartIndex = i + 1;
101
+ break;
102
+ }
103
+ }
104
+ }
105
+
106
+ // 如果没找到空行,直接找第一个 { 或 [
107
+ if (jsonStartIndex === -1) {
108
+ for (let i = 0; i < lines.length; i++) {
109
+ const trimmed = lines[i].trim();
110
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
111
+ jsonStartIndex = i;
112
+ break;
113
+ }
114
+ }
115
+ }
116
+
117
+ if (jsonStartIndex === -1) {
118
+ // POST/PUT/DELETE/PATCH 请求没有 body,返回空对象
119
+ return {};
120
+ }
121
+
122
+ // 从 jsonStartIndex 开始提取 JSON
123
+ const jsonContent = lines.slice(jsonStartIndex).join('\n').trim();
124
+
125
+ // 验证 JSON 格式
126
+ try {
127
+ return JSON.parse(jsonContent);
128
+ } catch (e) {
129
+ console.error(`错误: 文件 ${filePath} 的 JSON 格式无效:`, e.message);
130
+ return {};
131
+ }
132
+ } else {
133
+ // Request 文件但无法识别 HTTP 方法,尝试通用提取
134
+ // 找到第一个空行后的第一个 {
135
+ let jsonStartIndex = -1;
136
+ for (let i = 0; i < lines.length; i++) {
137
+ if (lines[i].trim() === '' && i + 1 < lines.length) {
138
+ const nextLine = lines[i + 1].trim();
139
+ if (nextLine.startsWith('{') || nextLine.startsWith('[')) {
140
+ jsonStartIndex = i + 1;
141
+ break;
142
+ }
143
+ }
144
+ }
145
+
146
+ if (jsonStartIndex === -1) {
147
+ for (let i = 0; i < lines.length; i++) {
148
+ const trimmed = lines[i].trim();
149
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
150
+ jsonStartIndex = i;
151
+ break;
152
+ }
153
+ }
154
+ }
155
+
156
+ if (jsonStartIndex === -1) {
157
+ // 无法提取,返回空对象
158
+ return {};
159
+ }
160
+
161
+ const jsonContent = lines.slice(jsonStartIndex).join('\n').trim();
162
+ try {
163
+ return JSON.parse(jsonContent);
164
+ } catch (e) {
165
+ return {};
166
+ }
167
+ }
168
+ }
169
+
170
+ // 对于 Response 文件,提取 JSON body
171
+ if (isResponse) {
172
+ // 找到第一个空行后的第一个 {
173
+ let jsonStartIndex = -1;
174
+ for (let i = 0; i < lines.length; i++) {
175
+ if (lines[i].trim() === '' && i + 1 < lines.length) {
176
+ const nextLine = lines[i + 1].trim();
177
+ if (nextLine.startsWith('{') || nextLine.startsWith('[')) {
178
+ jsonStartIndex = i + 1;
179
+ break;
180
+ }
181
+ }
182
+ }
183
+
184
+ // 如果没找到空行,直接找第一个 { 或 [
185
+ if (jsonStartIndex === -1) {
186
+ for (let i = 0; i < lines.length; i++) {
187
+ const trimmed = lines[i].trim();
188
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
189
+ jsonStartIndex = i;
190
+ break;
191
+ }
192
+ }
193
+ }
194
+
195
+ if (jsonStartIndex === -1) {
196
+ console.warn(`警告: 在文件 ${filePath} 中未找到 JSON 数据`);
197
+ return null;
198
+ }
199
+
200
+ // 从 jsonStartIndex 开始提取 JSON
201
+ const jsonContent = lines.slice(jsonStartIndex).join('\n').trim();
202
+
203
+ // 验证 JSON 格式
204
+ try {
205
+ return JSON.parse(jsonContent);
206
+ } catch (e) {
207
+ console.error(`错误: 文件 ${filePath} 的 JSON 格式无效:`, e.message);
208
+ return null;
209
+ }
210
+ }
49
211
 
212
+ // 如果无法判断文件类型,使用原来的逻辑
50
213
  // 找到第一个空行后的第一个 {
51
214
  let jsonStartIndex = -1;
52
215
  for (let i = 0; i < lines.length; i++) {
53
216
  if (lines[i].trim() === '' && i + 1 < lines.length) {
54
217
  const nextLine = lines[i + 1].trim();
55
- if (nextLine.startsWith('{')) {
218
+ if (nextLine.startsWith('{') || nextLine.startsWith('[')) {
56
219
  jsonStartIndex = i + 1;
57
220
  break;
58
221
  }
59
222
  }
60
223
  }
61
224
 
62
- // 如果没找到空行,直接找第一个 {
225
+ // 如果没找到空行,直接找第一个 { 或 [
63
226
  if (jsonStartIndex === -1) {
64
227
  for (let i = 0; i < lines.length; i++) {
65
- if (lines[i].trim().startsWith('{')) {
228
+ const trimmed = lines[i].trim();
229
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
66
230
  jsonStartIndex = i;
67
231
  break;
68
232
  }
@@ -92,8 +256,12 @@ function extractJsonFromFile(filePath) {
92
256
 
93
257
  /**
94
258
  * 从文件名中提取接口信息
259
+ * 支持两种格式:
260
+ * 1. SOA2 格式: soa2_数字_接口名.txt
261
+ * 2. Proxyman 格式: [序号] Request/Response - 域名_路径.txt
95
262
  */
96
- function extractInterfaceInfo(filename) {
263
+ function extractInterfaceInfo(filePath) {
264
+ const filename = path.basename(filePath);
97
265
  const indexMatch = filename.match(INDEX_PATTERN);
98
266
  if (!indexMatch) {
99
267
  return null;
@@ -102,48 +270,166 @@ function extractInterfaceInfo(filename) {
102
270
  const index = parseInt(indexMatch[1], 10);
103
271
  const type = indexMatch[2].toLowerCase();
104
272
 
273
+ // 尝试 SOA2 格式
105
274
  const soaMatch = filename.match(SOA_EXTRACT_PATTERN);
106
- if (!soaMatch) {
107
- return null;
275
+ if (soaMatch) {
276
+ const interfaceId = soaMatch[1];
277
+ let interfaceName = soaMatch[2];
278
+
279
+ if (interfaceName.startsWith('json_')) {
280
+ interfaceName = interfaceName.substring(5);
281
+ }
282
+
283
+ return { index, type, interfaceId, interfaceName };
108
284
  }
109
285
 
110
- const interfaceId = soaMatch[1];
111
- let interfaceName = soaMatch[2];
112
-
113
- if (interfaceName.startsWith('json_')) {
114
- interfaceName = interfaceName.substring(5);
286
+ // 尝试 Proxyman 格式或其他通用格式
287
+ const proxymanMatch = filename.match(PROXYMAN_EXTRACT_PATTERN);
288
+ if (proxymanMatch) {
289
+ // 从文件名中提取路径部分(域名_路径)
290
+ const pathPart = proxymanMatch[3];
291
+
292
+ // 尝试从请求文件中读取 URL 路径来提取接口名(更准确)
293
+ let interfaceName = null;
294
+
295
+ // 对于 Request 文件,直接从文件内容提取
296
+ if (type === 'request' && fs.existsSync(filePath)) {
297
+ interfaceName = extractInterfaceNameFromRequestFile(filePath);
298
+ }
299
+
300
+ // 对于 Response 文件,尝试找到对应的 Request 文件来提取接口名
301
+ if (!interfaceName && type === 'response') {
302
+ // 查找对应的 Request 文件(相同序号)
303
+ const requestFileName = filename.replace('Response', 'Request');
304
+ const requestFilePath = path.join(path.dirname(filePath), requestFileName);
305
+ if (fs.existsSync(requestFilePath)) {
306
+ interfaceName = extractInterfaceNameFromRequestFile(requestFilePath);
307
+ }
308
+ }
309
+
310
+ // 如果从文件内容中无法提取,则从文件名中提取
311
+ if (!interfaceName) {
312
+ // 从文件名中提取路径部分(域名_路径)
313
+ // 例如: www.klook.cn_v1_experiencesrv_activity_package_service_get_activity_right_price_sources
314
+ // -> get_activity_right_price_sources
315
+ const parts = pathPart.split('_');
316
+
317
+ // 查找包含 "service" 或 "api" 的部分,取后面的部分
318
+ const serviceIndex = parts.findIndex(p => p.includes('service') || p.includes('api'));
319
+ if (serviceIndex !== -1 && serviceIndex < parts.length - 1) {
320
+ interfaceName = parts.slice(serviceIndex + 1).join('_');
321
+ } else if (parts.length > 3) {
322
+ // 如果路径较长,取最后几部分作为接口名
323
+ interfaceName = parts.slice(-3).join('_');
324
+ } else {
325
+ // 默认取最后一部分
326
+ interfaceName = parts[parts.length - 1];
327
+ }
328
+ }
329
+
330
+ return { index, type, interfaceId: null, interfaceName };
331
+ }
332
+
333
+ // 如果都不匹配,但文件名符合 [序号] Request/Response 格式,尝试通用提取
334
+ // 这可以处理其他格式,只要文件名包含 [序号] Request/Response
335
+ if (indexMatch) {
336
+ // 尝试从文件内容中提取接口名
337
+ let interfaceName = null;
338
+
339
+ if (type === 'request' && fs.existsSync(filePath)) {
340
+ interfaceName = extractInterfaceNameFromRequestFile(filePath);
341
+ } else if (type === 'response') {
342
+ // 对于 Response 文件,尝试找到对应的 Request 文件
343
+ const requestFileName = filename.replace('Response', 'Request');
344
+ const requestFilePath = path.join(path.dirname(filePath), requestFileName);
345
+ if (fs.existsSync(requestFilePath)) {
346
+ interfaceName = extractInterfaceNameFromRequestFile(requestFilePath);
347
+ }
348
+ }
349
+
350
+ // 如果无法从文件内容提取,尝试从文件名中提取(去掉序号和类型后的部分)
351
+ if (!interfaceName) {
352
+ // 移除 [序号] Request/Response - 前缀,取剩余部分作为接口名
353
+ const remaining = filename
354
+ .replace(INDEX_PATTERN, '')
355
+ .replace(/^\s*-\s*/, '')
356
+ .replace(/\.txt$/, '')
357
+ .trim();
358
+
359
+ if (remaining) {
360
+ // 如果剩余部分包含路径,提取最后一部分
361
+ const parts = remaining.split(/[_\-\/]/).filter(p => p);
362
+ if (parts.length > 0) {
363
+ interfaceName = parts[parts.length - 1];
364
+ }
365
+ }
366
+ }
367
+
368
+ if (interfaceName) {
369
+ return { index, type, interfaceId: null, interfaceName };
370
+ }
115
371
  }
116
372
 
117
- return { index, type, interfaceId, interfaceName };
373
+ return null;
374
+ }
375
+
376
+ /**
377
+ * 从请求文件中提取接口名
378
+ */
379
+ function extractInterfaceNameFromRequestFile(filePath) {
380
+ try {
381
+ const content = fs.readFileSync(filePath, 'utf-8');
382
+ const firstLine = content.split('\n')[0];
383
+ // 匹配 HTTP 请求行,例如: GET /v1/experiencesrv/activity/package_service/get_activity_right_price_sources?...
384
+ const urlMatch = firstLine.match(/^(GET|POST|PUT|DELETE|PATCH)\s+([^\s?]+)/);
385
+ if (urlMatch) {
386
+ const urlPath = urlMatch[2];
387
+ // 从路径中提取接口名:取最后一个斜杠后的部分
388
+ const pathParts = urlPath.split('/').filter(p => p);
389
+ if (pathParts.length > 0) {
390
+ return pathParts[pathParts.length - 1];
391
+ }
392
+ }
393
+ } catch (e) {
394
+ // 如果读取失败,返回 null
395
+ }
396
+ return null;
118
397
  }
119
398
 
120
399
  /**
121
- * 扫描 Raw 文件夹,提取所有 SOA2 接口文件
400
+ * 扫描 Raw 文件夹,提取所有接口文件
401
+ * 支持多种格式:
402
+ * 1. SOA2 格式: soa2_数字_接口名.txt(必须包含 [序号] Request/Response)
403
+ * 2. Proxyman 标准格式: [序号] Request/Response - 域名_路径.txt
404
+ * 3. 通用格式: [序号] Request/Response - 任意内容.txt(会尝试从文件内容提取接口名)
122
405
  */
123
406
  function scanRawFolder(rawFolderPath) {
124
407
  const files = fs.readdirSync(rawFolderPath);
125
- const soaFiles = [];
408
+ const interfaceFiles = [];
126
409
 
127
410
  for (const file of files) {
128
- if (!SOA_FILE_PATTERN.test(file)) {
411
+ // 检查文件名是否包含 [序号] Request/Response 格式
412
+ // 这是最通用的格式要求,可以匹配多种文件命名方式
413
+ if (!INDEX_PATTERN.test(file)) {
129
414
  continue;
130
415
  }
131
416
 
132
- const info = extractInterfaceInfo(file);
417
+ const filePath = path.join(rawFolderPath, file);
418
+ const info = extractInterfaceInfo(filePath);
133
419
  if (!info) {
134
- console.warn(`警告: 无法解析文件 ${file}`);
420
+ console.warn(`警告: 无法解析文件 ${file}(文件名需包含 [序号] Request/Response 格式)`);
135
421
  continue;
136
422
  }
137
423
 
138
- soaFiles.push({
424
+ interfaceFiles.push({
139
425
  index: info.index,
140
426
  type: info.type,
141
427
  interfaceName: info.interfaceName,
142
- filePath: path.join(rawFolderPath, file),
428
+ filePath: filePath,
143
429
  });
144
430
  }
145
431
 
146
- return soaFiles;
432
+ return interfaceFiles;
147
433
  }
148
434
 
149
435
  /**
@@ -641,10 +927,14 @@ async function executeMigration(scenarioName, targetFolder, rawFolderPath, rl =
641
927
 
642
928
  console.log('扫描 Raw 文件夹...');
643
929
  const files = scanRawFolder(rawFolderPath);
644
- console.log(`找到 ${files.length} 个 SOA2 接口文件`);
930
+ console.log(`找到 ${files.length} 个接口文件`);
645
931
 
646
932
  if (files.length === 0) {
647
- console.error('错误: 未找到任何 SOA2 接口文件');
933
+ console.error('错误: 未找到任何接口文件');
934
+ console.error('提示: 文件名需包含 [序号] Request/Response 格式,例如:');
935
+ console.error(' - [1] Request - api.example.com_get_user_info.txt');
936
+ console.error(' - [1] Response - api.example.com_get_user_info.txt');
937
+ console.error(' - soa2_123_getUserInfo.txt(需包含 [序号] Request/Response)');
648
938
  process.exit(1);
649
939
  }
650
940
 
@@ -705,8 +995,16 @@ async function executeMigration(scenarioName, targetFolder, rawFolderPath, rl =
705
995
  const requestData = extractJsonFromFile(filePair.request);
706
996
  const responseData = extractJsonFromFile(filePair.response);
707
997
 
708
- if (!requestData || !responseData) {
998
+ // 对于 GET 请求,requestData 可能是空对象 {},这是有效的
999
+ // 只有 responseData 是必需的
1000
+ if (requestData === null || responseData === null) {
709
1001
  console.warn(`警告: 序号 ${index} 的数据提取失败,跳过`);
1002
+ if (requestData === null) {
1003
+ console.warn(` 请求数据提取失败: ${filePair.request}`);
1004
+ }
1005
+ if (responseData === null) {
1006
+ console.warn(` 响应数据提取失败: ${filePair.response}`);
1007
+ }
710
1008
  continue;
711
1009
  }
712
1010
 
@@ -39,10 +39,10 @@ function findProjectRoot() {
39
39
  function isRawFolder(pathStr) {
40
40
  if (!pathStr) return false;
41
41
 
42
- // 检查是否是绝对路径或相对路径
42
+ // 使用 path.resolve 规范化路径(处理 ./ 和 ../ 等相对路径)
43
43
  const fullPath = path.isAbsolute(pathStr)
44
- ? pathStr
45
- : path.join(process.cwd(), pathStr);
44
+ ? path.resolve(pathStr)
45
+ : path.resolve(process.cwd(), pathStr);
46
46
 
47
47
  // 检查路径是否存在且是目录
48
48
  if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isDirectory()) {
@@ -74,79 +74,146 @@ function findDefaultRawFolder() {
74
74
  return null;
75
75
  }
76
76
 
77
+ /**
78
+ * 检查 Raw 文件夹下是否已有生成的文件
79
+ */
80
+ function checkRawFolderFiles(rawFolderPath) {
81
+ const baseDataPath = path.join(rawFolderPath, 'base-data');
82
+ const mocksPath = path.join(rawFolderPath, 'mocks');
83
+ const configPath = path.join(rawFolderPath, 'proxy.config.json');
84
+
85
+ return {
86
+ hasBaseData: fs.existsSync(baseDataPath) && fs.statSync(baseDataPath).isDirectory(),
87
+ hasMocks: fs.existsSync(mocksPath) && fs.statSync(mocksPath).isDirectory(),
88
+ hasConfig: fs.existsSync(configPath),
89
+ allExist: fs.existsSync(baseDataPath) && fs.existsSync(mocksPath) && fs.existsSync(configPath)
90
+ };
91
+ }
92
+
77
93
  /**
78
94
  * start 命令主函数
79
95
  */
80
96
  async function startCommand(mockIdOrPath, options) {
81
97
  let finalMockId = mockIdOrPath;
98
+ let workDir = process.cwd(); // 默认工作目录
82
99
 
83
100
  // 检查是否是 Raw 文件夹路径
84
101
  if (isRawFolder(mockIdOrPath)) {
85
- console.log('🔍 检测到 Raw 文件夹,将自动转换后启动...');
86
-
102
+ // 规范化路径,确保正确处理相对路径(包括 ./ 开头的路径)
87
103
  const rawFolderPath = path.isAbsolute(mockIdOrPath)
88
- ? mockIdOrPath
89
- : path.join(process.cwd(), mockIdOrPath);
104
+ ? path.resolve(mockIdOrPath)
105
+ : path.resolve(process.cwd(), mockIdOrPath);
90
106
 
91
- // 如果设置了 --no-migrate,直接报错
92
- if (options.noMigrate) {
93
- console.error('错误: 指定了 --no-migrate,但提供的是 Raw 文件夹路径');
94
- console.error('提示: 请先使用 jacky-proxy migrate 命令转换,或移除 --no-migrate 参数');
95
- process.exit(1);
96
- }
97
-
98
- // 执行迁移
99
- const migrateCommand = require('./migrate');
100
- const scenarioName = options.scenario || '场景1';
101
- const targetFolder = options.target || 'mocks/test-folder';
102
- const ignoreInterfaces = options.ignore
103
- ? options.ignore.split(',').map(s => s.trim()).filter(s => s)
104
- : [];
105
-
106
- console.log('');
107
- console.log('═══════════════════════════════════════════════════════');
108
- console.log(' 自动迁移 Raw 文件夹');
109
- console.log('═══════════════════════════════════════════════════════');
110
- console.log(`场景名称: ${scenarioName}`);
111
- console.log(`目标文件夹: ${targetFolder}`);
112
- console.log(`Raw 文件夹: ${rawFolderPath}`);
113
- if (ignoreInterfaces.length > 0) {
114
- console.log(`忽略接口: ${ignoreInterfaces.join(', ')}`);
115
- }
116
- console.log('');
117
-
118
- // 调用迁移命令(非交互式)
119
- const migrateOptions = {
120
- scenario: scenarioName,
121
- target: targetFolder,
122
- raw: rawFolderPath,
123
- ignore: options.ignore || '',
124
- interactive: false
125
- };
107
+ // 检查 Raw 文件夹下是否已有生成的文件
108
+ const filesCheck = checkRawFolderFiles(rawFolderPath);
126
109
 
127
- try {
128
- await migrateCommand(migrateOptions);
110
+ if (filesCheck.allExist) {
111
+ // 文件已存在,直接启动
112
+ console.log('🔍 检测到 Raw 文件夹,且已存在生成的文件,直接启动...');
113
+ console.log(` base-data: ${filesCheck.hasBaseData ? '✓' : '✗'}`);
114
+ console.log(` mocks: ${filesCheck.hasMocks ? '✓' : '✗'}`);
115
+ console.log(` proxy.config.json: ${filesCheck.hasConfig ? '✓' : '✗'}`);
116
+ console.log('');
129
117
 
130
- // 从配置文件中读取新创建的 mock-id(配置文件在工作目录)
131
- const workDir = process.cwd();
132
- const configPath = path.join(workDir, options.config || 'proxy.config.json');
118
+ // Raw 文件夹下的配置文件中读取 mock-id
119
+ const configPath = path.join(rawFolderPath, options.config || 'proxy.config.json');
133
120
  if (fs.existsSync(configPath)) {
134
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
135
- const folder = config.folders.list.find(f => f.path === targetFolder);
136
- if (folder) {
137
- finalMockId = String(folder.id);
138
- console.log(`\n✅ 迁移完成,将使用场景 ID: ${finalMockId}`);
139
- } else {
140
- console.warn('警告: 无法在配置文件中找到新创建的场景,使用默认 ID: 1');
121
+ try {
122
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
123
+ // 获取第一个场景的 ID,或者使用默认值
124
+ if (config.folders && config.folders.list && config.folders.list.length > 0) {
125
+ finalMockId = String(config.folders.list[0].id);
126
+ console.log(`✅ 从配置文件读取场景 ID: ${finalMockId}`);
127
+ } else {
128
+ console.warn('警告: 配置文件中没有场景配置,使用默认 ID: 1');
129
+ finalMockId = '1';
130
+ }
131
+ } catch (error) {
132
+ console.warn(`警告: 读取配置文件失败: ${error.message},使用默认 ID: 1`);
141
133
  finalMockId = '1';
142
134
  }
143
135
  } else {
144
136
  console.warn('警告: 配置文件不存在,使用默认 ID: 1');
145
137
  finalMockId = '1';
146
138
  }
147
- } catch (error) {
148
- console.error('迁移失败:', error.message);
149
- process.exit(1);
139
+
140
+ // 设置工作目录为 Raw 文件夹
141
+ workDir = rawFolderPath;
142
+ } else {
143
+ // 文件不存在,需要生成
144
+ console.log('🔍 检测到 Raw 文件夹,将自动转换后启动...');
145
+
146
+ // 如果设置了 --no-migrate,直接报错
147
+ if (options.noMigrate) {
148
+ console.error('错误: 指定了 --no-migrate,但 Raw 文件夹下还没有生成的文件');
149
+ console.error('提示: 请先使用 jacky-proxy migrate 命令转换,或移除 --no-migrate 参数');
150
+ process.exit(1);
151
+ }
152
+
153
+ // 保存原始工作目录
154
+ const originalCwd = process.cwd();
155
+
156
+ try {
157
+ // 切换到 Raw 文件夹目录,这样 migrate 会在 Raw 文件夹下创建文件
158
+ process.chdir(rawFolderPath);
159
+
160
+ // 执行迁移
161
+ const migrateCommand = require('./migrate');
162
+ const scenarioName = options.scenario || '默认场景';
163
+ const targetFolder = options.target || 'mocks/test-folder';
164
+ const ignoreInterfaces = options.ignore
165
+ ? options.ignore.split(',').map(s => s.trim()).filter(s => s)
166
+ : [];
167
+
168
+ console.log('');
169
+ console.log('═══════════════════════════════════════════════════════');
170
+ console.log(' 自动迁移 Raw 文件夹');
171
+ console.log('═══════════════════════════════════════════════════════');
172
+ console.log(`场景名称: ${scenarioName}`);
173
+ console.log(`目标文件夹: ${targetFolder}`);
174
+ console.log(`Raw 文件夹: ${rawFolderPath}`);
175
+ if (ignoreInterfaces.length > 0) {
176
+ console.log(`忽略接口: ${ignoreInterfaces.join(', ')}`);
177
+ }
178
+ console.log('');
179
+
180
+ // 调用迁移命令(非交互式)
181
+ // 注意:raw 参数应该是相对于当前工作目录的路径,或者使用绝对路径
182
+ const migrateOptions = {
183
+ scenario: scenarioName,
184
+ target: targetFolder,
185
+ raw: rawFolderPath, // 使用绝对路径
186
+ ignore: options.ignore || '',
187
+ interactive: false
188
+ };
189
+
190
+ await migrateCommand(migrateOptions);
191
+
192
+ // 从配置文件中读取新创建的 mock-id(配置文件在 Raw 文件夹下)
193
+ const configPath = path.join(rawFolderPath, options.config || 'proxy.config.json');
194
+ if (fs.existsSync(configPath)) {
195
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
196
+ const folder = config.folders.list.find(f => f.path === targetFolder);
197
+ if (folder) {
198
+ finalMockId = String(folder.id);
199
+ console.log(`\n✅ 迁移完成,将使用场景 ID: ${finalMockId}`);
200
+ } else {
201
+ console.warn('警告: 无法在配置文件中找到新创建的场景,使用默认 ID: 1');
202
+ finalMockId = '1';
203
+ }
204
+ } else {
205
+ console.warn('警告: 配置文件不存在,使用默认 ID: 1');
206
+ finalMockId = '1';
207
+ }
208
+
209
+ // 设置工作目录为 Raw 文件夹
210
+ workDir = rawFolderPath;
211
+ } catch (error) {
212
+ // 恢复原始工作目录
213
+ process.chdir(originalCwd);
214
+ console.error('迁移失败:', error.message);
215
+ process.exit(1);
216
+ }
150
217
  }
151
218
  } else {
152
219
  // 检查是否是数字(mock-id)
@@ -158,12 +225,22 @@ async function startCommand(mockIdOrPath, options) {
158
225
  }
159
226
 
160
227
  // 设置环境变量
161
- const workDir = process.cwd(); // 保存当前工作目录
162
228
  process.env.MOCK_ID = finalMockId;
163
229
  process.env.PORT = options.port || '5001';
164
- process.env.WORK_DIR = workDir; // 设置工作目录环境变量
165
- process.env.CONFIG_PATH = path.join(workDir, options.config || 'proxy.config.json');
230
+ // 确保 workDir 是绝对路径
231
+ const absoluteWorkDir = path.isAbsolute(workDir) ? workDir : path.resolve(workDir);
232
+ process.env.WORK_DIR = absoluteWorkDir; // 设置工作目录环境变量(可能是 Raw 文件夹)
233
+ // 确保 CONFIG_PATH 是绝对路径
234
+ const configFileName = options.config || 'proxy.config.json';
235
+ const configPath = path.isAbsolute(configFileName)
236
+ ? configFileName
237
+ : path.join(absoluteWorkDir, configFileName);
238
+ process.env.CONFIG_PATH = path.resolve(configPath); // 确保是绝对路径
166
239
  process.env.DEBUG = options.debug ? 'true' : 'false'; // 设置 Debug 模式
240
+
241
+ // 调试信息
242
+ console.log(`📁 工作目录: ${process.env.WORK_DIR}`);
243
+ console.log(`📄 配置文件: ${process.env.CONFIG_PATH}`);
167
244
 
168
245
  console.log('');
169
246
  console.log('═══════════════════════════════════════════════════════');
@@ -309,6 +309,39 @@ export function formatDiffInfo(diffs: DiffInfo[]): string {
309
309
  return sections.join('\n');
310
310
  }
311
311
 
312
+ /**
313
+ * 规范化 query 参数中的数组值
314
+ * Express 会将同名 query 参数解析为数组(如 ?preview=&preview=0 -> ["", "0"])
315
+ * 为了匹配,需要将数组转换为字符串:取最后一个非空值,或如果都是空字符串则取最后一个值
316
+ */
317
+ function normalizeQueryArrayValues(obj: any): any {
318
+ if (!obj || typeof obj !== 'object') {
319
+ return obj;
320
+ }
321
+
322
+ if (Array.isArray(obj)) {
323
+ return obj.map(item => normalizeQueryArrayValues(item));
324
+ }
325
+
326
+ const result: any = {};
327
+ for (const key in obj) {
328
+ const value = obj[key];
329
+ if (Array.isArray(value)) {
330
+ // 如果是数组,取最后一个非空值,或如果都是空字符串则取最后一个值
331
+ const nonEmptyValues = value.filter(v => v !== '' && v !== null && v !== undefined);
332
+ result[key] = nonEmptyValues.length > 0
333
+ ? nonEmptyValues[nonEmptyValues.length - 1]
334
+ : value[value.length - 1];
335
+ } else if (typeof value === 'object' && value !== null) {
336
+ // 递归处理嵌套对象
337
+ result[key] = normalizeQueryArrayValues(value);
338
+ } else {
339
+ result[key] = value;
340
+ }
341
+ }
342
+ return result;
343
+ }
344
+
312
345
  /**
313
346
  * 处理请求对象(应用忽略、排序等规则)
314
347
  */
@@ -354,6 +387,34 @@ function processRequest(
354
387
  processed.body = body;
355
388
  }
356
389
 
390
+ // 处理 query 参数(GET 请求通常使用 query 参数)
391
+ // 如果请求没有 body,query 参数也会参与匹配
392
+ if (processed.query && typeof processed.query === 'object') {
393
+ let query = { ...processed.query };
394
+
395
+ // 规范化数组值(Express 会将同名 query 参数解析为数组)
396
+ query = normalizeQueryArrayValues(query);
397
+
398
+ // 深度忽略属性
399
+ if (deepIgnore) {
400
+ query = deepIgnoreProps(query, ignoreProps);
401
+ } else {
402
+ // 浅层忽略属性
403
+ for (const prop of ignoreProps) {
404
+ if (!essentialProps.includes(prop)) {
405
+ delete query[prop];
406
+ }
407
+ }
408
+ }
409
+
410
+ // 排序数组属性
411
+ if (sortProps.length > 0) {
412
+ query = sortArrayProps(query, sortProps);
413
+ }
414
+
415
+ processed.query = query;
416
+ }
417
+
357
418
  return processed;
358
419
  }
359
420
 
@@ -399,6 +460,15 @@ export function matchResponse(
399
460
 
400
461
  const finalSortProps = interfaceConfig?.sortProps || sortProps;
401
462
 
463
+ // 先提取原始请求数据(在过滤之前),用于后续比较
464
+ // 优先级:body > query
465
+ let originalRequestData: any;
466
+ if (request.body !== undefined && request.body !== null && request.body !== '') {
467
+ originalRequestData = request.body;
468
+ } else if (request.query && typeof request.query === 'object' && Object.keys(request.query).length > 0) {
469
+ originalRequestData = request.query;
470
+ }
471
+
402
472
  // 处理真实请求
403
473
  let processedRequest = processRequest(
404
474
  request,
@@ -418,10 +488,50 @@ export function matchResponse(
418
488
  }
419
489
 
420
490
  // 提取实际请求的 body 部分用于比较
421
- // 如果请求有 body 字段,则使用 body;否则使用整个请求对象
422
- let requestBodyForCompare = processedRequest.body !== undefined
423
- ? processedRequest.body
424
- : processedRequest;
491
+ // 优先级:body > query > 整个请求对象
492
+ // GET 请求通常使用 query 参数,POST 请求使用 body
493
+ let requestBodyForCompare: any;
494
+
495
+ // 检查 body 是否存在且不为空
496
+ const hasBody = processedRequest.body !== undefined &&
497
+ processedRequest.body !== null &&
498
+ processedRequest.body !== '' &&
499
+ (typeof processedRequest.body !== 'object' || Object.keys(processedRequest.body).length > 0);
500
+
501
+ // 检查 query 是否存在且不为空
502
+ const hasQuery = processedRequest.query &&
503
+ typeof processedRequest.query === 'object' &&
504
+ Object.keys(processedRequest.query).length > 0;
505
+
506
+ if (hasBody) {
507
+ requestBodyForCompare = processedRequest.body;
508
+ } else if (hasQuery) {
509
+ // GET 请求:使用 query 参数(已经在 processRequest 中规范化了数组值)
510
+ requestBodyForCompare = processedRequest.query;
511
+ } else if (originalRequestData) {
512
+ // 如果过滤后都为空,使用原始数据(但需要应用过滤规则)
513
+ // 先规范化数组值
514
+ let normalizedData = normalizeQueryArrayValues(originalRequestData);
515
+
516
+ // 对原始数据应用过滤规则
517
+ if (finalDeepIgnore) {
518
+ requestBodyForCompare = deepIgnoreProps(normalizedData, ignoreProps);
519
+ } else {
520
+ requestBodyForCompare = { ...normalizedData };
521
+ for (const prop of ignoreProps) {
522
+ if (!essentialProps.includes(prop)) {
523
+ delete requestBodyForCompare[prop];
524
+ }
525
+ }
526
+ }
527
+ // 应用排序
528
+ if (finalSortProps.length > 0) {
529
+ requestBodyForCompare = sortArrayProps(requestBodyForCompare, finalSortProps);
530
+ }
531
+ } else {
532
+ // 最后兜底:使用处理后的请求对象(可能包含其他字段)
533
+ requestBodyForCompare = processedRequest;
534
+ }
425
535
 
426
536
  // 检查必需属性
427
537
  if (needContainProps.length > 0) {
@@ -438,6 +548,9 @@ export function matchResponse(
438
548
  }
439
549
 
440
550
  // 遍历 Mock 请求列表,找到匹配的请求
551
+ // 同时保存处理后的 mock 请求,用于错误信息显示
552
+ const processedMockRequests: any[] = [];
553
+
441
554
  for (let i = 0; i < requestList.length; i++) {
442
555
  const mockRequest = requestList[i];
443
556
 
@@ -455,9 +568,46 @@ export function matchResponse(
455
568
  );
456
569
 
457
570
  // 提取 mock 请求的 body 部分用于比较
458
- let mockBodyForCompare = processedMockRequest.body !== undefined
459
- ? processedMockRequest.body
460
- : processedMockRequest;
571
+ // 优先级:body > query > 整个请求对象(与真实请求保持一致)
572
+ let mockBodyForCompare: any;
573
+
574
+ // 检查 body 是否存在且不为空
575
+ const hasMockBody = processedMockRequest.body !== undefined &&
576
+ processedMockRequest.body !== null &&
577
+ processedMockRequest.body !== '' &&
578
+ (typeof processedMockRequest.body !== 'object' || Object.keys(processedMockRequest.body).length > 0);
579
+
580
+ // 检查 query 是否存在且不为空
581
+ const hasMockQuery = processedMockRequest.query &&
582
+ typeof processedMockRequest.query === 'object' &&
583
+ Object.keys(processedMockRequest.query).length > 0;
584
+
585
+ if (hasMockBody) {
586
+ mockBodyForCompare = processedMockRequest.body;
587
+ } else if (hasMockQuery) {
588
+ // GET 请求:使用 query 参数
589
+ mockBodyForCompare = processedMockRequest.query;
590
+ } else {
591
+ // 兜底:Mock 请求通常是纯对象格式(没有 body/query 字段)
592
+ // 如果 processedRequest.body 存在但为空对象,说明原始 mockRequest 被包装成了 { body: mockRequest }
593
+ // 此时应该使用原始 mockRequest(但需要应用相同的过滤规则)
594
+ // 由于 mockRequest 是纯对象,它已经被包装为 { body: mockRequest } 并处理过了
595
+ // 如果 body 是空对象,说明所有属性都被过滤了,但原始 mockRequest 可能还有数据
596
+ // 这里需要重新处理原始 mockRequest,应用相同的过滤规则
597
+ if (mockRequest && typeof mockRequest === 'object' && !mockRequest.body && !mockRequest.query) {
598
+ // 纯对象格式,需要重新处理(应用过滤规则)
599
+ // 创建一个临时对象来应用过滤规则
600
+ const tempRequest = { body: mockRequest };
601
+ const tempProcessed = processRequest(tempRequest, ignoreProps, essentialProps, finalDeepIgnore, finalSortProps);
602
+ mockBodyForCompare = tempProcessed.body || mockRequest;
603
+ } else {
604
+ // 使用处理后的请求对象
605
+ mockBodyForCompare = processedMockRequest;
606
+ }
607
+ }
608
+
609
+ // 保存处理后的 mock 请求(已去除忽略的属性),用于错误信息显示
610
+ processedMockRequests.push(mockBodyForCompare);
461
611
 
462
612
  // 使用 lodash 的 isEqualWith 进行深度比较(只比较 body 部分)
463
613
  const isMatch = _.isEqualWith(
@@ -477,12 +627,13 @@ export function matchResponse(
477
627
 
478
628
  // 没有找到匹配的请求,返回错误信息
479
629
  // 注意:详细差异信息不再打印到控制台,避免终端输出过多信息
630
+ // 错误信息中的 request 和 mockRequests 都已去除忽略的属性,便于对比
480
631
 
481
632
  return {
482
633
  error: true,
483
634
  message: '未找到匹配的 Mock 请求',
484
635
  request: JSON.stringify(requestBodyForCompare),
485
- mockRequests: JSON.stringify(requestList.map((req, index) => ({
636
+ mockRequests: JSON.stringify(processedMockRequests.map((req, index) => ({
486
637
  index,
487
638
  request: req
488
639
  })))