zipcalm 0.0.0-stage → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/bin/zipcalm.js +191 -0
- package/package.json +67 -4
- package/server/app.js +309 -0
- package/server/boot.js +145 -0
- package/server/config/defaults.js +153 -0
- package/server/config/index.js +140 -0
- package/server/config/schema.js +244 -0
- package/server/core/auth/password.js +89 -0
- package/server/core/auth/store.js +261 -0
- package/server/core/compress/blacklist.js +236 -0
- package/server/core/compress/capRegistry.js +171 -0
- package/server/core/compress/deleter.js +285 -0
- package/server/core/compress/hardlinkScan.js +67 -0
- package/server/core/compress/orchestrator.js +783 -0
- package/server/core/compress/preflight.js +376 -0
- package/server/core/compress/registry.js +240 -0
- package/server/core/compress/sourceWalk.js +84 -0
- package/server/core/compress/verify.js +47 -0
- package/server/core/engineRunner.js +369 -0
- package/server/core/fs/list.js +164 -0
- package/server/core/fs/platformProfile.js +167 -0
- package/server/core/fs/repairNames.js +150 -0
- package/server/core/fs/roots.js +178 -0
- package/server/core/fs/safePath.js +173 -0
- package/server/core/fs/search.js +108 -0
- package/server/core/fs/stat.js +110 -0
- package/server/core/fs/systemTrash.js +212 -0
- package/server/core/fs/writeOps.js +257 -0
- package/server/core/hardlink/executor.js +106 -0
- package/server/core/hardlink/scanner.js +193 -0
- package/server/core/metrics/collector.js +279 -0
- package/server/core/metrics/procfs.js +107 -0
- package/server/core/task/events.js +82 -0
- package/server/core/task/manager.js +987 -0
- package/server/core/task/recovery.js +116 -0
- package/server/core/task/store.js +240 -0
- package/server/http/middleware/auth.js +130 -0
- package/server/http/middleware/error.js +54 -0
- package/server/http/middleware/logger.js +27 -0
- package/server/http/middleware/pathGuard.js +119 -0
- package/server/http/router.js +29 -0
- package/server/http/routes/auth.js +115 -0
- package/server/http/routes/blacklist.js +97 -0
- package/server/http/routes/capabilities.js +21 -0
- package/server/http/routes/compress.js +339 -0
- package/server/http/routes/fs.js +109 -0
- package/server/http/routes/hardlink.js +160 -0
- package/server/http/routes/health.js +52 -0
- package/server/http/routes/metrics.js +36 -0
- package/server/http/routes/tasks.js +304 -0
- package/server/lib/atomic-json.js +118 -0
- package/server/lib/errors.js +145 -0
- package/server/lib/fs-probe.js +25 -0
- package/server/lib/logger.js +68 -0
- package/server/lib/runtime-guard.js +49 -0
- package/server/lib/throttle.js +42 -0
- package/server/plugins/capabilities/password/index.js +300 -0
- package/server/plugins/capabilities/password/methods/aes256.js +31 -0
- package/server/plugins/capabilities/password/methods/zip20.js +29 -0
- package/server/plugins/formats/_shared/createGuards.js +26 -0
- package/server/plugins/formats/_shared/entrySafety.js +174 -0
- package/server/plugins/formats/_shared/nameRepair.js +123 -0
- package/server/plugins/formats/_shared/streams.js +18 -0
- package/server/plugins/formats/_shared/tar/compression.js +114 -0
- package/server/plugins/formats/_shared/tar/reader.js +74 -0
- package/server/plugins/formats/_shared/tar/writer.js +206 -0
- package/server/plugins/formats/_shared/tarFamily.js +207 -0
- package/server/plugins/formats/rar/index.js +354 -0
- package/server/plugins/formats/rar/js-engine.js +122 -0
- package/server/plugins/formats/rar/js-runner.mjs +129 -0
- package/server/plugins/formats/rar/unrar.js +266 -0
- package/server/plugins/formats/sevenzip/engine.js +355 -0
- package/server/plugins/formats/sevenzip/index.js +461 -0
- package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
- package/server/plugins/formats/tar/index.js +19 -0
- package/server/plugins/formats/targz/index.js +19 -0
- package/server/plugins/formats/tarzst/index.js +26 -0
- package/server/plugins/formats/zip/index.js +587 -0
- package/shared/app-version.js +7 -0
- package/shared/capabilities.js +21 -0
- package/shared/compress-modes.js +38 -0
- package/shared/error-codes.js +117 -0
- package/shared/format-list.js +101 -0
- package/shared/hardlink-hint.js +21 -0
- package/shared/task-phase.js +62 -0
- package/shared/task-status.js +84 -0
- package/web/dist/assets/index-D3zjq7N2.js +131 -0
- package/web/dist/assets/index-D9Vdmrb9.css +1 -0
- package/web/dist/index.html +18 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// 性能指标端点(docs/M0/API规范.md §4.6)
|
|
2
|
+
//
|
|
3
|
+
// - GET /metrics/snapshot 最新一帧(前端 2 秒轮询这一支)
|
|
4
|
+
// - GET /metrics/history?window=300&step=5 最近窗口内的降采样样本
|
|
5
|
+
//
|
|
6
|
+
// 约束(docs/07 §四):只暴露 6 张卡片对应的字段,不堆砌指标。
|
|
7
|
+
// 兼容性:/proc 缺失时字段为 null(接口照常 200,前端显示"—"),绝不因为采集不到而 500。
|
|
8
|
+
|
|
9
|
+
import { ERROR_CODES, apiError } from '../../lib/errors.js';
|
|
10
|
+
|
|
11
|
+
/** 历史窗口上限(秒):契约 §4.6 说明环形缓冲只保留最近 5 分钟 */
|
|
12
|
+
const MAX_HISTORY_WINDOW_SECONDS = 300;
|
|
13
|
+
/** 默认与最小步长(秒) */
|
|
14
|
+
const DEFAULT_STEP_SECONDS = 5;
|
|
15
|
+
const MIN_STEP_SECONDS = 1;
|
|
16
|
+
|
|
17
|
+
/** 查询参数 → 正整数(非法值回退默认,不抛错) */
|
|
18
|
+
function positiveInt(raw, fallback) {
|
|
19
|
+
const parsed = Number.parseInt(raw, 10);
|
|
20
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async function metricsRoutes(app) {
|
|
24
|
+
app.get('/metrics/snapshot', { preHandler: [app.requireAuth] }, async (request, reply) => {
|
|
25
|
+
if (!app.metrics) throw apiError(ERROR_CODES.SERVICE_UNAVAILABLE, '指标采集尚未就绪');
|
|
26
|
+
reply.ok(app.metrics.snapshot());
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
app.get('/metrics/history', { preHandler: [app.requireAuth] }, async (request, reply) => {
|
|
30
|
+
if (!app.metrics) throw apiError(ERROR_CODES.SERVICE_UNAVAILABLE, '指标采集尚未就绪');
|
|
31
|
+
const query = request.query || {};
|
|
32
|
+
const windowSeconds = Math.min(MAX_HISTORY_WINDOW_SECONDS, positiveInt(query.window, MAX_HISTORY_WINDOW_SECONDS));
|
|
33
|
+
const stepSeconds = Math.max(MIN_STEP_SECONDS, positiveInt(query.step, DEFAULT_STEP_SECONDS));
|
|
34
|
+
reply.ok({ samples: app.metrics.history({ window: windowSeconds, step: stepSeconds }) });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// 任务端点(M3):SSE 事件流 + 列表/详情/取消/重试/确认删除
|
|
2
|
+
//
|
|
3
|
+
// 契约:docs/M0/API规范.md §五(SSE 端点)+ §4.3(任务端点)
|
|
4
|
+
//
|
|
5
|
+
// SSE 实现要点(逐条对齐契约):
|
|
6
|
+
// 1. 连接建立后**第一帧必是 snapshot**(INV-05:进度是状态不是消息,重连先拿快照);
|
|
7
|
+
// 2. 先订阅后发快照:快照期间产生的事件先缓冲,避免"快照与增量之间丢事件"的竞态;
|
|
8
|
+
// 3. `since=<seq>` 续传:窗口内补发增量;窗口外(replaySince 返回 null)只靠快照兜底;
|
|
9
|
+
// 4. 心跳每 15s(防中间设备断连);
|
|
10
|
+
// 5. 响应头带 `X-Accel-Buffering: no`(反代下不被缓冲);
|
|
11
|
+
// 6. 连接结束(close/error)必须退订,防订阅者泄漏;
|
|
12
|
+
// 7. 连接登记进 `app.tasks.sseClients`:关停时统一 end(),否则优雅停止会超时(见 app.js preClose)。
|
|
13
|
+
//
|
|
14
|
+
// 结构:SSE 的每一步(接管响应 / 建帧写入器 / 缓冲订阅 / 首帧快照 / 续传补发 / 心跳)都是
|
|
15
|
+
// 一个模块级小函数,主 handler 只做编排。
|
|
16
|
+
|
|
17
|
+
import { ERROR_CODES, apiError } from '../../lib/errors.js';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_CHANNELS = ['tasks'];
|
|
20
|
+
|
|
21
|
+
/** 解析 channels 查询参数(默认 tasks;空值也回落默认) */
|
|
22
|
+
function parseChannels(raw) {
|
|
23
|
+
if (typeof raw !== 'string' || raw.length === 0) return DEFAULT_CHANNELS;
|
|
24
|
+
const channels = raw.split(',').map((item) => item.trim()).filter(Boolean);
|
|
25
|
+
return channels.length > 0 ? channels : DEFAULT_CHANNELS;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 单帧序列化(SSE 报文格式;id 用 seq,便于 Last-Event-ID 续传) */
|
|
29
|
+
function frameToText(frame) {
|
|
30
|
+
return `id: ${frame.seq}\nevent: ${frame.event}\ndata: ${JSON.stringify(frame.data)}\n\n`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ===== SSE 分步实现 =====
|
|
34
|
+
|
|
35
|
+
/** 接管原生响应并写响应头;返回 res(Fastify 的响应装饰不适用于流式响应) */
|
|
36
|
+
function hijackForSse(reply, request) {
|
|
37
|
+
reply.hijack();
|
|
38
|
+
const res = reply.raw;
|
|
39
|
+
const socket = request.raw.socket;
|
|
40
|
+
if (socket && typeof socket.setTimeout === 'function') socket.setTimeout(0); // SSE 不能被空闲超时掐断
|
|
41
|
+
if (typeof res.setTimeout === 'function') res.setTimeout(0);
|
|
42
|
+
|
|
43
|
+
res.writeHead(200, {
|
|
44
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
45
|
+
'Cache-Control': 'no-cache',
|
|
46
|
+
Connection: 'keep-alive',
|
|
47
|
+
'X-Accel-Buffering': 'no',
|
|
48
|
+
});
|
|
49
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
50
|
+
return res;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 帧写入器:连接已结束返回 false(事件总线据此自愈剔除订阅者) */
|
|
54
|
+
function createFrameWriter(res) {
|
|
55
|
+
return (frame) => {
|
|
56
|
+
if (res.writableEnded || res.destroyed) return false;
|
|
57
|
+
try {
|
|
58
|
+
res.write(frameToText(frame));
|
|
59
|
+
return !res.writableEnded;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 先订阅后发快照:快照完成前的事件先缓冲,activate() 后切换为直发 */
|
|
67
|
+
function subscribeBuffered(events, channels, writeFrame) {
|
|
68
|
+
const buffered = [];
|
|
69
|
+
let ready = false;
|
|
70
|
+
const unsubscribe = events.subscribe({
|
|
71
|
+
channels,
|
|
72
|
+
send: (frame) => {
|
|
73
|
+
if (ready) return writeFrame(frame);
|
|
74
|
+
buffered.push(frame);
|
|
75
|
+
return true;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
buffered,
|
|
80
|
+
unsubscribe,
|
|
81
|
+
activate: () => {
|
|
82
|
+
ready = true;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 首帧快照;构造失败返回 null(调用方负责断开,绝不静默挂着连接) */
|
|
88
|
+
function sendSnapshotFrame(app, manager, events, writeFrame) {
|
|
89
|
+
try {
|
|
90
|
+
const seq = events.currentSeq();
|
|
91
|
+
writeFrame({ seq, event: 'snapshot', data: { ...manager.snapshot(), seq } });
|
|
92
|
+
return seq;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
app.log.error('SSE 首帧快照构造失败,已断开连接', { error: error.message });
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** since 续传 + 缓冲补发(跳过快照已覆盖的 seq,按 seq 升序去重) */
|
|
100
|
+
function flushResumeFrames(events, since, snapshotSeq, buffered, writeFrame) {
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
const replayed = Number.isInteger(since) ? events.replaySince(since) : null;
|
|
103
|
+
for (const frame of replayed || []) {
|
|
104
|
+
if (frame.seq <= snapshotSeq) continue; // 快照已覆盖的状态不再重发
|
|
105
|
+
seen.add(frame.seq);
|
|
106
|
+
writeFrame(frame);
|
|
107
|
+
}
|
|
108
|
+
const pending = [...buffered].filter((frame) => frame.seq > snapshotSeq).sort((a, b) => a.seq - b.seq);
|
|
109
|
+
for (const frame of pending) {
|
|
110
|
+
if (seen.has(frame.seq)) continue;
|
|
111
|
+
writeFrame(frame);
|
|
112
|
+
}
|
|
113
|
+
buffered.length = 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 心跳定时器(连接已结束则走清理);返回 interval 句柄,由 cleanup 负责清掉 */
|
|
117
|
+
function startHeartbeat(res, heartbeatMs, cleanup) {
|
|
118
|
+
const heartbeat = setInterval(() => {
|
|
119
|
+
if (res.writableEnded || res.destroyed) {
|
|
120
|
+
cleanup();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
res.write(`event: heartbeat\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`);
|
|
125
|
+
} catch {
|
|
126
|
+
cleanup();
|
|
127
|
+
}
|
|
128
|
+
}, heartbeatMs);
|
|
129
|
+
if (typeof heartbeat.unref === 'function') heartbeat.unref();
|
|
130
|
+
return heartbeat;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ===== 端点 handler =====
|
|
134
|
+
|
|
135
|
+
/** GET /tasks/events —— SSE 事件流(首帧快照 + 增量 + 心跳 + seq/since 续传) */
|
|
136
|
+
async function handleTaskEvents(app, request, reply) {
|
|
137
|
+
const { manager, events } = app.tasks;
|
|
138
|
+
const heartbeatMs = app.m2.config.limits.sseHeartbeatMs;
|
|
139
|
+
const channels = parseChannels(request.query.channels);
|
|
140
|
+
|
|
141
|
+
const res = hijackForSse(reply, request);
|
|
142
|
+
const writeFrame = createFrameWriter(res);
|
|
143
|
+
const subscription = subscribeBuffered(events, channels, writeFrame);
|
|
144
|
+
|
|
145
|
+
// ① 首帧:当前状态快照(契约铁律,无论是否带 since 都先发)
|
|
146
|
+
const snapshotSeq = sendSnapshotFrame(app, manager, events, writeFrame);
|
|
147
|
+
if (snapshotSeq === null) {
|
|
148
|
+
subscription.unsubscribe();
|
|
149
|
+
try {
|
|
150
|
+
res.end();
|
|
151
|
+
} catch {
|
|
152
|
+
/* 连接可能已被对端关闭:忽略即可,不影响订阅者清理 */
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ② since 续传 + ③ 缓冲补发,随后切换为直发
|
|
158
|
+
const since = Number.parseInt(request.query.since, 10);
|
|
159
|
+
flushResumeFrames(events, since, snapshotSeq, subscription.buffered, writeFrame);
|
|
160
|
+
subscription.activate();
|
|
161
|
+
|
|
162
|
+
// ④ 心跳与断开清理(登记 res,关停时由 app.preClose 统一 end())
|
|
163
|
+
let cleaned = false;
|
|
164
|
+
let heartbeat = null;
|
|
165
|
+
function cleanup() {
|
|
166
|
+
if (cleaned) return;
|
|
167
|
+
cleaned = true;
|
|
168
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
169
|
+
subscription.unsubscribe();
|
|
170
|
+
app.tasks.sseClients.delete(res);
|
|
171
|
+
app.log.info('SSE 订阅断开', { remaining: events.subscriberCount() });
|
|
172
|
+
}
|
|
173
|
+
app.tasks.sseClients.add(res);
|
|
174
|
+
heartbeat = startHeartbeat(res, heartbeatMs, cleanup);
|
|
175
|
+
|
|
176
|
+
request.raw.on('close', cleanup);
|
|
177
|
+
request.raw.on('error', cleanup);
|
|
178
|
+
res.on('close', cleanup);
|
|
179
|
+
res.on('error', cleanup);
|
|
180
|
+
|
|
181
|
+
app.log.info('SSE 订阅建立', {
|
|
182
|
+
channels,
|
|
183
|
+
since: Number.isInteger(since) ? since : null,
|
|
184
|
+
subscribers: events.subscriberCount(),
|
|
185
|
+
snapshotSeq,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** GET /tasks —— 任务列表(内存非终态 + 历史缓存;契约 §六.5) */
|
|
190
|
+
async function handleTaskList(app, request, reply) {
|
|
191
|
+
const rawStatus = request.query.status;
|
|
192
|
+
const status = typeof rawStatus === 'string' && rawStatus.length > 0
|
|
193
|
+
? rawStatus.split(',').map((item) => item.trim()).filter(Boolean)
|
|
194
|
+
: null;
|
|
195
|
+
const limit = Number.parseInt(request.query.limit, 10);
|
|
196
|
+
reply.ok(app.tasks.manager.list({ status, limit: Number.isInteger(limit) ? limit : 50 }));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** GET /tasks/history —— 历史查询(最近 N 条;内存缓存 + 文件兜底) */
|
|
200
|
+
async function handleTaskHistory(app, request, reply) {
|
|
201
|
+
const limit = Number.parseInt(request.query.limit, 10);
|
|
202
|
+
const rawStatus = request.query.status;
|
|
203
|
+
const status = typeof rawStatus === 'string' && rawStatus.length > 0
|
|
204
|
+
? rawStatus.split(',').map((item) => item.trim()).filter(Boolean)
|
|
205
|
+
: null;
|
|
206
|
+
const items = app.tasks.store.listHistory({ limit: Number.isInteger(limit) ? limit : 50, status });
|
|
207
|
+
reply.ok({ items, total: items.length, max: app.tasks.store.historyMax });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** GET /tasks/{id} —— 单任务详情(终态任务从历史缓存还原) */
|
|
211
|
+
async function handleTaskGet(app, request, reply) {
|
|
212
|
+
const task = app.tasks.manager.get(request.params.id);
|
|
213
|
+
if (!task) throw apiError(ERROR_CODES.TASK_NOT_FOUND, '任务不存在', { taskId: request.params.id });
|
|
214
|
+
reply.ok({ task });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** POST /tasks/{id}/cancel —— 取消(queued/running → cancelling → aborted) */
|
|
218
|
+
async function handleTaskCancel(app, request, reply) {
|
|
219
|
+
reply.ok({ task: app.tasks.manager.cancel(request.params.id) });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** POST /tasks/{id}/retry —— 重试 = **新任务**(新 UUID + retryOf 指向原任务) */
|
|
223
|
+
async function handleTaskRetry(app, request, reply) {
|
|
224
|
+
const { manager } = app.tasks;
|
|
225
|
+
const orchestrator = app.m2.orchestrator;
|
|
226
|
+
const task = await manager.retry(request.params.id);
|
|
227
|
+
manager.enqueue(task.id, (record) => (task.kind === 'extract'
|
|
228
|
+
? orchestrator.runExtract(record)
|
|
229
|
+
: orchestrator.runCompress(record)));
|
|
230
|
+
reply.ok({ task: manager.get(task.id) }, 201);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 取任务并确认它确实处于"待二次确认"状态(三个前置条件是同一类错误,集中判定) */
|
|
234
|
+
function requireConfirmableTask(manager, id) {
|
|
235
|
+
const task = manager.get(id);
|
|
236
|
+
if (!task) throw apiError(ERROR_CODES.TASK_NOT_FOUND, '任务不存在', { taskId: id });
|
|
237
|
+
if (!task.delete) throw apiError(ERROR_CODES.INVALID_STATE, '该任务没有待删除目标', { taskId: id });
|
|
238
|
+
if (task.phase !== 'waiting_confirm') {
|
|
239
|
+
throw apiError(ERROR_CODES.INVALID_STATE, '当前不是待确认状态', { phase: task.phase });
|
|
240
|
+
}
|
|
241
|
+
return task;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 数量/字节比对(INV-03:弹窗数字与实际不一致就拒绝删除);返回归一化后的期望值 */
|
|
245
|
+
function parseExpectedCounts(body, task) {
|
|
246
|
+
const expectedFiles = Number.isInteger(body.expectedFiles) ? body.expectedFiles : null;
|
|
247
|
+
const expectedBytes = Number.isFinite(body.expectedBytes) ? body.expectedBytes : null;
|
|
248
|
+
const actualFiles = task.delete.targets.length;
|
|
249
|
+
const actualBytes = task.delete.targets.reduce((sum, target) => sum + (Number(target.sizeBytes) || 0), 0);
|
|
250
|
+
|
|
251
|
+
if (expectedFiles !== null && expectedFiles !== actualFiles) {
|
|
252
|
+
throw apiError(ERROR_CODES.VALIDATION_FAILED, '待删除数量与弹窗不一致,已拒绝删除', { expectedFiles, actualFiles });
|
|
253
|
+
}
|
|
254
|
+
if (expectedBytes !== null && Math.abs(expectedBytes - actualBytes) > 1) {
|
|
255
|
+
throw apiError(ERROR_CODES.VALIDATION_FAILED, '待删除字节数与弹窗不一致,已拒绝删除', { expectedBytes, actualBytes });
|
|
256
|
+
}
|
|
257
|
+
return { expectedFiles, expectedBytes };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** POST /tasks/{id}/confirm-delete —— 二次确认删除(压缩删原文件 / 解压删压缩包共用) */
|
|
261
|
+
async function handleTaskConfirmDelete(app, request, reply) {
|
|
262
|
+
const { manager } = app.tasks;
|
|
263
|
+
const id = request.params.id;
|
|
264
|
+
const task = requireConfirmableTask(manager, id);
|
|
265
|
+
|
|
266
|
+
const body = request.body || {};
|
|
267
|
+
if (body.confirm === false) {
|
|
268
|
+
manager.resolveConfirm(id, { confirmed: false });
|
|
269
|
+
reply.ok({ task: manager.get(id) });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const { expectedFiles, expectedBytes } = parseExpectedCounts(body, task);
|
|
274
|
+
manager.resolveConfirm(id, { confirmed: true, expectedFiles, expectedBytes });
|
|
275
|
+
reply.ok({ task: manager.get(id) });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** DELETE /tasks/{id} —— 删除**终态**任务的活动记录(只动 tasks.json,绝不动历史) */
|
|
279
|
+
async function handleTaskDelete(app, request, reply) {
|
|
280
|
+
// 删除后由 manager 广播一次快照,其他客户端自行对齐
|
|
281
|
+
reply.ok(await app.tasks.manager.remove(request.params.id));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default async function taskRoutes(app) {
|
|
285
|
+
const auth = { preHandler: [app.requireAuth] };
|
|
286
|
+
const bind = (handler) => (request, reply) => handler(app, request, reply);
|
|
287
|
+
|
|
288
|
+
// SSE 必须注册在 /tasks/:id 之前(否则 "events" 会被当成任务 id)
|
|
289
|
+
app.get('/tasks/events', auth, bind(handleTaskEvents));
|
|
290
|
+
app.get('/tasks/history', auth, bind(handleTaskHistory));
|
|
291
|
+
|
|
292
|
+
app.get('/tasks', auth, bind(handleTaskList));
|
|
293
|
+
app.get('/tasks/:id', auth, bind(handleTaskGet));
|
|
294
|
+
app.get('/compress/tasks/:id', auth, bind(handleTaskGet)); // 契约别名
|
|
295
|
+
|
|
296
|
+
app.delete('/tasks/:id', auth, bind(handleTaskDelete));
|
|
297
|
+
|
|
298
|
+
app.post('/tasks/:id/cancel', auth, bind(handleTaskCancel));
|
|
299
|
+
app.post('/compress/tasks/:id/cancel', auth, bind(handleTaskCancel));
|
|
300
|
+
|
|
301
|
+
app.post('/tasks/:id/retry', auth, bind(handleTaskRetry));
|
|
302
|
+
app.post('/tasks/:id/confirm-delete', auth, bind(handleTaskConfirmDelete));
|
|
303
|
+
app.post('/compress/tasks/:id/confirm-delete', auth, bind(handleTaskConfirmDelete));
|
|
304
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// 原子写 JSON(先写临时文件再 rename)
|
|
2
|
+
//
|
|
3
|
+
// 为什么必须原子:任务/令牌这类状态文件如果被"原地截断写"中途打断,
|
|
4
|
+
// 重启后就是半个 JSON —— 直接导致服务起不来。先写 <file>.tmp 再 rename 可保证
|
|
5
|
+
// 读到的永远是「旧完整版」或「新完整版」,不存在中间态(docs/12 lib/atomic-json.js)。
|
|
6
|
+
//
|
|
7
|
+
// 为什么还要重试:CIFS/SMB 的 rename 语义与 POSIX 不同 ——
|
|
8
|
+
// · 目标文件正被别的进程读打开时,rename 会返回 EACCES(瞬时冲突,读完就释放)
|
|
9
|
+
// · rename 过程中存在极短窗口目标不可见,此时读会拿到 ENOENT
|
|
10
|
+
// 实测(150 次原子写 + 持续并发读,storage 在 CIFS 上):写失败 28%、读失败 58%;
|
|
11
|
+
// 同样的代码在本地盘上写失败 0%、读失败 2.4%。这两个错误都是**瞬时**的,
|
|
12
|
+
// 几十毫秒后即消失,所以有界重试就能覆盖,不需要改动调用方。
|
|
13
|
+
//
|
|
14
|
+
// 重试不会掩盖真问题:
|
|
15
|
+
// · 真失败(磁盘满 / 权限真的不对 / 目录不存在)会连续重试全败,最终仍抛错
|
|
16
|
+
// · 内容损坏(JSON 解析失败)不重试 —— 那和文件可见性无关,重试也没用
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs/promises';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
/** 瞬时错误码:网络盘上由并发读写引起的可见性/共享冲突,重试可恢复 */
|
|
22
|
+
const TRANSIENT_CODES = new Set(['EACCES', 'ENOENT', 'EBUSY', 'EPERM']);
|
|
23
|
+
|
|
24
|
+
const WRITE_RETRIES = 3;
|
|
25
|
+
const WRITE_RETRY_DELAY_MS = 50;
|
|
26
|
+
const READ_RETRIES = 2;
|
|
27
|
+
const READ_RETRY_DELAY_MS = 50;
|
|
28
|
+
|
|
29
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 原子写入 JSON 文件。
|
|
33
|
+
*
|
|
34
|
+
* 瞬时冲突(网络盘的 rename 可见性窗口)会自动重试;真正失败(磁盘满、权限、
|
|
35
|
+
* 目录不存在)重试耗尽后照常抛出,调用方的错误处理逻辑不受影响。
|
|
36
|
+
*
|
|
37
|
+
* @param {string} filePath 目标文件(父目录不存在会自动创建)
|
|
38
|
+
* @param {unknown} data 可被 JSON.stringify 的数据
|
|
39
|
+
*/
|
|
40
|
+
export async function writeJsonAtomic(filePath, data) {
|
|
41
|
+
const dir = path.dirname(filePath);
|
|
42
|
+
await fs.mkdir(dir, { recursive: true });
|
|
43
|
+
const tmpPath = `${filePath}.tmp`;
|
|
44
|
+
const text = `${JSON.stringify(data, null, 2)}\n`;
|
|
45
|
+
|
|
46
|
+
let lastError = null;
|
|
47
|
+
for (let attempt = 0; attempt <= WRITE_RETRIES; attempt += 1) {
|
|
48
|
+
try {
|
|
49
|
+
await fs.writeFile(tmpPath, text, 'utf8');
|
|
50
|
+
await fs.rename(tmpPath, filePath);
|
|
51
|
+
return;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
lastError = error;
|
|
54
|
+
// 非瞬时错误(如 ENOSPC)立刻放弃,不做无意义重试
|
|
55
|
+
if (!TRANSIENT_CODES.has(error && error.code)) break;
|
|
56
|
+
if (attempt < WRITE_RETRIES) {
|
|
57
|
+
// 先清掉可能残留的 .tmp,再等一下重试:.tmp 残留会让下次写覆盖同一临时文件,
|
|
58
|
+
// 而并发写的场景下留着它反而可能加剧冲突。
|
|
59
|
+
await fs.rm(tmpPath, { force: true }).catch(() => {});
|
|
60
|
+
await sleep(WRITE_RETRY_DELAY_MS * (attempt + 1));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 重试耗尽仍失败:清理临时文件(避免 .tmp 残留被「无残留」类断言误判成产品泄漏),
|
|
66
|
+
// 目标文件(旧完整版)保持不动,然后把错误抛给调用方。
|
|
67
|
+
await fs.rm(tmpPath, { force: true }).catch(() => {});
|
|
68
|
+
throw lastError;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 读取 JSON;文件不存在或内容损坏时返回 fallback(不抛错,避免"一个坏文件拖垮启动")。
|
|
73
|
+
*
|
|
74
|
+
* ENOENT 会短暂重试:网络盘的 rename 有可见性窗口,读到「文件暂时不存在」不代表真丢了。
|
|
75
|
+
* 重试仍读不到才按 not-found 返回 fallback;JSON 解析失败不重试(内容问题重试无意义)。
|
|
76
|
+
*
|
|
77
|
+
* @param {string} filePath
|
|
78
|
+
* @param {unknown} fallback
|
|
79
|
+
* @returns {Promise<{data: unknown, ok: boolean, reason?: string}>}
|
|
80
|
+
*/
|
|
81
|
+
export async function readJson(filePath, fallback = null) {
|
|
82
|
+
let lastReason = null;
|
|
83
|
+
for (let attempt = 0; attempt <= READ_RETRIES; attempt += 1) {
|
|
84
|
+
try {
|
|
85
|
+
const text = await fs.readFile(filePath, 'utf8');
|
|
86
|
+
return { data: JSON.parse(text), ok: true };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const code = error && error.code;
|
|
89
|
+
if (code === 'ENOENT') {
|
|
90
|
+
lastReason = 'not-found';
|
|
91
|
+
if (attempt < READ_RETRIES) {
|
|
92
|
+
await sleep(READ_RETRY_DELAY_MS * (attempt + 1));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
// 读到了但解析失败(内容损坏)、或权限类错误:重试无意义,直接返回原因
|
|
98
|
+
lastReason = `unreadable:${code}`;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { data: fallback, ok: false, reason: lastReason };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 确保目录存在(幂等) */
|
|
106
|
+
export async function ensureDir(dirPath) {
|
|
107
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 检查路径是否可读写(用于配置校验时给出明确原因) */
|
|
111
|
+
export async function checkAccess(targetPath, mode) {
|
|
112
|
+
try {
|
|
113
|
+
await fs.access(targetPath, mode);
|
|
114
|
+
return { ok: true };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return { ok: false, reason: error && error.code ? error.code : 'EUNKNOWN' };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// 错误对象与错误码统一入口(M1)
|
|
2
|
+
//
|
|
3
|
+
// 铁律(docs/M0/API规范.md §八 + INV-12):**错误码全部来自 shared/error-codes.js,不允许自造**。
|
|
4
|
+
// 这里只做三件事:
|
|
5
|
+
// 1. 提供 ApiError(携带契约错误码 + 可选 details);
|
|
6
|
+
// 2. 把 Node 的系统错误(ENOENT/EACCES/ENOSPC…)翻译成契约错误码;
|
|
7
|
+
// 3. 输出契约要求的 error 载荷 {code, message, details}。
|
|
8
|
+
|
|
9
|
+
import { ERROR_CODES, isErrorCode, errorCodeMeta } from '../../shared/error-codes.js';
|
|
10
|
+
|
|
11
|
+
/** 系统错误码 → 契约错误码(只映射已知的,其余落到 INTERNAL_ERROR) */
|
|
12
|
+
const SYSTEM_ERROR_MAP = {
|
|
13
|
+
ENOENT: ERROR_CODES.PATH_NOT_FOUND,
|
|
14
|
+
EACCES: ERROR_CODES.PERMISSION_DENIED,
|
|
15
|
+
EPERM: ERROR_CODES.PERMISSION_DENIED,
|
|
16
|
+
ENOTDIR: ERROR_CODES.NOT_A_DIRECTORY,
|
|
17
|
+
EISDIR: ERROR_CODES.NOT_A_DIRECTORY,
|
|
18
|
+
ENOSPC: ERROR_CODES.DISK_FULL,
|
|
19
|
+
EDQUOT: ERROR_CODES.QUOTA_EXCEEDED,
|
|
20
|
+
EMFILE: ERROR_CODES.SERVICE_UNAVAILABLE,
|
|
21
|
+
ENFILE: ERROR_CODES.SERVICE_UNAVAILABLE,
|
|
22
|
+
ELOOP: ERROR_CODES.PATH_OUT_OF_ROOT,
|
|
23
|
+
// 路径类错误:全部归 INVALID_PATH(400),**不允许落到 500**
|
|
24
|
+
// (契约 §八 把「超长」明确列在 INVALID_PATH 的触发场景里:这是客户端可修正的请求,
|
|
25
|
+
// 不是服务端故障。真实案例:GET /fs/list?path=/aaaa…(300) 曾返回 500 INTERNAL_ERROR,
|
|
26
|
+
// 根因就是 ENAMETOOLONG 没有映射,落到兜底 INTERNAL_ERROR。)
|
|
27
|
+
ENAMETOOLONG: ERROR_CODES.INVALID_PATH,
|
|
28
|
+
EINVAL: ERROR_CODES.INVALID_PATH,
|
|
29
|
+
EBADF: ERROR_CODES.INVALID_PATH,
|
|
30
|
+
EFAULT: ERROR_CODES.INVALID_PATH,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 与路径操作相关的系统调用名。
|
|
35
|
+
* 用途:**兜底**——即便某个底层错误码没在上面显式映射,只要它出自路径类 syscall,
|
|
36
|
+
* 也一律翻译成 INVALID_PATH(400),避免"路径太长/非法"再次变成 500。
|
|
37
|
+
*/
|
|
38
|
+
const PATH_SYSCALLS = new Set([
|
|
39
|
+
'realpath',
|
|
40
|
+
'stat',
|
|
41
|
+
'lstat',
|
|
42
|
+
'fstat',
|
|
43
|
+
'readdir',
|
|
44
|
+
'scandir',
|
|
45
|
+
'opendir',
|
|
46
|
+
'open',
|
|
47
|
+
'access',
|
|
48
|
+
'readlink',
|
|
49
|
+
'mkdir',
|
|
50
|
+
'rename',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export class ApiError extends Error {
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} code shared/error-codes.js 里的错误码
|
|
56
|
+
* @param {string} [message] 给人看的说明(不给则用契约里的中文文案)
|
|
57
|
+
* @param {object|null} [details] 机器可读的附加信息
|
|
58
|
+
*/
|
|
59
|
+
constructor(code, message = '', details = null) {
|
|
60
|
+
const safeCode = isErrorCode(code) ? code : ERROR_CODES.INTERNAL_ERROR;
|
|
61
|
+
super(message || errorCodeMeta(safeCode).label);
|
|
62
|
+
this.name = 'ApiError';
|
|
63
|
+
this.code = safeCode;
|
|
64
|
+
this.details = details;
|
|
65
|
+
// 标记为非预期错误(用于日志级别判断)
|
|
66
|
+
this.expected = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 语法糖:throw apiError('PATH_NOT_FOUND', '...', {path}) */
|
|
71
|
+
export function apiError(code, message, details = null) {
|
|
72
|
+
return new ApiError(code, message, details);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 任意异常 → ApiError。
|
|
77
|
+
* 已经是 ApiError 的原样返回;系统错误按表翻译;其余归 INTERNAL_ERROR(保留 cause 便于排查)。
|
|
78
|
+
*
|
|
79
|
+
* 安全:对外可见的 message **绝不携带本机绝对路径与内部细节**——
|
|
80
|
+
* Node 系统错误的原始 message 里通常带 `realpath '<绝对路径>'`,直接透出等于把存储根布局
|
|
81
|
+
* 告诉任何已登录客户端。因此这里只保留 syscall(如 realpath)作为排障线索,路径不进 message。
|
|
82
|
+
*/
|
|
83
|
+
/** 标 expected=true 的错误:属于调用方造成的可预期失败,不写内部堆栈 */
|
|
84
|
+
function expectedError(code, details) {
|
|
85
|
+
const mapped = new ApiError(code, undefined, details);
|
|
86
|
+
mapped.expected = true;
|
|
87
|
+
return mapped;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 系统错误码 → 契约错误(不为认知的错误返回 null,由调用方继续兜底)。
|
|
92
|
+
* @returns {ApiError|null}
|
|
93
|
+
*/
|
|
94
|
+
function mapSystemError(error) {
|
|
95
|
+
const systemCode = error && error.code;
|
|
96
|
+
if (typeof systemCode !== 'string') return null;
|
|
97
|
+
if (SYSTEM_ERROR_MAP[systemCode]) {
|
|
98
|
+
return expectedError(SYSTEM_ERROR_MAP[systemCode], { syscall: error.syscall || null });
|
|
99
|
+
}
|
|
100
|
+
// 未映射的路径类 syscall:按"路径非法"处理(4xx),不要变成 500
|
|
101
|
+
if (PATH_SYSCALLS.has(error && error.syscall)) {
|
|
102
|
+
return expectedError(ERROR_CODES.INVALID_PATH, { syscall: error.syscall, sys: systemCode });
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function toApiError(error) {
|
|
108
|
+
if (error instanceof ApiError) return error;
|
|
109
|
+
|
|
110
|
+
const mappedSystem = mapSystemError(error);
|
|
111
|
+
if (mappedSystem) return mappedSystem;
|
|
112
|
+
|
|
113
|
+
// Fastify 自带校验错误(如 body 结构不对)
|
|
114
|
+
if (error && error.validation) {
|
|
115
|
+
return new ApiError(ERROR_CODES.VALIDATION_FAILED, error.message, { validation: error.validation });
|
|
116
|
+
}
|
|
117
|
+
if (error && typeof error.statusCode === 'number' && error.statusCode === 400) {
|
|
118
|
+
return new ApiError(ERROR_CODES.BAD_REQUEST, '请求格式不正确');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 兜底:内部错误对外只给通用文案(真实原因写日志,靠 traceId 关联)
|
|
122
|
+
const internal = new ApiError(ERROR_CODES.INTERNAL_ERROR, undefined, null);
|
|
123
|
+
internal.expected = false;
|
|
124
|
+
internal.cause = error;
|
|
125
|
+
return internal;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 契约的 error 载荷 */
|
|
129
|
+
export function errorPayload(error) {
|
|
130
|
+
const apiErr = toApiError(error);
|
|
131
|
+
const payload = {
|
|
132
|
+
code: apiErr.code,
|
|
133
|
+
message: apiErr.message || errorCodeMeta(apiErr.code).label,
|
|
134
|
+
};
|
|
135
|
+
if (apiErr.details) payload.details = apiErr.details;
|
|
136
|
+
return payload;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** HTTP 状态:优先契约里的映射,缺省 500 */
|
|
140
|
+
export function httpStatusOfError(error) {
|
|
141
|
+
const meta = errorCodeMeta(toApiError(error).code);
|
|
142
|
+
return meta.http || 500;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { ERROR_CODES };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 文件系统探测小工具(**一律异步**)
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独收成一模块:服务端的这些探测几乎都发生在**请求路径**或**任务执行路径**上,
|
|
5
|
+
* `existsSync` / `statSync` 会把事件循环钉住(一次系统调用不慢,但它们在循环与并发请求里会被放大);
|
|
6
|
+
* 而且它们被多个模块各写一份(预检、产物命名、创建守卫、落盘计划)—— 收成一处才不会"改一处忘一处"。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fsp from 'node:fs/promises';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 路径是否存在。
|
|
13
|
+
* 语义与 `existsSync` 对齐:任何访问失败(ENOENT / EACCES / ENOTDIR …)都算"不存在",
|
|
14
|
+
* 不区分失败原因(需要区分原因的调用方请直接用 fsp.stat/access 并自行处理错误)。
|
|
15
|
+
* @param {string} target
|
|
16
|
+
* @returns {Promise<boolean>}
|
|
17
|
+
*/
|
|
18
|
+
export async function pathExists(target) {
|
|
19
|
+
try {
|
|
20
|
+
await fsp.access(target);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|