主题
Egg在开发中一些常用功能
一. 文件上传
Egg文件上传,官方提供了两方式:file和stream;开发中可以根据自生需要,选择合适的方式进行文件上传。文件上传官方示例。 但是,更推荐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 需要的包exceljs、moment
一些辅助函数
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;