Skip to content

Egg在开发中一些常用功能

一. 文件上传

Egg文件上传,官方提供了两方式:filestream;开发中可以根据自生需要,选择合适的方式进行文件上传。文件上传官方示例。 但是,更推荐stream的方式,在nodejs中,stream才是主流,相比file的方式,stream占用的内存空间会更小,速度也会更快。

比如:上传一个非常大的文件(2GB),file会将2GB文件先上传到内存中,再对文件进行操作(读写等),处理完后再释放内存,此时服务器压力会非常大,若是服务器内存很吃紧的话,会导致服务器宕机,相比之下,stream的方式就不存在这种请情况

1、file模式与stream共存的配置

一个项目中,可能存在多种文件上传的方式,file模式和stream模式,虽然推荐stream模式,但是有的场景下file处理起来会更方便,因此同时支持stream和file模式配置很有必要

js
// config.default.js
config.multipart = {
  mode: 'stream',
  // 这些接口使用stream模式,其余的上传文件接口使用file模式
  fileModeMatch: /^((?!((\/api\/uploadImg)|(\/api\/uploadExcel))).)*$/, 
  tmpdir: path.join(__dirname, '../tmp/', 'egg-multipart-tmp'),
  fileExtensions: ['.wav', '.PDF', '.BMP', '.DOC', '.DOCX', '.GIF', '.JPG', '.PNG', '.PPT', '.PPTX', '.RAR', '.TXT', '.XLS', '.XLSX', '.ZIP', '.csv', '.ftl', '.html'],

2、创建文件夹封装

js
const fs = require("fs");
function mkdirsSync(dirname) {
  if (fs.existsSync(dirname)) {
    return true;
  } else {
    if (mkdirsSync(path.dirname(dirname))) {
      fs.mkdirSync(dirname);
      return true;
    }
  }
}

单文件上传

1. file模式

js
// app/controller/upload.js
const Controller = require('egg').Controller;
const fs = require('mz/fs');

module.exports = class extends Controller {
  async upload() {
    const { ctx } = this;
    const file = ctx.request.files[0];
    const name = 'egg-multipart-test/' + path.basename(file.filename);
    let result;
    try {
      // 处理文件,比如上传到云端
      result = await ctx.oss.put(name, file.filepath);
    } finally {
      // 需要删除临时文件
      await fs.unlink(file.filepath);
    }

    ctx.body = {
      url: result.url,
      // 获取所有的字段值
      requestBody: ctx.request.body,
    };
  }
};

2. stream模式

egg服务端 controller

js
/*所需要依赖*/
//故名思意 异步二进制 写入流
const awaitWriteStream = require("await-stream-ready").write;
//管道读入一个虫洞。
const sendToWormhole = require("stream-wormhole");
const dayjs = require("dayjs");


/*核心代码*/
/**
 * @api {post} /api/uploadFile 文件上传
 * @apiName uploadFile
 * @apiGroup 公共模块
 * @apiParam {File} file 文件
 * @apiSuccess {String} data 文件相对路径
 *
 * @apiHeader {String} authorization 用户授权token
 */
async uploadFileAtLocal() {
  const { ctx, app, config } = this;
  const stream = await ctx.getFileStream();
  console.log("-----------获取表单中其它数据 start--------------");
  console.log(stream.fields);
  console.log("-----------获取表单中其它数据 end--------------");
  // 基础的目录
  const uplaodBasePath = "app/public/uploads";
  // 生成文件名
  const filename = `${Date.now()}${Number.parseInt(
    Math.random() * 1000
  )}${path.extname(stream.filename).toLocaleLowerCase()}`;
  // 生成文件夹
  const dirname = dayjs(Date.now()).format("YYYY/MM/DD");
  mkdirsSync(path.join(/* app.root,  */ uplaodBasePath, dirname));
  // 生成写入路径
  const target = path.join(
    /* app.root,  */ uplaodBasePath,
    dirname,
    filename
  );
  // 写入流
  const writeStream = fs.createWriteStream(target);
  let filePath = path.join("/public/uploads", dirname, filename);
  this.success(filePath);
  try {
    //异步把文件流 写入
    await awaitWriteStream(stream.pipe(writeStream));
    let prePath = path.join("/uploads", dirname, filename);
    this.success(prePath.replace(/\\/g, "/"));
  } catch (err) {
    //如果出现错误,关闭管道
    await sendToWormhole(stream);
    this.fail("文件上传失败");

前端 前端以formData的方式发情请求

Content-Type: multipart/form-data;

js
let formData = new FormData()
formData.append('file',file)
// 字段也这样添加进去
formData.append('publicStatus',true)

最后将这个formData放到body里面即可

多文件上传

多文件上传处理方式同单文件上传处理方式一致,参见egg获取上传的文件

egg文件上传总结

针对node Stream 上传文件的文章 https://blog.csdn.net/u014695532/article/details/51150704https://blog.csdn.net/qq_34832846/article/details/88125645https://blog.csdn.net/xj5210224/article/details/103024171

直接从流中获取数据的插件: https://www.npmjs.com/package/concat-stream

若是上传excel文件,或导出excel文件,有excel.js插件, 非常方便

egg用stream文件上传,不存文件到本地,直接获取文件内数据案例

javascript
// controller
// 批量上传txt
  async batchPosId() {
    const { ctx, service } = this;
    let stream;
    try {
      stream = await ctx.getFileStream();
      let str = await service.common.getStreamData(stream, 'string')
      let idsArr = str.split('\n') // txt内所有数据的数组

      await sendToWormhole(stream);
      ctx.body = { code: 0, msg: "操作成功", data: idsArr }
    } catch (err) {
      console.log('==========文件上传失败========', err);
      await sendToWormhole(stream);
      ctx.body = { code: 1, msg: '文件上传或解析失败' };
      return;
    }
  }

 // service

 const concat = require('concat-stream')
 /**
   * 
   * @param {File} stream 文件流对象
   * @param {*} encoding 要返回的编码   encoding取值如下   
   *           string - get a string (默认)   
   *           buffer - get back a Buffer   
   *           array - get an array of byte integers   
   *           uint8array, u8, uint8 - get back a Uint8Array   
   *           object, get back an array of Objects
   */
  async getStreamData(stream, encoding = "string") {
    return new Promise((resolve, reject) => {
      stream.pipe(concat({ encoding }, data => {
        resolve(data)
      }))
      stream.on('error', err => {
        reject(err)
      })
    })
  }

二. 处理 Excel,(exceljs)

Excel可以存放多少条数据? 根据不同的版本有不同的存储条数: 1、Excel 07-2003 一个工作表最多可有65536行,行用数字1—65536表示,最多可有256列。 单个sheet可以保存:行(65536)*列(256)=16777216个数据。 2、Excel 2007及以后版本,一个工作表最多可有1048576行,16384列; 单个sheet可以保存:行(1048576)*列(16384)=17179869184个数据。

exceljs文档:https://github.com/exceljs/exceljs/blob/master/README_zh.md

0、Excel 公共service

js
'use strict';
const Service = require('egg').Service;
const Excel = require('exceljs');
const concat = require('concat-stream');
const moment = require('moment');
const path = require('path');

function getcellVal(obj) {
  if (Object.prototype.toString.call(obj) === '[object Object]' && Array.isArray(obj.richText)) {
    return obj.richText.reduce((pre, cur) => {
      pre += cur.text;
      return pre;
    }, '').trim().replace(/\\/g, '\\\\');
  } else if (Object.prototype.toString.call(obj) === '[object Object]' && 'text' in obj && 'hyperlink' in obj) {
    return String(obj.text).trim().replace(/\\/g, '\\\\');
  } else if (typeof obj === 'string' && obj) {
    return obj.trim().replace(/\\/g, '\\\\');
  }
  return obj;
}

class ExcelService extends Service {
  /**
    * 负责解析excel,以头部为键,生成数组
    * @param {Stream} stream excel流
    * @param {Record<string, string>} headNameMap 头部名称和键的索引
    * @param {
    *   colIndex?: number
    *   sheet: number | string
    *   strictHeader?: boolean
    *   headerRowNum?: number
    * } [options] 配置项
    * @param {number} [option.colIndex=1] worksheet.getColumn(col).values[colIndex] 从指定(colIndex)索引位置获取某列表格值
    * @param {number | string} [option.sheet=1] workbook.getWorksheet(sheet) 获取指定(sheet)工作表
    * @param {boolean} [option.strictHeader=false] 表头严格检查 检查表头是否有缺失
    * @param {number} [option.headerRowNum=1] 从(headerRowNum)指定行获取表头
    * @returns {Promise<Array<Record<string, string | number>>> | never} 解析后的数据
    */
    async parseExcel(stream, headNameMap, { colIndex = 1, sheet = 1, strictHeader = false, headerRowNum = 1 } = {}) {
      const workbook = new Excel.Workbook();
      try {
        await workbook.xlsx.read(stream);
      } catch (err) {
        throw new Error('文件内容解析失败,请确保使用的是模板文件且未加锁!');
      }
      const worksheet = workbook.getWorksheet(sheet);
      const data = [];
      const columnCount = worksheet.columnCount;
      const excelHeaderContent = Object.values(headNameMap);
      const excelHeaders = worksheet.getRow(headerRowNum).values.filter(Boolean).map(h => getcellVal(h));
      const missHeaders = excelHeaderContent.filter(h => !excelHeaders.includes(h));
      if (missHeaders.length) {
        throw new Error(`表头[${missHeaders.join('、')}]缺失,建议重新下载模板 填写后上传!`);
      }
      for (let c = 1; c <= columnCount; c++) {
        const col = worksheet.getColumn(c);
        Object.entries(headNameMap).forEach(([key, value]) => {
          const colHeader = getcellVal(col.values[colIndex]); // 表头也可能是富文本
          if (!colHeader) return;
          if (strictHeader && !excelHeaderContent.includes(colHeader)) {
            throw new Error(`无效的表头[${colHeader}],请检查表头是否有更改,建议重新下载模板 填写后上传!`);
          }
          if (colHeader === value) {
            const colValues = col.values.slice(colIndex + 1);
            colValues.forEach((val, index) => {
              let item = {};
              if (data[index]) {
                item = data[index];
              } else {
                data[index] = item;
              }
              item[key] = getcellVal(val); // 获取富文本,超链接值
            });
          }
        });
      }
      return data;
    }


    /**
     * 获取excel workbook对象,具体用法见controller/sys_manage/log_manage.js > function export
     * exceljs文档:https://github.com/exceljs/exceljs/blob/master/README_zh.md
     */
    getWorkbook({ sheetName = '', columns = [], rows = [] }) {
        let workbook = new Excel.Workbook();
        workbook.creator = 'Mr.Li';
        workbook.created = new Date(Date.now());
        workbook.modified = new Date(Date.now());

        let sheet = workbook.addWorksheet(sheetName);

        sheet.columns = columns.map(item => {
            return {
                ...item,
                style: item.style || {
                    alignment: {
                        vertical: 'center',
                        horizontal: 'center'
                    }
                }
            };
        });

        rows.forEach((row, index) => {
            let rowData = {
                ...row,
                index: index + 1
            };
            sheet.addRow(rowData);
        });

        return workbook;
    }

}

module.exports = ExcelService;

1、上传并解析excel

js
async uploadExcel() {
  const { ctx, service, logger } = this;
  let stream;
  try {
    stream = await ctx.getFileStream();
    let fileExtname = path.extname(stream.filename).toLowerCase();
    if (fileExtname !== ".xls" && fileExtname !== ".xlsx") {
      this.fail("文件类型错误,请上传Excel文件");
      await streamWormhole(stream);
      return;
    }
    const headNameMap = {
      name: "姓名",
      age: "年龄",
      sex: "性别"
    };
    // 获取Excel内数据
    let exceldata = await service.common.excel.parseExcelFile(
      stream,
      headNameMap
    );
    if (exceldata.length <= 0) {
      this.fail("Excel文件内容为空,操作失败");
      await streamWormhole(stream);
      return;
    }
    // exceldata就是上传的excel中根据headNameMap表头解析出来的数据,这里可以对这些数据去重,检查有效性,最后根据需求做相应的处理
    ctx.body = exceldata;
    await streamWormhole(stream);
  } catch (error) {
    logger.error(error);
    ctx.body = "文件上传或解析失败";
    await streamWormhole(stream);
    return;
  }
}

2、导出(下载)Excel

导出excel的方式有多种, 若是有固定的excel模板,

  • ① 可将模板放到前端,前端自己创建a标签下载即可,
  • ② 也可以将模板放到node,node通过读取excel文件后,将模板传给前端达到下载的目的,
  • ③ 若是模板里面的字段是动态的,那么就需要node根据动态数据来生成excel
  • 但是不管是那种方式,表头名称和字段的对应关系需要和解析excel里面的一致,否则再次上传后,无法解析出数据
js
const urlencode = require("urlencode");

async createExcelTpl() {
  const { ctx, service } = this;
  // 先获取所有表头列(根据需求来取到数据)
  const colunmMap = await service.xxxx.getAllCustomColumnItem();
  const columnConfig = [];
  for (const key in colunmMap) {
    if (Object.hasOwnProperty.call(colunmMap, key)) {
      const header = colunmMap[key];
      columnConfig.push({
        key, // 表头对应的字段
        header, // 表头的中文
        width: 15, // 每一列宽度
        style: { // 每一列表头样式
          alignment: {
            wrapText: true,
            vertical: "left",
            horizontal: "top"
          }
        }
      });
    }
  }
  const workbook = await service.common.excel.getWorkbook({
    sheetName: "测试sheet",
    columns: columnConfig,
    rows: [] // 数据
  });
  // filename可以根据时间戳写成动态的,避免缓存
  let filename = urlencode("测试excel导出.xlsx");
  ctx.set("content-disposition",`attachment; filename* = UTF-8''${filename}`);
  ctx.status = 200;
  await workbook.xlsx.write(ctx.res);
  ctx.res.end();
}

3. 用stream的形式导出Excel

上面记录的文件 I/O 需要在内存中建立整个工作簿,然后才能写入文件。虽然方便,但是由于所需的内存量,它可能会限制文档的大小。

流写入器(或读取器)在生成工作簿或工作表数据时对其进行处理,然后将其转换为文件形式。通常,这在内存上效率要高得多,因为最终的内存占用量,甚至中间的内存占用量都比文档版本要紧凑得多,尤其是当您考虑到行和单元格对象一旦提交就被销毁时,尤其如此。

也就是说,当需要导出或者读取的Excel数据量非常大的时候,流式I/O的性能会非常好,它可以让每一行数据写入excel中后立即销毁这一行数据,以减小内存的使用量。

下面是在express中导出Excel的示例;

js
const Excel = require("exceljs");
const urlencode = require("urlencode");
router.get("/export", async function (req, res) {
    // 使用样式和共享字符串构造流式 XLSX 工作簿编写器
    // const options = {
    // stream:fs.createWriteStream('./excel.xlsx')   // 指定要写入 XLSX 工作簿的可写流。
    //   filename: "./streamed-workbook.xlsx",      // 如果未指定流,则此字段指定要写入 XLSX 工作簿的文件的路径。
    //   useStyles: true,           // 指定是否将样式信息添加到工作簿。样式会增加一些性能开销。默认为 false
    //   useSharedStrings: true,    // 指定是否在工作簿中使用共享字符串。默认为 false
    //   zip:undefined      // ExcelJS 内部传递给 Archiver 的 Zip选项。默认值为 undefined
    // };
    // const workbook = new Excel.stream.xlsx.WorkbookWriter(options);

    /* 
    如果在选项中未指定 stream 或 filename,则工作簿编写器将创建一个 StreamBuf 对象,该对象将 XLSX 工作簿的内容存储在内存中。
    可以通过属性 workbook.stream 访问此 StreamBuf 对象,该对象可用于通过 stream.read() 直接访问字节,或将内容通过管道传输到另一个流。
    */
    const workbook = new Excel.stream.xlsx.WorkbookWriter({
        useStyles: true
    }); // 创建工作簿对象
    let worksheet = workbook.addWorksheet("My Sheet"); // 添加一个工作表
    
    // 添加列
    worksheet.columns = [
      { header: "Id", key: "id", width: 10, },
      { header: "Name", key: "name", width: 32 },
      { header: "D.O.B.", key: "DOB", width: 10, outlineLevel: 1 },
    ];
  
    // 添加行
    for (let i = 0; i < 1000000; i++) {
      worksheet
        .addRow({
          id: i,
          name: "name" + i,
          DOB: "someOtherDetail" + i,
        })
        .commit(); // 添加完一行数据后,调用commit方法,将会释放这一行数据(销毁),以提高性能
    }
    //  完成添加数据。 提交工作表
    await workbook.commit();
    
    let filename = urlencode(`测试文件-${Date.now()}.xlsx`)
    res.set("content-disposition", `attachment; filename* = UTF-8''${filename}`);
    res.status = 200;
    workbook.stream.pipe(res); // 利用管道流将创建的工作簿交给res以响应客户端
    res.end();
})

用stream的方式读写Excel,能减少内存使用量,加快处理速度,在Exceljs里面,处理流的API与非流的API基本类似,具体说明见官方文档(流式I/O

若是数据量很大的话,不建议直接将Excel响应前端,可以通过服务端生成Excel文件,然后上传到对象存储服务等位置,给到前端一个URL链接,让前端慢慢下载,并且设置一个定时任务,去删除该文件

4. stream示例

controller 公共方法

js
const moment = require('moment');
const urlencode = require('urlencode');

/**
     * 响应excel下载
     * @param {String} [options.filename] 文件名(支持带后缀和不带后缀)不传默认使用当前时间戳
     * @param {String} [options.sheetName] 工作表名称 不传默认sheet1
     * @param {String} [options.columns ] 列配置;
     * @param {String} [options.rows] 行数据;
     * 
     * 使用示例
     * ```js
     * await this.resExcelStream({
     *      sheetName: 'xxxxx',
     *      columns: [
     *          { header: '序号', key: 'index', width: 10 },
     *          { header: '内容', key: 'previousContent', width: 50 },
     *          { header: '操作结果', key: 'result', width: 50 },
     *          { header: '操作原因', key: 'reason', width: 30 },
     *          { header: '操作时间', key: 'insertTime', width: 30 }
     *      ],
     *      rows, // 数据行组成的数组
     *      filename: `日志.xlsx`
     *  });
     * ```
     * 
     */
  async resExcelStream({ filename = '', sheetName = '', columns = [], rows = [] }) {
        const { ctx, service } = this;
        try {
            const workbook = await service.common.excel.getWorkBookStream({ sheetName, columns, rows });
            if (filename) {
                filename = filename.split('.xlsx')[0] + moment(Date.now()).format('YYYYMMDD-HHmmss') + '.xlsx';
            } else {
                filename = `${moment().format('YYYYMMDDHHmmss')}.xlsx`;
            }
            filename = urlencode(filename);
            ctx.set('content-disposition', `attachment; filename* = UTF-8''${filename}`);
            ctx.status = 200;
            await workbook.stream.pipe(ctx.res);
            ctx.res.end();
            return;
        } catch (error) {
            ctx.status = 500;
            ctx.message = '服务器错误';
            this.fail('服务器错误');
            ctx.logger.error(error);
            return;
        }

service

js
    /**
     * 用流的方式获取excel workbook对象,性能优于getWorkbook, 处理数据量较大excel导出更适合
     * exceljs文档:https://github.com/exceljs/exceljs/blob/master/README_zh.md#%E6%B5%81%E5%BC%8F-io
     */
    async getWorkBookStream({ sheetName = '', columns = [], rows = [] }) {
        console.time('组装Excel所用时间');
        let workbook = new Excel.stream.xlsx.WorkbookWriter({ useStyles: true }); //创建一个流式写入器 工作簿对象
        workbook.creator = 'create';
        workbook.created = new Date(Date.now());
        workbook.modified = new Date(Date.now());

        let sheet = workbook.addWorksheet(sheetName, { views: [{ state: 'frozen', xSplit: 0, ySplit: 1 }] }); // 冻结表头

        sheet.columns = columns.map(item => {
            return {
                ...item,
                style: item.style || {
                    alignment: {
                        vertical: 'middle',
                        horizontal: 'center'
                    }
                }
            };
        });

        rows.forEach((row, index) => {
            let rowData = {
                ...row,
                index: index + 1
            };
            let newRow = sheet.addRow(rowData, 'i');
            newRow.commit();
        });
        // 完成 workbook.
        await workbook.commit();
        // ... 流已被写入
        console.timeEnd('组装Excel所用时间');

        return workbook;
    }

5. exceljs解析Excel超级封装

依赖parseExcel方法

js
/**
 * parseExcelSuper options参数字段注释
 * @typedef {Object} parseExcelSuperOptions
 * @property {Number} [contentStartRowNo] 内容开始的Excel行号
 * @property {Number} [headRowNo] 表头所在的Excel行号
 * @property {Number | String} [sheet] 要解析的sheetID 或者名称
 * @property {Boolean} [strictHeader=false] true-excel表头需要和headerMap一致 false-表头允许多一些无用的列(不会取值)
 */

/**
 * 负责解析excel,返回有数据的行号:行数据的映射
 * @param {stream} stream excel流
 * @param {Object} headNameMap 头部名称和字段对应关系
 * @param {parseExcelSuperOptions} [options] - 字段详情见 {@link parseExcelSuperOptions} 对象
 * @example const headNameMap = {
 * Id: 'ID',
 * name: '名称',
 * };
 * @example optons = {contentStartRowNo:3, sheet:2,strictHeader: false,headRowNo: 1 }
 */
async parseExcelSuper(stream, headMap, options) {
  const { service, ctx } = this;
  const isNullVal = ctx.helper.isNullVal;
  try {
    options = { headRowNo: 1, contentStartRowNo: 2, sheet: 1, strictHeader: false, ...(options || {}) };
    const { headRowNo, contentStartRowNo, sheet, strictHeader } = options;
    if (path.extname(stream.filename).slice(1).toLowerCase() !== 'xlsx') {
      throw new Error('仅支持上传后缀名为.xlsx的文件!');
    }

    const rawExcelData = await service.common.excel.parseExcel(stream, headMap, { colIndex: headRowNo, sheet, strictHeader });
    let sliceNum = 0;
    if (contentStartRowNo > 2) {
      sliceNum = contentStartRowNo - 2;
    }

    if (!Array.isArray(rawExcelData)) throw new Error('文件内容为空或无可用数据,请先下载模板,按模板填写后上传!');
    if (rawExcelData.length <= 0) throw new Error('文件内容为空或无可用数据,请先下载模板,按模板填写后上传!');
    const newDatas = rawExcelData.slice(sliceNum);
    if (newDatas.filter(Boolean).length <= 0) throw new Error('文件内容为空或无可用数据,请先下载模板,按模板填写后上传!');

    // 取到非空行对应的行号与数据
    const fields = Object.keys(headMap);
    const rowNoDataMap = newDatas.reduce((pre, cur, index) => {
      if (cur) {
        const rowIndex = index + contentStartRowNo; // 内容开始的excel行号
        if (fields.some(k => !isNullVal(cur[k]))) {
          const curItem = fields.reduce((p, c) => {
            p[c] = isNullVal(cur[c]) ? null : cur[c];
            return p;
          }, {});
          pre[rowIndex] = curItem;
        }
      }
      return pre;
    }, {});

    return {
      rawExcelData,
      rowNoDataMap
    };
  } catch (error) {
    throw error;
  }
}

6. 上传并解析、校验 Excel 数据文件

数据的批量处理对在中后台管理系统里面是极其常见的功能,可以提高数据录入的效率,但是数据的 有效性校验确实是一个繁琐而复杂的过程

excel内数据有行有列,一般第一行为表头,第二行开始 为数据,也有可能 第二行为当前列的填写说明,从 第三 行开始才是真实数据,具体看你或者你的需求场景如何设计,但不管什么样子,excel里面肯定会包含表头和数据

一般的excel数据校验都是在一个for循环内循环每一行数据,然后依次校验这行的每一个字段 (列),如果字段之间有依赖关系,那么代码写起来 就非常的乱,逻辑一多可读性变差,还很更容易出bug,简直就真一大坨(s),不管 谁遇到都会脱口而出WC,谁TM写的代码,结果一看记录,竟然是自己3年前写的......

经过长期实践,我总结出一套数据处理的模式,解决了上述存在的每一个问题,每次遇到批量新增的需求都能 快乐编码,准时下班

- 6.1 上传并解析

无论你用何种方式,首先需要获得上传的excel文件(仅支持 .xlsx),并交给exceljs,然后用第5步的方式解析得到行号与数据映射的

得到的数据示例:

js
{
  rowNoDataMap: {
    2: {
      name:'张三',
      age: 10,
      address: '云南'
    },
    3: {
      name:'李四',
      age: 50,
      address: '广西'
    }
  }
}

- 6.2 封装数据校验BaseClass

校验 baseClass 的目的是为了为提取校验过程中的一些公共且通用的逻辑

js
'use strict';

const {Service} = require('egg');

/**
 * 批量导入基础服务类
 * 提供批量导入功能的基础校验、错误处理和消息生成方法
 * @extends BaseService
 */
class BatchImportBaseService extends Service {
    /**
     * 获取操作类型枚举
     * @returns {{ADD: string, EDIT: string}} 操作类型对象,包含 ADD 和 EDIT 两种类型
     */
    get OperationType() {
        return {
            ADD: '新增',
            EDIT: '编辑'
        };
    }

    /**
     * 失败类型与固定消息的映射关系
     * @type {Object}
     * @property {string} REQUIRED - 未填写数据
     * @property {string} RULE_ERR - 不符合填写规则
     * @property {string} VALUE_ERR - 填写的值错误
     * @property {string} REPEAT - 值有重复
     * @property {string} DATE_ERR - 日期格式错误
     * @property {string} OVERFLOW - 超出限制范围
     */
    FailTypeMaps = {
        REQUIRED: '未填写数据',
        RULE_ERR: '不符合填写规则或格式错误',
        VALUE_ERR: '填写的值错误',
        REPEAT: '值有重复',
        DATE_ERR: '不是正确的日期',
        OVERFLOW: '超出限制范围'
    };


    /**
     * 根据名称从对象中查找对应的键值
     * @param {Object} source - 源对象
     * @param {string} name - 要查找的值,如 "不缓存"
     * @returns {string|undefined} 对应的键值,如 "0",未找到则返回 undefined
     * @example
     * // 从示例 {0: "缓存ID", 1: "缓存名称", 2: "不缓存"} 的结构中找出"不缓存"对应的值2
     * this.getValueByName({0: "缓存ID", 1: "缓存名称", 2: "不缓存"}, "不缓存") // 返回 "2"
     */
    getValueByName(source, name) {
        for (const val in source) {
            if (Object.hasOwnProperty.call(source, val)) {
                if (name === source[val]) return val;
            }
        }
    }

    /**
     * 设置校验结果
     * @param {boolean} pass - 是否校验通过,true 表示通过,false 表示失败
     * @param {Object|null} newObj - 校验通过后返回的新对象,默认为 null,当pass为true时,newObj 参数必须为对象
     * @returns {Object} 校验结果对象,包含 pass 和 newObj 属性
     */
    setValidateRes(pass, newObj = null) {
        // 当pass为true时,newObj 参数必须为对象
        if (pass === true) {
            if (!newObj || (newObj && typeof newObj !== 'object')) {
                throw new TypeError('BatchImportBaseService.setValidateRes: 参数错误,当pass为true时,newObj 参数必须为对象!');
            }
        }
        return {
            pass,
            newObj
        };
    }


    /**
     * 生成存储校验结果的上下文对象
     * @param {Object} headMap - 表头映射对象,键为字段名,值为字段标签
     * @returns {Object} 校验结果上下文对象,包含每个字段的校验失败信息结构
     * @example
     * // headMap: {name: "名称", age: "年龄"}
     * // 返回: {name: {label: "名称", failRes: {...}}, age: {label: "年龄", failRes: {...}}}
     */
    generateFialResContext(headMap) {
        const target = {};
        Object.keys(headMap).forEach(head => {
            target[head] = {
                label: headMap[head],
                failRes: Object.keys(this.FailTypeMaps).reduce((pre, key) => {
                    pre[key] = {
                        key,
                        fixedMsg: this.FailTypeMaps[key],
                        rowNos: [], // 失败行号
                        valueAry: [], // 失败的值
                        tips: [] // 失败提示
                    };
                    return pre;
                }, {})
            };
        });
        return target;
    }

    /**
     * 设置必填字段错误信息
     * @param {Object} fieldContext - 字段失败结果配置对象
     * @param {number} rowNo - 错误行号
     * @param {string|Array<string>} tips - 错误提示信息,可以是单个字符串或字符串数组
     */
    setRequired(fieldContext, rowNo, tips) {
        if (!fieldContext?.failRes) {
            throw new Error('BatchImportBaseService.setRequired: fieldContext 参数错误');
        }
        const { REQUIRED } = fieldContext.failRes;
        REQUIRED.rowNos.push(rowNo);
        if (Array.isArray(tips)) {
            REQUIRED.tips.push(...tips);
        } else {
            !this.ctx.helper.isNullVal(tips) && REQUIRED.tips.push(tips);
        }
        return this.setValidateRes(false);
    }

    /**
     * 设置规则校验错误信息
     * @param {Object} fieldContext - 字段失败结果配置对象
     * @param {number} rowNo - 错误行号
     * @param {*|Array} errValues - 错误值,可以是单个值或数组
     * @param {string|Array<string>} tips - 错误提示信息,可以是单个字符串或字符串数组
     */
    setRuleErr(fieldContext, rowNo, errValues, tips) {
        if (!fieldContext?.failRes) {
            throw new Error('BatchImportBaseService.setRuleErr: fieldContext 参数错误');
        }
        const { RULE_ERR } = fieldContext.failRes;
        RULE_ERR.rowNos.push(rowNo);
        if (Array.isArray(tips)) {
            RULE_ERR.tips.push(...tips);
        } else {
            !this.ctx.helper.isNullVal(tips) && RULE_ERR.tips.push(tips);
        }
        if (Array.isArray(errValues)) {
            RULE_ERR.valueAry.push(...errValues);
        } else {
            !this.ctx.helper.isNullVal(errValues) && RULE_ERR.valueAry.push(errValues);
        }
        return this.setValidateRes(false);
    }

    /**
     * 设置值错误信息
     * @param {Object} fieldContext - 字段失败结果配置对象
     * @param {number} rowNo - 错误行号
     * @param {*|Array} errValues - 错误值,可以是单个值或数组
     * @param failRes
     * @param {string|Array<string>} tips - 错误提示信息,可以是单个字符串或字符串数组
     */
    setValueErr(fieldContext, rowNo, errValues, tips) {
        if (!fieldContext?.failRes) {
            throw new Error('BatchImportBaseService.setValueErr: fieldContext 参数错误');
        }
        const { VALUE_ERR } = fieldContext.failRes;
        VALUE_ERR.rowNos.push(rowNo);

        if (Array.isArray(tips)) {
            VALUE_ERR.tips.push(...tips);
        } else {
            !this.ctx.helper.isNullVal(tips) && VALUE_ERR.tips.push(tips);
        }

        if (Array.isArray(errValues)) {
            VALUE_ERR.valueAry.push(...errValues);
        } else {
            !this.ctx.helper.isNullVal(errValues) && VALUE_ERR.valueAry.push(errValues);
        }
        return this.setValidateRes(false);
    }
    /**
     * 设置重复值错误信息
     * @param {Object} fieldContext - 字段失败结果配置对象
     * @param {number} rowNo - 错误行号
     * @param {*|Array} repeatVals - 重复值,可以是单个值或数组
     * @param failRes
     * @param {string|Array<string>} tips - 错误提示信息,可以是单个字符串或字符串数组
     */
    setRepeatErr(fieldContext, rowNo, repeatVals, tips) {
        if (!fieldContext?.failRes) {
            throw new Error('BatchImportBaseService.setRepeatErr: fieldContext 参数错误');
        }
        const { REPEAT } = fieldContext.failRes;

        REPEAT.rowNos.push(rowNo);
        if (Array.isArray(tips)) {
            REPEAT.tips.push(...tips);
        } else {
            !this.ctx.helper.isNullVal(tips) && REPEAT.tips.push(tips);
        }

        if (Array.isArray(repeatVals)) {
            REPEAT.valueAry.push(...repeatVals);
        } else {
            !this.ctx.helper.isNullVal(repeatVals) && REPEAT.valueAry.push(repeatVals);
        }
        return this.setValidateRes(false);
    }
    /**
     * 设置日期格式错误信息
     * @param {Object} fieldContext - 字段失败结果配置对象
     * @param {number} rowNo - 错误行号
     * @param {*|Array} errValues - 错误的日期值,可以是单个值或数组
     * @param failRes
     * @param {string|Array<string>} tips - 错误提示信息,可以是单个字符串或字符串数组
     */
    setDateErr(fieldContext, rowNo, errValues, tips) {
        if (!fieldContext?.failRes) {
            throw new Error('BatchImportBaseService.setDateErr: fieldContext 参数错误');
        }
        const { DATE_ERR } = fieldContext.failRes;
        DATE_ERR.rowNos.push(rowNo);
        if (Array.isArray(tips)) {
            DATE_ERR.tips.push(...tips);
        } else {
            !this.ctx.helper.isNullVal(tips) && DATE_ERR.tips.push(tips);
        }
        if (Array.isArray(errValues)) {
            DATE_ERR.valueAry.push(...errValues);
        } else {
            !this.ctx.helper.isNullVal(errValues) && DATE_ERR.valueAry.push(errValues);
        }
        return this.setValidateRes(false);
    }
    /**
     * 设置超出限制范围错误信息
     * @param {Object} fieldContext - 字段失败结果配置对象
     * @param {number} rowNo - 错误行号
     * @param {*|Array} errValues - 超出范围的值,可以是单个值或数组
     * @param {string|Array<string>} tips - 错误提示信息,可以是单个字符串或字符串数组
     */
    setOverflow(fieldContext, rowNo, errValues, tips) {
        if (!fieldContext?.failRes) {
            throw new Error('BatchImportBaseService.setOverflow: fieldContext 参数错误');
        }
        const { OVERFLOW } = fieldContext.failRes;
        OVERFLOW.rowNos.push(rowNo);
        if (Array.isArray(tips)) {
            OVERFLOW.tips.push(...tips);
        } else {
            !this.ctx.helper.isNullVal(tips) && OVERFLOW.tips.push(tips);
        }
        if (Array.isArray(errValues)) {
            OVERFLOW.valueAry.push(...errValues);
        } else {
            !this.ctx.helper.isNullVal(errValues) && OVERFLOW.valueAry.push(errValues);
        }
        return this.setValidateRes(false);
    }


    /**
     * 生成某个字段的错误信息 HTML
     * @private
     * @param {Object} failResContext - 校验失败结果上下文对象
     * @returns {string} 格式化的错误信息 HTML 字符串
     */
    _generateFieldErrMsg(failResContext) {
        const msg = [];
        Object.values(failResContext).forEach(({ label, failRes }) => {
            const errData = Object.values(failRes).filter(item => item.rowNos.length);
            if (errData.length) {
                msg.push(`<div>✘【${label}】列:</div>`);
                errData.forEach(({ fixedMsg, rowNos, valueAry, tips }) => {
                    const rowNosStr = [...new Set(rowNos)].join('、');
                    const errValue = valueAry?.length ? [...new Set(valueAry)].join('、') : null;
                    const tipsVal = tips?.length ? [...new Set(tips)] : null;
                    msg.push(`<p> ● ${fixedMsg},${errValue ? `错误值有[${errValue}],` : ''}涉及行号[${rowNosStr}]</p>`);
                    if (tipsVal) {
                        msg.push(`<p>提示:${tipsVal.map((item, idx) => `<div style="padding-left: 2em">${idx + 1}、${item}</div>`).join('')}</p>`);
                    }
                });
            }
        });
        return msg.join('');
    }
    /**
     * 生成 Excel 校验错误信息
     * @param {Object} failResContext - 校验失败结果上下文对象
     * @param {Function} beforeHandler - 前置处理函数,返回值必须为字符串,默认返回空字符串
     * @returns {string} 完整的校验错误信息 HTML 字符串
     * @throws {Error} 当 beforeHandler 返回值不是字符串时抛出错误
     */
    generateErrMsg(failResContext, beforeHandler = () => '') {
        let msgRes = '<p>文件内容校验失败, 详情如下:</p>';
        if (beforeHandler instanceof Function) {
            const beforeHandlerRes = beforeHandler();
            if (typeof beforeHandlerRes !== 'string') {
                throw new Error('generateErrMsg 中第二个参数 beforeHandler 返回值必须为字符串');
            }
            beforeHandlerRes && (msgRes += beforeHandlerRes);
        }
        msgRes += this._generateFieldErrMsg(failResContext);
        return msgRes;
    }

}

module.exports = BatchImportBaseService;

- 6.3 写 校验逻辑

校验逻辑需要继承BaseClass 需要的包exceljsmoment

一些辅助函数

js
function isNullVal(val) {
  return val === null || val === undefined || val === '' || String(val).trim() ===  ''
}

真正的校验代码

js
'use strict';
const BaseService = require('../BaseClass/batch_import_base_service');
const path = require('path');
const ExcelJS = require('exceljs');
const moment = require('moment');

class BatchService extends BaseService {
    // 批量上传模板字段
    get BatchUploadCommonHeadMap() {
        return {
            name: '名称',
            type: '类型',
            indexType: '指标',
            min: '最小值',
            timeRange: '日期间隔',

        };
    }

    // 固定枚举值
    get fixedEnums() {
        return {
            // 指标
            IndexType: { 1: '现金', 2: '流水' },
            // 类型
            Type: { 1: '收入', 2: '支出' },
        };
    }

    // 批量新增
    async batchInsert(list) {
        // TODO: ... 批量新增逻辑
    }

    /**
     * 批量创建或更新数据。
     *
     */
    async multipleCreatAndUpdate(validateRes, serviceName) {
        try {
            // 调接口提交数据
            const { newRowNoDataMap } = validateRes;

            // 组装请求参数
            const list = Object.entries(newRowNoDataMap).reduce((pre, [rowIndex, requestData]) => {
                pre.push({
                    ...requestData,
                    index: +rowIndex // 行号
                });
                return pre;
            }, []);
            const res = await serviceName(list); // 调用接口
            // TODO: 处理批量新增或编辑 接口响应结果
        } catch (error) {
            throw error;
        }
    }

    // 本地校验批量上传excel数据
    async validateBatchConfigExcelData(rowNoDataMap, headMap) {
        const { service, ctx } = this;
        const uploadRowLimit =  1000; // excel内数据上限(可做成动态配置)
        try {
            const excelDataLen = Object.keys(rowNoDataMap).length;
            if (excelDataLen > uploadRowLimit) throw new Error(`批量导入的上限为${uploadRowLimit}条, 但文件内实际存在${excelDataLen}条数据, 请检查!`);

            // TODO:若有动态枚举 ,可以在这实现查询接口获取

            // 存储所有字段的失败结果
            const failResContext = this.generateFialResContext(headMap);

            let validatePass = true; // 用于控制校验是否全部通过
            const newRowNoDataMap = {};
            const uniSet = new Set();
            const repeatRowNos = [];

            for (const [rowNum, obj] of Object.entries(rowNoDataMap)) {
                // 先做excel去重校验
                const uniqueKey = this.getUnique(obj);
                if (uniSet.has(uniqueKey)) {
                    repeatRowNos.push(rowNum);
                    validatePass = false;
                    continue;
                } else {
                    uniSet.add(uniqueKey);
                }
                const allValidateRules = [
                    this.ValidateName(rowNum, obj, failResContext.name),
                    // 类型
                    this.validateType(rowNum, obj, failResContext.type),
                    // 指标
                    this.validateGradientIndexType(rowNum, obj, failResContext.indexType),
                    // 最小值
                    this.validateMin(rowNum, obj, failResContext.min),
                    // 日期间隔
                    this.validateTimeRange(rowNum, obj, failResContext.timeRange)
                ];

                // 校验
                const res = await Promise.all(allValidateRules);
                // 当前行数据是否校验通过
                const noPass = res.some(item => !item.pass);
                if (noPass) {
                    // 只要有一行未校验通过, 则表示excel校验未通过, 不会调新增接口写数据
                    validatePass = false;
                } else {

                    // 这行数据校验通过, 对翻译后的数据进行收集,组装成后端接口需要的结构
                    const newObjData = res.reduce((pre, cur) => {
                        pre = { ...pre, ...cur.newObj };
                        return pre;
                    }, {});

                    newRowNoDataMap[rowNum] = newObjData;
                }
            }

            if (!validatePass) {
                const errMsg = this.generateErrMsg(failResContext, () => {
                    if (repeatRowNos.length) {
                        return `<div>✘ 文件内已存在一模一样的数据,涉及行号为[${repeatRowNos.join('、')}]</div>`;
                    }
                    return '';
                });
                throw new Error(errMsg);
            }
            return { newRowNoDataMap };

        } catch (error) {
            throw error;
        }
    }

    // 获取行数据唯一key, 用于去重
    getUnique(obj) {
        const { ctx } = this;
        const isNullVal = ctx.helper.isNullVal;
        const uniAry = [];
        Object.keys(obj).forEach(k => {
            if (isNullVal(obj[k])) {
                uniAry.push(k);
            } else {
                const strAry = String(obj[k]).split(',');
                strAry.sort((a, b) => a.localeCompare(b));
                uniAry.push(k, ...strAry);
            }
        });
        return uniAry.join('_');
    }


    // 校验Name
    validateName(rowNum, obj, failContext) {
        const { ctx } = this;
        const isNullVal = ctx.helper.isNullVal;
        const { name } = obj;
        const tips = [
            `可选值有 ${Object.values(ReturnTypeMap).join('、')}`
        ];
        // 必填校验
        if (isNullVal(name)) return this.setRequired(failContext, rowNum);
        // 有效性校验
        if(String(name).length > 10) return this.setRuleErr(failContext, rowNum, name, '名称长度不能超过10个字符');
        if(String(name).length < 2) return this.setRuleErr(failContext, rowNum, name, '名称长度不能小于2个字符');
        return this.setValidateRes(true, { name: String(name) });
    }

    // 校验生效时间
    validateTimeRange(rowNum, obj, failContext) {
        const { ctx } = this;
        const { isNullVal } = ctx.helper;
        const { timeRange } = obj;
        // 必填校验
        if (isNullVal(timeRange)) return this.setRequired(failContext, rowNum);
        // 格式校验
        if (!/^\d{6}-\d{6}$/.test(timeRange)) return this.setRuleErr(failContext, rowNum, timeRange, '支持年月区间,日期时间用中横线分隔,示例:YYYYMM-YYYYMM');
        const [startStr, endStr] = String(timeRange).split('-');
        if (Number(startStr) - Number(endStr) > 0) {
            return this.setDateErr(failContext, rowNum, timeRange, '起始年月不能大于结束年月');
        }
        // 检查startStr和endStr是否为有效日期
        const startMoment = moment(`${startStr}01`, 'YYYYMMDD', true);
        const endMoment = moment(`${endStr}01`, 'YYYYMMDD', true);
        if (!startMoment.isValid() || !endMoment.isValid()) {
            return this.setRuleErr(failContext, rowNum, timeRange, '起止年月不是有效日期');
        }
        // startTime: 当月1日00:00:00
        const startTime = startMoment.startOf('day').valueOf();
        // endTime: 当月最后一天23:59:59
        const endTime = endMoment.endOf('month').valueOf();
        return this.setValidateRes(true, {
            returnBeginTime: parseInt(startTime / 1000, 10),
            returnEndTime: parseInt(endTime / 1000, 10)
        });


    }

    // TODO:其余字段的校验同上
}

module.exports = BatchService;

- 6.4 使用校验逻辑

EggController方法

js
async batchAdd() {
    const { ctx, service } = this;
    let stream;
    try {
        /* 数据解析节点 */
        stream = await ctx.getFileStream();

        // 获取表头映射关系
        const headMap = {
            ...service.BatchUploadCommonHeadMap
        };
        // TODO:我这里是从第三行开始才是内容,具体根据实际来
        const { rowNoDataMap } = await service.common.excel.parseExcelSuper(stream, headMap, { contentStartRowNo: 3, strictHeader: true });
        /* 数据校验 */
        const validateRes = await service.validateBatchConfigExcelData(rowNoDataMap, headMap);
        const serviceName = service.batchInsert.bind(service);
        const res = await service.multipleCreatAndUpdate(validateRes, serviceName);
        this.resData(res) // 响应前端的封装方法(内带错误处理)
    } catch (error) {
        this.fail(error); // 响应前端错误的方法封装
    } finally {
        stream && await sendToWormhole(stream); // 用于最终消耗无用的stream,避免浏览器卡死
    }
}

三. 处理 TXT 文本

1、上传解析txt,得到内容

同文件上传,获取内容可以通过插件concat-stream

js
const concat = require('concat-stream');
  /**
* 
* @param {File} stream 文件流对象
* @param {*} encoding 要返回的编码   encoding取值如下   
*           string - get a string (默认)   
*           buffer - get back a Buffer   
*           array - get an array of byte integers   
*           uint8array, u8, uint8 - get back a Uint8Array   
*           object, get back an array of Objects
*/
  async getStreamData(stream, encoding = "string") {
      return new Promise((resolve, reject) => {
          stream.pipe(concat({ encoding }, data => {
              resolve(data)
          }))
          stream.on('error', err => {
              reject(err)
          })
      })
  }

获取到的内容是一个String,根据特定的分隔符解析就能得到内容

2、下载txt

js
const moment = require('moment');
const urlencode = require('urlencode');

async downloadTxtTpl() {
    try {
      let filePath = path.resolve('.').replace(/\\/g, '/') + '/app/public/txt/demo.txt';
      let txtContent = fs.createReadStream(filePath);
      let filename = urlencode('xxx文本文件-' + moment(Date.now()).format('YYYYMMDD-HHmmss') + '.txt');
      this.ctx.set('content-disposition', `attachment; filename* = UTF-8''${filename}`);
      this.ctx.set('content-type', 'application/octet-stream');
      this.ctx.status = 200;
      this.ctx.body = txtContent;
    } catch (e) {
      console.log(e);
      this.ctx.logger.error(e)
      this.fail('系统错误')
    }
}

3、nodeJS将buffer转换成为stream

js
const stream = require('stream);
let bufferStream = new stream.PassThrough();
bufferStream.end(new Buffer(['hello word']))
// bufferStream就是buffer转换成的stream,可以直接使用,例如
bufferStream.pipe(process.stdout)

4、egg解析csv文件

这里是直接解析的, github上也有一些线程的库,比如exceljs等

js
    /**
     * 解析简单的csv文件
     * @param {Stream|String} stream csv流
     * @param {Object} [options] 配置对象
     * @param {String} [options.delimiter] 每行内数据分隔符,默认英文逗号",""
     * @param {Number} [options.columnMaxCount] 列数
     * @param {Array} [options.headerArr] 若有表头,则表头的列字段
     * @param {Boolean} [options.trim] 默认值false,每个字段是否去除两边空格
     * @returns {Object} {total, effectCsvArrData, invalidData}
     */
    async parseCsvFile(stream, { delimiter = ",", columnMaxCount, headerArr, trim = false }) {
        let csvStr = stream;
        if (typeof stream !== 'string') {
            csvStr = await this.service.common.excel.getStreamData(stream)
        }
        csvStr = csvStr.split(/\r?\n/)

        let effectCsvArrData = [] // 最终有效的数据
        let invalidData = [] // 每行字段不够的数据(无效)
        let total = 0 // 总行数(不包括空行)

        // 对csv文件进行有效性过滤
        csvStr.forEach(item => {
            total++
            let itemArr = item.split(delimiter)
            // 若规定了一行有几个字段的情况
            if (columnMaxCount && itemArr.length < columnMaxCount) {
                invalidData.push(item)
            } else {
                itemArr = itemArr.map(filed => {
                    if (filed) {
                        return filed.trim() || ""
                    } else {
                        return ""
                    }
                })
                effectCsvArrData.push(itemArr)
            }
        })
        if (Array.isArray(headerArr)) {
            effectCsvArrData = effectCsvArrData.map(item => {
                return item.reduce((pre, cur, index) => {
                    let columnName = headerArr[index] || 'column-' + index
                    pre[columnName] = cur
                    return pre
                }, {})
            })
        }

        return {
            total,
            effectCsvArrData,
            invalidData
        }

    }

5、stream转化成为string

js
const concat = require('concat-stream');

/**
 * 
 * @param {File} stream 文件流对象
 * @param {*} encoding 要返回的编码   encoding取值如下   
 *           string - get a string (默认)   
 *           buffer - get back a Buffer   
 *           array - get an array of byte integers   
 *           uint8array, u8, uint8 - get back a Uint8Array   
 *           object, get back an array of Objects
 */
async getStreamData(stream, encoding = "string") {
    return new Promise((resolve, reject) => {
        stream.pipe(concat({ encoding }, data => {
            resolve(data)
        }))
        stream.on('error', err => {
            reject(err)
        })
    })
}

四、egg中同时使用mysql和mongodb遇到的问题

问题描述: 项目同时使用了mysql数据库和mongoDB数据库,mysql用的egg-sequelize来操纵的数据库,导致启动时候一直报错

查文档发现是由于 egg-sequelize 导致的,而部分业务接口之前配置了使用 MySQL 数据库。 egg-sequelize 和 egg-mongoose 默认都是加载 app/model 下的文件,所以导致加载失败

解决方案: 看是否可以修改插件的加载目录。egg-mongoose 是不能配置的,源码里面写死了指定的 model 路径, egg-sequelize 是可以的。

js
  // 配置mysql
  // delegate 表示将 orm 挂载的位置,之前默认是 model,修改完之后不要忘记业务代码中的已有代码(如this.ctx.model.xxx => this.ctx.myModel.xxx)。
  sequelize: {
    baseDir:"my_model", // 修改mysql的model目录,会与mongooDB冲突
    delegate: "myModel", // 使用this.ctx.myModel.xxx来回去mysql模型
    dialect: "mysql", // support: mysql, mariadb, postgres, mssql
    database: "app_test",
    host: "localhost",
    port: 3306,
    username: "root",
    password: '123456',
  },

五. egg封装基类

base Controller

js
"use strict";

const Controller = require("egg").Controller;

class BaseController extends Controller {
  /**
   * 成功响应
   * @param {any}} data 响应数据
   * @param {String} msg 成功提示
   */
  success(data, msg = "操作成功") {
    const { config } = this.app;
    this.ctx.body = {
      code: config.code.SUCCESS_CODE,
      data,
      msg
    };
  }

  /**
   * 失败响应
   * @param {String} msg 失败原因
   * @param {Number} code 失败状态码
   */
  fail(msg = "操作失败", code) {
    const { config } = this.app;
    if (msg instanceof Error) {
      msg = msg.message;
    }
    this.ctx.body = {
      code: code || config.code.ERROR_CODE,
      msg
    };
  }
  /**
   * 返回客户端无分页数据
   * @param {Object} data 返回客户端的数据
   */
  resNoPage(data, msg) {
    let { app } = this;
    if (data && data instanceof Error) {
      this.fail(data.message || app.config.tip.SERVICE_ERROR);
      return;
    } else {
      this.success(data, msg);
    }
  }
  /**
   * 返回客户端错误信息
   * @param {Object} res 数据库得来的信息(可能是错误也可能是数据)
   * @param {String} msg 当res不是错误信息时候,给客户端的错误提示信息
   */
  resSalfError(res, msg) {
    const { config } = this.app;
    if (res instanceof Error) {
      console.log("++++++++++++++++++++++++", res);
      let errMsg = res.message;
      this.fail(errMsg);
      return;
    }
    this.fail(msg || config.tip.SERVICE_ERROR);
  }

  /**
   * 返回分页数据
   * @param {Object} data 返回分页数据
   * @param {Object} page 默认的分页
   * @param {*} other 其他字段
   */
  resPageData(data, page, other) {
    const { app } = this;
    const { rows, count } = data;
    const { pageSize, currentPage } = page;

    const resData = {
      records: rows,
      total: count,
      totalPage: Math.ceil(count / pageSize),
      pageSize: pageSize,
      currentPage: currentPage
    };

    if (other) {
      resData.other = other;
    }
    if (data && data instanceof Error) {
      this.fail(app.config.tip.SERVICE_ERROR);
      return;
    } else {
      this.success(resData);
    }
  }

  /**
   * 校验参数,失败直接响应,通过返回true
   * @param {Object} rule 校验规则对象
   * @param {Object} params 客户端传过来的参数
   * @returns Boolean
   */
  validate(rule, params) {
    try {
      this.ctx.validate(rule, params);
      return true;
    } catch (error) {
      let msg = this.ctx.helper.tip(error.errors[0], "PARAMS_ERROR");
      this.fail(msg);
      return false;
    }
  }

  /**
   * 为where条件加入模糊查询
   * @param {Array} fields 要模糊查询的字段名组成的数组
   * @param {Object} params 前端纯过来的有效参数
   * @param {Boolean} throuthZero value为0时候是否添加模糊查询,默认不添加(用于判断类型为int的字段)
   * @returns {Object} where
   */
  addSequelizeOpLike(fields = [], params, throuthZero = false) {
    const { Op } = this.app.Sequelize;
    let obj = {};
    fields.forEach(item => {
      if (params[item] || (throuthZero && params[item] === 0)) {
        obj[item] = { [Op.like]: `%${params[item]}%` };
      }
    });
    return obj;
  }

  async hasDate(filter, model) {
    try {
      let result = await model.findOne(filter);
      if (result instanceof Error) {
        this.fail(this.config.tip.SERVICE_ERROR);
        return true;
      }
      if (!result) {
        return false;
      }
      if (result) {
        return result;
      }
    } catch (error) {
      this.fail(this.config.tip.SERVICE_ERROR);
      return true;
    }
  }


}

module.exports = Base_controllerController;

base Service

js
"use strict";

const Service = require("egg").Service;

class BaseService extends Service {
  /**
   * 封装的Mysql处理数据库统一处理请求以及错误
   * @param  {...any} args (method params)
   * @returns
   */
  async safeSevice(...args) {
    console.log("============MySQL============\n", args);
    let [method, ...param] = args;
    console.log(
      "----------Mysql传入参数--------\n",
      JSON.stringify(param, null, 2)
    );
    try {
      let result = await this.myModel[method](...param);
      // console.log("----------Mysql输出结果--------\n", result);
      return result;
    } catch (error) {
      this.ctx.logger.error(error);
      console.log("-------Mysql错误-------\n", error);
      if (this.app.config.env === "prod") {
        throw new Error("服务器错误");
      }
      throw error;
    }
  }

  /**
   * 封装的MongoDB查询数据库同统一处理错误
   * @param  {参数1} 数据库方法
   * @param  {参数2...} 传参
   * @returns
   */
  async salfeMongDB(...args) {
    console.log("===========MongoDb=============\n", args);
    let [method, ...param] = args;
    console.log(
      "----------MongoDb传入参数--------\n",
      JSON.stringify(param, null, 2)
    );
    // console.log("------------", ...param);
    try {
      return await this.MongModel[method](...param);
    } catch (error) {
      console.log("-------MongDb错误-------\n", error);
      throw new Error("服务器错误");
    }
  }

  /**
   * 插入数据Mysql
   * @param {Object} params 要添加的数据
   * @returns
   */
  async cerate(params) {
    return this.safeSevice("create", params);
  }

  /**
   * 根据条件查询某个数据Mysql
   * @param {Object} params 查询条件
   * @returns
   */
  async findOne(params) {
    return this.safeSevice("findOne", params);
  }

  /**
   * cha查询mysql所有数据
   * @param {Object} params 查询条件
   * @returns
   */
  async findAll(params) {
    return this.safeSevice("findAll", params);
  }

  /**
   * 查询分页Mysql
   * @param {Object} params 查询条件
   */
  async findAndCountAll(params) {
    return this.safeSevice("findAndCountAll", params);
  }

  /**
   * 修改Mysql数据
   * @param {Array} params 要更新的数据
   * @param {Object} filter 查询条件
   * @returns
   */
  async update(params, filter) {
    return this.safeSevice("update", params, filter);
  }

  /**
   * 根据条件删除Mysql数据
   * @param {Object} params 删除的查询条件
   * @returns
   */
  async destroy(params) {
    return this.safeSevice("destroy", params);
  }

  /**
   * Mysql批量更新
   * 文档: https://itbilu.com/nodejs/npm/V1PExztfb.html#api-bulkCreate
   * @param {Object} params 要更新的数据
   * @param {Object} options 配置
   * @returns
   */
  async bulkCreate(params, options) {
    return this.safeSevice("bulkCreate", params, options);
  }

  /**
   * 根据主键查找Mysql
   * @param {String} pk 主键值
   * @returns
   */
  async findByPk(pk) {
    return this.safeSevice("findByPk", pk);
  }

  /**
   * 添加mongoDb数据
   * @param {Object} params 要添加的数据
   * @returns
   */
  async createMongodb(params) {
    return this.salfeMongDB("create", params);
  }

  /**
   * 查找mongodb里面的某个内容 findOne
   * @param {Object} filter 查询条件
   * @returns
   */
  async findMongdbOne(filter) {
    return this.salfeMongDB("findOne", filter);
  }

  /**
   * 编辑mongoDb里的文章信息 update
   * @param {Object} filter 查询条件{_id:"q232323x"}
   * @param {Object} params 编辑的内容 {xxx: 'sfddssd'}
   */
  async editMongodb(filter, params) {
    return this.salfeMongDB("update", filter, params);
  }

  /**
   * 根据条件删除mongodb数据
   * @param {Object} params 查询参数
   * @returns
   */
  async delMongodb(params) {
    return this.salfeMongDB("remove", params);
  }
}

module.exports = Base_serviceService;

六. 封装一个导出Excel的service

js
const Service = require('egg').Service;
const Excel = require('exceljs');
const concat = require('concat-stream');

function getcellVal(obj) {
    if (Object.prototype.toString.call(obj) === '[object Object]' && Array.isArray(obj.richText)) {
        return obj.richText.reduce((pre, cur) => {
            pre += cur.text;
            return pre;
        }, '').trim().replace('/\\/g', '\\\\');
    } else if (Object.prototype.toString.call(obj) === '[object Object]' && 'text' in obj && 'hyperlink' in obj) {
        return obj.text.trim().replace('/\\/g', '\\\\');
    } else if (typeof obj === 'string' && obj) {
        return obj.trim().replace('/\\/g', '\\\\');
    } else {
        return obj;
    }
}

class ExcelService extends Service {
    /**
     *负责解析excel,以头部为键,生成数组
     * @param {stream} stream excel流
     * @param {Object} headNameMap 头部名称和键的索引
     * 例如:
     *  const headNameMap = {
        id: 'ID',
        name: '姓名',
      };
     */
    async parseExcel(stream, headNameMap) {
        const workbook = new Excel.Workbook();
        await workbook.xlsx.read(stream);
        const worksheet = workbook.getWorksheet(1);
        const data = [];
        const columnCount = worksheet.columnCount;
        for (let c = 1; c <= columnCount; c++) {
            const col = worksheet.getColumn(c);
            Object.entries(headNameMap).forEach(([key, value]) => {
                let colHeader = getcellVal(col.values[1]);
                if (!colHeader && typeof colHeader !== 'string') return;
                if (colHeader === value) {
                    const colValues = col.values.slice(2);
                    colValues.forEach((val, index) => {
                        let item = {};
                        if (data[index]) {
                            item = data[index];
                        } else {
                            data[index] = item;
                        }
                        item[key] = getcellVal(val);
                    });
                }
            });
        }
        return data;
    }


    /**
     * 获取excel workbook对象,具体用法见controller/sys_manage/log_manage.js > function export
     * exceljs文档:https://github.com/exceljs/exceljs/blob/master/README_zh.md
     */
    getWorkbook({ sheetName = '', columns = [], rows = [] }) {
        let workbook = new Excel.Workbook();
        workbook.creator = 'createUser';
        workbook.created = new Date(Date.now());
        workbook.modified = new Date(Date.now());

        let sheet = workbook.addWorksheet(sheetName);

        sheet.columns = columns.map(item => {
            return {
                ...item,
                style: item.style || {
                    alignment: {
                        vertical: 'middle',
                        horizontal: 'center'
                    }
                }
            };
        });

        rows.forEach((row, index) => {
            let rowData = {
                ...row,
                index: index + 1
            };
            sheet.addRow(rowData);
        });

        return workbook;
    }


    /**
     * 用流的方式获取excel workbook对象,性能优于getWorkbook, 处理数据量较大excel导出更适合
     * exceljs文档:https://github.com/exceljs/exceljs/blob/master/README_zh.md#%E6%B5%81%E5%BC%8F-io
     */
    async getWorkBookStream({ sheetName = '', columns = [], rows = [] }) {
        console.time('组装Excel所用时间');
        let workbook = new Excel.stream.xlsx.WorkbookWriter({
            useStyles: true
        });//创建一个流式写入器
        workbook.creator = 'CreateUser';
        workbook.created = new Date(Date.now());
        workbook.modified = new Date(Date.now());

        let sheet = workbook.addWorksheet(sheetName, { views: [{ state: 'frozen', xSplit: 0, ySplit: 1 }] }); // 表头固定

        sheet.columns = columns.map(item => {
            return {
                ...item,
                style: item.style || {
                    alignment: {
                        vertical: 'middle',
                        horizontal: 'center'
                    }
                }
            };
        });

        rows.forEach((row, index) => {
            let rowData = {
                ...row,
                index: index + 1
            };
            let newRow = sheet.addRow(rowData, 'i');
            newRow.commit();
        });
        // 完成 workbook.
        await workbook.commit();
        // ... 流已被写入
        console.timeEnd('组装Excel所用时间');

        return workbook;
    }


    /**
  * 
  * @param {File} stream 文件流对象
  * @param {*} encoding 要返回的编码   encoding取值如下   
  *           string - get a string (默认)   
  *           buffer - get back a Buffer   
  *           array - get an array of byte integers   
  *           uint8array, u8, uint8 - get back a Uint8Array   
  *           object, get back an array of Objects
  */
    async getStreamData(stream, encoding = 'string') {
        return new Promise((resolve, reject) => {
            stream.pipe(concat({ encoding }, data => {
                resolve(data);
            }));
            stream.on('error', err => {
                reject(err);
            });
        });
    }
// 流式解析Excel数据
    async parseExcelStream(stream, headNameMap, option) {
        // sheel为第几个sheet
        let { sheet = 1 } = option || {};
        const workbook = new Excel.stream.xlsx.WorkbookReader(stream);
        let data = [];
        let headSource = Object.values(headNameMap);
        let start = 1;
        for await (const worksheetReader of workbook) {
            if (sheet !== start) {
                start++;
                continue;
            }
            let index = 0;
            let headKeys = [];
            for await (const row of worksheetReader) {
                let rowArr = row.values.slice(1);
                if (index === 0) {
                    rowArr.forEach(item => {
                        if (headSource.includes(item)) {
                            Object.entries(headNameMap).forEach(([key, h]) => {
                                if (getcellVal(item) === h) {
                                    headKeys.push(key);
                                }
                            });
                        } else {
                            headKeys.push(null);
                        }
                    });
                    index += 1;
                } else {
                    let itemObj = {};
                    headKeys.forEach((item, idx) => {
                        if (item !== null) {
                            const val = rowArr[idx];
                            itemObj[item] = val === undefined ? null : getcellVal(val);
                        }
                    });
                    data.push(itemObj);
                    index += 1;
                }
            }
            start++;
        }
        return data;
    }
    /**
     * 将excel解析得到的 45438.5 日期时间转化为 正确的时间格式, 例如: yyyy-mm-dd hh:mm:ss
     * @param {Number} excelDateNum excel解析得到的时间值, 一般为小数或整数 
     * @param {*} option 格式化配置 可选
     * @param {String} option.type 可选值'string', 'timeStamp' 默认 'string'
     * @param {String} option.format moment中的format所需参数, 默认YYYY-MM-DD HH:mm:ss type=== 'string'时生效
     * @param {Boolean} option.isMillisecond 时间戳是否以毫秒为单位, 默认false type === 'timeStamp'是生效
     * @returns 
     */
    excelDateToString(excelDateNum, option) {
        try {
            const { type = 'string', format = 'YYYY-MM-DD HH:mm:ss', isMillisecond = false } = option || {};
            if (!['string', 'timeStamp'].includes(type)) throw new Error('excelDateToString 参数 option.type错误, 仅支持 "string" 或 "timeStamp"');
            // Excel 中的基准日期是 1899 年 12 月 30 日(注意 Excel 认为 1900 年是闰年)  
            const excelEpoch = new Date(Date.UTC(1899, 11, 30)); // 注意:月份是从 0 开始的,所以 11 表示 12 月  
            const jsMilliseconds = excelDateNum * 86400000 + excelEpoch.getTime(); // 减 2 是因为 Excel 的日期从 1 开始,而 JavaScript 从 0 开始  

            const jsDate = new Date(jsMilliseconds);
            const offset = 8 * 60 * 60 * 1000; // UTC+8 小时的时区偏移量  
            const time = jsDate - offset;
            if (type === 'timeStamp') {
                if (isMillisecond) return time;
                return parseInt(time / 1000, 10);
            }
            return moment(time).format(format);
        } catch (error) {
            throw error;
        }
    }
}

module.exports = ExcelService;