echoapi-cron-scheduler-batch 1.0.12 → 1.0.16

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 ADDED
@@ -0,0 +1,2 @@
1
+ 定时任务
2
+ 批量跑测试用例
package/index.js CHANGED
@@ -149,7 +149,10 @@ class CronScheduler {
149
149
  if (worker) {
150
150
  worker.send({
151
151
  action: 'EXECUTE_UNIT',
152
- payload: { executionId, test_events: runner.test_events, option: runner.option, unitIndex: index, api_token: job.api_token }
152
+ payload: {
153
+ executionId, test_events: runner.test_events, option: runner.option, unitIndex: index, api_token: job.api_token,// --- 确保传给 Worker 的参数 ---
154
+ jobId: job.job_id,
155
+ }
153
156
  });
154
157
  }
155
158
  });
@@ -308,9 +311,37 @@ class CronScheduler {
308
311
  const { run: runner } = require('runner-runtime');
309
312
  process.on('message', async (msg) => {
310
313
  if (msg.action === 'EXECUTE_UNIT') {
314
+ const unitRequests = []; // 👈 每个用例单元独立的请求收集器
311
315
  console.log(`⚙️ [Worker ${process.pid}] 执行单元: ${msg.payload.executionId}`);
312
316
  runner(msg.payload.test_events, msg.payload.option || {}, (res) => {
317
+ // 1. 收集过程中的每一个 request 动作
318
+ if (res?.action === 'request') {
319
+ unitRequests.push(res.data);
320
+ }
313
321
  if (res?.action === 'complete') {
322
+ if (unitRequests.length > 0) {
323
+ try {
324
+ axios.post(`${this.apiUrl}/open/hnzycfc/report_steps_details/task/add`, {
325
+ job_id: msg?.payload?.jobId,
326
+ project_id: res?.data?.project_id,
327
+ testing_id: res?.data?.testing_id,
328
+ report_id: res?.data?.report_id,
329
+ list: unitRequests // 本用例产生的所有请求
330
+ }, {
331
+ headers: { 'api-token': msg.payload.api_token },
332
+ timeout: 30000,
333
+ maxContentLength: Infinity,
334
+ maxBodyLength: Infinity
335
+ }).then((response) => {
336
+ if (response?.data?.code == 0) {
337
+ console.error(`✅ [Worker] 用例 详情上传成功:`, response?.data?.data?.uri);
338
+ }
339
+ });
340
+ console.log(`✅ [Worker] 用例 #${msg.payload.unitIndex} 请求详情上传成功 (${unitRequests.length} 条)`);
341
+ } catch (e) {
342
+ console.error(`❌ [Worker] 用例 #${msg.payload.unitIndex} 详情上传失败:`, e.message);
343
+ }
344
+ }
314
345
  process.send({ action: 'UNIT_COMPLETED', payload: { ...msg.payload, data: res.data } });
315
346
  }
316
347
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoapi-cron-scheduler-batch",
3
- "version": "1.0.12",
3
+ "version": "1.0.16",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -10,14 +10,13 @@
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "axios": "^1.13.4",
13
- "better-sqlite3": "^12.6.2",
14
13
  "cron-parser": "^5.5.0",
15
14
  "database-query": "^1.1.20",
16
15
  "dayjs": "^1.11.19",
17
16
  "json5": "^2.2.3",
18
17
  "lodash": "^4.17.23",
19
18
  "node-schedule": "^2.1.1",
20
- "runner-runtime": "^1.0.126",
19
+ "runner-runtime": "1.0.123",
21
20
  "uuid": "^9.0.0"
22
21
  }
23
22
  }
package/index_bak.js DELETED
@@ -1,725 +0,0 @@
1
- const cluster = require('cluster');
2
- const schedule = require('node-schedule');
3
- const _ = require('lodash');
4
- const axios = require('axios');
5
- const path = require('path');
6
- const os = require('os');
7
- const JSON5 = require('json5');
8
- const Database = require('better-sqlite3');
9
- const fs = require('fs');
10
- const { v4: uuidv4 } = require('uuid');
11
- const { execSync } = require('child_process');
12
-
13
- /**
14
- * CronScheduler - 企业级多进程自动化测试调度器
15
- */
16
- class CronScheduler {
17
- constructor(config = {}) {
18
- // 配置初始化
19
- this.apiUrl = config.apiUrl || process?.env["OPENAPI_DOMAIN"] || "https://ee.apipost.cc";
20
- this.dbFile = config.dbPath || path.resolve(process?.env?.TEMP_DIR || os.tmpdir(), 'echoapi-batch-tasks.sqlite');
21
- this.workerNum = config.workerNum || os.cpus().length;
22
-
23
- // 结果聚合收集器:用于收集同一批次下所有 Runner 的结果
24
- this.resultCollector = new Map();
25
-
26
- if (cluster.isPrimary) {
27
- this.db = new Database(this.dbFile);
28
- this._initDB();
29
- }
30
- }
31
-
32
- /**
33
- * 内部方法:初始化数据库表结构
34
- */
35
- _initDB() {
36
- // 1. 任务配置表
37
-
38
- this.db.pragma('journal_mode = WAL');
39
- this.db.prepare(`
40
- CREATE TABLE IF NOT EXISTS jobs (
41
- job_id TEXT PRIMARY KEY,
42
- name TEXT,
43
- frequency TEXT,
44
- is_cancel INTEGER DEFAULT 0,
45
- api_token TEXT,
46
- project_id TEXT,
47
- cases TEXT, -- 存储 runners 数组 JSON
48
- create_dtime INTEGER
49
- )
50
- `).run();
51
-
52
- // 2. 新增:中间结果结果暂存表 (用于卸载 Master 内存压力)
53
- this.db.prepare(`
54
- CREATE TABLE IF NOT EXISTS task_results (
55
- id INTEGER PRIMARY KEY AUTOINCREMENT,
56
- execution_id TEXT,
57
- data TEXT,
58
- create_at INTEGER
59
- )
60
- `).run();
61
- this.db.prepare(`CREATE INDEX IF NOT EXISTS idx_exec_id ON task_results(execution_id)`).run();
62
- }
63
-
64
- /**
65
- * 核心启动入口
66
- */
67
- run() {
68
- if (cluster.isPrimary) {
69
- this._startMaster();
70
- } else {
71
- this._startWorker();
72
- }
73
- }
74
- /**
75
- * Converts timestamp to ISO format
76
- */
77
- formatTimeToISO(time) {
78
- const dayjs = require('dayjs');
79
- const utc = require('dayjs/plugin/utc');
80
- const timezone = require('dayjs/plugin/timezone');
81
- dayjs.extend(utc);
82
- dayjs.extend(timezone);
83
- return dayjs(time).tz('Asia/Shanghai').format();
84
- }
85
-
86
- // ==========================================
87
- // Master 进程逻辑:负责任务分发与结果聚合
88
- // ==========================================
89
-
90
- _startMaster() {
91
- console.log(`[CronScheduler] Master process ${process.pid} is running.`);
92
-
93
- // 衍生子进程
94
- for (let i = 0; i < this.workerNum; i++) {
95
- this._bindWorker(cluster.fork());
96
- }
97
-
98
- // 进程自动重启逻辑
99
- cluster.on('exit', (worker) => {
100
- console.warn(`[CronScheduler] Worker ${worker.process.pid} died. Forking a new one...`);
101
- this._bindWorker(cluster.fork());
102
- });
103
-
104
- // 启动时自动加载数据库中的任务
105
- this.loadJobs();
106
- }
107
-
108
- _bindWorker(worker) {
109
- worker.on('message', async (msg) => {
110
- if (msg.action === 'UNIT_COMPLETED') {
111
- const { executionId, data } = msg.payload;
112
- const record = this.resultCollector.get(executionId);
113
-
114
- if (record) {
115
-
116
- // record.received.push(_.assign(data, {
117
- // source: 'scheduled',
118
- // start_at: this.formatTimeToISO(data?.start_at),
119
- // end_at: this.formatTimeToISO(data?.end_at)
120
- // }));
121
- // // 检查是否收齐了该 Job 下所有的 runners 结果
122
- // if (record.received.length >= record.total) {
123
- // await this._reportAggregated(executionId, record);
124
- // }
125
- try {
126
- // 【关键优化】不再存入内存 Map,而是存入 SQLite 临时表
127
- this.db.prepare(`INSERT INTO task_results (execution_id, data, create_at) VALUES (?, ?, ?)`)
128
- .run(executionId, JSON.stringify(data), Date.now());
129
-
130
- record.received_count++;
131
- console.log(record.total, record.received_count, 'record.received_count');
132
-
133
- // 检查是否收齐
134
- if (record.received_count >= record.total) {
135
- await this._reportFromDB(executionId, record);
136
- }
137
- } catch (e) {
138
- console.error(`[Master Result Collect Error]`, e.message);
139
- }
140
- }
141
- }
142
- });
143
- }
144
-
145
- /**
146
- * 将聚合后的结果一次性推送到 OpenAPI
147
- */
148
- // async _reportAggregated(executionId, record) {
149
- // try {
150
-
151
- // const results = record.received;
152
- // const endTime = Date.now();
153
-
154
- // // --- 核心统计逻辑 ---
155
- // let stats = {
156
- // http_total: 0,
157
- // http_success: 0,
158
- // assert_total: 0,
159
- // assert_success: 0,
160
- // total_response_time: 0,
161
- // count: 0
162
- // };
163
-
164
- // results.forEach(item => {
165
- // // 累加 HTTP 统计
166
- // stats.http_total += (item.http?.total || 0);
167
- // stats.http_success += (item.http?.success || 0);
168
-
169
- // // 累加 断言 统计
170
- // stats.assert_total += (item.assert?.total || 0);
171
- // stats.assert_success += (item.assert?.success || 0);
172
-
173
- // // 累加 响应时间 用于计算平均值
174
- // stats.total_response_time += (item.total_response_time || 0);
175
- // stats.count++;
176
- // });
177
-
178
- // // 计算通过率 (保留两位小数)
179
- // const httpPassRate = stats.http_total > 0
180
- // ? ((stats.http_success / stats.http_total) * 100).toFixed(2)
181
- // : "0.00";
182
-
183
- // const assertPassRate = stats.assert_total > 0
184
- // ? ((stats.assert_success / stats.assert_total) * 100).toFixed(2)
185
- // : "0.00";
186
-
187
- // // 计算平均响应时间
188
- // const avgResponseTime = stats.count > 0
189
- // ? (stats.total_response_time / stats.count).toFixed(0)
190
- // : 0;
191
-
192
- // // --- 封装最终 Payload ---
193
- // const finalPayload = {
194
- // info: {
195
- // job_id: record.job_info.job_id,
196
- // report_name: record.job_info.name,
197
- // project_id: record.job_info.project_id,
198
- // execution_id: executionId,
199
- // start_at: this.formatTimeToISO(record.job_info.start_time),
200
- // end_at: this.formatTimeToISO(endTime),
201
-
202
- // // 业务统计字段
203
- // http_pass_rate: `${httpPassRate}%`,
204
- // assert_pass_rate: `${assertPassRate}%`,
205
- // avg_response_time: `${avgResponseTime}ms`,
206
-
207
- // // 原始计数
208
- // total_http: stats.http_total,
209
- // success_http: stats.http_success,
210
- // total_assert: stats.assert_total,
211
- // success_assert: stats.assert_success,
212
-
213
- // total_units: record.total,
214
- // actual_units: results.length
215
- // },
216
- // results: results // 原始详细列表
217
- // };
218
-
219
- // // 写入本地备份,增加 try-catch 避免权限问题
220
- // try {
221
- // // fs.writeFileSync('last_report.json', JSON.stringify(finalPayload));
222
- // } catch (e) { }
223
-
224
- // console.log(`[CronScheduler] Batch job finished. ExecutionID: ${executionId}. Aggregating ${record.total} results...`);
225
- // // console.log(`[CronScheduler] TOKEN ${record.api_token}`);
226
-
227
- // const response = await axios.post(`${this.apiUrl}/open/hnzycfc/scheduled_task/report/add`, finalPayload, {
228
- // headers: {
229
- // 'Content-Type': 'application/json',
230
- // 'api-token': record.api_token
231
- // }
232
- // });
233
- // if (response?.data?.code != 0) {
234
- // console.log(`Execution Id ${executionId} reporting error: ${response?.data?.msg}`);
235
- // } else {
236
- // console.log(`Execution Id ${executionId} reported success...`);
237
- // }
238
- // } catch (err) {
239
- // console.error(`[CronScheduler] Report aggregation failed:`, err.message);
240
- // } finally {
241
- // this.resultCollector.delete(executionId); // 清理内存
242
- // console.info(`[CronScheduler] Result collector cleared for ${executionId}`);
243
- // }
244
- // }
245
- /**
246
- * 查看当前所有待执行(活跃)的任务及其详细信息
247
- */
248
- getPendingJobs() {
249
- // 1. 从内存中获取 node-schedule 注册的所有 job_id
250
- const activeTimerIds = Object.keys(schedule.scheduledJobs);
251
-
252
- if (activeTimerIds.length === 0) {
253
- return [];
254
- }
255
-
256
- // 2. 从数据库中查询这些活跃任务的详细配置
257
- // 使用 IN 语句批量查询,效率更高
258
- const placeholders = activeTimerIds.map(() => '?').join(',');
259
- const query = `SELECT job_id, name, frequency, project_id, create_dtime FROM jobs WHERE job_id IN (${placeholders}) AND is_cancel < 1`;
260
-
261
- try {
262
- const jobs = this.db.prepare(query).all(...activeTimerIds);
263
-
264
- // 3. 结合内存中的下次执行时间进行封装
265
- return _.map(jobs, job => {
266
- const cronRule = this.convertToCron(JSON5.parse(job.frequency));
267
- const nextTime = this.getNextExecutionTime(cronRule);
268
-
269
- return {
270
- job_id: job.job_id,
271
- name: job.name,
272
- project_id: job.project_id,
273
- cron: cronRule,
274
- next_run_at: typeof nextTime === 'number' ? new Date(nextTime).toLocaleString() : nextTime,
275
- timestamp: nextTime // 方便前端排序
276
- };
277
- }).sort((a, b) => (a.timestamp - b.timestamp)); // 按执行时间先后排序
278
- } catch (e) {
279
- console.error('[View Jobs Error]', e.message);
280
- return [];
281
- }
282
- }
283
-
284
- /**
285
- * 增量更新定时器:解决时间漂移和重复触发的关键
286
- */
287
- upsertTimer(job_id, name, frequency, jobData) {
288
- // 1. 如果已存在,先取消旧的内存定时器
289
- if (schedule.scheduledJobs[job_id]) {
290
- schedule.scheduledJobs[job_id].cancel();
291
- }
292
-
293
- // 2. 如果任务未被取消 (is_cancel < 1)
294
- if (jobData && jobData.is_cancel < 1) {
295
- try {
296
- const freq = typeof frequency === 'string' ? JSON5.parse(frequency) : frequency;
297
- const cronRule = this.convertToCron(freq);
298
-
299
- // 重新创建定时器
300
- schedule.scheduleJob(job_id, cronRule, () => {
301
- const now = new Date().toLocaleString();
302
- console.log(`[Timer Triggered] ${name} (${job_id}) at ${now}`);
303
-
304
- // 触发时重新从数据库获取最新数据,确保分发的是最新的 runners
305
- const currentJob = this.db.prepare(`SELECT * FROM jobs WHERE job_id = ?`).get(job_id);
306
- if (currentJob && currentJob.is_cancel < 1) {
307
- this._dispatch(currentJob);
308
- }
309
- });
310
- } catch (e) {
311
- console.error(`[CronScheduler] Upsert timer error (ID: ${job_id}):`, e.message);
312
- }
313
- }
314
- }
315
-
316
- // ==========================================
317
- // 公共接口:供 Express 路由层调用
318
- // ==========================================
319
-
320
- /**
321
- * 创建或更新任务
322
- */
323
- createJob(payload) {
324
- const { job_id, name, frequency, api_token, project_id, runners } = payload;
325
- const casesJson = JSON.stringify(Array.isArray(runners) ? runners : []);
326
- const freqJson = JSON.stringify(frequency);
327
-
328
- const stmt = this.db.prepare(`
329
- INSERT INTO jobs (job_id, name, frequency, api_token, project_id, cases, create_dtime)
330
- VALUES (?, ?, ?, ?, ?, ?, ?)
331
- ON CONFLICT(job_id) DO UPDATE SET
332
- name=excluded.name,
333
- frequency=excluded.frequency,
334
- cases=excluded.cases,
335
- api_token=excluded.api_token,
336
- is_cancel=0
337
- `);
338
- stmt.run(job_id, name, freqJson, api_token, project_id, casesJson, Date.now());
339
-
340
- // 使用增量更新,不再 loadJobs()
341
- this.upsertTimer(job_id, name, frequency, { is_cancel: 0 });
342
- return true;
343
- }
344
-
345
- cancelJob(job_id) {
346
- this.db.prepare(`UPDATE jobs SET is_cancel = 1 WHERE job_id = ?`).run(job_id);
347
- if (schedule.scheduledJobs[job_id]) {
348
- schedule.scheduledJobs[job_id].cancel();
349
- }
350
- return true;
351
- }
352
-
353
- restartJob(job_id) {
354
- this.db.prepare(`UPDATE jobs SET is_cancel = 0 WHERE job_id = ?`).run(job_id);
355
- const job = this.db.prepare(`SELECT * FROM jobs WHERE job_id = ?`).get(job_id);
356
- if (job) {
357
- this.upsertTimer(job.job_id, job.name, job.frequency, job);
358
- }
359
- return true;
360
- }
361
-
362
- deleteJob(job_id) {
363
- this.db.prepare(`DELETE FROM jobs WHERE job_id = ?`).run(job_id);
364
- if (schedule.scheduledJobs[job_id]) {
365
- schedule.scheduledJobs[job_id].cancel();
366
- }
367
- return true;
368
- }
369
-
370
- getAllJobs(project_id) {
371
- const jobs = this.db.prepare(`SELECT * FROM jobs WHERE project_id = ?`).all(project_id);
372
- // 返回列表时剔除巨大的 cases 字段,优化内存
373
- return _.map(jobs, job => {
374
- const freq = JSON5.parse(job.frequency || '{}');
375
- const cases = JSON5.parse(job.cases || '[]');
376
- const cronRule = this.convertToCron(freq);
377
-
378
- // 获取下次执行时间
379
- // 如果任务已取消,则不显示下次执行时间
380
- let nextRunTime = '-';
381
- if (job.is_cancel < 1) {
382
- nextRunTime = this.getNextExecutionTime(cronRule);
383
- }
384
-
385
- return {
386
- ..._.omit(job, ['cases']), // 排除大数据字段
387
- status: job.is_cancel === 0 ? 0 : 1, // 0:运行中, 1:已暂停
388
- next_run_time: nextRunTime,
389
- frequency_display: cronRule, // 返回原始 Cron 规则供前端参考
390
- runners: _.map(cases, (v) => v?.option?.testing_id)
391
- };
392
- });
393
- }
394
-
395
- loadJobs() {
396
- console.log('[CronScheduler] Initializing timers from database...');
397
- // 启动时全量清理一次
398
- Object.values(schedule.scheduledJobs).forEach(j => j.cancel());
399
- // 修改 loadJobs 里的 SQL
400
- const jobs = this.db.prepare(`SELECT job_id, name, frequency, is_cancel FROM jobs WHERE is_cancel < 1`).all();
401
- // const jobs = this.db.prepare(`SELECT * FROM jobs WHERE is_cancel < 1`).all();
402
- jobs.forEach(job => {
403
- this.upsertTimer(job.job_id, job.name, job.frequency, job);
404
- });
405
- }
406
-
407
- /**
408
- * 从数据库提取数据并聚合报告
409
- */
410
- async _reportFromDB(executionId, record) {
411
- try {
412
- console.log(`[CronScheduler] Batch job finished. Aggregating results from DB for: ${executionId}`);
413
-
414
- // 1. 从 DB 读取该执行批次的所有结果
415
- const rows = this.db.prepare(`SELECT data FROM task_results WHERE execution_id = ?`).all(executionId);
416
-
417
- let stats = {
418
- http_total: 0, http_success: 0,
419
- assert_total: 0, assert_success: 0,
420
- total_resp_time: 0
421
- };
422
-
423
- // 2. 转换数据并计算统计信息
424
- const results = rows.map(row => {
425
- const item = JSON.parse(row.data);
426
-
427
- // 累加统计
428
- stats.http_total += (item.http?.total || 0);
429
- stats.http_success += (item.http?.success || 0);
430
- stats.assert_total += (item.assert?.total || 0);
431
- stats.assert_success += (item.assert?.success || 0);
432
- stats.total_resp_time += (item.total_response_time || 0);
433
-
434
- return _.assign(item, {
435
- source: 'scheduled',
436
- start_at: this.formatTimeToISO(item?.start_at),
437
- end_at: this.formatTimeToISO(item?.end_at)
438
- });
439
- });
440
-
441
- // 3. 计算通过率等指标
442
- const httpRate = stats.http_total > 0 ? ((stats.http_success / stats.http_total) * 100).toFixed(2) : "0.00";
443
- const assertRate = stats.assert_total > 0 ? ((stats.assert_success / stats.assert_total) * 100).toFixed(2) : "0.00";
444
- const avgResp = results.length > 0 ? (stats.total_resp_time / results.length).toFixed(0) : 0;
445
-
446
- // 4. 封装 Payload
447
- const finalPayload = {
448
- info: {
449
- job_id: record.job_info.job_id,
450
- report_name: record.job_info.name,
451
- project_id: record.job_info.project_id,
452
- execution_id: executionId,
453
- start_at: this.formatTimeToISO(record.job_info.start_time),
454
- end_at: this.formatTimeToISO(Date.now()),
455
- http_pass_rate: `${httpRate}%`,
456
- assert_pass_rate: `${assertRate}%`,
457
- avg_response_time: `${avgResp}ms`,
458
- total_http: stats.http_total,
459
- success_http: stats.http_success,
460
- total_assert: stats.assert_total,
461
- success_assert: stats.assert_success,
462
- total_units: record.total,
463
- actual_units: results.length
464
- },
465
- results: results
466
- };
467
- // fs.writeFileSync('finalPayload.json', JSON.stringify(finalPayload))
468
- // 5. 上报 API
469
- const response = await axios.post(`${this.apiUrl}/open/hnzycfc/scheduled_task/report/add`, finalPayload, {
470
- headers: { 'Content-Type': 'application/json', 'api-token': record.api_token },
471
- maxContentLength: Infinity,
472
- maxBodyLength: Infinity
473
- });
474
- // console.log(response?.data, 'response?.data');
475
-
476
- if (response?.data?.code == 0) {
477
- console.log(`[CronScheduler] Execution ${executionId} reported success.`);
478
- // 成功后删除数据库里的临时结果
479
- this.db.prepare(`DELETE FROM task_results WHERE execution_id = ?`).run(executionId);
480
- } else {
481
- console.error(`[Report Error] ${executionId}: ${response?.data?.msg}`);
482
- }
483
-
484
- } catch (err) {
485
- console.error(`[Aggregation Critical Error]`, err.stack);
486
- } finally {
487
- this.resultCollector.delete(executionId);
488
- }
489
- }
490
- /**
491
- * 分发逻辑:将一个批次内的所有 Runner 均衡分配给 Workers
492
- */
493
- _dispatch(job) {
494
- try {
495
- const runners = JSON5.parse(job.cases || '[]');
496
- if (runners.length === 0) return;
497
-
498
- const executionId = `${job.job_id}_${Date.now()}`;
499
-
500
- // 1. 初始化内存计数器
501
- this.resultCollector.set(executionId, {
502
- total: runners.length,
503
- received_count: 0,
504
- api_token: job.api_token,
505
- job_info: {
506
- job_id: job.job_id,
507
- name: job.name,
508
- project_id: job.project_id,
509
- start_time: Date.now()
510
- }
511
- });
512
-
513
- // 2. 【新增】僵尸任务超时清理 (30分钟)
514
- // 解决 Worker 意外崩溃导致结果永远收不齐、内存无法释放的问题
515
- setTimeout(() => {
516
- if (this.resultCollector.has(executionId)) {
517
- console.warn(`[Timeout Cleanup] Execution ${executionId} 超过30分钟未收齐结果,强制释放内存。`);
518
- this.resultCollector.delete(executionId);
519
-
520
- // 可选:同时清理数据库中的残留碎片,防止数据库体积无限增长
521
- try {
522
- this.db.prepare(`DELETE FROM task_results WHERE execution_id = ?`).run(executionId);
523
- } catch (dbErr) {
524
- console.error(`[Cleanup DB Error] ${executionId}:`, dbErr.message);
525
- }
526
- }
527
- }, 30 * 60 * 1000);
528
-
529
- // 3. 任务分发
530
- const activeWorkers = Object.values(cluster.workers).filter(w => w.isConnected());
531
- console.log(`[Dispatch] Active workers count: ${activeWorkers.length}`);
532
-
533
- runners.forEach((runner, index) => {
534
- const worker = _.sample(activeWorkers);
535
- if (worker) {
536
- console.log(`[Dispatch] Sending Unit ${index} to Worker ${worker.process.pid}`);
537
- worker.send({
538
- action: 'EXECUTE_UNIT',
539
- payload: {
540
- executionId,
541
- api_token: job.api_token,
542
- test_events: runner.test_events,
543
- option: runner.option,
544
- unit_index: index
545
- }
546
- });
547
- } else {
548
- console.error(`[Dispatch Error] No active workers available to handle job ${job.job_id}`);
549
- }
550
- });
551
- } catch (e) {
552
- console.error(`[CronScheduler] Dispatch error:`, e.message);
553
- }
554
- }
555
-
556
- // ==========================================
557
- // Worker 进程逻辑:负责具体的任务执行
558
- // ==========================================
559
-
560
- _startWorker() {
561
- try {
562
- console.log(`[Worker ${process.pid}] Starting initialization...`);
563
- const { run: runner } = require('runner-runtime');
564
- const net = require('net');
565
-
566
- process.on('message', async (msg) => {
567
- if (msg.action === 'EXECUTE_UNIT') {
568
- const { executionId, api_token, test_events, option } = msg.payload;
569
- const socketPath = path.join(process?.env?.TEMP_DIR || os.tmpdir(), `echoapi_${uuidv4()}.sock`);
570
-
571
- const server = net.createServer((socket) => {
572
- socket.on('data', async (stream) => {
573
- try {
574
- const info = JSON.parse(stream.toString());
575
- const { action, data } = info;
576
- await this._handleUnitScript(socket, action, data);
577
- } catch (e) {
578
- socket.write(JSON.stringify({ status: 'error', message: e.message }) + "\n\n");
579
- }
580
- });
581
- });
582
-
583
- // --- 这里是关键!注册错误监听 ---
584
- server.on('error', (err) => {
585
- // 在服务器上看到这个日志,就能定位是权限问题还是路径问题
586
- console.error(`[Worker Socket Server Error] PID: ${process.pid}`);
587
- console.error(`[Error Details] Code: ${err.code} | Message: ${err.message}`);
588
- console.error(`[Attempted Path] ${socketPath}`);
589
- });
590
-
591
- server.listen(socketPath, () => {
592
- const finalOptions = _.cloneDeep(option || {});
593
- const base64Pipe = Buffer.from(socketPath).toString('base64');
594
- _.set(finalOptions, 'env.ELECTRON_PIPE', base64Pipe);
595
-
596
- runner(test_events, finalOptions, (res) => {
597
- if (res?.action === 'complete') {
598
- process.send({
599
- action: 'UNIT_COMPLETED',
600
- payload: { executionId, data: res.data, api_token }
601
- });
602
- server.close();
603
- if (fs.existsSync(socketPath)) {
604
- try { fs.unlinkSync(socketPath); } catch (e) { }
605
- }
606
- }
607
- });
608
- });
609
- }
610
- });
611
- process.on('uncaughtException', (err) => {
612
- console.error('[Worker Fatal Error] 捕获到沙箱崩溃:', err.message);
613
- // 即使崩溃也不要让子进程立即退出,或者让 Master 自动重启它
614
- });
615
- } catch (error) {
616
- console.error(`[Worker ${process.pid}] Critical Boot Error:`, err.stack);
617
- process.exit(1);
618
- }
619
- }
620
-
621
- async _handleUnitScript(socket, action, data) {
622
- try {
623
- switch (action) {
624
- case 'queryDatabase': {
625
- const { dbconfig, query } = data;
626
- const { DatabaseQuery } = require('database-query');
627
- const result = await DatabaseQuery(dbconfig, query);
628
- socket.write(JSON.stringify(result) + "\n\n");
629
- break;
630
- }
631
- case 'execute': {
632
- const { file, args = [], option: execOption = {} } = data;
633
- const ext = path.extname(file).slice(1).toLowerCase();
634
- let command = "";
635
-
636
- switch (ext) {
637
- case 'jar': command = `java -jar ${file} ${args.join(' ')}`; break;
638
- case 'php': command = `php -f ${file} ${args.join(' ')}`; break;
639
- case 'js': command = `node ${file} ${args.join(' ')}`; break;
640
- case 'py':
641
- case 'py3': command = `python3 ${file} ${args.join(' ')}`; break;
642
- case 'go': command = `go run ${file} ${args.join(' ')}`; break;
643
- case 'sh': command = `sh ${file} ${args.join(' ')}`; break;
644
- default: command = `${file} ${args.join(' ')}`;
645
- }
646
-
647
- const isWindows = process.platform === 'win32';
648
- const config = _.assign(isWindows ? { encoding: 'cp936' } : { encoding: 'utf8' }, execOption);
649
- const output = String(execSync(command, config));
650
-
651
- socket.write(JSON.stringify({ status: 'success', result: output }) + "\n\n");
652
- break;
653
- }
654
- }
655
- } catch (err) {
656
- socket.write(JSON.stringify({ status: 'error', message: err.message }) + "\n\n");
657
- }
658
- }
659
-
660
- // ==========================================
661
- // 辅助工具
662
- // ==========================================
663
-
664
- /**
665
- * 修正后的 Cron 转换逻辑:确保每一项都从第 0 秒开始执行
666
- */
667
- convertToCron(freq) {
668
- if (!freq) return '0 0 0 * * *';
669
- const data = typeof freq === 'string' ? JSON5.parse(freq) : freq;
670
-
671
- // 情况 A:用户直接传 Cron 表达式
672
- if (data.type === 'cron') {
673
- const exp = data.cron?.expression || '0 0 * * *';
674
- const parts = exp.trim().split(/\s+/);
675
- // 如果是 5 位,补齐第 0 位为 0 秒,确保不重复触发
676
- return parts.length === 5 ? `0 ${exp}` : exp;
677
- }
678
-
679
- // 情况 B:预设周期配置
680
- if (data.type === 'preset') {
681
- const { cycle, config } = data.preset;
682
-
683
- switch (cycle) {
684
- case 'minute':
685
- // 修正:强制 0 秒开始,每 interval 分钟执行
686
- return `0 */${config.interval} * * * *`;
687
-
688
- case 'hour':
689
- // 修正:强制 0 秒 0 分开始,每 interval 小时执行
690
- return `0 0 */${config.interval} * * *`;
691
-
692
- case 'day': {
693
- // 兼容 '10:30' 这种格式
694
- const [hour, minute] = (config.time || "0:0").split(':');
695
- return `0 ${parseInt(minute)} ${parseInt(hour)} * * *`;
696
- }
697
-
698
- case 'week': {
699
- const [hour, minute] = (config.time || "0:0").split(':');
700
- // config.weekdays 应该是 [1, 3, 5] 这种数组
701
- const days = Array.isArray(config.weekdays) ? config.weekdays.join(',') : '*';
702
- return `0 ${parseInt(minute)} ${parseInt(hour)} * * ${days}`;
703
- }
704
-
705
- default:
706
- return '0 0 0 * * *';
707
- }
708
- }
709
-
710
- return '0 0 0 * * *';
711
- }
712
-
713
- getNextExecutionTime(cron) {
714
-
715
- try {
716
- const { CronExpressionParser } = require('cron-parser');
717
- const interval = CronExpressionParser.parse(cron, { tz: 'Asia/Shanghai', utc: false });
718
- return new Date(interval.next().toISOString()).getTime()
719
- } catch (e) {
720
- return '-';
721
- }
722
- }
723
- }
724
-
725
- module.exports = CronScheduler;
package/index_bak1.js DELETED
@@ -1,465 +0,0 @@
1
- const cluster = require('cluster');
2
- const schedule = require('node-schedule');
3
- const _ = require('lodash');
4
- const axios = require('axios');
5
- const path = require('path');
6
- const os = require('os');
7
- const JSON5 = require('json5');
8
- const Database = require('better-sqlite3');
9
- const fs = require('fs');
10
- const { v4: uuidv4 } = require('uuid');
11
- const { execSync } = require('child_process');
12
-
13
- /**
14
- * CronScheduler - 企业级多进程自动化测试调度器 (生产增强版)
15
- */
16
- class CronScheduler {
17
- constructor(config = {}) {
18
- // 1. 配置初始化
19
- this.apiUrl = config.apiUrl || process?.env["OPENAPI_DOMAIN"] || "https://ee.apipost.cc";
20
-
21
- // 【优化】不要直接放 /tmp,在项目目录下创建持久化 data 目录
22
- const dataDir = path.resolve(process?.env?.TEMP_DIR || os.tmpdir(), 'scheduler_data');
23
- if (cluster.isPrimary && !fs.existsSync(dataDir)) {
24
- fs.mkdirSync(dataDir, { recursive: true });
25
- }
26
-
27
- this.dbFile = config.dbPath || path.join(dataDir, 'echoapi-batch-tasks.sqlite');
28
- this.workerNum = config.workerNum || os.cpus().length;
29
-
30
- // 结果聚合收集器 (仅在 Master 内存中存在)
31
- this.resultCollector = new Map();
32
-
33
- // 2. 数据库连接与安全自检 (仅 Master 进程持有写连接)
34
- if (cluster.isPrimary) {
35
- this._connectDB();
36
- }
37
- }
38
-
39
- /**
40
- * 内部方法:建立数据库连接并增加损坏自动修复逻辑
41
- */
42
- _connectDB() {
43
- try {
44
- // 预检:如果是 0 字节损坏文件,直接删除
45
- if (fs.existsSync(this.dbFile) && fs.statSync(this.dbFile).size === 0) {
46
- fs.unlinkSync(this.dbFile);
47
- }
48
-
49
- this.db = new Database(this.dbFile, { timeout: 10000 });
50
-
51
- // 【核心】执行完整性自检,解决 malformed 和 not a database 问题
52
- const check = this.db.pragma('integrity_check');
53
- if (check[0].integrity_check !== 'ok') {
54
- throw new Error('SQLite integrity check failed');
55
- }
56
-
57
- this._initDB();
58
- console.log(`[Database] Connection verified and initialized.`);
59
- } catch (e) {
60
- console.error(`[Database Critical] 数据库损坏(Malformed/NotADb): ${e.message}`);
61
- if (this.db) this.db.close();
62
-
63
- // 自动备份损坏文件,腾出位置重建
64
- const backupPath = `${this.dbFile}.corrupted_${Date.now()}`;
65
- if (fs.existsSync(this.dbFile)) {
66
- fs.renameSync(this.dbFile, backupPath);
67
- }
68
-
69
- // 重建数据库
70
- this.db = new Database(this.dbFile);
71
- this._initDB();
72
- console.warn(`[Database] 坏库已隔离至 ${backupPath},新库已就绪。`);
73
- }
74
- }
75
-
76
- /**
77
- * 初始化表结构与性能参数
78
- */
79
- _initDB() {
80
- // 性能调优:WAL 模式能极大减少多进程读取时的锁竞争
81
- this.db.pragma('journal_mode = WAL');
82
- this.db.pragma('synchronous = NORMAL');
83
- this.db.pragma('busy_timeout = 5000');
84
-
85
- // 1. 任务配置表
86
- this.db.prepare(`
87
- CREATE TABLE IF NOT EXISTS jobs (
88
- job_id TEXT PRIMARY KEY,
89
- name TEXT,
90
- frequency TEXT,
91
- is_cancel INTEGER DEFAULT 0,
92
- api_token TEXT,
93
- project_id TEXT,
94
- cases TEXT,
95
- create_dtime INTEGER
96
- )
97
- `).run();
98
-
99
- // 2. 中间结果暂存表 (Master 写入,聚合后删除)
100
- this.db.prepare(`
101
- CREATE TABLE IF NOT EXISTS task_results (
102
- id INTEGER PRIMARY KEY AUTOINCREMENT,
103
- execution_id TEXT,
104
- data TEXT,
105
- create_at INTEGER
106
- )
107
- `).run();
108
- this.db.prepare(`CREATE INDEX IF NOT EXISTS idx_exec_id ON task_results(execution_id)`).run();
109
- }
110
-
111
- run() {
112
- if (cluster.isPrimary) {
113
- this._startMaster();
114
- } else {
115
- this._startWorker();
116
- }
117
- }
118
-
119
- formatTimeToISO(time) {
120
- if (!time) return null;
121
- try {
122
- const dayjs = require('dayjs');
123
- const utc = require('dayjs/plugin/utc');
124
- const timezone = require('dayjs/plugin/timezone');
125
- dayjs.extend(utc);
126
- dayjs.extend(timezone);
127
- return dayjs(time).tz('Asia/Shanghai').format();
128
- } catch (e) {
129
- return new Date(time).toISOString();
130
- }
131
- }
132
-
133
- // ==========================================
134
- // Master 进程逻辑:单点读写
135
- // ==========================================
136
-
137
- _startMaster() {
138
- console.log(`[CronScheduler] Master ${process.pid} is running.`);
139
-
140
- for (let i = 0; i < this.workerNum; i++) {
141
- this._bindWorker(cluster.fork());
142
- }
143
-
144
- cluster.on('exit', (worker, code) => {
145
- console.warn(`[CronScheduler] Worker ${worker.process.pid} died (code:${code}). Forking...`);
146
- this._bindWorker(cluster.fork());
147
- });
148
-
149
- this.loadJobs();
150
- }
151
-
152
- _bindWorker(worker) {
153
- worker.on('message', async (msg) => {
154
- if (msg.action === 'UNIT_COMPLETED') {
155
- const { executionId, data } = msg.payload;
156
- const record = this.resultCollector.get(executionId);
157
-
158
- if (record) {
159
- try {
160
- // 【单进程写入】只有 Master 负责操作数据库,规避并发写导致的损坏
161
- this.db.prepare(`INSERT INTO task_results (execution_id, data, create_at) VALUES (?, ?, ?)`)
162
- .run(executionId, JSON.stringify(data), Date.now());
163
-
164
- // 计数检查
165
- if (typeof record.received_count !== 'number') record.received_count = 0;
166
- record.received_count++;
167
-
168
- console.log(`[Progress] ${executionId}: ${record.received_count}/${record.total}`);
169
-
170
- if (record.received_count >= record.total) {
171
- await this._reportFromDB(executionId, record);
172
- }
173
- } catch (e) {
174
- console.error(`[Master DB Write Error]`, e.message);
175
- }
176
- }
177
- }
178
- });
179
- }
180
-
181
- async _reportFromDB(executionId, record) {
182
- try {
183
- console.log(`[CronScheduler] Aggregating results for: ${executionId}`);
184
- const rows = this.db.prepare(`SELECT data FROM task_results WHERE execution_id = ?`).all(executionId);
185
-
186
- let stats = {
187
- http_total: 0, http_success: 0,
188
- assert_total: 0, assert_success: 0,
189
- total_resp_time: 0
190
- };
191
-
192
- const results = rows.map(row => {
193
- const item = JSON.parse(row.data);
194
- stats.http_total += (item.http?.total || 0);
195
- stats.http_success += (item.http?.success || 0);
196
- stats.assert_total += (item.assert?.total || 0);
197
- stats.assert_success += (item.assert?.success || 0);
198
- stats.total_resp_time += (item.total_response_time || 0);
199
-
200
- return _.assign(item, {
201
- source: 'scheduled',
202
- start_at: this.formatTimeToISO(item?.start_at),
203
- end_at: this.formatTimeToISO(item?.end_at)
204
- });
205
- });
206
-
207
- const httpRate = stats.http_total > 0 ? ((stats.http_success / stats.http_total) * 100).toFixed(2) : "0.00";
208
- const assertRate = stats.assert_total > 0 ? ((stats.assert_success / stats.assert_total) * 100).toFixed(2) : "0.00";
209
- const avgResp = results.length > 0 ? (stats.total_resp_time / results.length).toFixed(0) : 0;
210
-
211
- const finalPayload = {
212
- info: {
213
- job_id: record.job_info.job_id,
214
- report_name: record.job_info.name,
215
- project_id: record.job_info.project_id,
216
- execution_id: executionId,
217
- start_at: this.formatTimeToISO(record.job_info.start_time),
218
- end_at: this.formatTimeToISO(Date.now()),
219
- http_pass_rate: `${httpRate}%`,
220
- assert_pass_rate: `${assertRate}%`,
221
- avg_response_time: `${avgResp}ms`,
222
- total_http: stats.http_total,
223
- success_http: stats.http_success,
224
- total_assert: stats.assert_total,
225
- success_assert: stats.assert_success,
226
- total_units: record.total,
227
- actual_units: results.length
228
- },
229
- results: results
230
- };
231
-
232
- const response = await axios.post(`${this.apiUrl}/open/hnzycfc/scheduled_task/report/add`, finalPayload, {
233
- headers: { 'Content-Type': 'application/json', 'api-token': record.api_token },
234
- maxContentLength: Infinity,
235
- maxBodyLength: Infinity,
236
- timeout: 60000 // 聚合报告可能很大,增加超时时间
237
- });
238
-
239
- if (response?.data?.code == 0) {
240
- console.log(`[CronScheduler] Execution ${executionId} Success.`);
241
- // 上报成功后清理中间数据
242
- this.db.prepare(`DELETE FROM task_results WHERE execution_id = ?`).run(executionId);
243
- } else {
244
- console.error(`[Report Error] ${executionId}: ${response?.data?.msg}`);
245
- }
246
- } catch (err) {
247
- console.error(`[Aggregation Error]`, err.stack);
248
- } finally {
249
- this.resultCollector.delete(executionId);
250
- }
251
- }
252
-
253
- _dispatch(job) {
254
- try {
255
- const runners = JSON5.parse(job.cases || '[]');
256
- if (runners.length === 0) return;
257
-
258
- const executionId = `${job.job_id}_${Date.now()}`;
259
- this.resultCollector.set(executionId, {
260
- total: runners.length,
261
- received_count: 0,
262
- api_token: job.api_token,
263
- job_info: {
264
- job_id: job.job_id,
265
- name: job.name,
266
- project_id: job.project_id,
267
- start_time: Date.now()
268
- }
269
- });
270
-
271
- const activeWorkers = Object.values(cluster.workers).filter(w => w.isConnected());
272
- runners.forEach((runner, index) => {
273
- const worker = _.sample(activeWorkers);
274
- if (worker) {
275
- worker.send({
276
- action: 'EXECUTE_UNIT',
277
- payload: { executionId, api_token: job.api_token, test_events: runner.test_events, option: runner.option, unit_index: index }
278
- });
279
- }
280
- });
281
- } catch (e) { console.error(`[Dispatch Error]:`, e.message); }
282
- }
283
-
284
- // ==========================================
285
- // Worker 进程逻辑:仅负责计算,不碰数据库
286
- // ==========================================
287
-
288
- _startWorker() {
289
- console.log(`[Worker ${process.pid}] Ready.`);
290
- const { run: runner } = require('runner-runtime');
291
- const net = require('net');
292
-
293
- process.on('message', async (msg) => {
294
- if (msg.action === 'EXECUTE_UNIT') {
295
- const { executionId, api_token, test_events, option } = msg.payload;
296
- const socketPath = path.join(os.tmpdir(), `ea_${uuidv4().slice(0, 8)}.sock`);
297
-
298
- const server = net.createServer((socket) => {
299
- socket.on('data', async (stream) => {
300
- try {
301
- const info = JSON.parse(stream.toString());
302
- await this._handleUnitScript(socket, info.action, info.data);
303
- } catch (e) {
304
- if (!socket.destroyed) socket.write(JSON.stringify({ status: 'error', message: e.message }) + "\n\n");
305
- }
306
- });
307
- });
308
-
309
- server.on('error', (err) => console.error(`[Socket Server Error] PID ${process.pid}: ${err.message}`));
310
-
311
- server.listen(socketPath, () => {
312
- const finalOptions = _.cloneDeep(option || {});
313
- const base64Pipe = Buffer.from(socketPath).toString('base64');
314
- _.set(finalOptions, 'env.ELECTRON_PIPE', base64Pipe);
315
-
316
- runner(test_events, finalOptions, (res) => {
317
- if (res?.action === 'complete') {
318
- if (process.connected) {
319
- process.send({ action: 'UNIT_COMPLETED', payload: { executionId, data: res.data, api_token } });
320
- }
321
- server.close();
322
- try { if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath); } catch (e) { }
323
- }
324
- });
325
- });
326
- }
327
- });
328
-
329
- process.on('uncaughtException', (err) => {
330
- console.error('[Worker Fatal] Exception:', err.message);
331
- });
332
- }
333
-
334
- // ==========================================
335
- // 业务与辅助方法
336
- // ==========================================
337
-
338
- async _handleUnitScript(socket, action, data) {
339
- try {
340
- switch (action) {
341
- case 'queryDatabase': {
342
- const { DatabaseQuery } = require('database-query');
343
- const result = await DatabaseQuery(data.dbconfig, data.query);
344
- socket.write(JSON.stringify(result) + "\n\n");
345
- break;
346
- }
347
- case 'execute': {
348
- const commandMap = { 'jar': 'java -jar', 'php': 'php -f', 'js': 'node', 'py': 'python3', 'py3': 'python3', 'go': 'go run', 'sh': 'sh' };
349
- const ext = path.extname(data.file).slice(1).toLowerCase();
350
- const command = `${commandMap[ext] || ''} ${data.file} ${(data.args || []).join(' ')}`;
351
- const output = execSync(command, _.assign({ encoding: process.platform === 'win32' ? 'cp936' : 'utf8' }, data.option || {}));
352
- socket.write(JSON.stringify({ status: 'success', result: String(output) }) + "\n\n");
353
- break;
354
- }
355
- }
356
- } catch (err) {
357
- socket.write(JSON.stringify({ status: 'error', message: err.message }) + "\n\n");
358
- }
359
- }
360
-
361
- loadJobs() {
362
- console.log('[CronScheduler] Loading timers from DB...');
363
- Object.values(schedule.scheduledJobs).forEach(j => j.cancel());
364
- try {
365
- const jobs = this.db.prepare(`SELECT * FROM jobs WHERE is_cancel < 1`).all();
366
- jobs.forEach(job => this.upsertTimer(job.job_id, job.name, job.frequency, job));
367
- } catch (e) {
368
- console.error('[Load Jobs Error]', e.message);
369
- }
370
- }
371
-
372
- upsertTimer(job_id, name, frequency, jobData) {
373
- if (schedule.scheduledJobs[job_id]) schedule.scheduledJobs[job_id].cancel();
374
- if (jobData?.is_cancel < 1) {
375
- const cronRule = this.convertToCron(frequency);
376
- schedule.scheduleJob(job_id, cronRule, () => {
377
- const currentJob = this.db.prepare(`SELECT * FROM jobs WHERE job_id = ?`).get(job_id);
378
- if (currentJob && currentJob.is_cancel < 1) this._dispatch(currentJob);
379
- });
380
- }
381
- }
382
-
383
- createJob(payload) {
384
- const { job_id, name, frequency, api_token, project_id, runners } = payload;
385
- const stmt = this.db.prepare(`
386
- INSERT INTO jobs (job_id, name, frequency, api_token, project_id, cases, create_dtime)
387
- VALUES (?, ?, ?, ?, ?, ?, ?)
388
- ON CONFLICT(job_id) DO UPDATE SET name=excluded.name, frequency=excluded.frequency, cases=excluded.cases, api_token=excluded.api_token, is_cancel=0
389
- `);
390
- stmt.run(job_id, name, JSON.stringify(frequency), api_token, project_id, JSON.stringify(runners || []), Date.now());
391
- this.upsertTimer(job_id, name, frequency, { is_cancel: 0 });
392
- return true;
393
- }
394
-
395
- cancelJob(job_id) {
396
- this.db.prepare(`UPDATE jobs SET is_cancel = 1 WHERE job_id = ?`).run(job_id);
397
- if (schedule.scheduledJobs[job_id]) schedule.scheduledJobs[job_id].cancel();
398
- return true;
399
- }
400
-
401
- /**
402
- * 重启已暂停的任务
403
- */
404
- restartJob(job_id) {
405
- try {
406
- // 1. 更新数据库状态
407
- this.db.prepare(`UPDATE jobs SET is_cancel = 0 WHERE job_id = ?`).run(job_id);
408
-
409
- // 2. 获取最新数据并重新注册定时器
410
- const job = this.db.prepare(`SELECT * FROM jobs WHERE job_id = ?`).get(job_id);
411
- if (job) {
412
- this.upsertTimer(job.job_id, job.name, job.frequency, job);
413
- console.log(`[CronScheduler] Job ${job_id} restarted.`);
414
- return true;
415
- }
416
- return false;
417
- } catch (e) {
418
- console.error(`[Restart Job Error] ${job_id}:`, e.message);
419
- return false;
420
- }
421
- }
422
-
423
- deleteJob(job_id) {
424
- this.db.prepare(`DELETE FROM jobs WHERE job_id = ?`).run(job_id);
425
- if (schedule.scheduledJobs[job_id]) schedule.scheduledJobs[job_id].cancel();
426
- return true;
427
- }
428
-
429
- getAllJobs(project_id) {
430
- const jobs = this.db.prepare(`SELECT * FROM jobs WHERE project_id = ?`).all(project_id);
431
- return jobs.map(job => ({
432
- ..._.omit(job, ['cases']),
433
- status: job.is_cancel === 0 ? 0 : 1,
434
- next_run_time: this.getNextExecutionTime(this.convertToCron(job.frequency)),
435
- runners: JSON5.parse(job.cases || '[]').map(v => v?.option?.testing_id)
436
- }));
437
- }
438
-
439
- convertToCron(freq) {
440
- const data = typeof freq === 'string' ? JSON5.parse(freq) : freq;
441
- if (data.type === 'cron') {
442
- const exp = data.cron?.expression || '0 0 * * *';
443
- const parts = exp.trim().split(/\s+/);
444
- return parts.length === 5 ? `0 ${exp}` : exp;
445
- }
446
- if (data.type === 'preset') {
447
- const { cycle, config } = data.preset;
448
- const [h, m] = (config.time || "0:0").split(':');
449
- if (cycle === 'minute') return `0 */${config.interval} * * * *`;
450
- if (cycle === 'hour') return `0 0 */${config.interval} * * *`;
451
- if (cycle === 'day') return `0 ${parseInt(m)} ${parseInt(h)} * * *`;
452
- if (cycle === 'week') return `0 ${parseInt(m)} ${parseInt(h)} * * ${Array.isArray(config.weekdays) ? config.weekdays.join(',') : '*'}`;
453
- }
454
- return '0 0 0 * * *';
455
- }
456
-
457
- getNextExecutionTime(cron) {
458
- try {
459
- const { CronExpressionParser } = require('cron-parser');
460
- return CronExpressionParser.parse(cron, { tz: 'Asia/Shanghai' }).next().getTime();
461
- } catch (e) { return '-'; }
462
- }
463
- }
464
-
465
- module.exports = CronScheduler;