@layoutkit/bree-core 1.0.10 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,12 +5,14 @@ import nodemailer from 'nodemailer';
5
5
  import { createClient } from 'redis';
6
6
  import Bree from 'bree';
7
7
  import fs$1 from 'node:fs';
8
- import { fileURLToPath } from 'node:url';
9
8
  import winston from 'winston';
10
9
  import 'winston-daily-rotate-file';
11
10
  import knex from 'knex';
12
- import dotenv from 'dotenv';
13
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';
14
16
  import { parentPort, workerData } from 'node:worker_threads';
15
17
 
16
18
  let baseFolder = null;
@@ -137,7 +139,7 @@ var excelUtil = {
137
139
 
138
140
  let transporter = null;
139
141
  let from = '';
140
- const init$2 = () => {
142
+ const init = () => {
141
143
  from = `"${process.env.MAIL_USERNAME}" <${process.env.MAIL_ACCOUNT}>`;
142
144
  transporter = nodemailer.createTransport({
143
145
  host: process.env.MAIL_HOST, // 或公司邮箱 SMTP
@@ -168,7 +170,7 @@ const send = async (subject, toUser = '', copyUser = '', content = '', filePath
168
170
  };
169
171
 
170
172
  var mailUtil = {
171
- init: init$2,
173
+ init,
172
174
  send
173
175
  };
174
176
 
@@ -374,57 +376,131 @@ class RedisHelper {
374
376
 
375
377
  var redis = new RedisHelper();
376
378
 
377
- const __filename$1 = fileURLToPath(import.meta.url);
378
- const __dirname$1 = path.dirname(__filename$1);
379
-
380
- const { combine: combine$2, timestamp: timestamp$2, printf: printf$2 } = winston.format;
381
- const logFormat$2 = printf$2(({ timestamp, level, message }) => {
382
- return `[${timestamp}] [${level.toUpperCase()}] ${message}`
383
- });
379
+ const { combine, timestamp, printf } = winston.format;
384
380
 
385
- const breeLogger = winston.createLogger({
386
- level: 'info',
387
- format: combine$2(
388
- timestamp$2({ format: 'YYYY-MM-DD HH:mm:ss' }),
389
- logFormat$2
390
- ),
391
- transports: [
392
- new winston.transports.Console(),
393
- new winston.transports.DailyRotateFile({
394
- dirname: 'logs',
395
- filename: 'worker-%DATE%.log',
396
- datePattern: 'YYYY-MM-DD',
397
- maxSize: '20m',
398
- maxFiles: '30d'
399
- })
400
- ]
381
+ /**
382
+ * 默认日志格式:可被 format 参数覆盖
383
+ */
384
+ const defaultLogFormat = printf(({ timestamp, level, message }) => {
385
+ return `[${timestamp}] [${level.toUpperCase()}] ${message}`
401
386
  });
402
387
 
403
388
  /**
404
- * worker日志
389
+ * 单级别文件过滤器(用于 split 按级别拆文件)
405
390
  */
406
- const worker = (jobName, message) => {
407
- breeLogger.info(`[${jobName}] ${message}`);
391
+ const levelFilter = (level) => {
392
+ return winston.format((info) => {
393
+ return info.level === level ? info : false
394
+ })()
408
395
  };
409
396
 
410
397
  /**
411
- * error日志
398
+ * split=true 时默认按全部级别拆分
412
399
  */
413
- const error$1 = (jobName, err) => {
414
- breeLogger.error(`[${jobName}] ${err?.stack || err?.message || err}`);
415
- };
416
- var blogger = { worker, error: error$1 };
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
+ }
417
493
 
418
- const ALGORITHM_MAP$1 = {
494
+ const ALGORITHM_MAP = {
419
495
  16: 'aes-128-ecb',
420
496
  24: 'aes-192-ecb',
421
497
  32: 'aes-256-ecb'
422
498
  };
423
499
 
424
- const encrypt$1 = (data, key) => {
500
+ const encrypt = (data, key) => {
425
501
  // key 必须 16 / 24 / 32 字节
426
502
  const keyBuf = Buffer.from(key, 'utf8');
427
- const cipher = crypto.createCipheriv(ALGORITHM_MAP$1[keyBuf.length], keyBuf, null);
503
+ const cipher = crypto.createCipheriv(ALGORITHM_MAP[keyBuf.length], keyBuf, null);
428
504
  cipher.setAutoPadding(true); // PKCS7
429
505
  const encrypted = Buffer.concat([
430
506
  cipher.update(data),
@@ -433,9 +509,9 @@ const encrypt$1 = (data, key) => {
433
509
  return encrypted
434
510
  };
435
511
 
436
- const decrypt$1 = (data, key) => {
512
+ const decrypt = (data, key) => {
437
513
  const keyBuf = Buffer.from(key, 'utf8');
438
- const decipher = crypto.createDecipheriv(ALGORITHM_MAP$1[keyBuf.length], keyBuf, null);
514
+ const decipher = crypto.createDecipheriv(ALGORITHM_MAP[keyBuf.length], keyBuf, null);
439
515
  decipher.setAutoPadding(true);
440
516
  const decrypted = Buffer.concat([
441
517
  decipher.update(data),
@@ -453,9 +529,9 @@ const decrypt$1 = (data, key) => {
453
529
  * @param {string} key AES密钥(16/24/32字节)
454
530
  * @returns {string} 明文
455
531
  */
456
- const decryptString$1 = (data, key) => {
532
+ const decryptString = (data, key) => {
457
533
  const keyBuf = Buffer.from(key, 'utf8');
458
- const decipher = crypto.createDecipheriv(ALGORITHM_MAP$1[keyBuf.length], keyBuf, null);
534
+ const decipher = crypto.createDecipheriv(ALGORITHM_MAP[keyBuf.length], keyBuf, null);
459
535
  decipher.setAutoPadding(true);
460
536
  const decrypted = Buffer.concat([
461
537
  decipher.update(Buffer.from(data, 'base64')),
@@ -464,73 +540,131 @@ const decryptString$1 = (data, key) => {
464
540
  return decrypted.toString('utf8')
465
541
  };
466
542
 
467
- var aes$1 = { encrypt: encrypt$1, decrypt: decrypt$1, decryptString: decryptString$1 };
543
+ var aes = { encrypt, decrypt, decryptString };
468
544
 
469
- dotenv.config();
470
-
471
- function useDatabase() {
472
- let db = null;
473
-
474
- const init = async (connectionString) => {
475
- const aesKey = process.env.DB_AES_KEY;
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) {
476
562
  try {
477
- const decryptedConnectionString = aes$1.decryptString(connectionString, aesKey);
478
- const connectionParams = decryptedConnectionString.split(';').reduce((acc, param) => {
479
- const [key, value] = param.split('=');
480
- acc[key] = value;
481
- return acc
482
- }, {});
483
- db = knex({
484
- client: 'mssql',
485
- connection: {
486
- user: connectionParams.user,
487
- password: connectionParams.password,
488
- server: connectionParams.server,
489
- database: connectionParams.database,
490
- options: {
491
- encrypt: false
492
- }
493
- }
494
- });
563
+ return aes.decryptString(connectionString, aesKey)
564
+ } catch {
565
+ // 明文连接串,直接使用
566
+ }
567
+ }
568
+ return connectionString
569
+ };
495
570
 
496
- // 等待数据库真实连接
497
- await db.raw('SELECT 1');
498
- console.log('database initialized');
499
- return db
500
- } catch (error) {
501
- await close();
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
+ });
502
601
 
503
- throw error
602
+ try {
603
+ await db$1.raw('SELECT 1'); // 验证真实连接
604
+ } catch (err) {
605
+ await this.close();
606
+ throw err
504
607
  }
505
- };
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
+ }
506
619
 
507
- const close = async () => {
508
- if (db) {
509
- await db.destroy();
510
- db = null;
620
+ /** 关闭数据库连接 */
621
+ async close() {
622
+ if (db$1) {
623
+ await db$1.destroy();
624
+ db$1 = null;
511
625
  }
512
- };
626
+ }
627
+ }
513
628
 
514
- return { init, close }
515
- }
629
+ var database = new DatabaseHelper();
516
630
 
517
- dotenv.config();
518
-
519
- const db$1 = useDatabase();
520
- const init$1 = async () => await db$1.init(process.env.DB_CONNECTION_STRING);
521
- const close = async () => await db$1.close();
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
+ };
522
655
 
523
- var database = {
524
- init: init$1,
525
- close
656
+ var hooks = {
657
+ registerLoader,
658
+ registerError,
659
+ registerComplete
526
660
  };
527
661
 
528
- path.resolve(__dirname$1, '../../');
529
-
530
662
  let bree = null;
531
663
  let db = null;
532
664
  let jobsRoot = null;
533
665
 
666
+ const logger = createLogger({ name: 'scheduler', split: true });
667
+
534
668
  // 让使用者通过环境变量或配置指定 jobs 目录
535
669
  const getJobsRoot = () => {
536
670
  if (jobsRoot) return jobsRoot
@@ -573,47 +707,51 @@ const createBree = () => {
573
707
  const instance = new Bree({
574
708
  hasSeconds: true,
575
709
  root: jobsPath,
710
+ doRootCheck: false, // 任务列表由 hooks.registerLoader / 数据库提供,无需 root/index.js
576
711
  errorHandler: async (error, workerMetadata) => {
577
- if (workerMetadata?.threadId) {
578
- blogger.worker(workerMetadata.name,
579
- `There was an error while running a worker ${workerMetadata.name} with thread ID: ${workerMetadata.threadId}`);
580
- } else {
581
- blogger.worker(workerMetadata.name,
582
- `There was an error while running a worker ${workerMetadata.name}`);
583
- }
584
-
585
- blogger.error(workerMetadata?.name, error);
586
712
  if (error?.message?.indexOf('exited with code') > -1) return
587
- const job = instance.config.jobs.find(x => x.name === workerMetadata?.name);
588
- const workerData = job?.worker?.workerData;
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;
589
718
  if (workerData) {
590
- await db('Sys_BreeJobs')
591
- .where('ID', workerData.id)
592
- .update({
593
- 'ResultStatus': 1,
594
- 'ErrorMessage': error?.stack
595
- });
719
+ await hooks.registerError(db, { id: workerData.id, error: error?.stack });
596
720
  }
597
721
  }
598
722
  });
599
723
 
600
724
  instance.on('worker created', (name) => {
601
- console.log('worker created', name);
602
- blogger.worker(name, 'worker created');
725
+ console.log(`[${name}] worker created`);
726
+ logger.info(`[${name}] worker created`);
603
727
  });
604
728
 
605
- instance.on('worker deleted', (name) => {
606
- console.log('worker deleted', name);
607
- blogger.worker(name, 'worker deleted');
729
+ instance.on('worker deleted', async (name) => {
730
+ console.log(`[${name}] worker deleted`);
731
+ logger.info(`[${name}] worker deleted`);
608
732
  });
609
733
 
610
- return instance
611
- };
734
+ instance.on('worker message', async (name, message) => {
735
+ console.log(`[${name}] worker message:`, message);
612
736
 
613
- const getBree = () => {
614
- return bree
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
+ });
750
+
751
+ return instance
615
752
  };
616
753
 
754
+ const getBree = () => bree;
617
755
  const reset = async (job, startStatus) => {
618
756
  await bree.stop(job.name);
619
757
  const index = bree.config.jobs.findIndex(x => x.name === job.name);
@@ -624,10 +762,8 @@ const reset = async (job, startStatus) => {
624
762
 
625
763
  const remove = async (name) => {
626
764
  await bree.stop(name);
627
-
628
765
  // 删除运行中的 worker 貌似不需要
629
766
  // delete bree.workers.get(name)
630
-
631
767
  const index = bree.config.jobs.findIndex(x => x.name === name);
632
768
  if (index !== -1) bree.config.jobs.splice(index, 1);
633
769
  };
@@ -640,66 +776,20 @@ const create = async (job) => {
640
776
  };
641
777
 
642
778
  // 启动任务
643
- const start = async (name) => {
644
- await bree.start(name);
645
- };
779
+ const start = async (name) => await bree.start(name);
646
780
  // 停止任务
647
- const stop = async (name) => {
648
- await bree.stop(name);
649
- };
781
+ const stop = async (name) => await bree.stop(name);
650
782
  // 执行一次任务
651
- const run = async (name) => {
652
- await bree.run(name);
653
- };
654
-
783
+ const run = async (name) => await bree.run(name);
655
784
  // 任务初始化
656
- const init = async () => {
657
- // 1. 从数据库读取任务列表
785
+ const register$1 = async () => {
658
786
  db = await database.init();
659
- // 2. 创建 Bree 实例(在数据库初始化之后)
660
787
  bree = createBree();
661
- // 3. 从数据库读取任务列表
662
- const jobs = await db('Sys_BreeJobs'); //.where('Status', 1)
663
- // 4. 批量注册到调度器
664
- for (const job of jobs) {
665
- const id = job['ID'];
666
- const name = job['Name'];
667
- const cron = job['Cron'];
668
- const path = job['Path'];
669
- const status = job['Status']; // status 状态为0:已停止 1:执行中 2:
670
- const createStatus = job['CreateStatus'];
671
-
672
- try {
673
- await create({
674
- name,
675
- cron,
676
- path: path || undefined,
677
- worker: { workerData: { id } }
678
- });
679
-
680
- if (status === 1) await bree.start(name);
681
- if (createStatus === 0) {
682
- await db('Sys_BreeJobs')
683
- .where('ID', id)
684
- .update({
685
- 'CreateStatus': 1
686
- });
687
- }
688
- }
689
- catch (error) {
690
- blogger.error(name, error);
691
- await db('Sys_BreeJobs')
692
- .where('ID', id)
693
- .update({
694
- 'ResultStatus': 1,
695
- 'ErrorMessage': error?.stack
696
- });
697
- }
698
- }
788
+ await hooks.registerLoader(db, logger);
699
789
  };
700
790
 
701
791
  var scheduler = {
702
- init,
792
+ register: register$1,
703
793
  create,
704
794
  reset,
705
795
  start,
@@ -709,149 +799,176 @@ var scheduler = {
709
799
  remove
710
800
  };
711
801
 
712
- const { combine: combine$1, timestamp: timestamp$1, printf: printf$1 } = winston.format;
713
- const logFormat$1 = printf$1(({ timestamp, level, message }) => {
714
- return `[${timestamp}] [${level.toUpperCase()}] ${message}`
715
- });
716
-
717
802
  /**
718
- * 日志级别过滤器
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
719
837
  */
720
- const levelFilter = (level) => {
721
- return winston.format((info) => {
722
- return info.level === level ? info : false
723
- })()
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
724
847
  };
725
848
 
726
- const logger = winston.createLogger({
727
- level: 'info',
728
- format: combine$1(
729
- timestamp$1({ format: 'YYYY-MM-DD HH:mm:ss' }),
730
- logFormat$1
731
- ),
732
- transports: [
733
- new winston.transports.Console(),
734
- new winston.transports.DailyRotateFile({
735
- dirname: 'logs',
736
- filename: 'info-%DATE%.log',
737
- datePattern: 'YYYY-MM-DD',
738
- maxSize: '20m',
739
- maxFiles: '30d',
740
- format: combine$1(
741
- levelFilter('info'),
742
- timestamp$1({ format: 'YYYY-MM-DD HH:mm:ss' }),
743
- logFormat$1
744
- )
745
- }),
746
- new winston.transports.DailyRotateFile({
747
- dirname: 'logs',
748
- filename: 'error-%DATE%.log',
749
- level: 'error',
750
- datePattern: 'YYYY-MM-DD',
751
- maxSize: '20m',
752
- maxFiles: '30d',
753
- format: combine$1(
754
- levelFilter('error'),
755
- timestamp$1({ format: 'YYYY-MM-DD HH:mm:ss' }),
756
- logFormat$1
757
- )
758
- })
759
- ]
760
- });
761
-
762
- function useHandler() {
763
- let isCancelled = false;
764
- let db = null;
849
+ /** 获取所有已注册路由(内部使用,供 useServer 启动时收集) */
850
+ const getRegisteredRoutes = () => [...registeredRoutes];
765
851
 
766
- const getIsCancelled = () => isCancelled;
767
- const updateTask = async (obj) => {
768
- await db('Sys_BreeJobs')
769
- .where('ID', workerData.id)
770
- .update(obj);
771
- };
772
- const run = async (worker) => {
773
- if (parentPort)
774
- parentPort.once('message', (message) => {
775
- console.log(message);
776
- if (message === 'cancel') isCancelled = true;
777
- });
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
+ };
778
858
 
779
- db = await database.init();
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
+ };
780
874
 
781
- try {
782
- const result = await worker({ logger, redis, db });
783
- await updateTask({ 'ResultStatus': 0, 'ExecuteResult': result });
784
- } catch (err) {
785
- await updateTask({ 'ResultStatus': 1, 'ErrorMessage': err.message });
786
- logger.error(`${err?.stack || err?.message || err}`);
787
- } finally {
788
- await redis.close();
789
- await database.close(); // 关闭连接池
790
- if (parentPort) {
791
- parentPort.postMessage("done");
792
- } else {
793
- process.exit(0);
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);
794
898
  }
795
899
  }
796
900
  };
797
-
798
- return { run, getIsCancelled }
799
- }
800
-
801
- const ALGORITHM_MAP = {
802
- 16: 'aes-128-ecb',
803
- 24: 'aes-192-ecb',
804
- 32: 'aes-256-ecb'
901
+ return register(item)
805
902
  };
806
903
 
807
- const encrypt = (data, key) => {
808
- // key 必须 16 / 24 / 32 字节
809
- const keyBuf = Buffer.from(key, 'utf8');
810
- const cipher = crypto.createCipheriv(ALGORITHM_MAP[keyBuf.length], keyBuf, null);
811
- cipher.setAutoPadding(true); // PKCS7
812
- const encrypted = Buffer.concat([
813
- cipher.update(data),
814
- cipher.final()
815
- ]);
816
- return encrypted
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')
817
915
  };
818
916
 
819
- const decrypt = (data, key) => {
820
- const keyBuf = Buffer.from(key, 'utf8');
821
- const decipher = crypto.createDecipheriv(ALGORITHM_MAP[keyBuf.length], keyBuf, null);
822
- decipher.setAutoPadding(true);
823
- const decrypted = Buffer.concat([
824
- decipher.update(data),
825
- decipher.final()
826
- ]);
827
- return decrypted
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')
828
937
  };
829
938
 
830
939
  /**
831
- * AES解密
832
- * 对应 Java:
833
- * AesUtil.decrypt(String data, String key)
834
- *
835
- * @param {string} data Base64密文
836
- * @param {string} key AES密钥(16/24/32字节)
837
- * @returns {string} 明文
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}}
838
948
  */
839
- const decryptString = (data, key) => {
840
- const keyBuf = Buffer.from(key, 'utf8');
841
- const decipher = crypto.createDecipheriv(ALGORITHM_MAP[keyBuf.length], keyBuf, null);
842
- decipher.setAutoPadding(true);
843
- const decrypted = Buffer.concat([
844
- decipher.update(Buffer.from(data, 'base64')),
845
- decipher.final()
846
- ]);
847
- return decrypted.toString('utf8')
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
+ };
961
+
962
+ const getClientIp = (req) => {
963
+ return req.headers['x-forwarded-for']?.split(',')[0]?.trim()
964
+ || req.headers['x-real-ip']
965
+ || req.ip
966
+ || req.connection.remoteAddress
848
967
  };
849
968
 
850
- var aes = { encrypt, decrypt, decryptString };
851
-
852
969
  function auth(req, res, next) {
853
- const apiKey = req.headers['x-api-key'];
854
-
970
+ const headerName = process.env.API_SECRET_KEY_NAME.toLowerCase();
971
+ const apiKey = req.headers[headerName];
855
972
  if (!apiKey) {
856
973
  return res.status(200).json({
857
974
  code: -1,
@@ -867,12 +984,7 @@ function auth(req, res, next) {
867
984
  })
868
985
  }
869
986
 
870
- let ip = req.ip;
871
- console.log('Request IP:', ip);
872
- if (ip.startsWith('::ffff:')) {
873
- ip = ip.substring(7);
874
- }
875
-
987
+ const ip = getClientIp(req);
876
988
  const allowIps = process.env.ALLOW_IPS?.split(',') || [];
877
989
  if (!allowIps.includes(ip)) {
878
990
  return res.status(200)
@@ -885,43 +997,273 @@ function auth(req, res, next) {
885
997
  next();
886
998
  }
887
999
 
888
- const { combine, timestamp, printf } = winston.format;
889
- const logFormat = printf(({ timestamp, level, message }) => {
890
- return `[${timestamp}] [${level.toUpperCase()}] ${message}`
891
- });
1000
+ /**
1001
+ * 递归扫描目录下所有 JS 文件并动态加载(触发 route() 自动注册)
1002
+ * 跳过以 . / _ 开头的文件(如 .DS_Store、_private.js)
1003
+ */
1004
+ const loadRouteFiles = async (dirs, logger) => {
1005
+ const list = Array.isArray(dirs) ? dirs : [dirs];
1006
+ let total = 0;
892
1007
 
893
- const weixinLogger = winston.createLogger({
894
- level: 'info',
895
- format: combine(
896
- timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
897
- logFormat
898
- ),
899
- transports: [
900
- new winston.transports.Console(),
901
- new winston.transports.DailyRotateFile({
902
- dirname: 'logs',
903
- filename: 'weixinapi-%DATE%.log',
904
- datePattern: 'YYYY-MM-DD',
905
- maxSize: '20m',
906
- maxFiles: '30d'
907
- })
908
- ]
909
- });
1008
+ for (const dir of list) {
1009
+ const abs = path$1.resolve(dir);
1010
+ let entries;
1011
+ try {
1012
+ entries = await readdir(abs, { withFileTypes: true });
1013
+ } catch {
1014
+ // 目录不存在则跳过,但给出提示便于诊断路由未加载
1015
+ logger?.warn?.(`routes dir not found, skipped: ${abs}`);
1016
+ continue
1017
+ }
1018
+
1019
+ const walk = async (items, base) => {
1020
+ for (const item of items) {
1021
+ if (item.name.startsWith('.') || item.name.startsWith('_')) continue
1022
+ const full = path$1.join(base, item.name);
1023
+ if (item.isDirectory()) {
1024
+ await walk(await readdir(full, { withFileTypes: true }), full);
1025
+ } else if (/\.(mjs|cjs|js)$/i.test(item.name)) {
1026
+ try {
1027
+ await import(pathToFileURL(full).href);
1028
+ total += 1;
1029
+ } catch (err) {
1030
+ logger?.error?.(`load routes file failed: ${full} - ${err?.message || err}`);
1031
+ }
1032
+ }
1033
+ }
1034
+ };
1035
+ await walk(entries, abs);
1036
+ }
1037
+
1038
+ if (total > 0) logger?.info?.(`auto-loaded ${total} routes file(s)`);
1039
+ };
910
1040
 
911
1041
  /**
912
- * worker日志
1042
+ * CORS 中间件(不引入额外依赖)
913
1043
  */
914
- const info = (apiName, message) => {
915
- weixinLogger.info(`[${apiName}] ${message}`);
1044
+ const createCors = (options = {}) => {
1045
+ const opts = typeof options === 'string' ? { origin: options } : options;
1046
+ const origin = opts.origin ?? '*';
1047
+ const methods = opts.methods || 'GET,POST,PUT,DELETE,PATCH,OPTIONS';
1048
+ const headers = opts.headers || 'Content-Type,Authorization,x-api-key';
1049
+
1050
+ return (req, res, next) => {
1051
+ res.setHeader('Access-Control-Allow-Origin', origin);
1052
+ res.setHeader('Access-Control-Allow-Methods', methods);
1053
+ res.setHeader('Access-Control-Allow-Headers', headers);
1054
+ if (req.method === 'OPTIONS') return res.sendStatus(204)
1055
+ next();
1056
+ }
916
1057
  };
917
1058
 
918
1059
  /**
919
- * error日志
1060
+ * Express 服务封装
1061
+ * @param {object} options
1062
+ * @param {number} [options.port=3000] 监听端口
1063
+ * @param {string} [options.host='0.0.0.0'] 监听地址
1064
+ * @param {object} [options.logger] 自定义 logger(默认 createLogger({ name: 'server', split: true }))
1065
+ * @param {string} [options.jsonLimit='10mb'] JSON body 大小限制
1066
+ * @param {string} [options.urlencodedLimit='10mb'] urlencoded body 大小限制
1067
+ * @param {boolean|string|object} [options.cors=true] 是否启用 CORS;可传 origin 字符串或 { origin, methods, headers }
1068
+ * @param {string|string[]} [options.staticDir=null] 静态资源目录(可传数组),相对 cwd 或绝对路径
1069
+ * @param {boolean|function|function[]} [options.auth=false] 认证中间件:
1070
+ * true 使用内置 auth(依赖 API_SECRET_KEY/API_TOKEN/ALLOW_IPS 环境变量);
1071
+ * 传函数或函数数组则使用外部自定义中间件(如 (req, res, next) => {...});false 不启用
1072
+ * @param {boolean} [options.health=true] 是否启用 GET /health 健康检查
1073
+ * @param {boolean} [options.trustProxy=true] 是否信任代理(影响 req.ip)
1074
+ * @param {function|Array<{method:string,path:string,handler:function|function[]}>} [options.routes=null]
1075
+ * 路由注册:函数 (app) => {...} 或路由配置数组;也可省略,用 route() 定义即自动注册
1076
+ * @param {string|string[]|false} [options.routesDir=['routes','src/routes']] 路由文件目录(相对 cwd 或绝对路径),
1077
+ * start() 时自动递归扫描并加载其中所有 JS 文件(触发 route() 自动注册),无需手动 import;
1078
+ * 默认同时扫描 'routes' 与 'src/routes' 两个候选目录(存在哪个扫哪个);
1079
+ * 传 false 关闭扫描;传数组可自定义多个目录
1080
+ * @returns {{ app, start, stop, getServer }}
920
1081
  */
921
- const error = (apiName, err) => {
922
- weixinLogger.error(`[${apiName}] ${err?.stack || err?.message || err}`);
923
- };
924
- var weixinLogger_default = { info, error };
1082
+ function useServer(options = {}) {
1083
+ const {
1084
+ port = 3000,
1085
+ host = '0.0.0.0',
1086
+ logger = createLogger({ name: 'server', split: true }),
1087
+ jsonLimit = '10mb',
1088
+ urlencodedLimit = '10mb',
1089
+ cors = true,
1090
+ staticDir = null,
1091
+ auth: enableAuth = false,
1092
+ health = true,
1093
+ trustProxy = true,
1094
+ routes = null,
1095
+ routesDir = ['routes', 'src/routes']
1096
+ } = options;
1097
+
1098
+ const app = express();
1099
+
1100
+ if (trustProxy) app.set('trust proxy', true);
1101
+
1102
+ // body 解析
1103
+ app.use(express.json({ limit: jsonLimit }));
1104
+ app.use(express.urlencoded({ extended: true, limit: urlencodedLimit }));
1105
+
1106
+ // 请求日志(含耗时/状态码/IP)
1107
+ app.use((req, res, next) => {
1108
+ const start = Date.now();
1109
+ res.on('finish', () => {
1110
+ const duration = Date.now() - start;
1111
+ logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms ${req.ip || ''}`);
1112
+ });
1113
+ next();
1114
+ });
1115
+
1116
+ // CORS
1117
+ if (cors) app.use(createCors(cors));
1118
+
1119
+ // 统一响应格式 res.success / res.fail
1120
+ app.use((req, res, next) => {
1121
+ res.success = (data = null, message = 'ok') => {
1122
+ res.json({ code: 0, message, data });
1123
+ };
1124
+ res.fail = (message = 'error', code = -1) => {
1125
+ res.json({ code, message, data: null });
1126
+ };
1127
+ next();
1128
+ });
1129
+
1130
+ // 静态资源
1131
+ if (staticDir) {
1132
+ const dirs = Array.isArray(staticDir) ? staticDir : [staticDir];
1133
+ for (const dir of dirs) {
1134
+ app.use(express.static(dir));
1135
+ }
1136
+ }
1137
+
1138
+ // 认证(true 用内置 auth;函数/数组用外部自定义中间件)
1139
+ if (enableAuth) {
1140
+ if (typeof enableAuth === 'function') {
1141
+ app.use(enableAuth);
1142
+ } else if (Array.isArray(enableAuth)) {
1143
+ app.use(...enableAuth);
1144
+ } else {
1145
+ app.use(auth);
1146
+ }
1147
+ }
1148
+
1149
+ // 健康检查
1150
+ if (health) {
1151
+ app.get('/health', (req, res) => {
1152
+ res.success({ status: 'ok', uptime: process.uptime() });
1153
+ });
1154
+ }
1155
+
1156
+ // 路由注册 + 404 + 错误处理
1157
+ // 需在 routesDir 扫描加载(触发 route() 自动注册)之后执行,故封装为函数由 start() 调用
1158
+ const setupRoutes = () => {
1159
+ // 业务路由(routes 为函数时保留原逻辑:完全自控注册顺序)
1160
+ if (typeof routes === 'function') {
1161
+ routes(app);
1162
+ }
1163
+
1164
+ // 路由配置:手动传入的 routes + route() 自动注册的路由
1165
+ // 定义即自动注册,无需在 app.js 中重复传递
1166
+ const allRoutes = [
1167
+ ...(Array.isArray(routes) ? routes : []),
1168
+ ...getRegisteredRoutes()
1169
+ ];
1170
+
1171
+ // 静态路径优先注册,避免被动态参数路由(如 /:id)吞掉
1172
+ allRoutes.sort((a, b) => {
1173
+ const dynamic = (r) => (r.path || '').split('/').filter((s) => s.startsWith(':')).length;
1174
+ return dynamic(a) - dynamic(b)
1175
+ });
1176
+
1177
+ for (const route of allRoutes) {
1178
+ const { method, path: routePath, handler } = route;
1179
+ const fn = app[method?.toLowerCase()];
1180
+ if (typeof fn === 'function' && routePath && handler) {
1181
+ fn.call(app, routePath, handler);
1182
+ }
1183
+ }
1184
+
1185
+ // 404
1186
+ app.use((req, res) => {
1187
+ res.status(404).fail('Not Found', 404);
1188
+ });
1189
+
1190
+ // 全局错误处理(express 5 自动捕获 async 异常)
1191
+ app.use((err, req, res, next) => {
1192
+ logger.error(`${req.method} ${req.originalUrl} - ${err?.stack || err?.message || err}`);
1193
+ if (res.headersSent) return next(err)
1194
+
1195
+ const status = err?.status || err?.statusCode || 500;
1196
+ res.status(status).fail(err?.message || 'Internal Server Error', status);
1197
+ });
1198
+ };
1199
+
1200
+ // 启动/停止控制
1201
+ let server = null;
1202
+
1203
+ const start = async () => {
1204
+ // 自动扫描加载路由文件(触发 route() 自动注册)
1205
+ if (routesDir) await loadRouteFiles(routesDir, logger);
1206
+
1207
+ // 路由注册 + 404 + 错误处理(在扫描之后执行)
1208
+ setupRoutes();
1209
+
1210
+ return new Promise((resolve, reject) => {
1211
+ server = app.listen(port, host, () => {
1212
+ logger.info(`Server listening on http://${host}:${port}`);
1213
+ resolve(server);
1214
+ });
1215
+ server.on('error', (err) => {
1216
+ logger.error(`Server error: ${err?.message}`);
1217
+ reject(err);
1218
+ });
1219
+ })
1220
+ };
1221
+
1222
+ const stop = () => {
1223
+ return new Promise((resolve) => {
1224
+ if (!server) return resolve()
1225
+ server.close(() => resolve());
1226
+ })
1227
+ };
1228
+
1229
+ return {
1230
+ app,
1231
+ start,
1232
+ stop,
1233
+ getServer: () => server
1234
+ }
1235
+ }
1236
+
1237
+ function useHandler() {
1238
+ let isCancelled = false;
1239
+ let db = null;
1240
+ const logger = createLogger({ name: 'worker' });
1241
+
1242
+ if (parentPort)
1243
+ parentPort.once('message', (message) => {
1244
+ if (message !== 'cancel') return
1245
+
1246
+ isCancelled = true;
1247
+ if (parentPort) parentPort.postMessage('cancelled');
1248
+ else process.exit(0);
1249
+ });
1250
+
1251
+ const getIsCancelled = () => isCancelled;
1252
+ const run = async (worker) => {
1253
+ db = await database.init();
1254
+ const requestData = workerData?.requestData;
1255
+ const result = await worker({ logger, redis, db, requestData });
1256
+ if (parentPort) {
1257
+ parentPort.postMessage({
1258
+ type: 'done',
1259
+ data: result
1260
+ });
1261
+ }
1262
+ else process.exit(0);
1263
+ };
1264
+
1265
+ return { run, getIsCancelled }
1266
+ }
925
1267
 
926
- export { aes$1 as aes, auth, database, excelUtil as excel, mailUtil as mail, redis, scheduler, useDatabase, useHandler, weixinLogger_default as weixinLogger };
1268
+ export { aes, auth, createLogger, database, excelUtil as excel, hooks, mailUtil as mail, redis, route, scheduler, useHandler, useServer };
927
1269
  //# sourceMappingURL=index.js.map