whistle.mockbubu 2.0.0 → 2.1.0
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/README.md +38 -0
- package/index.js +3 -3
- package/lib/config/const.js +138 -0
- package/lib/config/rule-collector.js +81 -0
- package/lib/constants.js +62 -0
- package/lib/core/memory-buffer/index.js +207 -0
- package/lib/core/memory-buffer/shared-instance.js +15 -0
- package/lib/core/plugin-mode-manager.js +74 -0
- package/lib/core/resRulesServer.js +14 -0
- package/lib/core/rulesServer.js +31 -0
- package/lib/core/server-entry/capture-handler.js +191 -0
- package/lib/core/server-entry/request-interceptor.js +82 -0
- package/lib/core/server-entry/response-handler.js +147 -0
- package/lib/core/server-entry/server.js +230 -0
- package/lib/storage/group-manager.js +627 -0
- package/lib/storage/storage-adapter.js +712 -0
- package/lib/storage/storage-v3.js +1418 -0
- package/lib/uiServer/index.js +61 -24
- package/lib/uiServer/router/export/import-export-router.js +459 -0
- package/lib/uiServer/router/files/api-list-router.js +150 -0
- package/lib/uiServer/router/files/batch-operations-router.js +185 -0
- package/lib/uiServer/router/files/file-config-router.js +118 -0
- package/lib/uiServer/router/files/file-crud-router.js +212 -0
- package/lib/uiServer/router/files/file-save-router.js +146 -0
- package/lib/uiServer/router/files/version-router.js +260 -0
- package/lib/uiServer/router/global/plugin-control.js +135 -0
- package/lib/uiServer/router/global/system-info-router.js +386 -0
- package/lib/uiServer/router/{group-router.js → groups/group-router.js} +21 -20
- package/lib/uiServer/router/index.js +38 -1521
- package/lib/uiServer/utils/router-helpers.js +100 -0
- package/lib/uiServer/utils/util.js +172 -0
- package/lib/uiServer/{validator.js → utils/validator.js} +11 -6
- package/lib/utils/archive-utils.js +788 -0
- package/lib/utils/error-handler.js +173 -0
- package/lib/utils/logger.js +79 -0
- package/lib/utils/path-utils.js +147 -0
- package/lib/utils/performance.js +265 -0
- package/lib/utils/utils.js +541 -0
- package/package.json +2 -2
- package/public/js/app.js +3707 -1922
- package/public/js/app.js.map +1 -1
- package/public/js/chunk-vendors.js +5098 -3965
- package/public/js/chunk-vendors.js.map +1 -1
- package/rules.txt +1 -1
- package/CHANGELOG_GROUP_FEATURE.md +0 -468
- package/CHANGELOG_P0_FIXES.md +0 -412
- package/CHANGELOG_P1_OPTIMIZATIONS.md +0 -292
- package/CLAUDE.md +0 -436
- package/GROUP_FEATURE_DESIGN.md +0 -520
- package/lib/const.js +0 -47
- package/lib/group-manager.js +0 -491
- package/lib/resRulesServer.js +0 -9
- package/lib/server.js +0 -249
- package/lib/uiServer/router/version-router.js +0 -205
- package/lib/uiServer/util.js +0 -153
- package/lib/utils.js +0 -409
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件名: storage-adapter.js
|
|
3
|
+
* 功能: Storage Adapter - V3 存储适配层
|
|
4
|
+
* 依赖: storage-v3.js, logger.js, path-utils.js, error-handler.js
|
|
5
|
+
* 更新: 2025-12-05
|
|
6
|
+
*
|
|
7
|
+
* 职责:
|
|
8
|
+
* - 封装 V3 FileSystemStorage,提供统一的存储接口
|
|
9
|
+
* - 处理路径解析、分组逻辑、错误处理
|
|
10
|
+
* - 为上层路由和中间件提供简洁的 API
|
|
11
|
+
*
|
|
12
|
+
* 设计模式:
|
|
13
|
+
* - 适配器模式: 封装底层存储实现细节
|
|
14
|
+
* - 单例模式: 全局共享存储实例
|
|
15
|
+
*
|
|
16
|
+
* 使用示例:
|
|
17
|
+
* ```javascript
|
|
18
|
+
* const adapter = new StorageAdapter({ baseDir: '~/.whistle' })
|
|
19
|
+
* await adapter.init()
|
|
20
|
+
* await adapter.writeFile('default/https://api.test.com', '{"data": {...}}')
|
|
21
|
+
* const content = await adapter.readFile('default/https://api.test.com')
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const FileSystemStorage = require('./storage-v3')
|
|
26
|
+
const path = require('path')
|
|
27
|
+
const { createLogger } = require('../utils/logger')
|
|
28
|
+
const { parsePath, parseConfigKey } = require('../utils/path-utils')
|
|
29
|
+
const { withErrorHandling } = require('../utils/error-handler')
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* StorageAdapter 类
|
|
33
|
+
* V3 文件系统存储的适配层,提供统一的存储接口
|
|
34
|
+
*/
|
|
35
|
+
class StorageAdapter {
|
|
36
|
+
/**
|
|
37
|
+
* 构造函数
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} options - 配置选项
|
|
40
|
+
* @param {string} options.baseDir - 基础目录路径(可选)
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* const adapter = new StorageAdapter({
|
|
44
|
+
* baseDir: '~/.whistle'
|
|
45
|
+
* })
|
|
46
|
+
*/
|
|
47
|
+
constructor(options) {
|
|
48
|
+
// 初始化 logger
|
|
49
|
+
this.logger = createLogger('storage-adapter')
|
|
50
|
+
|
|
51
|
+
// 计算基础目录路径
|
|
52
|
+
let baseDir = path.join(
|
|
53
|
+
process.env.HOME,
|
|
54
|
+
'.WhistleAppData/.whistle/.plugins/whistle.mockbubu',
|
|
55
|
+
)
|
|
56
|
+
if (options.baseDir) {
|
|
57
|
+
baseDir = `${options.baseDir}/.plugins/whistle.mockbubu`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 记录基础目录(生产环境可保留,便于问题排查)
|
|
61
|
+
this.logger.log('基础目录:', baseDir)
|
|
62
|
+
|
|
63
|
+
// 初始化 V3 存储实例
|
|
64
|
+
this.v3Storage = new FileSystemStorage(baseDir)
|
|
65
|
+
this.currentGroupId = 'default'
|
|
66
|
+
this.initialized = false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 初始化存储适配器
|
|
71
|
+
* 必须在使用前调用,负责初始化底层 V3 存储并加载当前组ID
|
|
72
|
+
*
|
|
73
|
+
* @returns {Promise<void>}
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* const adapter = new StorageAdapter(options)
|
|
77
|
+
* await adapter.init() // 必须先初始化
|
|
78
|
+
* await adapter.readFile('default/test.json')
|
|
79
|
+
*/
|
|
80
|
+
async init() {
|
|
81
|
+
if (this.initialized) return
|
|
82
|
+
|
|
83
|
+
// 初始化 V3 存储(创建目录结构、加载元数据等)
|
|
84
|
+
await this.v3Storage.init()
|
|
85
|
+
|
|
86
|
+
// 从文件系统加载当前激活的组ID
|
|
87
|
+
this.currentGroupId = await this.v3Storage.getCurrentGroupId()
|
|
88
|
+
|
|
89
|
+
this.initialized = true
|
|
90
|
+
this.logger.log('存储适配器初始化完成,当前组:', this.currentGroupId)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 确保已初始化(内部方法)
|
|
95
|
+
* 所有对外API调用前都应该先调用此方法
|
|
96
|
+
*
|
|
97
|
+
* @private
|
|
98
|
+
* @returns {Promise<void>}
|
|
99
|
+
*/
|
|
100
|
+
async _ensureInit() {
|
|
101
|
+
if (!this.initialized) {
|
|
102
|
+
await this.init()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 设置当前激活的组ID
|
|
108
|
+
* 会持久化到文件系统,重启后仍然有效
|
|
109
|
+
*
|
|
110
|
+
* @param {string} groupId - 组ID
|
|
111
|
+
* @returns {Promise<void>}
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* await adapter.setCurrentGroupId('group_123')
|
|
115
|
+
*/
|
|
116
|
+
async setCurrentGroupId(groupId) {
|
|
117
|
+
this.currentGroupId = groupId
|
|
118
|
+
|
|
119
|
+
// V3 架构:持久化到文件系统(写入组元数据)
|
|
120
|
+
if (this.getV3Storage) {
|
|
121
|
+
const v3Storage = this.getV3Storage()
|
|
122
|
+
await v3Storage.setCurrentGroupId(groupId)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.logger.log('切换当前组:', groupId)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 获取当前激活的组ID
|
|
130
|
+
* 从文件系统读取,确保获取最新值
|
|
131
|
+
*
|
|
132
|
+
* @returns {Promise<string>} 当前组ID
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* const currentGroup = await adapter.getCurrentGroupId()
|
|
136
|
+
* console.log('当前组:', currentGroup)
|
|
137
|
+
*/
|
|
138
|
+
async getCurrentGroupId() {
|
|
139
|
+
// V3 架构:从文件系统读取(确保获取最新值)
|
|
140
|
+
if (this.getV3Storage) {
|
|
141
|
+
const v3Storage = this.getV3Storage()
|
|
142
|
+
this.currentGroupId = await v3Storage.getCurrentGroupId()
|
|
143
|
+
}
|
|
144
|
+
return this.currentGroupId
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ========================================
|
|
148
|
+
// 文件操作 API
|
|
149
|
+
// ========================================
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 读取文件内容
|
|
153
|
+
* 通过 URL 查找文件,返回 session 数据的 JSON 字符串
|
|
154
|
+
*
|
|
155
|
+
* 工作流程:
|
|
156
|
+
* 1. 解析路径得到 groupId 和 filename(URL)
|
|
157
|
+
* 2. 强制刷新索引缓存(避免读到过期数据)
|
|
158
|
+
* 3. 从索引中根据 URL 查找 fileId
|
|
159
|
+
* 4. 使用 fileId 读取完整文件数据
|
|
160
|
+
* 5. 返回 session 数据的 JSON 字符串
|
|
161
|
+
*
|
|
162
|
+
* @param {string} name - 文件路径(格式:{groupId}/{filename})
|
|
163
|
+
* @returns {Promise<string|null>} session 数据的 JSON 字符串,不存在返回 null
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* const data = await adapter.readFile('default/https://api.test.com/users')
|
|
167
|
+
* const session = JSON.parse(data)
|
|
168
|
+
* console.log(session.res.body) // 响应内容
|
|
169
|
+
*/
|
|
170
|
+
async readFile(name) {
|
|
171
|
+
await this._ensureInit()
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
// 解析路径:提取 groupId 和 filename(URL)
|
|
175
|
+
const { groupId, filename } = parsePath(name, this.currentGroupId)
|
|
176
|
+
|
|
177
|
+
// 强制刷新索引缓存,确保读取最新数据
|
|
178
|
+
// 这很重要:避免删除文件后立即读取时读到旧缓存
|
|
179
|
+
this.v3Storage.invalidateIndex(groupId)
|
|
180
|
+
|
|
181
|
+
// 从索引查找对应的 fileId
|
|
182
|
+
// 注意:V3 架构中 fileId 是随机生成的,无法从 URL 推导
|
|
183
|
+
const index = await this.v3Storage.getIndex(groupId)
|
|
184
|
+
const entry = index.files.find(f => f.url === filename)
|
|
185
|
+
|
|
186
|
+
if (!entry) {
|
|
187
|
+
this.logger.warn(`文件不存在: ${filename}`)
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 使用索引中的 fileId 读取完整文件数据
|
|
192
|
+
const file = await this.v3Storage.getFile(groupId, entry.id)
|
|
193
|
+
|
|
194
|
+
if (!file) {
|
|
195
|
+
this.logger.warn(`文件不存在: ${groupId}/${entry.id}`)
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ✅ V3 架构:session 数据存储在 file.json 的 session 字段中
|
|
200
|
+
// 直接从文件数据读取 session
|
|
201
|
+
const sessionData = file.session
|
|
202
|
+
|
|
203
|
+
if (!sessionData) {
|
|
204
|
+
this.logger.warn(`session 数据为空: ${groupId}/${entry.id}`)
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 返回 session 数据的 JSON 字符串
|
|
209
|
+
return JSON.stringify(sessionData)
|
|
210
|
+
} catch (err) {
|
|
211
|
+
this.logger.error('readFile 失败:', err.message)
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 写入文件(创建或更新)
|
|
218
|
+
* 接收 session 数据的 JSON 字符串,自动判断是新建还是更新
|
|
219
|
+
*
|
|
220
|
+
* 工作流程:
|
|
221
|
+
* 1. 解析路径得到 groupId 和 filename
|
|
222
|
+
* 2. 尝试解析 JSON 数据为 session 对象
|
|
223
|
+
* 3. 强制刷新索引缓存
|
|
224
|
+
* 4. 查找是否已存在相同 URL 的文件
|
|
225
|
+
* 5. 存在则更新,不存在则创建新文件
|
|
226
|
+
*
|
|
227
|
+
* 错误处理:
|
|
228
|
+
* - JSON 解析失败时,会构造最小可用的 session 对象并标记错误
|
|
229
|
+
* - 存储失败不会抛出异常,避免影响正常请求流程
|
|
230
|
+
*
|
|
231
|
+
* @param {string} name - 文件路径(格式:{groupId}/{filename})
|
|
232
|
+
* @param {string} data - session 数据的 JSON 字符串
|
|
233
|
+
* @returns {Promise<void>}
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* const session = {
|
|
237
|
+
* req: { method: 'GET', url: '/users', headers: {...} },
|
|
238
|
+
* res: { statusCode: 200, headers: {...}, body: '{"data": [...]}' }
|
|
239
|
+
* }
|
|
240
|
+
* await adapter.writeFile('default/https://api.test.com/users', JSON.stringify(session))
|
|
241
|
+
*/
|
|
242
|
+
async writeFile(name, data) {
|
|
243
|
+
await this._ensureInit()
|
|
244
|
+
|
|
245
|
+
const { groupId, filename } = parsePath(name, this.currentGroupId)
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
// 1. 解析 session 数据
|
|
249
|
+
let session
|
|
250
|
+
let hasParseError = false
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
session = JSON.parse(data)
|
|
254
|
+
this.logger.log(`JSON 解析成功: method=${session.req?.method}, status=${session.res?.statusCode}`)
|
|
255
|
+
} catch (parseErr) {
|
|
256
|
+
// JSON 解析失败 - 可能是非标准格式,但仍然保存(宽容处理)
|
|
257
|
+
this.logger.warn(`JSON 解析失败,将保存原始数据: ${filename}`)
|
|
258
|
+
this.logger.warn(`解析错误: ${parseErr.message}`)
|
|
259
|
+
hasParseError = true
|
|
260
|
+
|
|
261
|
+
// 构造最小可用的 session 对象,保存原始响应数据
|
|
262
|
+
session = {
|
|
263
|
+
req: {
|
|
264
|
+
method: 'UNKNOWN',
|
|
265
|
+
headers: {},
|
|
266
|
+
},
|
|
267
|
+
res: {
|
|
268
|
+
statusCode: 0,
|
|
269
|
+
headers: {},
|
|
270
|
+
body: data, // 保存原始的未解析数据
|
|
271
|
+
},
|
|
272
|
+
_parseError: parseErr.message, // 标记解析错误,便于后续排查
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 2. 强制刷新索引缓存(确保查询到最新状态)
|
|
277
|
+
this.v3Storage.invalidateIndex(groupId)
|
|
278
|
+
|
|
279
|
+
// 3. 从索引查找是否已有相同 URL 的文件
|
|
280
|
+
const index = await this.v3Storage.getIndex(groupId)
|
|
281
|
+
const existingEntry = index.files.find(f => f.url === filename)
|
|
282
|
+
|
|
283
|
+
if (existingEntry) {
|
|
284
|
+
// 4a. 更新现有文件(使用已有的 fileId)
|
|
285
|
+
this.logger.log(`更新现有文件: ${existingEntry.id}`)
|
|
286
|
+
await this.v3Storage.updateFile(groupId, existingEntry.id, {
|
|
287
|
+
session,
|
|
288
|
+
updateTime: Date.now(),
|
|
289
|
+
hasParseError, // 标记是否有解析错误
|
|
290
|
+
})
|
|
291
|
+
this.logger.log(`文件更新成功${hasParseError ? ' (包含解析错误)' : ''}`)
|
|
292
|
+
} else {
|
|
293
|
+
// 4b. 创建新文件(生成新的随机 fileId)
|
|
294
|
+
const newFileId = this.v3Storage.generateFileId()
|
|
295
|
+
this.logger.log(`创建新文件: ${newFileId}`)
|
|
296
|
+
await this.v3Storage.createFile(groupId, newFileId, {
|
|
297
|
+
url: filename,
|
|
298
|
+
method: session.req?.method || 'UNKNOWN',
|
|
299
|
+
status: session.res?.statusCode || 0,
|
|
300
|
+
headers: session.res?.headers || {},
|
|
301
|
+
session,
|
|
302
|
+
config: {
|
|
303
|
+
mock: false, // 默认不启用 mock
|
|
304
|
+
locked: false, // 默认不锁定
|
|
305
|
+
mockVersion: 'source', // 默认使用原始版本
|
|
306
|
+
},
|
|
307
|
+
hasParseError, // 标记是否有解析错误
|
|
308
|
+
})
|
|
309
|
+
this.logger.log(`文件创建成功${hasParseError ? ' (包含解析错误)' : ''}`)
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
// 存储层错误 - 记录但不抛出,避免影响正常流量
|
|
313
|
+
// 这个设计很重要:存储失败不应该导致用户请求失败
|
|
314
|
+
this.logger.error(`写入文件失败: ${filename}`)
|
|
315
|
+
this.logger.error(`错误详情: ${err.message}`)
|
|
316
|
+
|
|
317
|
+
// 仅在开发环境输出堆栈
|
|
318
|
+
if (process.env.NODE_ENV === 'development') {
|
|
319
|
+
this.logger.error('错误堆栈:', err.stack)
|
|
320
|
+
}
|
|
321
|
+
// 不再抛出错误,避免中断请求流程
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 删除文件
|
|
327
|
+
* 根据 URL 查找文件并删除(包括所有版本数据)
|
|
328
|
+
*
|
|
329
|
+
* 工作流程:
|
|
330
|
+
* 1. 解析路径得到 groupId 和 filename
|
|
331
|
+
* 2. 强制刷新索引缓存
|
|
332
|
+
* 3. 从索引中根据 URL 查找 fileId
|
|
333
|
+
* 4. 使用 fileId 删除文件(包括目录和所有版本)
|
|
334
|
+
*
|
|
335
|
+
* 错误处理:
|
|
336
|
+
* - 文件不存在也视为成功(幂等操作)
|
|
337
|
+
*
|
|
338
|
+
* @param {string} name - 文件路径(格式:{groupId}/{filename})
|
|
339
|
+
* @returns {Promise<void>}
|
|
340
|
+
*
|
|
341
|
+
* @example
|
|
342
|
+
* await adapter.removeFile('default/https://api.test.com/users')
|
|
343
|
+
*/
|
|
344
|
+
async removeFile(name) {
|
|
345
|
+
await this._ensureInit()
|
|
346
|
+
|
|
347
|
+
const { groupId, filename } = parsePath(name, this.currentGroupId)
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
// 强制刷新索引缓存(确保查询到最新状态)
|
|
351
|
+
this.v3Storage.invalidateIndex(groupId)
|
|
352
|
+
|
|
353
|
+
// 从索引查找对应的 fileId
|
|
354
|
+
const index = await this.v3Storage.getIndex(groupId)
|
|
355
|
+
const entry = index.files.find(f => f.url === filename)
|
|
356
|
+
|
|
357
|
+
if (!entry) {
|
|
358
|
+
this.logger.warn(`文件不存在,无需删除: ${filename}`)
|
|
359
|
+
return // 幂等操作:文件不存在也视为成功
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
this.logger.log(`删除文件: ${entry.id} (${filename})`)
|
|
363
|
+
await this.v3Storage.deleteFile(groupId, entry.id)
|
|
364
|
+
this.logger.log('删除成功')
|
|
365
|
+
} catch (err) {
|
|
366
|
+
this.logger.error('删除失败:', err.message)
|
|
367
|
+
|
|
368
|
+
// 仅在开发环境输出堆栈
|
|
369
|
+
if (process.env.NODE_ENV === 'development') {
|
|
370
|
+
this.logger.error('错误堆栈:', err.stack)
|
|
371
|
+
}
|
|
372
|
+
// 文件不存在也视为成功,不抛出异常
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 获取所有组的文件列表
|
|
378
|
+
* 返回所有组中所有文件的列表
|
|
379
|
+
*
|
|
380
|
+
* 工作流程:
|
|
381
|
+
* 1. 获取所有组列表
|
|
382
|
+
* 2. 遍历每个组,获取文件列表
|
|
383
|
+
* 3. 构造文件对象(格式:{groupId}/{url})
|
|
384
|
+
*
|
|
385
|
+
* @returns {Promise<Array>} 文件列表,每个文件包含 name 和 size
|
|
386
|
+
*
|
|
387
|
+
* @example
|
|
388
|
+
* const files = await adapter.getFileList()
|
|
389
|
+
* files.forEach(file => {
|
|
390
|
+
* console.log(file.name, file.size)
|
|
391
|
+
* })
|
|
392
|
+
*/
|
|
393
|
+
async getFileList() {
|
|
394
|
+
await this._ensureInit()
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
const groups = await this.v3Storage.listGroups()
|
|
398
|
+
const allFiles = []
|
|
399
|
+
|
|
400
|
+
for (const group of groups) {
|
|
401
|
+
const files = await this.v3Storage.listFiles(group.id)
|
|
402
|
+
|
|
403
|
+
files.forEach(file => {
|
|
404
|
+
// 构造文件名(格式:{groupId}/{url})
|
|
405
|
+
const name = `${group.id}/${file.url}`
|
|
406
|
+
|
|
407
|
+
allFiles.push({
|
|
408
|
+
name, // 文件路径标识
|
|
409
|
+
size: file.size || 0, // 文件大小(字节)
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return allFiles
|
|
415
|
+
} catch (err) {
|
|
416
|
+
this.logger.error('getFileList 失败:', err.message)
|
|
417
|
+
return [] // 失败时返回空数组,避免中断业务
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ========================================
|
|
422
|
+
// 属性操作 API
|
|
423
|
+
// ========================================
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 获取属性
|
|
427
|
+
* @param {string} key - 属性键
|
|
428
|
+
*/
|
|
429
|
+
async getProperty(key) {
|
|
430
|
+
await this._ensureInit()
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
// 特殊处理:组列表
|
|
434
|
+
if (key === '__groups__') {
|
|
435
|
+
return await this._getGroupsProperty()
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 特殊处理:组文件配置
|
|
439
|
+
if (key.startsWith('group.') && key.includes('.file.')) {
|
|
440
|
+
return await this._getFileConfigProperty(key)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 全局配置:从 V3 storage 的 _meta.json.config 读取
|
|
444
|
+
return await this.v3Storage.getConfig(key)
|
|
445
|
+
} catch (err) {
|
|
446
|
+
return null
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 设置属性
|
|
452
|
+
* @param {string} key - 属性键
|
|
453
|
+
* @param {*} value - 属性值
|
|
454
|
+
*/
|
|
455
|
+
async setProperty(key, value) {
|
|
456
|
+
await this._ensureInit()
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
// 特殊处理:组列表
|
|
460
|
+
if (key === '__groups__') {
|
|
461
|
+
// 新架构不需要手动管理组列表
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 特殊处理:组文件配置
|
|
466
|
+
if (key.startsWith('group.') && key.includes('.file.')) {
|
|
467
|
+
await this._setFileConfigProperty(key, value)
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 全局配置:写入 V3 storage 的 _meta.json.config
|
|
472
|
+
await this.v3Storage.setConfig(key, value)
|
|
473
|
+
} catch (err) {
|
|
474
|
+
console.error('[storage-adapter] setProperty failed:', key, err.message)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 检查属性是否存在
|
|
480
|
+
* @param {string} key - 属性键
|
|
481
|
+
*/
|
|
482
|
+
async hasProperty(key) {
|
|
483
|
+
await this._ensureInit()
|
|
484
|
+
|
|
485
|
+
const value = await this.getProperty(key)
|
|
486
|
+
return value !== null && value !== undefined
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 检查文件是否在索引中(用于 Issue #4 检测)
|
|
491
|
+
*
|
|
492
|
+
* 与 hasProperty 的区别:
|
|
493
|
+
* - hasProperty:检查物理文件是否存在
|
|
494
|
+
* - hasFileInIndex:检查索引中是否有记录(即使物理文件被删除)
|
|
495
|
+
*
|
|
496
|
+
* 使用场景:
|
|
497
|
+
* - 检测删除后立即捕获时,索引可能还有记录但物理文件已删除
|
|
498
|
+
* - 用于诊断文件系统和索引不一致的问题
|
|
499
|
+
*
|
|
500
|
+
* @param {string} key - 配置键,格式:group.{groupId}.file.{filename}
|
|
501
|
+
* @returns {Promise<boolean>} 索引中是否存在该文件
|
|
502
|
+
*
|
|
503
|
+
* @example
|
|
504
|
+
* const exists = await adapter.hasFileInIndex('group.default.file.https://api.test.com/users')
|
|
505
|
+
* if (!exists) {
|
|
506
|
+
* console.log('索引中不存在该文件')
|
|
507
|
+
* }
|
|
508
|
+
*/
|
|
509
|
+
async hasFileInIndex(key) {
|
|
510
|
+
await this._ensureInit()
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
// 1. 验证配置键格式
|
|
514
|
+
if (!key.startsWith('group.') || !key.includes('.file.')) {
|
|
515
|
+
return false
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 2. 解析配置键得到 groupId 和 filename
|
|
519
|
+
const { groupId, filename } = parseConfigKey(key)
|
|
520
|
+
|
|
521
|
+
// 3. 强制刷新索引缓存,确保读取最新数据
|
|
522
|
+
// 这很重要:避免删除后立即捕获时读到旧缓存
|
|
523
|
+
this.v3Storage.invalidateIndex(groupId)
|
|
524
|
+
|
|
525
|
+
// 4. 检查索引中是否存在(通过 URL 而不是 fileId)
|
|
526
|
+
// 注意:V3 架构中 fileId 是创建时生成的随机ID
|
|
527
|
+
const index = await this.v3Storage.getIndex(groupId)
|
|
528
|
+
const exists = index.files.some(f => f.url === filename)
|
|
529
|
+
|
|
530
|
+
return exists
|
|
531
|
+
} catch (err) {
|
|
532
|
+
this.logger.error('hasFileInIndex 失败:', err.message)
|
|
533
|
+
return false // 失败时返回 false,视为不存在
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* 通过 URL 获取文件索引条目
|
|
539
|
+
*
|
|
540
|
+
* @param {string} groupId - 组ID
|
|
541
|
+
* @param {string} url - 文件URL
|
|
542
|
+
* @returns {Promise<Object|null>} 文件索引条目,不存在返回 null
|
|
543
|
+
*/
|
|
544
|
+
async getFileByUrl(groupId, url) {
|
|
545
|
+
await this._ensureInit()
|
|
546
|
+
|
|
547
|
+
try {
|
|
548
|
+
// 强制刷新索引缓存,确保读取最新数据
|
|
549
|
+
this.v3Storage.invalidateIndex(groupId)
|
|
550
|
+
|
|
551
|
+
// 从索引中查找 URL 匹配的文件
|
|
552
|
+
const index = await this.v3Storage.getIndex(groupId)
|
|
553
|
+
const file = index.files.find(f => f.url === url)
|
|
554
|
+
|
|
555
|
+
return file || null
|
|
556
|
+
} catch (err) {
|
|
557
|
+
this.logger.error('getFileByUrl 失败:', err.message)
|
|
558
|
+
return null
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ========================================
|
|
563
|
+
// 内部辅助方法
|
|
564
|
+
// ========================================
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* 注意:原来的 _parsePath 和 _parseConfigKey 方法已移除
|
|
568
|
+
* 现在统一使用从 path-utils.js 导入的 parsePath 和 parseConfigKey
|
|
569
|
+
*/
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 获取组列表属性(兼容旧格式)
|
|
573
|
+
* 内部方法,用于处理 __groups__ 属性查询
|
|
574
|
+
*
|
|
575
|
+
* @private
|
|
576
|
+
* @returns {Promise<Object>} 组列表对象
|
|
577
|
+
* @property {Array} groups - 组列表
|
|
578
|
+
* @property {string} currentGroupId - 当前激活的组ID
|
|
579
|
+
*
|
|
580
|
+
* @example
|
|
581
|
+
* const groupsData = await this._getGroupsProperty()
|
|
582
|
+
* console.log(groupsData.currentGroupId)
|
|
583
|
+
* groupsData.groups.forEach(g => console.log(g.name))
|
|
584
|
+
*/
|
|
585
|
+
async _getGroupsProperty() {
|
|
586
|
+
const groups = await this.v3Storage.listGroups()
|
|
587
|
+
const currentGroupId = await this.v3Storage.getCurrentGroupId()
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
groups: groups.map(g => ({
|
|
591
|
+
id: g.id,
|
|
592
|
+
name: g.name,
|
|
593
|
+
description: g.description,
|
|
594
|
+
createTime: g.createTime,
|
|
595
|
+
updateTime: g.updateTime,
|
|
596
|
+
isDefault: g.isDefault,
|
|
597
|
+
})),
|
|
598
|
+
currentGroupId,
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 获取文件配置属性(兼容旧格式)
|
|
604
|
+
* 内部方法,用于处理 group.{groupId}.file.{filename} 格式的配置查询
|
|
605
|
+
*
|
|
606
|
+
* @private
|
|
607
|
+
* @param {string} key - 配置键,格式:group.{groupId}.file.{filename}
|
|
608
|
+
* @returns {Promise<Object|null>} 文件配置对象,不存在返回 null
|
|
609
|
+
*
|
|
610
|
+
* @example
|
|
611
|
+
* const config = await this._getFileConfigProperty('group.default.file.https://api.test.com/users')
|
|
612
|
+
* if (config) {
|
|
613
|
+
* console.log('Mock状态:', config.mock)
|
|
614
|
+
* console.log('锁定状态:', config.locked)
|
|
615
|
+
* }
|
|
616
|
+
*/
|
|
617
|
+
async _getFileConfigProperty(key) {
|
|
618
|
+
const { groupId, filename } = parseConfigKey(key)
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
// 强制刷新索引缓存
|
|
622
|
+
this.v3Storage.invalidateIndex(groupId)
|
|
623
|
+
|
|
624
|
+
// 从索引查找对应的 fileId
|
|
625
|
+
const index = await this.v3Storage.getIndex(groupId)
|
|
626
|
+
const entry = index.files.find(f => f.url === filename)
|
|
627
|
+
|
|
628
|
+
if (!entry) {
|
|
629
|
+
return null
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const file = await this.v3Storage.getFile(groupId, entry.id)
|
|
633
|
+
|
|
634
|
+
// 返回兼容旧格式的配置对象
|
|
635
|
+
return {
|
|
636
|
+
...file.config,
|
|
637
|
+
url: file.url,
|
|
638
|
+
method: file.method,
|
|
639
|
+
status: file.status,
|
|
640
|
+
date: file.createTime,
|
|
641
|
+
}
|
|
642
|
+
} catch (err) {
|
|
643
|
+
this.logger.error('_getFileConfigProperty 失败:', err.message)
|
|
644
|
+
return null
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* 设置文件配置属性
|
|
650
|
+
* 内部方法,用于处理 group.{groupId}.file.{filename} 格式的配置更新
|
|
651
|
+
*
|
|
652
|
+
* @private
|
|
653
|
+
* @param {string} key - 配置键,格式:group.{groupId}.file.{filename}
|
|
654
|
+
* @param {Object} value - 配置值(会与现有配置合并)
|
|
655
|
+
* @returns {Promise<void>}
|
|
656
|
+
*
|
|
657
|
+
* @example
|
|
658
|
+
* await this._setFileConfigProperty(
|
|
659
|
+
* 'group.default.file.https://api.test.com/users',
|
|
660
|
+
* { mock: true, locked: true }
|
|
661
|
+
* )
|
|
662
|
+
*/
|
|
663
|
+
async _setFileConfigProperty(key, value) {
|
|
664
|
+
const { groupId, filename } = parseConfigKey(key)
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
// 强制刷新索引缓存
|
|
668
|
+
this.v3Storage.invalidateIndex(groupId)
|
|
669
|
+
|
|
670
|
+
// 从索引查找对应的 fileId
|
|
671
|
+
const index = await this.v3Storage.getIndex(groupId)
|
|
672
|
+
const entry = index.files.find(f => f.url === filename)
|
|
673
|
+
|
|
674
|
+
if (!entry) {
|
|
675
|
+
this.logger.error('_setFileConfigProperty: 文件不存在:', filename)
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const file = await this.v3Storage.getFile(groupId, entry.id)
|
|
680
|
+
|
|
681
|
+
// 合并配置(保留原有配置,覆盖新值)
|
|
682
|
+
await this.v3Storage.updateFile(groupId, entry.id, {
|
|
683
|
+
config: {
|
|
684
|
+
...file.config,
|
|
685
|
+
...value,
|
|
686
|
+
},
|
|
687
|
+
})
|
|
688
|
+
} catch (err) {
|
|
689
|
+
this.logger.error('_setFileConfigProperty 失败:', err.message)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ========================================
|
|
694
|
+
// 新增:直接访问 V3 Storage
|
|
695
|
+
// ========================================
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* 获取底层的 V3 Storage 实例
|
|
699
|
+
* 用于需要直接使用 V3 API 的场景(如版本管理、批量操作等)
|
|
700
|
+
*
|
|
701
|
+
* @returns {FileSystemStorage} V3 存储实例
|
|
702
|
+
*
|
|
703
|
+
* @example
|
|
704
|
+
* const v3Storage = adapter.getV3Storage()
|
|
705
|
+
* await v3Storage.createVersion(groupId, fileId, 'v1', {...})
|
|
706
|
+
*/
|
|
707
|
+
getV3Storage() {
|
|
708
|
+
return this.v3Storage
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
module.exports = StorageAdapter
|