|
|
@@ -2,26 +2,29 @@ package com.zsjz.ai.module.dm.pre;
|
|
|
|
|
|
import cn.hutool.core.collection.CollUtil;
|
|
|
import cn.hutool.core.collection.ListUtil;
|
|
|
-import cn.hutool.core.io.FileUtil;
|
|
|
import cn.hutool.core.map.MapUtil;
|
|
|
-import cn.hutool.core.util.CharsetUtil;
|
|
|
import cn.hutool.core.util.IdUtil;
|
|
|
import cn.hutool.core.util.StrUtil;
|
|
|
+import cn.hutool.extra.spring.SpringUtil;
|
|
|
+import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
|
|
|
import com.google.common.collect.Lists;
|
|
|
import com.zsjz.ai.common.cache.GlobalCache;
|
|
|
-import com.zsjz.ai.common.constants.PathConst;
|
|
|
+import com.zsjz.ai.common.constants.StrConsts;
|
|
|
import com.zsjz.ai.common.enums.FileSheetStateEnum;
|
|
|
import com.zsjz.ai.common.model.dm.dto.TableRuleDTO;
|
|
|
import com.zsjz.ai.common.model.dm.entity.FileInfo;
|
|
|
import com.zsjz.ai.common.model.dm.mapstruct.TableFieldConvert;
|
|
|
import com.zsjz.ai.common.model.plat.entity.TableField;
|
|
|
import com.zsjz.ai.common.model.plat.entity.TableInfo;
|
|
|
-import com.zsjz.ai.common.utils.Json;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.apache.fesod.sheet.context.AnalysisContext;
|
|
|
import org.apache.fesod.sheet.event.AnalysisEventListener;
|
|
|
+import org.duckdb.DuckDBAppender;
|
|
|
+import org.duckdb.DuckDBConnection;
|
|
|
|
|
|
-import java.nio.file.Path;
|
|
|
+import javax.sql.DataSource;
|
|
|
+import java.sql.SQLException;
|
|
|
+import java.sql.Statement;
|
|
|
import java.time.LocalDateTime;
|
|
|
import java.util.*;
|
|
|
import java.util.concurrent.atomic.AtomicBoolean;
|
|
|
@@ -30,7 +33,8 @@ import java.util.stream.Collectors;
|
|
|
|
|
|
/**
|
|
|
* 预览数据监听器
|
|
|
- * 监听 Excel/CSV 文件读取过程,解析文件结构、匹配模板、提取预览数据
|
|
|
+ * 监听 Excel/CSV 文件读取过程,解析文件结构、匹配模板
|
|
|
+ * 为每个 sheet 在 DuckDB 动态创建 raw_{sheetId} 表(全 VARCHAR),用 DuckDBAppender 流式追加全部数据行
|
|
|
* 实现流式数据处理,避免大文件内存溢出
|
|
|
*/
|
|
|
@Slf4j
|
|
|
@@ -57,24 +61,39 @@ public class PreDataListener extends AnalysisEventListener<Map<Integer, String>>
|
|
|
private final String fileName;
|
|
|
|
|
|
/**
|
|
|
- * 批量写入阈值,每读取 2000 行写入一次临时文件
|
|
|
+ * 是否已匹配到模板的标识(原子操作,线程安全)
|
|
|
*/
|
|
|
- private static final int BATCH_COUNT = 2000;
|
|
|
+ private final AtomicBoolean matched = new AtomicBoolean(false);
|
|
|
|
|
|
/**
|
|
|
- * 预览数据列表,临时存储待写入的 JSON 格式数据
|
|
|
+ * 当前工作表的文件信息对象
|
|
|
*/
|
|
|
- private final List<String> preDataList = ListUtil.toList();
|
|
|
+ private FileInfo fileSheet;
|
|
|
|
|
|
/**
|
|
|
- * 是否已匹配到模板的标识(原子操作,线程安全)
|
|
|
+ * 当前 sheet 的 DuckDB 连接(每 sheet 独立,sheet 结束即关闭)
|
|
|
*/
|
|
|
- private final AtomicBoolean matched = new AtomicBoolean(false);
|
|
|
+ private DuckDBConnection rawConn;
|
|
|
|
|
|
/**
|
|
|
- * 当前工作表的文件信息对象
|
|
|
+ * 当前 sheet 的 Appender(初始化失败或异常关闭后为 null,本 sheet 跳过 DB 写入)
|
|
|
*/
|
|
|
- private FileInfo fileSheet;
|
|
|
+ private DuckDBAppender rawAppender;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 动态表当前数据列数(col_0 ~ col_{n-1})
|
|
|
+ */
|
|
|
+ private int rawColCount;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 动态表表名:raw_{sheetId}
|
|
|
+ */
|
|
|
+ private String rawTableName;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 当前 sheet 是否已尝试初始化动态表(成功失败都只试一次)
|
|
|
+ */
|
|
|
+ private boolean rawTableInited;
|
|
|
|
|
|
/**
|
|
|
* 构造函数
|
|
|
@@ -93,51 +112,135 @@ public class PreDataListener extends AnalysisEventListener<Map<Integer, String>>
|
|
|
|
|
|
/**
|
|
|
* 处理每一行数据
|
|
|
- * 读取行数据、匹配模板、收集预览数据并定期写入临时文件
|
|
|
+ * 读取行数据、匹配模板、全部行写入 DuckDB 动态表
|
|
|
*
|
|
|
* @param rowData 当前行的数据,Map 的 key 为列索引,value 为单元格值
|
|
|
* @param analysisContext 分析上下文,包含工作表信息
|
|
|
*/
|
|
|
@Override
|
|
|
public void invoke(Map<Integer, String> rowData, AnalysisContext analysisContext) {
|
|
|
- // 只处理前 2000 行用于预览
|
|
|
Integer rowIndex = analysisContext.readSheetHolder().getRowIndex();
|
|
|
- if (BATCH_COUNT < rowIndex) {
|
|
|
- return;
|
|
|
- }
|
|
|
// 构建当前工作表的文件信息对象
|
|
|
buildFileSheet(analysisContext);
|
|
|
// 前 20 行且未匹配到模板时,尝试匹配表头
|
|
|
if (rowIndex <= 20 && !matched.get()) {
|
|
|
findTableHead(rowData, rowIndex);
|
|
|
}
|
|
|
- // 将行数据转换为 JSON 格式并添加到预览列表
|
|
|
- String jsonStr = Json.toStr(rowData.values());
|
|
|
- preDataList.add(jsonStr);
|
|
|
- // 达到批量写入阈值时,写入临时文件
|
|
|
- if (preDataList.size() == BATCH_COUNT) {
|
|
|
- String sheetName = analysisContext.readSheetHolder().getSheetName();
|
|
|
- writePreTmp(sheetName);
|
|
|
- }
|
|
|
+ // 全部行写入 DuckDB 动态表
|
|
|
+ appendRawRow(rowData);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 将预览数据写入临时文件
|
|
|
- * 按工作表分别存储预览数据,用于后续展示
|
|
|
+ * 追加一行到当前 sheet 的动态表
|
|
|
+ * 首行时懒初始化建表,行宽超过现有列数时动态扩列
|
|
|
*
|
|
|
- * @param sheetName 工作表名称
|
|
|
+ * @param rowData 当前行数据,key 为列索引
|
|
|
*/
|
|
|
- private void writePreTmp(String sheetName) {
|
|
|
- if (CollUtil.isEmpty(preDataList)) {
|
|
|
+ private void appendRawRow(Map<Integer, String> rowData) {
|
|
|
+ if (!rawTableInited) {
|
|
|
+ initRawTable(rowData);
|
|
|
+ }
|
|
|
+ if (rawAppender == null) {
|
|
|
return;
|
|
|
}
|
|
|
- // 构建临时文件路径:tmpPath/batchId/fileId_sheetName.json
|
|
|
- sheetName = StrUtil.emptyToDefault(sheetName, "csv");
|
|
|
- Path resolve = PathConst.TMP_PATH.resolve(batchId.toString()).resolve(fileId + "_" + sheetName + ".json");
|
|
|
- FileUtil.writeLines(preDataList, resolve.toFile(), CharsetUtil.CHARSET_UTF_8);
|
|
|
- preDataList.clear();
|
|
|
- // 更新文件信息中的临时文件路径
|
|
|
- fileSheet.setFilePath(resolve.toString());
|
|
|
+ try {
|
|
|
+ // 稀疏行:列数按最大 key+1 计算,后续更宽的行通过 ALTER 扩列
|
|
|
+ int width = rowData.isEmpty() ? 0 : Collections.max(rowData.keySet()) + 1;
|
|
|
+ if (width > rawColCount) {
|
|
|
+ growRawColumns(width);
|
|
|
+ }
|
|
|
+ rawAppender.beginRow();
|
|
|
+ rawAppender.append(String.valueOf(fileId));
|
|
|
+ rawAppender.append(String.valueOf(fileSheet.getId()));
|
|
|
+ for (int i = 0; i < rawColCount; i++) {
|
|
|
+ // append(String) 对 null 安全,缺失列自动写 NULL
|
|
|
+ rawAppender.append(rowData.get(i));
|
|
|
+ }
|
|
|
+ rawAppender.endRow();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("动态表写入失败,本 sheet 剩余行跳过, table={}", rawTableName, e);
|
|
|
+ // 关闭并置空,避免 appender 行状态损坏影响后续
|
|
|
+ closeRawTable();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 首行懒初始化动态表
|
|
|
+ * 取 slave(DuckDB)连接、CREATE TABLE、创建 Appender;失败仅记日志,不影响预览主流程
|
|
|
+ *
|
|
|
+ * @param rowData 首行数据,用于确定初始列数
|
|
|
+ */
|
|
|
+ private void initRawTable(Map<Integer, String> rowData) {
|
|
|
+ rawTableInited = true;
|
|
|
+ rawTableName = "raw_" + fileSheet.getId();
|
|
|
+ try {
|
|
|
+ DynamicRoutingDataSource ds = (DynamicRoutingDataSource) SpringUtil.getBean(DataSource.class);
|
|
|
+ rawConn = (DuckDBConnection) ds.getDataSource(StrConsts.DS_KEY_SLAVE).getConnection();
|
|
|
+ rawColCount = Math.max(1, rowData.isEmpty() ? 1 : Collections.max(rowData.keySet()) + 1);
|
|
|
+ // 全 VARCHAR:file_id、sheet_id 关联列 + col_0..col_{n-1}
|
|
|
+ StringJoiner cols = new StringJoiner(", ");
|
|
|
+ cols.add("file_id VARCHAR").add("sheet_id VARCHAR");
|
|
|
+ for (int i = 0; i < rawColCount; i++) {
|
|
|
+ cols.add("col_" + i + " VARCHAR");
|
|
|
+ }
|
|
|
+ try (Statement st = rawConn.createStatement()) {
|
|
|
+ st.execute("CREATE TABLE IF NOT EXISTS " + rawTableName + " (" + cols + ")");
|
|
|
+ }
|
|
|
+ rawAppender = rawConn.createAppender(DuckDBConnection.DEFAULT_SCHEMA, rawTableName);
|
|
|
+ log.info("动态表初始化成功, table={}, colCount={}", rawTableName, rawColCount);
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 典型场景:未开案时 slave 回退为 master(PG),强转失败 → 降级为不写 DB
|
|
|
+ log.error("动态表初始化失败,本 sheet 跳过 DuckDB 存储, table={}", rawTableName, e);
|
|
|
+ closeRawTable();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 行宽超过现有列数时动态扩列
|
|
|
+ * 先关 appender(flush 已缓冲行)→ ALTER TABLE 补列 → 重建 appender
|
|
|
+ *
|
|
|
+ * @param width 新的列数
|
|
|
+ */
|
|
|
+ private void growRawColumns(int width) throws SQLException {
|
|
|
+ rawAppender.close();
|
|
|
+ try (Statement st = rawConn.createStatement()) {
|
|
|
+ for (int i = rawColCount; i < width; i++) {
|
|
|
+ st.execute("ALTER TABLE " + rawTableName + " ADD COLUMN col_" + i + " VARCHAR");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ rawColCount = width;
|
|
|
+ rawAppender = rawConn.createAppender(DuckDBConnection.DEFAULT_SCHEMA, rawTableName);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 释放当前 sheet 的 DuckDB 资源
|
|
|
+ * 幂等,先 appender 后 connection,与 AbstractDataLoader 关闭顺序一致
|
|
|
+ */
|
|
|
+ private void closeRawTable() {
|
|
|
+ if (rawAppender != null) {
|
|
|
+ try {
|
|
|
+ rawAppender.close();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("关闭动态表 Appender 失败, table={}", rawTableName, e);
|
|
|
+ }
|
|
|
+ rawAppender = null;
|
|
|
+ }
|
|
|
+ if (rawConn != null) {
|
|
|
+ try {
|
|
|
+ rawConn.close();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("关闭动态表连接失败, table={}", rawTableName, e);
|
|
|
+ }
|
|
|
+ rawConn = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 对外兜底释放 DuckDB 资源
|
|
|
+ * PreTask 读取异常中断时调用;正常流程各 sheet 结束时已释放,此处为空操作
|
|
|
+ */
|
|
|
+ public void close() {
|
|
|
+ closeRawTable();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -199,19 +302,18 @@ public class PreDataListener extends AnalysisEventListener<Map<Integer, String>>
|
|
|
|
|
|
/**
|
|
|
* 所有数据分析完成后的回调
|
|
|
- * 写入剩余的预览数据并重置状态
|
|
|
+ * 关闭动态表资源并重置状态,准备处理下一个工作表
|
|
|
*
|
|
|
* @param analysisContext 分析上下文
|
|
|
*/
|
|
|
@Override
|
|
|
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
|
|
|
- String sheetName = analysisContext.readSheetHolder().getSheetName();
|
|
|
- // 写入剩余的预览数据
|
|
|
- writePreTmp(sheetName);
|
|
|
+ // 关闭当前 sheet 的动态表资源并重置状态
|
|
|
+ closeRawTable();
|
|
|
+ rawTableInited = false;
|
|
|
// 重置状态,准备处理下一个工作表
|
|
|
fileSheet = null;
|
|
|
matched.set(false);
|
|
|
- preDataList.clear();
|
|
|
}
|
|
|
|
|
|
/**
|