whistle.mockbubu 2.2.7 → 2.2.9-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/index.js +1 -1
- package/lib/config/rule-collector.js +1 -1
- package/lib/core/res-write/index.js +151 -0
- package/lib/core/res-write/response-rewriter.js +258 -0
- package/lib/core/rulesServer.js +15 -4
- package/lib/core/server-entry/capture-handler.js +4 -186
- package/lib/core/server-entry/response-handler.js +9 -348
- package/lib/core/server-entry/server.js +5 -13
- package/lib/core/shared/buffer.js +23 -0
- package/lib/core/shared/capture.js +203 -0
- package/lib/core/shared/concurrent.js +72 -0
- package/lib/core/shared/initializers.js +93 -0
- package/lib/core/shared/mock-response.js +306 -0
- package/lib/utils/utils.js +1 -1
- package/package.json +1 -1
- package/public/js/app.js +236 -32
- package/public/js/app.js.map +1 -1
- package/rules.txt +10 -1
|
@@ -1,298 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 文件名: response-handler.js
|
|
3
|
-
* 功能: Mock
|
|
3
|
+
* 功能: Mock 响应返回逻辑(薄封装,实现已抽取到 shared)
|
|
4
4
|
* 职责:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* - 处理版本切换
|
|
8
|
-
* - 处理响应头
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const { MATCH_MODE_VALUE } = require('../../config/const')
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* 返回 Mock 响应数据
|
|
15
|
-
* @param {Object} res - Whistle 响应对象
|
|
16
|
-
* @param {string} sourceData - 源数据 (file.json 的字符串内容)
|
|
17
|
-
* @param {Object} options - 配置选项
|
|
18
|
-
* @param {string} options.mockVersion - Mock 版本名称 (null 表示使用源数据)
|
|
19
|
-
* @param {Object} options.groupManager - 组管理器实例
|
|
20
|
-
* @param {string} options.currentGroupId - 当前组ID
|
|
21
|
-
* @param {string} options.filename - 文件名
|
|
22
|
-
* @param {Object} options.originalReq - Whistle 原始请求对象(用于条件匹配)
|
|
23
|
-
*/
|
|
24
|
-
async function sendMockResponse(res, sourceData, options = {}) {
|
|
25
|
-
const { mockVersion, groupManager, currentGroupId, filename, originalReq } = options
|
|
26
|
-
|
|
27
|
-
// 解析源数据 (storageAdapter.readFile 返回的是 session 对象的 JSON 字符串)
|
|
28
|
-
const session = JSON.parse(sourceData)
|
|
29
|
-
const sourceResponse = session?.res
|
|
30
|
-
const { statusCode, statusMessage } = sourceResponse
|
|
31
|
-
|
|
32
|
-
// 浅拷贝 headers,避免直接修改 session 对象(若未来引入缓存会产生副作用)
|
|
33
|
-
const headers = { ...sourceResponse.headers }
|
|
34
|
-
headers['from-mockbubu-source'] = 'true'
|
|
35
|
-
headers['x-mockbubu-mocked'] = 'true'
|
|
36
|
-
delete headers['content-encoding']
|
|
37
|
-
delete headers['content-length']
|
|
38
|
-
|
|
39
|
-
// 【条件匹配模式】
|
|
40
|
-
if (mockVersion === MATCH_MODE_VALUE) {
|
|
41
|
-
const matchResult = await matchByConditions({
|
|
42
|
-
groupManager,
|
|
43
|
-
currentGroupId,
|
|
44
|
-
filename,
|
|
45
|
-
originalReq,
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
headers['x-mockbubu-match-mode'] = 'match'
|
|
49
|
-
|
|
50
|
-
if (matchResult.matched) {
|
|
51
|
-
// header 值必须为 ASCII,对中文规则名做 encodeURIComponent 编码
|
|
52
|
-
headers['x-mockbubu-match-rule'] = encodeURIComponent(matchResult.ruleName || '')
|
|
53
|
-
headers['x-mockbubu-match-params'] = encodeURIComponent(matchResult.paramsSummary || '')
|
|
54
|
-
res.writeHead(statusCode, statusMessage, headers)
|
|
55
|
-
res.end(JSON.stringify(matchResult.content ?? {}))
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// 全不命中 → 通知调用方透传请求,不返回 mock 数据
|
|
60
|
-
return { action: 'passthrough' }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// 【版本直选模式】先确认版本数据是否存在,再统一调用 writeHead
|
|
64
|
-
if (mockVersion && groupManager && currentGroupId && filename) {
|
|
65
|
-
const versionData = await groupManager.getGroupVersionContent(currentGroupId, filename, mockVersion)
|
|
66
|
-
if (versionData) {
|
|
67
|
-
res.writeHead(statusCode, statusMessage, headers)
|
|
68
|
-
res.end(JSON.stringify(versionData))
|
|
69
|
-
return
|
|
70
|
-
}
|
|
71
|
-
// 版本不存在时降级到源数据
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
res.writeHead(statusCode, statusMessage, headers)
|
|
75
|
-
res.end(sourceResponse.body)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* 条件匹配核心逻辑
|
|
80
|
-
*
|
|
81
|
-
* 匹配规则:
|
|
82
|
-
* 1. 按条件数量降序(条件越多优先级越高)
|
|
83
|
-
* 2. 条件数量相同时按 priority 字段升序(数字越小越优先)
|
|
84
|
-
* 3. 空条件规则作为兜底,最后匹配
|
|
85
|
-
* 4. 全不命中时返回 matched: false,由调用方降级到 source
|
|
5
|
+
* - 保留 checkShouldCapture(仅 server 钩子使用)
|
|
6
|
+
* - sendMockResponse / matchByConditions / handleConcurrentCapture 重新导出自 shared
|
|
86
7
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* @param {string} params.filename - 文件名(URL)
|
|
91
|
-
* @param {Object} params.originalReq - Whistle 原始请求对象
|
|
92
|
-
* @returns {{ matched: boolean, content?, ruleName?, paramsSummary? }}
|
|
8
|
+
* 实现位置:
|
|
9
|
+
* - lib/core/shared/mock-response.js: sendMockResponse + matchByConditions + 辅助函数
|
|
10
|
+
* - lib/core/shared/concurrent.js: handleConcurrentCapture
|
|
93
11
|
*/
|
|
94
|
-
async function matchByConditions(params) {
|
|
95
|
-
const { groupManager, currentGroupId, filename, originalReq } = params
|
|
96
12
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return { matched: false }
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// 提取当前请求参数
|
|
104
|
-
const queryParams = extractQueryParams(originalReq)
|
|
105
|
-
const payloadParams = extractPayloadParams(originalReq)
|
|
106
|
-
|
|
107
|
-
// 按条件数量降序 + priority 升序排序(条件越多越具体,优先匹配)
|
|
108
|
-
// storage 层已按 priority 排序,这里再按 conditions.length 降序叠加
|
|
109
|
-
const sorted = [...matchVersions].sort((a, b) => {
|
|
110
|
-
const condDiff = (b.conditions?.length || 0) - (a.conditions?.length || 0)
|
|
111
|
-
if (condDiff !== 0) return condDiff
|
|
112
|
-
return (a.priority || 0) - (b.priority || 0)
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
let fallbackRule = null
|
|
116
|
-
|
|
117
|
-
// 逐条匹配(AND 关系:所有条件必须同时满足)
|
|
118
|
-
for (const rule of sorted) {
|
|
119
|
-
if (!rule.conditions || rule.conditions.length === 0) {
|
|
120
|
-
// 空条件规则作为兜底,记录备用
|
|
121
|
-
if (!fallbackRule) fallbackRule = rule
|
|
122
|
-
continue
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const allMatch = rule.conditions.every((cond) => {
|
|
126
|
-
const sourceParams = cond.source === 'query' ? queryParams : payloadParams
|
|
127
|
-
return matchCondition(sourceParams, cond)
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
if (allMatch) {
|
|
131
|
-
return {
|
|
132
|
-
matched: true,
|
|
133
|
-
content: rule.content ?? {},
|
|
134
|
-
ruleName: rule.name,
|
|
135
|
-
paramsSummary: rule.conditions
|
|
136
|
-
.map(c => `${c.source}.${c.key}[${c.operator || 'eq'}]${c.value ?? ''}`)
|
|
137
|
-
.join(', '),
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 尝试兜底规则(空条件规则)
|
|
143
|
-
if (fallbackRule) {
|
|
144
|
-
return {
|
|
145
|
-
matched: true,
|
|
146
|
-
content: fallbackRule.content ?? {},
|
|
147
|
-
ruleName: fallbackRule.name,
|
|
148
|
-
paramsSummary: '(default)',
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return { matched: false }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* 单条件匹配:根据 operator 执行对应的比较
|
|
157
|
-
* operator 缺省时向后兼容旧数据(等同 eq)
|
|
158
|
-
*
|
|
159
|
-
* @param {Object} sourceParams - 请求参数 key-value 对
|
|
160
|
-
* @param {{ key: string, operator?: string, value?: string }} cond
|
|
161
|
-
* @returns {boolean}
|
|
162
|
-
*/
|
|
163
|
-
function matchCondition(sourceParams, cond) {
|
|
164
|
-
const { key, value = '' } = cond
|
|
165
|
-
const operator = cond.operator || 'eq'
|
|
166
|
-
|
|
167
|
-
// 条件不完整时跳过(视为满足),避免空值产生意外的匹配行为
|
|
168
|
-
if (!key) return true
|
|
169
|
-
const VALUELESS_OPS = new Set(['exists', 'not_exists'])
|
|
170
|
-
if (!VALUELESS_OPS.has(operator) && !value) return true
|
|
171
|
-
|
|
172
|
-
const exists = Object.prototype.hasOwnProperty.call(sourceParams, key)
|
|
173
|
-
const actual = exists ? String(sourceParams[key]) : undefined
|
|
174
|
-
const expected = String(value)
|
|
175
|
-
|
|
176
|
-
switch (operator) {
|
|
177
|
-
case 'eq':
|
|
178
|
-
return actual === expected
|
|
179
|
-
case 'neq':
|
|
180
|
-
return actual !== expected
|
|
181
|
-
case 'contains':
|
|
182
|
-
return actual !== undefined && actual.includes(expected)
|
|
183
|
-
case 'regex': {
|
|
184
|
-
if (!actual) return false
|
|
185
|
-
try {
|
|
186
|
-
return new RegExp(expected).test(actual)
|
|
187
|
-
} catch {
|
|
188
|
-
return false
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
case 'exists':
|
|
192
|
-
return exists
|
|
193
|
-
case 'not_exists':
|
|
194
|
-
return !exists
|
|
195
|
-
default:
|
|
196
|
-
return actual === expected
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* 从原始请求中提取 query 参数
|
|
202
|
-
* 来源:req.originalReq.url,fallback 到 x-whistle-full-url header
|
|
203
|
-
*
|
|
204
|
-
* @param {Object} originalReq - Whistle 原始请求对象
|
|
205
|
-
* @returns {Object} query 参数 key-value 对
|
|
206
|
-
*/
|
|
207
|
-
function extractQueryParams(originalReq) {
|
|
208
|
-
if (!originalReq) return {}
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
// 某些 Whistle 代理场景(tunnel/HTTP2)下 originalReq.url 可能不含 query,
|
|
212
|
-
// 完整 URL 会在 x-whistle-full-url header 里(URL 编码格式)
|
|
213
|
-
const fullUrlHeader = originalReq.headers?.['x-whistle-full-url']
|
|
214
|
-
const rawUrl = originalReq.url ||
|
|
215
|
-
(fullUrlHeader ? decodeURIComponent(fullUrlHeader) : '') ||
|
|
216
|
-
''
|
|
217
|
-
const url = new URL(rawUrl)
|
|
218
|
-
const params = {}
|
|
219
|
-
url.searchParams.forEach((value, key) => {
|
|
220
|
-
params[key] = value
|
|
221
|
-
})
|
|
222
|
-
return params
|
|
223
|
-
} catch {
|
|
224
|
-
return {}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* 从原始请求中提取 payload 参数(支持 JSON 和 x-www-form-urlencoded,只提取第一层 key-value)
|
|
230
|
-
* 来源:req.originalReq.body
|
|
231
|
-
*
|
|
232
|
-
* @param {Object} originalReq - Whistle 原始请求对象
|
|
233
|
-
* @returns {Object} payload 参数 key-value 对(值统一转为字符串)
|
|
234
|
-
*/
|
|
235
|
-
function extractPayloadParams(originalReq) {
|
|
236
|
-
if (!originalReq) return {}
|
|
237
|
-
|
|
238
|
-
try {
|
|
239
|
-
// 优先使用 mock 路径预缓冲的请求体,fallback 到 Whistle 预解析的 body
|
|
240
|
-
let body = originalReq._reqBody || originalReq.body || ''
|
|
241
|
-
|
|
242
|
-
// Buffer 转字符串
|
|
243
|
-
if (Buffer.isBuffer(body)) {
|
|
244
|
-
body = body.toString('utf8')
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// body 可能已被 Whistle 预解析为对象,直接提取
|
|
248
|
-
if (typeof body === 'object' && body !== null) {
|
|
249
|
-
return extractFlatParams(body)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (typeof body !== 'string' || !body.trim()) {
|
|
253
|
-
return {}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// 尝试 JSON 格式
|
|
257
|
-
try {
|
|
258
|
-
const parsed = JSON.parse(body)
|
|
259
|
-
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
260
|
-
return extractFlatParams(parsed)
|
|
261
|
-
}
|
|
262
|
-
} catch {
|
|
263
|
-
// 不是 JSON,继续尝试
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// 尝试 application/x-www-form-urlencoded 格式
|
|
267
|
-
if (body.includes('=')) {
|
|
268
|
-
const result = {}
|
|
269
|
-
new URLSearchParams(body).forEach((value, key) => {
|
|
270
|
-
result[key] = value
|
|
271
|
-
})
|
|
272
|
-
if (Object.keys(result).length > 0) return result
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return {}
|
|
276
|
-
} catch {
|
|
277
|
-
return {}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* 从对象中提取第一层 string/number/boolean 类型的 key-value(统一转为字符串)
|
|
283
|
-
* @param {Object} obj
|
|
284
|
-
* @returns {Object}
|
|
285
|
-
*/
|
|
286
|
-
function extractFlatParams(obj) {
|
|
287
|
-
const params = {}
|
|
288
|
-
Object.keys(obj).forEach((key) => {
|
|
289
|
-
const val = obj[key]
|
|
290
|
-
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
|
291
|
-
params[key] = String(val)
|
|
292
|
-
}
|
|
293
|
-
})
|
|
294
|
-
return params
|
|
295
|
-
}
|
|
13
|
+
// 重新导出共用实现(保持 server.js 调用链不变)
|
|
14
|
+
const { sendMockResponse } = require('../shared/mock-response')
|
|
15
|
+
const { handleConcurrentCapture } = require('../shared/concurrent')
|
|
296
16
|
|
|
297
17
|
/**
|
|
298
18
|
* 检查是否需要捕获源数据
|
|
@@ -329,65 +49,6 @@ async function checkShouldCapture(params) {
|
|
|
329
49
|
}
|
|
330
50
|
}
|
|
331
51
|
|
|
332
|
-
/**
|
|
333
|
-
* 处理并发捕获时的等待逻辑
|
|
334
|
-
* @param {Map} capturingFiles - 捕获中的文件 Map
|
|
335
|
-
* @param {string} captureKey - 捕获 key
|
|
336
|
-
* @param {Object} params - 参数对象
|
|
337
|
-
* @returns {Promise<Object>} { handled, response }
|
|
338
|
-
*/
|
|
339
|
-
async function handleConcurrentCapture(capturingFiles, captureKey, params) {
|
|
340
|
-
const {
|
|
341
|
-
res,
|
|
342
|
-
storageAdapter,
|
|
343
|
-
groupManager,
|
|
344
|
-
currentGroupId,
|
|
345
|
-
filename,
|
|
346
|
-
mockVersion,
|
|
347
|
-
filePath,
|
|
348
|
-
originalReq,
|
|
349
|
-
} = params
|
|
350
|
-
|
|
351
|
-
if (!capturingFiles.has(captureKey)) {
|
|
352
|
-
return { handled: false }
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// 正在捕获源数据中,等待捕获完成
|
|
356
|
-
console.log(`[mockbubu] ⏳ 检测到并发请求,等待源数据捕获完成: ${filename}`)
|
|
357
|
-
|
|
358
|
-
try {
|
|
359
|
-
// 等待第一个请求捕获完成(最多等待10秒)
|
|
360
|
-
await Promise.race([
|
|
361
|
-
capturingFiles.get(captureKey),
|
|
362
|
-
new Promise((_resolve, reject) => setTimeout(() => reject(new Error('捕获超时')), 10000)),
|
|
363
|
-
])
|
|
364
|
-
|
|
365
|
-
// 捕获完成,重新读取源数据
|
|
366
|
-
const freshSourceData = await storageAdapter.readFile(filePath)
|
|
367
|
-
if (freshSourceData) {
|
|
368
|
-
console.log(`[mockbubu] ✅ 等待成功,返回捕获的源数据: ${filename}`)
|
|
369
|
-
|
|
370
|
-
await sendMockResponse(res, freshSourceData, {
|
|
371
|
-
mockVersion,
|
|
372
|
-
groupManager,
|
|
373
|
-
currentGroupId,
|
|
374
|
-
filename,
|
|
375
|
-
originalReq,
|
|
376
|
-
})
|
|
377
|
-
|
|
378
|
-
return { handled: true, response: 'success' }
|
|
379
|
-
} else {
|
|
380
|
-
// 等待后仍无源数据,降级到透传
|
|
381
|
-
console.warn(`[mockbubu] ⚠️ 等待后仍无源数据,降级透传: ${filename}`)
|
|
382
|
-
return { handled: true, response: 'passthrough' }
|
|
383
|
-
}
|
|
384
|
-
} catch (err) {
|
|
385
|
-
// 等待超时或失败,降级到透传
|
|
386
|
-
console.error(`[mockbubu] ❌ 等待捕获失败,降级透传: ${filename}`, err.message)
|
|
387
|
-
return { handled: true, response: 'passthrough' }
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
52
|
module.exports = {
|
|
392
53
|
sendMockResponse,
|
|
393
54
|
checkShouldCapture,
|
|
@@ -22,19 +22,7 @@ const {
|
|
|
22
22
|
captureResponse,
|
|
23
23
|
} = require('./capture-handler')
|
|
24
24
|
const pluginModeManager = require('../plugin-mode-manager')
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* 从 req 流缓冲请求体,供条件匹配使用
|
|
28
|
-
* 仅在 mock 路径调用,此时 req 流未被 pipe,可安全读取
|
|
29
|
-
*/
|
|
30
|
-
function bufferReqBody(req) {
|
|
31
|
-
return new Promise((resolve) => {
|
|
32
|
-
const chunks = []
|
|
33
|
-
req.on('data', chunk => chunks.push(chunk))
|
|
34
|
-
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
35
|
-
req.on('error', () => resolve(Buffer.alloc(0)))
|
|
36
|
-
})
|
|
37
|
-
}
|
|
25
|
+
const { bufferReqBody } = require('../shared/buffer')
|
|
38
26
|
|
|
39
27
|
module.exports = (server, options) => {
|
|
40
28
|
console.log('[mockbubu] 🚀 server.js 模块已加载!')
|
|
@@ -69,6 +57,10 @@ module.exports = (server, options) => {
|
|
|
69
57
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
70
58
|
}
|
|
71
59
|
|
|
60
|
+
// 【互斥标志】标记此请求已由 server 钩子处理
|
|
61
|
+
// resWrite 钩子见此标志直接 passThrough,不重复处理
|
|
62
|
+
req._mockbubuServerHandled = true
|
|
63
|
+
|
|
72
64
|
// 判断是否需要拦截该请求(使用模式管理器获取当前模式)
|
|
73
65
|
const interceptResult = shouldInterceptRequest(req, storage, pluginModeManager.getMode())
|
|
74
66
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件名: buffer.js
|
|
3
|
+
* 功能: 请求体缓冲工具(共用模块)
|
|
4
|
+
* 职责:
|
|
5
|
+
* - 从 req 流缓冲请求体,供条件匹配使用
|
|
6
|
+
* - 仅在 mock 路径调用,此时 req 流未被 pipe,可安全读取
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 从 req 流缓冲请求体
|
|
11
|
+
* @param {Object} req - Whistle 请求对象
|
|
12
|
+
* @returns {Promise<Buffer>} 请求体 Buffer
|
|
13
|
+
*/
|
|
14
|
+
function bufferReqBody(req) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const chunks = []
|
|
17
|
+
req.on('data', chunk => chunks.push(chunk))
|
|
18
|
+
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
19
|
+
req.on('error', () => resolve(Buffer.alloc(0)))
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { bufferReqBody }
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件名: capture.js
|
|
3
|
+
* 功能: 源数据捕获逻辑(共用模块)
|
|
4
|
+
* 职责:
|
|
5
|
+
* - 捕获真实 API 响应数据
|
|
6
|
+
* - 处理响应体解压缩
|
|
7
|
+
* - 保存源数据到存储 (file.json)
|
|
8
|
+
* - 更新文件配置
|
|
9
|
+
* - 并发控制
|
|
10
|
+
*
|
|
11
|
+
* 从 lib/core/server-entry/capture-handler.js 抽取,供 server / resWrite 钩子共用
|
|
12
|
+
*
|
|
13
|
+
* 注意:
|
|
14
|
+
* - captureResponse 用于「server 钩子接管请求」场景:主动 req.request 发起上游请求
|
|
15
|
+
* - resWrite 钩子「不接管请求」场景应使用 captureAndPassthrough(见 res-write/response-rewriter.js)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
setApiListUpdated,
|
|
20
|
+
handleBuffer2String,
|
|
21
|
+
withTryCatch,
|
|
22
|
+
} = require('../../utils/utils')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 捕获真实 API 响应数据
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} params - 参数对象
|
|
28
|
+
* @param {Object} params.req - Whistle 请求对象
|
|
29
|
+
* @param {Object} params.res - Whistle 响应对象
|
|
30
|
+
* @param {Object} params.storageAdapter - 存储适配器
|
|
31
|
+
* @param {Object} params.groupManager - 组管理器
|
|
32
|
+
* @param {Object} params.memoryBuffer - 内存缓冲区
|
|
33
|
+
* @param {string} params.currentGroupId - 当前组ID
|
|
34
|
+
* @param {string} params.filename - 文件名
|
|
35
|
+
* @param {boolean} params.isFirstCapture - 是否首次捕获
|
|
36
|
+
* @param {boolean} params.mock - Mock 状态
|
|
37
|
+
* @param {Object} params.requestInfo - 请求信息 { method, rule, pattern, ruleValue, url }
|
|
38
|
+
* @param {Map} params.capturingFiles - 捕获中的文件 Map
|
|
39
|
+
* @param {Object} params.storage - Whistle storage 对象
|
|
40
|
+
*/
|
|
41
|
+
async function captureResponse(params) {
|
|
42
|
+
const {
|
|
43
|
+
req,
|
|
44
|
+
res,
|
|
45
|
+
storageAdapter,
|
|
46
|
+
groupManager,
|
|
47
|
+
memoryBuffer,
|
|
48
|
+
currentGroupId,
|
|
49
|
+
filename,
|
|
50
|
+
isFirstCapture,
|
|
51
|
+
mock,
|
|
52
|
+
requestInfo,
|
|
53
|
+
capturingFiles,
|
|
54
|
+
storage,
|
|
55
|
+
} = params
|
|
56
|
+
|
|
57
|
+
const { method, rule, pattern, ruleValue, url } = requestInfo
|
|
58
|
+
const captureKey = `${currentGroupId}/${filename}`
|
|
59
|
+
|
|
60
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] 🚀 captureResponse 函数开始执行: ${filename}`)
|
|
61
|
+
|
|
62
|
+
// 设置捕获标志
|
|
63
|
+
let resolveCapture
|
|
64
|
+
const capturePromise = new Promise(resolve => { resolveCapture = resolve })
|
|
65
|
+
capturingFiles.set(captureKey, capturePromise)
|
|
66
|
+
|
|
67
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] 🌐 准备调用 req.request(): ${filename}`)
|
|
68
|
+
|
|
69
|
+
// 发起真实请求
|
|
70
|
+
const client = req.request((svrRes) => {
|
|
71
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] 🔵 req.request 回调已执行: ${filename}`)
|
|
72
|
+
const encoding = svrRes.headers['content-encoding']
|
|
73
|
+
let body
|
|
74
|
+
|
|
75
|
+
svrRes.on('data', (data) => {
|
|
76
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] 📥 接收数据块: ${filename}, 大小: ${data?.length || 0}`)
|
|
77
|
+
body = body ? Buffer.concat([body, data]) : data
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
svrRes.on('end', withTryCatch(async () => {
|
|
81
|
+
try {
|
|
82
|
+
if (!body) {
|
|
83
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] ⚠️ 响应体为空,跳过捕获: ${filename}`)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const resContentType = svrRes.headers['content-type'] || ''
|
|
88
|
+
if (!resContentType.includes('application/json')) {
|
|
89
|
+
console.log(`[mockbubu] ⏭️ 非 JSON 响应,跳过捕获: ${filename} (Content-Type: ${resContentType})`)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] 📦 响应接收完成,开始解压: ${filename}`)
|
|
94
|
+
// 解压响应体
|
|
95
|
+
const content = await handleBuffer2String({ body, encoding })
|
|
96
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] ✅ 解压完成,调用 req.getSession(): ${filename}`)
|
|
97
|
+
|
|
98
|
+
// 获取完整的请求响应数据 (用于构建源数据)
|
|
99
|
+
req.getSession(async (session) => {
|
|
100
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] 🔍 getSession 回调已触发: ${filename}`)
|
|
101
|
+
console.log('[mockbubu DEBUG] session.req.body存在:', !!session?.req?.body)
|
|
102
|
+
console.log('[mockbubu DEBUG] session.req.body类型:', typeof session?.req?.body)
|
|
103
|
+
if (session?.req?.body) {
|
|
104
|
+
console.log('[mockbubu DEBUG] session.req.body前200字符:', String(session.req.body).substring(0, 200))
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
// 如果设置了 enable://hide 会获取到空数据
|
|
108
|
+
if (!session) {
|
|
109
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] ⚠️ session 为空(可能设置了 enable://hide): ${filename}`)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] 💾 开始保存源数据: ${filename}`)
|
|
114
|
+
// 保存源数据和文件配置
|
|
115
|
+
await saveSourceData({
|
|
116
|
+
session,
|
|
117
|
+
content,
|
|
118
|
+
storageAdapter,
|
|
119
|
+
groupManager,
|
|
120
|
+
memoryBuffer,
|
|
121
|
+
currentGroupId,
|
|
122
|
+
filename,
|
|
123
|
+
isFirstCapture,
|
|
124
|
+
mock,
|
|
125
|
+
method,
|
|
126
|
+
rule,
|
|
127
|
+
pattern,
|
|
128
|
+
ruleValue,
|
|
129
|
+
url,
|
|
130
|
+
storage,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
console.log(`[mockbubu] ✅ 捕获完成: ${filename}`)
|
|
134
|
+
} finally {
|
|
135
|
+
// 捕获完成,清除标志并通知等待的请求
|
|
136
|
+
resolveCapture()
|
|
137
|
+
capturingFiles.delete(captureKey)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error(`[mockbubu] ❌ 捕获过程出错: ${filename}`, err)
|
|
142
|
+
// 出错也要清除标志
|
|
143
|
+
resolveCapture()
|
|
144
|
+
capturingFiles.delete(captureKey)
|
|
145
|
+
}
|
|
146
|
+
}))
|
|
147
|
+
|
|
148
|
+
// 将响应透传给客户端
|
|
149
|
+
svrRes.pipe(res)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] ✅ req.request() 已调用,准备 pipe 数据: ${filename}`)
|
|
153
|
+
|
|
154
|
+
req.pipe(client)
|
|
155
|
+
|
|
156
|
+
console.log(`[mockbubu 2025/12/8 ${new Date().toLocaleTimeString()}] ✅ req.pipe(client) 已完成: ${filename}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 保存源数据到内存缓冲区
|
|
161
|
+
*
|
|
162
|
+
* 职责:
|
|
163
|
+
* - 不写入文件系统(文件仅在用户点击"保存"时创建)
|
|
164
|
+
* - 将完整会话数据添加到内存缓冲区(供前端轮询拉取)
|
|
165
|
+
* - 刷新页面后,memoryBuffer 自动清空,未保存的数据消失
|
|
166
|
+
*
|
|
167
|
+
* @param {Object} params - 参数对象
|
|
168
|
+
*/
|
|
169
|
+
async function saveSourceData(params) {
|
|
170
|
+
const {
|
|
171
|
+
session,
|
|
172
|
+
content,
|
|
173
|
+
memoryBuffer,
|
|
174
|
+
currentGroupId,
|
|
175
|
+
url,
|
|
176
|
+
method,
|
|
177
|
+
storage,
|
|
178
|
+
} = params
|
|
179
|
+
|
|
180
|
+
// 1. 构建完整会话数据
|
|
181
|
+
const sourceDataToSave = JSON.parse(JSON.stringify(session))
|
|
182
|
+
sourceDataToSave.res.body = content
|
|
183
|
+
|
|
184
|
+
// 2. 添加到内存缓冲区(供前端轮询拉取)
|
|
185
|
+
// MemoryBuffer 内部会检查暂存区是否已存在,已存在则跳过
|
|
186
|
+
memoryBuffer.add(currentGroupId, url, {
|
|
187
|
+
method,
|
|
188
|
+
status: session.res.statusCode,
|
|
189
|
+
session: sourceDataToSave,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// 3. ⚠️ 不要在捕获时创建文件配置
|
|
193
|
+
// 只有用户明确点击"保存"按钮后,才会创建 file.json + versions/
|
|
194
|
+
// 捕获的数据仅保存在 memoryBuffer 中,刷新后自动清空
|
|
195
|
+
|
|
196
|
+
// 4. 标记 API 列表已更新
|
|
197
|
+
await setApiListUpdated(storage, true)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
captureResponse,
|
|
202
|
+
saveSourceData,
|
|
203
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件名: concurrent.js
|
|
3
|
+
* 功能: 并发捕获控制(共用模块)
|
|
4
|
+
* 职责:
|
|
5
|
+
* - 处理并发捕获时的等待逻辑
|
|
6
|
+
* - 同一文件并发请求时,后续请求等待首个捕获完成
|
|
7
|
+
*
|
|
8
|
+
* 从 lib/core/server-entry/response-handler.js 抽取,供 server / resWrite 钩子共用
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { sendMockResponse } = require('./mock-response')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 处理并发捕获时的等待逻辑
|
|
15
|
+
* @param {Map} capturingFiles - 捕获中的文件 Map
|
|
16
|
+
* @param {string} captureKey - 捕获 key
|
|
17
|
+
* @param {Object} params - 参数对象
|
|
18
|
+
* @returns {Promise<Object>} { handled, response }
|
|
19
|
+
*/
|
|
20
|
+
async function handleConcurrentCapture(capturingFiles, captureKey, params) {
|
|
21
|
+
const {
|
|
22
|
+
res,
|
|
23
|
+
storageAdapter,
|
|
24
|
+
groupManager,
|
|
25
|
+
currentGroupId,
|
|
26
|
+
filename,
|
|
27
|
+
mockVersion,
|
|
28
|
+
filePath,
|
|
29
|
+
originalReq,
|
|
30
|
+
} = params
|
|
31
|
+
|
|
32
|
+
if (!capturingFiles.has(captureKey)) {
|
|
33
|
+
return { handled: false }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 正在捕获源数据中,等待捕获完成
|
|
37
|
+
console.log(`[mockbubu] ⏳ 检测到并发请求,等待源数据捕获完成: ${filename}`)
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// 等待第一个请求捕获完成(最多等待10秒)
|
|
41
|
+
await Promise.race([
|
|
42
|
+
capturingFiles.get(captureKey),
|
|
43
|
+
new Promise((_resolve, reject) => setTimeout(() => reject(new Error('捕获超时')), 10000)),
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
// 捕获完成,重新读取源数据
|
|
47
|
+
const freshSourceData = await storageAdapter.readFile(filePath)
|
|
48
|
+
if (freshSourceData) {
|
|
49
|
+
console.log(`[mockbubu] ✅ 等待成功,返回捕获的源数据: ${filename}`)
|
|
50
|
+
|
|
51
|
+
await sendMockResponse(res, freshSourceData, {
|
|
52
|
+
mockVersion,
|
|
53
|
+
groupManager,
|
|
54
|
+
currentGroupId,
|
|
55
|
+
filename,
|
|
56
|
+
originalReq,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return { handled: true, response: 'success' }
|
|
60
|
+
} else {
|
|
61
|
+
// 等待后仍无源数据,降级到透传
|
|
62
|
+
console.warn(`[mockbubu] ⚠️ 等待后仍无源数据,降级透传: ${filename}`)
|
|
63
|
+
return { handled: true, response: 'passthrough' }
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// 等待超时或失败,降级到透传
|
|
67
|
+
console.error(`[mockbubu] ❌ 等待捕获失败,降级透传: ${filename}`, err.message)
|
|
68
|
+
return { handled: true, response: 'passthrough' }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { handleConcurrentCapture }
|