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.
@@ -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, 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
+ // 精确匹配:参数名和参数值必须完全相等(都转为字符串比较)
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
- return await sendMockResponse(res, sourceData, {
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
- return await sendMockResponse(res, sourceData, {
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
  // ========================================
@@ -49,8 +49,7 @@ module.exports = (router) => {
49
49
  * method: 'GET',
50
50
  * status: 200,
51
51
  * mock: true,
52
- * locked: false,
53
- * session: {...} // 完整会话数据
52
+ * locked: false
54
53
  * }
55
54
  * ],
56
55
  * msg: '操作成功'