@layoutkit/bree-core 1.0.9 → 1.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 +358 -26
- package/dist/index.js +737 -370
- package/dist/index.js.map +1 -1
- package/package.json +33 -33
package/dist/index.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import knex from 'knex';
|
|
2
|
-
import dotenv from 'dotenv';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
1
|
import ExcelJS from 'exceljs';
|
|
5
2
|
import path from 'node:path';
|
|
6
3
|
import fs from 'node:fs/promises';
|
|
@@ -8,110 +5,15 @@ import nodemailer from 'nodemailer';
|
|
|
8
5
|
import { createClient } from 'redis';
|
|
9
6
|
import Bree from 'bree';
|
|
10
7
|
import fs$1 from 'node:fs';
|
|
11
|
-
import { fileURLToPath } from 'node:url';
|
|
12
8
|
import winston from 'winston';
|
|
13
9
|
import 'winston-daily-rotate-file';
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const encrypt$1 = (data, key) => {
|
|
23
|
-
// key 必须 16 / 24 / 32 字节
|
|
24
|
-
const keyBuf = Buffer.from(key, 'utf8');
|
|
25
|
-
const cipher = crypto.createCipheriv(ALGORITHM_MAP$1[keyBuf.length], keyBuf, null);
|
|
26
|
-
cipher.setAutoPadding(true); // PKCS7
|
|
27
|
-
const encrypted = Buffer.concat([
|
|
28
|
-
cipher.update(data),
|
|
29
|
-
cipher.final()
|
|
30
|
-
]);
|
|
31
|
-
return encrypted
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const decrypt$1 = (data, key) => {
|
|
35
|
-
const keyBuf = Buffer.from(key, 'utf8');
|
|
36
|
-
const decipher = crypto.createDecipheriv(ALGORITHM_MAP$1[keyBuf.length], keyBuf, null);
|
|
37
|
-
decipher.setAutoPadding(true);
|
|
38
|
-
const decrypted = Buffer.concat([
|
|
39
|
-
decipher.update(data),
|
|
40
|
-
decipher.final()
|
|
41
|
-
]);
|
|
42
|
-
return decrypted
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* AES解密
|
|
47
|
-
* 对应 Java:
|
|
48
|
-
* AesUtil.decrypt(String data, String key)
|
|
49
|
-
*
|
|
50
|
-
* @param {string} data Base64密文
|
|
51
|
-
* @param {string} key AES密钥(16/24/32字节)
|
|
52
|
-
* @returns {string} 明文
|
|
53
|
-
*/
|
|
54
|
-
const decryptString$1 = (data, key) => {
|
|
55
|
-
const keyBuf = Buffer.from(key, 'utf8');
|
|
56
|
-
const decipher = crypto.createDecipheriv(ALGORITHM_MAP$1[keyBuf.length], keyBuf, null);
|
|
57
|
-
decipher.setAutoPadding(true);
|
|
58
|
-
const decrypted = Buffer.concat([
|
|
59
|
-
decipher.update(Buffer.from(data, 'base64')),
|
|
60
|
-
decipher.final()
|
|
61
|
-
]);
|
|
62
|
-
return decrypted.toString('utf8')
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
var aes$1 = { encrypt: encrypt$1, decrypt: decrypt$1, decryptString: decryptString$1 };
|
|
66
|
-
|
|
67
|
-
dotenv.config();
|
|
68
|
-
|
|
69
|
-
let db$1 = null;
|
|
70
|
-
const init$2 = async () => {
|
|
71
|
-
const connectionString = process.env.DB_CONNECTION_STRING;
|
|
72
|
-
const aesKey = process.env.DB_AES_KEY;
|
|
73
|
-
try {
|
|
74
|
-
const decryptedConnectionString = aes$1.decryptString(connectionString, aesKey);
|
|
75
|
-
const connectionParams = decryptedConnectionString.split(';').reduce((acc, param) => {
|
|
76
|
-
const [key, value] = param.split('=');
|
|
77
|
-
acc[key] = value;
|
|
78
|
-
return acc
|
|
79
|
-
}, {});
|
|
80
|
-
db$1 = knex({
|
|
81
|
-
client: 'mssql',
|
|
82
|
-
connection: {
|
|
83
|
-
user: connectionParams.user,
|
|
84
|
-
password: connectionParams.password,
|
|
85
|
-
server: connectionParams.server,
|
|
86
|
-
database: connectionParams.database,
|
|
87
|
-
options: {
|
|
88
|
-
encrypt: false
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// 等待数据库真实连接
|
|
94
|
-
await db$1.raw('SELECT 1');
|
|
95
|
-
console.log('database initialized');
|
|
96
|
-
return db$1
|
|
97
|
-
} catch (error) {
|
|
98
|
-
await close();
|
|
99
|
-
|
|
100
|
-
throw error
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
const close = async () => {
|
|
105
|
-
if (db$1) {
|
|
106
|
-
await db$1.destroy();
|
|
107
|
-
db$1 = null;
|
|
108
|
-
}
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
var database = {
|
|
112
|
-
init: init$2,
|
|
113
|
-
close
|
|
114
|
-
};
|
|
10
|
+
import knex from 'knex';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import express from 'express';
|
|
13
|
+
import { readdir } from 'fs/promises';
|
|
14
|
+
import path$1 from 'path';
|
|
15
|
+
import { pathToFileURL } from 'url';
|
|
16
|
+
import { parentPort } from 'node:worker_threads';
|
|
115
17
|
|
|
116
18
|
let baseFolder = null;
|
|
117
19
|
|
|
@@ -237,7 +139,7 @@ var excelUtil = {
|
|
|
237
139
|
|
|
238
140
|
let transporter = null;
|
|
239
141
|
let from = '';
|
|
240
|
-
const init
|
|
142
|
+
const init = () => {
|
|
241
143
|
from = `"${process.env.MAIL_USERNAME}" <${process.env.MAIL_ACCOUNT}>`;
|
|
242
144
|
transporter = nodemailer.createTransport({
|
|
243
145
|
host: process.env.MAIL_HOST, // 或公司邮箱 SMTP
|
|
@@ -268,7 +170,7 @@ const send = async (subject, toUser = '', copyUser = '', content = '', filePath
|
|
|
268
170
|
};
|
|
269
171
|
|
|
270
172
|
var mailUtil = {
|
|
271
|
-
init
|
|
173
|
+
init,
|
|
272
174
|
send
|
|
273
175
|
};
|
|
274
176
|
|
|
@@ -438,6 +340,15 @@ class RedisHelper {
|
|
|
438
340
|
)
|
|
439
341
|
}
|
|
440
342
|
|
|
343
|
+
async rPush(key, value) {
|
|
344
|
+
const redis = await getRedis();
|
|
345
|
+
|
|
346
|
+
return await redis.rPush(
|
|
347
|
+
key,
|
|
348
|
+
JSON.stringify(value)
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
441
352
|
async rPop(key) {
|
|
442
353
|
const redis = await getRedis();
|
|
443
354
|
|
|
@@ -448,6 +359,16 @@ class RedisHelper {
|
|
|
448
359
|
: null
|
|
449
360
|
}
|
|
450
361
|
|
|
362
|
+
async lPop(key) {
|
|
363
|
+
const redis = await getRedis();
|
|
364
|
+
|
|
365
|
+
const value = await redis.lPop(key);
|
|
366
|
+
|
|
367
|
+
return value
|
|
368
|
+
? JSON.parse(value)
|
|
369
|
+
: null
|
|
370
|
+
}
|
|
371
|
+
|
|
451
372
|
async close() {
|
|
452
373
|
await closeRedis();
|
|
453
374
|
}
|
|
@@ -455,53 +376,295 @@ class RedisHelper {
|
|
|
455
376
|
|
|
456
377
|
var redis = new RedisHelper();
|
|
457
378
|
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
379
|
+
const { combine, timestamp, printf } = winston.format;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* 默认日志格式:可被 format 参数覆盖
|
|
383
|
+
*/
|
|
384
|
+
const defaultLogFormat = printf(({ timestamp, level, message }) => {
|
|
463
385
|
return `[${timestamp}] [${level.toUpperCase()}] ${message}`
|
|
464
386
|
});
|
|
465
387
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
new winston.transports.DailyRotateFile({
|
|
475
|
-
dirname: 'logs',
|
|
476
|
-
filename: 'worker-%DATE%.log',
|
|
477
|
-
datePattern: 'YYYY-MM-DD',
|
|
478
|
-
maxSize: '20m',
|
|
479
|
-
maxFiles: '30d'
|
|
480
|
-
})
|
|
481
|
-
]
|
|
482
|
-
});
|
|
388
|
+
/**
|
|
389
|
+
* 单级别文件过滤器(用于 split 按级别拆文件)
|
|
390
|
+
*/
|
|
391
|
+
const levelFilter = (level) => {
|
|
392
|
+
return winston.format((info) => {
|
|
393
|
+
return info.level === level ? info : false
|
|
394
|
+
})()
|
|
395
|
+
};
|
|
483
396
|
|
|
484
397
|
/**
|
|
485
|
-
*
|
|
398
|
+
* split=true 时默认按全部级别拆分
|
|
486
399
|
*/
|
|
487
|
-
const
|
|
488
|
-
|
|
400
|
+
const defaultSplitLevels = ['debug', 'info', 'warn', 'error'];
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 统一 logger 工厂
|
|
404
|
+
* @param {object} options
|
|
405
|
+
* @param {string} [options.name='app'] 日志文件名前缀,如 'worker' -> worker-%DATE%.log;不传 dirname 时同时作为子目录名
|
|
406
|
+
* @param {string} [options.dirname] 日志子目录(相对 logs 根);不传则用 name 作为子目录名,
|
|
407
|
+
* 显式传 '' 则直接写在 logs 根下。如 'weixinapi' -> logs/weixinapi/
|
|
408
|
+
* @param {string} [options.level='info'] 日志级别:error | warn | info | debug
|
|
409
|
+
* @param {function} [options.format] 自定义日志格式(printf 函数),默认 [时间] [级别] 消息
|
|
410
|
+
* @param {boolean} [options.console=true] 是否输出到控制台
|
|
411
|
+
* @param {boolean} [options.file=true] 是否输出到文件
|
|
412
|
+
* @param {string} [options.datePattern='YYYY-MM-DD'] 日志文件名日期格式
|
|
413
|
+
* @param {string} [options.maxSize='20m'] 单个日志文件大小上限,超过即滚动,如 '20m' | '100k'
|
|
414
|
+
* @param {string|number} [options.maxFiles='30d'] 日志保留时长/份数,如 '30d' | 14
|
|
415
|
+
* @param {boolean|string[]} [options.split=false]
|
|
416
|
+
* false:不拆分,全部写到 <name>-%DATE%.log
|
|
417
|
+
* true:按 debug/info/warn/error 各写一个文件
|
|
418
|
+
* 数组:按传入级别拆分,如 ['info', 'warn', 'error']
|
|
419
|
+
* @returns {import('winston').Logger}
|
|
420
|
+
*/
|
|
421
|
+
function createLogger(options = {}) {
|
|
422
|
+
const {
|
|
423
|
+
name = 'app',
|
|
424
|
+
dirname,
|
|
425
|
+
level = 'info',
|
|
426
|
+
format = defaultLogFormat,
|
|
427
|
+
console = true,
|
|
428
|
+
file = true,
|
|
429
|
+
datePattern = 'YYYY-MM-DD',
|
|
430
|
+
maxSize = '10m',
|
|
431
|
+
maxFiles = '10d',
|
|
432
|
+
split = false
|
|
433
|
+
} = options;
|
|
434
|
+
|
|
435
|
+
// 目录解析:设置 LOG_DIR 环境变量即自动以其为日志根目录,否则以 logs 为根目录
|
|
436
|
+
// dirname 为其下子目录;不传 dirname 时自动用 name 作为子目录;显式传 '' 则直接写根
|
|
437
|
+
const subDir = dirname ?? name;
|
|
438
|
+
const root = process.env.LOG_DIR ? path.join(process.env.LOG_DIR, 'logs') : 'logs';
|
|
439
|
+
const logDir = path.join(root, subDir);
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 内部:创建文件滚动 transport
|
|
443
|
+
* @param {string} filename
|
|
444
|
+
* @param {string} [fileLevel] 指定级别时配合 levelFilter 精确过滤,仅写该级别日志
|
|
445
|
+
*/
|
|
446
|
+
const createFileTransport = (filename, fileLevel) => {
|
|
447
|
+
const formatParts = [
|
|
448
|
+
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
449
|
+
format
|
|
450
|
+
];
|
|
451
|
+
if (fileLevel) formatParts.unshift(levelFilter(fileLevel));
|
|
452
|
+
|
|
453
|
+
return new winston.transports.DailyRotateFile({
|
|
454
|
+
dirname: logDir,
|
|
455
|
+
filename,
|
|
456
|
+
...(fileLevel ? { level: fileLevel } : {}),
|
|
457
|
+
datePattern,
|
|
458
|
+
maxSize,
|
|
459
|
+
maxFiles,
|
|
460
|
+
format: combine(...formatParts)
|
|
461
|
+
})
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const transports = [];
|
|
465
|
+
|
|
466
|
+
if (console) {
|
|
467
|
+
transports.push(new winston.transports.Console());
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (file) {
|
|
471
|
+
const splitLevels = Array.isArray(split)
|
|
472
|
+
? split
|
|
473
|
+
: (split ? defaultSplitLevels : null);
|
|
474
|
+
|
|
475
|
+
if (splitLevels) {
|
|
476
|
+
for (const splitLevel of splitLevels) {
|
|
477
|
+
transports.push(createFileTransport(`${name}-${splitLevel}-%DATE%.log`, splitLevel));
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
transports.push(createFileTransport(`${name}-%DATE%.log`));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return winston.createLogger({
|
|
485
|
+
level,
|
|
486
|
+
format: combine(
|
|
487
|
+
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
488
|
+
format
|
|
489
|
+
),
|
|
490
|
+
transports
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const ALGORITHM_MAP = {
|
|
495
|
+
16: 'aes-128-ecb',
|
|
496
|
+
24: 'aes-192-ecb',
|
|
497
|
+
32: 'aes-256-ecb'
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const encrypt = (data, key) => {
|
|
501
|
+
// key 必须 16 / 24 / 32 字节
|
|
502
|
+
const keyBuf = Buffer.from(key, 'utf8');
|
|
503
|
+
const cipher = crypto.createCipheriv(ALGORITHM_MAP[keyBuf.length], keyBuf, null);
|
|
504
|
+
cipher.setAutoPadding(true); // PKCS7
|
|
505
|
+
const encrypted = Buffer.concat([
|
|
506
|
+
cipher.update(data),
|
|
507
|
+
cipher.final()
|
|
508
|
+
]);
|
|
509
|
+
return encrypted
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const decrypt = (data, key) => {
|
|
513
|
+
const keyBuf = Buffer.from(key, 'utf8');
|
|
514
|
+
const decipher = crypto.createDecipheriv(ALGORITHM_MAP[keyBuf.length], keyBuf, null);
|
|
515
|
+
decipher.setAutoPadding(true);
|
|
516
|
+
const decrypted = Buffer.concat([
|
|
517
|
+
decipher.update(data),
|
|
518
|
+
decipher.final()
|
|
519
|
+
]);
|
|
520
|
+
return decrypted
|
|
489
521
|
};
|
|
490
522
|
|
|
491
523
|
/**
|
|
492
|
-
*
|
|
524
|
+
* AES解密
|
|
525
|
+
* 对应 Java:
|
|
526
|
+
* AesUtil.decrypt(String data, String key)
|
|
527
|
+
*
|
|
528
|
+
* @param {string} data Base64密文
|
|
529
|
+
* @param {string} key AES密钥(16/24/32字节)
|
|
530
|
+
* @returns {string} 明文
|
|
493
531
|
*/
|
|
494
|
-
const
|
|
495
|
-
|
|
532
|
+
const decryptString = (data, key) => {
|
|
533
|
+
const keyBuf = Buffer.from(key, 'utf8');
|
|
534
|
+
const decipher = crypto.createDecipheriv(ALGORITHM_MAP[keyBuf.length], keyBuf, null);
|
|
535
|
+
decipher.setAutoPadding(true);
|
|
536
|
+
const decrypted = Buffer.concat([
|
|
537
|
+
decipher.update(Buffer.from(data, 'base64')),
|
|
538
|
+
decipher.final()
|
|
539
|
+
]);
|
|
540
|
+
return decrypted.toString('utf8')
|
|
496
541
|
};
|
|
497
|
-
|
|
542
|
+
|
|
543
|
+
var aes = { encrypt, decrypt, decryptString };
|
|
498
544
|
|
|
499
|
-
|
|
545
|
+
let db$1 = null;
|
|
546
|
+
|
|
547
|
+
/** 解析 `key=value;key=value` 形式的连接串(值允许包含 `=`) */
|
|
548
|
+
const parseConnectionString = (str) =>
|
|
549
|
+
str.split(';').reduce((acc, param) => {
|
|
550
|
+
const idx = param.indexOf('=');
|
|
551
|
+
if (idx === -1) return acc
|
|
552
|
+
const key = param.slice(0, idx).trim();
|
|
553
|
+
const value = param.slice(idx + 1).trim();
|
|
554
|
+
if (key) acc[key] = value;
|
|
555
|
+
return acc
|
|
556
|
+
}, {});
|
|
557
|
+
|
|
558
|
+
/** 配置了 DB_AES_KEY 时尝试解密;非密文(解密失败)则原样使用 */
|
|
559
|
+
const resolveConnectionString = (connectionString) => {
|
|
560
|
+
const aesKey = process.env.DB_AES_KEY;
|
|
561
|
+
if (aesKey) {
|
|
562
|
+
try {
|
|
563
|
+
return aes.decryptString(connectionString, aesKey)
|
|
564
|
+
} catch {
|
|
565
|
+
// 明文连接串,直接使用
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return connectionString
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
class DatabaseHelper {
|
|
572
|
+
/**
|
|
573
|
+
* 初始化数据库连接(自动读取 DB_CONNECTION_STRING,可传参覆盖)
|
|
574
|
+
* @param {string} [connectionString=process.env.DB_CONNECTION_STRING] 连接串(明文或 AES 密文)
|
|
575
|
+
* @returns {Promise<object>} knex 实例
|
|
576
|
+
* @example
|
|
577
|
+
* const db = await database.init()
|
|
578
|
+
* const rows = await db('users').select('*')
|
|
579
|
+
*/
|
|
580
|
+
async init(connectionString = process.env.DB_CONNECTION_STRING) {
|
|
581
|
+
if (!connectionString) {
|
|
582
|
+
throw new Error(
|
|
583
|
+
'DB connection string required: pass it to database.init(str) or set DB_CONNECTION_STRING'
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
await this.close(); // 重复初始化前先释放旧连接
|
|
587
|
+
|
|
588
|
+
const params = parseConnectionString(resolveConnectionString(connectionString));
|
|
589
|
+
db$1 = knex({
|
|
590
|
+
client: 'mssql',
|
|
591
|
+
connection: {
|
|
592
|
+
user: params.user,
|
|
593
|
+
password: params.password,
|
|
594
|
+
server: params.server,
|
|
595
|
+
database: params.database,
|
|
596
|
+
options: {
|
|
597
|
+
encrypt: false
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
await db$1.raw('SELECT 1'); // 验证真实连接
|
|
604
|
+
} catch (err) {
|
|
605
|
+
await this.close();
|
|
606
|
+
throw err
|
|
607
|
+
}
|
|
608
|
+
return db$1
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* 获取 knex 实例(未初始化时给出明确提示)
|
|
613
|
+
* @returns {object} knex 实例
|
|
614
|
+
*/
|
|
615
|
+
get() {
|
|
616
|
+
if (!db$1) throw new Error('Database not initialized, call await database.init() first')
|
|
617
|
+
return db$1
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** 关闭数据库连接 */
|
|
621
|
+
async close() {
|
|
622
|
+
if (db$1) {
|
|
623
|
+
await db$1.destroy();
|
|
624
|
+
db$1 = null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
var database = new DatabaseHelper();
|
|
630
|
+
|
|
631
|
+
let registerError = null;
|
|
632
|
+
let registerComplete = null;
|
|
633
|
+
let registerLoader = null;
|
|
634
|
+
|
|
635
|
+
/** 每个钩子未注册时只提示一次,避免刷屏 */
|
|
636
|
+
const warnOnce = (() => {
|
|
637
|
+
const warned = new Set();
|
|
638
|
+
return (name) => {
|
|
639
|
+
if (warned.has(name)) return
|
|
640
|
+
warned.add(name);
|
|
641
|
+
console.warn(
|
|
642
|
+
`[bree-core] hooks.${name} 未注册,任务状态不会回写数据库。` +
|
|
643
|
+
`可直接赋值 hooks.${name} = async (db, obj) => { /* 回写逻辑 */ } 注册。`
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
})();
|
|
647
|
+
|
|
648
|
+
// 默认空实现:不注册不崩溃,只提示一次
|
|
649
|
+
registerError = async () => warnOnce('registerError');
|
|
650
|
+
registerComplete = async () => warnOnce('registerComplete');
|
|
651
|
+
registerLoader = async () => {
|
|
652
|
+
warnOnce('registerLoader');
|
|
653
|
+
return []
|
|
654
|
+
};
|
|
500
655
|
|
|
656
|
+
var hooks = {
|
|
657
|
+
registerLoader,
|
|
658
|
+
registerError,
|
|
659
|
+
registerComplete
|
|
660
|
+
};
|
|
661
|
+
|
|
501
662
|
let bree = null;
|
|
502
663
|
let db = null;
|
|
503
664
|
let jobsRoot = null;
|
|
504
665
|
|
|
666
|
+
const logger = createLogger({ name: 'scheduler', split: true });
|
|
667
|
+
|
|
505
668
|
// 让使用者通过环境变量或配置指定 jobs 目录
|
|
506
669
|
const getJobsRoot = () => {
|
|
507
670
|
if (jobsRoot) return jobsRoot
|
|
@@ -544,47 +707,51 @@ const createBree = () => {
|
|
|
544
707
|
const instance = new Bree({
|
|
545
708
|
hasSeconds: true,
|
|
546
709
|
root: jobsPath,
|
|
710
|
+
doRootCheck: false, // 任务列表由 hooks.registerLoader / 数据库提供,无需 root/index.js
|
|
547
711
|
errorHandler: async (error, workerMetadata) => {
|
|
548
|
-
if (workerMetadata?.threadId) {
|
|
549
|
-
blogger.worker(workerMetadata.name,
|
|
550
|
-
`There was an error while running a worker ${workerMetadata.name} with thread ID: ${workerMetadata.threadId}`);
|
|
551
|
-
} else {
|
|
552
|
-
blogger.worker(workerMetadata.name,
|
|
553
|
-
`There was an error while running a worker ${workerMetadata.name}`);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
blogger.error(workerMetadata?.name, error);
|
|
557
712
|
if (error?.message?.indexOf('exited with code') > -1) return
|
|
558
|
-
const
|
|
559
|
-
|
|
713
|
+
const name = workerMetadata?.name;
|
|
714
|
+
logger.info(`[${name}] There was an error while running a worker ${name} with thread ID: ${workerMetadata?.threadId}`);
|
|
715
|
+
logger.error(`[${name}] ${error?.stack || error?.message || error}`);
|
|
716
|
+
const worker = instance.workers.get(name);
|
|
717
|
+
const workerData = worker?.workerData;
|
|
560
718
|
if (workerData) {
|
|
561
|
-
await db
|
|
562
|
-
.where('ID', workerData.id)
|
|
563
|
-
.update({
|
|
564
|
-
'ResultStatus': 1,
|
|
565
|
-
'ErrorMessage': error?.stack
|
|
566
|
-
});
|
|
719
|
+
await hooks.registerError(db, { id: workerData.id, error: error?.stack });
|
|
567
720
|
}
|
|
568
721
|
}
|
|
569
722
|
});
|
|
570
723
|
|
|
571
724
|
instance.on('worker created', (name) => {
|
|
572
|
-
console.log(
|
|
573
|
-
|
|
725
|
+
console.log(`[${name}] worker created`);
|
|
726
|
+
logger.info(`[${name}] worker created`);
|
|
574
727
|
});
|
|
575
728
|
|
|
576
|
-
instance.on('worker deleted', (name) => {
|
|
577
|
-
console.log(
|
|
578
|
-
|
|
729
|
+
instance.on('worker deleted', async (name) => {
|
|
730
|
+
console.log(`[${name}] worker deleted`);
|
|
731
|
+
logger.info(`[${name}] worker deleted`);
|
|
579
732
|
});
|
|
580
733
|
|
|
581
|
-
|
|
582
|
-
};
|
|
734
|
+
instance.on('worker message', async (name, message) => {
|
|
735
|
+
console.log(`[${name}] worker message:`, message);
|
|
736
|
+
|
|
737
|
+
if (message.type === 'done') {
|
|
738
|
+
try {
|
|
739
|
+
const worker = instance.workers.get(name);
|
|
740
|
+
const workerData = worker?.workerData;
|
|
741
|
+
await hooks.registerComplete(db, {
|
|
742
|
+
id: workerData?.id,
|
|
743
|
+
result: message.data
|
|
744
|
+
});
|
|
745
|
+
} catch (error) {
|
|
746
|
+
logger.error(`[${name}] registerComplete failed`, error);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
});
|
|
583
750
|
|
|
584
|
-
|
|
585
|
-
return bree
|
|
751
|
+
return instance
|
|
586
752
|
};
|
|
587
753
|
|
|
754
|
+
const getBree = () => bree;
|
|
588
755
|
const reset = async (job, startStatus) => {
|
|
589
756
|
await bree.stop(job.name);
|
|
590
757
|
const index = bree.config.jobs.findIndex(x => x.name === job.name);
|
|
@@ -595,10 +762,8 @@ const reset = async (job, startStatus) => {
|
|
|
595
762
|
|
|
596
763
|
const remove = async (name) => {
|
|
597
764
|
await bree.stop(name);
|
|
598
|
-
|
|
599
765
|
// 删除运行中的 worker 貌似不需要
|
|
600
766
|
// delete bree.workers.get(name)
|
|
601
|
-
|
|
602
767
|
const index = bree.config.jobs.findIndex(x => x.name === name);
|
|
603
768
|
if (index !== -1) bree.config.jobs.splice(index, 1);
|
|
604
769
|
};
|
|
@@ -611,66 +776,20 @@ const create = async (job) => {
|
|
|
611
776
|
};
|
|
612
777
|
|
|
613
778
|
// 启动任务
|
|
614
|
-
const start = async (name) =>
|
|
615
|
-
await bree.start(name);
|
|
616
|
-
};
|
|
779
|
+
const start = async (name) => await bree.start(name);
|
|
617
780
|
// 停止任务
|
|
618
|
-
const stop = async (name) =>
|
|
619
|
-
await bree.stop(name);
|
|
620
|
-
};
|
|
781
|
+
const stop = async (name) => await bree.stop(name);
|
|
621
782
|
// 执行一次任务
|
|
622
|
-
const run = async (name) =>
|
|
623
|
-
await bree.run(name);
|
|
624
|
-
};
|
|
625
|
-
|
|
783
|
+
const run = async (name) => await bree.run(name);
|
|
626
784
|
// 任务初始化
|
|
627
|
-
const
|
|
628
|
-
// 1. 从数据库读取任务列表
|
|
785
|
+
const register$1 = async () => {
|
|
629
786
|
db = await database.init();
|
|
630
|
-
// 2. 创建 Bree 实例(在数据库初始化之后)
|
|
631
787
|
bree = createBree();
|
|
632
|
-
|
|
633
|
-
const jobs = await db('Sys_BreeJobs'); //.where('Status', 1)
|
|
634
|
-
// 4. 批量注册到调度器
|
|
635
|
-
for (const job of jobs) {
|
|
636
|
-
const id = job['ID'];
|
|
637
|
-
const name = job['Name'];
|
|
638
|
-
const cron = job['Cron'];
|
|
639
|
-
const path = job['Path'];
|
|
640
|
-
const status = job['Status']; // status 状态为0:已停止 1:执行中 2:
|
|
641
|
-
const createStatus = job['CreateStatus'];
|
|
642
|
-
|
|
643
|
-
try {
|
|
644
|
-
await create({
|
|
645
|
-
name,
|
|
646
|
-
cron,
|
|
647
|
-
path: path || undefined,
|
|
648
|
-
worker: { workerData: { id } }
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
if (status === 1) await bree.start(name);
|
|
652
|
-
if (createStatus === 0) {
|
|
653
|
-
await db('Sys_BreeJobs')
|
|
654
|
-
.where('ID', id)
|
|
655
|
-
.update({
|
|
656
|
-
'CreateStatus': 1
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
catch (error) {
|
|
661
|
-
blogger.error(name, error);
|
|
662
|
-
await db('Sys_BreeJobs')
|
|
663
|
-
.where('ID', id)
|
|
664
|
-
.update({
|
|
665
|
-
'ResultStatus': 1,
|
|
666
|
-
'ErrorMessage': error?.stack
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
}
|
|
788
|
+
await hooks.registerLoader(db, logger);
|
|
670
789
|
};
|
|
671
790
|
|
|
672
791
|
var scheduler = {
|
|
673
|
-
|
|
792
|
+
register: register$1,
|
|
674
793
|
create,
|
|
675
794
|
reset,
|
|
676
795
|
start,
|
|
@@ -680,149 +799,168 @@ var scheduler = {
|
|
|
680
799
|
remove
|
|
681
800
|
};
|
|
682
801
|
|
|
683
|
-
const { combine: combine$1, timestamp: timestamp$1, printf: printf$1 } = winston.format;
|
|
684
|
-
const logFormat$1 = printf$1(({ timestamp, level, message }) => {
|
|
685
|
-
return `[${timestamp}] [${level.toUpperCase()}] ${message}`
|
|
686
|
-
});
|
|
687
|
-
|
|
688
802
|
/**
|
|
689
|
-
*
|
|
803
|
+
* 单函数接口风格路由(近似 `@Route('/path')` 装饰器,纯 JS 用函数包装实现)
|
|
804
|
+
*
|
|
805
|
+
* 用法(业务模块中定义即自动注册,无需手动传给 useServer):
|
|
806
|
+
* route.post('/test', (body) => {
|
|
807
|
+
* const { name, age } = body
|
|
808
|
+
* return { code: 0, message: 'success', data: null } // 完整响应体原样返回
|
|
809
|
+
* })
|
|
810
|
+
* route.get('/user/:id', async ({ id }) => ({ id }))
|
|
811
|
+
*
|
|
812
|
+
* 分组前缀(减少重复路径):
|
|
813
|
+
* const api = route.parentRoute('/task')
|
|
814
|
+
* api.post('/upload', upload.single('file'), async (body, req) => {
|
|
815
|
+
* // 注册为 POST /task/upload
|
|
816
|
+
* if (!req.file) return { code: -1, message: '文件不能为空' }
|
|
817
|
+
* return { code: 0, message: '上传成功', data: { filename: req.file.filename } }
|
|
818
|
+
* })
|
|
819
|
+
* api.get('/list', async () => []) // GET /task/list
|
|
820
|
+
* api.parentRoute('/:id').get('/info', async ({ id }) => ({})) // 支持嵌套,GET /task/:id/info
|
|
821
|
+
*
|
|
822
|
+
* 支持中间件(Express 风格,如 multer 文件上传):
|
|
823
|
+
* route.post('/upload', upload.single('file'), async (body, req, res) => {
|
|
824
|
+
* // req.file 由 multer 解析后注入(body 不含文件,文件在 req.file)
|
|
825
|
+
* return { code: 0, message: '上传成功', data: { filename: req.file.filename } }
|
|
826
|
+
* })
|
|
827
|
+
* route.post('/upload', [auth, upload.array('files', 5)], async (body, req, res) => { ... })
|
|
828
|
+
*
|
|
829
|
+
* handler 签名:async (body, req, res) => result
|
|
830
|
+
* - body:自动合并 { params, query, body },解构即用
|
|
831
|
+
* - req / res:原始对象(需要时可用 req.file、req.ip、res.setHeader 等)
|
|
832
|
+
* - 返回 { code, ... } 对象时原样 res.json 输出;返回普通数据自动包装为 res.success
|
|
833
|
+
* - 抛错自动进入全局错误处理
|
|
834
|
+
*
|
|
835
|
+
* 方法类型:route.get / route.post / route.put / route.delete / route.patch
|
|
836
|
+
* 也可 route(path, handler, method),默认 POST
|
|
690
837
|
*/
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
838
|
+
|
|
839
|
+
/** 自动注册路由注册表(模块加载时定义即注册,useServer 启动时自动收集) */
|
|
840
|
+
const registeredRoutes = [];
|
|
841
|
+
|
|
842
|
+
const register = (route) => {
|
|
843
|
+
const idx = registeredRoutes.findIndex((r) => r.method === route.method && r.path === route.path);
|
|
844
|
+
if (idx >= 0) registeredRoutes[idx] = route;
|
|
845
|
+
else registeredRoutes.push(route);
|
|
846
|
+
return route
|
|
695
847
|
};
|
|
696
848
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
format: combine$1(
|
|
700
|
-
timestamp$1({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
701
|
-
logFormat$1
|
|
702
|
-
),
|
|
703
|
-
transports: [
|
|
704
|
-
new winston.transports.Console(),
|
|
705
|
-
new winston.transports.DailyRotateFile({
|
|
706
|
-
dirname: 'logs',
|
|
707
|
-
filename: 'info-%DATE%.log',
|
|
708
|
-
datePattern: 'YYYY-MM-DD',
|
|
709
|
-
maxSize: '20m',
|
|
710
|
-
maxFiles: '30d',
|
|
711
|
-
format: combine$1(
|
|
712
|
-
levelFilter('info'),
|
|
713
|
-
timestamp$1({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
714
|
-
logFormat$1
|
|
715
|
-
)
|
|
716
|
-
}),
|
|
717
|
-
new winston.transports.DailyRotateFile({
|
|
718
|
-
dirname: 'logs',
|
|
719
|
-
filename: 'error-%DATE%.log',
|
|
720
|
-
level: 'error',
|
|
721
|
-
datePattern: 'YYYY-MM-DD',
|
|
722
|
-
maxSize: '20m',
|
|
723
|
-
maxFiles: '30d',
|
|
724
|
-
format: combine$1(
|
|
725
|
-
levelFilter('error'),
|
|
726
|
-
timestamp$1({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
727
|
-
logFormat$1
|
|
728
|
-
)
|
|
729
|
-
})
|
|
730
|
-
]
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
function useHandler() {
|
|
734
|
-
let isCancelled = false;
|
|
735
|
-
let db = null;
|
|
849
|
+
/** 获取所有已注册路由(内部使用,供 useServer 启动时收集) */
|
|
850
|
+
const getRegisteredRoutes = () => [...registeredRoutes];
|
|
736
851
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const run = async (worker) => {
|
|
744
|
-
if (parentPort)
|
|
745
|
-
parentPort.once('message', (message) => {
|
|
746
|
-
console.log(message);
|
|
747
|
-
if (message === 'cancel') isCancelled = true;
|
|
748
|
-
});
|
|
852
|
+
/** 拼接路径:joinPath('/task', '/upload') => '/task/upload';joinPath('/task', '') => '/task' */
|
|
853
|
+
const joinPath = (prefix, path) => {
|
|
854
|
+
const p = String(prefix ?? '').replace(/\/+$/, '');
|
|
855
|
+
const h = String(path ?? '').replace(/^\/+/, '');
|
|
856
|
+
return h ? `${p}/${h}` : p
|
|
857
|
+
};
|
|
749
858
|
|
|
750
|
-
|
|
859
|
+
/**
|
|
860
|
+
* 解析参数:route(path, [middlewares...,] handler[, method])
|
|
861
|
+
* 兼容:route(path, handler, method)
|
|
862
|
+
*/
|
|
863
|
+
const resolveArgs = (...args) => {
|
|
864
|
+
let method = null;
|
|
865
|
+
// 末尾若为合法方法字符串,视为 method(兼容 route(path, handler, method))
|
|
866
|
+
if (typeof args[args.length - 1] === 'string' && ['get', 'post', 'put', 'delete', 'patch'].includes(args[args.length - 1].toLowerCase())) {
|
|
867
|
+
method = args.pop();
|
|
868
|
+
}
|
|
869
|
+
const handler = args.pop();
|
|
870
|
+
// 中间件:单个函数或数组(数组展开),handler 之前的参数
|
|
871
|
+
const middlewares = Array.isArray(args[0]) ? args[0] : args;
|
|
872
|
+
return { middlewares, handler, method }
|
|
873
|
+
};
|
|
751
874
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
875
|
+
const createRoute = (path, middlewares, handler, method) => {
|
|
876
|
+
const item = {
|
|
877
|
+
method,
|
|
878
|
+
path,
|
|
879
|
+
handler: async (req, res, next) => {
|
|
880
|
+
try {
|
|
881
|
+
// 先执行引用者中间件(Express 风格 (req, res, next),如 multer 的 upload.single('file'))
|
|
882
|
+
for (const mw of middlewares) {
|
|
883
|
+
await new Promise((resolve, reject) => {
|
|
884
|
+
mw(req, res, (err) => (err ? reject(err) : resolve()));
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
const body = { ...req.params, ...req.query, ...req.body };
|
|
888
|
+
const result = await handler(body, req, res);
|
|
889
|
+
if (!res.headersSent) {
|
|
890
|
+
if (result && typeof result === 'object' && 'code' in result) {
|
|
891
|
+
res.json(result);
|
|
892
|
+
} else {
|
|
893
|
+
res.success(result === undefined ? null : result);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
} catch (err) {
|
|
897
|
+
next(err);
|
|
765
898
|
}
|
|
766
899
|
}
|
|
767
900
|
};
|
|
768
|
-
|
|
769
|
-
return { run, getIsCancelled }
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
const ALGORITHM_MAP = {
|
|
773
|
-
16: 'aes-128-ecb',
|
|
774
|
-
24: 'aes-192-ecb',
|
|
775
|
-
32: 'aes-256-ecb'
|
|
901
|
+
return register(item)
|
|
776
902
|
};
|
|
777
903
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
904
|
+
/**
|
|
905
|
+
* 定义并自动注册一个路由
|
|
906
|
+
* @param {string} path 路由路径,如 '/test'
|
|
907
|
+
* @param {function|function[]} handlerOrMws 处理函数 async (body, req, res) => result;
|
|
908
|
+
* 也可先传中间件(单个或数组,如 multer 的 upload.single('file')),最后一个参数为处理函数
|
|
909
|
+
* @param {string} [method='post'] HTTP 方法:get | post | put | delete | patch
|
|
910
|
+
* @returns {{method:string,path:string,handler:function}} 路由配置
|
|
911
|
+
*/
|
|
912
|
+
const route = (path, ...args) => {
|
|
913
|
+
const { middlewares, handler, method } = resolveArgs(...args);
|
|
914
|
+
return createRoute(path, middlewares, handler, method || 'post')
|
|
788
915
|
};
|
|
789
916
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
917
|
+
// 方法类型便捷写法(IDE 自动补全友好)
|
|
918
|
+
route.get = (path, ...args) => {
|
|
919
|
+
const { middlewares, handler } = resolveArgs(...args);
|
|
920
|
+
return createRoute(path, middlewares, handler, 'get')
|
|
921
|
+
};
|
|
922
|
+
route.post = (path, ...args) => {
|
|
923
|
+
const { middlewares, handler } = resolveArgs(...args);
|
|
924
|
+
return createRoute(path, middlewares, handler, 'post')
|
|
925
|
+
};
|
|
926
|
+
route.put = (path, ...args) => {
|
|
927
|
+
const { middlewares, handler } = resolveArgs(...args);
|
|
928
|
+
return createRoute(path, middlewares, handler, 'put')
|
|
929
|
+
};
|
|
930
|
+
route.delete = (path, ...args) => {
|
|
931
|
+
const { middlewares, handler } = resolveArgs(...args);
|
|
932
|
+
return createRoute(path, middlewares, handler, 'delete')
|
|
933
|
+
};
|
|
934
|
+
route.patch = (path, ...args) => {
|
|
935
|
+
const { middlewares, handler } = resolveArgs(...args);
|
|
936
|
+
return createRoute(path, middlewares, handler, 'patch')
|
|
799
937
|
};
|
|
800
938
|
|
|
801
939
|
/**
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
* @
|
|
940
|
+
* 分组前缀路由:返回带 get/post/put/delete/patch 的子路由,路径自动拼接前缀
|
|
941
|
+
* 用法:
|
|
942
|
+
* const api = route.parentRoute('/task')
|
|
943
|
+
* api.post('/upload', upload.single('file'), handler) // POST /task/upload
|
|
944
|
+
* api.get('/list', handler) // GET /task/list
|
|
945
|
+
* api.parentRoute('/:id').get('/info', handler) // 支持嵌套,GET /task/:id/info
|
|
946
|
+
* @param {string} [prefix=''] 路径前缀,如 '/task'
|
|
947
|
+
* @returns {{get:function,post:function,put:function,delete:function,patch:function,parentRoute:function}}
|
|
809
948
|
*/
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
949
|
+
route.parentRoute = (prefix = '') => {
|
|
950
|
+
const api = {};
|
|
951
|
+
for (const method of ['get', 'post', 'put', 'delete', 'patch']) {
|
|
952
|
+
api[method] = (path, ...args) => {
|
|
953
|
+
const { middlewares, handler } = resolveArgs(...args);
|
|
954
|
+
return createRoute(joinPath(prefix, path), middlewares, handler, method)
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
// 嵌套分组:api.parentRoute('/:id').get(...)
|
|
958
|
+
api.parentRoute = (sub = '') => route.parentRoute(joinPath(prefix, sub));
|
|
959
|
+
return api
|
|
960
|
+
};
|
|
822
961
|
|
|
823
962
|
function auth(req, res, next) {
|
|
824
|
-
const apiKey = req.headers[
|
|
825
|
-
|
|
963
|
+
const apiKey = req.headers[process.env.API_SECRET_KEY_NAME];
|
|
826
964
|
if (!apiKey) {
|
|
827
965
|
return res.status(200).json({
|
|
828
966
|
code: -1,
|
|
@@ -856,43 +994,272 @@ function auth(req, res, next) {
|
|
|
856
994
|
next();
|
|
857
995
|
}
|
|
858
996
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
997
|
+
/**
|
|
998
|
+
* 递归扫描目录下所有 JS 文件并动态加载(触发 route() 自动注册)
|
|
999
|
+
* 跳过以 . / _ 开头的文件(如 .DS_Store、_private.js)
|
|
1000
|
+
*/
|
|
1001
|
+
const loadRouteFiles = async (dirs, logger) => {
|
|
1002
|
+
const list = Array.isArray(dirs) ? dirs : [dirs];
|
|
1003
|
+
let total = 0;
|
|
863
1004
|
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
});
|
|
1005
|
+
for (const dir of list) {
|
|
1006
|
+
const abs = path$1.resolve(dir);
|
|
1007
|
+
let entries;
|
|
1008
|
+
try {
|
|
1009
|
+
entries = await readdir(abs, { withFileTypes: true });
|
|
1010
|
+
} catch {
|
|
1011
|
+
// 目录不存在则跳过,但给出提示便于诊断路由未加载
|
|
1012
|
+
logger?.warn?.(`routes dir not found, skipped: ${abs}`);
|
|
1013
|
+
continue
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const walk = async (items, base) => {
|
|
1017
|
+
for (const item of items) {
|
|
1018
|
+
if (item.name.startsWith('.') || item.name.startsWith('_')) continue
|
|
1019
|
+
const full = path$1.join(base, item.name);
|
|
1020
|
+
if (item.isDirectory()) {
|
|
1021
|
+
await walk(await readdir(full, { withFileTypes: true }), full);
|
|
1022
|
+
} else if (/\.(mjs|cjs|js)$/i.test(item.name)) {
|
|
1023
|
+
try {
|
|
1024
|
+
await import(pathToFileURL(full).href);
|
|
1025
|
+
total += 1;
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
logger?.error?.(`load routes file failed: ${full} - ${err?.message || err}`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
await walk(entries, abs);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (total > 0) logger?.info?.(`auto-loaded ${total} routes file(s)`);
|
|
1036
|
+
};
|
|
881
1037
|
|
|
882
1038
|
/**
|
|
883
|
-
*
|
|
1039
|
+
* CORS 中间件(不引入额外依赖)
|
|
884
1040
|
*/
|
|
885
|
-
const
|
|
886
|
-
|
|
1041
|
+
const createCors = (options = {}) => {
|
|
1042
|
+
const opts = typeof options === 'string' ? { origin: options } : options;
|
|
1043
|
+
const origin = opts.origin ?? '*';
|
|
1044
|
+
const methods = opts.methods || 'GET,POST,PUT,DELETE,PATCH,OPTIONS';
|
|
1045
|
+
const headers = opts.headers || 'Content-Type,Authorization,x-api-key';
|
|
1046
|
+
|
|
1047
|
+
return (req, res, next) => {
|
|
1048
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1049
|
+
res.setHeader('Access-Control-Allow-Methods', methods);
|
|
1050
|
+
res.setHeader('Access-Control-Allow-Headers', headers);
|
|
1051
|
+
if (req.method === 'OPTIONS') return res.sendStatus(204)
|
|
1052
|
+
next();
|
|
1053
|
+
}
|
|
887
1054
|
};
|
|
888
1055
|
|
|
889
1056
|
/**
|
|
890
|
-
*
|
|
1057
|
+
* Express 服务封装
|
|
1058
|
+
* @param {object} options
|
|
1059
|
+
* @param {number} [options.port=3000] 监听端口
|
|
1060
|
+
* @param {string} [options.host='0.0.0.0'] 监听地址
|
|
1061
|
+
* @param {object} [options.logger] 自定义 logger(默认 createLogger({ name: 'server', split: true }))
|
|
1062
|
+
* @param {string} [options.jsonLimit='10mb'] JSON body 大小限制
|
|
1063
|
+
* @param {string} [options.urlencodedLimit='10mb'] urlencoded body 大小限制
|
|
1064
|
+
* @param {boolean|string|object} [options.cors=true] 是否启用 CORS;可传 origin 字符串或 { origin, methods, headers }
|
|
1065
|
+
* @param {string|string[]} [options.staticDir=null] 静态资源目录(可传数组),相对 cwd 或绝对路径
|
|
1066
|
+
* @param {boolean|function|function[]} [options.auth=false] 认证中间件:
|
|
1067
|
+
* true 使用内置 auth(依赖 API_SECRET_KEY/API_TOKEN/ALLOW_IPS 环境变量);
|
|
1068
|
+
* 传函数或函数数组则使用外部自定义中间件(如 (req, res, next) => {...});false 不启用
|
|
1069
|
+
* @param {boolean} [options.health=true] 是否启用 GET /health 健康检查
|
|
1070
|
+
* @param {boolean} [options.trustProxy=true] 是否信任代理(影响 req.ip)
|
|
1071
|
+
* @param {function|Array<{method:string,path:string,handler:function|function[]}>} [options.routes=null]
|
|
1072
|
+
* 路由注册:函数 (app) => {...} 或路由配置数组;也可省略,用 route() 定义即自动注册
|
|
1073
|
+
* @param {string|string[]|false} [options.routesDir=['routes','src/routes']] 路由文件目录(相对 cwd 或绝对路径),
|
|
1074
|
+
* start() 时自动递归扫描并加载其中所有 JS 文件(触发 route() 自动注册),无需手动 import;
|
|
1075
|
+
* 默认同时扫描 'routes' 与 'src/routes' 两个候选目录(存在哪个扫哪个);
|
|
1076
|
+
* 传 false 关闭扫描;传数组可自定义多个目录
|
|
1077
|
+
* @returns {{ app, start, stop, getServer }}
|
|
891
1078
|
*/
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1079
|
+
function useServer(options = {}) {
|
|
1080
|
+
const {
|
|
1081
|
+
port = 3000,
|
|
1082
|
+
host = '0.0.0.0',
|
|
1083
|
+
logger = createLogger({ name: 'server', split: true }),
|
|
1084
|
+
jsonLimit = '10mb',
|
|
1085
|
+
urlencodedLimit = '10mb',
|
|
1086
|
+
cors = true,
|
|
1087
|
+
staticDir = null,
|
|
1088
|
+
auth: enableAuth = false,
|
|
1089
|
+
health = true,
|
|
1090
|
+
trustProxy = true,
|
|
1091
|
+
routes = null,
|
|
1092
|
+
routesDir = ['routes', 'src/routes']
|
|
1093
|
+
} = options;
|
|
1094
|
+
|
|
1095
|
+
const app = express();
|
|
1096
|
+
|
|
1097
|
+
if (trustProxy) app.set('trust proxy', true);
|
|
1098
|
+
|
|
1099
|
+
// body 解析
|
|
1100
|
+
app.use(express.json({ limit: jsonLimit }));
|
|
1101
|
+
app.use(express.urlencoded({ extended: true, limit: urlencodedLimit }));
|
|
1102
|
+
|
|
1103
|
+
// 请求日志(含耗时/状态码/IP)
|
|
1104
|
+
app.use((req, res, next) => {
|
|
1105
|
+
const start = Date.now();
|
|
1106
|
+
res.on('finish', () => {
|
|
1107
|
+
const duration = Date.now() - start;
|
|
1108
|
+
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms ${req.ip || ''}`);
|
|
1109
|
+
});
|
|
1110
|
+
next();
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// CORS
|
|
1114
|
+
if (cors) app.use(createCors(cors));
|
|
1115
|
+
|
|
1116
|
+
// 统一响应格式 res.success / res.fail
|
|
1117
|
+
app.use((req, res, next) => {
|
|
1118
|
+
res.success = (data = null, message = 'ok') => {
|
|
1119
|
+
res.json({ code: 0, message, data });
|
|
1120
|
+
};
|
|
1121
|
+
res.fail = (message = 'error', code = -1) => {
|
|
1122
|
+
res.json({ code, message, data: null });
|
|
1123
|
+
};
|
|
1124
|
+
next();
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
// 静态资源
|
|
1128
|
+
if (staticDir) {
|
|
1129
|
+
const dirs = Array.isArray(staticDir) ? staticDir : [staticDir];
|
|
1130
|
+
for (const dir of dirs) {
|
|
1131
|
+
app.use(express.static(dir));
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// 认证(true 用内置 auth;函数/数组用外部自定义中间件)
|
|
1136
|
+
if (enableAuth) {
|
|
1137
|
+
if (typeof enableAuth === 'function') {
|
|
1138
|
+
app.use(enableAuth);
|
|
1139
|
+
} else if (Array.isArray(enableAuth)) {
|
|
1140
|
+
app.use(...enableAuth);
|
|
1141
|
+
} else {
|
|
1142
|
+
app.use(auth);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// 健康检查
|
|
1147
|
+
if (health) {
|
|
1148
|
+
app.get('/health', (req, res) => {
|
|
1149
|
+
res.success({ status: 'ok', uptime: process.uptime() });
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// 路由注册 + 404 + 错误处理
|
|
1154
|
+
// 需在 routesDir 扫描加载(触发 route() 自动注册)之后执行,故封装为函数由 start() 调用
|
|
1155
|
+
const setupRoutes = () => {
|
|
1156
|
+
// 业务路由(routes 为函数时保留原逻辑:完全自控注册顺序)
|
|
1157
|
+
if (typeof routes === 'function') {
|
|
1158
|
+
routes(app);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// 路由配置:手动传入的 routes + route() 自动注册的路由
|
|
1162
|
+
// 定义即自动注册,无需在 app.js 中重复传递
|
|
1163
|
+
const allRoutes = [
|
|
1164
|
+
...(Array.isArray(routes) ? routes : []),
|
|
1165
|
+
...getRegisteredRoutes()
|
|
1166
|
+
];
|
|
1167
|
+
|
|
1168
|
+
// 静态路径优先注册,避免被动态参数路由(如 /:id)吞掉
|
|
1169
|
+
allRoutes.sort((a, b) => {
|
|
1170
|
+
const dynamic = (r) => (r.path || '').split('/').filter((s) => s.startsWith(':')).length;
|
|
1171
|
+
return dynamic(a) - dynamic(b)
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
for (const route of allRoutes) {
|
|
1175
|
+
const { method, path: routePath, handler } = route;
|
|
1176
|
+
const fn = app[method?.toLowerCase()];
|
|
1177
|
+
if (typeof fn === 'function' && routePath && handler) {
|
|
1178
|
+
fn.call(app, routePath, handler);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// 404
|
|
1183
|
+
app.use((req, res) => {
|
|
1184
|
+
res.status(404).fail('Not Found', 404);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
// 全局错误处理(express 5 自动捕获 async 异常)
|
|
1188
|
+
app.use((err, req, res, next) => {
|
|
1189
|
+
logger.error(`${req.method} ${req.originalUrl} - ${err?.stack || err?.message || err}`);
|
|
1190
|
+
if (res.headersSent) return next(err)
|
|
1191
|
+
|
|
1192
|
+
const status = err?.status || err?.statusCode || 500;
|
|
1193
|
+
res.status(status).fail(err?.message || 'Internal Server Error', status);
|
|
1194
|
+
});
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
// 启动/停止控制
|
|
1198
|
+
let server = null;
|
|
1199
|
+
|
|
1200
|
+
const start = async () => {
|
|
1201
|
+
// 自动扫描加载路由文件(触发 route() 自动注册)
|
|
1202
|
+
if (routesDir) await loadRouteFiles(routesDir, logger);
|
|
1203
|
+
|
|
1204
|
+
// 路由注册 + 404 + 错误处理(在扫描之后执行)
|
|
1205
|
+
setupRoutes();
|
|
1206
|
+
|
|
1207
|
+
return new Promise((resolve, reject) => {
|
|
1208
|
+
server = app.listen(port, host, () => {
|
|
1209
|
+
logger.info(`Server listening on http://${host}:${port}`);
|
|
1210
|
+
resolve(server);
|
|
1211
|
+
});
|
|
1212
|
+
server.on('error', (err) => {
|
|
1213
|
+
logger.error(`Server error: ${err?.message}`);
|
|
1214
|
+
reject(err);
|
|
1215
|
+
});
|
|
1216
|
+
})
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
const stop = () => {
|
|
1220
|
+
return new Promise((resolve) => {
|
|
1221
|
+
if (!server) return resolve()
|
|
1222
|
+
server.close(() => resolve());
|
|
1223
|
+
})
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
return {
|
|
1227
|
+
app,
|
|
1228
|
+
start,
|
|
1229
|
+
stop,
|
|
1230
|
+
getServer: () => server
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function useHandler() {
|
|
1235
|
+
let isCancelled = false;
|
|
1236
|
+
let db = null;
|
|
1237
|
+
const logger = createLogger({ name: 'worker' });
|
|
1238
|
+
|
|
1239
|
+
if (parentPort)
|
|
1240
|
+
parentPort.once('message', (message) => {
|
|
1241
|
+
if (message !== 'cancel') return
|
|
1242
|
+
|
|
1243
|
+
isCancelled = true;
|
|
1244
|
+
if (parentPort) parentPort.postMessage('cancelled');
|
|
1245
|
+
else process.exit(0);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
const getIsCancelled = () => isCancelled;
|
|
1249
|
+
const run = async (worker) => {
|
|
1250
|
+
db = await database.init();
|
|
1251
|
+
const result = await worker({ logger, redis, db });
|
|
1252
|
+
if (parentPort) {
|
|
1253
|
+
parentPort.postMessage({
|
|
1254
|
+
type: 'done',
|
|
1255
|
+
data: result
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
else process.exit(0);
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
return { run, getIsCancelled }
|
|
1262
|
+
}
|
|
896
1263
|
|
|
897
|
-
export { aes
|
|
1264
|
+
export { aes, auth, createLogger, database, excelUtil as excel, hooks, mailUtil as mail, redis, route, scheduler, useHandler, useServer };
|
|
898
1265
|
//# sourceMappingURL=index.js.map
|