Browse Source
- 添加ImportAttrEnum、ImportExportEnabledStatus、ImportWriteModeEnum枚举类 - 实现ImportExportExcelUtil工具类,支持导入模板生成和文件下载 - 添加ImportExportFileCleanTimer定时清理任务 - 在OnlineConstant中增加导入导出模板缓存key相关方法 - 扩展OnlineDataSourceUtil提供IN条件查询功能 - 定义OnlineExportDataService接口及其实现类OnlineExportDataServiceImpl - 修复ObjectValue类中类型转换可能引发的ClassCastException问题feature/2026-08/0812-ccc-dev
48 changed files with 6548 additions and 10 deletions
@ -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"); |
||||
} |
||||
} |
||||
@ -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> { |
||||
} |
||||
@ -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> { |
||||
} |
||||
@ -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); |
||||
} |
||||
@ -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> |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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()); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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()); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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)); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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"; |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
@ -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); |
||||
} |
||||
@ -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(); |
||||
} |
||||
@ -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); |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -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)); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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 + ",请修改后再保存!"); |
||||
} |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -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…
Reference in new issue