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.
@@ -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, headers } = sourceResponse
30
+ const { statusCode, statusMessage } = sourceResponse
27
31
 
28
- // 设置响应头标记 (来自 Mock)
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
- res.writeHead(statusCode, statusMessage, headers)
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
- // 检查是否需要使用版本数据 (用户编辑的 Mock 数据)
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
- return await sendMockResponse(res, sourceData, {
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
- return await sendMockResponse(res, sourceData, {
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
  // ========================================