From b8e9998ec86d470ef8008ea1fe05a936f51e6d45 Mon Sep 17 00:00:00 2001 From: chenchuchuan <13554600537@163.com> Date: Thu, 24 Sep 2026 15:26:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(online):=20=E6=96=B0=E5=A2=9E=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE=E5=92=8C=E5=B7=A5=E5=85=B7=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加ImportAttrEnum、ImportExportEnabledStatus、ImportWriteModeEnum枚举类 - 实现ImportExportExcelUtil工具类,支持导入模板生成和文件下载 - 添加ImportExportFileCleanTimer定时清理任务 - 在OnlineConstant中增加导入导出模板缓存key相关方法 - 扩展OnlineDataSourceUtil提供IN条件查询功能 - 定义OnlineExportDataService接口及其实现类OnlineExportDataServiceImpl - 修复ObjectValue类中类型转换可能引发的ClassCastException问题 --- .../apelet/common/core/object/ObjectValue.java | 5 +- .../common/online/config/OnlineProperties.java | 37 + .../online/controller/OnlineFormController.java | 49 +- .../OnlineImportExportTemplateController.java | 158 +++ .../controller/OnlineOperationController.java | 252 ++++ .../online/dao/OnlineImportExportFieldMapper.java | 13 + .../online/dao/OnlineImportExportTaskMapper.java | 13 + .../dao/OnlineImportExportTemplateMapper.java | 26 + .../mapper/OnlineImportExportTemplateMapper.xml | 58 + .../common/online/dto/OnlineExportParamDto.java | 38 + .../common/online/dto/OnlineExportResultDto.java | 50 + .../common/online/dto/OnlineImportErrorDto.java | 51 + .../online/dto/OnlineImportExportTableDto.java | 40 + .../dto/OnlineImportExportTemplateStatusDto.java | 48 + .../common/online/dto/OnlineImportGroupDto.java | 73 ++ .../common/online/dto/OnlineImportParamDto.java | 40 + .../common/online/dto/OnlineImportResultDto.java | 105 ++ .../common/online/dto/OnlineImportRowDto.java | 104 ++ .../common/online/dto/OnlineImportSheetDto.java | 42 + .../online/model/OnlineImportExportField.java | 143 +++ .../online/model/OnlineImportExportTask.java | 141 +++ .../online/model/OnlineImportExportTemplate.java | 121 ++ .../online/model/constant/ImportAttrEnum.java | 65 + .../model/constant/ImportExportEnabledStatus.java | 59 + .../online/model/constant/ImportWriteModeEnum.java | 59 + .../constant/OnlineImportExportTaskStatus.java | 59 + .../model/constant/OnlineImportExportTaskType.java | 26 + .../online/service/OnlineExportDataService.java | 81 ++ .../service/OnlineImportExportDataService.java | 67 + .../service/OnlineImportExportFieldService.java | 23 + .../service/OnlineImportExportTaskService.java | 73 ++ .../service/OnlineImportExportTemplateService.java | 120 ++ .../online/service/OnlineOperationService.java | 16 + .../common/online/service/OnlineTableService.java | 8 +- .../service/impl/OnlineExportDataServiceImpl.java | 696 +++++++++++ .../impl/OnlineImportExportDataServiceImpl.java | 1320 ++++++++++++++++++++ .../impl/OnlineImportExportFieldServiceImpl.java | 45 + .../impl/OnlineImportExportTaskServiceImpl.java | 489 ++++++++ .../OnlineImportExportTemplateServiceImpl.java | 532 ++++++++ .../service/impl/OnlineOperationServiceImpl.java | 10 + .../service/impl/OnlineTableServiceImpl.java | 26 +- .../online/timer/ImportExportFileCleanTimer.java | 130 ++ .../common/online/util/ImportExportExcelUtil.java | 378 ++++++ .../apelet/common/online/util/OnlineConstant.java | 15 + .../common/online/util/OnlineDataSourceUtil.java | 105 ++ .../online/util/OnlineImportExcelReader.java | 226 ++++ .../common/online/util/RelationValueResolver.java | 267 ++++ .../online/util/QuickFormWidgetUtilTest.java | 56 + 48 files changed, 6548 insertions(+), 10 deletions(-) create mode 100644 common/common-online/src/main/java/apelet/common/online/controller/OnlineImportExportTemplateController.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportFieldMapper.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTaskMapper.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTemplateMapper.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineImportExportTemplateMapper.xml create mode 100644 common/common-online/src/main/java/apelet/common/online/dto/OnlineExportParamDto.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dto/OnlineExportResultDto.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dto/OnlineImportErrorDto.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTableDto.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTemplateStatusDto.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dto/OnlineImportGroupDto.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dto/OnlineImportParamDto.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dto/OnlineImportResultDto.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dto/OnlineImportRowDto.java create mode 100644 common/common-online/src/main/java/apelet/common/online/dto/OnlineImportSheetDto.java create mode 100644 common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportField.java create mode 100644 common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTask.java create mode 100644 common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTemplate.java create mode 100644 common/common-online/src/main/java/apelet/common/online/model/constant/ImportAttrEnum.java create mode 100644 common/common-online/src/main/java/apelet/common/online/model/constant/ImportExportEnabledStatus.java create mode 100644 common/common-online/src/main/java/apelet/common/online/model/constant/ImportWriteModeEnum.java create mode 100644 common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskStatus.java create mode 100644 common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskType.java create mode 100644 common/common-online/src/main/java/apelet/common/online/service/OnlineExportDataService.java create mode 100644 common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportDataService.java create mode 100644 common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportFieldService.java create mode 100644 common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTaskService.java create mode 100644 common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTemplateService.java create mode 100644 common/common-online/src/main/java/apelet/common/online/service/impl/OnlineExportDataServiceImpl.java create mode 100644 common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportDataServiceImpl.java create mode 100644 common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportFieldServiceImpl.java create mode 100644 common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTaskServiceImpl.java create mode 100644 common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTemplateServiceImpl.java create mode 100644 common/common-online/src/main/java/apelet/common/online/timer/ImportExportFileCleanTimer.java create mode 100644 common/common-online/src/main/java/apelet/common/online/util/ImportExportExcelUtil.java create mode 100644 common/common-online/src/main/java/apelet/common/online/util/OnlineImportExcelReader.java create mode 100644 common/common-online/src/main/java/apelet/common/online/util/RelationValueResolver.java create mode 100644 common/common-online/src/test/java/apelet/common/online/util/QuickFormWidgetUtilTest.java diff --git a/common/common-core/src/main/java/apelet/common/core/object/ObjectValue.java b/common/common-core/src/main/java/apelet/common/core/object/ObjectValue.java index 603c611..6d74742 100644 --- a/common/common-core/src/main/java/apelet/common/core/object/ObjectValue.java +++ b/common/common-core/src/main/java/apelet/common/core/object/ObjectValue.java @@ -120,7 +120,10 @@ public class ObjectValue implements Serializable { this.values.put(k, null); return null; } - return (ObjectCollection) this.values.put(k, value); + // 404 多选:orm 查询链路会先把锚点物理列读成 Long 放进同 key(BIGINT 读 NULL 得 Long(0)), + // 再调本方法挂集合,若强转旧值会抛 ClassCastException;调用方不消费返回值,非集合旧值返回 null。 + Object old = this.values.put(k, value); + return old instanceof ObjectCollection ? (ObjectCollection) old : null; } public Object remove(String key) { diff --git a/common/common-online/src/main/java/apelet/common/online/config/OnlineProperties.java b/common/common-online/src/main/java/apelet/common/online/config/OnlineProperties.java index 1150938..7012091 100644 --- a/common/common-online/src/main/java/apelet/common/online/config/OnlineProperties.java +++ b/common/common-online/src/main/java/apelet/common/online/config/OnlineProperties.java @@ -56,4 +56,41 @@ public class OnlineProperties { * 在线表单编辑权限的URL列表。 */ private List editUrlList; + /** + * 单次导入的最大Excel行数(不含表头行)。超出时提示分批导入。 + */ + private Integer importMaxRowCount = 20000; + /** + * 单次导入的文件大小上限(MB)。 + */ + private Integer importMaxFileSizeMb = 10; + /** + * 导入写入的每批主记录数。 + */ + private Integer importBatchSize = 500; + /** + * 导入行数超过该阈值时转异步任务;不超过则同步返回结果。 + */ + private Integer importAsyncRowThreshold = 5000; + /** + * 导出行数超过该阈值时转异步任务。 + */ + private Integer exportAsyncRowThreshold = 50000; + /** + * 导入导出文件(上传的源文件、导入失败文件、导出结果文件)的保留时长(小时)。 + * 下载过的文件在下载完成后立即删除;未被下载的超过该时长由定时清理任务删除。 + */ + private Integer importExportFileRetainHours = 24; + /** + * 异步任务线程池核心/最大线程数。不用 Spring 异步体系,避免与 Flowable 冲突。 + */ + private Integer asyncTaskPoolSize = 2; + /** + * 异步任务队列容量。队列满时直接返回「系统繁忙」,不无界堆积。 + */ + private Integer asyncTaskQueueCapacity = 100; + /** + * 异步任务记录与结果文件的保留天数,超期由定时任务清理。 + */ + private Integer asyncTaskRetainDays = 7; } diff --git a/common/common-online/src/main/java/apelet/common/online/controller/OnlineFormController.java b/common/common-online/src/main/java/apelet/common/online/controller/OnlineFormController.java index ad0d9f5..226032f 100644 --- a/common/common-online/src/main/java/apelet/common/online/controller/OnlineFormController.java +++ b/common/common-online/src/main/java/apelet/common/online/controller/OnlineFormController.java @@ -461,6 +461,40 @@ public class OnlineFormController { } /** + * 查询指定表单**主表**的字段列表(查 onl_form_field,只查主表,不含分录表)。 + * + *

链路:formId → {@code zz_online_form.master_table_id}(= zz_online_table.table_id)取表名 + * → 按表名取表单元数据 headId → 按 headId 取字段。onl_form_head 没有 table_id 列,两张表是靠 + * table_name 关联的,所以中间这一步绕不开。

+ * + *

字段元数据走 {@code queryOnlFormField},会自动补上关联信息:402 的关联表描述 + * (refPropertMap)、404 的中间表描述(refMultiTableMap),前端据此展示"关联到哪张表"。

+ * + * @param formId 表单id。 + * @return 主表字段列表;表单 / 主表 / 表单元数据不存在时报错。 + */ + @PostMapping("/listMasterFields") + public ResponseResult> listMasterFields(@MyRequestBody(required = true) Long formId) { + OnlineForm onlineForm = formId == null ? null : onlineFormService.getById(formId); + if (onlineForm == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "数据查询失败,表单不存在!"); + } + // ① formId → 主表在线表(masterTableId 就是 zz_online_table.table_id),拿表名 + OnlineTable masterTable = onlineTableService.getOnlineTableFromCache(onlineForm.getMasterTableId()); + if (masterTable == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "数据查询失败,表单主表不存在!"); + } + // ② 表名 → 表单元数据(onl_form_head 没有 table_id 列,只能按 table_name 关联) + OnlFormHead masterHead = onlFormHeadService.getHeadTableNameCache(masterTable.getTableName()); + if (masterHead == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "数据查询失败,主表的表单元数据不存在!"); + } + // ③ headId → 字段 + List fieldList = onlFormFieldService.queryOnlFormField(new LambdaQueryWrapper().eq(OnlFormField::getHeadId, masterHead.getId())); + return ResponseResult.success(fieldList == null ? Collections.emptyList() : fieldList); + } + + /** * 获取指定在线表单对象在前端渲染时所需的所有数据对象。 * * @param formId 指定对象主键Id。 @@ -740,8 +774,10 @@ public class OnlineFormController { field.setCreateUserId(tokenData.getUserId()); field.setUpdateTime(now); field.setUpdateUserId(tokenData.getUserId()); + // 不用 saveBatch:OnlineQuickFormFieldServiceImpl 继承 BaseService, + // 其 saveBatch 会按泛型拼出 BaseDaoMapper.insert(不存在)而报错。 + onlineQuickFormFieldService.save(field); } - onlineQuickFormFieldService.saveBatch(fieldList); } // 3. 解析数据源与主表 OnlineDatasource datasource = onlineDatasourceService.getOnlineDatasourceByMasterTableId(dto.getMasterTableId()); @@ -861,8 +897,9 @@ public class OnlineFormController { field.setColumnId(columnId); updateList.add(field); } - if (CollUtil.isNotEmpty(updateList)) { - onlineQuickFormFieldService.updateBatchById(updateList); + // 不用 updateBatchById:OnlineQuickFormFieldServiceImpl 继承 BaseService,批量方法不可用 + for (OnlineQuickFormField field : updateList) { + onlineQuickFormFieldService.updateById(field); } } @@ -910,10 +947,10 @@ public class OnlineFormController { column.setUpdateUserId(tokenData.getUserId()); updateList.add(column); } - if (updateList.isEmpty()) { - return; + // 不用 updateBatchById:OnlineColumnServiceImpl 继承 BaseService,批量方法不可用 + for (OnlineColumn column : updateList) { + onlineColumnService.updateById(column); } - onlineColumnService.updateBatchById(updateList); redissonClient.getBucket(OnlineRedisKeyUtil.makeOnlineTableKey(masterTableId)).delete(); } diff --git a/common/common-online/src/main/java/apelet/common/online/controller/OnlineImportExportTemplateController.java b/common/common-online/src/main/java/apelet/common/online/controller/OnlineImportExportTemplateController.java new file mode 100644 index 0000000..e9fc4ea --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/controller/OnlineImportExportTemplateController.java @@ -0,0 +1,158 @@ +package apelet.common.online.controller; + +import apelet.common.core.annotation.MyRequestBody; +import apelet.common.core.object.MyOrderParam; +import apelet.common.core.object.MyPageData; +import apelet.common.core.object.MyPageParam; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.util.MyPageUtil; +import apelet.common.log.annotation.OperationLog; +import apelet.common.log.model.constant.SysOperationLogType; +import apelet.common.online.dto.OnlineImportExportTableDto; +import apelet.common.online.exception.OnlineRuntimeException; +import apelet.common.online.model.OnlineImportExportField; +import apelet.common.online.model.OnlineImportExportTemplate; +import apelet.common.online.service.OnlineImportExportFieldService; +import apelet.common.online.service.OnlineImportExportTemplateService; +import apelet.common.online.util.ImportExportExcelUtil; +import com.github.pagehelper.page.PageMethod; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 在线表单导入导出模板管理接口。 + * + *

模板是整表单级别的开关:某张表存在已启用模板时,导入导出走新引擎;否则走原有链路。

+ * + * @author chenchuchuan + * @date 2026-09-17 + */ +@Tag(name = "在线表单导入导出模板管理接口") +@Slf4j +@RestController +@RequestMapping("${common-online.urlPrefix}/onlineImportExportTemplate") +@ConditionalOnProperty(name = "common-online.operationEnabled", havingValue = "true") +public class OnlineImportExportTemplateController { + + @Autowired + private OnlineImportExportTemplateService onlineImportExportTemplateService; + + @Autowired + private OnlineImportExportFieldService onlineImportExportFieldService; + + /** + * 模板列表查询。 + * + * @param templateFilter 过滤对象,支持 masterHeadId、templateName(模糊)、isEnabled。 + * @param orderParam 排序参数。 + * @param pageParam 分页参数。 + * @return 应答结果对象,包含模板列表。 + */ + @PostMapping("/list") + public ResponseResult> list( + @MyRequestBody OnlineImportExportTemplate templateFilter, + @MyRequestBody MyOrderParam orderParam, + @MyRequestBody MyPageParam pageParam) { + if (pageParam != null) { + PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); + } + String orderBy = MyOrderParam.buildOrderBy(orderParam, OnlineImportExportTemplate.class); + List templateList = onlineImportExportTemplateService.getOnlineFormListWithRelation(templateFilter, orderBy); + return ResponseResult.success(MyPageUtil.makeResponseData(templateList)); + } + + /** + * 查看模板详情(含字段配置)。 + * + * @param id 模板主键Id。 + * @return 应答结果对象,包含模板详情;字段配置按「表 → 字段」两级树放在 tableList 中。 + */ + @GetMapping("/view") + public ResponseResult view(@RequestParam Long id) { + OnlineImportExportTemplate template = onlineImportExportTemplateService.getTemplateDetail(id); + if (template == null) { + return ResponseResult.error(apelet.common.core.constant.ErrorCodeEnum.DATA_NOT_EXIST); + } + template.setTableList(onlineImportExportTemplateService.groupToTableList(onlineImportExportFieldService.getListByTemplateId(id))); + return ResponseResult.success(template); + } + + /** + * 保存模板(新建或编辑)。新建的模板默认停用;字段配置整体覆盖保存。 + * + * @param template 模板对象,字段配置放在扁平的 onlineImportExportFields 中(与落库结构一致)。 + * @return 应答结果对象,包含保存后的模板。 + */ + @OperationLog(type = SysOperationLogType.UPDATE) + @PostMapping("/save") + public ResponseResult save(@MyRequestBody(required = true) OnlineImportExportTemplate template) { + return ResponseResult.success(onlineImportExportTemplateService.saveTemplate(template, template.getOnlineImportExportFields())); + } + + /** + * 删除模板。删除的是生效模板时自动顺延该表的第一套;删空则回落原有链路。 + * + * @param id 模板主键Id。 + * @return 应答结果对象。 + */ + @OperationLog(type = SysOperationLogType.DELETE) + @PostMapping("/delete") + public ResponseResult delete(@MyRequestBody(required = true) Long id) { + onlineImportExportTemplateService.deleteTemplate(id); + return ResponseResult.success(); + } + + /** + * 设置模板启用状态。启用时同一表单元数据下其它模板自动停用;停用后该表若无生效模板,则立即回落原有链路。 + * + * @param id 模板主键Id。 + * @param isEnabled 目标启用状态:0-停用,1-启用。 + * @return 应答结果对象。 + */ + @OperationLog(type = SysOperationLogType.UPDATE) + @PostMapping("/setEnabled") + public ResponseResult setEnabled(@MyRequestBody(required = true) Long id, @MyRequestBody(required = true) Integer isEnabled) { + onlineImportExportTemplateService.setEnabled(id, isEnabled); + return ResponseResult.success(); + } + + /** + * 按默认规则生成字段配置(不落库),供新建模板与「一键生成」使用。 + * + * @param masterHeadId 表单元数据id。 + * @return 应答结果对象,包含字段配置列表。 + */ + @GetMapping("/generateDefaultFields") + public ResponseResult> generateDefaultFields(@RequestParam Long masterHeadId) { + return ResponseResult.success(onlineImportExportTemplateService.generateDefaultFields(masterHeadId)); + } + + /** + * 下载导入模板。列取该模板中勾选「是否导入」的字段,表头取展示名。 + * + * @param id 模板主键Id。 + * @throws IOException 写响应失败时抛出。 + */ + @OperationLog(type = SysOperationLogType.DOWNLOAD, saveResponse = false) + @PostMapping("/downloadTemplate") + public void downloadTemplate(@MyRequestBody Long id) throws IOException { + OnlineImportExportTemplate template = onlineImportExportTemplateService.getById(id); + if (template == null) { + throw new OnlineRuntimeException("下载失败,模板不存在!"); + } + List importFieldList = onlineImportExportFieldService.getListByTemplateId(id).stream() + .filter(field -> Integer.valueOf(1).equals(field.getIsImport())).collect(Collectors.toList()); + if (CollectionUtils.isEmpty(importFieldList)) { + throw new OnlineRuntimeException("下载失败,该模板未勾选任何导入字段!"); + } + ImportExportExcelUtil.writeImportTemplate(importFieldList, template.getTemplateName(), template.getTemplateName() + ".xlsx"); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/controller/OnlineOperationController.java b/common/common-online/src/main/java/apelet/common/online/controller/OnlineOperationController.java index af5c07c..27d9551 100644 --- a/common/common-online/src/main/java/apelet/common/online/controller/OnlineOperationController.java +++ b/common/common-online/src/main/java/apelet/common/online/controller/OnlineOperationController.java @@ -26,7 +26,12 @@ import apelet.common.online.abstractplugin.model.ExportData; import apelet.common.online.abstractplugin.model.GridData; import apelet.common.online.config.OnlineProperties; import apelet.common.online.dto.OnlineEventPluginExecuteDto; +import apelet.common.online.dto.OnlineExportParamDto; +import apelet.common.online.dto.OnlineExportResultDto; import apelet.common.online.dto.OnlineFilterDto; +import apelet.common.online.dto.OnlineImportExportTemplateStatusDto; +import apelet.common.online.dto.OnlineImportParamDto; +import apelet.common.online.dto.OnlineImportResultDto; import apelet.common.online.dto.OnlinePluginExecuteDto; import apelet.common.online.exception.OnlineRuntimeException; import apelet.common.online.model.*; @@ -35,7 +40,10 @@ import apelet.common.online.model.constant.FieldFilterType; import apelet.common.online.model.constant.FieldKind; import apelet.common.online.model.constant.RelationType; import apelet.common.online.service.*; +import apelet.common.online.service.impl.OnlineExportDataServiceImpl; +import apelet.common.online.util.ImportExportExcelUtil; import apelet.common.online.util.OnlineConstant; +import apelet.common.online.util.OnlineImportExcelReader; import apelet.common.online.util.OnlineOperationHelper; import apelet.common.online.util.WidgetFieldTypeMapping; import apelet.common.online.util.WidgetJsonUtil; @@ -53,6 +61,7 @@ import cn.hutool.core.util.CharUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSON; +import com.github.pagehelper.page.PageMethod; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -67,6 +76,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpServletResponse; +import java.io.File; import java.io.IOException; import java.io.Serializable; import java.math.BigDecimal; @@ -127,6 +137,18 @@ public class OnlineOperationController { @Autowired(required = false) private FormButtonPermissionChecker buttonPermissionChecker; + @Autowired + private OnlineImportExportTemplateService onlineImportExportTemplateService; + + @Autowired + private OnlineImportExportDataService onlineImportExportDataService; + + @Autowired + private OnlineExportDataService onlineExportDataService; + + @Autowired + private OnlineImportExportTaskService onlineImportExportTaskService; + /** * 执行功能插件 * @@ -719,6 +741,236 @@ public class OnlineOperationController { } + /** + * 导入校验(模板层)。只校验模板配置本身(必填、类型长度、主表列全空、混填多分录表、 + * 匹配键是否在表头等),**不查业务库、不写任何数据**。 + * + * @param datasourceVariableName 数据源名称。 + * @param datasourceId 数据源Id。 + * @param formId 表单id,据此解析该表已启用的模板。 + * @param writeMode 写入模式,可空(空按新增)。 + * @param matchColumns 匹配键,逗号分隔的字段物理列名,可空。 + * @param importFile 上传的Excel文件。 + * @return 校验结果,含主记录数、分录明细数与错误清单。 + */ + @OperationLog(type = SysOperationLogType.IMPORT, saveResponse = false) + @PostMapping("/importValidate/{datasourceVariableName}") + public ResponseResult importValidate( + @PathVariable("datasourceVariableName") String datasourceVariableName, + @RequestParam Long datasourceId, + @RequestParam Long formId, + @RequestParam(required = false) String writeMode, + @RequestParam(required = false) String matchColumns, + @RequestParam("importFile") MultipartFile importFile) { + ResponseResult datasourceResult = this.doVerifyAndGetDatasource(datasourceId, datasourceVariableName); + if (!datasourceResult.isSuccess()) { + return ResponseResult.errorFrom(datasourceResult); + } + return this.runEngineTask(() -> onlineImportExportDataService.validateImport(formId, importFile, writeMode, matchColumns)); + } + + /** + * 导入入库。先跑一遍模板层校验防绕过,再跑业务层校验(字典、F7、覆盖匹配)后写入。 + * 前端需再次上传同一个文件(不用 fileKey 在服务端传递)。 + * + * @param datasourceVariableName 数据源名称。 + * @param datasourceId 数据源Id。 + * @param formId 表单id。 + * @param writeMode 写入模式,可空。 + * @param matchColumns 匹配键,可空。 + * @param importFile 上传的Excel文件。 + * @return 执行结果,含成功/失败的主记录数与失败文件标识。 + */ + @OperationLog(type = SysOperationLogType.IMPORT, saveResponse = false) + @PostMapping("/importExecute/{datasourceVariableName}") + public ResponseResult importExecute( + @PathVariable("datasourceVariableName") String datasourceVariableName, + @RequestParam Long datasourceId, + @RequestParam Long formId, + @RequestParam(required = false) String writeMode, + @RequestParam(required = false) String matchColumns, + @RequestParam("importFile") MultipartFile importFile) { + ResponseResult datasourceResult = this.doVerifyAndGetDatasource(datasourceId, datasourceVariableName); + if (!datasourceResult.isSuccess()) { + return ResponseResult.errorFrom(datasourceResult); + } + return this.runEngineTask(() -> this.doImportExecute(formId, importFile, writeMode, matchColumns)); + } + + /** + * 入库的同步/异步分档执行。 + * 行数不超过配置阈值时同步跑完并直接返回结果;超过则建异步任务,只返回 taskId。 + * + * @param formId 表单id。 + * @param importFile 上传文件。 + * @param writeMode 写入模式。 + * @param matchColumns 匹配键。 + * @return 执行结果;异步时只有 taskId 与总行数。 + */ + private OnlineImportResultDto doImportExecute(Long formId, MultipartFile importFile, String writeMode, String matchColumns) { + String filePath = onlineImportExportDataService.saveImportFile(importFile); + int rowCount = OnlineImportExcelReader.countDataRows(filePath); + Integer threshold = onlineProperties.getImportAsyncRowThreshold(); + if (threshold == null || rowCount <= threshold) { + return onlineImportExportDataService.executeImportByFilePath(formId, filePath, writeMode, matchColumns); + } + OnlineImportExportTemplateStatusDto statusDto = onlineImportExportTemplateService.getTemplateStatus(formId); + OnlineImportExportTask task = onlineImportExportTaskService.submitImportTask(formId, new File(filePath).getName(), statusDto.getTemplateId(), writeMode, matchColumns, rowCount); + OnlineImportResultDto resultDto = new OnlineImportResultDto(); + resultDto.setTaskId(task.getId()); + resultDto.setTotalRowCount(rowCount); + return resultDto; + } + + /** + * 查询异步任务状态与进度。前端凭 taskId 轮询,直到状态为 SUCCESS / PARTIAL / FAILED。 + * + * @param taskId 任务id。 + * @return 任务对象,含状态、进度、统计与结果文件标识。 + */ + @GetMapping("/getImportExportTaskStatus") + public ResponseResult getImportExportTaskStatus(@RequestParam Long taskId) { + OnlineImportExportTask task = onlineImportExportTaskService.getTaskDetail(taskId); + if (task == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST); + } + return ResponseResult.success(task); + } + + /** + * 异步任务列表(排查用)。 + * + * @param taskFilter 过滤对象,支持 taskType、status、formId。 + * @param orderParam 排序参数。 + * @param pageParam 分页参数。 + * @return 应答结果对象。 + */ + @PostMapping("/listImportExportTask") + public ResponseResult> listImportExportTask( + @MyRequestBody OnlineImportExportTask taskFilter, + @MyRequestBody MyOrderParam orderParam, + @MyRequestBody MyPageParam pageParam) { + if (pageParam != null) { + PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); + } + String orderBy = MyOrderParam.buildOrderBy(orderParam, OnlineImportExportTask.class); + return ResponseResult.success(MyPageUtil.makeResponseData(onlineImportExportTaskService.getTaskList(taskFilter, orderBy))); + } + + /** + * 查询表单是否已启用导入导出模板。前端据此决定调新引擎接口还是原有接口。 + * + * @param formId 表单id。 + * @return 链路状态。 + */ + @GetMapping("/getImportExportTemplateStatus") + public ResponseResult getImportExportTemplateStatus(@RequestParam Long formId) { + return this.runEngineTask(() -> onlineImportExportTemplateService.getTemplateStatus(formId)); + } + + /** + * 下载导入失败数据。失败文件为「原始导入列 + 失败原因」,可改完直接重传。 + * + * @param fileKey 失败文件标识。 + * @throws IOException 写响应失败时抛出。 + */ + @OperationLog(type = SysOperationLogType.DOWNLOAD, saveResponse = false) + @GetMapping("/downloadImportFailFile") + public void downloadImportFailFile(@RequestParam String fileKey) throws IOException { + ImportExportExcelUtil.writeFailFileToResponse(fileKey, onlineProperties.getUploadFileBaseDir(), "importedFile/fail"); + } + + /** + * 导出(模板驱动的新引擎)。前端据 {@code getImportExportTemplateStatus} 的 enabled 决定走本接口还是老的 + * {@code exportByDatasourceId}。 + * + *

按主表记录数分档:不超过 {@code exportAsyncRowThreshold}(默认 5 万)时同步导出、**直接写响应流**; + * 超过时转异步任务,**手动写 JSON** 返回 taskId。因两条分支都自己写响应体,故方法返回 void + * (若声明为 ResponseResult,Spring 会在已写出的 Excel 流后追加 JSON,导致文件损坏)。

+ * + * @param datasourceVariableName 数据源变量名(仅用于日志与默认文件名)。 + * @param datasourceId 数据源id。 + * @param formId 表单id。 + * @param filterDtoList 过滤条件。 + * @param orderParam 排序参数。 + * @throws IOException 写响应失败时抛出。 + */ + @OperationLog(type = SysOperationLogType.EXPORT, saveResponse = false) + @PostMapping("/exportExecute/{datasourceVariableName}") + public void exportExecute( + @PathVariable("datasourceVariableName") String datasourceVariableName, + @MyRequestBody(required = true) Long datasourceId, + @MyRequestBody(required = true) Long formId, + @MyRequestBody List filterDtoList, + @MyRequestBody MyOrderParam orderParam) throws IOException { + OnlineExportResultDto resultDto; + try { + resultDto = this.doExportExecute(datasourceId, formId, filterDtoList, orderParam); + } catch (OnlineRuntimeException e) { + // 业务异常转成错误结果,避免落到全局异常处理器变成 500 + ResponseResult.output(HttpServletResponse.SC_BAD_REQUEST, + ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, e.getMessage())); + return; + } + if (Boolean.TRUE.equals(resultDto.getAsync())) { + // 异步:同步分支已把 Excel 写进响应流,这里只在异步分支手动写 JSON + ResponseResult.output(HttpServletResponse.SC_OK, ResponseResult.success(resultDto)); + } + } + + /** + * 导出分档执行:主表记录数不超过阈值走同步(写响应流),超过走异步(建任务、返回 taskId)。 + */ + private OnlineExportResultDto doExportExecute(Long datasourceId, Long formId, List filterDtoList, MyOrderParam orderParam) { + int masterRowCount = onlineExportDataService.countMasterRows(formId, datasourceId, filterDtoList); + Integer threshold = onlineProperties.getExportAsyncRowThreshold(); + int asyncThreshold = threshold == null || threshold <= 0 ? 50000 : threshold; + if (masterRowCount <= asyncThreshold) { + return onlineExportDataService.exportToResponse(formId, datasourceId, filterDtoList, orderParam); + } + Long templateId = onlineExportDataService.getEnabledTemplateId(formId); + OnlineExportParamDto param = new OnlineExportParamDto(); + param.setDatasourceId(datasourceId); + param.setFilterDtoList(filterDtoList); + param.setOrderParam(orderParam); + OnlineImportExportTask task = onlineImportExportTaskService.submitExportTask(formId, templateId, param, masterRowCount); + OnlineExportResultDto resultDto = new OnlineExportResultDto(); + resultDto.setAsync(Boolean.TRUE); + resultDto.setTaskId(task.getId()); + resultDto.setMasterRowCount(masterRowCount); + return resultDto; + } + + /** + * 下载导出结果文件(异步导出的产物)。下载后服务端副本立即删除。 + * + * @param fileKey 结果文件标识(任务对象的 resultFileKey)。 + * @throws IOException 写响应失败时抛出。 + */ + @OperationLog(type = SysOperationLogType.DOWNLOAD, saveResponse = false) + @GetMapping("/downloadExportFile") + public void downloadExportFile(@RequestParam String fileKey) throws IOException { + // 文件名按任务上的 formId 反查表单名生成,与同步导出保持同一口径。 + String fileName = onlineImportExportTaskService.getExportDownloadFileName(fileKey); + ImportExportExcelUtil.writeResultFileToResponse(fileKey, onlineProperties.getUploadFileBaseDir(), + OnlineExportDataServiceImpl.EXPORT_SUB_DIR, fileName); + } + + /** + * 执行新引擎任务并把业务异常转成错误结果,避免落到全局异常处理器变成 500。 + * + * @param supplier 任务。 + * @param 返回类型。 + * @return 应答结果。 + */ + private ResponseResult runEngineTask(java.util.function.Supplier supplier) { + try { + return ResponseResult.success(supplier.get()); + } catch (OnlineRuntimeException e) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, e.getMessage()); + } + } + private void exeImportPlugin( List> dataList, Long formId, diff --git a/common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportFieldMapper.java b/common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportFieldMapper.java new file mode 100644 index 0000000..79c82e5 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportFieldMapper.java @@ -0,0 +1,13 @@ +package apelet.common.online.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.online.model.OnlineImportExportField; + +/** + * 在线表单导入导出模板字段配置数据操作访问接口。 + * + * @author chenchuchuan + * @date 2026-09-17 + */ +public interface OnlineImportExportFieldMapper extends BaseDaoMapper { +} diff --git a/common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTaskMapper.java b/common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTaskMapper.java new file mode 100644 index 0000000..f880c8b --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTaskMapper.java @@ -0,0 +1,13 @@ +package apelet.common.online.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.online.model.OnlineImportExportTask; + +/** + * 在线表单导入导出异步任务数据操作访问接口。 + * + * @author chenchuchuan + * @date 2026-09-18 + */ +public interface OnlineImportExportTaskMapper extends BaseDaoMapper { +} diff --git a/common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTemplateMapper.java b/common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTemplateMapper.java new file mode 100644 index 0000000..2b96da8 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTemplateMapper.java @@ -0,0 +1,26 @@ +package apelet.common.online.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.online.model.OnlineImportExportTemplate; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 在线表单导入导出模板数据操作访问接口。 + * + * @author chenchuchuan + * @date 2026-09-17 + */ +public interface OnlineImportExportTemplateMapper extends BaseDaoMapper { + + /** + * 获取过滤后的对象列表。 + * + * @param templateFilter 主表过滤对象。 + * @param orderBy 排序字符串,order by从句的参数。 + * @return 对象列表。 + */ + List getOnlineFormList( + @Param("templateFilter") OnlineImportExportTemplate templateFilter, @Param("orderBy") String orderBy); +} diff --git a/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineImportExportTemplateMapper.xml b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineImportExportTemplateMapper.xml new file mode 100644 index 0000000..b81183c --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineImportExportTemplateMapper.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + AND zz_online_import_export_template.tenant_id IS NULL + + + AND zz_online_import_export_template.tenant_id = #{templateFilter.tenantId} + + + AND zz_online_import_export_template.app_code IS NULL + + + AND zz_online_import_export_template.app_code = #{templateFilter.appCode} + + + AND zz_online_import_export_template.master_head_id = #{templateFilter.masterHeadId} + + + AND zz_online_import_export_template.master_table_name = #{templateFilter.masterTableName} + + + AND zz_online_import_export_template.is_enabled = #{templateFilter.isEnabled} + + + + AND zz_online_import_export_template.template_name LIKE #{safeTemplateName} + + + + + + diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineExportParamDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineExportParamDto.java new file mode 100644 index 0000000..d2a9699 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineExportParamDto.java @@ -0,0 +1,38 @@ +package apelet.common.online.dto; + +import apelet.common.core.object.MyOrderParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 导出任务参数:提交时抓取,异步线程执行时还原。 + * + *

以 JSON 形式存进 {@code zz_online_import_export_task.param_json}。

+ * + * @author chenchuchuan + * @date 2026-09-21 + */ +@Data +@Schema(description = "导出任务参数") +public class OnlineExportParamDto { + + /** + * 数据源id。 + */ + @Schema(description = "数据源id") + private Long datasourceId; + + /** + * 过滤条件。 + */ + @Schema(description = "过滤条件") + private List filterDtoList; + + /** + * 排序参数。 + */ + @Schema(description = "排序参数") + private MyOrderParam orderParam; +} diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineExportResultDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineExportResultDto.java new file mode 100644 index 0000000..596de5b --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineExportResultDto.java @@ -0,0 +1,50 @@ +package apelet.common.online.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 导出执行结果。 + * + *

同步导出(行数不超过阈值)直接写响应流,返回时 {@code async=false}; + * 异步导出(超过阈值)只返回 {@code taskId},前端轮询 + * {@code /onlineOperation/getImportExportTaskStatus} 直到 {@code isFinished()}, + * 再凭 {@code resultFileKey} 调下载接口。

+ * + * @author chenchuchuan + * @date 2026-09-21 + */ +@Data +@Schema(description = "导出执行结果") +public class OnlineExportResultDto { + + /** + * 是否转异步任务:true-已建任务,前端需轮询;false-已同步写出文件。 + */ + @Schema(description = "是否转异步任务") + private Boolean async = Boolean.FALSE; + + /** + * 异步任务id(仅 async=true 时有值)。 + */ + @Schema(description = "异步任务id") + private Long taskId; + + /** + * 主表记录数(用于前端提示与分档判断)。 + */ + @Schema(description = "主表记录数") + private Integer masterRowCount; + + /** + * 展开后的总行数(主记录 + 分录明细,即 Excel 行数;仅同步导出时可得)。 + */ + @Schema(description = "展开后的总行数") + private Integer totalRowCount; + + /** + * 结果文件标识(文件名;仅异步导出落盘后有值),供前端调下载接口。 + */ + @Schema(description = "结果文件标识") + private String resultFileKey; +} diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportErrorDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportErrorDto.java new file mode 100644 index 0000000..69ff275 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportErrorDto.java @@ -0,0 +1,51 @@ +package apelet.common.online.dto; + +import lombok.Data; + +/** + * 导入校验/执行的一条错误(失败)明细。 + * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Data +public class OnlineImportErrorDto { + + /** + * Excel 行号(1 起,含表头行)。 + */ + private Integer rowNum; + + /** + * 列名(表头展示名)。 + */ + private String columnName; + + /** + * 单元格值(原样回显,便于用户定位)。 + */ + private String cellValue; + + /** + * 错误原因。 + */ + private String reason; + + /** + * 构造一条行内错误。 + * + * @param rowNum 行号。 + * @param columnName 列名。 + * @param cellValue 单元格值。 + * @param reason 原因。 + * @return 错误对象。 + */ + public static OnlineImportErrorDto of(Integer rowNum, String columnName, String cellValue, String reason) { + OnlineImportErrorDto errorDto = new OnlineImportErrorDto(); + errorDto.setRowNum(rowNum); + errorDto.setColumnName(columnName); + errorDto.setCellValue(cellValue); + errorDto.setReason(reason); + return errorDto; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTableDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTableDto.java new file mode 100644 index 0000000..8b4de08 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTableDto.java @@ -0,0 +1,40 @@ +package apelet.common.online.dto; + +import apelet.common.online.model.OnlineImportExportField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 导入导出模板的「表 → 字段」两级树节点。 + * + *

模板字段配置在库里是扁平的(zz_online_import_export_field),但对前端展示、 + * 以及 save 往返而言,按表分组更直观:一个节点 = 一张表(主表或某张分录表), + * 其下 fieldList = 该表的字段配置。

+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Data +@Schema(description = "导入导出模板的表级字段分组") +public class OnlineImportExportTableDto { + + /** + * 所属物理表名(主表或分录表)。 + */ + @Schema(description = "所属物理表名") + private String tableName; + + /** + * 表描述,供前端展示分组标题。 + */ + @Schema(description = "表描述") + private String tableLabel; + + /** + * 该表的字段配置列表。 + */ + @Schema(description = "该表下的字段配置列表") + private List fieldList; +} diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTemplateStatusDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTemplateStatusDto.java new file mode 100644 index 0000000..6b25169 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTemplateStatusDto.java @@ -0,0 +1,48 @@ +package apelet.common.online.dto; + +import lombok.Data; + +/** + * 导入导出模板的链路状态:前端据此决定调新引擎接口还是原有接口。 + * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Data +public class OnlineImportExportTemplateStatusDto { + + /** + * 该表单是否已启用导入导出模板。false 表示走原有链路。 + */ + private Boolean enabled = Boolean.FALSE; + + /** + * 生效模板id。enabled 为 false 时为空。 + */ + private Long templateId; + + /** + * 生效模板名称。 + */ + private String templateName; + + /** + * 表单元数据id。 + */ + private Long masterHeadId; + + /** + * 主表物理表名。 + */ + private String masterTableName; + + /** + * 已勾选导入的字段数。 + */ + private Integer importColumnCount; + + /** + * 已勾选导出的字段数。 + */ + private Integer exportColumnCount; +} diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportGroupDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportGroupDto.java new file mode 100644 index 0000000..0729e30 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportGroupDto.java @@ -0,0 +1,73 @@ +package apelet.common.online.dto; + +import lombok.Data; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 导入分组结果:一条主记录及其名下的分录行。 + * + *

分组规则:全文件内**所有主表列值完全相同**的行归为同一条主记录,不要求相邻。

+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Data +public class OnlineImportGroupDto { + + /** + * 该主记录首次出现的 Excel 行号,用于错误定位。 + */ + private Integer firstRowNum; + + /** + * 主表列 → 值(取自该主记录首次出现的那一行)。 + */ + private Map masterValueMap = new LinkedHashMap<>(); + + /** + * 该主记录下的**全部原始行**(保持文件顺序)。 + * 失败 Excel 按它还原用户上传的内容;也用于判断该组是否有失败行。 + */ + private List sourceRowList = java.util.Collections.emptyList(); + + /** + * 分录表名 → 该主记录名下的分录行(保持文件顺序)。 + */ + private Map> entryRowMap = new LinkedHashMap<>(); + + /** + * 覆盖模式下匹配到的已有主记录主键;为空表示新增。写入阶段回填。 + */ + private Object existId; + + /** + * 该主记录的分录明细总数。 + * + * @return 明细条数。 + */ + public int getEntryCount() { + return entryRowMap.values().stream().mapToInt(List::size).sum(); + } + + /** + * 该主记录下是否已标记失败的行。 + * + * @return 存在则返回true。 + */ + public boolean hasFailedRow() { + return this.sourceRowList.stream().anyMatch(OnlineImportRowDto::isFailed); + } + + /** + * 该主记录下已标记失败的行。 + * + * @return 行列表。 + */ + public List getFailedRowList() { + return this.sourceRowList.stream().filter(OnlineImportRowDto::isFailed).collect(Collectors.toList()); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportParamDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportParamDto.java new file mode 100644 index 0000000..2f6064a --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportParamDto.java @@ -0,0 +1,40 @@ +package apelet.common.online.dto; + +import lombok.Data; + +/** + * 导入入参。 + * + *

写入模式与匹配键由前端在导入时传入,不落库。

+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Data +public class OnlineImportParamDto { + + /** + * 数据源Id。必填。 + */ + private Long datasourceId; + + /** + * 模板Id。必填。 + */ + private Long templateId; + + /** + * 阶段一返回的文件标识,阶段二原样带回。阶段二必填。 + */ + private String fileKey; + + /** + * 写入模式:INSERT/UPSERT/UPDATE。为空按 INSERT 处理。 + */ + private String writeMode; + + /** + * 覆盖模式的匹配键,逗号分隔的字段物理列名。为空时按该模板字段配置中的主键列推导。 + */ + private String matchColumns; +} diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportResultDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportResultDto.java new file mode 100644 index 0000000..31127d3 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportResultDto.java @@ -0,0 +1,105 @@ +package apelet.common.online.dto; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 导入结果。阶段一(校验)与阶段二(执行)共用。 + * + *

计数口径:报「主记录数 + 分录明细数」,不是 Excel 行数 —— 扁平格式下多行可能合并为一条主记录。

+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Data +public class OnlineImportResultDto { + + /** + * Excel 数据行数(不含表头行)。行数上限按它计算。 + */ + private Integer totalRowCount; + + /** + * 解析出的主记录数。 + */ + private Integer masterCount; + + /** + * 解析出的分录明细数。 + */ + private Integer entryCount; + + /** + * 阶段二:成功写入的主记录数。 + */ + private Integer successCount; + + /** + * 阶段二:失败的主记录数。 + */ + private Integer failCount; + + /** + * 错误/失败清单,最多 {@code MAX_ERROR_SIZE} 条。 + */ + private List errorList = new ArrayList<>(); + + /** + * 错误总数。清单被截断时大于 errorList.size()。 + */ + private Integer errorTotal; + + /** + * 失败文件标识(文件名)。有失败行时才有值,前端据此调下载接口拿「原始列 + 失败原因」的 Excel。 + */ + private String failFileKey; + + /** + * 异步任务id。**有值表示本次转成了后台任务**,前端需轮询任务状态; + * 为空表示同步执行完成,结果就在本对象里。 + */ + private Long taskId; + + /** + * 追加一条错误,超出上限时只累加总数。 + * + * @param errorDto 错误明细。 + */ + public void addError(OnlineImportErrorDto errorDto) { + if (this.errorTotal == null) { + this.errorTotal = 0; + } + this.errorTotal = this.errorTotal + 1; + if (this.errorList.size() < MAX_ERROR_SIZE) { + this.errorList.add(errorDto); + } + } + + /** + * 追加一条错误(按行号+列名+值+原因构造)。 + * + * @param rowNum 行号。 + * @param columnName 列名。 + * @param cellValue 单元格值。 + * @param reason 原因。 + */ + public void addError(Integer rowNum, String columnName, String cellValue, String reason) { + this.addError(OnlineImportErrorDto.of(rowNum, columnName, cellValue, reason)); + } + + /** + * 是否存在错误。 + * + * @return 有错误返回true。 + */ + public boolean hasError() { + return this.errorTotal != null && this.errorTotal > 0; + } + + /** + * 错误清单最多展示条数。 + */ + public static final int MAX_ERROR_SIZE = 200; +} diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportRowDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportRowDto.java new file mode 100644 index 0000000..076a517 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportRowDto.java @@ -0,0 +1,104 @@ +package apelet.common.online.dto; + +import lombok.Data; +import org.apache.commons.lang3.StringUtils; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 导入 Excel 的一行数据。 + * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Data +public class OnlineImportRowDto { + + /** + * Excel 行号(1 起,含表头行),用于错误清单定位。 + */ + private Integer rowNum; + + /** + * 单元格值:`表名.列名` → 单元格原始值。 + * + *

用「表名.列名」而不是裸列名做键:**主表与分录表可能有同名列**(例如两边都有 number/name), + * 裸列名会互相覆盖,导致主记录被写成分录的值、分组也失去意义。

+ */ + private Map valueMap = new LinkedHashMap<>(); + + /** + * 该行的失败原因。为空表示通过;非空会被写入失败 Excel 并阻止其所在主记录写入。 + */ + private String failReason; + + /** + * 反查后的实际入库值:`表名.列名` → 实际值。 + * 例如关联字段按名称/编码反查出的主键、字典字段的名称换成字典项 id。 + * + *

两个要点:① 与 {@link #valueMap} 分开存 —— 失败文件要回显用户原始输入,写入时才用这里的值; + * ② 用「表名.列名」而不是裸列名做键 —— 同一个行对象同时参与主表与分录表的解析, + * 而主表与分录表可能有同名列。

+ */ + private Map resolvedValueMap = new LinkedHashMap<>(); + + /** + * 记录某表某列的单元格值。 + * + * @param tableName 物理表名。 + * @param columnName 字段物理列名。 + * @param value 单元格原始值。 + */ + public void putValue(String tableName, String columnName, Object value) { + this.valueMap.put(tableName + "." + columnName, value); + } + + /** + * 取指定表指定列的原始值。 + * + * @param tableName 物理表名。 + * @param columnName 字段物理列名。 + * @return 单元格原始值;不存在时返回null。 + */ + public Object getValue(String tableName, String columnName) { + return valueMap.get(tableName + "." + columnName); + } + + /** + * 记录某表某字段反查后的实际入库值。 + * + * @param tableName 物理表名。 + * @param columnName 字段物理列名。 + * @param value 实际入库值。 + */ + public void putResolvedValue(String tableName, String columnName, Object value) { + this.resolvedValueMap.put(tableName + "." + columnName, value); + } + + /** + * 标记该行失败。同一行的多个问题**全部保留**(用逗号连接、重复的不再追加), + * 让用户在失败文件里一次看到所有要改的地方,而不是改一条传一次。 + * + * @param reason 失败原因。 + */ + public void markFailed(String reason) { + if (StringUtils.isBlank(reason)) { + return; + } + if (StringUtils.isBlank(this.failReason)) { + this.failReason = reason; + } else if (!StringUtils.contains(this.failReason, reason)) { + this.failReason = this.failReason + "," + reason; + } + } + + /** + * 该行是否已标记失败。 + * + * @return 失败返回true。 + */ + public boolean isFailed() { + return StringUtils.isNotBlank(this.failReason); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportSheetDto.java b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportSheetDto.java new file mode 100644 index 0000000..1382ff5 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/dto/OnlineImportSheetDto.java @@ -0,0 +1,42 @@ +package apelet.common.online.dto; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 导入 Excel 解析结果。 + * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Data +public class OnlineImportSheetDto { + + /** + * 表头中缺失的「导入且必填」字段展示名。 + */ + private List missingRequiredColumns = new ArrayList<>(); + + /** + * 表头中实际命中的字段物理列名,用于校验「匹配键必须出现在表头中」。 + */ + private Set matchedColumnNames = new LinkedHashSet<>(); + + /** + * 数据行,已跳过整行空行。 + */ + private List rowList = new ArrayList<>(); + + /** + * 数据行数(不含表头行)。 + * + * @return 行数。 + */ + public int getRowCount() { + return rowList.size(); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportField.java b/common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportField.java new file mode 100644 index 0000000..94cc28c --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportField.java @@ -0,0 +1,143 @@ +package apelet.common.online.model; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 在线表单导入导出模板字段配置实体对象。 + * + *

每行描述一个字段在该模板中的导入导出行为。tableName 标明该字段属于哪张物理表 + * (主表或分录表);某分录表一个字段都没有配置,等价于该模板不包含该分录表。

+ * + * @author chenchuchuan + * @date 2026-09-17 + */ +@Data +@TableName(value = "zz_online_import_export_field") +public class OnlineImportExportField { + + /** + * 主键Id。 + */ + @TableId(value = "id") + private Long id; + + /** + * 模板id(zz_online_import_export_template.id)。 + */ + @TableField(value = "template_id") + private Long templateId; + + /** + * 所属物理表名(主表或分录表)。 + */ + @TableField(value = "table_name") + private String tableName; + + /** + * 字段物理列名。 + */ + @TableField(value = "column_name") + private String columnName; + + /** + * 字段描述(保存时的快照,供列表展示)。 + */ + @TableField(value = "field_remark") + private String fieldRemark; + + /** + * 表头展示名(同一模板内唯一)。主表列默认取 fieldRemark,分录列默认取「表描述-fieldRemark」。 + */ + @TableField(value = "show_name") + private String showName; + + /** + * 是否主键列:0-否,1-是。 + */ + @TableField(value = "is_key") + private Integer isKey; + + /** + * 是否必填:0-否,1-是。 + */ + @TableField(value = "is_required") + private Integer isRequired; + + /** + * 是否导入:0-否,1-是。 + */ + @TableField(value = "is_import") + private Integer isImport; + + /** + * 是否导出:0-否,1-是。 + */ + @TableField(value = "is_export") + private Integer isExport; + + /** + * Excel列宽;为空表示使用默认宽度。 + */ + @TableField(value = "excel_width") + private Integer excelWidth; + + /** + * 关联字段导入属性:CODE-编码,NAME-名称,PK-主键;非关联字段为空。 + */ + @TableField(value = "import_attr") + private String importAttr; + + /** + * 关联字段导出属性:目标表列名;为空表示输出裸外键id。 + */ + @TableField(value = "export_attr") + private String exportAttr; + + /** + * 列顺序。 + */ + @TableField(value = "sort_no") + private Integer sortNo; + + /** + * 租户ID。 + */ + @TableField(value = "tenant_id") + private Long tenantId; + + /** + * 创建时间。 + */ + @TableField(value = "create_time") + private Date createTime; + + /** + * 创建人ID。 + */ + @TableField(value = "create_user_id") + private Long createUserId; + + /** + * 更新时间。 + */ + @TableField(value = "update_time") + private Date updateTime; + + /** + * 更新人ID。 + */ + @TableField(value = "update_user_id") + private Long updateUserId; + + /** + * 是否 F7 关联字段(onl_form_field.ref_propert 非空,含单选 402 与多选 404):0-否,1-是。 + * 非数据库列,供前端判断该列是否需要配置关联属性(importAttr / exportAttr)。 + */ + @TableField(exist = false) + private Integer isF7; +} diff --git a/common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTask.java b/common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTask.java new file mode 100644 index 0000000..b103337 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTask.java @@ -0,0 +1,141 @@ +package apelet.common.online.model; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 在线表单导入导出异步任务实体对象。 + * + *

导入 / 导出的行数超过配置阈值时转为后台任务,前端凭 taskId 轮询状态与进度。

+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Data +@TableName(value = "zz_online_import_export_task") +public class OnlineImportExportTask { + + /** + * 任务Id。 + */ + @TableId(value = "id") + private Long id; + + /** + * 任务类型:IMPORT-导入,EXPORT-导出。 + * + * @see apelet.common.online.model.constant.OnlineImportExportTaskType + */ + @TableField(value = "task_type") + private String taskType; + + /** + * 表单id。 + */ + @TableField(value = "form_id") + private Long formId; + + /** + * 模板id。 + */ + @TableField(value = "template_id") + private Long templateId; + + /** + * 状态:QUEUED/RUNNING/SUCCESS/PARTIAL/FAILED。 + * + * @see apelet.common.online.model.constant.OnlineImportExportTaskStatus + */ + @TableField(value = "status") + private String status; + + /** + * 总行数。 + */ + @TableField(value = "total_row_count") + private Integer totalRowCount; + + /** + * 已处理行数,前端进度条用。 + */ + @TableField(value = "processed_count") + private Integer processedCount; + + /** + * 成功的主记录数。 + */ + @TableField(value = "success_count") + private Integer successCount; + + /** + * 失败的主记录数。 + */ + @TableField(value = "fail_count") + private Integer failCount; + + /** + * 源文件标识(导入的上传文件)。 + */ + @TableField(value = "source_file_key") + private String sourceFileKey; + + /** + * 结果文件标识(导出的结果文件 / 导入的失败数据文件)。 + */ + @TableField(value = "result_file_key") + private String resultFileKey; + + /** + * 任务参数(JSON)。导出任务用:datasourceId / filterDtoList / orderParam + * —— 这些是提交时才能确定的运行时条件,异步线程执行时需要还原。 + * 导入任务不用(入库参数 writeMode / matchColumns 已单独成列)。 + */ + @TableField(value = "param_json") + private String paramJson; + + /** + * 任务级失败原因。 + */ + @TableField(value = "error_message") + private String errorMessage; + + /** + * 租户id。 + */ + @TableField(value = "tenant_id") + private Long tenantId; + + /** + * 应用编码。 + */ + @TableField(value = "app_code") + private String appCode; + + /** + * 创建时间。 + */ + @TableField(value = "create_time") + private Date createTime; + + /** + * 创建者。 + */ + @TableField(value = "create_user_id") + private Long createUserId; + + /** + * 更新时间。 + */ + @TableField(value = "update_time") + private Date updateTime; + + /** + * 更新者。 + */ + @TableField(value = "update_user_id") + private Long updateUserId; +} diff --git a/common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTemplate.java b/common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTemplate.java new file mode 100644 index 0000000..7c7fc51 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTemplate.java @@ -0,0 +1,121 @@ +package apelet.common.online.model; + +import apelet.common.core.annotation.RelationOneToMany; +import apelet.common.generator.model.OnlFormHead; +import apelet.common.online.dto.OnlineImportExportTableDto; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; +import java.util.List; + +/** + * 在线表单导入导出模板实体对象。 + * + *

一张表单元数据可创建多套模板,但至多一套处于启用状态(isEnabled = 1)。 + * 未启用任何模板的表,导入导出走原有链路。

+ * + * @author chenchuchuan + * @date 2026-09-17 + */ +@Data +@TableName(value = "zz_online_import_export_template") +public class OnlineImportExportTemplate { + + /** + * 主键Id。 + */ + @TableId(value = "id") + private Long id; + + /** + * 模板名称(同一表单元数据下唯一)。 + */ + @TableField(value = "template_name") + private String templateName; + + /** + * 表单元数据id(onl_form_head.id)。 + */ + @TableField(value = "master_head_id") + private Long masterHeadId; + + /** + * 主表物理表名(冗余,路由时按它查询,避免每次反查表单元数据)。 + */ + @TableField(value = "master_table_name") + private String masterTableName; + + /** + * 导入扩展插件类名。 + * + *

预留字段,本期未实现插件机制:引擎不会读取它,填了也不生效。 + * 提前建列是为了将来实现 PRD FR-5 时不必再 ALTER 表。

+ */ + @TableField(value = "plugin_class") + private String pluginClass; + + /** + * 是否启用:0-否,1-是。一张表至多一条为1。 + */ + @TableField(value = "is_enabled") + private Integer isEnabled; + + /** + * 租户id。 + */ + @TableField(value = "tenant_id") + private Long tenantId; + + /** + * 应用编码。 + */ + @TableField(value = "app_code") + private String appCode; + + /** + * 创建时间。 + */ + @TableField(value = "create_time") + private Date createTime; + + /** + * 创建者。 + */ + @TableField(value = "create_user_id") + private Long createUserId; + + /** + * 更新时间。 + */ + @TableField(value = "update_time") + private Date updateTime; + + /** + * 更新者。 + */ + @TableField(value = "update_user_id") + private Long updateUserId; + + /** + * 表单元数据(主表表头),非数据库列。 + */ + @TableField(exist = false) + private OnlFormHead masterHead; + + /** + * 模板字段配置列表,非数据库列。 + */ + @RelationOneToMany(masterIdField = "id", slaveModelClass = OnlineImportExportField.class, slaveIdField = "templateId") + @TableField(exist = false) + private List onlineImportExportFields; + + /** + * 模板字段配置的「表 → 字段」两级树(主表 1 个节点 + 每张分录表各 1 个节点),非数据库列。 + * 用于 save 入参 / view、generateDefaultFields 出参;落库前会展平成 onlineImportExportFields。 + */ + @TableField(exist = false) + private List tableList; +} diff --git a/common/common-online/src/main/java/apelet/common/online/model/constant/ImportAttrEnum.java b/common/common-online/src/main/java/apelet/common/online/model/constant/ImportAttrEnum.java new file mode 100644 index 0000000..41108d9 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/constant/ImportAttrEnum.java @@ -0,0 +1,65 @@ +package apelet.common.online.model.constant; + +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 关联字段的导入属性枚举。 + * + *

决定 Excel 单元格里填什么值来定位关联记录:主键直接匹配;编码/名称需要先按约定列反查目标表主键。

+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +public enum ImportAttrEnum { + + PK("PK", "主键"), + CODE("CODE", "编码"), + NAME("NAME", "名称"); + + @Getter + private final String code; + + @Getter + private final String description; + + private static final Map ENUM_MAP; + + ImportAttrEnum(String code, String description) { + this.code = code; + this.description = description; + } + + static { + ENUM_MAP = Arrays.stream(values()).collect(Collectors.toMap(ImportAttrEnum::getCode, e -> e)); + } + + /** + * 根据 code 获取枚举对象,空值按主键处理(非关联字段、或管理员未配置时)。 + * + * @param code 导入属性码。 + * @return 枚举对象。 + */ + public static ImportAttrEnum ofNullable(String code) { + if (StringUtils.isBlank(code)) { + return PK; + } + return Optional.ofNullable(ENUM_MAP.get(code.toUpperCase())).orElseThrow(() -> new IllegalArgumentException("无法根据 code 获取关联字段导入属性:" + code)); + } + + /** + * 根据 code 获取枚举对象,空值和未知 code 都返回 null(不兜底为 PK、也不抛异常)。 + * 供「该配置值既可能是属性编码、也可能是物理列名」的场景使用。 + * + * @param code 属性码。 + * @return 枚举对象;不是已知编码时返回 null。 + */ + public static ImportAttrEnum parse(String code) { + return StringUtils.isBlank(code) ? null : ENUM_MAP.get(code.trim().toUpperCase()); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/model/constant/ImportExportEnabledStatus.java b/common/common-online/src/main/java/apelet/common/online/model/constant/ImportExportEnabledStatus.java new file mode 100644 index 0000000..d1c9dcb --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/constant/ImportExportEnabledStatus.java @@ -0,0 +1,59 @@ +package apelet.common.online.model.constant; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 导入导出模板启用状态枚举。 + * + *

一张表单元数据下至多一套模板处于启用状态;该表没有启用模板时,导入导出走原有链路。

+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +public enum ImportExportEnabledStatus { + + DISABLED(0, "停用"), + ENABLED(1, "启用"); + + @Getter + private final Integer code; + + @Getter + private final String description; + + private static final Map ENUM_MAP; + + ImportExportEnabledStatus(Integer code, String description) { + this.code = code; + this.description = description; + } + + static { + ENUM_MAP = Arrays.stream(values()).collect(Collectors.toMap(ImportExportEnabledStatus::getCode, e -> e)); + } + + /** + * 根据 code 获取枚举对象。 + * + * @param code 启用状态码。 + * @return 枚举对象。 + */ + public static ImportExportEnabledStatus of(Integer code) { + return Optional.ofNullable(ENUM_MAP.get(code)).orElseThrow(() -> new IllegalArgumentException("无法根据 code 获取导入导出模板启用状态:" + code)); + } + + /** + * 判断给定状态码是否为启用。 + * + * @param code 启用状态码,可为null。 + * @return 启用返回true,否则false。 + */ + public static boolean isEnabled(Integer code) { + return ENABLED.code.equals(code); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/model/constant/ImportWriteModeEnum.java b/common/common-online/src/main/java/apelet/common/online/model/constant/ImportWriteModeEnum.java new file mode 100644 index 0000000..9426f87 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/constant/ImportWriteModeEnum.java @@ -0,0 +1,59 @@ +package apelet.common.online.model.constant; + +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 导入写入模式枚举。 + * + *

由前端在导入时传入,不落库。主表行按「匹配键」匹配、分录行按其 id 匹配:

+ *
    + *
  • 新增:全部新增
  • + *
  • 覆盖:匹配到则更新;未匹配到列入失败清单;键为空则新增
  • + *
  • 覆盖并新增:匹配到则更新;未匹配到或键为空则新增
  • + *
+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +public enum ImportWriteModeEnum { + + INSERT("INSERT", "新增"), + UPDATE("UPDATE", "覆盖"), + UPSERT("UPSERT", "覆盖并新增"); + + @Getter + private final String code; + + @Getter + private final String description; + + private static final Map ENUM_MAP; + + ImportWriteModeEnum(String code, String description) { + this.code = code; + this.description = description; + } + + static { + ENUM_MAP = Arrays.stream(values()).collect(Collectors.toMap(ImportWriteModeEnum::getCode, e -> e)); + } + + /** + * 根据 code 获取枚举对象,空值按「新增」处理(最安全,不会误改已有数据)。 + * + * @param code 写入模式码。 + * @return 枚举对象。 + */ + public static ImportWriteModeEnum ofNullable(String code) { + if (StringUtils.isBlank(code)) { + return INSERT; + } + return Optional.ofNullable(ENUM_MAP.get(code.toUpperCase())).orElseThrow(() -> new IllegalArgumentException("无法根据 code 获取导入写入模式:" + code)); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskStatus.java b/common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskStatus.java new file mode 100644 index 0000000..ba798ad --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskStatus.java @@ -0,0 +1,59 @@ +package apelet.common.online.model.constant; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 导入导出异步任务状态枚举。 + * + * @author chenchuchuan + * @date 2026-09-18 + */ +public enum OnlineImportExportTaskStatus { + + QUEUED("QUEUED", "排队中"), + RUNNING("RUNNING", "执行中"), + SUCCESS("SUCCESS", "全部成功"), + PARTIAL("PARTIAL", "部分成功"), + FAILED("FAILED", "整体失败"); + + @Getter + private final String code; + + @Getter + private final String description; + + private static final Map ENUM_MAP; + + OnlineImportExportTaskStatus(String code, String description) { + this.code = code; + this.description = description; + } + + static { + ENUM_MAP = Arrays.stream(values()).collect(Collectors.toMap(OnlineImportExportTaskStatus::getCode, e -> e)); + } + + /** + * 根据 code 获取枚举对象。 + * + * @param code 状态码。 + * @return 枚举对象。 + */ + public static OnlineImportExportTaskStatus of(String code) { + return Optional.ofNullable(ENUM_MAP.get(code)).orElseThrow(() -> new IllegalArgumentException("无法根据 code 获取导入导出任务状态:" + code)); + } + + /** + * 是否已结束(成功、部分成功或失败)。 + * + * @return 已结束返回true。 + */ + public boolean isFinished() { + return this == SUCCESS || this == PARTIAL || this == FAILED; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskType.java b/common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskType.java new file mode 100644 index 0000000..6a001a8 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskType.java @@ -0,0 +1,26 @@ +package apelet.common.online.model.constant; + +import lombok.Getter; + +/** + * 导入导出异步任务类型枚举。 + * + * @author chenchuchuan + * @date 2026-09-18 + */ +public enum OnlineImportExportTaskType { + + IMPORT("IMPORT", "导入"), + EXPORT("EXPORT", "导出"); + + @Getter + private final String code; + + @Getter + private final String description; + + OnlineImportExportTaskType(String code, String description) { + this.code = code; + this.description = description; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineExportDataService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineExportDataService.java new file mode 100644 index 0000000..eaf7959 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineExportDataService.java @@ -0,0 +1,81 @@ +package apelet.common.online.service; + +import apelet.common.core.object.MyOrderParam; +import apelet.common.online.dto.OnlineExportResultDto; +import apelet.common.online.dto.OnlineFilterDto; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; + +/** + * 在线表单导出数据服务:模板驱动的新引擎。 + * + *

按行数分档:主表记录数不超过 {@code common-online.exportAsyncRowThreshold}(默认 5 万)时 + * 同步导出(直接写响应流,不落盘);超过时转异步任务,结果落盘后由下载接口取走。

+ * + * @author chenchuchuan + * @date 2026-09-21 + */ +public interface OnlineExportDataService { + + /** + * 判断该表单是否可走新导出引擎(已启用模板且模板里有勾选导出的字段)。 + * + * @param formId 表单id。 + * @return 可走新引擎返回 true。 + */ + boolean supportsExport(Long formId); + + /** + * 取该表单启用模板的 id(用于建异步导出任务时记录模板)。 + * + * @param formId 表单id。 + * @return 模板id;无启用模板时返回 null。 + */ + Long getEnabledTemplateId(Long formId); + + /** + * 轻量统计主表记录数(只查 1 条取 totalCount),供同步/异步分档判断。 + * 注意:这是「主记录数」,不是展开后的 Excel 行数(后者需完整取数后才可得)。 + * + * @param formId 表单id。 + * @param datasourceId 数据源id。 + * @param filterDtoList 过滤条件。 + * @return 主记录数。 + */ + int countMasterRows(Long formId, Long datasourceId, List filterDtoList); + + /** + * 同步导出:取数 → 展开 → 补值 → 直接写入 HTTP 响应(不落盘)。 + * + * @param formId 表单id。 + * @param datasourceId 数据源id。 + * @param filterDtoList 过滤条件。 + * @param orderParam 排序参数。 + * @return 执行结果(含行数统计)。 + */ + OnlineExportResultDto exportToResponse(Long formId, Long datasourceId, List filterDtoList, MyOrderParam orderParam); + + /** + * 异步导出:取数 → 展开 → 补值 → 生成 Excel 落盘,返回结果文件标识。 + * 供异步任务线程调用,本身同步执行、不写 HTTP 响应。 + * + * @param formId 表单id。 + * @param datasourceId 数据源id。 + * @param filterDtoList 过滤条件。 + * @param orderParam 排序参数。 + * @return 执行结果,其中 resultFileKey 为落盘文件名。 + */ + OnlineExportResultDto buildExportFile(Long formId, Long datasourceId, List filterDtoList, MyOrderParam orderParam); + + /** + * 导出文件下载名:表单名({@code OnlineForm#formName})+「导出」。 + * 同步导出(写响应流)与异步导出(下载落盘产物)共用,避免两处口径不一致。 + * + * @param formName 表单名。 + * @return 带 .xlsx 后缀的下载文件名。 + */ + static String buildExportFileName(String formName) { + return StringUtils.defaultString(formName) + "导出.xlsx"; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportDataService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportDataService.java new file mode 100644 index 0000000..81710cb --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportDataService.java @@ -0,0 +1,67 @@ +package apelet.common.online.service; + +import apelet.common.online.dto.OnlineImportResultDto; +import org.springframework.web.multipart.MultipartFile; + +/** + * 在线表单导入数据服务:模板驱动的新引擎。 + * + *

校验与入库拆成两个接口,各自接收上传文件(不用 fileKey 在服务端传递):

+ *
    + *
  • {@link #validateImport} —— 只跑**模板层**校验(不碰业务库),零写入
  • + *
  • {@link #executeImport} —— 跑**业务层**校验(碰业务库)后入库
  • + *
+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +public interface OnlineImportExportDataService { + + /** + * 校验(模板层):必填、类型与长度、主表列全空、一行混填多个分录表、 + * 匹配键是否在表头、文件内匹配键重复、行数与文件大小上限。 + * **不查业务库,不写任何数据。** + * + * @param formId 表单id,据此解析该表已启用的模板。 + * @param importFile 上传的Excel文件。 + * @param writeMode 写入模式,可空(空按新增)。 + * @param matchColumns 匹配键,逗号分隔的字段物理列名,可空。 + * @return 校验结果,含主记录数、分录明细数与错误清单。 + */ + OnlineImportResultDto validateImport(Long formId, MultipartFile importFile, String writeMode, String matchColumns); + + /** + * 入库(业务层):先跑一遍模板层校验防绕过,再跑业务校验(字典值是否存在、 + * F7 能否命中、覆盖模式能否匹配、分录 id 归属),最后按批写入。 + * + *

按行数分档:不超过配置阈值时同步返回结果;超过时由上层转为异步任务。 + * 本方法本身只做同步执行。

+ * + * @param formId 表单id。 + * @param importFile 上传的Excel文件(前端第二次上传的同一个文件)。 + * @param writeMode 写入模式,可空。 + * @param matchColumns 匹配键,可空。 + * @return 执行结果,含成功/失败的主记录数与失败文件标识。 + */ + OnlineImportResultDto executeImport(Long formId, MultipartFile importFile, String writeMode, String matchColumns); + + /** + * 入库(按服务端文件路径)。供异步任务使用 —— 任务执行时文件已在盘上,没有 MultipartFile。 + * + * @param formId 表单id。 + * @param filePath 服务端文件绝对路径。 + * @param writeMode 写入模式,可空。 + * @param matchColumns 匹配键,可空。 + * @return 执行结果。 + */ + OnlineImportResultDto executeImportByFilePath(Long formId, String filePath, String writeMode, String matchColumns); + + /** + * 保存上传的导入文件到服务端的导入临时目录,返回文件绝对路径。 + * 供上层「先存盘、再数行数决定同步还是异步」使用。 + * + * @param importFile 上传文件。 + * @return 文件绝对路径。 + */ + String saveImportFile(MultipartFile importFile); +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportFieldService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportFieldService.java new file mode 100644 index 0000000..b9714ec --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportFieldService.java @@ -0,0 +1,23 @@ +package apelet.common.online.service; + +import apelet.common.core.base.service.IBaseService; +import apelet.common.online.model.OnlineImportExportField; + +import java.util.List; + +/** + * 在线表单导入导出模板字段配置数据操作服务接口。 + * + * @author chenchuchuan + * @date 2026-09-17 + */ +public interface OnlineImportExportFieldService extends IBaseService { + + /** + * 获取指定模板下的全部字段配置,按 sortNo 升序。 + * + * @param templateId 模板id。 + * @return 字段配置列表。 + */ + List getListByTemplateId(Long templateId); +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTaskService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTaskService.java new file mode 100644 index 0000000..7a2f415 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTaskService.java @@ -0,0 +1,73 @@ +package apelet.common.online.service; + +import apelet.common.core.base.service.IBaseService; +import apelet.common.online.dto.OnlineExportParamDto; +import apelet.common.online.model.OnlineImportExportTask; + +/** + * 在线表单导入导出异步任务服务。 + * + * @author chenchuchuan + * @date 2026-09-18 + */ +public interface OnlineImportExportTaskService extends IBaseService { + + /** + * 提交一个异步导入任务。任务先以 QUEUED 落库,再投递到线程池; + * 队列满时删除任务并抛业务异常(「系统繁忙,请稍后重试」)。 + * + * @param formId 表单id。 + * @param sourceFileKey 服务端已保存的源文件标识(文件名)。 + * @param templateId 模板id。 + * @param writeMode 写入模式,可空。 + * @param matchColumns 匹配键,可空。 + * @param totalRowCount 总行数,供进度条展示。 + * @return 已落库的任务对象。 + */ + OnlineImportExportTask submitImportTask(Long formId, String sourceFileKey, Long templateId, String writeMode, String matchColumns, int totalRowCount); + + /** + * 提交一个异步导出任务。任务先以 QUEUED 落库,再投递到线程池(与导入任务共用线程池); + * 队列满时删除任务并抛业务异常(「系统繁忙,请稍后重试」)。 + * + * @param formId 表单id。 + * @param templateId 模板id。 + * @param param 导出参数(数据源、过滤条件、排序),会序列化进 param_json。 + * @param masterRowCount 主表记录数,供进度展示。 + * @return 已落库的任务对象。 + */ + OnlineImportExportTask submitExportTask(Long formId, Long templateId, OnlineExportParamDto param, int masterRowCount); + + /** + * 取任务详情(含状态与进度)。 + * + * @param taskId 任务id。 + * @return 任务对象;不存在时返回null。 + */ + OnlineImportExportTask getTaskDetail(Long taskId); + + /** + * 按导出结果文件标识取下载文件名(表单名 +「导出」)。 + * 结果文件由异步导出任务落盘,任务上记着 formId,故用文件标识反查任务即可拿到表单名,前端无需额外传参。 + * + * @param resultFileKey 结果文件标识(任务对象的 resultFileKey)。 + * @return 下载文件名;任务或表单已不存在时返回默认名。 + */ + String getExportDownloadFileName(String resultFileKey); + + /** + * 任务列表查询(排查用)。 + * + * @param filter 过滤对象,支持 taskType、status、formId。 + * @param orderBy 排序从句。 + * @return 任务列表。 + */ + java.util.List getTaskList(OnlineImportExportTask filter, String orderBy); + + /** + * 清理超过保留期的任务记录与其结果文件。 + * + * @return 清理的任务数。 + */ + int cleanExpiredTasks(); +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTemplateService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTemplateService.java new file mode 100644 index 0000000..8caf826 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTemplateService.java @@ -0,0 +1,120 @@ +package apelet.common.online.service; + +import apelet.common.core.base.service.IBaseService; +import apelet.common.online.dto.OnlineImportExportTableDto; +import apelet.common.online.dto.OnlineImportExportTemplateStatusDto; +import apelet.common.online.model.OnlineImportExportField; +import apelet.common.online.model.OnlineImportExportTemplate; + +import java.util.List; + +/** + * 在线表单导入导出模板数据操作服务接口。 + * + * @author chenchuchuan + * @date 2026-09-17 + */ +public interface OnlineImportExportTemplateService extends IBaseService { + + /** + * 获取单表查询结果。由于没有关联数据查询,因此在仅仅获取单表数据的场景下,效率更高。 + * 如果需要同时获取关联数据,请移步(getOnlineFormListWithRelation)方法。 + * + * @param filter 过滤对象。 + * @param orderBy 排序参数。 + * @return 查询结果集。 + */ + List getOnlineFormList(OnlineImportExportTemplate filter, String orderBy); + + /** + * 获取主表的查询结果,以及主表关联的表单元数据。 + * + * @param filter 主表过滤对象。 + * @param orderBy 排序参数。 + * @return 查询结果集。 + */ + List getOnlineFormListWithRelation(OnlineImportExportTemplate filter, String orderBy); + + /** + * 获取指定模板(含字段配置)。 + * + * @param id 模板id。 + * @return 模板对象;不存在时返回null。 + */ + OnlineImportExportTemplate getTemplateDetail(Long id); + + /** + * 获取某张表当前生效(已启用)的模板,含字段配置。带缓存,供导入导出分流与执行使用。 + * + * @param tableName 主表物理表名。 + * @return 生效模板;该表未启用任何模板时返回null。 + */ + OnlineImportExportTemplate getEnabledTemplate(String tableName); + + /** + * 获取某张表当前生效的模板,不走缓存。 + * + * @param tableName 主表物理表名。 + * @return 生效模板;该表未启用任何模板时返回null。 + */ + OnlineImportExportTemplate getEnabledTemplateFromDb(String tableName); + + /** + * 保存模板(新建或编辑),同时整体覆盖字段配置。新建的模板默认停用。 + * + * @param template 模板对象。 + * @param fieldList 字段配置列表。 + * @return 保存后的模板对象。 + */ + OnlineImportExportTemplate saveTemplate(OnlineImportExportTemplate template, List fieldList); + + /** + * 删除模板。若删除的是生效模板,则自动将剩余的第一套设为生效;删空则该表回落原有链路。 + * + * @param id 模板id。 + */ + void deleteTemplate(Long id); + + /** + * 设置模板启用状态。 + * 启用时,同一表单元数据下的其它模板自动停用;停用后该表若无其它生效模板,则回落原有链路。 + * + * @param id 模板id。 + * @param isEnabled 目标启用状态,取值见 ImportExportEnabledStatus。 + */ + void setEnabled(Long id, Integer isEnabled); + + /** + * 按默认规则生成字段配置(不落库,供新建模板与「一键生成」使用)。 + * 默认:全部导出、默认不导入、必填取字段元数据的可空性; + * 展示名:主表列取字段描述,分录列取「表描述-字段描述」。 + * + * @param masterHeadId 表单元数据id。 + * @return 「表 → 字段」两级树(主表 1 个节点 + 每张分录表各 1 个节点)。 + */ + List generateDefaultFields(Long masterHeadId); + + /** + * 把扁平的字段配置按 tableName 分组为「表 → 字段」两级树;表描述取 onl_form_head.tableTxt, + * 取不到时回落表名。仅用于模板详情回显(view),save 仍收扁平结构。 + * + * @param fieldList 扁平字段配置列表。 + * @return 表节点列表,主表节点在最前。 + */ + List groupToTableList(List fieldList); + + /** + * 查询某表单的导入导出链路状态,供前端决定调新引擎接口还是原有接口。 + * + * @param formId 表单id。 + * @return 链路状态;表单不存在或未启用模板时 enabled 为 false。 + */ + OnlineImportExportTemplateStatusDto getTemplateStatus(Long formId); + + /** + * 失效指定表的生效模板缓存。 + * + * @param tableName 主表物理表名。 + */ + void evictEnabledTemplate(String tableName); +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java index 6a9f057..207cee0 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java @@ -197,6 +197,22 @@ public interface OnlineOperationService { */ MyPageData> getSlaveDataList( OnlineDatasourceRelation relation, List filterList, String orderBy, MyPageParam pageParam); + + /** + * 从一对多数据源关联中,动态获取数据列表,并可指定从表的一对一(F7)关联是否强制左连接。 + * + *

F7 外键允许为空,而关系上的 leftJoin 默认 false(自动生成时写死),内连接会把 + * 「F7 没填值」的从表行整条滤掉;导出这类"要全量"的场景需要左连接。

+ * + * @param relation 一对多数据源关联对象。 + * @param filterList 过滤参数列表。 + * @param orderBy 排序字符串。 + * @param pageParam 分页对象。 + * @param forceLeftJoin 是否把从表的一对一关联强制成左连接。 + * @return 查询结果集。 + */ + MyPageData> getSlaveDataList( + OnlineDatasourceRelation relation, List filterList, String orderBy, MyPageParam pageParam, boolean forceLeftJoin); /** * 从一对多数据源关联中,动态获取数据列表。 * diff --git a/common/common-online/src/main/java/apelet/common/online/service/OnlineTableService.java b/common/common-online/src/main/java/apelet/common/online/service/OnlineTableService.java index b688e44..853b379 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/OnlineTableService.java +++ b/common/common-online/src/main/java/apelet/common/online/service/OnlineTableService.java @@ -66,5 +66,11 @@ public interface OnlineTableService extends IBaseService { */ OnlineColumn getOnlineColumnFromCache(Long tableId, Long columnId); - + /** + * 根据表名查询表对象。并获取关联的字段列表。 + * + * @param tableName 表名 + * @return 表对象 + */ + OnlineTable queryByTableName(String tableName); } diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineExportDataServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineExportDataServiceImpl.java new file mode 100644 index 0000000..7ffc563 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineExportDataServiceImpl.java @@ -0,0 +1,696 @@ +package apelet.common.online.service.impl; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.object.*; +import apelet.common.core.util.DefaultDataSourceResolver; +import apelet.common.generator.model.OnlFormField; +import apelet.common.generator.model.OnlFormHead; +import apelet.common.generator.service.IOnlFormFieldService; +import apelet.common.generator.service.IOnlFormHeadService; +import apelet.common.online.config.OnlineProperties; +import apelet.common.online.dto.OnlineExportResultDto; +import apelet.common.online.dto.OnlineFilterDto; +import apelet.common.online.exception.OnlineRuntimeException; +import apelet.common.online.model.*; +import apelet.common.online.model.constant.RelationType; +import apelet.common.online.service.*; +import apelet.common.online.util.ImportExportExcelUtil; +import apelet.common.online.util.OnlineOperationHelper; +import apelet.common.online.util.OnlineDataSourceUtil; +import apelet.common.online.util.RelationValueResolver; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 在线表单导出数据服务实现:模板驱动的新引擎。 + * + *

设计要点(详见《02-导入导出改造分析.md》§3.4 / §4.8 / §5):

+ *
    + *
  1. 取数复用老 SQL 取数层 {@code getMasterDataList / getSlaveDataList}(自带逻辑删除、 + * 租户过滤、数据权限、字典翻译、虚拟列、附件回填;select 全表列,故 id 与 F7 裸 id 都在),不换 orm;
  2. + *
  3. 选列取模板中 isExport=1 的字段,按 sortNo 排序,表头用 showName、列宽用 excelWidth;
  4. + *
  5. 扁平展开与导入同格式(每行分录重复主表列值,按分录表分段、不交错),保证可往返导入;
  6. + *
  7. F7 / 多选补值:老取数层是纯 SQL,拿不到 F7 联表显示值、也读不到中间表, + * 故先取裸外键 id 再按 id 批量 IN 反查(F7),或补查中间表(多选,分号拼接);
  8. + *
  9. 不脱敏:与现有导出行为一致(本期决定不改)。
  10. + *
+ * + * @author chenchuchuan + * @date 2026-09-21 + */ +@Slf4j +@MyDataSourceResolver(resolver = DefaultDataSourceResolver.class, intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE) +@Service("onlineExportDataService") +public class OnlineExportDataServiceImpl implements OnlineExportDataService { + + /** + * 导出结果文件的落盘子目录(相对 uploadFileBaseDir),与 fail 目录平级。 + */ + public static final String EXPORT_SUB_DIR = "importedFile/export"; + + /** + * 多选值在单元格内的分隔符(用分号不用逗号 —— 目标表的值里可能含逗号,与导入侧 splitMultiValue 对称)。 + */ + private static final String MULTI_VALUE_SEPARATOR = ";"; + + /** + * 宿主行主键列名(中间表 biz_id 的取值来源)。 + */ + private static final String ID_COLUMN = "id"; + + @Autowired + private OnlineFormService onlineFormService; + + @Autowired + private OnlineDatasourceService onlineDatasourceService; + + @Autowired + private OnlineTableService onlineTableService; + + @Autowired + private OnlineOperationService onlineOperationService; + + @Autowired + private OnlineOperationHelper onlineOperationHelper; + + @Autowired + private OnlineImportExportTemplateService onlineImportExportTemplateService; + + @Autowired + private OnlineImportExportFieldService onlineImportExportFieldService; + + @Autowired + private RelationValueResolver relationValueResolver; + + @Autowired + private IOnlFormHeadService onlFormHeadService; + + @Autowired + private IOnlFormFieldService onlFormFieldService; + + @Autowired + private OnlineDataSourceUtil dataSourceUtil; + + @Autowired + private OnlineProperties onlineProperties; + + + @Override + public boolean supportsExport(Long formId) { + if (formId == null) { + return false; + } + OnlineForm onlineForm = onlineFormService.getById(formId); + if (onlineForm == null) { + return false; + } + OnlFormHead masterHead = onlFormHeadService.getHeadCacheByTableId(onlineForm.getMasterTableId()); + if (masterHead == null) { + return false; + } + OnlineImportExportTemplate template = onlineImportExportTemplateService.getEnabledTemplate(masterHead.getTableName()); + return template != null && !this.loadExportFields(template).isEmpty(); + } + + @Override + public Long getEnabledTemplateId(Long formId) { + if (formId == null) { + return null; + } + OnlineForm onlineForm = onlineFormService.getById(formId); + if (onlineForm == null) { + return null; + } + OnlFormHead masterHead = onlFormHeadService.getHeadCacheByTableId(onlineForm.getMasterTableId()); + if (masterHead == null) { + return null; + } + OnlineImportExportTemplate template = onlineImportExportTemplateService.getEnabledTemplate(masterHead.getTableName()); + return template == null ? null : template.getId(); + } + + @Override + public int countMasterRows(Long formId, Long datasourceId, List filterDtoList) { + OnlineForm onlineForm = formId == null ? null : onlineFormService.getById(formId); + if (onlineForm == null) { + return 0; + } + OnlFormHead masterHead = onlFormHeadService.getHeadCacheByTableId(onlineForm.getMasterTableId()); + if (masterHead == null) { + return 0; + } + OnlineDatasource datasource = datasourceId == null ? null : onlineDatasourceService.getOnlineDatasourceFromCache(datasourceId); + if (datasource == null) { + return 0; + } + OnlineTable masterTable = onlineTableService.getOnlineTableFromCache(datasource.getMasterTableId()); + if (masterTable == null) { + return 0; + } + List allRelationList = onlineOperationHelper.verifyAndGetRelationList(datasourceId, null).getData(); + if (allRelationList == null) { + allRelationList = Collections.emptyList(); + } + List oneToOneRelationList = this.useLeftJoin(allRelationList.stream() + .filter(r -> r.getRelationType() != null && r.getRelationType() == RelationType.ONE_TO_ONE) + .collect(Collectors.toList())); + // 只查 1 条,拿 totalCount 即可(取数层会带逻辑删除/租户/数据权限过滤,故口径与正式导出一致) + MyPageParam pageParam = new MyPageParam(); + pageParam.setPageNum(1); + pageParam.setPageSize(1); + MyPageData> pageData = onlineOperationService.getMasterDataList(masterTable, + oneToOneRelationList, allRelationList, filterDtoList, null, pageParam, null); + if (pageData == null || pageData.getTotalCount() == null) { + return 0; + } + return pageData.getTotalCount().intValue(); + } + + @Override + public OnlineExportResultDto exportToResponse(Long formId, Long datasourceId, List filterDtoList, MyOrderParam orderParam) { + ExportContext context = this.prepare(formId, datasourceId, filterDtoList, orderParam); + List rowList = this.buildRows(context); + try { + ImportExportExcelUtil.writeExportFileToResponse(context.exportFieldList, this.toValueRows(rowList), + context.sheetName, context.fileName); + } catch (Exception e) { + throw new OnlineRuntimeException("导出失败:" + e.getMessage()); + } + return this.buildResult(context, rowList.size()); + } + + @Override + public OnlineExportResultDto buildExportFile(Long formId, Long datasourceId, List filterDtoList, MyOrderParam orderParam) { + ExportContext context = this.prepare(formId, datasourceId, filterDtoList, orderParam); + List rowList = this.buildRows(context); + OnlineExportResultDto resultDto = this.buildResult(context, rowList.size()); + try { + resultDto.setResultFileKey(ImportExportExcelUtil.writeExportFile(context.exportFieldList, this.toValueRows(rowList), + context.sheetName, onlineProperties.getUploadFileBaseDir(), EXPORT_SUB_DIR)); + } catch (Exception e) { + throw new OnlineRuntimeException("导出失败:" + e.getMessage()); + } + return resultDto; + } + + /** + * 组装导出上下文:表单 → 主表 head → 生效模板 → 导出字段 → 数据源/表/关联 → 字段元数据。 + */ + private ExportContext prepare(Long formId, Long datasourceId, List filterDtoList, MyOrderParam orderParam) { + ExportContext context = new ExportContext(); + context.formId = formId; + context.filterDtoList = filterDtoList; + context.orderParam = orderParam; + + OnlineForm onlineForm = formId == null ? null : onlineFormService.getById(formId); + if (onlineForm == null) { + throw new OnlineRuntimeException("数据验证失败,表单不存在!"); + } + OnlFormHead masterHead = onlFormHeadService.getHeadCacheByTableId(onlineForm.getMasterTableId()); + if (masterHead == null) { + throw new OnlineRuntimeException("数据验证失败,表单元数据不存在!"); + } + context.masterHead = masterHead; + context.template = onlineImportExportTemplateService.getEnabledTemplate(masterHead.getTableName()); + if (context.template == null) { + throw new OnlineRuntimeException("数据验证失败,该表单未启用导入导出模板!"); + } + context.exportFieldList = this.loadExportFields(context.template); + if (context.exportFieldList.isEmpty()) { + throw new OnlineRuntimeException("导出失败,该模板未勾选任何导出字段!"); + } + context.sheetName = context.template.getTemplateName(); + // 下载文件名取表单名(OnlineForm#formName)+「导出」,模板名只作 sheet 名。 + context.fileName = OnlineExportDataService.buildExportFileName(onlineForm.getFormName()); + + OnlineDatasource datasource = datasourceId == null ? null : onlineDatasourceService.getOnlineDatasourceFromCache(datasourceId); + if (datasource == null) { + throw new OnlineRuntimeException("数据验证失败,数据源不存在!"); + } + context.masterTable = onlineTableService.getOnlineTableFromCache(datasource.getMasterTableId()); + if (context.masterTable == null) { + throw new OnlineRuntimeException("数据验证失败,主表不存在!"); + } + List allRelationList = onlineOperationHelper.verifyAndGetRelationList(datasourceId, null).getData(); + context.allRelationList = allRelationList == null ? Collections.emptyList() : allRelationList; + context.oneToOneRelationList = this.useLeftJoin(context.allRelationList.stream() + .filter(r -> r.getRelationType() != null && r.getRelationType() == RelationType.ONE_TO_ONE) + .collect(Collectors.toList())); + // 分录表名 → 关系(模板里勾选了导出字段的分录表) + context.entryRelationMap = new LinkedHashMap<>(); + for (String entryTableName : this.collectEntryTableNames(context.exportFieldList, masterHead.getTableName())) { + OnlineDatasourceRelation relation = context.allRelationList.stream() + .filter(r -> r.getRelationType() != null && r.getRelationType() == RelationType.ONE_TO_MANY) + .filter(r -> r.getSlaveTable() != null && StringUtils.equalsIgnoreCase(r.getSlaveTable().getTableName(), entryTableName)) + .findFirst().orElse(null); + if (relation == null) { + throw new OnlineRuntimeException("数据验证失败,模板中的分录表 [" + entryTableName + "] 不存在于该数据源!"); + } + context.entryRelationMap.put(entryTableName, relation); + } + // 字段元数据(识别 F7 / 多选列):主表 + 各分录表 + context.formFieldMap.put(masterHead.getTableName(), this.loadFormFieldMap(masterHead.getTableName())); + for (String entryTableName : context.entryRelationMap.keySet()) { + context.formFieldMap.put(entryTableName, this.loadFormFieldMap(entryTableName)); + } + context.orderBy = this.buildOrderBy(orderParam); + return context; + } + + /** + * 导出取数时把一对一关联强制成左连接。 + * + *

F7 外键允许为空,而关系上的 {@code leftJoin} 自动生成时写死为 false,内连接会把 + * 「F7 没填值」的主记录整条滤掉 —— 表现为列表看得到数据、导出却是空的。 + * 这里只改本次导出用的对象(关系列表每次都是从 Redis 反序列化出来的副本),不动关系本身的配置, + * 也不影响列表、老导出等其他链路。

+ * + * @param oneToOneRelationList 一对一关联列表。 + * @return 同一列表,已置为左连接。 + */ + private List useLeftJoin(List oneToOneRelationList) { + oneToOneRelationList.forEach(relation -> relation.setLeftJoin(Boolean.TRUE)); + return oneToOneRelationList; + } + + /** + * 按模板取导出字段:isExport=1,按 sortNo 升序。 + */ + private List loadExportFields(OnlineImportExportTemplate template) { + List fieldList = CollectionUtils.isNotEmpty(template.getOnlineImportExportFields()) + ? template.getOnlineImportExportFields() + : onlineImportExportFieldService.getListByTemplateId(template.getId()); + return fieldList.stream() + .filter(field -> Integer.valueOf(1).equals(field.getIsExport())) + .sorted(Comparator.comparing(OnlineImportExportField::getSortNo, Comparator.nullsLast(Integer::compareTo))) + .collect(Collectors.toList()); + } + + /** + * 从导出字段里收集「分录表名」(排除主表),保持首次出现的顺序。 + */ + private List collectEntryTableNames(List exportFieldList, String masterTableName) { + LinkedHashSet tableNameSet = new LinkedHashSet<>(); + for (OnlineImportExportField field : exportFieldList) { + if (StringUtils.isNotBlank(field.getTableName()) && !StringUtils.equalsIgnoreCase(field.getTableName(), masterTableName)) { + tableNameSet.add(field.getTableName()); + } + } + return new ArrayList<>(tableNameSet); + } + + /** + * 拼排序串(前端按列名传)。 + */ + private String buildOrderBy(MyOrderParam orderParam) { + if (orderParam == null || orderParam.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (MyOrderParam.OrderInfo orderInfo : orderParam) { + if (StringUtils.isBlank(orderInfo.getFieldName())) { + continue; + } + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(orderInfo.getFieldName()); + sb.append(Boolean.TRUE.equals(orderInfo.getAsc()) ? " ASC" : " DESC"); + } + return sb.length() == 0 ? null : sb.toString(); + } + + /** + * 取数 + 扁平展开 + 补值。 + */ + private List buildRows(ExportContext context) { + // 1. 主表取数 + MyPageData> masterPageData = onlineOperationService.getMasterDataList(context.masterTable, + context.oneToOneRelationList, context.allRelationList, context.filterDtoList, context.orderBy, null, null); + List> masterRowList = masterPageData == null || masterPageData.getDataList() == null ? Collections.emptyList() : masterPageData.getDataList(); + context.masterRowCount = masterPageData == null || masterPageData.getTotalCount() == null ? masterRowList.size() : masterPageData.getTotalCount().intValue(); + + // 2. 分录取数 + 按宿主行分组 + for (Map.Entry entry : context.entryRelationMap.entrySet()) { + OnlineDatasourceRelation relation = entry.getValue(); + // 过滤条件只作用于主表:它绑的是主表(或主表的一对一关联表)的列,分录 SQL 里没有这些表, + // 直接透传会报 Unknown column。分录本就是全量取数、再按外键分组挂到主记录上(设计如此)。 + // 传空列表而不是 null:getSlaveDataList 内部靠这个列表追加从表的逻辑删除过滤,null 会把这层过滤弄丢。 + MyPageData> slavePageData = + onlineOperationService.getSlaveDataList(relation, new LinkedList<>(), context.orderBy, null, true); + List> slaveRowList = slavePageData == null || slavePageData.getDataList() == null + ? Collections.emptyList() : slavePageData.getDataList(); + String fkColumnName = this.resolveColumnName(relation.getSlaveTable(), relation.getSlaveColumnId()); + context.entryRowsByTable.put(entry.getKey(), this.groupByColumn(slaveRowList, fkColumnName)); + } + + // 3. 展开:主记录 × 分录表分段(不交错),无任何分录行时输出一行空分录列 + List rowList = new ArrayList<>(); + for (Map masterRow : masterRowList) { + String masterId = this.toKeyValue(masterRow.get(ID_COLUMN)); + boolean hasEntrySegment = false; + for (Map.Entry entry : context.entryRelationMap.entrySet()) { + List> entryRowList = context.entryRowsByTable + .getOrDefault(entry.getKey(), Collections.emptyMap()).get(masterId); + if (CollectionUtils.isEmpty(entryRowList)) { + continue; + } + hasEntrySegment = true; + for (Map entryRow : entryRowList) { + rowList.add(this.buildOneRow(context, masterRow, entry.getKey(), entryRow)); + } + } + if (!hasEntrySegment) { + rowList.add(this.buildOneRow(context, masterRow, null, null)); + } + } + + // 4. 补值 + this.fillF7Values(context, rowList); + this.fillMultiLinkValues(context, rowList); + return rowList; + } + + /** + * 组装一行:主表字段取主记录值,分录字段取分录行值(entryTableName 为 null 时留空)。 + */ + private ExportRow buildOneRow(ExportContext context, Map masterRow, String entryTableName, Map entryRow) { + ExportRow exportRow = new ExportRow(); + exportRow.masterRow = masterRow; + exportRow.entryTableName = entryTableName; + exportRow.entryRow = entryRow; + List values = new ArrayList<>(context.exportFieldList.size()); + for (OnlineImportExportField field : context.exportFieldList) { + Map sourceRow = this.resolveSourceRow(context, field, masterRow, entryTableName, entryRow); + Object value = sourceRow == null ? null : sourceRow.get(field.getColumnName()); + values.add(value == null ? StringUtils.EMPTY : value); + } + exportRow.values = values; + return exportRow; + } + + /** + * 字段对应的取数行:主表字段取主记录;分录字段取「该行所属分录表」的行;都不匹配返回 null(留空)。 + */ + private Map resolveSourceRow(ExportContext context, OnlineImportExportField field, + Map masterRow, String entryTableName, Map entryRow) { + if (StringUtils.equalsIgnoreCase(field.getTableName(), context.masterHead.getTableName())) { + return masterRow; + } + if (entryRow != null && StringUtils.equalsIgnoreCase(field.getTableName(), entryTableName)) { + return entryRow; + } + return null; + } + + /** + * F7 单选补值:裸外键 id → 「导出属性」指定列的值,按 id 批量 IN 反查(一次查询,非 N+1)。 + */ + private void fillF7Values(ExportContext context, List rowList) { + for (int i = 0; i < context.exportFieldList.size(); i++) { + OnlineImportExportField field = context.exportFieldList.get(i); + OnlFormField formField = this.findFormField(context, field); + // 只处理 F7 单选(refPropert 非空且非多选);多选走 fillMultiLinkValues + if (formField == null || formField.getRefPropert() == null || formField.getRefMultiTableId() != null) { + continue; + } + OnlineTable relTable = this.resolveRelTable(formField.getRefPropert()); + if (relTable == null) { + continue; + } + String attrColumn = relationValueResolver.resolveExportAttrColumn(relTable, field.getExportAttr()); + if (StringUtils.isBlank(attrColumn)) { + continue; + } + Collection idValues = new LinkedHashSet<>(); + for (ExportRow row : rowList) { + String idValue = this.toKeyValue(row.values.get(i)); + if (idValue != null) { + idValues.add(idValue); + } + } + if (idValues.isEmpty()) { + continue; + } + Map valueMap = relationValueResolver.resolveRelatedValueMap(relTable, attrColumn, idValues); + for (ExportRow row : rowList) { + String idValue = this.toKeyValue(row.values.get(i)); + if (idValue == null) { + continue; + } + String resolved = valueMap.get(idValue); + if (resolved != null) { + row.values.set(i, resolved); + } + } + } + } + + /** + * 多选(404)补值:按宿主行 id 查中间表取 obj_id,再按「导出属性」反查显示值,分号拼接。 + * + *

宿主行 id 的口径与写入一致:主表字段用主记录 id,分录字段用该分录行 id。

+ */ + private void fillMultiLinkValues(ExportContext context, List rowList) { + for (int i = 0; i < context.exportFieldList.size(); i++) { + OnlineImportExportField field = context.exportFieldList.get(i); + OnlFormField formField = this.findFormField(context, field); + if (formField == null || formField.getRefMultiTableId() == null) { + continue; + } + String middleTableName = this.resolveHeadTableName(formField.getRefMultiTableId()); + OnlineTable relTable = this.resolveRelTable(formField.getRefPropert()); + if (StringUtils.isBlank(middleTableName) || relTable == null) { + continue; + } + String attrColumn = relationValueResolver.resolveExportAttrColumn(relTable, field.getExportAttr()); + if (StringUtils.isBlank(attrColumn)) { + continue; + } + boolean isMasterField = StringUtils.equalsIgnoreCase(field.getTableName(), context.masterHead.getTableName()); + Collection hostIdSet = new LinkedHashSet<>(); + for (ExportRow row : rowList) { + String hostId = this.resolveHostId(isMasterField, row); + if (hostId != null) { + hostIdSet.add(hostId); + } + } + if (hostIdSet.isEmpty()) { + continue; + } + Map> objIdsByHost = this.queryMiddleTable(context, middleTableName, hostIdSet); + Collection objIdSet = new LinkedHashSet<>(); + objIdsByHost.values().forEach(objIdSet::addAll); + Map valueMap = objIdSet.isEmpty() ? Collections.emptyMap() + : relationValueResolver.resolveRelatedValueMap(relTable, attrColumn, objIdSet); + for (ExportRow row : rowList) { + String hostId = this.resolveHostId(isMasterField, row); + if (hostId == null) { + continue; + } + List objIdList = objIdsByHost.get(hostId); + if (CollectionUtils.isEmpty(objIdList)) { + row.values.set(i, StringUtils.EMPTY); + continue; + } + List textList = new ArrayList<>(objIdList.size()); + for (String objId : objIdList) { + String text = valueMap.get(objId); + // 关联记录已删除时反查不到显示值:跳过该元素,避免拼出「张三;」这种空尾巴 + // (导入侧 splitMultiValue 也会过滤空元素,两边口径一致)。 + if (StringUtils.isNotBlank(text)) { + textList.add(text); + } + } + row.values.set(i, String.join(MULTI_VALUE_SEPARATOR, textList)); + } + } + } + + /** + * 取该行在「多选字段所属宿主表」上的行 id。 + * + * @param isMasterField 该多选字段是否属于主表。 + * @param row 导出行。 + * @return 宿主行 id,取不到返回 null。 + */ + private String resolveHostId(boolean isMasterField, ExportRow row) { + Map hostRow = isMasterField ? row.masterRow : row.entryRow; + return hostRow == null ? null : this.toKeyValue(hostRow.get(ID_COLUMN)); + } + + /** + * 在字段元数据里找该导出字段对应的 OnlFormField(key 用小写列名)。 + */ + private OnlFormField findFormField(ExportContext context, OnlineImportExportField field) { + Map fieldMap = context.formFieldMap.get(field.getTableName()); + if (fieldMap == null) { + return null; + } + return fieldMap.get(StringUtils.lowerCase(field.getColumnName())); + } + + /** + * 按宿主行 id 批量查中间表,返回 宿主行id → obj_id 列表(按 seq 升序)。 + */ + private Map> queryMiddleTable(ExportContext context, String middleTableName, Collection hostIdSet) { + Map> resultMap = new LinkedHashMap<>(); + // 中间表只有 biz_id/obj_id/seq,且不注册在线表,故按主表 dblink 直查本地库; + // 按 seq 排序保证多选值的先后与勾选顺序一致(导出→导入闭环)。 + List returnColumns = Arrays.asList("biz_id", "obj_id"); + List> rowList = dataSourceUtil.queryByIn(context.masterTable.getDblinkId(), middleTableName, + "biz_id", returnColumns, hostIdSet, "biz_id, seq"); + for (Map row : rowList) { + String bizId = OnlineDataSourceUtil.getColumnText(row, "biz_id"); + String objId = OnlineDataSourceUtil.getColumnText(row, "obj_id"); + if (StringUtils.isBlank(bizId) || StringUtils.isBlank(objId)) { + continue; + } + resultMap.computeIfAbsent(bizId, key -> new ArrayList<>()).add(objId); + } + return resultMap; + } + + /** + * 按列分组:key = 该列值(转字符串),value = 行列表。 + */ + private Map>> groupByColumn(List> rowList, String columnName) { + Map>> resultMap = new LinkedHashMap<>(); + if (StringUtils.isBlank(columnName)) { + return resultMap; + } + for (Map row : rowList) { + String key = this.toKeyValue(row.get(columnName)); + if (key == null) { + continue; + } + resultMap.computeIfAbsent(key, k -> new ArrayList<>()).add(row); + } + return resultMap; + } + + /** + * 取主键值并归一成字符串;null/空返回 null。 + */ + private String toKeyValue(Object value) { + if (value == null) { + return null; + } + String text = value.toString().trim(); + return text.isEmpty() ? null : text; + } + + /** + * 把内部行结构转成写 Excel 用的纯值行。 + */ + private List> toValueRows(List rowList) { + return rowList.stream().map(row -> row.values).collect(Collectors.toList()); + } + + /** + * 由列定义 id 反查列名。 + */ + private String resolveColumnName(OnlineTable table, Long columnId) { + if (table == null || table.getColumnMap() == null || columnId == null) { + return null; + } + return table.getColumnMap().values().stream() + .filter(column -> columnId.equals(column.getColumnId())) + .map(OnlineColumn::getColumnName) + .filter(StringUtils::isNotBlank) + .findFirst().orElse(null); + } + + /** + * F7 目标表:refPropert 指向 onl_form_head.id,取其物理表名再解析为 OnlineTable。 + */ + private OnlineTable resolveRelTable(Long refPropertHeadId) { + String tableName = this.resolveHeadTableName(refPropertHeadId); + if (StringUtils.isBlank(tableName)) { + return null; + } + return onlineTableService.queryByTableName(tableName); + } + + /** + * 由 onl_form_head.id 取物理表名(走缓存)。 + */ + private String resolveHeadTableName(Long headId) { + if (headId == null) { + return null; + } + OnlFormHead head = onlFormHeadService.getOnlFormHeadFromCache(headId); + return head == null ? null : head.getTableName(); + } + + /** + * 载入某表的字段元数据(key = 小写列名),供识别 F7 / 多选列。 + */ + private Map loadFormFieldMap(String tableName) { + OnlFormHead head = onlFormHeadService.getHeadTableNameCache(tableName); + if (head == null) { + return Collections.emptyMap(); + } + List fieldList = onlFormFieldService.getFiledByHeadIdCache(head.getId()); + if (CollectionUtils.isEmpty(fieldList)) { + return Collections.emptyMap(); + } + Map fieldMap = new LinkedHashMap<>(); + for (OnlFormField field : fieldList) { + fieldMap.putIfAbsent(StringUtils.lowerCase(field.getFieldName()), field); + } + return fieldMap; + } + + /** + * 组装执行结果统计。 + */ + private OnlineExportResultDto buildResult(ExportContext context, int totalRowCount) { + OnlineExportResultDto resultDto = new OnlineExportResultDto(); + resultDto.setAsync(Boolean.FALSE); + resultDto.setMasterRowCount(context.masterRowCount); + resultDto.setTotalRowCount(totalRowCount); + return resultDto; + } + + /** + * 导出行:值 + 来源行引用(补 F7 / 多选时需要回溯宿主行 id,故不能只留值)。 + */ + private static class ExportRow { + + private List values; + private Map masterRow; + private String entryTableName; + private Map entryRow; + } + + /** + * 导出上下文。 + */ + private static class ExportContext { + + private Long formId; + private OnlineImportExportTemplate template; + private OnlFormHead masterHead; + private OnlineTable masterTable; + private List exportFieldList; + private List allRelationList; + private List oneToOneRelationList; + private Map entryRelationMap = new LinkedHashMap<>(); + private Map>>> entryRowsByTable = new LinkedHashMap<>(); + private Map> formFieldMap = new LinkedHashMap<>(); + private List filterDtoList; + private MyOrderParam orderParam; + private String orderBy; + private String sheetName; + private String fileName; + private int masterRowCount; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportDataServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportDataServiceImpl.java new file mode 100644 index 0000000..ff0e70e --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportDataServiceImpl.java @@ -0,0 +1,1320 @@ +package apelet.common.online.service.impl; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.object.ObjectValue; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.DefaultDataSourceResolver; +import apelet.common.core.util.ImportUtil; +import apelet.common.dict.model.TenantGlobalDict; +import apelet.common.dict.service.GlobalDictService; +import apelet.common.dict.service.TenantGlobalDictService; +import apelet.common.generator.model.OnlFormField; +import apelet.common.generator.model.OnlFormHead; +import apelet.common.generator.service.IOnlFormFieldService; +import apelet.common.generator.service.IOnlFormHeadService; +import apelet.common.generator.utils.OrmGenDataSourceUtil; +import apelet.common.online.config.OnlineProperties; +import apelet.common.online.dto.OnlineImportGroupDto; +import apelet.common.online.dto.OnlineImportResultDto; +import apelet.common.online.dto.OnlineImportRowDto; +import apelet.common.online.dto.OnlineImportSheetDto; +import apelet.common.online.exception.OnlineRuntimeException; +import apelet.common.online.model.OnlineForm; +import apelet.common.online.model.OnlineImportExportField; +import apelet.common.online.model.OnlineImportExportTemplate; +import apelet.common.online.model.OnlineTable; +import apelet.common.online.model.constant.ImportWriteModeEnum; +import apelet.common.online.service.*; +import apelet.common.online.util.OnlineDataSourceUtil; +import apelet.common.online.util.OnlineImportExcelReader; +import apelet.common.online.util.RelationValueResolver; +import cn.hutool.core.convert.Convert; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 在线表单导入数据服务实现:模板驱动的新引擎。 + * + *

校验(模板层,不碰业务库)与入库(业务层校验 + 写入)拆成两个公开方法, + * 各自接收上传文件,不用 fileKey 在服务端传递。

+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Slf4j +@MyDataSourceResolver(resolver = DefaultDataSourceResolver.class, intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE) +@Service("onlineImportExportDataService") +public class OnlineImportExportDataServiceImpl implements OnlineImportExportDataService { + + /** + * 导入文件的子目录,与 ImportUtil.saveImportFile 保持一致。 + */ + private static final String IMPORT_SUB_DIR = "importedFile"; + + /** + * 失败文件落盘子目录。 + */ + private static final String FAIL_SUB_DIR = "fail"; + + /** + * 主键列名。 + */ + private static final String PK_COLUMN = "id"; + + /** + * 分录外键列名。由 common-orm 的 EntryLinkBatchExecutor 硬编码,不可配置。 + */ + private static final String PARENT_ID_COLUMN = "parent_id"; + + /** + * 分录排序列名。jar 不维护,由导入方按 1..n 填。 + */ + private static final String SEQ_COLUMN = "seq"; + + /** + * 多选关联值(widgetType=404)在单元格中的分隔符。 + */ + private static final String MULTI_VALUE_SEPARATOR = ";"; + + /** + * 失败 Excel 末尾追加的原因列名。重新导入时会被「多余列忽略」规则跳过,故可直接重传。 + */ + private static final String FAIL_REASON_HEADER = "失败原因"; + + /** + * 字段类型字典编码。 + */ + private static final String FIELD_TYPE_DICT_CODE = "onl_fieldType"; + + /** + * 单次 IN 查询的最大值个数。 + */ + private static final int MAX_IN_BATCH_SIZE = 1000; + + @Autowired + private OnlineImportExportTemplateService onlineImportExportTemplateService; + + @Autowired + private OnlineImportExportFieldService onlineImportExportFieldService; + + @Autowired + private OnlineFormService onlineFormService; + + @Autowired + private OnlineDictService onlineDictService; + + @Autowired + private OrmGenDataSourceUtil ormGenDataSourceUtil; + + @Autowired + private IOnlFormHeadService onlFormHeadService; + + @Autowired + private IOnlFormFieldService onlFormFieldService; + + @Autowired + private RelationValueResolver relationValueResolver; + + @Autowired + private OnlineTableService onlineTableService; + + @Autowired + private OnlineDataSourceUtil dataSourceUtil; + + @Autowired + private OnlineProperties onlineProperties; + + @Autowired + private GlobalDictService globalDictService; + + @Autowired + private TenantGlobalDictService tenantGlobalDictService; + + @Autowired + private PlatformTransactionManager transactionManager; + + // ------------------------------------------------------------------ 公开入口 + + @Override + public OnlineImportResultDto validateImport(Long formId, MultipartFile importFile, String writeMode, String matchColumns) { + ImportContext context = this.prepare(formId, writeMode, matchColumns); + context.tempFilePath = this.saveImportFile(importFile); + return this.doValidate(context); + } + + @Override + public OnlineImportResultDto executeImport(Long formId, MultipartFile importFile, String writeMode, String matchColumns) { + ImportContext context = this.prepare(formId, writeMode, matchColumns); + context.tempFilePath = this.saveImportFile(importFile); + return this.doExecute(context); + } + + @Override + public OnlineImportResultDto executeImportByFilePath(Long formId, String filePath, String writeMode, String matchColumns) { + ImportContext context = this.prepare(formId, writeMode, matchColumns); + context.tempFilePath = filePath; + return this.doExecute(context); + } + + /** + * 校验主流程(模板层)。 + * + * @param context 导入上下文,须已设置 tempFilePath。 + * @return 校验结果。 + */ + private OnlineImportResultDto doValidate(ImportContext context) { + OnlineImportResultDto resultDto = new OnlineImportResultDto(); + List groupList = this.parseAndVerifyTemplate(context, resultDto); + this.fillCount(resultDto, context.sheetDto, groupList); + // 校验失败也生成失败文件,供前端直接下载(原列 + 失败原因,可改完直接重传)。 + // 注意:本接口只做模板层校验,不查业务库,故失败文件仅含模板层错误(必填/类型/长度等)。 + // buildFailFile 内部会删掉上传的临时文件,故这里不再单独删。 + resultDto.setFailFileKey(this.buildFailFile(context)); + return resultDto; + } + + /** + * 入库主流程:模板层校验 → 业务层校验 → 写入 → 生成失败文件。 + * + * @param context 导入上下文,须已设置 tempFilePath。 + * @return 执行结果。 + */ + private OnlineImportResultDto doExecute(ImportContext context) { + OnlineImportResultDto resultDto = new OnlineImportResultDto(); + // 前端可以绕过校验接口直接调这里,模板层校验不碰库、很便宜,再跑一次防绕过。 + List groupList = this.parseAndVerifyTemplate(context, resultDto); + this.fillCount(resultDto, context.sheetDto, groupList); + if (resultDto.hasError()) { + resultDto.setSuccessCount(0); + resultDto.setFailCount(groupList.size()); + resultDto.setFailFileKey(this.buildFailFile(context)); + return resultDto; + } + this.verifyBusiness(context, groupList); + this.doWrite(context, groupList, resultDto); + resultDto.setFailFileKey(this.buildFailFile(context)); + return resultDto; + } + + // ------------------------------------------------------------------ 准备 + + /** + * 解析表单、定位已启用模板,组装上下文(不含文件)。 + * + * @param formId 表单id。 + * @param writeMode 写入模式。 + * @param matchColumns 匹配键。 + * @return 导入上下文。 + */ + private ImportContext prepare(Long formId, String writeMode, String matchColumns) { + if (formId == null) { + throw new OnlineRuntimeException("数据验证失败,表单id不能为空!"); + } + ImportContext context = new ImportContext(); + context.formId = formId; + context.writeMode = ImportWriteModeEnum.ofNullable(writeMode); + context.matchColumns = matchColumns; + OnlineForm onlineForm = onlineFormService.getById(formId); + if (onlineForm == null) { + throw new OnlineRuntimeException("数据验证失败,表单不存在!"); + } + context.masterHead = onlFormHeadService.getHeadCacheByTableId(onlineForm.getMasterTableId()); + if (context.masterHead == null) { + throw new OnlineRuntimeException("数据验证失败,未找到表单对应的表单元数据!"); + } + context.masterTable = onlineTableService.getOnlineTableFromCache(onlineForm.getMasterTableId()); + if (context.masterTable == null) { + throw new OnlineRuntimeException("数据验证失败,未找到表单对应的在线表元数据!"); + } + context.template = onlineImportExportTemplateService.getEnabledTemplate(context.masterHead.getTableName()); + if (context.template == null) { + throw new OnlineRuntimeException("该表未启用导入导出模板,请使用原有导入功能!"); + } + context.importFieldList = onlineImportExportFieldService.getListByTemplateId(context.template.getId()).stream() + .filter(field -> Integer.valueOf(1).equals(field.getIsImport())).collect(Collectors.toList()); + if (CollectionUtils.isEmpty(context.importFieldList)) { + throw new OnlineRuntimeException("导入失败,该模板未勾选任何导入字段!"); + } + String masterTableName = context.masterHead.getTableName(); + context.masterFieldList = this.filterByTable(context.importFieldList, masterTableName); + context.entryFieldMap = this.groupByEntryTable(context.importFieldList, masterTableName); + return context; + } + + // ------------------------------------------------------------------ 解析 + 模板层校验 + + /** + * 存盘、解析,并做模板层校验(不查业务库)。有错误写入 resultDto。 + * + * @param context 导入上下文。 + * @param resultDto 结果对象,出参。 + * @return 分组结果。 + */ + private List parseAndVerifyTemplate(ImportContext context, OnlineImportResultDto resultDto) { + context.sheetDto = OnlineImportExcelReader.read(context.tempFilePath, context.importFieldList); + // 文件级错误:整个文件都不可用,直接失败、零写入。 + if (CollectionUtils.isNotEmpty(context.sheetDto.getMissingRequiredColumns())) { + throw new OnlineRuntimeException("导入失败,表头缺少必填列:" + String.join(",", context.sheetDto.getMissingRequiredColumns())); + } + Integer maxRowCount = onlineProperties.getImportMaxRowCount(); + if (maxRowCount != null && context.sheetDto.getRowCount() > maxRowCount) { + throw new OnlineRuntimeException("导入失败,本次共 " + context.sheetDto.getRowCount() + " 行,超出单次上限 " + maxRowCount + " 行,请分批导入!"); + } + context.matchColumnList = this.resolveMatchColumns(context, resultDto); + if (resultDto.hasError()) { + return Collections.emptyList(); + } + // 逐行模板层校验:主表列全空、一行混填多个分录表、必填、类型与长度。 + Map> rowEntryTableMap = new LinkedHashMap<>(); + this.verifyRowBasics(context, rowEntryTableMap, resultDto); + List groupList = this.doGroup(context.sheetDto, context.masterFieldList, rowEntryTableMap); + this.verifyRequired(context, groupList, resultDto); + this.verifyMatchKeyDuplicate(context, groupList, resultDto); + return groupList; + } + + /** + * 逐行的模板层校验。 + * + * @param context 导入上下文。 + * @param rowEntryTableMap 行 → 该行贡献的分录表,出参。 + * @param resultDto 结果对象,出参。 + */ + private void verifyRowBasics(ImportContext context, Map> rowEntryTableMap, OnlineImportResultDto resultDto) { + for (OnlineImportRowDto rowDto : context.sheetDto.getRowList()) { + boolean masterAllBlank = context.masterFieldList.stream().allMatch(field -> this.isBlankValue(rowDto.getValue(field.getTableName(), field.getColumnName()))); + if (masterAllBlank) { + rowDto.markFailed("主表字段全为空,无法归组"); + resultDto.addError(rowDto.getRowNum(), StringUtils.EMPTY, StringUtils.EMPTY, rowDto.getFailReason()); + continue; + } + Set contributedTables = new LinkedHashSet<>(); + for (Map.Entry> entry : context.entryFieldMap.entrySet()) { + boolean hasValue = entry.getValue().stream().anyMatch(field -> !this.isBlankValue(rowDto.getValue(field.getTableName(), field.getColumnName()))); + if (hasValue) { + contributedTables.add(entry.getKey()); + } + } + if (contributedTables.size() > 1) { + rowDto.markFailed("[" + String.join(",", contributedTables) + "] 表 不能在同一行混填"); + resultDto.addError(rowDto.getRowNum(), StringUtils.EMPTY, StringUtils.EMPTY, rowDto.getFailReason()); + continue; + } + rowEntryTableMap.put(rowDto.getRowNum(), contributedTables); + } + } + + /** + * 必填与类型长度校验。 + * + * @param context 导入上下文。 + * @param groupList 分组结果。 + * @param resultDto 结果对象,出参。 + */ + private void verifyRequired(ImportContext context, List groupList, OnlineImportResultDto resultDto) { + Map masterFormFieldMap = this.loadFormFieldMap(context.masterHead.getTableName()); + for (OnlineImportGroupDto groupDto : groupList) { + OnlineImportRowDto firstRow = groupDto.getSourceRowList().isEmpty() ? null : groupDto.getSourceRowList().get(0); + // 一次报全该主记录缺失的全部必填项(原先逐个上报、只留第一条,用户只能改一个再传一次) + List missingRequiredList = context.masterFieldList.stream() + .filter(field -> Integer.valueOf(1).equals(field.getIsRequired()) && this.isBlankValue(groupDto.getMasterValueMap().get(field.getColumnName()))) + .map(OnlineImportExportField::getShowName).collect(Collectors.toList()); + if (!missingRequiredList.isEmpty()) { + this.markGroupFailed(groupDto, "[" + String.join(",", missingRequiredList) + "] 列 必填项不能为空"); + missingRequiredList.forEach(showName -> resultDto.addError(groupDto.getFirstRowNum(), showName, StringUtils.EMPTY, "必填项不能为空")); + } + if (firstRow != null) { + this.verifyFieldType(context, context.masterFieldList, masterFormFieldMap, firstRow, resultDto); + } + } + for (Map.Entry> entry : context.entryFieldMap.entrySet()) { + Map entryFormFieldMap = this.loadFormFieldMap(entry.getKey()); + for (OnlineImportGroupDto groupDto : groupList) { + for (OnlineImportRowDto rowDto : groupDto.getEntryRowMap().getOrDefault(entry.getKey(), Collections.emptyList())) { + List missingRequiredList = entry.getValue().stream() + .filter(field -> Integer.valueOf(1).equals(field.getIsRequired()) && this.isBlankValue(rowDto.getValue(field.getTableName(), field.getColumnName()))) + .map(OnlineImportExportField::getShowName).collect(Collectors.toList()); + if (!missingRequiredList.isEmpty()) { + rowDto.markFailed("[" + String.join(",", missingRequiredList) + "] 列 必填项不能为空"); + missingRequiredList.forEach(showName -> resultDto.addError(rowDto.getRowNum(), showName, StringUtils.EMPTY, "必填项不能为空")); + } + this.verifyFieldType(context, entry.getValue(), entryFormFieldMap, rowDto, resultDto); + } + } + } + } + + /** + * 类型与长度校验。字典字段按字符串处理,不校验数值类型。 + * + * @param fieldList 字段配置。 + * @param formFieldMap 字段元数据。 + * @param rowDto 数据行。 + * @param resultDto 结果对象,出参。 + */ + private void verifyFieldType(ImportContext context, List fieldList, Map formFieldMap, OnlineImportRowDto rowDto, OnlineImportResultDto resultDto) { + for (OnlineImportExportField field : fieldList) { + Object value = rowDto.getValue(field.getTableName(), field.getColumnName()); + if (this.isBlankValue(value)) { + continue; + } + OnlFormField formField = formFieldMap.get(StringUtils.lowerCase(field.getColumnName())); + // 字典列与关联(F7)列单元格里填的是「显示值」(名称/编码),不是物理列的值, + // 按物理列类型/长度校验必然误判;它们的合法性分别由 verifyDictValues / verifyRelationValues 判定。 + if (formField == null || formField.getRefDict() != null || formField.getRefPropert() != null) { + continue; + } + String text = Convert.toStr(value, StringUtils.EMPTY).trim(); + String typeName = this.resolveTypeName(context, formField); + if (!this.isValueMatchType(text, typeName)) { + rowDto.markFailed("[" + field.getShowName() + "] 列 值与字段类型不匹配(" + typeName + ")"); + resultDto.addError(rowDto.getRowNum(), field.getShowName(), text, "值与字段类型不匹配(" + typeName + ")"); + continue; + } + Integer fieldLength = formField.getFieldLength(); + if (fieldLength != null && "varchar".equalsIgnoreCase(typeName) && text.length() > fieldLength) { + rowDto.markFailed("[" + field.getShowName() + "] 列 值长度超出限制(" + fieldLength + ")"); + resultDto.addError(rowDto.getRowNum(), field.getShowName(), text, "长度超出 " + fieldLength); + } + } + } + + /** + * 文件内匹配键重复校验。 + * + * @param context 导入上下文。 + * @param groupList 分组结果。 + * @param resultDto 结果对象,出参。 + */ + private void verifyMatchKeyDuplicate(ImportContext context, List groupList, OnlineImportResultDto resultDto) { + if (context.writeMode == ImportWriteModeEnum.INSERT) { + return; + } + Map, Integer> existRowMap = new LinkedHashMap<>(); + for (OnlineImportGroupDto groupDto : groupList) { + List key = context.matchColumnList.stream().map(column -> Convert.toStr(groupDto.getMasterValueMap().get(column), StringUtils.EMPTY)).collect(Collectors.toList()); + Integer existRow = existRowMap.get(key); + if (existRow == null) { + existRowMap.put(key, groupDto.getFirstRowNum()); + continue; + } + String reason = "匹配键与第 " + existRow + " 行重复,会导致互相覆盖"; + this.markGroupFailed(groupDto, reason); + resultDto.addError(groupDto.getFirstRowNum(), StringUtils.EMPTY, String.join(" + ", key), reason); + } + } + + // ------------------------------------------------------------------ 业务层校验 + + /** + * 业务层校验:字典值是否存在、F7 能否命中、覆盖模式能否匹配到已有主记录。 + * 都按「列」批量查库,不逐行查。失败标记到行上。 + * + * @param context 导入上下文。 + * @param groupList 分组结果。 + */ + private void verifyBusiness(ImportContext context, List groupList) { + this.verifyDictValues(context, groupList); + this.verifyMultiLinkValues(context, groupList); + this.verifyRelationValues(context, groupList); + this.matchExistMaster(context, groupList); + } + + /** + * 多选关联(404)校验与回填。 + * + *

单元格是**分号分隔的显示值**(与导出侧同约定),逐个按「导入属性」反查成主键; + * 中间表存的是 obj_id,所以反查结果(主键列表)必须回填,不能把名称写进去。

+ * + * @param context 导入上下文。 + * @param groupList 分组结果。 + */ + private void verifyMultiLinkValues(ImportContext context, List groupList) { + for (Map.Entry> entry : this.allTableFieldMap(context).entrySet()) { + Map formFieldMap = this.loadFormFieldMap(entry.getKey()); + for (OnlineImportExportField field : entry.getValue()) { + OnlFormField formField = formFieldMap.get(StringUtils.lowerCase(field.getColumnName())); + if (formField == null || formField.getRefMultiTableId() == null || formField.getRefPropert() == null) { + continue; + } + OnlineTable relTable = relationValueResolver.findRelTable(entry.getKey(), field.getColumnName()); + if (relTable == null) { + log.warn("导入多选关联校验跳过:字段 {}.{} 未找到关联表元数据", entry.getKey(), field.getColumnName()); + continue; + } + String attrColumn = relationValueResolver.resolveImportAttrColumn(relTable, field.getImportAttr()); + List rowList = this.rowsOfTable(groupList, context, entry.getKey()); + Set valueSet = rowList.stream().flatMap(row -> this.splitMultiValue(row.getValue(field.getTableName(), field.getColumnName())).stream()).collect(Collectors.toSet()); + Map> idMap = relationValueResolver.resolveRelatedIdMap(relTable, attrColumn, valueSet); + for (OnlineImportRowDto rowDto : rowList) { + List textList = this.splitMultiValue(rowDto.getValue(field.getTableName(), field.getColumnName())); + if (textList.isEmpty()) { + continue; + } + // 按分号顺序逐个反查,去重后整体回填(任一元素命中 0 条或多条都算该行失败) + Collection idList = new LinkedHashSet<>(textList.size()); + String failReason = null; + for (String text : textList) { + List matchedIdList = idMap.get(text); + if (CollectionUtils.isEmpty(matchedIdList)) { + failReason = "按 [" + field.getShowName() + "] 列 未找到记录"; + } else if (matchedIdList.size() > 1) { + failReason = "按 [" + field.getShowName() + "] 列 匹配到多条记录"; + } + if (failReason != null) { + rowDto.markFailed(failReason); + break; + } + idList.add(matchedIdList.get(0)); + } + if (failReason == null) { + rowDto.putResolvedValue(entry.getKey(), field.getColumnName(), new ArrayList<>(idList)); + } + } + } + } + } + + /** + * 字典值存在性校验。按字段的 refDict 取字典的名称集合,批量比对。 + * 取不到字典数据的(非自定义字典)跳过校验并记录日志。 + * + * @param context 导入上下文。 + * @param groupList 分组结果。 + */ + private void verifyDictValues(ImportContext context, List groupList) { + for (Map.Entry> entry : this.allTableFieldMap(context).entrySet()) { + Map formFieldMap = this.loadFormFieldMap(entry.getKey()); + for (OnlineImportExportField field : entry.getValue()) { + OnlFormField formField = formFieldMap.get(StringUtils.lowerCase(field.getColumnName())); + if (formField == null || formField.getRefDict() == null) { + continue; + } + Map dictMap = onlineDictService.getDictMap(formField.getRefDict()); + if (dictMap.isEmpty()) { + log.warn("导入字典校验跳过:字段 {} 的字典 {} 非自定义字典,取不到值域", field.getColumnName(), formField.getRefDict()); + continue; + } + Set validValues = new HashSet<>(dictMap.values()); + validValues.addAll(dictMap.keySet()); + Map nameToIdMap = this.buildDictNameToIdMap(dictMap); + for (OnlineImportRowDto rowDto : this.rowsOfTable(groupList, context, entry.getKey())) { + String text = Convert.toStr(rowDto.getValue(field.getTableName(), field.getColumnName()), StringUtils.EMPTY).trim(); + if (StringUtils.isNotBlank(text) && !validValues.contains(text)) { + rowDto.markFailed("按 [" + field.getShowName() + "] 列 未找到字典项"); + continue; + } + // 填的是字典项名称时换成 id(填 id 或空的原样写入),与 F7 列同一套回填口径。 + if (StringUtils.isNotBlank(text) && !dictMap.containsKey(text)) { + String dictId = nameToIdMap.get(text); + if (dictId != null) { + rowDto.putResolvedValue(entry.getKey(), field.getColumnName(), dictId); + } + } + } + } + } + } + + /** + * 字典项「名称 → id」。同名项取第一个(字典里同名本就无法区分)。 + * + * @param dictMap 字典的 id → 名称。 + * @return 名称 → id;名称为空的不收录。 + */ + private Map buildDictNameToIdMap(Map dictMap) { + Map nameToIdMap = new HashMap<>(dictMap.size()); + dictMap.forEach((id, name) -> { + if (StringUtils.isNotBlank(name)) { + nameToIdMap.putIfAbsent(name, id); + } + }); + return nameToIdMap; + } + + /** + * F7 关联命中校验。按列批量反查,命中 0 条或多条都算失败。 + * + * @param context 导入上下文。 + * @param groupList 分组结果。 + */ + private void verifyRelationValues(ImportContext context, List groupList) { + for (Map.Entry> entry : this.allTableFieldMap(context).entrySet()) { + Map formFieldMap = this.loadFormFieldMap(entry.getKey()); + for (OnlineImportExportField field : entry.getValue()) { + OnlFormField formField = formFieldMap.get(StringUtils.lowerCase(field.getColumnName())); + // 多选关联(404)单元格是分号分隔的多值,走 verifyMultiLinkValues + if (formField == null || formField.getRefPropert() == null || formField.getRefMultiTableId() != null) { + continue; + } + OnlineTable relTable = relationValueResolver.findRelTable(entry.getKey(), field.getColumnName()); + if (relTable == null) { + log.warn("导入关联校验跳过:字段 {}.{} 未找到关联表元数据", entry.getKey(), field.getColumnName()); + continue; + } + String attrColumn = relationValueResolver.resolveImportAttrColumn(relTable, field.getImportAttr()); + List rowList = this.rowsOfTable(groupList, context, entry.getKey()); + Set valueSet = rowList.stream().map(row -> Convert.toStr(row.getValue(field.getTableName(), field.getColumnName()), StringUtils.EMPTY).trim()).filter(StringUtils::isNotBlank).collect(Collectors.toSet()); + Map> idMap = relationValueResolver.resolveRelatedIdMap(relTable, attrColumn, valueSet); + for (OnlineImportRowDto rowDto : rowList) { + String text = Convert.toStr(rowDto.getValue(field.getTableName(), field.getColumnName()), StringUtils.EMPTY).trim(); + if (StringUtils.isBlank(text)) { + continue; + } + List idList = idMap.get(text); + if (CollectionUtils.isEmpty(idList)) { + rowDto.markFailed("按 [" + field.getShowName() + "] 列 未找到记录"); + } else if (idList.size() > 1) { + rowDto.markFailed("按 [" + field.getShowName() + "] 列 匹配到多条记录"); + } else { + // 唯一命中:Excel 里填的是显示值(名称/编码/主键),入库要用反查到的主键。 + // 注意:F7 单选列在 ORM 元数据里是 LinkEntityColumn(见 MetaCacheService), + // setEntry 要求它的值是 Map({id: 主键}),传裸 id 会 ClassCastException。 + Map relValueMap = new LinkedHashMap<>(2); + relValueMap.put(PK_COLUMN, idList.get(0)); + rowDto.putResolvedValue(entry.getKey(), field.getColumnName(), relValueMap); + } + } + } + } + } + + /** + * 覆盖 / 覆盖并新增模式:按匹配键批量查已有主记录,回填 existId; + * 覆盖模式匹配不到的直接标记为失败。 + * + * @param context 导入上下文。 + * @param groupList 分组结果。 + */ + private void matchExistMaster(ImportContext context, List groupList) { + if (context.writeMode == ImportWriteModeEnum.INSERT) { + return; + } + Map, Object> existIdMap = this.queryExistMasterIds(context, context.matchColumnList, groupList); + for (OnlineImportGroupDto groupDto : groupList) { + // 匹配键本身可能也是关联字段/字典列,必须用反查后的实际值去比对。 + Map masterValueMap = this.resolveMasterWriteMap(context, groupDto); + List key = context.matchColumnList.stream().map(column -> this.resolveMatchKeyText(masterValueMap.get(column))).collect(Collectors.toList()); + // 复合键空判定(设计 §4.7):全部为空 = 空 → 按新增处理;部分为空 → 匹配键不完整,报错 + List blankColumnList = context.matchColumnList.stream() + .filter(column -> StringUtils.isBlank(this.resolveMatchKeyText(masterValueMap.get(column)))) + .collect(Collectors.toList()); + if (blankColumnList.size() == context.matchColumnList.size()) { + continue; + } + if (!blankColumnList.isEmpty()) { + this.markGroupFailed(groupDto, "匹配键不完整:" + String.join(",", blankColumnList) + " 为空"); + continue; + } + Object existId = existIdMap.get(key); + groupDto.setExistId(existId); + if (existId == null && context.writeMode == ImportWriteModeEnum.UPDATE) { + this.markGroupFailed(groupDto, "[" + String.join(",", context.matchColumnList) + "] 列 未找到已有记录"); + } + } + } + + /** + * 按匹配键批量查已有主记录主键。 + * + * @param context 导入上下文。 + * @param matchColumnList 匹配键列名。 + * @param groupList 分组结果。 + * @return 匹配键组合 → 主记录主键。 + */ + private Map, Object> queryExistMasterIds(ImportContext context, List matchColumnList, List groupList) { + Map, Object> resultMap = new HashMap<>(); + if (CollectionUtils.isEmpty(matchColumnList) || CollectionUtils.isEmpty(groupList)) { + return resultMap; + } + // 以第一个匹配键做 IN 查询,其余键在内存里组合判定,避免复合 IN 无法表达。 + String firstColumn = matchColumnList.get(0); + List valueList = groupList.stream().map(group -> this.resolveMatchKeyText(this.resolveMasterWriteMap(context, group).get(firstColumn))).filter(StringUtils::isNotBlank).distinct().collect(Collectors.toList()); + for (int i = 0; i < valueList.size(); i += MAX_IN_BATCH_SIZE) { + List batchValues = valueList.subList(i, Math.min(i + MAX_IN_BATCH_SIZE, valueList.size())); + this.queryExistMasterBatch(context, matchColumnList, batchValues, resultMap); + } + return resultMap; + } + + /** + * 执行一批已有主记录查询(本地库直查,不走 ORM:ORM 的 in 过滤器要求把值自己拼成字符串, + * 且会把带 EAS 异构标记的表转发到 EAS)。 + * + * @param context 导入上下文。 + * @param matchColumnList 匹配键列名。 + * @param batchValues 本批第一个匹配键的取值。 + * @param resultMap 结果map,出参。 + */ + private void queryExistMasterBatch(ImportContext context, List matchColumnList, List batchValues, Map, Object> resultMap) { + List returnColumns = new ArrayList<>(matchColumnList.size() + 1); + returnColumns.add(PK_COLUMN); + returnColumns.addAll(matchColumnList); + List> rowList = dataSourceUtil.queryByIn(context.masterTable, matchColumnList.get(0), returnColumns, batchValues); + for (Map row : rowList) { + List key = matchColumnList.stream().map(column -> Convert.toStr(OnlineDataSourceUtil.getColumnValue(row, column), StringUtils.EMPTY)).collect(Collectors.toList()); + resultMap.put(key, OnlineDataSourceUtil.getColumnValue(row, PK_COLUMN)); + } + } + + // ------------------------------------------------------------------ 写入 + + /** + * 按批写入。每批最多 {@code importBatchSize} 行,批内任一组失败则整批回滚; + * 已提交的批不受后续影响。 + * + * @param context 导入上下文。 + * @param groupList 分组结果。 + * @param resultDto 结果对象,出参。 + */ + private void doWrite(ImportContext context, List groupList, OnlineImportResultDto resultDto) { + int batchSize = onlineProperties.getImportBatchSize() == null ? 500 : onlineProperties.getImportBatchSize(); + TransactionTemplate transactionTemplate = new TransactionTemplate(this.transactionManager); + List batchList = new ArrayList<>(); + int batchRowCount = 0; + int successCount = 0; + int failCount = 0; + for (OnlineImportGroupDto groupDto : groupList) { + // 校验未通过的主记录整条跳过,不进批。 + if (groupDto.hasFailedRow()) { + failCount++; + continue; + } + int groupRowCount = groupDto.getSourceRowList().size(); + if (!batchList.isEmpty() && batchRowCount + groupRowCount > batchSize) { + int[] batchResult = this.flushBatch(transactionTemplate, context, batchList); + successCount += batchResult[0]; + failCount += batchResult[1]; + batchList = new ArrayList<>(); + batchRowCount = 0; + } + batchList.add(groupDto); + batchRowCount += groupRowCount; + } + if (!batchList.isEmpty()) { + int[] batchResult = this.flushBatch(transactionTemplate, context, batchList); + successCount += batchResult[0]; + failCount += batchResult[1]; + } + resultDto.setSuccessCount(successCount); + resultDto.setFailCount(failCount); + } + + /** + * 提交一个批次。批内任一组失败则整批回滚,并把该批所有主记录标记失败。 + * + * @param transactionTemplate 事务模板。 + * @param context 导入上下文。 + * @param batchList 该批的主记录。 + * @return [成功数, 失败数]。 + */ + private int[] flushBatch(TransactionTemplate transactionTemplate, ImportContext context, List batchList) { + try { + transactionTemplate.execute(status -> { + for (OnlineImportGroupDto groupDto : batchList) { + try { + this.writeOneGroup(context, groupDto); + } catch (Exception e) { + // 抛出以触发整批回滚,异常消息作为该批的失败原因。 + throw new OnlineRuntimeException(this.resolveErrorMessage(e)); + } + } + return null; + }); + return new int[]{batchList.size(), 0}; + } catch (Exception e) { + String reason = this.resolveErrorMessage(e); + log.warn("导入批次失败,回滚 {} 条主记录:{}", batchList.size(), reason); + for (OnlineImportGroupDto groupDto : batchList) { + this.markGroupFailed(groupDto, reason); + } + return new int[]{0, batchList.size()}; + } + } + + /** + * 写入一条主记录及其分录。 + * + * @param context 导入上下文。 + * @param groupDto 主记录分组。 + * @throws Exception ORM 写入异常。 + */ + private void writeOneGroup(ImportContext context, OnlineImportGroupDto groupDto) throws Exception { + String masterTableName = context.masterHead.getTableName(); + Map masterValueMap = this.resolveMasterWriteMap(context, groupDto); + Object masterId = groupDto.getExistId(); + if (masterId == null) { + this.fillAuditValues(masterValueMap, true); + // 新增主记录:id 由框架生成,随后从返回的 ObjectValue 取回。 + ObjectValue savedValue = ormGenDataSourceUtil.addNew(masterTableName, this.buildObjectValue(masterTableName, masterValueMap)); + masterId = savedValue == null ? null : savedValue.get(PK_COLUMN); + } else { + masterValueMap.put(PK_COLUMN, masterId); + this.fillAuditValues(masterValueMap, false); + ormGenDataSourceUtil.update(masterTableName, this.buildObjectValue(masterTableName, masterValueMap), null); + } + if (masterId == null) { + throw new OnlineRuntimeException("主记录写入后未取到主键,无法挂接分录"); + } + for (Map.Entry> entry : groupDto.getEntryRowMap().entrySet()) { + int seq = 1; + for (OnlineImportRowDto rowDto : entry.getValue()) { + this.writeOneEntryRow(entry.getKey(), rowDto, masterId, seq++); + } + } + } + + /** + * 主记录的实际入库值:模板原始值叠加「反查结果」。 + * + *

Excel 里关联字段填的是名称/编码、字典字段填的是名称,真正入库的必须是反查出来的主键/字典项 id; + * 同一主记录分组内所有行的主表列值相同(分组键就是全部主表列),故取各行反查结果的并集即可。

+ * + * @param context 导入上下文。 + * @param groupDto 主记录分组。 + * @return 列名 → 实际入库值。 + */ + private Map resolveMasterWriteMap(ImportContext context, OnlineImportGroupDto groupDto) { + Map valueMap = new LinkedHashMap<>(groupDto.getMasterValueMap()); + for (OnlineImportRowDto rowDto : groupDto.getSourceRowList()) { + valueMap.putAll(this.pickResolvedValues(rowDto, context.masterHead.getTableName())); + } + return valueMap; + } + + /** + * 取某张表在该行上的原始单元格值,键还原成裸列名。 + * + * @param rowDto 行数据。 + * @param tableName 物理表名。 + * @return 列名 → 单元格原始值。 + */ + private Map pickRawValues(OnlineImportRowDto rowDto, String tableName) { + String prefix = tableName + "."; + Map valueMap = new LinkedHashMap<>(); + rowDto.getValueMap().forEach((key, value) -> { + if (StringUtils.startsWith(key, prefix)) { + valueMap.put(StringUtils.substringAfter(key, prefix), value); + } + }); + return valueMap; + } + + /** + * 取某张表在该行上的反查结果,键还原成裸列名。 + * + * @param rowDto 行数据。 + * @param tableName 物理表名。 + * @return 列名 → 实际入库值。 + */ + private Map pickResolvedValues(OnlineImportRowDto rowDto, String tableName) { + String prefix = tableName + "."; + Map valueMap = new LinkedHashMap<>(); + rowDto.getResolvedValueMap().forEach((key, value) -> { + if (StringUtils.startsWith(key, prefix)) { + valueMap.put(StringUtils.substringAfter(key, prefix), value); + } + }); + return valueMap; + } + + /** + * 取匹配键的文本形式。 + * + *

关联字段(F7 单选)反查后回填的是 {@code {id: 主键}} 形态的 Map(ORM 要求,见 setEntry), + * 多选回填的是主键列表 —— 比对时要取其中的主键,不能直接 toString。

+ * + * @param value 匹配键字段的实际取值。 + * @return 文本形式;取不到返回空串。 + */ + private String resolveMatchKeyText(Object value) { + if (value instanceof Map) { + return Convert.toStr(((Map) value).get(PK_COLUMN), StringUtils.EMPTY); + } + if (value instanceof Collection) { + return ((Collection) value).stream().map(item -> Convert.toStr(item, StringUtils.EMPTY)).collect(Collectors.joining(MULTI_VALUE_SEPARATOR)); + } + return Convert.toStr(value, StringUtils.EMPTY); + } + + /** + * 写入一条分录行:带 id 则更新(并校验归属),否则新增。 + * + * @param entryTableName 分录表物理表名。 + * @param rowDto 分录行。 + * @param masterId 所属主记录id。 + * @param seq 序号。 + * @throws Exception ORM 写入异常。 + */ + private void writeOneEntryRow(String entryTableName, OnlineImportRowDto rowDto, Object masterId, int seq) throws Exception { + // 只取该分录表的列:行数据按「表名.列名」存,主表与分录表可能有同名列。 + Map entryValueMap = this.pickRawValues(rowDto, entryTableName); + // 主键单独处理(下面按需回填):空主键不能进 ObjectValue,否则 ORM 会写入 null 主键。 + entryValueMap.remove(PK_COLUMN); + // 关联字段/字典列:Excel 里填的是显示值,入库要用反查出来的实际值。 + entryValueMap.putAll(this.pickResolvedValues(rowDto, entryTableName)); + // parent_id 由 jar 只在「分录随主表一起写入」时自动填;此处分录单独写入,必须自己指定。 + entryValueMap.put(PARENT_ID_COLUMN, masterId); + entryValueMap.put(SEQ_COLUMN, seq); + Object entryId = rowDto.getValue(entryTableName, PK_COLUMN); + if (this.isBlankValue(entryId)) { + this.fillAuditValues(entryValueMap, true); + ormGenDataSourceUtil.addNew(entryTableName, this.buildObjectValue(entryTableName, entryValueMap)); + return; + } + Long entryIdValue = Convert.toLong(entryId, null); + ObjectValue existValue = entryIdValue == null ? null : ormGenDataSourceUtil.queryOne(entryTableName, entryIdValue); + if (existValue == null) { + throw new OnlineRuntimeException("第 " + rowDto.getRowNum() + " 行:分录主键 " + entryId + " 在「" + entryTableName + "」中不存在"); + } + Object existParentId = existValue.get(PARENT_ID_COLUMN); + if (!StringUtils.equals(Convert.toStr(masterId, null), Convert.toStr(existParentId, null))) { + throw new OnlineRuntimeException("第 " + rowDto.getRowNum() + " 行:分录主键 " + entryId + " 不属于当前主记录"); + } + entryValueMap.put(PK_COLUMN, entryIdValue); + this.fillAuditValues(entryValueMap, false); + ormGenDataSourceUtil.update(entryTableName, this.buildObjectValue(entryTableName, entryValueMap), null); + } + + /** + * 补审计字段:create_time / update_time 与操作用户。 + * + *

标准保存路径({@code SaveOperationServicePlugIn})也是显式 put 时间列的,ORM 只自动回填一部分; + * 不补的话新插入的行 {@code update_time} 会是 NULL、{@code update_user_id} 会是 0。 + * 多出来的键由 {@code setEntry} 按表的实际列过滤,表里没有这些列也不会报错。

+ * + * @param valueMap 待写入的值。 + * @param isInsert 是否新增(新增才写 create_*,避免更新时覆盖原始创建信息)。 + */ + private void fillAuditValues(Map valueMap, boolean isInsert) { + Date now = new Date(); + TokenData tokenData = TokenData.takeFromRequest(); + Long userId = tokenData == null ? null : tokenData.getUserId(); + valueMap.put("update_time", now); + valueMap.put("update_user_id", userId); + if (isInsert) { + valueMap.put("create_time", now); + valueMap.put("create_user_id", userId); + } + } + + // ------------------------------------------------------------------ 失败文件 + + /** + * 生成失败 Excel:模板的导入列 + 末尾「失败原因」列,只含失败行。 + * 没有失败行时返回null。返回前会把临时文件删掉。 + * + * @param context 导入上下文。 + * @param groupList 分组结果。 + * @return 失败文件标识(文件名);无失败时返回null。 + */ + private String buildFailFile(ImportContext context) { + this.deleteQuietly(context.tempFilePath); + // 从解析出的整份行数据里取失败行:分组前失败的行(必填、混填多张分录表、主表列全空…) + // 不属于任何分组,只扫 groupList 的失败行会漏掉它们,表现为"有错误清单却没有失败文件"。 + List failedRowList = context.sheetDto.getRowList().stream() + .filter(OnlineImportRowDto::isFailed) + .collect(Collectors.toList()); + if (CollectionUtils.isEmpty(failedRowList)) { + return null; + } + try { + return apelet.common.online.util.ImportExportExcelUtil.writeFailFile(failedRowList, context.importFieldList, FAIL_REASON_HEADER, onlineProperties.getUploadFileBaseDir(), IMPORT_SUB_DIR + "/" + FAIL_SUB_DIR); + } catch (Exception e) { + log.error("导入失败文件生成失败", e); + return null; + } + } + + // ------------------------------------------------------------------ 杂项 + + /** + * 装配 ObjectValue,并处理 404 多选列的集合语义。 + * + * @param tableName 物理表名。 + * @param valueMap 列名 → 值。 + * @return ObjectValue。 + */ + private ObjectValue buildObjectValue(String tableName, Map valueMap) { + Map formFieldMap = this.loadFormFieldMap(tableName); + Map preparedMap = new LinkedHashMap<>(); + for (Map.Entry entry : valueMap.entrySet()) { + OnlFormField formField = formFieldMap.get(StringUtils.lowerCase(entry.getKey())); + if (formField != null && formField.getRefMultiTableId() != null) { + // 多选关联(404):反查回填的是主键列表,直接交给 setEntry 组装中间表集合; + // 没回填(如校验跳过的场景)时按分号切分单元格原文。 + // 单元格为空 → 传 null(setEntry 对 null 直接跳过)=「留空 = 不改动」, + // 否则空集合会被当成"清空",覆盖模式下会误删该记录已有的多选值。 + Object elements = entry.getValue() instanceof Collection ? entry.getValue() : this.splitMultiValue(entry.getValue()); + preparedMap.put(entry.getKey(), ((Collection) elements).isEmpty() ? null : elements); + } else { + preparedMap.put(entry.getKey(), entry.getValue()); + } + } + return onlineFormService.getObjectValue(preparedMap, Collections.emptyMap(), tableName); + } + + /** + * 拆分 404 多选值(分号分隔)。 + * + * @param value 单元格值。 + * @return 值列表。 + */ + private List splitMultiValue(Object value) { + String text = Convert.toStr(value, StringUtils.EMPTY); + if (StringUtils.isBlank(text)) { + return Collections.emptyList(); + } + return Arrays.stream(StringUtils.split(text, MULTI_VALUE_SEPARATOR)).map(StringUtils::trim).filter(StringUtils::isNotBlank).collect(Collectors.toList()); + } + + /** + * 分组:全文件内主表列值全等 → 同一条主记录,不要求相邻。 + * + * @param sheetDto 解析结果。 + * @param masterFieldList 主表字段。 + * @param rowEntryTableMap 行 → 贡献的分录表。 + * @return 分组结果。 + */ + private List doGroup(OnlineImportSheetDto sheetDto, List masterFieldList, Map> rowEntryTableMap) { + Map, OnlineImportGroupDto> groupMap = new LinkedHashMap<>(); + for (OnlineImportRowDto rowDto : sheetDto.getRowList()) { + Set contributedTables = rowEntryTableMap.get(rowDto.getRowNum()); + if (contributedTables == null) { + continue; + } + List groupKey = masterFieldList.stream().map(field -> rowDto.getValue(field.getTableName(), field.getColumnName())).collect(Collectors.toList()); + OnlineImportGroupDto groupDto = groupMap.get(groupKey); + if (groupDto == null) { + groupDto = new OnlineImportGroupDto(); + groupDto.setFirstRowNum(rowDto.getRowNum()); + for (OnlineImportExportField field : masterFieldList) { + groupDto.getMasterValueMap().put(field.getColumnName(), rowDto.getValue(field.getTableName(), field.getColumnName())); + } + groupMap.put(groupKey, groupDto); + } + List sourceRowList = new ArrayList<>(groupDto.getSourceRowList()); + sourceRowList.add(rowDto); + groupDto.setSourceRowList(sourceRowList); + for (String tableName : contributedTables) { + groupDto.getEntryRowMap().computeIfAbsent(tableName, key -> new ArrayList<>()).add(rowDto); + } + } + return new ArrayList<>(groupMap.values()); + } + + /** + * 解析匹配键:优先用前端传入的,为空时取模板中标记为主键的列。 + * + * @param context 导入上下文。 + * @param resultDto 结果对象,出参。 + * @return 匹配键列名列表。 + */ + private List resolveMatchColumns(ImportContext context, OnlineImportResultDto resultDto) { + List columnList; + if (StringUtils.isNotBlank(context.matchColumns)) { + columnList = Arrays.stream(StringUtils.split(context.matchColumns, ",")).map(StringUtils::trim).filter(StringUtils::isNotBlank).collect(Collectors.toList()); + } else { + columnList = context.masterFieldList.stream().filter(field -> Integer.valueOf(1).equals(field.getIsKey())).map(OnlineImportExportField::getColumnName).collect(Collectors.toList()); + } + if (context.writeMode == ImportWriteModeEnum.INSERT) { + return columnList; + } + if (CollectionUtils.isEmpty(columnList)) { + resultDto.addError(null, StringUtils.EMPTY, StringUtils.EMPTY, "匹配键无法从导入数据取值,请在导入时手动指定匹配键"); + return columnList; + } + Set masterColumnNames = context.masterFieldList.stream().map(OnlineImportExportField::getColumnName).collect(Collectors.toSet()); + for (String columnName : columnList) { + // 匹配键只能取主表字段:分录表的列拿去查主表会直接 SQL 报错(Unknown column)。 + if (!masterColumnNames.contains(columnName)) { + resultDto.addError(null, StringUtils.EMPTY, columnName, "匹配键必须是主表字段:「" + columnName + "」不是主表的导入字段"); + continue; + } + if (!context.sheetDto.getMatchedColumnNames().contains(columnName)) { + resultDto.addError(null, StringUtils.EMPTY, columnName, "匹配键「" + columnName + "」未出现在表头中"); + } + } + return columnList; + } + + /** + * 把「表名 → 该表涉及的导入字段」摊平,含主表与各分录表。 + * + * @param context 导入上下文。 + * @return 表名 → 字段配置。 + */ + private Map> allTableFieldMap(ImportContext context) { + Map> resultMap = new LinkedHashMap<>(); + resultMap.put(context.masterHead.getTableName(), context.masterFieldList); + resultMap.putAll(context.entryFieldMap); + return resultMap; + } + + /** + * 取某张表在某次导入中涉及的全部数据行(主表取每组首行,分录表取其分录行)。 + * + * @param groupList 分组结果。 + * @param context 导入上下文。 + * @param tableName 物理表名。 + * @return 行列表。 + */ + private List rowsOfTable(List groupList, ImportContext context, String tableName) { + if (StringUtils.equals(tableName, context.masterHead.getTableName())) { + return groupList.stream().map(group -> group.getSourceRowList().isEmpty() ? null : group.getSourceRowList().get(0)).filter(java.util.Objects::nonNull).collect(Collectors.toList()); + } + return groupList.stream().flatMap(group -> group.getEntryRowMap().getOrDefault(tableName, Collections.emptyList()).stream()).collect(Collectors.toList()); + } + + /** + * 取某张表的字段元数据(含字典、关联信息),按小写列名索引。 + * + * @param tableName 物理表名。 + * @return 列名 → 字段元数据。 + */ + private Map loadFormFieldMap(String tableName) { + OnlFormHead head = onlFormHeadService.getHeadTableNameCache(tableName); + if (head == null) { + return Collections.emptyMap(); + } + List fieldList = onlFormFieldService.getFiledByHeadIdCache(head.getId()); + if (CollectionUtils.isEmpty(fieldList)) { + return Collections.emptyMap(); + } + return fieldList.stream().collect(Collectors.toMap(field -> StringUtils.lowerCase(field.getFieldName()), field -> field, (a, b) -> a)); + } + + /** + * 取字段的类型名(如 varchar / bigint / decimal / datetime)。 + * 类型名来自全局字典 onl_fieldType 的 itemName,整次导入只查一次并缓存在上下文里。 + * + * @param context 导入上下文。 + * @param formField 字段元数据。 + * @return 类型名(小写);取不到时返回null,调用方据此跳过类型校验。 + */ + private String resolveTypeName(ImportContext context, OnlFormField formField) { + if (context.fieldTypeDictMap == null) { + context.fieldTypeDictMap = this.loadFieldTypeDictMap(); + } + return StringUtils.lowerCase(context.fieldTypeDictMap.get(formField.getFieldType())); + } + + /** + * 载入 onl_fieldType 字典的 itemId → itemName 映射。 + * 租户环境下走租户字典,取不到时返回空map(类型校运会自动跳过)。 + * + * @return 映射表。 + */ + private Map loadFieldTypeDictMap() { + try { + if (TokenData.takeFromRequest().getTenantId() == null) { + return globalDictService.getGlobalDictItemDictMapFromCache(FIELD_TYPE_DICT_CODE, null); + } + TenantGlobalDict dict = tenantGlobalDictService.getTenantGlobalDictFromCache(FIELD_TYPE_DICT_CODE); + if (dict == null) { + return Collections.emptyMap(); + } + return tenantGlobalDictService.getGlobalDictItemDictMapFromCache(dict, null); + } catch (Exception e) { + log.warn("导入类型校验跳过:取 onl_fieldType 字典失败", e); + return Collections.emptyMap(); + } + } + + /** + * 判断字符串值是否符合目标类型。 + * + * @param text 值。 + * @param typeName 类型名。 + * @return 符合返回true。 + */ + private boolean isValueMatchType(String text, String typeName) { + if (StringUtils.isBlank(typeName) || "varchar".equalsIgnoreCase(typeName)) { + return true; + } + try { + if ("int".equalsIgnoreCase(typeName) || "smallint".equalsIgnoreCase(typeName) || "tinyint".equalsIgnoreCase(typeName)) { + Integer.parseInt(text); + } else if ("bigint".equalsIgnoreCase(typeName) || "long".equalsIgnoreCase(typeName)) { + Long.parseLong(text); + } else if ("decimal".equalsIgnoreCase(typeName) || "numeric".equalsIgnoreCase(typeName) || "double".equalsIgnoreCase(typeName) || "float".equalsIgnoreCase(typeName)) { + new java.math.BigDecimal(text); + } else if ("datetime".equalsIgnoreCase(typeName) || "date".equalsIgnoreCase(typeName) || "timestamp".equalsIgnoreCase(typeName)) { + cn.hutool.core.date.DateUtil.parse(text); + } + return true; + } catch (Exception e) { + return false; + } + } + + /** + * 把一条主记录下的全部行标记为失败(保留已有的更具体原因)。 + * + * @param groupDto 主记录分组。 + * @param reason 失败原因。 + */ + private void markGroupFailed(OnlineImportGroupDto groupDto, String reason) { + for (OnlineImportRowDto rowDto : groupDto.getSourceRowList()) { + rowDto.markFailed(reason); + } + } + + /** + * 统计信息回填。 + * + * @param resultDto 结果对象,出参。 + * @param sheetDto 解析结果。 + * @param groupList 分组结果。 + */ + private void fillCount(OnlineImportResultDto resultDto, OnlineImportSheetDto sheetDto, List groupList) { + resultDto.setTotalRowCount(sheetDto == null ? 0 : sheetDto.getRowCount()); + resultDto.setMasterCount(groupList.size()); + resultDto.setEntryCount(groupList.stream().mapToInt(OnlineImportGroupDto::getEntryCount).sum()); + } + + /** + * 按表名过滤字段配置。 + * + * @param fieldList 字段配置列表。 + * @param tableName 物理表名。 + * @return 过滤结果。 + */ + private List filterByTable(List fieldList, String tableName) { + return fieldList.stream().filter(field -> StringUtils.equals(field.getTableName(), tableName)).collect(Collectors.toList()); + } + + /** + * 按分录表分组字段配置。 + * + * @param fieldList 字段配置列表。 + * @param masterTableName 主表物理表名。 + * @return 分录表名 → 字段配置。 + */ + private Map> groupByEntryTable(List fieldList, String masterTableName) { + Map> resultMap = new LinkedHashMap<>(); + for (OnlineImportExportField field : fieldList) { + if (StringUtils.equals(field.getTableName(), masterTableName)) { + continue; + } + resultMap.computeIfAbsent(field.getTableName(), key -> new ArrayList<>()).add(field); + } + return resultMap; + } + + @Override + public String saveImportFile(MultipartFile importFile) { + if (importFile == null || importFile.isEmpty()) { + throw new OnlineRuntimeException("数据验证失败,导入文件不能为空!"); + } + this.verifyFileSize(importFile.getSize()); + try { + return ImportUtil.saveImportFile(onlineProperties.getUploadFileBaseDir(), null, importFile); + } catch (Exception e) { + throw new OnlineRuntimeException("导入失败,文件保存失败:" + e.getMessage()); + } + } + + /** + * 静默删除临时文件。 + * + * @param filePath 文件路径。 + */ + private void deleteQuietly(String filePath) { + if (StringUtils.isBlank(filePath)) { + return; + } + try { + File file = new File(filePath); + if (file.exists() && !file.delete()) { + log.warn("导入临时文件删除失败:{}", filePath); + } + } catch (Exception e) { + log.warn("导入临时文件删除异常:{}", filePath, e); + } + } + + /** + * 文件大小校验。先判大小再判行数,避免白解析超大文件。 + * + * @param fileSize 文件字节数。 + */ + private void verifyFileSize(long fileSize) { + Integer maxSizeMb = onlineProperties.getImportMaxFileSizeMb(); + if (maxSizeMb != null && fileSize > (long) maxSizeMb * 1024 * 1024) { + throw new OnlineRuntimeException("导入失败,文件大小超出上限 " + maxSizeMb + "MB!"); + } + } + + /** + * 判断单元格值是否为空。 + * + * @param value 单元格值。 + * @return 空返回true。 + */ + private boolean isBlankValue(Object value) { + return value == null || StringUtils.isBlank(Convert.toStr(value, StringUtils.EMPTY)); + } + + /** + * 取可读的错误消息。 + * + * @param e 异常。 + * @return 消息。 + */ + private String resolveErrorMessage(Exception e) { + if (StringUtils.isNotBlank(e.getMessage())) { + return e.getMessage(); + } + return e.getClass().getSimpleName(); + } + + /** + * 一次导入的上下文,避免在各方法间传递长参数列表。 + */ + private static class ImportContext { + + private Long formId; + + private String tempFilePath; + + private ImportWriteModeEnum writeMode; + + private String matchColumns; + + private OnlineImportExportTemplate template; + + private OnlFormHead masterHead; + + /** + * 主表在线表对象,覆盖模式查已有主记录时用它的表名与 dblink 走本地 SQL。 + */ + private OnlineTable masterTable; + + private List importFieldList; + + private List masterFieldList; + + private Map> entryFieldMap; + + private List matchColumnList; + + private OnlineImportSheetDto sheetDto; + + /** + * onl_fieldType 字典的 itemId → itemName,整次导入只载入一次。 + */ + private Map fieldTypeDictMap; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportFieldServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportFieldServiceImpl.java new file mode 100644 index 0000000..6a15a42 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportFieldServiceImpl.java @@ -0,0 +1,45 @@ +package apelet.common.online.service.impl; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.core.base.service.BaseService; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.util.DefaultDataSourceResolver; +import apelet.common.online.dao.OnlineImportExportFieldMapper; +import apelet.common.online.model.OnlineImportExportField; +import apelet.common.online.service.OnlineImportExportFieldService; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 在线表单导入导出模板字段配置数据操作服务类。 + * + * @author chenchuchuan + * @date 2026-09-17 + */ +@Slf4j +@MyDataSourceResolver( + resolver = DefaultDataSourceResolver.class, + intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE) +@Service("onlineImportExportFieldService") +public class OnlineImportExportFieldServiceImpl extends BaseService implements OnlineImportExportFieldService { + + @Autowired + private OnlineImportExportFieldMapper onlineImportExportFieldMapper; + + @Override + protected BaseDaoMapper mapper() { + return onlineImportExportFieldMapper; + } + + @Override + public List getListByTemplateId(Long templateId) { + return this.list(new LambdaQueryWrapper() + .eq(OnlineImportExportField::getTemplateId, templateId) + .orderByAsc(OnlineImportExportField::getSortNo)); + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTaskServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTaskServiceImpl.java new file mode 100644 index 0000000..b9ee775 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTaskServiceImpl.java @@ -0,0 +1,489 @@ +package apelet.common.online.service.impl; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.core.base.service.BaseService; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.DefaultDataSourceResolver; +import apelet.common.online.config.OnlineProperties; +import apelet.common.online.dao.OnlineImportExportTaskMapper; +import apelet.common.online.dto.OnlineExportParamDto; +import apelet.common.online.dto.OnlineExportResultDto; +import apelet.common.online.dto.OnlineImportResultDto; +import apelet.common.online.exception.OnlineRuntimeException; +import apelet.common.online.model.OnlineForm; +import apelet.common.online.model.OnlineImportExportTask; +import apelet.common.online.model.constant.OnlineImportExportTaskStatus; +import apelet.common.online.model.constant.OnlineImportExportTaskType; +import apelet.common.online.service.OnlineExportDataService; +import apelet.common.online.service.OnlineFormService; +import apelet.common.online.service.OnlineImportExportDataService; +import apelet.common.online.service.OnlineImportExportTaskService; +import apelet.common.sequence.wrapper.IdGeneratorWrapper; +import com.alibaba.fastjson.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.servlet.http.HttpServletRequest; +import java.io.File; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.concurrent.*; +import java.util.regex.Pattern; + +/** + * 在线表单导入导出异步任务服务实现。 + * + *

线程池是**自定义的 JDK 线程池**,不走 Spring 异步体系 —— 与仓库中权限字生成的既有做法一致 + * (`OnlinePermCodeServiceImpl` 注释里写明:避免影响应用原有线程池配置,也就是避开与 Flowable 的冲突)。 + * 不同之处是这里必须用**有界队列 + 拒绝策略**:导入是长任务,无界队列会把任务堆在内存里。

+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Slf4j +@MyDataSourceResolver(resolver = DefaultDataSourceResolver.class, intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE) +@Service("onlineImportExportTaskService") +public class OnlineImportExportTaskServiceImpl extends BaseService implements OnlineImportExportTaskService { + + /** + * 导入文件的子目录。 + */ + private static final String IMPORT_SUB_DIR = "importedFile"; + + /** + * 失败文件子目录。 + */ + private static final String FAIL_SUB_DIR = "fail"; + + /** + * 合法的文件标识:仅允许文件名安全字符,防止路径穿越。 + */ + private static final Pattern SAFE_FILE_KEY_PATTERN = Pattern.compile("^[A-Za-z0-9\\-\\._]+$"); + + /** + * 线程空闲存活时间(秒)。 + */ + private static final long KEEP_ALIVE_SECONDS = 60L; + + @Autowired + private OnlineImportExportTaskMapper onlineImportExportTaskMapper; + + @Autowired + private OnlineImportExportDataService onlineImportExportDataService; + + @Autowired + private OnlineExportDataService onlineExportDataService; + + @Autowired + private OnlineFormService onlineFormService; + + @Autowired + private OnlineProperties onlineProperties; + + @Autowired + private IdGeneratorWrapper idGenerator; + + /** + * 任务执行线程池。延迟到 {@link #initExecutor()} 构造,因为容量来自配置。 + */ + private ExecutorService taskExecutor; + + @Override + protected BaseDaoMapper mapper() { + return onlineImportExportTaskMapper; + } + + /** + * 构造线程池。 + */ + @PostConstruct + public void initExecutor() { + int poolSize = onlineProperties.getAsyncTaskPoolSize() == null ? 2 : onlineProperties.getAsyncTaskPoolSize(); + int queueCapacity = onlineProperties.getAsyncTaskQueueCapacity() == null ? 100 : onlineProperties.getAsyncTaskQueueCapacity(); + this.taskExecutor = new ThreadPoolExecutor(poolSize, poolSize, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(queueCapacity), + runnable -> { + Thread thread = new Thread(runnable); + thread.setName("import-export-task-" + thread.getId()); + thread.setDaemon(true); + return thread; + }, + // 队列满直接拒绝,由上层转成「系统繁忙」,不无界堆积。 + new ThreadPoolExecutor.AbortPolicy()); + log.info("导入导出异步任务线程池初始化完成:poolSize={}, queueCapacity={}", poolSize, queueCapacity); + } + + /** + * 关闭线程池。 + */ + @PreDestroy + public void shutdownExecutor() { + if (this.taskExecutor != null) { + this.taskExecutor.shutdown(); + } + } + + @Override + public OnlineImportExportTask submitImportTask(Long formId, String sourceFileKey, Long templateId, String writeMode, String matchColumns, int totalRowCount) { + if (StringUtils.isBlank(sourceFileKey) || !SAFE_FILE_KEY_PATTERN.matcher(sourceFileKey).matches()) { + throw new OnlineRuntimeException("导入失败,文件标识不合法!"); + } + // TokenData 依赖 HttpServletRequest,异步线程里取不到,必须在提交线程里先抓下来。 + TokenData tokenData = TokenData.takeFromRequest(); + Date now = new Date(); + OnlineImportExportTask task = new OnlineImportExportTask(); + task.setId(idGenerator.nextLongId()); + task.setTaskType(OnlineImportExportTaskType.IMPORT.getCode()); + task.setFormId(formId); + task.setTemplateId(templateId); + task.setStatus(OnlineImportExportTaskStatus.QUEUED.getCode()); + task.setTotalRowCount(totalRowCount); + task.setProcessedCount(0); + task.setSuccessCount(0); + task.setFailCount(0); + task.setSourceFileKey(sourceFileKey); + task.setTenantId(tokenData == null ? null : tokenData.getTenantId()); + task.setAppCode(tokenData == null ? null : tokenData.getAppCode()); + task.setCreateTime(now); + task.setCreateUserId(tokenData == null ? null : tokenData.getUserId()); + task.setUpdateTime(now); + task.setUpdateUserId(tokenData == null ? null : tokenData.getUserId()); + this.save(task); + String sourceFilePath = this.resolveSourceFilePath(sourceFileKey); + try { + this.taskExecutor.execute(() -> this.runImportTask(task.getId(), formId, sourceFilePath, writeMode, matchColumns, tokenData)); + } catch (RejectedExecutionException e) { + // 队列满:任务不落库,直接告诉用户稍后重试。 + this.removeById(task.getId()); + log.warn("导入异步任务队列已满,已拒绝任务 {}", task.getId()); + throw new OnlineRuntimeException("系统繁忙,请稍后重试!"); + } + return task; + } + + @Override + public OnlineImportExportTask submitExportTask(Long formId, Long templateId, OnlineExportParamDto param, int masterRowCount) { + // TokenData 依赖 HttpServletRequest,异步线程里取不到,必须在提交线程里先抓下来。 + TokenData tokenData = TokenData.takeFromRequest(); + Date now = new Date(); + OnlineImportExportTask task = new OnlineImportExportTask(); + task.setId(idGenerator.nextLongId()); + task.setTaskType(OnlineImportExportTaskType.EXPORT.getCode()); + task.setFormId(formId); + task.setTemplateId(templateId); + task.setStatus(OnlineImportExportTaskStatus.QUEUED.getCode()); + task.setTotalRowCount(masterRowCount); + task.setProcessedCount(0); + task.setSuccessCount(0); + task.setFailCount(0); + task.setParamJson(JSON.toJSONString(param)); + task.setTenantId(tokenData == null ? null : tokenData.getTenantId()); + task.setAppCode(tokenData == null ? null : tokenData.getAppCode()); + task.setCreateTime(now); + task.setCreateUserId(tokenData == null ? null : tokenData.getUserId()); + // 任务表 4 个审计列都是 NOT NULL,update_* 也必须给值,否则 insert 报 "Field 'update_time' doesn't have a default value" + task.setUpdateTime(now); + task.setUpdateUserId(tokenData == null ? null : tokenData.getUserId()); + this.save(task); + try { + this.taskExecutor.execute(() -> this.runExportTask(task.getId(), formId, param, tokenData)); + } catch (RejectedExecutionException e) { + // 队列满:任务不落库,直接告诉用户稍后重试。 + this.removeById(task.getId()); + log.warn("导出异步任务队列已满,已拒绝任务 {}", task.getId()); + throw new OnlineRuntimeException("系统繁忙,请稍后重试!"); + } + return task; + } + + @Override + public OnlineImportExportTask getTaskDetail(Long taskId) { + return taskId == null ? null : this.getById(taskId); + } + + @Override + public String getExportDownloadFileName(String resultFileKey) { + OnlineImportExportTask task = StringUtils.isBlank(resultFileKey) ? null : this.getOne( + new LambdaQueryWrapper() + .eq(OnlineImportExportTask::getResultFileKey, resultFileKey).last("LIMIT 1")); + OnlineForm form = task == null || task.getFormId() == null ? null : onlineFormService.getById(task.getFormId()); + return OnlineExportDataService.buildExportFileName(form == null ? null : form.getFormName()); + } + + @Override + public List getTaskList(OnlineImportExportTask filter, String orderBy) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (filter != null) { + wrapper.eq(StringUtils.isNotBlank(filter.getTaskType()), OnlineImportExportTask::getTaskType, filter.getTaskType()); + wrapper.eq(StringUtils.isNotBlank(filter.getStatus()), OnlineImportExportTask::getStatus, filter.getStatus()); + wrapper.eq(filter.getFormId() != null, OnlineImportExportTask::getFormId, filter.getFormId()); + } + wrapper.last(StringUtils.isNotBlank(orderBy) ? "ORDER BY " + orderBy : "ORDER BY create_time DESC"); + return this.list(wrapper); + } + + @Override + public int cleanExpiredTasks() { + int retainDays = onlineProperties.getAsyncTaskRetainDays() == null ? 7 : onlineProperties.getAsyncTaskRetainDays(); + Date deadline = new Date(System.currentTimeMillis() - (long) retainDays * 24 * 60 * 60 * 1000); + List expiredList = this.list(new LambdaQueryWrapper().lt(OnlineImportExportTask::getCreateTime, deadline)); + for (OnlineImportExportTask task : expiredList) { + this.deleteQuietly(task.getSourceFileKey()); + this.deleteQuietly(task.getResultFileKey()); + } + if (!expiredList.isEmpty()) { + this.removeByIds(expiredList.stream().map(OnlineImportExportTask::getId).collect(java.util.stream.Collectors.toList())); + } + log.info("导入导出异步任务清理完成,共清理 {} 条", expiredList.size()); + return expiredList.size(); + } + + /** + * 任务执行体。 + * + * @param taskId 任务id。 + * @param formId 表单id。 + * @param sourceFilePath 源文件绝对路径。 + * @param writeMode 写入模式。 + * @param matchColumns 匹配键。 + * @param tokenData 提交线程里抓下来的登录信息。 + */ + private void runImportTask(Long taskId, Long formId, String sourceFilePath, String writeMode, String matchColumns, TokenData tokenData) { + this.updateTaskStatus(taskId, OnlineImportExportTaskStatus.RUNNING, null); + try { + this.runWithRequestContext(tokenData, () -> { + OnlineImportResultDto resultDto = onlineImportExportDataService.executeImportByFilePath(formId, sourceFilePath, writeMode, matchColumns); + OnlineImportExportTask updateEntity = new OnlineImportExportTask(); + updateEntity.setId(taskId); + updateEntity.setStatus(this.resolveFinalStatus(resultDto).getCode()); + updateEntity.setTotalRowCount(resultDto.getTotalRowCount()); + updateEntity.setProcessedCount(resultDto.getTotalRowCount()); + updateEntity.setSuccessCount(resultDto.getSuccessCount()); + updateEntity.setFailCount(resultDto.getFailCount()); + updateEntity.setResultFileKey(resultDto.getFailFileKey()); + updateEntity.setUpdateTime(new Date()); + updateEntity.setUpdateUserId(this.currentUserId()); + this.updateById(updateEntity); + }); + } catch (Exception e) { + log.error("导入异步任务执行失败,taskId={}", taskId, e); + this.updateTaskStatus(taskId, OnlineImportExportTaskStatus.FAILED, this.resolveErrorMessage(e)); + } finally { + this.deleteQuietly(new File(sourceFilePath).getName()); + } + } + + /** + * 导出任务执行体:取数 → 展开 → 补值 → 生成 Excel 落盘,把结果文件标识写回任务。 + * + * @param taskId 任务id。 + * @param formId 表单id。 + * @param param 导出参数。 + * @param tokenData 提交线程里抓下来的登录信息。 + */ + private void runExportTask(Long taskId, Long formId, OnlineExportParamDto param, TokenData tokenData) { + this.updateTaskStatus(taskId, OnlineImportExportTaskStatus.RUNNING, null); + try { + this.runWithRequestContext(tokenData, () -> { + OnlineExportResultDto resultDto = onlineExportDataService.buildExportFile(formId, param.getDatasourceId(), + param.getFilterDtoList(), param.getOrderParam()); + OnlineImportExportTask updateEntity = new OnlineImportExportTask(); + updateEntity.setId(taskId); + updateEntity.setStatus(OnlineImportExportTaskStatus.SUCCESS.getCode()); + updateEntity.setTotalRowCount(resultDto.getTotalRowCount()); + updateEntity.setProcessedCount(resultDto.getTotalRowCount()); + updateEntity.setSuccessCount(resultDto.getMasterRowCount()); + updateEntity.setFailCount(0); + updateEntity.setResultFileKey(resultDto.getResultFileKey()); + updateEntity.setUpdateTime(new Date()); + updateEntity.setUpdateUserId(this.currentUserId()); + this.updateById(updateEntity); + }); + } catch (Exception e) { + log.error("导出异步任务执行失败,taskId={}", taskId, e); + this.updateTaskStatus(taskId, OnlineImportExportTaskStatus.FAILED, this.resolveErrorMessage(e)); + } + } + + /** + * 根据执行结果判定最终状态。 + * + * @param resultDto 执行结果。 + * @return 任务状态。 + */ + private OnlineImportExportTaskStatus resolveFinalStatus(OnlineImportResultDto resultDto) { + int successCount = resultDto.getSuccessCount() == null ? 0 : resultDto.getSuccessCount(); + int failCount = resultDto.getFailCount() == null ? 0 : resultDto.getFailCount(); + if (successCount == 0 && failCount > 0) { + return OnlineImportExportTaskStatus.FAILED; + } + return failCount > 0 ? OnlineImportExportTaskStatus.PARTIAL : OnlineImportExportTaskStatus.SUCCESS; + } + + /** + * 在异步线程里恢复「请求上下文」,让依赖 {@link TokenData} 的代码(引擎的类型字典查询、 + * common-orm 写入时回填 create_user_id 等)能正常工作。 + * + *

{@code TokenData.takeFromRequest()} 只读 HttpServletRequest 的 attribute, + * 因此这里用一个动态代理伪造 request,只应答 getAttribute,其余方法返回默认值。

+ * + * @param tokenData 提交线程里抓下来的登录信息。 + * @param runnable 任务体。 + */ + private void runWithRequestContext(TokenData tokenData, Runnable runnable) { + ServletRequestAttributes previous = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + try { + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(buildMockRequest(tokenData))); + runnable.run(); + } finally { + if (previous == null) { + RequestContextHolder.resetRequestAttributes(); + } else { + RequestContextHolder.setRequestAttributes(previous); + } + } + } + + /** + * 构造只用于携带 TokenData 的假 request。 + * + * @param tokenData 登录信息。 + * @return 代理出来的 HttpServletRequest。 + */ + private HttpServletRequest buildMockRequest(TokenData tokenData) { + return (HttpServletRequest) Proxy.newProxyInstance(HttpServletRequest.class.getClassLoader(), new Class[]{HttpServletRequest.class}, + (proxy, method, args) -> { + if ("getAttribute".equals(method.getName()) && args != null && args.length == 1) { + return TokenData.REQUEST_ATTRIBUTE_NAME.equals(args[0]) ? tokenData : null; + } + if ("getAttributeNames".equals(method.getName())) { + return Collections.enumeration(Collections.singletonList(TokenData.REQUEST_ATTRIBUTE_NAME)); + } + return defaultValue(method.getReturnType()); + }); + } + + /** + * 代理未实现方法的默认返回值,避免返回 null 触发拆箱 NPE。 + * + * @param returnType 返回类型。 + * @return 默认值。 + */ + private Object defaultValue(Class returnType) { + if (!returnType.isPrimitive() || void.class.equals(returnType)) { + return null; + } + if (boolean.class.equals(returnType)) { + return Boolean.FALSE; + } + if (char.class.equals(returnType)) { + return (char) 0; + } + if (byte.class.equals(returnType)) { + return (byte) 0; + } + if (short.class.equals(returnType)) { + return (short) 0; + } + if (int.class.equals(returnType)) { + return 0; + } + if (long.class.equals(returnType)) { + return 0L; + } + if (float.class.equals(returnType)) { + return 0F; + } + return 0D; + } + + /** + * 取当前登录用户id。任务线程里靠伪造的 request 也能取到(见 runWithRequestContext)。 + * + * @return 用户id;取不到返回null。 + */ + private Long currentUserId() { + TokenData tokenData = TokenData.takeFromRequest(); + return tokenData == null ? null : tokenData.getUserId(); + } + + /** + * 更新任务状态。 + * + * @param taskId 任务id。 + * @param status 目标状态。 + * @param errorMessage 失败原因,可空。 + */ + private void updateTaskStatus(Long taskId, OnlineImportExportTaskStatus status, String errorMessage) { + OnlineImportExportTask updateEntity = new OnlineImportExportTask(); + updateEntity.setId(taskId); + updateEntity.setStatus(status.getCode()); + updateEntity.setErrorMessage(errorMessage); + updateEntity.setUpdateTime(new Date()); + updateEntity.setUpdateUserId(this.currentUserId()); + this.updateById(updateEntity); + } + + /** + * 把文件标识还原成服务端绝对路径,含路径穿越校验。 + * + * @param fileKey 文件标识。 + * @return 文件绝对路径。 + */ + private String resolveSourceFilePath(String fileKey) { + File baseDir = new File(onlineProperties.getUploadFileBaseDir(), IMPORT_SUB_DIR); + Path basePath = baseDir.getAbsoluteFile().toPath().normalize(); + File targetFile = new File(baseDir, fileKey); + if (!targetFile.getAbsoluteFile().toPath().normalize().startsWith(basePath)) { + throw new OnlineRuntimeException("导入失败,文件标识不合法!"); + } + if (!targetFile.exists()) { + throw new OnlineRuntimeException("导入失败,上传的文件已不存在,请重新上传!"); + } + return targetFile.getAbsolutePath(); + } + + /** + * 删除文件(源文件在导入目录、结果文件在 fail 子目录),静默失败。 + * + * @param fileKey 文件标识。 + */ + private void deleteQuietly(String fileKey) { + if (StringUtils.isBlank(fileKey) || !SAFE_FILE_KEY_PATTERN.matcher(fileKey).matches()) { + return; + } + String[] candidateDirs = {IMPORT_SUB_DIR, IMPORT_SUB_DIR + File.separator + FAIL_SUB_DIR, OnlineExportDataServiceImpl.EXPORT_SUB_DIR}; + for (String subDir : candidateDirs) { + File file = new File(new File(onlineProperties.getUploadFileBaseDir(), subDir), fileKey); + if (file.exists() && !file.delete()) { + log.warn("任务文件删除失败:{}", file.getAbsolutePath()); + } + } + } + + /** + * 取可读的错误消息。 + * + * @param e 异常。 + * @return 消息。 + */ + private String resolveErrorMessage(Exception e) { + String message = e.getMessage(); + if (StringUtils.isBlank(message)) { + message = e.getClass().getSimpleName(); + } + return message.length() > 500 ? message.substring(0, 500) : message; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTemplateServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTemplateServiceImpl.java new file mode 100644 index 0000000..efb4ab0 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTemplateServiceImpl.java @@ -0,0 +1,532 @@ +package apelet.common.online.service.impl; + +import apelet.common.core.annotation.MyDataSourceResolver; +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.core.base.service.BaseService; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.object.MyRelationParam; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.DefaultDataSourceResolver; +import apelet.common.generator.model.OnlFormField; +import apelet.common.generator.model.OnlFormHead; +import apelet.common.generator.service.IOnlFormFieldService; +import apelet.common.generator.service.IOnlFormHeadService; +import apelet.common.online.dao.OnlineImportExportTemplateMapper; +import apelet.common.online.dto.OnlineImportExportTableDto; +import apelet.common.online.dto.OnlineImportExportTemplateStatusDto; +import apelet.common.online.exception.OnlineRuntimeException; +import apelet.common.online.model.OnlineForm; +import apelet.common.online.model.OnlineImportExportField; +import apelet.common.online.model.OnlineImportExportTemplate; +import apelet.common.online.model.constant.ImportExportEnabledStatus; +import apelet.common.online.service.OnlineFormService; +import apelet.common.online.service.OnlineImportExportFieldService; +import apelet.common.online.service.OnlineImportExportTemplateService; +import apelet.common.online.util.OnlineConstant; +import apelet.common.sequence.wrapper.IdGeneratorWrapper; +import com.alibaba.fastjson.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.github.pagehelper.Page; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 在线表单导入导出模板数据操作服务类。 + * + * @author chenchuchuan + * @date 2026-09-17 + */ +@Slf4j +@MyDataSourceResolver(resolver = DefaultDataSourceResolver.class, intArg = ApplicationConstant.COMMON_FLOW_AND_ONLINE_DATASOURCE_TYPE) +@Service("onlineImportExportTemplateService") +public class OnlineImportExportTemplateServiceImpl extends BaseService implements OnlineImportExportTemplateService { + + /** + * 缓存过期时间(小时)。与表单元数据缓存保持一致。 + */ + private static final int DEFAULT_CACHED_TEMPLATE_HOURS = 168; + + /** + * 由框架维护的内部列,不进入模板配置。parent_id / seq 由或框架自身读写,暴露给用户只会造成误配。 + */ + private static final Set INTERNAL_COLUMNS = new HashSet<>(Arrays.asList("parent_id", "seq")); + + @Autowired + private OnlineImportExportTemplateMapper onlineImportExportTemplateMapper; + + @Autowired + private OnlineImportExportFieldService onlineImportExportFieldService; + + @Autowired + private IOnlFormHeadService onlFormHeadService; + + @Autowired + private IOnlFormFieldService onlFormFieldService; + + @Autowired + private OnlineFormService onlineFormService; + + @Autowired + private IdGeneratorWrapper idGenerator; + + @Autowired + private RedissonClient redissonClient; + + @Override + protected BaseDaoMapper mapper() { + return onlineImportExportTemplateMapper; + } + + @Override + public List getOnlineFormList(OnlineImportExportTemplate filter, String orderBy) { + if (filter == null) { + filter = new OnlineImportExportTemplate(); + } + TokenData tokenData = TokenData.takeFromRequest(); + filter.setTenantId(tokenData.getTenantId()); + filter.setAppCode(tokenData.getAppCode()); + return onlineImportExportTemplateMapper.getOnlineFormList(filter, orderBy); + } + + @Override + public List getOnlineFormListWithRelation(OnlineImportExportTemplate filter, String orderBy) { + List resultList = this.getOnlineFormList(filter, orderBy); + // 非分页(导出)场景下按批读取关联,避免一次性拉取大量数据。 + int batchSize = resultList instanceof Page ? 0 : 1000; + this.buildRelationForDataList(resultList, MyRelationParam.full(), batchSize); + return resultList; + } + + @Override + public OnlineImportExportTemplate getTemplateDetail(Long id) { + return this.getByIdWithRelation(id, MyRelationParam.full()); + } + + @Override + public OnlineImportExportTemplate getEnabledTemplate(String tableName) { + if (StringUtils.isBlank(tableName)) { + return null; + } + String redisKey = OnlineConstant.makeImportExportTemplateKey(tableName); + RBucket bucket = redissonClient.getBucket(redisKey); + if (bucket.isExists()) { + String cachedJson = bucket.get(); + // 空串表示该表未启用模板,用于防止缓存穿透。 + return StringUtils.isBlank(cachedJson) ? null : JSON.parseObject(cachedJson, OnlineImportExportTemplate.class); + } + OnlineImportExportTemplate template = this.getEnabledTemplateFromDb(tableName); + if (template == null) { + bucket.set(""); + } else { + template.setOnlineImportExportFields(onlineImportExportFieldService.getListByTemplateId(template.getId())); + // masterHead 体积大且执行期用不到,不入缓存。 + template.setMasterHead(null); + bucket.set(JSON.toJSONString(template)); + } + bucket.expire(DEFAULT_CACHED_TEMPLATE_HOURS, TimeUnit.HOURS); + return template; + } + + @Override + public OnlineImportExportTemplate getEnabledTemplateFromDb(String tableName) { + if (StringUtils.isBlank(tableName)) { + return null; + } + return this.getOne(new LambdaQueryWrapper().eq(OnlineImportExportTemplate::getMasterTableName, tableName).eq(OnlineImportExportTemplate::getIsEnabled, ImportExportEnabledStatus.ENABLED.getCode())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public OnlineImportExportTemplate saveTemplate(OnlineImportExportTemplate template, List fieldList) { + if (template == null || StringUtils.isBlank(template.getTemplateName())) { + throw new OnlineRuntimeException("数据验证失败,模板名称不能为空!"); + } + OnlFormHead masterHead = template.getMasterHeadId() == null ? null : onlFormHeadService.getOnlFormHeadFromCache(template.getMasterHeadId()); + if (masterHead == null) { + throw new OnlineRuntimeException("数据验证失败,表单元数据不存在!"); + } + template.setMasterTableName(masterHead.getTableName()); + this.verifyTemplateNameUnique(template); + this.verifyShowName(fieldList); + TokenData tokenData = TokenData.takeFromRequest(); + Date now = new Date(); + if (template.getId() == null) { + // 新建的模板默认停用,由管理员配置完字段后手动启用。 + template.setId(idGenerator.nextLongId()); + template.setIsEnabled(ImportExportEnabledStatus.DISABLED.getCode()); + template.setTenantId(tokenData.getTenantId()); + template.setAppCode(tokenData.getAppCode()); + template.setCreateTime(now); + template.setCreateUserId(tokenData.getUserId()); + template.setUpdateTime(now); + template.setUpdateUserId(tokenData.getUserId()); + this.save(template); + } else { + OnlineImportExportTemplate origin = this.getById(template.getId()); + if (origin == null) { + throw new OnlineRuntimeException("数据验证失败,模板不存在!"); + } + // 保存字段配置不改变启用状态。 + template.setIsEnabled(origin.getIsEnabled()); + template.setTenantId(origin.getTenantId()); + template.setAppCode(origin.getAppCode()); + template.setCreateTime(origin.getCreateTime()); + template.setCreateUserId(origin.getCreateUserId()); + template.setUpdateTime(now); + template.setUpdateUserId(tokenData.getUserId()); + this.updateById(template); + } + this.saveFields(template, fieldList, tokenData, now); + this.evictEnabledTemplate(template.getMasterTableName()); + return template; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteTemplate(Long id) { + OnlineImportExportTemplate template = id == null ? null : this.getById(id); + if (template == null) { + throw new OnlineRuntimeException("数据验证失败,模板不存在!"); + } + onlineImportExportFieldService.remove(new LambdaQueryWrapper().eq(OnlineImportExportField::getTemplateId, id)); + this.removeById(id); + // 删除的是生效模板时,顺延该表剩余的第一套;删空则该表回落原有链路。 + if (ImportExportEnabledStatus.isEnabled(template.getIsEnabled())) { + OnlineImportExportTemplate next = this.getOne(new LambdaQueryWrapper().eq(OnlineImportExportTemplate::getMasterHeadId, template.getMasterHeadId()).orderByAsc(OnlineImportExportTemplate::getId).last("LIMIT 1")); + if (next != null) { + OnlineImportExportTemplate enableEntity = new OnlineImportExportTemplate(); + enableEntity.setId(next.getId()); + enableEntity.setIsEnabled(ImportExportEnabledStatus.ENABLED.getCode()); + this.fillUpdateAudit(enableEntity); + this.updateById(enableEntity); + } + } + this.evictEnabledTemplate(template.getMasterTableName()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void setEnabled(Long id, Integer isEnabled) { + OnlineImportExportTemplate template = id == null ? null : this.getById(id); + if (template == null) { + throw new OnlineRuntimeException("数据验证失败,模板不存在!"); + } + ImportExportEnabledStatus status = ImportExportEnabledStatus.of(isEnabled); + if (status == ImportExportEnabledStatus.ENABLED) { + // 同一张表至多一套生效模板:先把该表全部停用,再启用目标模板。 + OnlineImportExportTemplate disableEntity = new OnlineImportExportTemplate(); + disableEntity.setIsEnabled(ImportExportEnabledStatus.DISABLED.getCode()); + this.fillUpdateAudit(disableEntity); + this.update(disableEntity, new LambdaQueryWrapper().eq(OnlineImportExportTemplate::getMasterHeadId, template.getMasterHeadId())); + } + OnlineImportExportTemplate updateEntity = new OnlineImportExportTemplate(); + updateEntity.setId(id); + updateEntity.setIsEnabled(status.getCode()); + this.fillUpdateAudit(updateEntity); + this.updateById(updateEntity); + this.evictEnabledTemplate(template.getMasterTableName()); + } + + @Override + public List generateDefaultFields(Long masterHeadId) { + OnlFormHead masterHead = masterHeadId == null ? null : onlFormHeadService.getOnlFormHeadFromCache(masterHeadId); + if (masterHead == null) { + throw new OnlineRuntimeException("数据验证失败,表单元数据不存在!"); + } + List tableList = new ArrayList<>(); + // 主表节点 + this.appendDefaultFields(tableList, masterHead, null); + // 分录表节点:展示名加「表描述-」前缀,避免与主表字段重名。 + List entryHeadList = onlFormHeadService.getOneToManyOnlFormHeadCache(masterHead.getTableName()); + for (OnlFormHead entryHead : entryHeadList) { + this.appendDefaultFields(tableList, entryHead, entryHead.getTableTxt()); + } + // 默认展示名去重:同一模板内展示名必须唯一(保存时 verifyShowName 会拦), + // 重名的追加「-字段名」后缀,保证一键生成的结果可直接保存。 + this.dedupeShowName(tableList); + return tableList; + } + + /** + * 对默认展示名去重:统计全模板内重复的展示名,逐个追加「-字段名」后缀; + * 若后缀仍撞(极端情况),再追加序号直到唯一。不重名的保持原样,避免名字无谓变长。 + * + * @param tableList 表节点列表。 + */ + private void dedupeShowName(List tableList) { + List allFieldList = new ArrayList<>(); + for (OnlineImportExportTableDto tableDto : tableList) { + if (CollectionUtils.isNotEmpty(tableDto.getFieldList())) { + allFieldList.addAll(tableDto.getFieldList()); + } + } + if (allFieldList.isEmpty()) { + return; + } + Map countMap = allFieldList.stream() + .collect(Collectors.groupingBy(OnlineImportExportField::getShowName, Collectors.counting())); + Set usedShowNameSet = allFieldList.stream() + .filter(field -> countMap.getOrDefault(field.getShowName(), 0L) <= 1L) + .map(OnlineImportExportField::getShowName).collect(Collectors.toSet()); + for (OnlineImportExportField field : allFieldList) { + if (countMap.getOrDefault(field.getShowName(), 0L) <= 1L) { + continue; + } + field.setShowName(this.makeUniqueShowName(usedShowNameSet, field.getShowName() + "-" + field.getColumnName())); + } + } + + /** + * 生成一个未被占用的展示名,已被占用时追加序号后缀。 + * + * @param usedShowNameSet 已占用的展示名集合,命中后会写入新名。 + * @param candidate 候选展示名。 + * @return 可用的展示名。 + */ + private String makeUniqueShowName(Set usedShowNameSet, String candidate) { + String showName = candidate; + int index = 2; + while (!usedShowNameSet.add(showName)) { + showName = candidate + "-" + index; + index++; + } + return showName; + } + + @Override + public OnlineImportExportTemplateStatusDto getTemplateStatus(Long formId) { + OnlineImportExportTemplateStatusDto statusDto = new OnlineImportExportTemplateStatusDto(); + if (formId == null) { + return statusDto; + } + OnlineForm onlineForm = onlineFormService.getById(formId); + if (onlineForm == null) { + return statusDto; + } + OnlFormHead masterHead = onlFormHeadService.getHeadCacheByTableId(onlineForm.getMasterTableId()); + if (masterHead == null) { + return statusDto; + } + statusDto.setMasterHeadId(masterHead.getId()); + statusDto.setMasterTableName(masterHead.getTableName()); + OnlineImportExportTemplate template = this.getEnabledTemplate(masterHead.getTableName()); + if (template == null) { + return statusDto; + } + statusDto.setEnabled(Boolean.TRUE); + statusDto.setTemplateId(template.getId()); + statusDto.setTemplateName(template.getTemplateName()); + List fieldList = onlineImportExportFieldService.getListByTemplateId(template.getId()); + statusDto.setImportColumnCount((int) fieldList.stream().filter(field -> Integer.valueOf(1).equals(field.getIsImport())).count()); + statusDto.setExportColumnCount((int) fieldList.stream().filter(field -> Integer.valueOf(1).equals(field.getIsExport())).count()); + return statusDto; + } + + @Override + public void evictEnabledTemplate(String tableName) { + if (StringUtils.isBlank(tableName)) { + return; + } + redissonClient.getBucket(OnlineConstant.makeImportExportTemplateKey(tableName)).delete(); + } + + @Override + public List groupToTableList(List fieldList) { + if (CollectionUtils.isEmpty(fieldList)) { + return new ArrayList<>(); + } + Map tableMap = new LinkedHashMap<>(); + for (OnlineImportExportField field : fieldList) { + OnlineImportExportTableDto tableDto = tableMap.computeIfAbsent(field.getTableName(), k -> { + OnlineImportExportTableDto dto = new OnlineImportExportTableDto(); + dto.setTableName(k); + dto.setFieldList(new ArrayList<>()); + return dto; + }); + tableDto.getFieldList().add(field); + } + for (OnlineImportExportTableDto tableDto : tableMap.values()) { + tableDto.setTableLabel(this.resolveTableLabel(tableDto.getTableName())); + // 从库里读出来的字段没有 isF7(它是 @TableField(exist=false) 的临时字段, + // 只有一键生成时才填),这里按表元数据补齐,保证「详情」与「一键生成」两个接口返回结构一致。 + Map formFieldMap = this.loadFormFieldMap(tableDto.getTableName()); + if (formFieldMap.isEmpty()) { + continue; + } + for (OnlineImportExportField field : tableDto.getFieldList()) { + OnlFormField formField = formFieldMap.get(StringUtils.lowerCase(field.getColumnName())); + field.setIsF7(formField != null && formField.getRefPropert() != null ? 1 : 0); + } + } + return new ArrayList<>(tableMap.values()); + } + + /** + * 按物理表名载入字段元数据(key = 小写列名),用于补齐 isF7 等派生标记。 + * + * @param tableName 物理表名。 + * @return 字段元数据map;取不到时返回空map。 + */ + private Map loadFormFieldMap(String tableName) { + OnlFormHead head = onlFormHeadService.getHeadTableNameCache(tableName); + if (head == null) { + return Collections.emptyMap(); + } + List fieldList = onlFormFieldService.getFiledByHeadIdCache(head.getId()); + if (CollectionUtils.isEmpty(fieldList)) { + return Collections.emptyMap(); + } + Map fieldMap = new LinkedHashMap<>(); + for (OnlFormField field : fieldList) { + fieldMap.putIfAbsent(StringUtils.lowerCase(field.getFieldName()), field); + } + return fieldMap; + } + + /** + * 按物理表名取表描述,取不到时回落表名。 + */ + private String resolveTableLabel(String tableName) { + if (StringUtils.isBlank(tableName)) { + return tableName; + } + OnlFormHead head = onlFormHeadService.getHeadTableNameCache(tableName); + if (head == null) { + return tableName; + } + return StringUtils.defaultIfBlank(head.getTableTxt(), tableName); + } + + /** + * 按默认规则组装某张表的字段配置,作为一个表节点追加到结果列表。 + * + * @param tableList 表节点列表。 + * @param head 该表的表单元数据。 + * @param showPrefix 展示名前缀,主表为 null,分录表为表描述。 + */ + private void appendDefaultFields(List tableList, OnlFormHead head, String showPrefix) { + List fieldList = onlFormFieldService.getFiledByHeadIdCache(head.getId()); + if (CollectionUtils.isEmpty(fieldList)) { + return; + } + OnlineImportExportTableDto tableDto = new OnlineImportExportTableDto(); + tableDto.setTableName(head.getTableName()); + tableDto.setTableLabel(head.getTableTxt()); + List importExportFieldList = new ArrayList<>(fieldList.size()); + int sortNo = 0; + for (OnlFormField formField : fieldList) { + String columnName = StringUtils.lowerCase(formField.getFieldName()); + if (StringUtils.isBlank(columnName) || INTERNAL_COLUMNS.contains(columnName)) { + continue; + } + String fieldRemark = StringUtils.defaultIfBlank(formField.getFieldRemark(), columnName); + OnlineImportExportField field = new OnlineImportExportField(); + field.setTableName(head.getTableName()); + field.setColumnName(columnName); + field.setFieldRemark(fieldRemark); + field.setShowName(showPrefix == null ? fieldRemark : showPrefix + "-" + fieldRemark); + field.setIsKey(Integer.valueOf(1).equals(formField.getIsKey()) ? 1 : 0); + // OnlFormField.isEmpty:1 表示允许为空,0 表示必填。 + field.setIsRequired(Integer.valueOf(0).equals(formField.getIsEmpty()) ? 1 : 0); + // 关联字段(单选 402 / 多选 404)都带 refPropert,前端据此决定是否要配置关联属性。 + field.setIsF7(formField.getRefPropert() == null ? 0 : 1); + field.setIsImport(0); + field.setIsExport(1); + field.setSortNo(sortNo++); + importExportFieldList.add(field); + } + if (importExportFieldList.isEmpty()) { + return; + } + tableDto.setFieldList(importExportFieldList); + tableList.add(tableDto); + } + + /** + * 整体覆盖保存字段配置:先按模板删除,再全部重建。 + * + * @param template 模板对象。 + * @param fieldList 字段配置列表。 + * @param tokenData 当前登录信息。 + * @param now 当前时间。 + */ + private void saveFields(OnlineImportExportTemplate template, List fieldList, TokenData tokenData, Date now) { + onlineImportExportFieldService.remove(new LambdaQueryWrapper().eq(OnlineImportExportField::getTemplateId, template.getId())); + if (CollectionUtils.isEmpty(fieldList)) { + return; + } + int sortNo = 0; + for (OnlineImportExportField field : fieldList) { + field.setId(idGenerator.nextLongId()); + field.setTemplateId(template.getId()); + field.setSortNo(sortNo++); + field.setTenantId(tokenData.getTenantId()); + field.setCreateTime(now); + field.setCreateUserId(tokenData.getUserId()); + field.setUpdateTime(now); + field.setUpdateUserId(tokenData.getUserId()); + // 不能用 saveBatch:OnlineImportExportFieldServiceImpl 继承 BaseService, + // 其 saveBatch 会按泛型拼出 BaseDaoMapper.insert(不存在)而报错。 + onlineImportExportFieldService.save(field); + } + } + + /** + * 补「更新」的审计字段:表里这四列都是 NOT NULL,更新时虽不会报错(MyBatis-Plus 只更新非空字段), + * 但不刷新就会一直是旧值/0。 + * + * @param entity 待更新的实体。 + */ + private void fillUpdateAudit(OnlineImportExportTemplate entity) { + TokenData tokenData = TokenData.takeFromRequest(); + entity.setUpdateTime(new Date()); + entity.setUpdateUserId(tokenData == null ? null : tokenData.getUserId()); + } + + /** + * 校验模板名称在同一表单元数据下唯一。 + * + * @param template 模板对象。 + */ + private void verifyTemplateNameUnique(OnlineImportExportTemplate template) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(OnlineImportExportTemplate::getMasterHeadId, template.getMasterHeadId()) + .eq(OnlineImportExportTemplate::getTemplateName, template.getTemplateName()); + if (template.getId() != null) { + wrapper.ne(OnlineImportExportTemplate::getId, template.getId()); + } + if (this.count(wrapper) > 0) { + throw new OnlineRuntimeException("数据验证失败,同一表单元数据下模板名称不能重复!"); + } + } + + /** + * 校验表头展示名非空且在同一模板内唯一。冲突时报出具体重复项,而不是让库层唯一索引抛原始错误。 + * + * @param fieldList 字段配置列表。 + */ + private void verifyShowName(List fieldList) { + if (CollectionUtils.isEmpty(fieldList)) { + return; + } + List blankColumns = fieldList.stream().filter(f -> StringUtils.isBlank(f.getShowName())).map(OnlineImportExportField::getColumnName).collect(Collectors.toList()); + if (CollectionUtils.isNotEmpty(blankColumns)) { + throw new OnlineRuntimeException("数据验证失败,以下字段的表头展示名不能为空:" + String.join("、", blankColumns) + "!"); + } + Map duplicatedMap = fieldList.stream().collect(Collectors.groupingBy(OnlineImportExportField::getShowName, Collectors.counting())).entrySet().stream().filter(e -> e.getValue() > 1).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + if (!duplicatedMap.isEmpty()) { + String detail = duplicatedMap.entrySet().stream().map(e -> e.getKey() + "(" + e.getValue() + "处)").collect(Collectors.joining("、")); + throw new OnlineRuntimeException("数据验证失败,表头展示名重复:" + detail + ",请修改后再保存!"); + } + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java index c5a3dbc..f4020b4 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java @@ -728,6 +728,12 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { @Override public MyPageData> getSlaveDataList( OnlineDatasourceRelation relation, List filterList, String orderBy, MyPageParam pageParam) { + return this.getSlaveDataList(relation, filterList, orderBy, pageParam, false); + } + + @Override + public MyPageData> getSlaveDataList( + OnlineDatasourceRelation relation, List filterList, String orderBy, MyPageParam pageParam, boolean forceLeftJoin) { OnlineTable slaveTable = relation.getSlaveTable(); this.normalizeFilterList(slaveTable, null, filterList, null); ResponseResult> relationListResult = @@ -735,6 +741,10 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { List allRelationList = relationListResult.getData(); List oneToOneRelationList = relationListResult.getData().stream() .filter(r -> r.getRelationType().equals(RelationType.ONE_TO_ONE)).collect(Collectors.toList()); + if (forceLeftJoin) { + // 从表自己的 F7 关联也要左连接,否则「F7 没填值」的分录行会被滤掉(关系列表是反序列化副本,可安全就地改) + oneToOneRelationList.forEach(r -> r.setLeftJoin(Boolean.TRUE)); + } // 组件表关联数据。 List joinInfoList = this.makeJoinInfoList(slaveTable, oneToOneRelationList); // 拼接关联表的select fields字段。 diff --git a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineTableServiceImpl.java b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineTableServiceImpl.java index fcdb533..6c982db 100644 --- a/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineTableServiceImpl.java +++ b/common/common-online/src/main/java/apelet/common/online/service/impl/OnlineTableServiceImpl.java @@ -19,10 +19,10 @@ import apelet.common.sequence.wrapper.IdGeneratorWrapper; import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.ObjectUtil; import com.alibaba.fastjson.JSON; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.google.common.base.CaseFormat; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; import org.redisson.api.RBucket; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; @@ -201,5 +201,27 @@ public class OnlineTableServiceImpl extends BaseService imple redissonClient.getBucket(tableIdKey).delete(); } - + @Override + public OnlineTable queryByTableName(String tableName) { + OnlineTable onlineTable = onlineTableMapper.selectOne(new QueryWrapper().eq("table_name", tableName)); + if (onlineTable == null) { + return null; + } + List columnList = onlineColumnService.getOnlineColumnListByTableIds(Collections.singleton(onlineTable.getTableId())); + if (CollectionUtils.isNotEmpty(columnList)) { + Map columnMap = columnList.stream().collect(Collectors.toMap(OnlineColumn::getColumnId, c -> c)); + onlineTable.setColumnMap(columnMap); + for (OnlineColumn column : columnList) { + if (BooleanUtil.isTrue(column.getPrimaryKey())) { + onlineTable.setPrimaryKeyColumn(column); + continue; + } + if (ObjectUtil.equal(column.getFieldKind(), FieldKind.LOGIC_DELETE)) { + onlineTable.setLogicDeleteColumn(column); + } + } + } + onlineTable.setColumnList(columnList); + return onlineTable; + } } diff --git a/common/common-online/src/main/java/apelet/common/online/timer/ImportExportFileCleanTimer.java b/common/common-online/src/main/java/apelet/common/online/timer/ImportExportFileCleanTimer.java new file mode 100644 index 0000000..8bface7 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/timer/ImportExportFileCleanTimer.java @@ -0,0 +1,130 @@ +package apelet.common.online.timer; + +import apelet.common.online.config.OnlineProperties; +import apelet.common.online.service.OnlineImportExportTaskService; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.io.File; +import java.util.concurrent.TimeUnit; + +/** + * 导入导出文件清理定时器。 + * + *

清理规则:

+ *
    + *
  1. 下载即删 —— 文件被下载后由下载接口立即删除(见 {@code ImportExportExcelUtil#writeFailFileToResponse});
  2. + *
  3. 超期清理 —— 未被下载的文件(上传的导入源文件、导入失败文件、导出结果文件), + * 超过 {@code common-online.import-export-file-retain-hours}(默认 24 小时)后由本定时器删除。
  4. + *
+ * + *

清理范围仅限 {@code {uploadFileBaseDir}/importedFile} 目录,不涉及同级的 attachment / image 目录。 + * 多实例下用分布式锁保证同一时刻只有一个实例在清理。

+ * + * @author chenchuchuan + * @date 2026-09-21 + */ +@EnableScheduling +@Component +@Slf4j +public class ImportExportFileCleanTimer { + + /** + * 导入导出文件所在的子目录(相对 uploadFileBaseDir)。 + */ + private static final String IMPORT_FILE_SUB_DIR = "importedFile"; + + /** + * 分布式锁名称。 + */ + private static final String LOCK_NAME = "IMPORT_EXPORT_FILE_CLEAN_TIMER"; + + @Autowired + private OnlineProperties onlineProperties; + + @Autowired + private OnlineImportExportTaskService onlineImportExportTaskService; + + @Autowired + private RedissonClient redissonClient; + + private RLock distLock; + + @PostConstruct + public void init() { + distLock = redissonClient.getLock(LOCK_NAME); + } + + /** + * 每小时执行一次:清理超期文件 + 清理超期的异步任务记录。 + */ + @Scheduled(cron = "0 17 * * * ?") + public void execute() { + if (!distLock.tryLock()) { + return; + } + try { + this.cleanExpiredFiles(); + // 任务记录清理失败不影响文件清理,单独兜底 + try { + onlineImportExportTaskService.cleanExpiredTasks(); + } catch (Exception e) { + log.error("导入导出异步任务清理失败", e); + } + } catch (Exception e) { + log.error("Failed to call ImportExportFileCleanTimer.execute", e); + } finally { + distLock.unlock(); + } + } + + /** + * 删除 importedFile 目录下超过保留时长的文件。 + */ + private void cleanExpiredFiles() { + File baseDir = new File(onlineProperties.getUploadFileBaseDir(), IMPORT_FILE_SUB_DIR); + if (!baseDir.exists()) { + return; + } + Integer retainHours = onlineProperties.getImportExportFileRetainHours(); + long retainMillis = TimeUnit.HOURS.toMillis(retainHours == null || retainHours <= 0 ? 24 : retainHours); + int deletedCount = this.deleteExpiredFiles(baseDir, System.currentTimeMillis() - retainMillis); + if (deletedCount > 0) { + log.info("导入导出文件清理完成,共删除 {} 个文件,目录={}", deletedCount, baseDir.getAbsolutePath()); + } + } + + /** + * 递归删除目录下最后修改时间早于 deadline 的文件;文件清空后目录一并删除。 + * + * @param dir 待清理目录。 + * @param deadline 过期时间戳(毫秒),早于该时间的文件会被删除。 + * @return 删除的文件数量。 + */ + private int deleteExpiredFiles(File dir, long deadline) { + File[] childArray = dir.listFiles(); + if (childArray == null) { + return 0; + } + int deletedCount = 0; + for (File child : childArray) { + if (child.isDirectory()) { + deletedCount += this.deleteExpiredFiles(child, deadline); + // 子目录里的文件清空后,目录本身也删掉 + File[] remains = child.listFiles(); + if (remains != null && remains.length == 0) { + child.delete(); + } + } else if (child.lastModified() < deadline && child.delete()) { + deletedCount++; + } + } + return deletedCount; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/util/ImportExportExcelUtil.java b/common/common-online/src/main/java/apelet/common/online/util/ImportExportExcelUtil.java new file mode 100644 index 0000000..e12503f --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/util/ImportExportExcelUtil.java @@ -0,0 +1,378 @@ +package apelet.common.online.util; + +import apelet.common.core.util.ContextUtil; +import apelet.common.online.dto.OnlineImportRowDto; +import apelet.common.online.exception.OnlineRuntimeException; +import apelet.common.online.model.OnlineImportExportField; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.net.URLEncodeUtil; +import cn.hutool.core.util.CharsetUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.poi.excel.ExcelUtil; +import cn.hutool.poi.excel.ExcelWriter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 导入导出 Excel 生成工具。 + * + *

不直接复用 {@code common-core} 的 ExportUtil,原因有三:其列宽写死为 30/20、文件名未做 + * URL 编码(中文乱码)、数据为空时不写任何响应。这里按模板配置控制列宽与表头。

+ * + * @author chenchuchuan + * @date 2026-09-17 + */ +@Slf4j +public final class ImportExportExcelUtil { + + /** + * 模板默认列宽(字符数)。 + */ + private static final int DEFAULT_COLUMN_WIDTH = 20; + + /** + * Excel 列宽下限,与配置页的校验区间保持一致。 + */ + private static final int MIN_COLUMN_WIDTH = 5; + + /** + * Excel 列宽上限。 + */ + private static final int MAX_COLUMN_WIDTH = 255; + + /** + * Excel sheet 名长度上限。 + */ + private static final int MAX_SHEET_NAME_LENGTH = 31; + + /** + * Excel sheet 名中的非法字符。 + */ + private static final String ILLEGAL_SHEET_NAME_CHARS = "[]:*?/\\"; + + /** + * 文件名中的非法字符。 + */ + private static final String ILLEGAL_FILE_NAME_CHARS = "[]:*?/\\<>|\""; + + /** + * 合法的文件标识:仅允许文件名安全字符,防止路径穿越。 + */ + private static final Pattern SAFE_FILE_KEY_PATTERN = Pattern.compile("^[A-Za-z0-9\\-\\._]+$"); + + private ImportExportExcelUtil() { + } + + /** + * 生成导入模板(仅表头、无数据行)并写入 HTTP 响应。 + * + * @param importFieldList 需要出现在模板中的字段,须已按 isImport 过滤并按 sortNo 排序。 + * @param sheetName sheet 名称,为空时取「导入模板」。 + * @param fileName 下载文件名,须带 .xlsx 后缀。 + * @throws IOException 写响应失败时抛出。 + */ + public static void writeImportTemplate(List importFieldList, String sheetName, String fileName) throws IOException { + if (CollectionUtils.isEmpty(importFieldList)) { + throw new OnlineRuntimeException("生成导入模板失败,该模板未勾选任何导入字段!"); + } + HttpServletResponse response = ContextUtil.getHttpResponse(); + if (response == null) { + throw new OnlineRuntimeException("生成导入模板失败,当前上下文无法获取 HTTP 响应!"); + } + response.setHeader("content-type", "application/octet-stream"); + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", buildContentDisposition(fileName)); + ExcelWriter writer = ExcelUtil.getWriter(true); + try { + writer.renameSheet(0, sanitizeSheetName(sheetName)); + List headerList = importFieldList.stream().map(OnlineImportExportField::getShowName).collect(Collectors.toList()); + // 只写表头行,不写任何数据行。 + writer.writeHeadRow(headerList); + for (int i = 0; i < importFieldList.size(); i++) { + writer.setColumnWidth(i, normalizeColumnWidth(importFieldList.get(i).getExcelWidth())); + } + ServletOutputStream out = response.getOutputStream(); + writer.flush(out); + IoUtil.close(out); + } finally { + writer.close(); + } + } + + /** + * 生成失败数据 Excel 并落盘:模板的导入列(原样回显)+ 末尾追加「失败原因」列。 + * + *

列头用的是模板展示名,多出来的原因列在重新导入时会被「多余列忽略」规则跳过, + * 因此用户可以改完直接重传这个文件。

+ * + * @param failedRowList 失败的行。 + * @param importFieldList 模板中已勾选导入的字段,决定列与顺序。 + * @param reasonHeader 原因列的表头文案。 + * @param uploadBaseDir 上传根目录。 + * @param subDir 落盘子目录(相对上传根目录)。 + * @return 失败文件标识(文件名),可直接交给下载接口。 + * @throws IOException 写文件失败时抛出。 + */ + public static String writeFailFile(List failedRowList, List importFieldList, + String reasonHeader, String uploadBaseDir, String subDir) throws IOException { + if (CollectionUtils.isEmpty(failedRowList) || CollectionUtils.isEmpty(importFieldList)) { + return null; + } + List headerList = importFieldList.stream().map(OnlineImportExportField::getShowName).collect(Collectors.toList()); + headerList.add(reasonHeader); + ExcelWriter writer = ExcelUtil.getWriter(true); + File targetFile; + try { + writer.renameSheet(0, "失败数据"); + writer.writeHeadRow(headerList); + for (OnlineImportRowDto rowDto : failedRowList) { + List dataList = new ArrayList<>(); + for (OnlineImportExportField field : importFieldList) { + dataList.add(rowDto.getValue(field.getTableName(), field.getColumnName())); + } + dataList.add(StringUtils.defaultString(rowDto.getFailReason())); + writer.writeRow(dataList); + } + for (int i = 0; i < importFieldList.size(); i++) { + writer.setColumnWidth(i, DEFAULT_COLUMN_WIDTH); + } + File targetDir = new File(uploadBaseDir, subDir); + if (!targetDir.exists() && !targetDir.mkdirs()) { + throw new IOException("失败文件目录创建失败:" + targetDir.getAbsolutePath()); + } + targetFile = new File(targetDir, UUID.randomUUID().toString().replace("-", "") + ".xlsx"); + OutputStream out = new FileOutputStream(targetFile); + try { + writer.flush(out); + } finally { + IoUtil.close(out); + } + } finally { + writer.close(); + } + return targetFile.getName(); + } + + /** + * 把失败文件写回 HTTP 响应(浏览器下载)。含路径穿越校验。 + * + * @param fileKey 失败文件标识(文件名)。 + * @param uploadBaseDir 上传根目录。 + * @param subDir 落盘子目录(相对上传根目录)。 + * @throws IOException 读文件或写响应失败时抛出。 + */ + public static void writeFailFileToResponse(String fileKey, String uploadBaseDir, String subDir) throws IOException { + writeResultFileToResponse(fileKey, uploadBaseDir, subDir, "导入失败数据.xlsx"); + } + + /** + * 把结果文件写回 HTTP 响应(浏览器下载)并删除落盘副本。含路径穿越校验。 + * + * @param fileKey 文件标识(文件名)。 + * @param uploadBaseDir 上传根目录。 + * @param subDir 落盘子目录(相对上传根目录)。 + * @param downloadFileName 下载保存的文件名(浏览器看到的)。 + * @throws IOException 读文件或写响应失败时抛出。 + */ + public static void writeResultFileToResponse(String fileKey, String uploadBaseDir, String subDir, String downloadFileName) throws IOException { + if (StringUtils.isBlank(fileKey) || !SAFE_FILE_KEY_PATTERN.matcher(fileKey).matches()) { + throw new OnlineRuntimeException("下载失败,文件标识不合法!"); + } + File baseDir = new File(uploadBaseDir, subDir); + Path basePath = baseDir.getAbsoluteFile().toPath().normalize(); + File targetFile = new File(baseDir, fileKey); + if (!targetFile.getAbsoluteFile().toPath().normalize().startsWith(basePath)) { + throw new OnlineRuntimeException("下载失败,文件标识不合法!"); + } + if (!targetFile.exists()) { + throw new OnlineRuntimeException("下载失败,文件已过期,请重新导入导出!"); + } + HttpServletResponse response = ContextUtil.getHttpResponse(); + if (response == null) { + throw new OnlineRuntimeException("下载失败,当前上下文无法获取 HTTP 响应!"); + } + response.setHeader("content-type", "application/octet-stream"); + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", buildContentDisposition(downloadFileName)); + ServletOutputStream out = response.getOutputStream(); + try { + // FileInputStream 必须显式关闭:IoUtil.copy 不负责关闭入参, + // 不关的话 Windows 下文件句柄一直被占用,下面删副本会报「另一个程序正在使用此文件」。 + try (InputStream in = new FileInputStream(targetFile)) { + IoUtil.copy(in, out); + } + } finally { + IoUtil.close(out); + } + // 下载即消费:内容已写入响应流,删除落盘副本(未下载的文件由定时任务按保留时长清理)。 + // 此时下载已经成功,删除失败不能影响这次响应(hutool 的 del 在删不掉时会抛异常而不是返回 false)。 + try { + if (!FileUtil.del(targetFile)) { + log.warn("结果文件下载后删除失败,fileKey={}", fileKey); + } + } catch (Exception e) { + log.warn("结果文件下载后删除失败,fileKey={}", fileKey, e); + } + } + + /** + * 生成导出 Excel 并落盘,返回文件标识(文件名),供异步导出与下载接口使用。 + * + * @param exportFieldList 导出列配置,须已按 isExport 过滤并按 sortNo 排序。 + * @param rowList 数据行,每行元素顺序与 exportFieldList 一一对应。 + * @param sheetName sheet 名称。 + * @param uploadBaseDir 上传根目录。 + * @param subDir 落盘子目录(相对上传根目录)。 + * @return 文件标识(文件名)。 + * @throws IOException 写文件失败时抛出。 + */ + public static String writeExportFile(List exportFieldList, List> rowList, + String sheetName, String uploadBaseDir, String subDir) throws IOException { + File targetDir = new File(uploadBaseDir, subDir); + if (!targetDir.exists() && !targetDir.mkdirs()) { + throw new IOException("导出文件目录创建失败:" + targetDir.getAbsolutePath()); + } + File targetFile = new File(targetDir, UUID.randomUUID().toString().replace("-", "") + ".xlsx"); + ExcelWriter writer = buildWriter(exportFieldList, sheetName); + try { + writeDataRows(writer, rowList); + OutputStream out = new FileOutputStream(targetFile); + try { + writer.flush(out); + } finally { + IoUtil.close(out); + } + } finally { + writer.close(); + } + return targetFile.getName(); + } + + /** + * 生成导出 Excel 并直接写入 HTTP 响应(同步导出,不落盘)。 + * + * @param exportFieldList 导出列配置,须已按 isExport 过滤并按 sortNo 排序。 + * @param rowList 数据行,每行元素顺序与 exportFieldList 一一对应。 + * @param sheetName sheet 名称。 + * @param fileName 下载文件名。 + * @throws IOException 写响应失败时抛出。 + */ + public static void writeExportFileToResponse(List exportFieldList, List> rowList, + String sheetName, String fileName) throws IOException { + HttpServletResponse response = ContextUtil.getHttpResponse(); + if (response == null) { + throw new OnlineRuntimeException("导出失败,当前上下文无法获取 HTTP 响应!"); + } + response.setHeader("content-type", "application/octet-stream"); + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", buildContentDisposition(fileName)); + ExcelWriter writer = buildWriter(exportFieldList, sheetName); + try { + writeDataRows(writer, rowList); + ServletOutputStream out = response.getOutputStream(); + writer.flush(out); + IoUtil.close(out); + } finally { + writer.close(); + } + } + + /** + * 表头行之后写入数据行,每行元素顺序与导出列一一对应(null 留空单元格)。 + * + * @param writer 已写过表头的 ExcelWriter。 + * @param rowList 数据行。 + */ + private static void writeDataRows(ExcelWriter writer, List> rowList) { + if (CollectionUtils.isEmpty(rowList)) { + return; + } + for (List row : rowList) { + writer.writeRow(row); + } + } + + /** + * 按导出列配置构建 ExcelWriter:写表头行 + 按 excelWidth 逐列设宽。 + * 绕开 {@code ExportUtil}(它列宽写死 30/20、文件名不编码、空数据不写响应)。 + * + * @param fieldList 列配置,顺序即列顺序。 + * @param sheetName sheet 名称,为空时取「导出数据」。 + * @return 已就绪的 ExcelWriter,调用方负责 flush 与 close。 + */ + private static ExcelWriter buildWriter(List fieldList, String sheetName) { + ExcelWriter writer = ExcelUtil.getWriter(true); + writer.renameSheet(0, sanitizeSheetName(sheetName)); + List headerList = fieldList.stream().map(OnlineImportExportField::getShowName).collect(Collectors.toList()); + writer.writeHeadRow(headerList); + for (int i = 0; i < fieldList.size(); i++) { + writer.setColumnWidth(i, normalizeColumnWidth(fieldList.get(i).getExcelWidth())); + } + return writer; + } + + /** + * 拼装 Content-Disposition。中文文件名按其规则补 filename*,否则浏览器会乱码。 + * + * @param fileName 文件名。 + * @return Content-Disposition 头。 + */ + private static String buildContentDisposition(String fileName) { + String safeName = StringUtils.defaultIfBlank(fileName, "import-template.xlsx"); + // RFC 6266:filename= 的值只能是 ASCII —— Tomcat 按 ISO-8859-1 写响应头, + // 含中文会抛 IllegalArgumentException 并把整个头丢掉(日志里那条 "header ... has been removed")。 + // 因此这里把非 ASCII 字符统一替换成下划线兜底,真实文件名交给 filename*=UTF-8''(RFC 5987)。 + String fallbackName = StringUtils.replaceChars(safeName, ILLEGAL_FILE_NAME_CHARS, null).replaceAll("[^\\x20-\\x7E]", "_"); + // filename* 的值按要求用 UTF-8 百分号编码;URLEncoder 会把空格编成 '+',而 RFC 5987 要求 %20 + String encodedName = StrUtil.replace(URLEncodeUtil.encode(safeName, CharsetUtil.CHARSET_UTF_8), "+", "%20"); + return "attachment;filename=\"" + fallbackName + "\";filename*=UTF-8''" + encodedName; + } + + /** + * 列宽归一化:未配置时取默认值,超出 [5, 255] 时收敛到边界。 + * + * @param excelWidth 模板中配置的列宽。 + * @return 实际使用的列宽。 + */ + private static int normalizeColumnWidth(Integer excelWidth) { + if (excelWidth == null) { + return DEFAULT_COLUMN_WIDTH; + } + if (excelWidth < MIN_COLUMN_WIDTH) { + return MIN_COLUMN_WIDTH; + } + return Math.min(excelWidth, MAX_COLUMN_WIDTH); + } + + /** + * sheet 名净化:去掉非法字符并截断到 Excel 允许的长度。 + * + * @param sheetName 原始 sheet 名。 + * @return 合法的 sheet 名。 + */ + private static String sanitizeSheetName(String sheetName) { + String safeName = StringUtils.defaultIfBlank(sheetName, "导入模板"); + safeName = StringUtils.replaceChars(safeName, ILLEGAL_SHEET_NAME_CHARS, null); + if (StringUtils.isBlank(safeName)) { + safeName = "导入模板"; + } + return safeName.length() > MAX_SHEET_NAME_LENGTH ? safeName.substring(0, MAX_SHEET_NAME_LENGTH) : safeName; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/util/OnlineConstant.java b/common/common-online/src/main/java/apelet/common/online/util/OnlineConstant.java index e704b9e..2aa6365 100644 --- a/common/common-online/src/main/java/apelet/common/online/util/OnlineConstant.java +++ b/common/common-online/src/main/java/apelet/common/online/util/OnlineConstant.java @@ -14,6 +14,21 @@ public class OnlineConstant { public static final String RELATION_TABLE_COLUMN_SEPARATOR = "__"; /** + * 生效的导入导出模板缓存 key 前缀。 + */ + private static final String IMPORT_EXPORT_TEMPLATE_CACHE_PREFIX = "IMPORT_EXPORT_TEMPLATE:"; + + /** + * 拼装「主表物理表名 → 生效导入导出模板」的缓存 key。 + * + * @param tableName 主表物理表名。 + * @return 缓存 key。 + */ + public static String makeImportExportTemplateKey(String tableName) { + return IMPORT_EXPORT_TEMPLATE_CACHE_PREFIX + tableName; + } + + /** * 私有构造函数,明确标识该常量类的作用。 */ private OnlineConstant() { diff --git a/common/common-online/src/main/java/apelet/common/online/util/OnlineDataSourceUtil.java b/common/common-online/src/main/java/apelet/common/online/util/OnlineDataSourceUtil.java index dd2ce57..553d0dc 100644 --- a/common/common-online/src/main/java/apelet/common/online/util/OnlineDataSourceUtil.java +++ b/common/common-online/src/main/java/apelet/common/online/util/OnlineDataSourceUtil.java @@ -4,11 +4,23 @@ import apelet.common.core.exception.MyRuntimeException; import apelet.common.dbutil.provider.DataSourceProvider; import apelet.common.dbutil.util.DataSourceUtil; import apelet.common.online.model.OnlineDblink; +import apelet.common.online.model.OnlineTable; import apelet.common.online.service.OnlineDblinkService; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + /** * 在线表单模块动态加载的数据源工具类。 * @@ -44,4 +56,97 @@ public class OnlineDataSourceUtil extends DataSourceUtil { } return dblink.getConfiguration(); } + + /** + * 按某一列的 IN 条件查询若干列(本地库直查,不走 ORM 取数)。 + * + *

用于导入导出的关联字段补值/匹配键查询:ORM 的 {@code in} 过滤器要求调用方把值自己拼成 + * 字符串再传,且会把带 EAS 异构标记的表(如 t_pm_user)转发到 EAS 取数;这两处读的就是本地表, + * 直接走 SQL 更可控,也和导出主表取数的口径一致。

+ * + * @param table 目标表,用其表名与 dblink。 + * @param matchColumn 用于匹配的列。 + * @param returnColumns 要取值的列。 + * @param matchValues 待匹配的值集合。 + * @return 每行一个 map(键为列名,取值用 {@link #getColumnText} 可忽略大小写与引号); + * 表对象为空、无待匹配值或查询无结果时返回空列表。 + *

匹配列一定会在结果里(调用方通常要用它做 key 建映射,漏选会拿到 null 静默丢数据)。

+ */ + public List> queryByIn(OnlineTable table, String matchColumn, Collection returnColumns, Collection matchValues) { + return table == null ? Collections.emptyList() + : this.queryByIn(table.getDblinkId(), table.getTableName(), matchColumn, returnColumns, matchValues, null); + } + + /** + * 按某一列的 IN 条件查询若干列,便于直接指定表名与 dblink(如多选关联的中间表 —— 它不注册在线表)。 + * + * @param dblinkId 数据源(dblink)id。 + * @param tableName 物理表名。 + * @param matchColumn 用于匹配的列。 + * @param returnColumns 要取值的列;匹配列会自动补进 SELECT。 + * @param matchValues 待匹配的值集合。 + * @param orderBy 排序从句,可空;不要带 ORDER BY 关键字。 + * @return 每行一个 map;无待匹配值或查询无结果时返回空列表。 + */ + public List> queryByIn(Long dblinkId, String tableName, String matchColumn, + Collection returnColumns, Collection matchValues, String orderBy) { + List distinctValues = matchValues == null ? Collections.emptyList() + : matchValues.stream().filter(StringUtils::isNotBlank).distinct().collect(Collectors.toList()); + Set queryColumns = new LinkedHashSet<>(); + if (CollectionUtils.isNotEmpty(returnColumns)) { + queryColumns.addAll(returnColumns); + } + // 匹配列一并选出:调用方要用它做 key,漏选会拿到 null 而静默丢数据。 + queryColumns.add(matchColumn); + if (dblinkId == null || StringUtils.isBlank(tableName) || distinctValues.isEmpty()) { + return Collections.emptyList(); + } + StringBuilder sql = new StringBuilder(160); + sql.append("SELECT ").append(String.join(", ", queryColumns)) + .append(" FROM ").append(tableName) + .append(" WHERE ").append(matchColumn).append(" IN (") + .append(distinctValues.stream().map(value -> "?").collect(Collectors.joining(","))) + .append(")"); + if (StringUtils.isNotBlank(orderBy)) { + sql.append(" ORDER BY ").append(orderBy); + } + try { + List> rowList = this.query(dblinkId, sql.toString(), new ArrayList<>(distinctValues)); + return rowList == null ? Collections.>emptyList() : rowList; + } catch (Exception e) { + throw new MyRuntimeException("查询表「" + tableName + "」失败:" + e.getMessage()); + } + } + + /** + * 从查询结果行取列值,忽略键的大小写与引号(不同驱动/参数下列标签形态不一致)。 + * + * @param row 结果行。 + * @param columnName 列名。 + * @return 列值;取不到返回 null。 + */ + public static Object getColumnValue(Map row, String columnName) { + if (row == null || StringUtils.isBlank(columnName)) { + return null; + } + for (Map.Entry entry : row.entrySet()) { + String key = entry.getKey(); + if (key != null && StringUtils.equalsIgnoreCase(StringUtils.remove(key, '"'), columnName)) { + return entry.getValue(); + } + } + return null; + } + + /** + * 取列值的字符串形式(null 返回 null)。 + * + * @param row 结果行。 + * @param columnName 列名。 + * @return 字符串形式的列值。 + */ + public static String getColumnText(Map row, String columnName) { + Object value = getColumnValue(row, columnName); + return value == null ? null : value.toString(); + } } diff --git a/common/common-online/src/main/java/apelet/common/online/util/OnlineImportExcelReader.java b/common/common-online/src/main/java/apelet/common/online/util/OnlineImportExcelReader.java new file mode 100644 index 0000000..11179a8 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/util/OnlineImportExcelReader.java @@ -0,0 +1,226 @@ +package apelet.common.online.util; + +import apelet.common.online.dto.OnlineImportRowDto; +import apelet.common.online.dto.OnlineImportSheetDto; +import apelet.common.online.model.OnlineImportExportField; +import cn.hutool.core.convert.Convert; +import cn.hutool.poi.excel.ExcelUtil; +import cn.hutool.poi.excel.sax.handler.RowHandler; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.io.File; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 导入 Excel 解析器:**按表头名**建立列映射,再逐行取值。 + * + *

不复用 {@code common-core} 的 ImportUtil.MyRowHandler,因为它按列位置硬对齐、且盲跳表头不校验 + * 表头内容,与"模板配置驱动、列顺序可调换"的设计不符。

+ * + *

表头匹配规则:trim 后精确匹配(不做大小写兼容,避免 Name 与 name 意外撞车);表头中多余的列忽略; + * 「导入且必填」的列若缺失,记入 {@link OnlineImportSheetDto#getMissingRequiredColumns()}。

+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Slf4j +public final class OnlineImportExcelReader { + + private OnlineImportExcelReader() { + } + + /** + * 解析 Excel 的第一个 sheet。 + * + * @param filePath 文件绝对路径。 + * @param importFieldList 该模板中「已勾选导入」的字段,须已按 sortNo 排序。 + * @return 解析结果。 + */ + public static OnlineImportSheetDto read(String filePath, List importFieldList) { + OnlineImportSheetDto sheetDto = new OnlineImportSheetDto(); + if (StringUtils.isBlank(filePath) || CollectionUtils.isEmpty(importFieldList)) { + return sheetDto; + } + ImportRowHandler rowHandler = new ImportRowHandler(importFieldList, sheetDto); + ExcelUtil.readBySax(new File(filePath), 0, rowHandler); + sheetDto.setMissingRequiredColumns(rowHandler.getMissingRequiredColumns()); + sheetDto.setMatchedColumnNames(rowHandler.getMatchedColumnNames()); + return sheetDto; + } + + /** + * 轻量统计数据行数(不含表头行,跳过整行空行)。只数行、不做值转换与列映射, + * 供「同步还是异步」的分档判断使用。 + * + * @param filePath 文件绝对路径。 + * @return 数据行数。 + */ + public static int countDataRows(String filePath) { + CountRowHandler rowHandler = new CountRowHandler(); + ExcelUtil.readBySax(new File(filePath), 0, rowHandler); + return rowHandler.getDataRowCount(); + } + + /** + * 只数行不取值的 SAX 处理器。 + */ + private static class CountRowHandler implements RowHandler { + + private int dataRowCount = 0; + + @Override + public void handle(int sheetIndex, long rowIndex, List rowList) { + if (rowIndex == 0 || CollectionUtils.isEmpty(rowList)) { + return; + } + boolean hasValue = rowList.stream().anyMatch(value -> StringUtils.isNotBlank(Convert.toStr(value, StringUtils.EMPTY))); + if (hasValue) { + this.dataRowCount++; + } + } + + int getDataRowCount() { + return this.dataRowCount; + } + } + + /** + * SAX 行处理器:首行建列映射,其余行按映射取值。 + */ + private static class ImportRowHandler implements RowHandler { + + /** + * 展示名 → 字段配置。 + */ + private final Map showNameMap = new HashMap<>(); + + /** + * Excel 列号 → 字段配置(仅包含表头中命中的列)。 + */ + private final Map columnFieldMap = new HashMap<>(); + + private final List importFieldList; + + private final OnlineImportSheetDto sheetDto; + + ImportRowHandler(List importFieldList, OnlineImportSheetDto sheetDto) { + this.importFieldList = importFieldList; + this.sheetDto = sheetDto; + for (OnlineImportExportField field : importFieldList) { + if (StringUtils.isNotBlank(field.getShowName())) { + this.showNameMap.put(StringUtils.trim(field.getShowName()), field); + } + } + } + + @Override + public void handle(int sheetIndex, long rowIndex, List rowList) { + if (rowIndex == 0) { + this.buildColumnMapping(rowList); + return; + } + this.appendDataRow(rowIndex, rowList); + } + + /** + * 首行:按展示名建立「列号 → 字段」映射。 + * + * @param headerRow 表头行。 + */ + private void buildColumnMapping(List headerRow) { + if (CollectionUtils.isEmpty(headerRow)) { + return; + } + for (int i = 0; i < headerRow.size(); i++) { + String showName = StringUtils.trim(Convert.toStr(headerRow.get(i), StringUtils.EMPTY)); + if (StringUtils.isBlank(showName)) { + continue; + } + OnlineImportExportField field = this.showNameMap.get(showName); + if (field != null && !this.columnFieldMap.containsValue(field)) { + this.columnFieldMap.put(i, field); + } + } + } + + /** + * 数据行:按列映射取值,整行全空则跳过。 + * + * @param rowIndex 行下标(0 起,含表头行)。 + * @param rowList 行数据。 + */ + private void appendDataRow(long rowIndex, List rowList) { + if (this.columnFieldMap.isEmpty() || CollectionUtils.isEmpty(rowList)) { + return; + } + OnlineImportRowDto rowDto = new OnlineImportRowDto(); + // Excel 行号从 1 开始,rowIndex 从 0 开始,故 +1。 + rowDto.setRowNum((int) rowIndex + 1); + boolean hasValue = false; + for (Map.Entry entry : this.columnFieldMap.entrySet()) { + int columnIndex = entry.getKey(); + if (columnIndex >= rowList.size()) { + continue; + } + Object value = rowList.get(columnIndex); + rowDto.putValue(entry.getValue().getTableName(), entry.getValue().getColumnName(), value); + if (value != null && StringUtils.isNotBlank(Convert.toStr(value, StringUtils.EMPTY))) { + hasValue = true; + } + } + if (hasValue) { + this.sheetDto.getRowList().add(rowDto); + } + } + + /** + * 计算缺失的「导入且必填」列。 + * + * @return 缺失的展示名列表。 + */ + List getMissingRequiredColumns() { + Set foundShowNames = this.columnFieldMap.values().stream().map(OnlineImportExportField::getShowName).map(StringUtils::trim).collect(Collectors.toSet()); + return this.importFieldList.stream() + .filter(field -> Integer.valueOf(1).equals(field.getIsRequired())) + .map(OnlineImportExportField::getShowName).map(StringUtils::trim) + .filter(showName -> !foundShowNames.contains(showName)) + .collect(Collectors.toList()); + } + + /** + * 表头中实际命中的字段物理列名。 + * + * @return 列名集合。 + */ + Set getMatchedColumnNames() { + return this.columnFieldMap.values().stream().map(OnlineImportExportField::getColumnName).collect(Collectors.toCollection(LinkedHashSet::new)); + } + } + + /** + * 建立「展示名 → 字段」映射,供调用方复用。 + * + * @param importFieldList 已勾选导入的字段列表。 + * @return 映射表。 + */ + public static Map buildShowNameMap(List importFieldList) { + Map showNameMap = new LinkedHashMap<>(); + if (CollectionUtils.isEmpty(importFieldList)) { + return showNameMap; + } + for (OnlineImportExportField field : importFieldList) { + if (StringUtils.isNotBlank(field.getShowName())) { + showNameMap.put(StringUtils.trim(field.getShowName()), field); + } + } + return showNameMap; + } +} diff --git a/common/common-online/src/main/java/apelet/common/online/util/RelationValueResolver.java b/common/common-online/src/main/java/apelet/common/online/util/RelationValueResolver.java new file mode 100644 index 0000000..dc6adc2 --- /dev/null +++ b/common/common-online/src/main/java/apelet/common/online/util/RelationValueResolver.java @@ -0,0 +1,267 @@ +package apelet.common.online.util; + +import apelet.common.generator.model.OnlFormField; +import apelet.common.generator.model.OnlFormHead; +import apelet.common.generator.service.IOnlFormFieldService; +import apelet.common.generator.service.IOnlFormHeadService; +import apelet.common.online.exception.OnlineRuntimeException; +import apelet.common.online.model.OnlineTable; +import apelet.common.online.model.constant.ImportAttrEnum; +import apelet.common.online.service.OnlineTableService; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 关联字段(F7)取值工具:定位目标表、解析属性列、按属性值批量反查目标行主键。 + * + *

能力下沉自 {@code common-openapi} 的 {@code OpenApiOnlineQueryServiceImpl} + * (依赖方向为 common-openapi → common-online,反向不可引用,故在本模块重建)。

+ * + *

批量反查按批执行 {@code IN} 查询,调用方应**按列解析一次目标表、再对全部行做一次批量反查**, + * 不要逐行调用。

+ * + * @author chenchuchuan + * @date 2026-09-18 + */ +@Slf4j +@Component +public class RelationValueResolver { + + /** + * 「编码」的候选列名,按顺序回退。这些只是建表时的默认列名,用户可能改名,故允许逐级回退。 + */ + private static final List CODE_CANDIDATE_COLUMNS = Arrays.asList("number", "code"); + + /** + * 「名称」的候选列名,按顺序回退。 + */ + private static final List NAME_CANDIDATE_COLUMNS = Collections.singletonList("name"); + + /** + * 单次 IN 查询的最大值个数,避免 SQL 过长。 + */ + private static final int MAX_IN_BATCH_SIZE = 1000; + + @Autowired + private IOnlFormHeadService onlFormHeadService; + + @Autowired + private IOnlFormFieldService onlFormFieldService; + + @Autowired + private OnlineTableService onlineTableService; + + @Autowired + private OnlineDataSourceUtil dataSourceUtil; + + /** + * 定位关联字段指向的目标表。 + * + * @param masterTableName 字段所在表的物理表名。 + * @param masterColumnName 字段物理列名。 + * @return 目标表;该字段不是关联字段或元数据缺失时返回null。 + */ + public OnlineTable findRelTable(String masterTableName, String masterColumnName) { + if (StringUtils.isBlank(masterTableName) || StringUtils.isBlank(masterColumnName)) { + return null; + } + OnlFormHead masterHead = onlFormHeadService.getHeadTableNameCache(masterTableName); + if (masterHead == null) { + return null; + } + List relFieldList = onlFormFieldService.list(new LambdaQueryWrapper() + .eq(OnlFormField::getHeadId, masterHead.getId()) + .eq(OnlFormField::getFieldName, masterColumnName) + .isNotNull(OnlFormField::getRefPropert)); + if (CollectionUtils.isEmpty(relFieldList)) { + return null; + } + OnlFormHead relHead = onlFormHeadService.getOnlFormHeadFromCache(relFieldList.get(0).getRefPropert()); + if (relHead == null) { + return null; + } + return onlineTableService.queryByTableName(relHead.getTableName()); + } + + /** + * 解析「导入属性」对应的目标表实际列名。 + * + * @param relTable 目标表。 + * @param importAttr 导入属性:PK/CODE/NAME;为空按 PK 处理。 + * @return 目标表中的实际列名。 + */ + public String resolveImportAttrColumn(OnlineTable relTable, String importAttr) { + if (relTable == null || relTable.getPrimaryKeyColumn() == null) { + throw new OnlineRuntimeException("关联字段配置有误,未取到关联表主键列!"); + } + String pkColumnName = relTable.getPrimaryKeyColumn().getColumnName(); + ImportAttrEnum attrEnum = ImportAttrEnum.ofNullable(importAttr); + if (attrEnum == ImportAttrEnum.PK) { + return pkColumnName; + } + List candidates = attrEnum == ImportAttrEnum.CODE ? CODE_CANDIDATE_COLUMNS : NAME_CANDIDATE_COLUMNS; + String columnName = this.findColumnNameByCandidates(relTable, candidates); + if (columnName == null) { + throw new OnlineRuntimeException("关联表「" + relTable.getTableName() + "」中未找到可用的" + attrEnum.getDescription() + "列(候选:" + String.join("、", candidates) + "),请改用主键或在关联表上补充该列!"); + } + return columnName; + } + + /** + * 解析「导出属性」对应的目标表实际列名。 + * + *

导出属性与导入属性是同一套编码(PK/CODE/NAME,见 {@link ImportAttrEnum}),必须换算成 + * 目标表上的真实列名后再去取值 —— 直接拿编码当列名会查一个并不存在的「NAME」列,取不到值。

+ * + * @param relTable 目标表。 + * @param exportAttr 导出属性:PK/CODE/NAME;为空时取主键列(即输出裸外键id,与改造前行为一致); + * 不是编码时按物理列名处理(兼容直接配置列名的写法)。 + * @return 目标表中的实际列名;目标表为空、或配置的列在目标表上不存在时返回 null(调用方不补值)。 + */ + public String resolveExportAttrColumn(OnlineTable relTable, String exportAttr) { + if (relTable == null || relTable.getPrimaryKeyColumn() == null) { + return null; + } + if (StringUtils.isBlank(exportAttr)) { + return relTable.getPrimaryKeyColumn().getColumnName(); + } + ImportAttrEnum attrEnum = ImportAttrEnum.parse(exportAttr); + if (attrEnum == null) { + // 不是属性编码:按物理列名处理,列不存在时告警并放弃补值(不中断整份导出)。 + String columnName = this.findColumnNameByCandidates(relTable, Collections.singletonList(StringUtils.lowerCase(exportAttr.trim()))); + if (columnName == null) { + log.warn("关联表「{}」中不存在导出属性列「{}」,该列导出时不做补值,仍输出外键id!", relTable.getTableName(), exportAttr); + } + return columnName; + } + if (attrEnum == ImportAttrEnum.PK) { + return relTable.getPrimaryKeyColumn().getColumnName(); + } + List candidates = attrEnum == ImportAttrEnum.CODE ? CODE_CANDIDATE_COLUMNS : NAME_CANDIDATE_COLUMNS; + String columnName = this.findColumnNameByCandidates(relTable, candidates); + if (columnName == null) { + throw new OnlineRuntimeException("关联表「" + relTable.getTableName() + "」中未找到可用的" + attrEnum.getDescription() + "列(候选:" + String.join("、", candidates) + "),请改用主键或在关联表上补充该列!"); + } + return columnName; + } + + /** + * 批量反查:按属性值查出命中的目标行主键。 + * + *

返回值中,一个属性值可能对应多个主键(该属性列在目标表中不唯一),由调用方决定是报错还是取值。

+ * + * @param relTable 目标表。 + * @param attrColumn 用于匹配的目标表列名。 + * @param attrValues 待匹配的属性值集合。 + * @return 属性值 → 命中的主键集合。 + */ + public Map> resolveRelatedIdMap(OnlineTable relTable, String attrColumn, Collection attrValues) { + Map> resultMap = new HashMap<>(); + if (relTable == null || relTable.getPrimaryKeyColumn() == null || StringUtils.isBlank(attrColumn) || CollectionUtils.isEmpty(attrValues)) { + return resultMap; + } + List distinctValues = attrValues.stream().filter(StringUtils::isNotBlank).distinct().collect(Collectors.toList()); + if (distinctValues.isEmpty()) { + return resultMap; + } + String pkColumnName = relTable.getPrimaryKeyColumn().getColumnName(); + for (int i = 0; i < distinctValues.size(); i += MAX_IN_BATCH_SIZE) { + List batchValues = distinctValues.subList(i, Math.min(i + MAX_IN_BATCH_SIZE, distinctValues.size())); + this.queryRelatedIdBatch(relTable, attrColumn, pkColumnName, batchValues, resultMap); + } + return resultMap; + } + + /** + * 批量反查(导出用,方向与 {@link #resolveRelatedIdMap} 相反):按主键查出对应的属性值。 + * + *

导出取数层是纯 SQL,拿不到 F7 的联表显示值,因此先取回裸外键 id,再用本方法按 id 批量补值。 + * 一次 IN 查询,不是 N+1。

+ * + * @param relTable 目标表。 + * @param attrColumn 要取值的列名(由 {@link #resolveExportAttrColumn} 解析)。 + * @param pkValues 待补值的主键集合。 + * @return 主键 → 该列的值;查不到的不在结果里。 + */ + public Map resolveRelatedValueMap(OnlineTable relTable, String attrColumn, Collection pkValues) { + Map resultMap = new HashMap<>(); + if (relTable == null || relTable.getPrimaryKeyColumn() == null || StringUtils.isBlank(attrColumn) || CollectionUtils.isEmpty(pkValues)) { + return resultMap; + } + List distinctValues = pkValues.stream().filter(StringUtils::isNotBlank).distinct().collect(Collectors.toList()); + if (distinctValues.isEmpty()) { + return resultMap; + } + String pkColumnName = relTable.getPrimaryKeyColumn().getColumnName(); + for (int i = 0; i < distinctValues.size(); i += MAX_IN_BATCH_SIZE) { + List batchValues = distinctValues.subList(i, Math.min(i + MAX_IN_BATCH_SIZE, distinctValues.size())); + this.queryRelatedValueBatch(relTable, attrColumn, pkColumnName, batchValues, resultMap); + } + return resultMap; + } + + /** + * 执行一批按主键的 IN 查询,把「主键 → 属性值」并入汇总map。 + * + * @param relTable 目标表。 + * @param attrColumn 要取值的列名。 + * @param pkColumnName 目标表主键列名。 + * @param batchValues 本批主键值。 + * @param resultMap 汇总map,出参。 + */ + private void queryRelatedValueBatch(OnlineTable relTable, String attrColumn, String pkColumnName, List batchValues, Map resultMap) { + for (Map row : dataSourceUtil.queryByIn(relTable, pkColumnName, Collections.singletonList(attrColumn), batchValues)) { + String pkValue = OnlineDataSourceUtil.getColumnText(row, pkColumnName); + if (StringUtils.isBlank(pkValue)) { + continue; + } + resultMap.put(pkValue, OnlineDataSourceUtil.getColumnText(row, attrColumn)); + } + } + + /** + * 按候选列名在目标表中查找实际存在的列。 + * + * @param relTable 目标表。 + * @param candidates 候选列名,按优先级排列。 + * @return 命中的列名;都不存在时返回null。 + */ + private String findColumnNameByCandidates(OnlineTable relTable, List candidates) { + Set columnNames = relTable.getColumnMap().values().stream().map(column -> StringUtils.lowerCase(column.getColumnName())).collect(Collectors.toCollection(HashSet::new)); + for (String candidate : candidates) { + if (columnNames.contains(candidate)) { + return candidate; + } + } + return null; + } + + /** + * 执行一批 IN 查询,把结果并入汇总map。 + * + * @param relTable 目标表。 + * @param attrColumn 用于匹配的目标表列名。 + * @param pkColumnName 目标表主键列名。 + * @param batchValues 本批属性值。 + * @param resultMap 汇总map,出参。 + */ + private void queryRelatedIdBatch(OnlineTable relTable, String attrColumn, String pkColumnName, List batchValues, Map> resultMap) { + // 与导出补值同一套本地库直查,避开 ORM in 过滤器的取值要求与 EAS 异构路由。 + for (Map row : dataSourceUtil.queryByIn(relTable, attrColumn, Arrays.asList(pkColumnName, attrColumn), batchValues)) { + String attrValue = OnlineDataSourceUtil.getColumnText(row, attrColumn); + String pkValue = OnlineDataSourceUtil.getColumnText(row, pkColumnName); + if (StringUtils.isBlank(attrValue) || StringUtils.isBlank(pkValue)) { + continue; + } + // 与调用方传入的 Excel 值口径一致(都做 trim),否则库里带空格的值永远匹配不上。 + resultMap.computeIfAbsent(attrValue.trim(), key -> new ArrayList<>()).add(pkValue); + } + } +} diff --git a/common/common-online/src/test/java/apelet/common/online/util/QuickFormWidgetUtilTest.java b/common/common-online/src/test/java/apelet/common/online/util/QuickFormWidgetUtilTest.java new file mode 100644 index 0000000..9d9c024 --- /dev/null +++ b/common/common-online/src/test/java/apelet/common/online/util/QuickFormWidgetUtilTest.java @@ -0,0 +1,56 @@ +//package apelet.common.online.util; +// +//import apelet.common.online.model.OnlineQuickFormField; +//import com.alibaba.fastjson.JSONObject; +//import org.junit.jupiter.api.Test; +// +//import java.util.ArrayList; +//import java.util.HashMap; +//import java.util.List; +//import java.util.Map; +// +//import static org.junit.jupiter.api.Assertions.assertEquals; +// +///** +// * QuickFormWidgetUtil 列表页生成:isFilter=1 且 filterType=0 的字段应进入查询区。 +// */ +//class QuickFormWidgetUtilTest { +// +// private OnlineQuickFormField field(String name, String remark, int fieldType, int isList, int isFilter, int filterType) { +// OnlineQuickFormField f = new OnlineQuickFormField(); +// f.setFieldName(name); +// f.setFieldRemark(remark); +// f.setFieldType(fieldType); +// f.setIsList(isList); +// f.setIsFilter(isFilter); +// f.setFilterType(filterType); +// return f; +// } +// +// @Test +// void listWidget_isFilter1_filterType0_应生成查询区控件() { +// List fields = new ArrayList<>(); +// fields.add(field("create_time", "创建日期", 20, 1, 1, 0)); +// fields.add(field("billstatus", "状态", 1, 1, 1, 0)); +// fields.add(field("flow_status", "流程状态", 402, 1, 1, 0)); +// +// Map columnIdMap = new HashMap<>(); +// columnIdMap.put("create_time", 2089242982925471744L); +// columnIdMap.put("billstatus", 2089242983953076224L); +// columnIdMap.put("flow_status", 2089242983571394560L); +// +// JSONObject json = QuickFormWidgetUtil.buildListWidgetJson(fields, 2090328308771196928L, 2090328308083331072L, +// 2089242982703173632L, "t_fastform", columnIdMap); +// +// JSONObject pc = json.getJSONObject("pc"); +// int pcWidgetCount = pc.getJSONArray("widgetList").size(); +// int pcColumnCount = pc.getJSONObject("tableWidget").getJSONObject("props").getJSONArray("tableColumnList").size(); +// JSONObject mobile = json.getJSONObject("mobile"); +// int mobileChildCount = mobile.getJSONObject("tableWidget").getJSONArray("childWidgetList").size(); +// +// System.out.println("pc.widgetList=" + pcWidgetCount + ", pc.tableColumnList=" + pcColumnCount + ", mobile.childWidgetList=" + mobileChildCount); +// assertEquals(3, pcWidgetCount, "isFilter=1 字段应全部进入 pc 查询区"); +// assertEquals(3, pcColumnCount, "isList=1 字段应全部进入表格列"); +// assertEquals(4, mobileChildCount, "mobile 应为 listCard + 3 个过滤控件"); +// } +//}