Browse Source

feat(online): 新增导入导出功能相关枚举和工具类

- 添加ImportAttrEnum、ImportExportEnabledStatus、ImportWriteModeEnum枚举类
- 实现ImportExportExcelUtil工具类,支持导入模板生成和文件下载
- 添加ImportExportFileCleanTimer定时清理任务
- 在OnlineConstant中增加导入导出模板缓存key相关方法
- 扩展OnlineDataSourceUtil提供IN条件查询功能
- 定义OnlineExportDataService接口及其实现类OnlineExportDataServiceImpl
- 修复ObjectValue类中类型转换可能引发的ClassCastException问题
feature/2026-08/0812-ccc-dev
chenchuchuan 1 month ago
parent
commit
b8e9998ec8
  1. 5
      common/common-core/src/main/java/apelet/common/core/object/ObjectValue.java
  2. 37
      common/common-online/src/main/java/apelet/common/online/config/OnlineProperties.java
  3. 49
      common/common-online/src/main/java/apelet/common/online/controller/OnlineFormController.java
  4. 158
      common/common-online/src/main/java/apelet/common/online/controller/OnlineImportExportTemplateController.java
  5. 252
      common/common-online/src/main/java/apelet/common/online/controller/OnlineOperationController.java
  6. 13
      common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportFieldMapper.java
  7. 13
      common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTaskMapper.java
  8. 26
      common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTemplateMapper.java
  9. 58
      common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineImportExportTemplateMapper.xml
  10. 38
      common/common-online/src/main/java/apelet/common/online/dto/OnlineExportParamDto.java
  11. 50
      common/common-online/src/main/java/apelet/common/online/dto/OnlineExportResultDto.java
  12. 51
      common/common-online/src/main/java/apelet/common/online/dto/OnlineImportErrorDto.java
  13. 40
      common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTableDto.java
  14. 48
      common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTemplateStatusDto.java
  15. 73
      common/common-online/src/main/java/apelet/common/online/dto/OnlineImportGroupDto.java
  16. 40
      common/common-online/src/main/java/apelet/common/online/dto/OnlineImportParamDto.java
  17. 105
      common/common-online/src/main/java/apelet/common/online/dto/OnlineImportResultDto.java
  18. 104
      common/common-online/src/main/java/apelet/common/online/dto/OnlineImportRowDto.java
  19. 42
      common/common-online/src/main/java/apelet/common/online/dto/OnlineImportSheetDto.java
  20. 143
      common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportField.java
  21. 141
      common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTask.java
  22. 121
      common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTemplate.java
  23. 65
      common/common-online/src/main/java/apelet/common/online/model/constant/ImportAttrEnum.java
  24. 59
      common/common-online/src/main/java/apelet/common/online/model/constant/ImportExportEnabledStatus.java
  25. 59
      common/common-online/src/main/java/apelet/common/online/model/constant/ImportWriteModeEnum.java
  26. 59
      common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskStatus.java
  27. 26
      common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskType.java
  28. 81
      common/common-online/src/main/java/apelet/common/online/service/OnlineExportDataService.java
  29. 67
      common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportDataService.java
  30. 23
      common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportFieldService.java
  31. 73
      common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTaskService.java
  32. 120
      common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTemplateService.java
  33. 16
      common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java
  34. 8
      common/common-online/src/main/java/apelet/common/online/service/OnlineTableService.java
  35. 696
      common/common-online/src/main/java/apelet/common/online/service/impl/OnlineExportDataServiceImpl.java
  36. 1320
      common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportDataServiceImpl.java
  37. 45
      common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportFieldServiceImpl.java
  38. 489
      common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTaskServiceImpl.java
  39. 532
      common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTemplateServiceImpl.java
  40. 10
      common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java
  41. 26
      common/common-online/src/main/java/apelet/common/online/service/impl/OnlineTableServiceImpl.java
  42. 130
      common/common-online/src/main/java/apelet/common/online/timer/ImportExportFileCleanTimer.java
  43. 378
      common/common-online/src/main/java/apelet/common/online/util/ImportExportExcelUtil.java
  44. 15
      common/common-online/src/main/java/apelet/common/online/util/OnlineConstant.java
  45. 105
      common/common-online/src/main/java/apelet/common/online/util/OnlineDataSourceUtil.java
  46. 226
      common/common-online/src/main/java/apelet/common/online/util/OnlineImportExcelReader.java
  47. 267
      common/common-online/src/main/java/apelet/common/online/util/RelationValueResolver.java
  48. 56
      common/common-online/src/test/java/apelet/common/online/util/QuickFormWidgetUtilTest.java

5
common/common-core/src/main/java/apelet/common/core/object/ObjectValue.java

@ -120,7 +120,10 @@ public class ObjectValue implements Serializable { @@ -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) {

37
common/common-online/src/main/java/apelet/common/online/config/OnlineProperties.java

@ -56,4 +56,41 @@ public class OnlineProperties { @@ -56,4 +56,41 @@ public class OnlineProperties {
* 在线表单编辑权限的URL列表。
*/
private List<String> 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;
}

49
common/common-online/src/main/java/apelet/common/online/controller/OnlineFormController.java

@ -461,6 +461,40 @@ public class OnlineFormController { @@ -461,6 +461,40 @@ public class OnlineFormController {
}
/**
* 查询指定表单**主表**的字段列表(查 onl_form_field,只查主表,不含分录表)。
*
* <p>链路:formId → {@code zz_online_form.master_table_id}(= zz_online_table.table_id)取表名
* → 按表名取表单元数据 headId → 按 headId 取字段。onl_form_head 没有 table_id 列,两张表是靠
* table_name 关联的,所以中间这一步绕不开。</p>
*
* <p>字段元数据走 {@code queryOnlFormField},会自动补上关联信息:402 的关联表描述
* (refPropertMap)、404 的中间表描述(refMultiTableMap),前端据此展示"关联到哪张表"。</p>
*
* @param formId 表单id。
* @return 主表字段列表;表单 / 主表 / 表单元数据不存在时报错。
*/
@PostMapping("/listMasterFields")
public ResponseResult<List<OnlFormField>> 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<OnlFormField> fieldList = onlFormFieldService.queryOnlFormField(new LambdaQueryWrapper<OnlFormField>().eq(OnlFormField::getHeadId, masterHead.getId()));
return ResponseResult.success(fieldList == null ? Collections.emptyList() : fieldList);
}
/**
* 获取指定在线表单对象在前端渲染时所需的所有数据对象。
*
* @param formId 指定对象主键Id。
@ -740,8 +774,10 @@ public class OnlineFormController { @@ -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 { @@ -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 { @@ -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();
}

158
common/common-online/src/main/java/apelet/common/online/controller/OnlineImportExportTemplateController.java

@ -0,0 +1,158 @@ @@ -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;
/**
* 在线表单导入导出模板管理接口。
*
* <p>模板是整表单级别的开关:某张表存在已启用模板时,导入导出走新引擎;否则走原有链路。</p>
*
* @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<MyPageData<OnlineImportExportTemplate>> 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<OnlineImportExportTemplate> templateList = onlineImportExportTemplateService.getOnlineFormListWithRelation(templateFilter, orderBy);
return ResponseResult.success(MyPageUtil.makeResponseData(templateList));
}
/**
* 查看模板详情(含字段配置)。
*
* @param id 模板主键Id。
* @return 应答结果对象,包含模板详情;字段配置按「表 → 字段」两级树放在 tableList 中。
*/
@GetMapping("/view")
public ResponseResult<OnlineImportExportTemplate> 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<OnlineImportExportTemplate> 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<Void> 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<Void> 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<List<OnlineImportExportTableDto>> 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<OnlineImportExportField> 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");
}
}

252
common/common-online/src/main/java/apelet/common/online/controller/OnlineOperationController.java

@ -26,7 +26,12 @@ import apelet.common.online.abstractplugin.model.ExportData; @@ -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; @@ -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; @@ -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.*; @@ -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 { @@ -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 { @@ -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<OnlineImportResultDto> 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<OnlineDatasource> 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<OnlineImportResultDto> 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<OnlineDatasource> 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<OnlineImportExportTask> 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<MyPageData<OnlineImportExportTask>> 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<OnlineImportExportTemplateStatusDto> 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}。
*
* <p>按主表记录数分档:不超过 {@code exportAsyncRowThreshold}(默认 5 万)时同步导出、**直接写响应流**;
* 超过时转异步任务,**手动写 JSON** 返回 taskId。因两条分支都自己写响应体,故方法返回 void
* (若声明为 ResponseResult,Spring 会在已写出的 Excel 流后追加 JSON,导致文件损坏)。</p>
*
* @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<OnlineFilterDto> 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<OnlineFilterDto> 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 <T> 返回类型。
* @return 应答结果。
*/
private <T> ResponseResult<T> runEngineTask(java.util.function.Supplier<T> supplier) {
try {
return ResponseResult.success(supplier.get());
} catch (OnlineRuntimeException e) {
return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, e.getMessage());
}
}
private void exeImportPlugin(
List<Map<String, Object>> dataList,
Long formId,

13
common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportFieldMapper.java

@ -0,0 +1,13 @@ @@ -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<OnlineImportExportField> {
}

13
common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTaskMapper.java

@ -0,0 +1,13 @@ @@ -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<OnlineImportExportTask> {
}

26
common/common-online/src/main/java/apelet/common/online/dao/OnlineImportExportTemplateMapper.java

@ -0,0 +1,26 @@ @@ -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<OnlineImportExportTemplate> {
/**
* 获取过滤后的对象列表。
*
* @param templateFilter 主表过滤对象。
* @param orderBy 排序字符串,order by从句的参数。
* @return 对象列表。
*/
List<OnlineImportExportTemplate> getOnlineFormList(
@Param("templateFilter") OnlineImportExportTemplate templateFilter, @Param("orderBy") String orderBy);
}

58
common/common-online/src/main/java/apelet/common/online/dao/mapper/OnlineImportExportTemplateMapper.xml

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="apelet.common.online.dao.OnlineImportExportTemplateMapper">
<resultMap id="BaseResultMap" type="apelet.common.online.model.OnlineImportExportTemplate">
<id column="id" jdbcType="BIGINT" property="id"/>
<result column="template_name" jdbcType="VARCHAR" property="templateName"/>
<result column="master_head_id" jdbcType="BIGINT" property="masterHeadId"/>
<result column="master_table_name" jdbcType="VARCHAR" property="masterTableName"/>
<result column="is_enabled" jdbcType="INTEGER" property="isEnabled"/>
<result column="tenant_id" jdbcType="BIGINT" property="tenantId"/>
<result column="app_code" jdbcType="VARCHAR" property="appCode"/>
<result column="create_time" jdbcType="TIMESTAMP" property="createTime"/>
<result column="create_user_id" jdbcType="BIGINT" property="createUserId"/>
<result column="update_time" jdbcType="TIMESTAMP" property="updateTime"/>
<result column="update_user_id" jdbcType="BIGINT" property="updateUserId"/>
</resultMap>
<!-- 这里仅包含调用接口输入的主表过滤条件 -->
<sql id="inputFilterRef">
<if test="templateFilter != null">
<if test="templateFilter.tenantId == null">
AND zz_online_import_export_template.tenant_id IS NULL
</if>
<if test="templateFilter.tenantId != null">
AND zz_online_import_export_template.tenant_id = #{templateFilter.tenantId}
</if>
<if test="templateFilter.appCode == null">
AND zz_online_import_export_template.app_code IS NULL
</if>
<if test="templateFilter.appCode != null">
AND zz_online_import_export_template.app_code = #{templateFilter.appCode}
</if>
<if test="templateFilter.masterHeadId != null">
AND zz_online_import_export_template.master_head_id = #{templateFilter.masterHeadId}
</if>
<if test="templateFilter.masterTableName != null and templateFilter.masterTableName != ''">
AND zz_online_import_export_template.master_table_name = #{templateFilter.masterTableName}
</if>
<if test="templateFilter.isEnabled != null">
AND zz_online_import_export_template.is_enabled = #{templateFilter.isEnabled}
</if>
<if test="templateFilter.templateName != null and templateFilter.templateName != ''">
<bind name="safeTemplateName" value="'%' + templateFilter.templateName + '%'"/>
AND zz_online_import_export_template.template_name LIKE #{safeTemplateName}
</if>
</if>
</sql>
<select id="getOnlineFormList" resultMap="BaseResultMap" parameterType="apelet.common.online.model.OnlineImportExportTemplate">
SELECT * FROM zz_online_import_export_template
<where>
<include refid="inputFilterRef"/>
</where>
<if test="orderBy != null and orderBy != ''">
ORDER BY ${orderBy}
</if>
</select>
</mapper>

38
common/common-online/src/main/java/apelet/common/online/dto/OnlineExportParamDto.java

@ -0,0 +1,38 @@ @@ -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;
/**
* 导出任务参数:提交时抓取,异步线程执行时还原。
*
* <p>以 JSON 形式存进 {@code zz_online_import_export_task.param_json}。</p>
*
* @author chenchuchuan
* @date 2026-09-21
*/
@Data
@Schema(description = "导出任务参数")
public class OnlineExportParamDto {
/**
* 数据源id。
*/
@Schema(description = "数据源id")
private Long datasourceId;
/**
* 过滤条件。
*/
@Schema(description = "过滤条件")
private List<OnlineFilterDto> filterDtoList;
/**
* 排序参数。
*/
@Schema(description = "排序参数")
private MyOrderParam orderParam;
}

50
common/common-online/src/main/java/apelet/common/online/dto/OnlineExportResultDto.java

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
package apelet.common.online.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 导出执行结果。
*
* <p>同步导出(行数不超过阈值)直接写响应流,返回时 {@code async=false};
* 异步导出(超过阈值)只返回 {@code taskId},前端轮询
* {@code /onlineOperation/getImportExportTaskStatus} 直到 {@code isFinished()},
* 再凭 {@code resultFileKey} 调下载接口。</p>
*
* @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;
}

51
common/common-online/src/main/java/apelet/common/online/dto/OnlineImportErrorDto.java

@ -0,0 +1,51 @@ @@ -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;
}
}

40
common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTableDto.java

@ -0,0 +1,40 @@ @@ -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;
/**
* 导入导出模板的「表 → 字段」两级树节点。
*
* <p>模板字段配置在库里是扁平的(zz_online_import_export_field),但对前端展示、
* 以及 save 往返而言,按表分组更直观:一个节点 = 一张表(主表或某张分录表),
* 其下 fieldList = 该表的字段配置。</p>
*
* @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<OnlineImportExportField> fieldList;
}

48
common/common-online/src/main/java/apelet/common/online/dto/OnlineImportExportTemplateStatusDto.java

@ -0,0 +1,48 @@ @@ -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;
}

73
common/common-online/src/main/java/apelet/common/online/dto/OnlineImportGroupDto.java

@ -0,0 +1,73 @@ @@ -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;
/**
* 导入分组结果:一条主记录及其名下的分录行。
*
* <p>分组规则:全文件内**所有主表列值完全相同**的行归为同一条主记录,不要求相邻。</p>
*
* @author chenchuchuan
* @date 2026-09-18
*/
@Data
public class OnlineImportGroupDto {
/**
* 该主记录首次出现的 Excel 行号,用于错误定位。
*/
private Integer firstRowNum;
/**
* 主表列 → 值(取自该主记录首次出现的那一行)。
*/
private Map<String, Object> masterValueMap = new LinkedHashMap<>();
/**
* 该主记录下的**全部原始行**(保持文件顺序)。
* 失败 Excel 按它还原用户上传的内容;也用于判断该组是否有失败行。
*/
private List<OnlineImportRowDto> sourceRowList = java.util.Collections.emptyList();
/**
* 分录表名 → 该主记录名下的分录行(保持文件顺序)。
*/
private Map<String, List<OnlineImportRowDto>> 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<OnlineImportRowDto> getFailedRowList() {
return this.sourceRowList.stream().filter(OnlineImportRowDto::isFailed).collect(Collectors.toList());
}
}

40
common/common-online/src/main/java/apelet/common/online/dto/OnlineImportParamDto.java

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
package apelet.common.online.dto;
import lombok.Data;
/**
* 导入入参。
*
* <p>写入模式与匹配键由前端在导入时传入,不落库。</p>
*
* @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;
}

105
common/common-online/src/main/java/apelet/common/online/dto/OnlineImportResultDto.java

@ -0,0 +1,105 @@ @@ -0,0 +1,105 @@
package apelet.common.online.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 导入结果。阶段一(校验)与阶段二(执行)共用。
*
* <p>计数口径:报「主记录数 + 分录明细数」,不是 Excel 行数 —— 扁平格式下多行可能合并为一条主记录。</p>
*
* @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<OnlineImportErrorDto> 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;
}

104
common/common-online/src/main/java/apelet/common/online/dto/OnlineImportRowDto.java

@ -0,0 +1,104 @@ @@ -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;
/**
* 单元格值:`表名.列名` → 单元格原始值。
*
* <p>用「表名.列名」而不是裸列名做键:**主表与分录表可能有同名列**(例如两边都有 number/name),
* 裸列名会互相覆盖,导致主记录被写成分录的值、分组也失去意义。</p>
*/
private Map<String, Object> valueMap = new LinkedHashMap<>();
/**
* 该行的失败原因。为空表示通过;非空会被写入失败 Excel 并阻止其所在主记录写入。
*/
private String failReason;
/**
* 反查后的实际入库值:`表名.列名` → 实际值。
* 例如关联字段按名称/编码反查出的主键、字典字段的名称换成字典项 id。
*
* <p>两个要点:① 与 {@link #valueMap} 分开存 —— 失败文件要回显用户原始输入,写入时才用这里的值;
* ② 用「表名.列名」而不是裸列名做键 —— 同一个行对象同时参与主表与分录表的解析,
* 而主表与分录表可能有同名列。</p>
*/
private Map<String, Object> 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);
}
}

42
common/common-online/src/main/java/apelet/common/online/dto/OnlineImportSheetDto.java

@ -0,0 +1,42 @@ @@ -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<String> missingRequiredColumns = new ArrayList<>();
/**
* 表头中实际命中的字段物理列名,用于校验「匹配键必须出现在表头中」。
*/
private Set<String> matchedColumnNames = new LinkedHashSet<>();
/**
* 数据行,已跳过整行空行。
*/
private List<OnlineImportRowDto> rowList = new ArrayList<>();
/**
* 数据行数(不含表头行)。
*
* @return 行数。
*/
public int getRowCount() {
return rowList.size();
}
}

143
common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportField.java

@ -0,0 +1,143 @@ @@ -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;
/**
* 在线表单导入导出模板字段配置实体对象。
*
* <p>每行描述一个字段在该模板中的导入导出行为。tableName 标明该字段属于哪张物理表
* (主表或分录表);某分录表一个字段都没有配置,等价于该模板不包含该分录表。</p>
*
* @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;
}

141
common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTask.java

@ -0,0 +1,141 @@ @@ -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;
/**
* 在线表单导入导出异步任务实体对象。
*
* <p>导入 / 导出的行数超过配置阈值时转为后台任务,前端凭 taskId 轮询状态与进度。</p>
*
* @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;
}

121
common/common-online/src/main/java/apelet/common/online/model/OnlineImportExportTemplate.java

@ -0,0 +1,121 @@ @@ -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;
/**
* 在线表单导入导出模板实体对象。
*
* <p>一张表单元数据可创建多套模板,但至多一套处于启用状态(isEnabled = 1)。
* 未启用任何模板的表,导入导出走原有链路。</p>
*
* @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;
/**
* 导入扩展插件类名。
*
* <p><b>预留字段,本期未实现插件机制</b>:引擎不会读取它,填了也不生效。
* 提前建列是为了将来实现 PRD FR-5 时不必再 ALTER 表。</p>
*/
@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<OnlineImportExportField> onlineImportExportFields;
/**
* 模板字段配置的「表 → 字段」两级树(主表 1 个节点 + 每张分录表各 1 个节点),非数据库列。
* 用于 save 入参 / view、generateDefaultFields 出参;落库前会展平成 onlineImportExportFields。
*/
@TableField(exist = false)
private List<OnlineImportExportTableDto> tableList;
}

65
common/common-online/src/main/java/apelet/common/online/model/constant/ImportAttrEnum.java

@ -0,0 +1,65 @@ @@ -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;
/**
* 关联字段的导入属性枚举。
*
* <p>决定 Excel 单元格里填什么值来定位关联记录:主键直接匹配;编码/名称需要先按约定列反查目标表主键。</p>
*
* @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<String, ImportAttrEnum> 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());
}
}

59
common/common-online/src/main/java/apelet/common/online/model/constant/ImportExportEnabledStatus.java

@ -0,0 +1,59 @@ @@ -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;
/**
* 导入导出模板启用状态枚举。
*
* <p>一张表单元数据下至多一套模板处于启用状态;该表没有启用模板时,导入导出走原有链路。</p>
*
* @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<Integer, ImportExportEnabledStatus> 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);
}
}

59
common/common-online/src/main/java/apelet/common/online/model/constant/ImportWriteModeEnum.java

@ -0,0 +1,59 @@ @@ -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;
/**
* 导入写入模式枚举。
*
* <p>由前端在导入时传入,不落库。主表行按「匹配键」匹配、分录行按其 id 匹配:</p>
* <ul>
* <li>新增:全部新增</li>
* <li>覆盖:匹配到则更新;未匹配到列入失败清单;键为空则新增</li>
* <li>覆盖并新增:匹配到则更新;未匹配到或键为空则新增</li>
* </ul>
*
* @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<String, ImportWriteModeEnum> 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));
}
}

59
common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskStatus.java

@ -0,0 +1,59 @@ @@ -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<String, OnlineImportExportTaskStatus> 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;
}
}

26
common/common-online/src/main/java/apelet/common/online/model/constant/OnlineImportExportTaskType.java

@ -0,0 +1,26 @@ @@ -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;
}
}

81
common/common-online/src/main/java/apelet/common/online/service/OnlineExportDataService.java

@ -0,0 +1,81 @@ @@ -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;
/**
* 在线表单导出数据服务:模板驱动的新引擎。
*
* <p>按行数分档:主表记录数不超过 {@code common-online.exportAsyncRowThreshold}(默认 5 万)时
* 同步导出(直接写响应流,不落盘);超过时转异步任务,结果落盘后由下载接口取走。</p>
*
* @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<OnlineFilterDto> filterDtoList);
/**
* 同步导出:取数 → 展开 → 补值 → 直接写入 HTTP 响应(不落盘)。
*
* @param formId 表单id。
* @param datasourceId 数据源id。
* @param filterDtoList 过滤条件。
* @param orderParam 排序参数。
* @return 执行结果(含行数统计)。
*/
OnlineExportResultDto exportToResponse(Long formId, Long datasourceId, List<OnlineFilterDto> filterDtoList, MyOrderParam orderParam);
/**
* 异步导出:取数 → 展开 → 补值 → 生成 Excel 落盘,返回结果文件标识。
* 供异步任务线程调用,本身同步执行、不写 HTTP 响应。
*
* @param formId 表单id。
* @param datasourceId 数据源id。
* @param filterDtoList 过滤条件。
* @param orderParam 排序参数。
* @return 执行结果,其中 resultFileKey 为落盘文件名。
*/
OnlineExportResultDto buildExportFile(Long formId, Long datasourceId, List<OnlineFilterDto> filterDtoList, MyOrderParam orderParam);
/**
* 导出文件下载名:表单名({@code OnlineForm#formName})+「导出」。
* 同步导出(写响应流)与异步导出(下载落盘产物)共用,避免两处口径不一致。
*
* @param formName 表单名。
* @return 带 .xlsx 后缀的下载文件名。
*/
static String buildExportFileName(String formName) {
return StringUtils.defaultString(formName) + "导出.xlsx";
}
}

67
common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportDataService.java

@ -0,0 +1,67 @@ @@ -0,0 +1,67 @@
package apelet.common.online.service;
import apelet.common.online.dto.OnlineImportResultDto;
import org.springframework.web.multipart.MultipartFile;
/**
* 在线表单导入数据服务:模板驱动的新引擎。
*
* <p>校验与入库拆成两个接口,各自接收上传文件(不用 fileKey 在服务端传递):</p>
* <ul>
* <li>{@link #validateImport} —— 只跑**模板层**校验(不碰业务库),零写入</li>
* <li>{@link #executeImport} —— 跑**业务层**校验(碰业务库)后入库</li>
* </ul>
*
* @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 归属),最后按批写入。
*
* <p>按行数分档:不超过配置阈值时同步返回结果;超过时由上层转为异步任务。
* 本方法本身只做同步执行。</p>
*
* @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);
}

23
common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportFieldService.java

@ -0,0 +1,23 @@ @@ -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<OnlineImportExportField, Long> {
/**
* 获取指定模板下的全部字段配置,按 sortNo 升序。
*
* @param templateId 模板id。
* @return 字段配置列表。
*/
List<OnlineImportExportField> getListByTemplateId(Long templateId);
}

73
common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTaskService.java

@ -0,0 +1,73 @@ @@ -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<OnlineImportExportTask, Long> {
/**
* 提交一个异步导入任务。任务先以 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<OnlineImportExportTask> getTaskList(OnlineImportExportTask filter, String orderBy);
/**
* 清理超过保留期的任务记录与其结果文件。
*
* @return 清理的任务数。
*/
int cleanExpiredTasks();
}

120
common/common-online/src/main/java/apelet/common/online/service/OnlineImportExportTemplateService.java

@ -0,0 +1,120 @@ @@ -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<OnlineImportExportTemplate, Long> {
/**
* 获取单表查询结果。由于没有关联数据查询,因此在仅仅获取单表数据的场景下,效率更高。
* 如果需要同时获取关联数据,请移步(getOnlineFormListWithRelation)方法。
*
* @param filter 过滤对象。
* @param orderBy 排序参数。
* @return 查询结果集。
*/
List<OnlineImportExportTemplate> getOnlineFormList(OnlineImportExportTemplate filter, String orderBy);
/**
* 获取主表的查询结果,以及主表关联的表单元数据。
*
* @param filter 主表过滤对象。
* @param orderBy 排序参数。
* @return 查询结果集。
*/
List<OnlineImportExportTemplate> 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<OnlineImportExportField> 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<OnlineImportExportTableDto> generateDefaultFields(Long masterHeadId);
/**
* 把扁平的字段配置按 tableName 分组为「表 → 字段」两级树;表描述取 onl_form_head.tableTxt,
* 取不到时回落表名。仅用于模板详情回显(view),save 仍收扁平结构。
*
* @param fieldList 扁平字段配置列表。
* @return 表节点列表,主表节点在最前。
*/
List<OnlineImportExportTableDto> groupToTableList(List<OnlineImportExportField> fieldList);
/**
* 查询某表单的导入导出链路状态,供前端决定调新引擎接口还是原有接口。
*
* @param formId 表单id。
* @return 链路状态;表单不存在或未启用模板时 enabled 为 false。
*/
OnlineImportExportTemplateStatusDto getTemplateStatus(Long formId);
/**
* 失效指定表的生效模板缓存。
*
* @param tableName 主表物理表名。
*/
void evictEnabledTemplate(String tableName);
}

16
common/common-online/src/main/java/apelet/common/online/service/OnlineOperationService.java

@ -197,6 +197,22 @@ public interface OnlineOperationService { @@ -197,6 +197,22 @@ public interface OnlineOperationService {
*/
MyPageData<Map<String, Object>> getSlaveDataList(
OnlineDatasourceRelation relation, List<OnlineFilterDto> filterList, String orderBy, MyPageParam pageParam);
/**
* 从一对多数据源关联中,动态获取数据列表,并可指定从表的一对一(F7)关联是否强制左连接。
*
* <p>F7 外键允许为空,而关系上的 leftJoin 默认 false(自动生成时写死),内连接会把
* 「F7 没填值」的从表行整条滤掉;导出这类"要全量"的场景需要左连接。</p>
*
* @param relation 一对多数据源关联对象。
* @param filterList 过滤参数列表。
* @param orderBy 排序字符串。
* @param pageParam 分页对象。
* @param forceLeftJoin 是否把从表的一对一关联强制成左连接。
* @return 查询结果集。
*/
MyPageData<Map<String, Object>> getSlaveDataList(
OnlineDatasourceRelation relation, List<OnlineFilterDto> filterList, String orderBy, MyPageParam pageParam, boolean forceLeftJoin);
/**
* 从一对多数据源关联中,动态获取数据列表。
*

8
common/common-online/src/main/java/apelet/common/online/service/OnlineTableService.java

@ -66,5 +66,11 @@ public interface OnlineTableService extends IBaseService<OnlineTable, Long> { @@ -66,5 +66,11 @@ public interface OnlineTableService extends IBaseService<OnlineTable, Long> {
*/
OnlineColumn getOnlineColumnFromCache(Long tableId, Long columnId);
/**
* 根据表名查询表对象。并获取关联的字段列表。
*
* @param tableName 表名
* @return 表对象
*/
OnlineTable queryByTableName(String tableName);
}

696
common/common-online/src/main/java/apelet/common/online/service/impl/OnlineExportDataServiceImpl.java

@ -0,0 +1,696 @@ @@ -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;
/**
* 在线表单导出数据服务实现:模板驱动的新引擎。
*
* <p>设计要点(详见《02-导入导出改造分析.md》§3.4 / §4.8 / §5):</p>
* <ol>
* <li><b>取数</b>复用老 SQL 取数层 {@code getMasterDataList / getSlaveDataList}(自带逻辑删除、
* 租户过滤、数据权限、字典翻译、虚拟列、附件回填;select 全表列,故 id 与 F7 裸 id 都在),不换 orm;</li>
* <li><b>选列</b>取模板中 isExport=1 的字段,按 sortNo 排序,表头用 showName、列宽用 excelWidth;</li>
* <li><b>扁平展开</b>与导入同格式(每行分录重复主表列值,按分录表分段、不交错),保证可往返导入;</li>
* <li><b>F7 / 多选补值</b>:老取数层是纯 SQL,拿不到 F7 联表显示值、也读不到中间表,
* 故先取裸外键 id 再按 id 批量 IN 反查(F7),或补查中间表(多选,分号拼接);</li>
* <li><b>不脱敏</b>:与现有导出行为一致(本期决定不改)。</li>
* </ol>
*
* @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<OnlineFilterDto> 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<OnlineDatasourceRelation> allRelationList = onlineOperationHelper.verifyAndGetRelationList(datasourceId, null).getData();
if (allRelationList == null) {
allRelationList = Collections.emptyList();
}
List<OnlineDatasourceRelation> 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<Map<String, Object>> 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<OnlineFilterDto> filterDtoList, MyOrderParam orderParam) {
ExportContext context = this.prepare(formId, datasourceId, filterDtoList, orderParam);
List<ExportRow> 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<OnlineFilterDto> filterDtoList, MyOrderParam orderParam) {
ExportContext context = this.prepare(formId, datasourceId, filterDtoList, orderParam);
List<ExportRow> 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<OnlineFilterDto> 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<OnlineDatasourceRelation> 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;
}
/**
* 导出取数时把一对一关联强制成左连接。
*
* <p>F7 外键允许为空,而关系上的 {@code leftJoin} 自动生成时写死为 false,内连接会把
* 「F7 没填值」的主记录整条滤掉 —— 表现为列表看得到数据、导出却是空的。
* 这里只改本次导出用的对象(关系列表每次都是从 Redis 反序列化出来的副本),不动关系本身的配置,
* 也不影响列表、老导出等其他链路。</p>
*
* @param oneToOneRelationList 一对一关联列表。
* @return 同一列表,已置为左连接。
*/
private List<OnlineDatasourceRelation> useLeftJoin(List<OnlineDatasourceRelation> oneToOneRelationList) {
oneToOneRelationList.forEach(relation -> relation.setLeftJoin(Boolean.TRUE));
return oneToOneRelationList;
}
/**
* 按模板取导出字段:isExport=1,按 sortNo 升序。
*/
private List<OnlineImportExportField> loadExportFields(OnlineImportExportTemplate template) {
List<OnlineImportExportField> 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<String> collectEntryTableNames(List<OnlineImportExportField> exportFieldList, String masterTableName) {
LinkedHashSet<String> 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<ExportRow> buildRows(ExportContext context) {
// 1. 主表取数
MyPageData<Map<String, Object>> masterPageData = onlineOperationService.getMasterDataList(context.masterTable,
context.oneToOneRelationList, context.allRelationList, context.filterDtoList, context.orderBy, null, null);
List<Map<String, Object>> 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<String, OnlineDatasourceRelation> entry : context.entryRelationMap.entrySet()) {
OnlineDatasourceRelation relation = entry.getValue();
// 过滤条件只作用于主表:它绑的是主表(或主表的一对一关联表)的列,分录 SQL 里没有这些表,
// 直接透传会报 Unknown column。分录本就是全量取数、再按外键分组挂到主记录上(设计如此)。
// 传空列表而不是 null:getSlaveDataList 内部靠这个列表追加从表的逻辑删除过滤,null 会把这层过滤弄丢。
MyPageData<Map<String, Object>> slavePageData =
onlineOperationService.getSlaveDataList(relation, new LinkedList<>(), context.orderBy, null, true);
List<Map<String, Object>> 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<ExportRow> rowList = new ArrayList<>();
for (Map<String, Object> masterRow : masterRowList) {
String masterId = this.toKeyValue(masterRow.get(ID_COLUMN));
boolean hasEntrySegment = false;
for (Map.Entry<String, OnlineDatasourceRelation> entry : context.entryRelationMap.entrySet()) {
List<Map<String, Object>> entryRowList = context.entryRowsByTable
.getOrDefault(entry.getKey(), Collections.emptyMap()).get(masterId);
if (CollectionUtils.isEmpty(entryRowList)) {
continue;
}
hasEntrySegment = true;
for (Map<String, Object> 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<String, Object> masterRow, String entryTableName, Map<String, Object> entryRow) {
ExportRow exportRow = new ExportRow();
exportRow.masterRow = masterRow;
exportRow.entryTableName = entryTableName;
exportRow.entryRow = entryRow;
List<Object> values = new ArrayList<>(context.exportFieldList.size());
for (OnlineImportExportField field : context.exportFieldList) {
Map<String, Object> 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<String, Object> resolveSourceRow(ExportContext context, OnlineImportExportField field,
Map<String, Object> masterRow, String entryTableName, Map<String, Object> 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<ExportRow> 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<String> 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<String, String> 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,再按「导出属性」反查显示值,分号拼接。
*
* <p>宿主行 id 的口径与写入一致:主表字段用主记录 id,分录字段用该分录行 id。</p>
*/
private void fillMultiLinkValues(ExportContext context, List<ExportRow> 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<String> hostIdSet = new LinkedHashSet<>();
for (ExportRow row : rowList) {
String hostId = this.resolveHostId(isMasterField, row);
if (hostId != null) {
hostIdSet.add(hostId);
}
}
if (hostIdSet.isEmpty()) {
continue;
}
Map<String, List<String>> objIdsByHost = this.queryMiddleTable(context, middleTableName, hostIdSet);
Collection<String> objIdSet = new LinkedHashSet<>();
objIdsByHost.values().forEach(objIdSet::addAll);
Map<String, String> 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<String> objIdList = objIdsByHost.get(hostId);
if (CollectionUtils.isEmpty(objIdList)) {
row.values.set(i, StringUtils.EMPTY);
continue;
}
List<String> 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<String, Object> 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<String, OnlFormField> 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<String, List<String>> queryMiddleTable(ExportContext context, String middleTableName, Collection<String> hostIdSet) {
Map<String, List<String>> resultMap = new LinkedHashMap<>();
// 中间表只有 biz_id/obj_id/seq,且不注册在线表,故按主表 dblink 直查本地库;
// 按 seq 排序保证多选值的先后与勾选顺序一致(导出→导入闭环)。
List<String> returnColumns = Arrays.asList("biz_id", "obj_id");
List<Map<String, Object>> rowList = dataSourceUtil.queryByIn(context.masterTable.getDblinkId(), middleTableName,
"biz_id", returnColumns, hostIdSet, "biz_id, seq");
for (Map<String, Object> 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<String, List<Map<String, Object>>> groupByColumn(List<Map<String, Object>> rowList, String columnName) {
Map<String, List<Map<String, Object>>> resultMap = new LinkedHashMap<>();
if (StringUtils.isBlank(columnName)) {
return resultMap;
}
for (Map<String, Object> 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<List<Object>> toValueRows(List<ExportRow> 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<String, OnlFormField> loadFormFieldMap(String tableName) {
OnlFormHead head = onlFormHeadService.getHeadTableNameCache(tableName);
if (head == null) {
return Collections.emptyMap();
}
List<OnlFormField> fieldList = onlFormFieldService.getFiledByHeadIdCache(head.getId());
if (CollectionUtils.isEmpty(fieldList)) {
return Collections.emptyMap();
}
Map<String, OnlFormField> 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<Object> values;
private Map<String, Object> masterRow;
private String entryTableName;
private Map<String, Object> entryRow;
}
/**
* 导出上下文。
*/
private static class ExportContext {
private Long formId;
private OnlineImportExportTemplate template;
private OnlFormHead masterHead;
private OnlineTable masterTable;
private List<OnlineImportExportField> exportFieldList;
private List<OnlineDatasourceRelation> allRelationList;
private List<OnlineDatasourceRelation> oneToOneRelationList;
private Map<String, OnlineDatasourceRelation> entryRelationMap = new LinkedHashMap<>();
private Map<String, Map<String, List<Map<String, Object>>>> entryRowsByTable = new LinkedHashMap<>();
private Map<String, Map<String, OnlFormField>> formFieldMap = new LinkedHashMap<>();
private List<OnlineFilterDto> filterDtoList;
private MyOrderParam orderParam;
private String orderBy;
private String sheetName;
private String fileName;
private int masterRowCount;
}
}

1320
common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportDataServiceImpl.java

File diff suppressed because it is too large Load Diff

45
common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportFieldServiceImpl.java

@ -0,0 +1,45 @@ @@ -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<OnlineImportExportField, Long> implements OnlineImportExportFieldService {
@Autowired
private OnlineImportExportFieldMapper onlineImportExportFieldMapper;
@Override
protected BaseDaoMapper<OnlineImportExportField> mapper() {
return onlineImportExportFieldMapper;
}
@Override
public List<OnlineImportExportField> getListByTemplateId(Long templateId) {
return this.list(new LambdaQueryWrapper<OnlineImportExportField>()
.eq(OnlineImportExportField::getTemplateId, templateId)
.orderByAsc(OnlineImportExportField::getSortNo));
}
}

489
common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTaskServiceImpl.java

@ -0,0 +1,489 @@ @@ -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;
/**
* 在线表单导入导出异步任务服务实现。
*
* <p>线程池是**自定义的 JDK 线程池**,不走 Spring 异步体系 —— 与仓库中权限字生成的既有做法一致
* (`OnlinePermCodeServiceImpl` 注释里写明:避免影响应用原有线程池配置,也就是避开与 Flowable 的冲突)。
* 不同之处是这里必须用**有界队列 + 拒绝策略**:导入是长任务,无界队列会把任务堆在内存里。</p>
*
* @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<OnlineImportExportTask, Long> 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<OnlineImportExportTask> 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<OnlineImportExportTask>()
.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<OnlineImportExportTask> getTaskList(OnlineImportExportTask filter, String orderBy) {
LambdaQueryWrapper<OnlineImportExportTask> 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<OnlineImportExportTask> expiredList = this.list(new LambdaQueryWrapper<OnlineImportExportTask>().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 等)能正常工作。
*
* <p>{@code TokenData.takeFromRequest()} 只读 HttpServletRequest 的 attribute,
* 因此这里用一个动态代理伪造 request,只应答 getAttribute,其余方法返回默认值。</p>
*
* @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;
}
}

532
common/common-online/src/main/java/apelet/common/online/service/impl/OnlineImportExportTemplateServiceImpl.java

@ -0,0 +1,532 @@ @@ -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<OnlineImportExportTemplate, Long> implements OnlineImportExportTemplateService {
/**
* 缓存过期时间(小时)。与表单元数据缓存保持一致。
*/
private static final int DEFAULT_CACHED_TEMPLATE_HOURS = 168;
/**
* 由框架维护的内部列,不进入模板配置。parent_id / seq 由或框架自身读写,暴露给用户只会造成误配。
*/
private static final Set<String> 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<OnlineImportExportTemplate> mapper() {
return onlineImportExportTemplateMapper;
}
@Override
public List<OnlineImportExportTemplate> 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<OnlineImportExportTemplate> getOnlineFormListWithRelation(OnlineImportExportTemplate filter, String orderBy) {
List<OnlineImportExportTemplate> 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<String> 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<OnlineImportExportTemplate>().eq(OnlineImportExportTemplate::getMasterTableName, tableName).eq(OnlineImportExportTemplate::getIsEnabled, ImportExportEnabledStatus.ENABLED.getCode()));
}
@Override
@Transactional(rollbackFor = Exception.class)
public OnlineImportExportTemplate saveTemplate(OnlineImportExportTemplate template, List<OnlineImportExportField> 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<OnlineImportExportField>().eq(OnlineImportExportField::getTemplateId, id));
this.removeById(id);
// 删除的是生效模板时,顺延该表剩余的第一套;删空则该表回落原有链路。
if (ImportExportEnabledStatus.isEnabled(template.getIsEnabled())) {
OnlineImportExportTemplate next = this.getOne(new LambdaQueryWrapper<OnlineImportExportTemplate>().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<OnlineImportExportTemplate>().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<OnlineImportExportTableDto> generateDefaultFields(Long masterHeadId) {
OnlFormHead masterHead = masterHeadId == null ? null : onlFormHeadService.getOnlFormHeadFromCache(masterHeadId);
if (masterHead == null) {
throw new OnlineRuntimeException("数据验证失败,表单元数据不存在!");
}
List<OnlineImportExportTableDto> tableList = new ArrayList<>();
// 主表节点
this.appendDefaultFields(tableList, masterHead, null);
// 分录表节点:展示名加「表描述-」前缀,避免与主表字段重名。
List<OnlFormHead> 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<OnlineImportExportTableDto> tableList) {
List<OnlineImportExportField> allFieldList = new ArrayList<>();
for (OnlineImportExportTableDto tableDto : tableList) {
if (CollectionUtils.isNotEmpty(tableDto.getFieldList())) {
allFieldList.addAll(tableDto.getFieldList());
}
}
if (allFieldList.isEmpty()) {
return;
}
Map<String, Long> countMap = allFieldList.stream()
.collect(Collectors.groupingBy(OnlineImportExportField::getShowName, Collectors.counting()));
Set<String> 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<String> 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<OnlineImportExportField> 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<OnlineImportExportTableDto> groupToTableList(List<OnlineImportExportField> fieldList) {
if (CollectionUtils.isEmpty(fieldList)) {
return new ArrayList<>();
}
Map<String, OnlineImportExportTableDto> 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<String, OnlFormField> 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<String, OnlFormField> loadFormFieldMap(String tableName) {
OnlFormHead head = onlFormHeadService.getHeadTableNameCache(tableName);
if (head == null) {
return Collections.emptyMap();
}
List<OnlFormField> fieldList = onlFormFieldService.getFiledByHeadIdCache(head.getId());
if (CollectionUtils.isEmpty(fieldList)) {
return Collections.emptyMap();
}
Map<String, OnlFormField> 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<OnlineImportExportTableDto> tableList, OnlFormHead head, String showPrefix) {
List<OnlFormField> fieldList = onlFormFieldService.getFiledByHeadIdCache(head.getId());
if (CollectionUtils.isEmpty(fieldList)) {
return;
}
OnlineImportExportTableDto tableDto = new OnlineImportExportTableDto();
tableDto.setTableName(head.getTableName());
tableDto.setTableLabel(head.getTableTxt());
List<OnlineImportExportField> 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<OnlineImportExportField> fieldList, TokenData tokenData, Date now) {
onlineImportExportFieldService.remove(new LambdaQueryWrapper<OnlineImportExportField>().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<OnlineImportExportTemplate> wrapper = new LambdaQueryWrapper<OnlineImportExportTemplate>()
.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<OnlineImportExportField> fieldList) {
if (CollectionUtils.isEmpty(fieldList)) {
return;
}
List<String> 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<String, Long> 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 + ",请修改后再保存!");
}
}
}

10
common/common-online/src/main/java/apelet/common/online/service/impl/OnlineOperationServiceImpl.java

@ -728,6 +728,12 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { @@ -728,6 +728,12 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
@Override
public MyPageData<Map<String, Object>> getSlaveDataList(
OnlineDatasourceRelation relation, List<OnlineFilterDto> filterList, String orderBy, MyPageParam pageParam) {
return this.getSlaveDataList(relation, filterList, orderBy, pageParam, false);
}
@Override
public MyPageData<Map<String, Object>> getSlaveDataList(
OnlineDatasourceRelation relation, List<OnlineFilterDto> filterList, String orderBy, MyPageParam pageParam, boolean forceLeftJoin) {
OnlineTable slaveTable = relation.getSlaveTable();
this.normalizeFilterList(slaveTable, null, filterList, null);
ResponseResult<List<OnlineDatasourceRelation>> relationListResult =
@ -735,6 +741,10 @@ public class OnlineOperationServiceImpl implements OnlineOperationService { @@ -735,6 +741,10 @@ public class OnlineOperationServiceImpl implements OnlineOperationService {
List<OnlineDatasourceRelation> allRelationList = relationListResult.getData();
List<OnlineDatasourceRelation> 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<JoinTableInfo> joinInfoList = this.makeJoinInfoList(slaveTable, oneToOneRelationList);
// 拼接关联表的select fields字段。

26
common/common-online/src/main/java/apelet/common/online/service/impl/OnlineTableServiceImpl.java

@ -19,10 +19,10 @@ import apelet.common.sequence.wrapper.IdGeneratorWrapper; @@ -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<OnlineTable, Long> imple @@ -201,5 +201,27 @@ public class OnlineTableServiceImpl extends BaseService<OnlineTable, Long> imple
redissonClient.getBucket(tableIdKey).delete();
}
@Override
public OnlineTable queryByTableName(String tableName) {
OnlineTable onlineTable = onlineTableMapper.selectOne(new QueryWrapper<OnlineTable>().eq("table_name", tableName));
if (onlineTable == null) {
return null;
}
List<OnlineColumn> columnList = onlineColumnService.getOnlineColumnListByTableIds(Collections.singleton(onlineTable.getTableId()));
if (CollectionUtils.isNotEmpty(columnList)) {
Map<Long, OnlineColumn> 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;
}
}

130
common/common-online/src/main/java/apelet/common/online/timer/ImportExportFileCleanTimer.java

@ -0,0 +1,130 @@ @@ -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;
/**
* 导入导出文件清理定时器。
*
* <p>清理规则:</p>
* <ol>
* <li><b>下载即删</b> —— 文件被下载后由下载接口立即删除(见 {@code ImportExportExcelUtil#writeFailFileToResponse});</li>
* <li><b>超期清理</b> —— 未被下载的文件(上传的导入源文件、导入失败文件、导出结果文件),
* 超过 {@code common-online.import-export-file-retain-hours}(默认 24 小时)后由本定时器删除。</li>
* </ol>
*
* <p>清理范围仅限 {@code {uploadFileBaseDir}/importedFile} 目录,不涉及同级的 attachment / image 目录。
* 多实例下用分布式锁保证同一时刻只有一个实例在清理。</p>
*
* @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;
}
}

378
common/common-online/src/main/java/apelet/common/online/util/ImportExportExcelUtil.java

@ -0,0 +1,378 @@ @@ -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 生成工具。
*
* <p>不直接复用 {@code common-core} 的 ExportUtil,原因有三:其列宽写死为 30/20、文件名未做
* URL 编码(中文乱码)、数据为空时不写任何响应。这里按模板配置控制列宽与表头。</p>
*
* @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<OnlineImportExportField> 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<String> 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 并落盘:模板的导入列(原样回显)+ 末尾追加「失败原因」列。
*
* <p>列头用的是模板展示名,多出来的原因列在重新导入时会被「多余列忽略」规则跳过,
* 因此用户可以改完直接重传这个文件。</p>
*
* @param failedRowList 失败的行。
* @param importFieldList 模板中已勾选导入的字段,决定列与顺序。
* @param reasonHeader 原因列的表头文案。
* @param uploadBaseDir 上传根目录。
* @param subDir 落盘子目录(相对上传根目录)。
* @return 失败文件标识(文件名),可直接交给下载接口。
* @throws IOException 写文件失败时抛出。
*/
public static String writeFailFile(List<OnlineImportRowDto> failedRowList, List<OnlineImportExportField> importFieldList,
String reasonHeader, String uploadBaseDir, String subDir) throws IOException {
if (CollectionUtils.isEmpty(failedRowList) || CollectionUtils.isEmpty(importFieldList)) {
return null;
}
List<String> 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<Object> 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<OnlineImportExportField> exportFieldList, List<List<Object>> 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<OnlineImportExportField> exportFieldList, List<List<Object>> 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<List<Object>> rowList) {
if (CollectionUtils.isEmpty(rowList)) {
return;
}
for (List<Object> 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<OnlineImportExportField> fieldList, String sheetName) {
ExcelWriter writer = ExcelUtil.getWriter(true);
writer.renameSheet(0, sanitizeSheetName(sheetName));
List<String> 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;
}
}

15
common/common-online/src/main/java/apelet/common/online/util/OnlineConstant.java

@ -14,6 +14,21 @@ public class OnlineConstant { @@ -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() {

105
common/common-online/src/main/java/apelet/common/online/util/OnlineDataSourceUtil.java

@ -4,11 +4,23 @@ import apelet.common.core.exception.MyRuntimeException; @@ -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 { @@ -44,4 +56,97 @@ public class OnlineDataSourceUtil extends DataSourceUtil {
}
return dblink.getConfiguration();
}
/**
* 按某一列的 IN 条件查询若干列(本地库直查,不走 ORM 取数)。
*
* <p>用于导入导出的关联字段补值/匹配键查询:ORM 的 {@code in} 过滤器要求调用方把值自己拼成
* 字符串再传,且会把带 EAS 异构标记的表(如 t_pm_user)转发到 EAS 取数;这两处读的就是本地表,
* 直接走 SQL 更可控,也和导出主表取数的口径一致。</p>
*
* @param table 目标表,用其表名与 dblink。
* @param matchColumn 用于匹配的列。
* @param returnColumns 要取值的列。
* @param matchValues 待匹配的值集合。
* @return 每行一个 map(键为列名,取值用 {@link #getColumnText} 可忽略大小写与引号);
* 表对象为空、无待匹配值或查询无结果时返回空列表。
* <p><b>匹配列一定会在结果里</b>(调用方通常要用它做 key 建映射,漏选会拿到 null 静默丢数据)。</p>
*/
public List<Map<String, Object>> queryByIn(OnlineTable table, String matchColumn, Collection<String> returnColumns, Collection<String> 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<Map<String, Object>> queryByIn(Long dblinkId, String tableName, String matchColumn,
Collection<String> returnColumns, Collection<String> matchValues, String orderBy) {
List<String> distinctValues = matchValues == null ? Collections.emptyList()
: matchValues.stream().filter(StringUtils::isNotBlank).distinct().collect(Collectors.toList());
Set<String> 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<Map<String, Object>> rowList = this.query(dblinkId, sql.toString(), new ArrayList<>(distinctValues));
return rowList == null ? Collections.<Map<String, Object>>emptyList() : rowList;
} catch (Exception e) {
throw new MyRuntimeException("查询表「" + tableName + "」失败:" + e.getMessage());
}
}
/**
* 从查询结果行取列值,忽略键的大小写与引号(不同驱动/参数下列标签形态不一致)。
*
* @param row 结果行。
* @param columnName 列名。
* @return 列值;取不到返回 null。
*/
public static Object getColumnValue(Map<String, Object> row, String columnName) {
if (row == null || StringUtils.isBlank(columnName)) {
return null;
}
for (Map.Entry<String, Object> 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<String, Object> row, String columnName) {
Object value = getColumnValue(row, columnName);
return value == null ? null : value.toString();
}
}

226
common/common-online/src/main/java/apelet/common/online/util/OnlineImportExcelReader.java

@ -0,0 +1,226 @@ @@ -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 解析器:**按表头名**建立列映射,再逐行取值。
*
* <p>不复用 {@code common-core} 的 ImportUtil.MyRowHandler,因为它按列位置硬对齐、且盲跳表头不校验
* 表头内容,与"模板配置驱动、列顺序可调换"的设计不符。</p>
*
* <p>表头匹配规则:trim 后精确匹配(不做大小写兼容,避免 Name 与 name 意外撞车);表头中多余的列忽略;
* 「导入且必填」的列若缺失,记入 {@link OnlineImportSheetDto#getMissingRequiredColumns()}。</p>
*
* @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<OnlineImportExportField> 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<Object> 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<String, OnlineImportExportField> showNameMap = new HashMap<>();
/**
* Excel 列号 → 字段配置(仅包含表头中命中的列)。
*/
private final Map<Integer, OnlineImportExportField> columnFieldMap = new HashMap<>();
private final List<OnlineImportExportField> importFieldList;
private final OnlineImportSheetDto sheetDto;
ImportRowHandler(List<OnlineImportExportField> 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<Object> rowList) {
if (rowIndex == 0) {
this.buildColumnMapping(rowList);
return;
}
this.appendDataRow(rowIndex, rowList);
}
/**
* 首行:按展示名建立「列号 → 字段」映射。
*
* @param headerRow 表头行。
*/
private void buildColumnMapping(List<Object> 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<Object> 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<Integer, OnlineImportExportField> 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<String> getMissingRequiredColumns() {
Set<String> 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<String> getMatchedColumnNames() {
return this.columnFieldMap.values().stream().map(OnlineImportExportField::getColumnName).collect(Collectors.toCollection(LinkedHashSet::new));
}
}
/**
* 建立「展示名 → 字段」映射,供调用方复用。
*
* @param importFieldList 已勾选导入的字段列表。
* @return 映射表。
*/
public static Map<String, OnlineImportExportField> buildShowNameMap(List<OnlineImportExportField> importFieldList) {
Map<String, OnlineImportExportField> 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;
}
}

267
common/common-online/src/main/java/apelet/common/online/util/RelationValueResolver.java

@ -0,0 +1,267 @@ @@ -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)取值工具:定位目标表、解析属性列、按属性值批量反查目标行主键。
*
* <p>能力下沉自 {@code common-openapi} 的 {@code OpenApiOnlineQueryServiceImpl}
* (依赖方向为 common-openapi → common-online,反向不可引用,故在本模块重建)。</p>
*
* <p>批量反查按批执行 {@code IN} 查询,调用方应**按列解析一次目标表、再对全部行做一次批量反查**,
* 不要逐行调用。</p>
*
* @author chenchuchuan
* @date 2026-09-18
*/
@Slf4j
@Component
public class RelationValueResolver {
/**
* 「编码」的候选列名,按顺序回退。这些只是建表时的默认列名,用户可能改名,故允许逐级回退。
*/
private static final List<String> CODE_CANDIDATE_COLUMNS = Arrays.asList("number", "code");
/**
* 「名称」的候选列名,按顺序回退。
*/
private static final List<String> 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<OnlFormField> relFieldList = onlFormFieldService.list(new LambdaQueryWrapper<OnlFormField>()
.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<String> 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;
}
/**
* 解析「导出属性」对应的目标表实际列名。
*
* <p>导出属性与导入属性是同一套编码(PK/CODE/NAME,见 {@link ImportAttrEnum}),必须换算成
* 目标表上的真实列名后再去取值 —— 直接拿编码当列名会查一个并不存在的「NAME」列,取不到值。</p>
*
* @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<String> 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;
}
/**
* 批量反查:按属性值查出命中的目标行主键。
*
* <p>返回值中,一个属性值可能对应多个主键(该属性列在目标表中不唯一),由调用方决定是报错还是取值。</p>
*
* @param relTable 目标表。
* @param attrColumn 用于匹配的目标表列名。
* @param attrValues 待匹配的属性值集合。
* @return 属性值 → 命中的主键集合。
*/
public Map<String, List<String>> resolveRelatedIdMap(OnlineTable relTable, String attrColumn, Collection<String> attrValues) {
Map<String, List<String>> resultMap = new HashMap<>();
if (relTable == null || relTable.getPrimaryKeyColumn() == null || StringUtils.isBlank(attrColumn) || CollectionUtils.isEmpty(attrValues)) {
return resultMap;
}
List<String> 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<String> batchValues = distinctValues.subList(i, Math.min(i + MAX_IN_BATCH_SIZE, distinctValues.size()));
this.queryRelatedIdBatch(relTable, attrColumn, pkColumnName, batchValues, resultMap);
}
return resultMap;
}
/**
* 批量反查(导出用,方向与 {@link #resolveRelatedIdMap} 相反):按主键查出对应的属性值。
*
* <p>导出取数层是纯 SQL,拿不到 F7 的联表显示值,因此先取回裸外键 id,再用本方法按 id 批量补值。
* 一次 IN 查询,不是 N+1。</p>
*
* @param relTable 目标表。
* @param attrColumn 要取值的列名(由 {@link #resolveExportAttrColumn} 解析)。
* @param pkValues 待补值的主键集合。
* @return 主键 → 该列的值;查不到的不在结果里。
*/
public Map<String, String> resolveRelatedValueMap(OnlineTable relTable, String attrColumn, Collection<String> pkValues) {
Map<String, String> resultMap = new HashMap<>();
if (relTable == null || relTable.getPrimaryKeyColumn() == null || StringUtils.isBlank(attrColumn) || CollectionUtils.isEmpty(pkValues)) {
return resultMap;
}
List<String> 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<String> 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<String> batchValues, Map<String, String> resultMap) {
for (Map<String, Object> 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<String> candidates) {
Set<String> 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<String> batchValues, Map<String, List<String>> resultMap) {
// 与导出补值同一套本地库直查,避开 ORM in 过滤器的取值要求与 EAS 异构路由。
for (Map<String, Object> 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);
}
}
}

56
common/common-online/src/test/java/apelet/common/online/util/QuickFormWidgetUtilTest.java

@ -0,0 +1,56 @@ @@ -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<OnlineQuickFormField> 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<String, Long> 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 个过滤控件");
// }
//}
Loading…
Cancel
Save