whistle.mockbubu 2.1.5 → 2.2.0-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/lib/config/const.js +11 -0
- package/lib/core/server-entry/response-handler.js +198 -7
- package/lib/core/server-entry/server.js +18 -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 +1955 -319
- 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,224 @@
|
|
|
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
|
+
// 精确匹配:参数名和参数值必须完全相等(都转为字符串比较)
|
|
128
|
+
return String(sourceParams[cond.key]) === String(cond.value)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if (allMatch) {
|
|
132
|
+
return {
|
|
133
|
+
matched: true,
|
|
134
|
+
content: rule.content ?? {},
|
|
135
|
+
ruleName: rule.name,
|
|
136
|
+
paramsSummary: rule.conditions
|
|
137
|
+
.map(c => `${c.source}.${c.key}=${c.value}`)
|
|
138
|
+
.join(', '),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 尝试兜底规则(空条件规则)
|
|
144
|
+
if (fallbackRule) {
|
|
145
|
+
return {
|
|
146
|
+
matched: true,
|
|
147
|
+
content: fallbackRule.content ?? {},
|
|
148
|
+
ruleName: fallbackRule.name,
|
|
149
|
+
paramsSummary: '(default)',
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { matched: false }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 从原始请求中提取 query 参数
|
|
158
|
+
* 来源:req.originalReq.url,fallback 到 x-whistle-full-url header
|
|
159
|
+
*
|
|
160
|
+
* @param {Object} originalReq - Whistle 原始请求对象
|
|
161
|
+
* @returns {Object} query 参数 key-value 对
|
|
162
|
+
*/
|
|
163
|
+
function extractQueryParams(originalReq) {
|
|
164
|
+
if (!originalReq) return {}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
// 某些 Whistle 代理场景(tunnel/HTTP2)下 originalReq.url 可能不含 query,
|
|
168
|
+
// 完整 URL 会在 x-whistle-full-url header 里(URL 编码格式)
|
|
169
|
+
const fullUrlHeader = originalReq.headers?.['x-whistle-full-url']
|
|
170
|
+
const rawUrl = originalReq.url ||
|
|
171
|
+
(fullUrlHeader ? decodeURIComponent(fullUrlHeader) : '') ||
|
|
172
|
+
''
|
|
173
|
+
const url = new URL(rawUrl)
|
|
174
|
+
const params = {}
|
|
175
|
+
url.searchParams.forEach((value, key) => {
|
|
176
|
+
params[key] = value
|
|
177
|
+
})
|
|
178
|
+
return params
|
|
179
|
+
} catch {
|
|
180
|
+
return {}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 从原始请求中提取 payload 参数(仅支持 JSON,只提取第一层 key-value)
|
|
186
|
+
* 来源:req.originalReq.body
|
|
187
|
+
*
|
|
188
|
+
* 非 JSON payload 时返回空对象,条件匹配只会比较 query 部分。
|
|
189
|
+
*
|
|
190
|
+
* @param {Object} originalReq - Whistle 原始请求对象
|
|
191
|
+
* @returns {Object} payload 参数 key-value 对(值统一转为字符串)
|
|
192
|
+
*/
|
|
193
|
+
function extractPayloadParams(originalReq) {
|
|
194
|
+
if (!originalReq) return {}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
let body = originalReq.body || ''
|
|
198
|
+
|
|
199
|
+
// Buffer 转字符串
|
|
200
|
+
if (Buffer.isBuffer(body)) {
|
|
201
|
+
body = body.toString('utf8')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 修复3:body 可能已被 Whistle 预解析为对象,直接提取而非走 JSON.parse
|
|
205
|
+
if (typeof body === 'object' && body !== null) {
|
|
206
|
+
return extractFlatParams(body)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (typeof body !== 'string' || !body.trim()) {
|
|
210
|
+
return {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const parsed = JSON.parse(body)
|
|
214
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
215
|
+
return {}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return extractFlatParams(parsed)
|
|
219
|
+
} catch {
|
|
220
|
+
return {}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 从对象中提取第一层 string/number/boolean 类型的 key-value(统一转为字符串)
|
|
226
|
+
* @param {Object} obj
|
|
227
|
+
* @returns {Object}
|
|
228
|
+
*/
|
|
229
|
+
function extractFlatParams(obj) {
|
|
230
|
+
const params = {}
|
|
231
|
+
Object.keys(obj).forEach((key) => {
|
|
232
|
+
const val = obj[key]
|
|
233
|
+
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
|
234
|
+
params[key] = String(val)
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
return params
|
|
238
|
+
}
|
|
239
|
+
|
|
51
240
|
/**
|
|
52
241
|
* 检查是否需要捕获源数据
|
|
53
242
|
* 如果文件已存在配置,则透传;如果是首次访问,则需要捕获
|
|
@@ -99,6 +288,7 @@ async function handleConcurrentCapture(capturingFiles, captureKey, params) {
|
|
|
99
288
|
filename,
|
|
100
289
|
mockVersion,
|
|
101
290
|
filePath,
|
|
291
|
+
originalReq,
|
|
102
292
|
} = params
|
|
103
293
|
|
|
104
294
|
if (!capturingFiles.has(captureKey)) {
|
|
@@ -125,6 +315,7 @@ async function handleConcurrentCapture(capturingFiles, captureKey, params) {
|
|
|
125
315
|
groupManager,
|
|
126
316
|
currentGroupId,
|
|
127
317
|
filename,
|
|
318
|
+
originalReq,
|
|
128
319
|
})
|
|
129
320
|
|
|
130
321
|
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 {
|
|
@@ -69,6 +68,10 @@ module.exports = (server, options) => {
|
|
|
69
68
|
return req.passThrough()
|
|
70
69
|
}
|
|
71
70
|
|
|
71
|
+
// 在所有 await 操作之前提前捕获 originalReq
|
|
72
|
+
// Whistle 在异步操作完成后可能会清空 req.originalReq,导致后续读取为 null
|
|
73
|
+
const originalReq = req.originalReq
|
|
74
|
+
|
|
72
75
|
// 提取请求信息
|
|
73
76
|
const { filename, rule, method, url, pattern, ruleValue, mode } = interceptResult
|
|
74
77
|
|
|
@@ -98,12 +101,15 @@ module.exports = (server, options) => {
|
|
|
98
101
|
|
|
99
102
|
// 有 mock 且有数据 → 返回 mock(不捕获)
|
|
100
103
|
console.log(`[mockbubu] ✅ Mock-Only 模式: 返回 Mock 数据(不捕获) | URL: ${url}`)
|
|
101
|
-
|
|
104
|
+
const mockOnlyResult = await sendMockResponse(res, sourceData, {
|
|
102
105
|
mockVersion,
|
|
103
106
|
groupManager,
|
|
104
107
|
currentGroupId,
|
|
105
108
|
filename,
|
|
109
|
+
originalReq,
|
|
106
110
|
})
|
|
111
|
+
if (mockOnlyResult?.action === 'passthrough') return req.passThrough()
|
|
112
|
+
return
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
// 以下是 Capture 模式的逻辑
|
|
@@ -111,32 +117,23 @@ module.exports = (server, options) => {
|
|
|
111
117
|
|
|
112
118
|
// 场景 1: Mock 启用 + 有源数据 → 返回 Mock 数据
|
|
113
119
|
if (mock && sourceData) {
|
|
114
|
-
|
|
120
|
+
const mockResult = await sendMockResponse(res, sourceData, {
|
|
115
121
|
mockVersion,
|
|
116
122
|
groupManager,
|
|
117
123
|
currentGroupId,
|
|
118
124
|
filename,
|
|
125
|
+
originalReq,
|
|
119
126
|
})
|
|
127
|
+
if (mockResult?.action === 'passthrough') return req.passThrough()
|
|
128
|
+
return
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
// 场景 2: Mock 启用 + 无源数据 → 容错恢复(自动捕获源数据)
|
|
123
132
|
// 可能原因:文件被删除、索引损坏、组导入不完整等
|
|
133
|
+
// sourceData=null 已证明物理文件无有效数据,不再通过索引判断是否跳过(索引残留会误阻恢复捕获)
|
|
124
134
|
if (mock && !sourceData) {
|
|
125
135
|
const captureKey = `${currentGroupId}/${filename}`
|
|
126
136
|
|
|
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
137
|
// 处理并发捕获
|
|
141
138
|
const concurrentResult = await handleConcurrentCapture(capturingFiles, captureKey, {
|
|
142
139
|
res,
|
|
@@ -146,6 +143,7 @@ module.exports = (server, options) => {
|
|
|
146
143
|
filename,
|
|
147
144
|
mockVersion,
|
|
148
145
|
filePath,
|
|
146
|
+
originalReq,
|
|
149
147
|
})
|
|
150
148
|
|
|
151
149
|
if (concurrentResult.handled) {
|
|
@@ -164,7 +162,7 @@ module.exports = (server, options) => {
|
|
|
164
162
|
memoryBuffer,
|
|
165
163
|
currentGroupId,
|
|
166
164
|
filename,
|
|
167
|
-
isFirstCapture,
|
|
165
|
+
isFirstCapture: true,
|
|
168
166
|
mock: true, // mock 状态
|
|
169
167
|
requestInfo: { method, rule, pattern, ruleValue, url },
|
|
170
168
|
capturingFiles,
|
|
@@ -179,23 +177,10 @@ module.exports = (server, options) => {
|
|
|
179
177
|
return req.passThrough()
|
|
180
178
|
}
|
|
181
179
|
|
|
182
|
-
// 3.2: 无源数据 →
|
|
180
|
+
// 3.2: 无源数据 → 捕获
|
|
181
|
+
// sourceData=null 已证明物理文件无有效数据,不再通过索引判断是否跳过(索引残留会误阻捕获)
|
|
183
182
|
const captureKey = `${currentGroupId}/${filename}`
|
|
184
183
|
|
|
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
184
|
// 检查是否正在捕获中
|
|
200
185
|
if (capturingFiles.has(captureKey)) {
|
|
201
186
|
// mock=false 时,并发请求直接透传(不等待)
|
|
@@ -212,7 +197,7 @@ module.exports = (server, options) => {
|
|
|
212
197
|
memoryBuffer,
|
|
213
198
|
currentGroupId,
|
|
214
199
|
filename,
|
|
215
|
-
isFirstCapture,
|
|
200
|
+
isFirstCapture: true,
|
|
216
201
|
mock: false, // mock 状态
|
|
217
202
|
requestInfo: { method, rule, pattern, ruleValue, url },
|
|
218
203
|
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
|
// ========================================
|