whistle.mockbubu 2.1.5 → 2.2.0-beta.2
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/lib/config/const.js +11 -0
- package/lib/core/server-entry/response-handler.js +210 -7
- package/lib/core/server-entry/server.js +33 -33
- package/lib/storage/group-manager.js +77 -0
- package/lib/storage/storage-v3.js +160 -0
- package/lib/uiServer/router/files/api-list-router.js +1 -2
- package/lib/uiServer/router/files/file-config-router.js +23 -1
- package/lib/uiServer/router/files/file-save-router.js +39 -4
- package/lib/uiServer/router/files/match-version-router.js +178 -0
- package/lib/uiServer/router/index.js +2 -0
- package/lib/uiServer/utils/util.js +25 -5
- package/package.json +1 -1
- package/public/js/app.js +2011 -299
- package/public/js/app.js.map +1 -1
- package/public/js/chunk-vendors.js +650 -0
- package/public/js/chunk-vendors.js.map +1 -1
- package/public/js/node_modules_element-ui_lib_element-ui_common_js.js +17713 -0
- package/public/js/node_modules_element-ui_lib_element-ui_common_js.js.map +1 -0
package/lib/config/const.js
CHANGED
|
@@ -136,3 +136,14 @@ exports.PLUGIN_MODE = {
|
|
|
136
136
|
* 默认插件模式
|
|
137
137
|
*/
|
|
138
138
|
exports.DEFAULT_PLUGIN_MODE = 'default'
|
|
139
|
+
|
|
140
|
+
// ============================================
|
|
141
|
+
// 条件匹配模式相关常量
|
|
142
|
+
// ============================================
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* mockVersion 的特殊值:表示启用条件匹配模式
|
|
146
|
+
* 当 file.json 中 config.mockVersion === MATCH_MODE_VALUE 时,
|
|
147
|
+
* 后端进入条件匹配流程(读取 match_versions/ 目录中的规则)
|
|
148
|
+
*/
|
|
149
|
+
exports.MATCH_MODE_VALUE = '__match_mode__'
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
* 功能: Mock 响应返回逻辑
|
|
4
4
|
* 职责:
|
|
5
5
|
* - 返回 Mock 数据 (源数据或版本数据)
|
|
6
|
+
* - 条件匹配模式下匹配规则并返回对应数据
|
|
6
7
|
* - 处理版本切换
|
|
7
8
|
* - 处理响应头
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
const { MATCH_MODE_VALUE } = require('../../config/const')
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* 返回 Mock 响应数据
|
|
12
15
|
* @param {Object} res - Whistle 响应对象
|
|
@@ -16,38 +19,236 @@
|
|
|
16
19
|
* @param {Object} options.groupManager - 组管理器实例
|
|
17
20
|
* @param {string} options.currentGroupId - 当前组ID
|
|
18
21
|
* @param {string} options.filename - 文件名
|
|
22
|
+
* @param {Object} options.originalReq - Whistle 原始请求对象(用于条件匹配)
|
|
19
23
|
*/
|
|
20
24
|
async function sendMockResponse(res, sourceData, options = {}) {
|
|
21
|
-
const { mockVersion, groupManager, currentGroupId, filename } = options
|
|
25
|
+
const { mockVersion, groupManager, currentGroupId, filename, originalReq } = options
|
|
22
26
|
|
|
23
27
|
// 解析源数据 (storageAdapter.readFile 返回的是 session 对象的 JSON 字符串)
|
|
24
28
|
const session = JSON.parse(sourceData)
|
|
25
29
|
const sourceResponse = session?.res
|
|
26
|
-
const { statusCode, statusMessage
|
|
30
|
+
const { statusCode, statusMessage } = sourceResponse
|
|
27
31
|
|
|
28
|
-
//
|
|
32
|
+
// 浅拷贝 headers,避免直接修改 session 对象(若未来引入缓存会产生副作用)
|
|
33
|
+
const headers = { ...sourceResponse.headers }
|
|
29
34
|
headers['from-mockbubu-source'] = 'true'
|
|
30
35
|
headers['x-mockbubu-mocked'] = 'true'
|
|
31
36
|
delete headers['content-encoding']
|
|
32
37
|
delete headers['content-length']
|
|
33
38
|
|
|
34
|
-
|
|
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'
|
|
35
49
|
|
|
36
|
-
|
|
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
|
|
37
64
|
if (mockVersion && groupManager && currentGroupId && filename) {
|
|
38
65
|
const versionData = await groupManager.getGroupVersionContent(currentGroupId, filename, mockVersion)
|
|
39
66
|
if (versionData) {
|
|
40
|
-
|
|
67
|
+
res.writeHead(statusCode, statusMessage, headers)
|
|
41
68
|
res.end(JSON.stringify(versionData))
|
|
42
69
|
return
|
|
43
70
|
}
|
|
44
71
|
// 版本不存在时降级到源数据
|
|
45
72
|
}
|
|
46
73
|
|
|
47
|
-
|
|
74
|
+
res.writeHead(statusCode, statusMessage, headers)
|
|
48
75
|
res.end(sourceResponse.body)
|
|
49
76
|
}
|
|
50
77
|
|
|
78
|
+
/**
|
|
79
|
+
* 条件匹配核心逻辑
|
|
80
|
+
*
|
|
81
|
+
* 匹配规则:
|
|
82
|
+
* 1. 按条件数量降序(条件越多优先级越高)
|
|
83
|
+
* 2. 条件数量相同时按 priority 字段升序(数字越小越优先)
|
|
84
|
+
* 3. 空条件规则作为兜底,最后匹配
|
|
85
|
+
* 4. 全不命中时返回 matched: false,由调用方降级到 source
|
|
86
|
+
*
|
|
87
|
+
* @param {Object} params
|
|
88
|
+
* @param {Object} params.groupManager - 组管理器
|
|
89
|
+
* @param {string} params.currentGroupId - 当前组ID
|
|
90
|
+
* @param {string} params.filename - 文件名(URL)
|
|
91
|
+
* @param {Object} params.originalReq - Whistle 原始请求对象
|
|
92
|
+
* @returns {{ matched: boolean, content?, ruleName?, paramsSummary? }}
|
|
93
|
+
*/
|
|
94
|
+
async function matchByConditions(params) {
|
|
95
|
+
const { groupManager, currentGroupId, filename, originalReq } = params
|
|
96
|
+
|
|
97
|
+
// 获取所有匹配规则(storage 层已按 priority 排序)
|
|
98
|
+
const matchVersions = await groupManager.getMatchVersions(currentGroupId, filename)
|
|
99
|
+
if (!matchVersions || matchVersions.length === 0) {
|
|
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 String(sourceParams[cond.key]) === String(cond.value)
|
|
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.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
|
+
* 从原始请求中提取 query 参数
|
|
157
|
+
* 来源:req.originalReq.url,fallback 到 x-whistle-full-url header
|
|
158
|
+
*
|
|
159
|
+
* @param {Object} originalReq - Whistle 原始请求对象
|
|
160
|
+
* @returns {Object} query 参数 key-value 对
|
|
161
|
+
*/
|
|
162
|
+
function extractQueryParams(originalReq) {
|
|
163
|
+
if (!originalReq) return {}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
// 某些 Whistle 代理场景(tunnel/HTTP2)下 originalReq.url 可能不含 query,
|
|
167
|
+
// 完整 URL 会在 x-whistle-full-url header 里(URL 编码格式)
|
|
168
|
+
const fullUrlHeader = originalReq.headers?.['x-whistle-full-url']
|
|
169
|
+
const rawUrl = originalReq.url ||
|
|
170
|
+
(fullUrlHeader ? decodeURIComponent(fullUrlHeader) : '') ||
|
|
171
|
+
''
|
|
172
|
+
const url = new URL(rawUrl)
|
|
173
|
+
const params = {}
|
|
174
|
+
url.searchParams.forEach((value, key) => {
|
|
175
|
+
params[key] = value
|
|
176
|
+
})
|
|
177
|
+
return params
|
|
178
|
+
} catch {
|
|
179
|
+
return {}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 从原始请求中提取 payload 参数(支持 JSON 和 x-www-form-urlencoded,只提取第一层 key-value)
|
|
185
|
+
* 来源:req.originalReq.body
|
|
186
|
+
*
|
|
187
|
+
* @param {Object} originalReq - Whistle 原始请求对象
|
|
188
|
+
* @returns {Object} payload 参数 key-value 对(值统一转为字符串)
|
|
189
|
+
*/
|
|
190
|
+
function extractPayloadParams(originalReq) {
|
|
191
|
+
if (!originalReq) return {}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
// 优先使用 mock 路径预缓冲的请求体,fallback 到 Whistle 预解析的 body
|
|
195
|
+
let body = originalReq._reqBody || originalReq.body || ''
|
|
196
|
+
|
|
197
|
+
// Buffer 转字符串
|
|
198
|
+
if (Buffer.isBuffer(body)) {
|
|
199
|
+
body = body.toString('utf8')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// body 可能已被 Whistle 预解析为对象,直接提取
|
|
203
|
+
if (typeof body === 'object' && body !== null) {
|
|
204
|
+
return extractFlatParams(body)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (typeof body !== 'string' || !body.trim()) {
|
|
208
|
+
return {}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 尝试 JSON 格式
|
|
212
|
+
try {
|
|
213
|
+
const parsed = JSON.parse(body)
|
|
214
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
215
|
+
return extractFlatParams(parsed)
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// 不是 JSON,继续尝试
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 尝试 application/x-www-form-urlencoded 格式
|
|
222
|
+
if (body.includes('=')) {
|
|
223
|
+
const result = {}
|
|
224
|
+
new URLSearchParams(body).forEach((value, key) => {
|
|
225
|
+
result[key] = value
|
|
226
|
+
})
|
|
227
|
+
if (Object.keys(result).length > 0) return result
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {}
|
|
231
|
+
} catch {
|
|
232
|
+
return {}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 从对象中提取第一层 string/number/boolean 类型的 key-value(统一转为字符串)
|
|
238
|
+
* @param {Object} obj
|
|
239
|
+
* @returns {Object}
|
|
240
|
+
*/
|
|
241
|
+
function extractFlatParams(obj) {
|
|
242
|
+
const params = {}
|
|
243
|
+
Object.keys(obj).forEach((key) => {
|
|
244
|
+
const val = obj[key]
|
|
245
|
+
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
|
246
|
+
params[key] = String(val)
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
return params
|
|
250
|
+
}
|
|
251
|
+
|
|
51
252
|
/**
|
|
52
253
|
* 检查是否需要捕获源数据
|
|
53
254
|
* 如果文件已存在配置,则透传;如果是首次访问,则需要捕获
|
|
@@ -99,6 +300,7 @@ async function handleConcurrentCapture(capturingFiles, captureKey, params) {
|
|
|
99
300
|
filename,
|
|
100
301
|
mockVersion,
|
|
101
302
|
filePath,
|
|
303
|
+
originalReq,
|
|
102
304
|
} = params
|
|
103
305
|
|
|
104
306
|
if (!capturingFiles.has(captureKey)) {
|
|
@@ -125,6 +327,7 @@ async function handleConcurrentCapture(capturingFiles, captureKey, params) {
|
|
|
125
327
|
groupManager,
|
|
126
328
|
currentGroupId,
|
|
127
329
|
filename,
|
|
330
|
+
originalReq,
|
|
128
331
|
})
|
|
129
332
|
|
|
130
333
|
return { handled: true, response: 'success' }
|
|
@@ -16,7 +16,6 @@ const {
|
|
|
16
16
|
} = require('./request-interceptor')
|
|
17
17
|
const {
|
|
18
18
|
sendMockResponse,
|
|
19
|
-
checkShouldCapture,
|
|
20
19
|
handleConcurrentCapture,
|
|
21
20
|
} = require('./response-handler')
|
|
22
21
|
const {
|
|
@@ -24,6 +23,19 @@ const {
|
|
|
24
23
|
} = require('./capture-handler')
|
|
25
24
|
const pluginModeManager = require('../plugin-mode-manager')
|
|
26
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
|
+
}
|
|
38
|
+
|
|
27
39
|
module.exports = (server, options) => {
|
|
28
40
|
console.log('[mockbubu] 🚀 server.js 模块已加载!')
|
|
29
41
|
const { storage } = options
|
|
@@ -69,6 +81,10 @@ module.exports = (server, options) => {
|
|
|
69
81
|
return req.passThrough()
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
// 在所有 await 操作之前提前捕获 originalReq
|
|
85
|
+
// Whistle 在异步操作完成后可能会清空 req.originalReq,导致后续读取为 null
|
|
86
|
+
const originalReq = req.originalReq
|
|
87
|
+
|
|
72
88
|
// 提取请求信息
|
|
73
89
|
const { filename, rule, method, url, pattern, ruleValue, mode } = interceptResult
|
|
74
90
|
|
|
@@ -98,12 +114,16 @@ module.exports = (server, options) => {
|
|
|
98
114
|
|
|
99
115
|
// 有 mock 且有数据 → 返回 mock(不捕获)
|
|
100
116
|
console.log(`[mockbubu] ✅ Mock-Only 模式: 返回 Mock 数据(不捕获) | URL: ${url}`)
|
|
101
|
-
|
|
117
|
+
originalReq._reqBody = await bufferReqBody(req)
|
|
118
|
+
const mockOnlyResult = await sendMockResponse(res, sourceData, {
|
|
102
119
|
mockVersion,
|
|
103
120
|
groupManager,
|
|
104
121
|
currentGroupId,
|
|
105
122
|
filename,
|
|
123
|
+
originalReq,
|
|
106
124
|
})
|
|
125
|
+
if (mockOnlyResult?.action === 'passthrough') return req.passThrough()
|
|
126
|
+
return
|
|
107
127
|
}
|
|
108
128
|
|
|
109
129
|
// 以下是 Capture 模式的逻辑
|
|
@@ -111,32 +131,24 @@ module.exports = (server, options) => {
|
|
|
111
131
|
|
|
112
132
|
// 场景 1: Mock 启用 + 有源数据 → 返回 Mock 数据
|
|
113
133
|
if (mock && sourceData) {
|
|
114
|
-
|
|
134
|
+
originalReq._reqBody = await bufferReqBody(req)
|
|
135
|
+
const mockResult = await sendMockResponse(res, sourceData, {
|
|
115
136
|
mockVersion,
|
|
116
137
|
groupManager,
|
|
117
138
|
currentGroupId,
|
|
118
139
|
filename,
|
|
140
|
+
originalReq,
|
|
119
141
|
})
|
|
142
|
+
if (mockResult?.action === 'passthrough') return req.passThrough()
|
|
143
|
+
return
|
|
120
144
|
}
|
|
121
145
|
|
|
122
146
|
// 场景 2: Mock 启用 + 无源数据 → 容错恢复(自动捕获源数据)
|
|
123
147
|
// 可能原因:文件被删除、索引损坏、组导入不完整等
|
|
148
|
+
// sourceData=null 已证明物理文件无有效数据,不再通过索引判断是否跳过(索引残留会误阻恢复捕获)
|
|
124
149
|
if (mock && !sourceData) {
|
|
125
150
|
const captureKey = `${currentGroupId}/${filename}`
|
|
126
151
|
|
|
127
|
-
// 检查文件是否已存在
|
|
128
|
-
const { shouldCapture, isFirstCapture } = await checkShouldCapture({
|
|
129
|
-
storageAdapter,
|
|
130
|
-
groupManager,
|
|
131
|
-
currentGroupId,
|
|
132
|
-
filename,
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
if (!shouldCapture) {
|
|
136
|
-
// 文件已存在,直接透传
|
|
137
|
-
return req.passThrough()
|
|
138
|
-
}
|
|
139
|
-
|
|
140
152
|
// 处理并发捕获
|
|
141
153
|
const concurrentResult = await handleConcurrentCapture(capturingFiles, captureKey, {
|
|
142
154
|
res,
|
|
@@ -146,6 +158,7 @@ module.exports = (server, options) => {
|
|
|
146
158
|
filename,
|
|
147
159
|
mockVersion,
|
|
148
160
|
filePath,
|
|
161
|
+
originalReq,
|
|
149
162
|
})
|
|
150
163
|
|
|
151
164
|
if (concurrentResult.handled) {
|
|
@@ -164,7 +177,7 @@ module.exports = (server, options) => {
|
|
|
164
177
|
memoryBuffer,
|
|
165
178
|
currentGroupId,
|
|
166
179
|
filename,
|
|
167
|
-
isFirstCapture,
|
|
180
|
+
isFirstCapture: true,
|
|
168
181
|
mock: true, // mock 状态
|
|
169
182
|
requestInfo: { method, rule, pattern, ruleValue, url },
|
|
170
183
|
capturingFiles,
|
|
@@ -179,23 +192,10 @@ module.exports = (server, options) => {
|
|
|
179
192
|
return req.passThrough()
|
|
180
193
|
}
|
|
181
194
|
|
|
182
|
-
// 3.2: 无源数据 →
|
|
195
|
+
// 3.2: 无源数据 → 捕获
|
|
196
|
+
// sourceData=null 已证明物理文件无有效数据,不再通过索引判断是否跳过(索引残留会误阻捕获)
|
|
183
197
|
const captureKey = `${currentGroupId}/${filename}`
|
|
184
198
|
|
|
185
|
-
// 检查文件是否已存在
|
|
186
|
-
const { shouldCapture, isFirstCapture } = await checkShouldCapture({
|
|
187
|
-
storageAdapter,
|
|
188
|
-
groupManager,
|
|
189
|
-
currentGroupId,
|
|
190
|
-
filename,
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
if (!shouldCapture) {
|
|
194
|
-
// 文件已存在,直接透传
|
|
195
|
-
console.log(`[mockbubu] ⏭️ 文件已存在(mock=false),跳过捕获并透传: ${filename}`)
|
|
196
|
-
return req.passThrough()
|
|
197
|
-
}
|
|
198
|
-
|
|
199
199
|
// 检查是否正在捕获中
|
|
200
200
|
if (capturingFiles.has(captureKey)) {
|
|
201
201
|
// mock=false 时,并发请求直接透传(不等待)
|
|
@@ -212,7 +212,7 @@ module.exports = (server, options) => {
|
|
|
212
212
|
memoryBuffer,
|
|
213
213
|
currentGroupId,
|
|
214
214
|
filename,
|
|
215
|
-
isFirstCapture,
|
|
215
|
+
isFirstCapture: true,
|
|
216
216
|
mock: false, // mock 状态
|
|
217
217
|
requestInfo: { method, rule, pattern, ruleValue, url },
|
|
218
218
|
capturingFiles,
|
|
@@ -486,6 +486,83 @@ class GroupManager {
|
|
|
486
486
|
}
|
|
487
487
|
}
|
|
488
488
|
|
|
489
|
+
// ========================================
|
|
490
|
+
// 条件匹配规则(match_versions)业务封装
|
|
491
|
+
// ========================================
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 通过 filename(URL)查找 fileId 的通用内部方法
|
|
495
|
+
* @private
|
|
496
|
+
*/
|
|
497
|
+
async _getFileIdByFilename(groupId, filename) {
|
|
498
|
+
const v3Storage = this.storage.getV3Storage()
|
|
499
|
+
v3Storage.invalidateIndex(groupId)
|
|
500
|
+
const index = await v3Storage.getIndex(groupId)
|
|
501
|
+
const fileEntry = index.files.find(f => f.url === filename)
|
|
502
|
+
if (!fileEntry) {
|
|
503
|
+
throw new Error(`文件不存在: ${filename}`)
|
|
504
|
+
}
|
|
505
|
+
return fileEntry.id
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* 获取文件的所有匹配规则列表
|
|
510
|
+
* @param {string} groupId - 组ID
|
|
511
|
+
* @param {string} filename - 文件URL
|
|
512
|
+
* @returns {Promise<Array>} 匹配规则列表
|
|
513
|
+
*/
|
|
514
|
+
async getMatchVersions(groupId, filename) {
|
|
515
|
+
const v3Storage = this.storage.getV3Storage()
|
|
516
|
+
try {
|
|
517
|
+
const fileId = await this._getFileIdByFilename(groupId, filename)
|
|
518
|
+
return await v3Storage.listMatchVersions(groupId, fileId)
|
|
519
|
+
} catch (err) {
|
|
520
|
+
this.logger.error('获取匹配规则列表失败:', err.message)
|
|
521
|
+
return []
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 创建匹配规则
|
|
527
|
+
* @param {string} groupId - 组ID
|
|
528
|
+
* @param {string} filename - 文件URL
|
|
529
|
+
* @param {Object} data - 规则数据
|
|
530
|
+
*/
|
|
531
|
+
async createMatchVersion(groupId, filename, data) {
|
|
532
|
+
const v3Storage = this.storage.getV3Storage()
|
|
533
|
+
const fileId = await this._getFileIdByFilename(groupId, filename)
|
|
534
|
+
return await v3Storage.createMatchVersion(groupId, fileId, data)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* 更新匹配规则
|
|
539
|
+
* @param {string} groupId - 组ID
|
|
540
|
+
* @param {string} filename - 文件URL
|
|
541
|
+
* @param {string} matchVersionId - 规则ID
|
|
542
|
+
* @param {Object} updates - 要更新的字段
|
|
543
|
+
*/
|
|
544
|
+
async updateMatchVersion(groupId, filename, matchVersionId, updates) {
|
|
545
|
+
const v3Storage = this.storage.getV3Storage()
|
|
546
|
+
const fileId = await this._getFileIdByFilename(groupId, filename)
|
|
547
|
+
return await v3Storage.updateMatchVersion(groupId, fileId, matchVersionId, updates)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 删除匹配规则
|
|
552
|
+
* @param {string} groupId - 组ID
|
|
553
|
+
* @param {string} filename - 文件URL
|
|
554
|
+
* @param {string} matchVersionId - 规则ID
|
|
555
|
+
*/
|
|
556
|
+
async deleteMatchVersion(groupId, filename, matchVersionId) {
|
|
557
|
+
const v3Storage = this.storage.getV3Storage()
|
|
558
|
+
const fileId = await this._getFileIdByFilename(groupId, filename)
|
|
559
|
+
return await v3Storage.deleteMatchVersion(groupId, fileId, matchVersionId)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ========================================
|
|
563
|
+
// URL/Payload 工具方法
|
|
564
|
+
// ========================================
|
|
565
|
+
|
|
489
566
|
/**
|
|
490
567
|
* 从 URL 提取 query 参数
|
|
491
568
|
* @param {string} url - 完整的 URL
|
|
@@ -656,6 +656,7 @@ class FileSystemStorage {
|
|
|
656
656
|
rule: fileData.rule || '',
|
|
657
657
|
ruleValue: fileData.ruleValue || '',
|
|
658
658
|
pattern: fileData.pattern || '',
|
|
659
|
+
remark: fileData.remark || '',
|
|
659
660
|
config,
|
|
660
661
|
session: fileData.session || null, // ✅ 保存完整 session 数据
|
|
661
662
|
createTime: Date.now(),
|
|
@@ -677,6 +678,7 @@ class FileSystemStorage {
|
|
|
677
678
|
mock: config.mock,
|
|
678
679
|
locked: config.locked,
|
|
679
680
|
mockVersion: config.mockVersion,
|
|
681
|
+
remark: fileData.remark || '',
|
|
680
682
|
versionCount: 0,
|
|
681
683
|
createTime: file.createTime,
|
|
682
684
|
updateTime: file.updateTime,
|
|
@@ -779,6 +781,7 @@ class FileSystemStorage {
|
|
|
779
781
|
mock: updated.config?.mock,
|
|
780
782
|
locked: updated.config?.locked,
|
|
781
783
|
mockVersion: updated.config?.mockVersion,
|
|
784
|
+
...(updated.remark !== undefined && { remark: updated.remark }),
|
|
782
785
|
updateTime: updated.updateTime,
|
|
783
786
|
// ✨ Phase 3.1: 索引增强字段
|
|
784
787
|
...(updated.url && {
|
|
@@ -1134,6 +1137,163 @@ class FileSystemStorage {
|
|
|
1134
1137
|
})
|
|
1135
1138
|
}
|
|
1136
1139
|
|
|
1140
|
+
// ========================================
|
|
1141
|
+
// 条件匹配规则(match_versions)
|
|
1142
|
+
// ========================================
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* 获取 match_versions 目录路径
|
|
1146
|
+
* @param {string} groupId - 组ID
|
|
1147
|
+
* @param {string} fileId - 文件ID
|
|
1148
|
+
* @returns {string} 目录路径
|
|
1149
|
+
*/
|
|
1150
|
+
getMatchVersionsDir(groupId, fileId) {
|
|
1151
|
+
return path.join(this.groupsDir, groupId, 'files', fileId, 'match_versions')
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* 获取匹配规则数量
|
|
1156
|
+
*/
|
|
1157
|
+
async getMatchVersionCount(groupId, fileId) {
|
|
1158
|
+
const matchVersionsDir = this.getMatchVersionsDir(groupId, fileId)
|
|
1159
|
+
try {
|
|
1160
|
+
const entries = await fs.readdir(matchVersionsDir)
|
|
1161
|
+
return entries.filter(e => e.endsWith('.json')).length
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
if (err.code === 'ENOENT') return 0
|
|
1164
|
+
throw err
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* 获取所有匹配规则列表
|
|
1170
|
+
*/
|
|
1171
|
+
async listMatchVersions(groupId, fileId) {
|
|
1172
|
+
const matchVersionsDir = this.getMatchVersionsDir(groupId, fileId)
|
|
1173
|
+
|
|
1174
|
+
try {
|
|
1175
|
+
const entries = await fs.readdir(matchVersionsDir)
|
|
1176
|
+
const matchVersions = []
|
|
1177
|
+
|
|
1178
|
+
for (const entry of entries) {
|
|
1179
|
+
if (entry.endsWith('.json')) {
|
|
1180
|
+
const matchVersionId = entry.replace('.json', '')
|
|
1181
|
+
const data = await this.getMatchVersionById(groupId, fileId, matchVersionId)
|
|
1182
|
+
if (data) {
|
|
1183
|
+
matchVersions.push(data)
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// 按 priority 升序排序(数字越小越靠前),priority 相同按 createTime 升序
|
|
1189
|
+
matchVersions.sort((a, b) => {
|
|
1190
|
+
const priorityDiff = (a.priority || 0) - (b.priority || 0)
|
|
1191
|
+
if (priorityDiff !== 0) return priorityDiff
|
|
1192
|
+
return (a.createTime || 0) - (b.createTime || 0)
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
return matchVersions
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
if (err.code === 'ENOENT') return []
|
|
1198
|
+
throw err
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* 通过ID获取单个匹配规则
|
|
1204
|
+
*/
|
|
1205
|
+
async getMatchVersionById(groupId, fileId, matchVersionId) {
|
|
1206
|
+
const matchVersionFile = path.join(
|
|
1207
|
+
this.getMatchVersionsDir(groupId, fileId),
|
|
1208
|
+
`${matchVersionId}.json`,
|
|
1209
|
+
)
|
|
1210
|
+
try {
|
|
1211
|
+
const content = await fs.readFile(matchVersionFile, 'utf8')
|
|
1212
|
+
return JSON.parse(content)
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
if (err.code === 'ENOENT') return null
|
|
1215
|
+
throw err
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* 创建匹配规则
|
|
1221
|
+
* @param {string} groupId - 组ID
|
|
1222
|
+
* @param {string} fileId - 文件ID
|
|
1223
|
+
* @param {Object} matchVersionData - 规则数据 { name, description?, conditions?, content?, priority? }
|
|
1224
|
+
*/
|
|
1225
|
+
async createMatchVersion(groupId, fileId, matchVersionData) {
|
|
1226
|
+
const matchVersionsDir = this.getMatchVersionsDir(groupId, fileId)
|
|
1227
|
+
await fs.mkdir(matchVersionsDir, { recursive: true })
|
|
1228
|
+
|
|
1229
|
+
// 生成唯一ID,前缀 mv_
|
|
1230
|
+
const matchVersionId = 'mv_' + this.generateFileId()
|
|
1231
|
+
|
|
1232
|
+
const matchVersion = {
|
|
1233
|
+
id: matchVersionId,
|
|
1234
|
+
name: matchVersionData.name || '默认',
|
|
1235
|
+
description: matchVersionData.description || '',
|
|
1236
|
+
conditions: matchVersionData.conditions || [],
|
|
1237
|
+
content: matchVersionData.content || {},
|
|
1238
|
+
priority: matchVersionData.priority || 0,
|
|
1239
|
+
createTime: Date.now(),
|
|
1240
|
+
updateTime: Date.now(),
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const matchVersionFile = path.join(matchVersionsDir, `${matchVersionId}.json`)
|
|
1244
|
+
await this._writeJSON(matchVersionFile, matchVersion)
|
|
1245
|
+
|
|
1246
|
+
// 同步更新索引中的 matchVersionCount
|
|
1247
|
+
const count = await this.getMatchVersionCount(groupId, fileId)
|
|
1248
|
+
await this._updateInIndex(groupId, fileId, { matchVersionCount: count })
|
|
1249
|
+
|
|
1250
|
+
return matchVersion
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* 更新匹配规则(通过ID)
|
|
1255
|
+
* @param {string} groupId - 组ID
|
|
1256
|
+
* @param {string} fileId - 文件ID
|
|
1257
|
+
* @param {string} matchVersionId - 规则ID
|
|
1258
|
+
* @param {Object} updates - 要更新的字段 { name?, description?, conditions?, content?, priority? }
|
|
1259
|
+
*/
|
|
1260
|
+
async updateMatchVersion(groupId, fileId, matchVersionId, updates) {
|
|
1261
|
+
const existing = await this.getMatchVersionById(groupId, fileId, matchVersionId)
|
|
1262
|
+
if (!existing) {
|
|
1263
|
+
throw new Error(`匹配规则不存在: ${matchVersionId}`)
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const updated = {
|
|
1267
|
+
...existing,
|
|
1268
|
+
...updates,
|
|
1269
|
+
id: matchVersionId, // 确保ID不被修改
|
|
1270
|
+
updateTime: Date.now(),
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const matchVersionFile = path.join(
|
|
1274
|
+
this.getMatchVersionsDir(groupId, fileId),
|
|
1275
|
+
`${matchVersionId}.json`,
|
|
1276
|
+
)
|
|
1277
|
+
await this._writeJSON(matchVersionFile, updated)
|
|
1278
|
+
|
|
1279
|
+
return updated
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* 删除匹配规则(通过ID)
|
|
1284
|
+
*/
|
|
1285
|
+
async deleteMatchVersion(groupId, fileId, matchVersionId) {
|
|
1286
|
+
const matchVersionFile = path.join(
|
|
1287
|
+
this.getMatchVersionsDir(groupId, fileId),
|
|
1288
|
+
`${matchVersionId}.json`,
|
|
1289
|
+
)
|
|
1290
|
+
await fs.unlink(matchVersionFile)
|
|
1291
|
+
|
|
1292
|
+
// 同步更新索引中的 matchVersionCount
|
|
1293
|
+
const count = await this.getMatchVersionCount(groupId, fileId)
|
|
1294
|
+
await this._updateInIndex(groupId, fileId, { matchVersionCount: count })
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1137
1297
|
// ========================================
|
|
1138
1298
|
// 内部辅助方法
|
|
1139
1299
|
// ========================================
|