From d86df6ef54a779e821237953b689429fd744e640 Mon Sep 17 00:00:00 2001 From: lijiahang Date: Sun, 29 May 2022 13:45:50 +0800 Subject: [PATCH] feat: import machine data. --- .../orion/ops/consts/export/ImportType.java | 67 +++++ .../ops/controller/DataImportController.java | 142 ++++++++++ .../ops/entity/dto/BaseDataImportDTO.java | 20 ++ .../orion/ops/entity/dto/DataImportDTO.java | 55 ++++ .../ops/entity/request/DataImportRequest.java | 20 ++ .../ops/entity/vo/DataImportCheckRowVO.java | 35 +++ .../ops/entity/vo/DataImportCheckVO.java | 37 +++ .../ops/service/api/DataImportService.java | 60 ++++ .../service/impl/DataImportServiceImpl.java | 256 ++++++++++++++++++ .../templates/machine-import-template.xlsx | Bin 0 -> 9166 bytes 10 files changed, 692 insertions(+) create mode 100644 orion-ops-service/src/main/java/com/orion/ops/consts/export/ImportType.java create mode 100644 orion-ops-service/src/main/java/com/orion/ops/controller/DataImportController.java create mode 100644 orion-ops-service/src/main/java/com/orion/ops/entity/dto/BaseDataImportDTO.java create mode 100644 orion-ops-service/src/main/java/com/orion/ops/entity/dto/DataImportDTO.java create mode 100644 orion-ops-service/src/main/java/com/orion/ops/entity/request/DataImportRequest.java create mode 100644 orion-ops-service/src/main/java/com/orion/ops/entity/vo/DataImportCheckRowVO.java create mode 100644 orion-ops-service/src/main/java/com/orion/ops/entity/vo/DataImportCheckVO.java create mode 100644 orion-ops-service/src/main/java/com/orion/ops/service/api/DataImportService.java create mode 100644 orion-ops-service/src/main/java/com/orion/ops/service/impl/DataImportServiceImpl.java create mode 100644 orion-ops-service/src/main/resources/templates/machine-import-template.xlsx diff --git a/orion-ops-service/src/main/java/com/orion/ops/consts/export/ImportType.java b/orion-ops-service/src/main/java/com/orion/ops/consts/export/ImportType.java new file mode 100644 index 00000000..cb103a33 --- /dev/null +++ b/orion-ops-service/src/main/java/com/orion/ops/consts/export/ImportType.java @@ -0,0 +1,67 @@ +package com.orion.ops.consts.export; + +import com.orion.ops.consts.machine.MachineFieldConst; +import com.orion.ops.entity.dto.MachineInfoImportDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.function.Consumer; + +/** + * 导入类型 + * + * @author Jiahang Li + * @version 1.0.0 + * @since 2022/5/26 17:15 + */ +@Getter +@AllArgsConstructor +public enum ImportType { + + /** + * 导入机器 + */ + MACHINE(10, + "/templates/machine-import-template.xlsx", + "机器导入模板.xlsx", + MachineInfoImportDTO.class, + MachineFieldConst::validData); + + /** + * 类型 + */ + private final Integer type; + + /** + * 文件路径 + */ + private final String templatePath; + + /** + * 下载名称 + */ + private final String templateName; + + /** + * importClass + */ + private Class importClass; + + /** + * 数据验证 + */ + private Consumer valid; + + public static ImportType of(Integer type) { + if (type == null) { + return null; + } + for (ImportType value : values()) { + if (value.type.equals(type)) { + return value; + } + } + return null; + } + +} diff --git a/orion-ops-service/src/main/java/com/orion/ops/controller/DataImportController.java b/orion-ops-service/src/main/java/com/orion/ops/controller/DataImportController.java new file mode 100644 index 00000000..1caa217f --- /dev/null +++ b/orion-ops-service/src/main/java/com/orion/ops/controller/DataImportController.java @@ -0,0 +1,142 @@ +package com.orion.ops.controller; + +import com.orion.lang.wrapper.HttpWrapper; +import com.orion.office.excel.Excels; +import com.orion.ops.OrionOpsServiceApplication; +import com.orion.ops.annotation.*; +import com.orion.ops.consts.SchedulerPools; +import com.orion.ops.consts.event.EventType; +import com.orion.ops.consts.export.ImportType; +import com.orion.ops.entity.dto.DataImportDTO; +import com.orion.ops.entity.dto.MachineInfoImportDTO; +import com.orion.ops.entity.dto.UserDTO; +import com.orion.ops.entity.request.DataImportRequest; +import com.orion.ops.entity.vo.DataImportCheckVO; +import com.orion.ops.service.api.DataImportService; +import com.orion.ops.utils.Currents; +import com.orion.ops.utils.Valid; +import com.orion.servlet.web.Servlets; +import com.orion.utils.Exceptions; +import com.orion.utils.Strings; +import com.orion.utils.Threads; +import com.orion.utils.io.Streams; +import org.apache.poi.ss.usermodel.Workbook; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.InputStream; +import java.util.Date; +import java.util.List; +import java.util.function.Consumer; + +/** + * 数据导入 controller + * + * @author Jiahang Li + * @version 1.0.0 + * @since 2022/5/26 14:02 + */ +@RestController +@RestWrapper +@RequestMapping("/orion/api/data-import") +public class DataImportController { + + @Resource + private DataImportService dataImportService; + + /** + * 获取导入模板 + */ + @GetMapping("/get-template") + @IgnoreWrapper + @IgnoreLog + @IgnoreAuth + public void getTemplate(Integer type, HttpServletResponse response) throws IOException { + ImportType importType = Valid.notNull(ImportType.of(type)); + Servlets.setDownloadHeader(response, importType.getTemplateName()); + // 读取文件 + InputStream in = OrionOpsServiceApplication.class.getResourceAsStream(importType.getTemplatePath()); + Streams.transfer(in, response.getOutputStream()); + } + + /** + * 检查导入信息 + */ + @RequestMapping("/check-data") + @SuppressWarnings("unchecked") + public DataImportCheckVO checkImportData(@RequestParam("file") MultipartFile file, + @RequestParam("type") Integer type, + @RequestParam(name = "protectPassword", required = false) String protectPassword) throws IOException { + ImportType importType = Valid.notNull(ImportType.of(type)); + Workbook workbook; + if (Strings.isBlank(protectPassword)) { + workbook = Excels.openWorkbook(file.getInputStream()); + } else { + workbook = Excels.openWorkbook(file.getInputStream(), protectPassword); + } + // 解析数据 + List rows; + try { + rows = dataImportService.parseImportWorkbook(importType, workbook); + } catch (Exception e) { + throw Exceptions.parse(e); + } finally { + Excels.close(workbook); + } + // 检查数据 + switch (importType) { + case MACHINE: + return dataImportService.checkMachineImportData((List) rows); + default: + throw Exceptions.unsupported(); + } + } + + /** + * 导入机器 + */ + @RequestMapping("/import-machine") + @EventLog(EventType.DATA_IMPORT_MACHINE) + public HttpWrapper importMachineData(@RequestBody DataImportRequest request) { + String token = Valid.notNull(request.getImportToken()); + // 导入 + this.asyncImportData(token, d -> dataImportService.importMachineData(d)); + return HttpWrapper.ok(); + } + + /** + * 取消导入 + */ + @RequestMapping("/cancel-import") + public HttpWrapper cancelImportData(@RequestBody DataImportRequest request) { + String token = request.getImportToken(); + if (Strings.isBlank(token)) { + return HttpWrapper.ok(); + } + dataImportService.clearImportToken(token); + return HttpWrapper.ok(); + } + + /** + * 异步导入 + * + * @param token token + * @param handler 处理器 + */ + private void asyncImportData(String token, Consumer handler) { + // 获取 token 信息 + DataImportDTO importData = dataImportService.checkImportToken(token); + UserDTO user = Currents.getUser(); + importData.setUserId(user.getId()); + importData.setUserName(user.getUsername()); + importData.setImportTime(new Date()); + // 异步执行导入 + Threads.start(() -> handler.accept(importData), SchedulerPools.ASYNC_IMPORT_SCHEDULER); + // 清空token + dataImportService.clearImportToken(token); + } + +} diff --git a/orion-ops-service/src/main/java/com/orion/ops/entity/dto/BaseDataImportDTO.java b/orion-ops-service/src/main/java/com/orion/ops/entity/dto/BaseDataImportDTO.java new file mode 100644 index 00000000..58c0d00e --- /dev/null +++ b/orion-ops-service/src/main/java/com/orion/ops/entity/dto/BaseDataImportDTO.java @@ -0,0 +1,20 @@ +package com.orion.ops.entity.dto; + +import lombok.Data; + +/** + * 导入数据 基类 + * + * @author Jiahang Li + * @version 1.0.0 + * @since 2022/5/26 15:27 + */ +@Data +public class BaseDataImportDTO { + + /** + * 非法信息 + */ + private String illegalMessage; + +} diff --git a/orion-ops-service/src/main/java/com/orion/ops/entity/dto/DataImportDTO.java b/orion-ops-service/src/main/java/com/orion/ops/entity/dto/DataImportDTO.java new file mode 100644 index 00000000..3613bfba --- /dev/null +++ b/orion-ops-service/src/main/java/com/orion/ops/entity/dto/DataImportDTO.java @@ -0,0 +1,55 @@ +package com.orion.ops.entity.dto; + +import com.orion.ops.entity.vo.DataImportCheckVO; +import lombok.Data; + +import java.util.Date; + +/** + * 数据导入对象 + * + * @author Jiahang Li + * @version 1.0.0 + * @since 2022/5/26 17:42 + */ +@Data +public class DataImportDTO { + + /** + * token + */ + private String importToken; + + /** + * 类型 + * + * @see com.orion.ops.consts.export.ImportType + */ + private Integer type; + + /** + * 导入数据 json + */ + private String data; + + /** + * 检查数据 + */ + private DataImportCheckVO check; + + /** + * 导入用户id 手动 + */ + private Long userId; + + /** + * 导入用户名称 手动 + */ + private String userName; + + /** + * 导入时间 手动 + */ + private Date importTime; + +} diff --git a/orion-ops-service/src/main/java/com/orion/ops/entity/request/DataImportRequest.java b/orion-ops-service/src/main/java/com/orion/ops/entity/request/DataImportRequest.java new file mode 100644 index 00000000..1d3be88a --- /dev/null +++ b/orion-ops-service/src/main/java/com/orion/ops/entity/request/DataImportRequest.java @@ -0,0 +1,20 @@ +package com.orion.ops.entity.request; + +import lombok.Data; + +/** + * 数据导入请求 + * + * @author Jiahang Li + * @version 1.0.0 + * @since 2022/5/26 17:33 + */ +@Data +public class DataImportRequest { + + /** + * 导入 token + */ + private String importToken; + +} diff --git a/orion-ops-service/src/main/java/com/orion/ops/entity/vo/DataImportCheckRowVO.java b/orion-ops-service/src/main/java/com/orion/ops/entity/vo/DataImportCheckRowVO.java new file mode 100644 index 00000000..d552e54b --- /dev/null +++ b/orion-ops-service/src/main/java/com/orion/ops/entity/vo/DataImportCheckRowVO.java @@ -0,0 +1,35 @@ +package com.orion.ops.entity.vo; + +import lombok.Data; + +/** + * 数据导入检查 + * + * @author Jiahang Li + * @version 1.0.0 + * @since 2022/5/27 11:18 + */ +@Data +public class DataImportCheckRowVO { + + /** + * 索引 0开始 + */ + private Integer index; + + /** + * 行号 前端提示 + */ + private Integer row; + + /** + * 唯一标识 + */ + private String symbol; + + /** + * 非法信息 + */ + private String illegalMessage; + +} diff --git a/orion-ops-service/src/main/java/com/orion/ops/entity/vo/DataImportCheckVO.java b/orion-ops-service/src/main/java/com/orion/ops/entity/vo/DataImportCheckVO.java new file mode 100644 index 00000000..4c2ca78f --- /dev/null +++ b/orion-ops-service/src/main/java/com/orion/ops/entity/vo/DataImportCheckVO.java @@ -0,0 +1,37 @@ +package com.orion.ops.entity.vo; + +import lombok.Data; + +import java.util.List; + +/** + * 数据导入检查 + * + * @author Jiahang Li + * @version 1.0.0 + * @since 2022/5/27 11:18 + */ +@Data +public class DataImportCheckVO { + + /** + * 无效行 + */ + private List illegalRows; + + /** + * 插入行 + */ + private List insertRows; + + /** + * 更新行 + */ + private List updateRows; + + /** + * 导入 token + */ + private String importToken; + +} diff --git a/orion-ops-service/src/main/java/com/orion/ops/service/api/DataImportService.java b/orion-ops-service/src/main/java/com/orion/ops/service/api/DataImportService.java new file mode 100644 index 00000000..2bf61280 --- /dev/null +++ b/orion-ops-service/src/main/java/com/orion/ops/service/api/DataImportService.java @@ -0,0 +1,60 @@ +package com.orion.ops.service.api; + +import com.orion.ops.consts.export.ImportType; +import com.orion.ops.entity.dto.DataImportDTO; +import com.orion.ops.entity.dto.MachineInfoImportDTO; +import com.orion.ops.entity.vo.DataImportCheckVO; +import org.apache.poi.ss.usermodel.Workbook; + +import java.util.List; + +/** + * 数据导入服务 + * + * @author Jiahang Li + * @version 1.0.0 + * @since 2022/5/26 17:06 + */ +public interface DataImportService { + + /** + * 解析导入 workbook + * + * @param workbook workbook + * @param type type + * @param T + * @return list + */ + List parseImportWorkbook(ImportType type, Workbook workbook); + + /** + * 检查机器导入信息 + * + * @param rows rows + * @return 导入信息 + */ + DataImportCheckVO checkMachineImportData(List rows); + + /** + * 检查导入 token + * + * @param token token + * @return 导入数据 + */ + DataImportDTO checkImportToken(String token); + + /** + * 导入机器信息 + * + * @param checkData 缓存信息 + */ + void importMachineData(DataImportDTO checkData); + + /** + * 清空导入 token + * + * @param token token + */ + void clearImportToken(String token); + +} diff --git a/orion-ops-service/src/main/java/com/orion/ops/service/impl/DataImportServiceImpl.java b/orion-ops-service/src/main/java/com/orion/ops/service/impl/DataImportServiceImpl.java new file mode 100644 index 00000000..b6b92b4a --- /dev/null +++ b/orion-ops-service/src/main/java/com/orion/ops/service/impl/DataImportServiceImpl.java @@ -0,0 +1,256 @@ +package com.orion.ops.service.impl; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.serializer.SerializerFeature; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.orion.id.UUIds; +import com.orion.office.excel.reader.ExcelBeanReader; +import com.orion.ops.consts.KeyConst; +import com.orion.ops.consts.MessageConst; +import com.orion.ops.consts.event.EventKeys; +import com.orion.ops.consts.export.ImportType; +import com.orion.ops.consts.message.MessageType; +import com.orion.ops.consts.system.SystemEnvAttr; +import com.orion.ops.dao.MachineInfoDAO; +import com.orion.ops.entity.domain.MachineInfoDO; +import com.orion.ops.entity.dto.BaseDataImportDTO; +import com.orion.ops.entity.dto.DataImportDTO; +import com.orion.ops.entity.dto.MachineInfoImportDTO; +import com.orion.ops.entity.vo.DataImportCheckRowVO; +import com.orion.ops.entity.vo.DataImportCheckVO; +import com.orion.ops.service.api.DataImportService; +import com.orion.ops.service.api.WebSideMessageService; +import com.orion.ops.utils.Currents; +import com.orion.ops.utils.PathBuilders; +import com.orion.utils.Exceptions; +import com.orion.utils.Strings; +import com.orion.utils.Valid; +import com.orion.utils.collect.Lists; +import com.orion.utils.collect.Maps; +import com.orion.utils.convert.Converts; +import com.orion.utils.io.FileWriters; +import com.orion.utils.io.Files1; +import com.orion.utils.time.Dates; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Workbook; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 数据导入服务 + * + * @author Jiahang Li + * @version 1.0.0 + * @since 2022/5/26 17:07 + */ +@Slf4j +@Service("dataImportService") +public class DataImportServiceImpl implements DataImportService { + + @Resource + private WebSideMessageService webSideMessageService; + + @Resource + private MachineInfoDAO machineInfoDAO; + + @Resource + private RedisTemplate redisTemplate; + + @Override + @SuppressWarnings("unchecked") + public List parseImportWorkbook(ImportType type, Workbook workbook) { + ExcelBeanReader reader = new ExcelBeanReader(workbook, workbook.getSheetAt(0), (Class) type.getImportClass()); + return reader.skip(2) + .read() + .getRows(); + } + + @Override + public DataImportCheckVO checkMachineImportData(List rows) { + // 检查数据合法性 + this.validImportData(ImportType.MACHINE, rows); + // 设置检查对象 + String dataJson = JSON.toJSONString(rows); + List illegalRows = Lists.newList(); + List insertRows = Lists.newList(); + List updateRows = Lists.newList(); + // 通过 tag 查询机器 + List tagList = rows.stream() + .filter(s -> Objects.isNull(s.getIllegalMessage())) + .map(MachineInfoImportDTO::getTag) + .collect(Collectors.toList()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .in(MachineInfoDO::getMachineTag, tagList); + List machines = machineInfoDAO.selectList(wrapper); + // 设置数据 + for (int i = 0; i < rows.size(); i++) { + MachineInfoImportDTO row = rows.get(i); + // 设置检查数据 + DataImportCheckRowVO checkRow = Converts.to(row, DataImportCheckRowVO.class); + checkRow.setIndex(i); + checkRow.setRow(i + 3); + // 不合法数据 + if (checkRow.getIllegalMessage() != null) { + illegalRows.add(checkRow); + continue; + } + final boolean update = machines.stream().anyMatch(m -> m.getMachineTag().equals(row.getTag())); + if (update) { + updateRows.add(checkRow); + } else { + insertRows.add(checkRow); + } + } + // 返回数据 + return this.setCheckData(ImportType.MACHINE.getType(), dataJson, illegalRows, insertRows, updateRows); + } + + @Override + public DataImportDTO checkImportToken(String token) { + // 查询缓存 + String data = redisTemplate.opsForValue().get(Strings.format(KeyConst.DATA_IMPORT_TOKEN, Currents.getUserId(), token)); + if (Strings.isEmpty(data)) { + throw Exceptions.argument(MessageConst.OPERATOR_TIMEOUT); + } + return JSON.parseObject(data, DataImportDTO.class); + } + + @Override + public void importMachineData(DataImportDTO importData) { + Exception ex = null; + try { + // 获取缓存数据 + List rows = this.getImportData(importData); + DataImportCheckVO dataCheck = importData.getCheck(); + // 插入 + List insertRows = dataCheck.getInsertRows(); + for (DataImportCheckRowVO insertRow : insertRows) { + // 获取需要插入的数据 + MachineInfoImportDTO insertImportRow = rows.get(insertRow.getIndex()); + MachineInfoDO insert = Converts.to(insertImportRow, MachineInfoDO.class); + machineInfoDAO.insert(insert); + } + // 更新 + List updateRows = dataCheck.getUpdateRows(); + for (DataImportCheckRowVO updateRow : updateRows) { + // 获取需要更新的数据 + MachineInfoImportDTO updateImportRow = rows.get(updateRow.getIndex()); + MachineInfoDO update = Converts.to(updateImportRow, MachineInfoDO.class); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(MachineInfoDO::getMachineTag, update.getMachineTag()); + machineInfoDAO.update(update, wrapper); + } + } catch (Exception e) { + ex = e; + log.error("机器信息导入失败 token: {}, data: {}", importData.getImportToken(), JSON.toJSONString(importData), e); + } + // 发送站内信 + this.sendImportWebSideMessage(importData, ex == null ? MessageType.MACHINE_IMPORT_SUCCESS : MessageType.MACHINE_IMPORT_FAILURE); + // 保存日志 + this.saveImportDataJson(importData); + } + + @Override + public void clearImportToken(String token) { + redisTemplate.delete(Strings.format(KeyConst.DATA_IMPORT_TOKEN, Currents.getUserId(), token)); + } + + /** + * 验证对象合法性 + * + * @param importType importType + * @param rows rows + */ + private void validImportData(ImportType importType, List rows) { + for (BaseDataImportDTO row : rows) { + try { + importType.getValid().accept(row); + } catch (Exception e) { + row.setIllegalMessage(e.getMessage()); + } + } + + } + + /** + * 设置检查数据 + * + * @param type type + * @param dataJson json + * @param illegalRows 无效数据 + * @param insertRows 插入数据 + * @param updateRows 更新数据 + * @return 检查数据 + */ + private DataImportCheckVO setCheckData(Integer type, + String dataJson, + List illegalRows, + List insertRows, + List updateRows) { + String token = UUIds.random32(); + String cacheKey = Strings.format(KeyConst.DATA_IMPORT_TOKEN, Currents.getUserId(), token); + // 返回数据 + DataImportCheckVO check = new DataImportCheckVO(); + check.setImportToken(token); + check.setIllegalRows(illegalRows); + check.setInsertRows(insertRows); + check.setUpdateRows(updateRows); + // 设置缓存 + DataImportDTO cache = new DataImportDTO(); + cache.setImportToken(token); + cache.setType(type); + cache.setData(dataJson); + cache.setCheck(check); + redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cache), + KeyConst.DATA_IMPORT_TOKEN_EXPIRE, TimeUnit.SECONDS); + return check; + } + + /** + * 获取导入数据 + * + * @param data data + * @param T + * @return list + */ + @SuppressWarnings("unchecked") + private List getImportData(DataImportDTO data) { + ImportType type = ImportType.of(data.getType()); + Valid.notNull(type); + return (List) JSON.parseArray(data.getData(), type.getImportClass()); + } + + /** + * 发送站内信 + * + * @param importData importData + * @param type type + */ + private void sendImportWebSideMessage(DataImportDTO importData, MessageType type) { + // 站内信参数 + Map params = Maps.newMap(); + params.put(EventKeys.TIME, Dates.format(importData.getImportTime())); + params.put(EventKeys.TOKEN, importData.getImportToken()); + webSideMessageService.addMessage(type, importData.getUserId(), importData.getUserName(), params); + } + + /** + * 将导入 json 存储到本地 + * + * @param importData json + */ + private void saveImportDataJson(DataImportDTO importData) { + // 将数据存储到本地 log + String importJsonPath = PathBuilders.getImportDataJsonPath(importData.getUserId(), importData.getType(), importData.getImportToken()); + String path = Files1.getPath(SystemEnvAttr.LOG_PATH.getValue(), importJsonPath); + FileWriters.write(path, JSON.toJSONString(importData, SerializerFeature.PrettyFormat)); + } + +} diff --git a/orion-ops-service/src/main/resources/templates/machine-import-template.xlsx b/orion-ops-service/src/main/resources/templates/machine-import-template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..092eaf6e876cad44b398715056d38cabdd442910 GIT binary patch literal 9166 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