皮皮舟印岁月

A JOURNAL OF TRAVEL · CODE · MARKETS
第四十一期 · 第 41 号 二〇二六年十月 · 寒露之后

文件数据导入与字段映射通用设计与实现

2027-10-18 2 阅读

状态:通用模式定稿 + 参考实现(配置链路已有参考实现落地;导入引擎按本设计与 §十 代码实现)

定位:描述"外部数据文件 → 结构化落库"这一类需求的通用模式:字段映射如何建模、表头如何对齐、单元格数据类型如何转换、错误如何处理。适用于一切"以文件为载体、列结构由外部决定、且会变化"的导入场景。

角色约定:源列名 = 文件表头原文;目标字段 = 目标表列名;映射配置表 = 存储两者对应关系的配置表;目标明细表 = 数据最终落入的业务表。

代码口径:§十 为 Java 参考实现(OpenJDK 17 / Spring Boot / MyBatis / Apache POI),包名 com.example.importx 按项目约定替换;工程约定(Result 信封、BizException、软删、手写校验)按各项目规范套用。


一、做这件事的目的是什么

1. 业务目的:让"换模板"不再是开发事件。 外部数据文件的表头由数据提供方决定,且会随时间、来源变化——同一个含义的列,不同文件里叫法不同("日期/期间/月份")。如果没有映射层,每来一份新格式文件就要改一次解析代码、发一次版。把"源列名 → 目标字段"的对应关系做成页面可维护的配置数据,模板变化就退化为一次配置变更,业务人员自行维护,研发不介入。

2. 数据质量目的:把"对齐"从人脑搬到系统。 文件导入的列对位错误是系统性风险:错一列,整批数据语义全错。映射配置是唯一的对齐依据:每个单元格去往哪个库字段由配置说了算,而不是靠"按列位置猜测";并在解析阶段就发现缺列、多列、表头改名,把错误拦在落库之前。

3. 工程目的:解析与业务解耦。 文件解析(读什么文件、哪一列是什么)与业务消费(字段什么含义、如何使用)是两件事。映射层让导入引擎只面向目标字段名编程,完全不感知具体文件模板;目标表结构稳定,外围模板自由变化。

4. 运营追溯目的:与导入日志配合,形成"谁、何时、导了哪个文件、成功多少失败多少"的完整账。


二、全景与数据流

外部数据文件(xlsx / xls / csv)
        │  ① 上传(multipart;推荐前端三段式:上传 → 预览 → 确认)
        ▼
导入引擎
        │  ② 物理解析:统一抽象为「表头行 + 数据行流」(RowSource,§五)
        │  ③ 查映射配置:按文件类别加载该模板的映射行
        │  ④ 构造路由表:列号 → 目标字段(如 第1列 → field_a,第4列 → period_ym)
        │  ⑤ 逐行取值 + 类型转换(§六 转换矩阵:文件物理类型 → 目标表列类型)
        │  ⑥ 校验:缺列/未知列 → 拒绝;行级错误 → 收集不中断
        ▼
目标明细表 ←— 行数据按路由落位
        │  ⑦ 写导入日志(谁、何时、哪个文件类别、成功/失败行数、错误明细)
        ▼
后续业务消费(按目标表的语义由各业务模块自行处理)

维护侧:

映射管理页(按文件类别分 tab 维护映射行)
    │  增删改查
    ▼
配置服务(映射 CRUD 接口)→ 映射配置表

三、映射配置模型

3.1 配置表结构(示例命名 tb_field_mapping)

列 类型 说明
category VARCHAR(10) 文件类别标识——同类文件的字段集合一致,各持一套映射(如 A/B 两类来源文件)
source_header VARCHAR(100) 源列名(表头原文)——匹配键
target_field VARCHAR(100) 目标字段名(目标明细表列名)——路由目标
remark VARCHAR(255) 字段描述(即目标表列的 comment 口径),兼作解析提示
sort_no INT 展示顺序 = 文件列顺序
  • 唯一约束:(category, source_header)(软删除外)——同类别内源列名不重复;跨类别允许同名。
  • 配置为全局数据(不进租户隔离清单):文件模板口径通常是平台统一标准;若出现"某租户模板特殊",届时加 org_id 并入拦截器清单,结构不变。
  • 值口径:target_field 与目标明细表列名以字符串约定对齐,不建外键(应用层保证一致性)。
  • 管理约束:category 必填且仅允许预定义枚举;source_header/target_field 必填;插入时 sort_no = 当前最大+1;软删除。

3.2 映射行的来源

映射行按真实文件样本逐列校准录入(表头原文照抄,不做人工"翻译");列名口径有调整时在映射页改配置,不改代码。


四、原理:表头怎么对应到字段

映射的本质是一张双向字典:source_header ↔ target_field。导入时的对应机制分四步:

① 取模板。 按文件类别(category)加载该模板的映射行,得到 Map<source_header, target_field>。

② 表头对齐(匹配策略:精确匹配为主,规范化兜底)。

  • 主策略:文件首行单元格文本与 source_header 精确相等即命中(映射录入的就是原文,正常路径零歧义)。
  • 兜底规范化:精确不中时,对两侧做 trim → 全角转半角 → 多空格合一 后再匹配,吸收手工改表头带来的空格/全半角漂移。
  • 匹配结果三种:命中(进入路由表);映射有但文件没有(缺列 → 解析失败,报告缺哪些列);文件有但映射没有(未知列 → 默认忽略并记入导入日志,避免脏数据入未知列)。

③ 构造路由表。 命中的列按出现顺序登记 Map<列号, target_field>,这是逐行取值的唯一依据——不依赖列位置约定,列顺序变化不影响正确性(这是映射方案相对"按列号硬编码"的根本优势)。

④ 逐行路由。 每行数据按路由表取值:row.get(列号) → target_field 对应槽位,再经类型转换(§六)后组装为插入对象。

同理,反向(预览展示、导出)用同一张映射:target_field → source_header,保证页面列名与用户手里的文件用词一致。


五、数据源格式支持

5.1 支持范围

格式 支持 说明
.xlsx(Office 2007+) ✅ 主格式 Apache POI XSSF
.xls(Office 97-2003) ✅ 主格式 Apache POI HSSF;老系统导出仍常见
.csv ✅ 辅格式 文本表;字符集自动探测 UTF-8 / GBK(Windows 导出常为 GBK);分隔符支持 , 与制表符
扫描件 / 图片型 PDF ❌ 不支持 需 OCR,属后续演进(§九);上传时明确报"不支持的格式"
加密 / 带口令文件 ❌ 不支持 明确报错,不尝试绕过
WPS / Numbers 等 ⚠️ 间接支持 要求另存为 xlsx 后上传

5.2 统一抽象:RowSource

各格式在引擎入口处统一抽象为 RowSource(表头行 + 数据行流),后续的路由、转换完全不感知物理格式:

  • Excel:POI 读首个非空工作表(多 sheet 时记入导入日志);大文件流式读,上限单文件 20MB / 10 万行,超限拒绝。
  • CSV:按探测出的字符集逐行解析,首行为表头;空行跳过。
  • 行内约定:单元格一律取原始值(原文字符串或数值),不做格式化展示转换(如"2024年11月"样式),转换交给 §六 统一处理。

六、数据类型与转换(没标类型的列怎么办)

6.1 问题的本质

文件单元格没有业务类型,只有五种物理类型:文本(STRING)、数值(NUMERIC)、布尔(BOOLEAN)、公式(FORMULA,取其缓存结果值再按前述类型处理)、空(BLANK;错误单元格按行错误处理)。同一个数值列,有人存文本 123,有人存数值 123.0;同一个日期列,写法五花八门。目标类型不由文件决定,由目标表列类型决定:目标明细表建表时每个 target_field 的列类型(TEXT / NUMERIC / DATE / INT)就是该字段的权威类型——这就是"映射里没标数据类型的列"的类型来源。

6.2 目标类型四类与字段归类

目标类型 库列 典型字段特征
文本 TEXT / VARCHAR 编码类、名称类、长编号类(超 15 位数字编号)、期间类(YYYYMM,语义是文本不是数字)、枚举类
金额 NUMERIC(12,2) 各类金额、费率
日期 DATE 各类日期
整数 INT 数量、时长

字段→目标类型的登记方式(演进三步):

  1. 现阶段:转换器内置字段白名单——已知按文本处理的字段(长编号、期间、单号等)强制文本;金额/日期字段按 §6.3 矩阵默认转换;
  2. 过渡:映射行 remark 里的人工提示("按文本解析")作为复核依据;
  3. 结构化:映射配置表增加 target_type / parse_rule 列,映射页可配置——映射即类型,无需改代码(预设演进点,当前不实现,避免过度设计)。

6.3 转换矩阵(文件物理类型 → 目标类型)

文件物理类型 → 目标 文本 金额 日期 整数
STRING 文本 原样(trim) 去 ¥/¥/千分位/空格 → BigDecimal;非法 → 行错误 多格式尝试:yyyy-MM-dd、yyyy/M/d、yyyyMMdd、yyyy年M月d日;全不中 → 行错误 去空格 → parse;非法 → 行错误
NUMERIC 数值 防科学计数法:整数值去 .0(123456.0→123456);非整数用 toPlainString;长编号一律按原文字符串读(DataFormatter),不走 double BigDecimal.valueOf(double) 直取 DateUtil.isCellDateFormatted 为真 → 取日期值;否则按 Excel 日期序列号换算 四舍五入取整
BOOLEAN 布尔 true/false 原文 —(不支持 → 行错误) — 1/0
BLANK 空 null null null null
FORMULA 公式 取缓存结果值后按本表重新路由 同左 同左 同左
ERROR 错误单元格 行错误(原值记录进日志) 同左 同左 同左

三条铁律:

  • 长编号类字段永不走 double——超 15 位的数字编号转 double 再转字符串会丢精度(1000000000000001 变 1000000000000000),这是文件导入最常见的隐形事故。源头约定:超 15 位的编号列必须以文本格式存储在文件里(double 只有约 15 位有效数字,数值格式下超出部分在读文件时已丢失,任何后端手段都救不回);
  • 金额一律 BigDecimal,禁止 float/double 参与计算;
  • 期间类(YYYYMM)按文本处理并校验(六位数字、月份 01-12)——它参与分组与对账,202411 与 202411.0 必须归一。

6.4 转换失败的处理口径

  • 行级错误:单格转换失败 → 记录(行号 + 列名 + 原文 + 原因)→ 跳过该行,继续处理,不中断整单;
  • 预览即暴露:三段式的"预览"阶段对全量数据先跑一遍路由+转换,把类型问题在确认前亮出来(错误行数、典型样例);
  • 错误上限:错误行占比超阈值(如 30%)判定为模板不匹配,整单拒绝,防止"半错半对"的数据入库;
  • 全部错误明细进导入日志,可按 log_id 复查。

七、已落地实现(映射配置链路,参考实现)

层 实现 要点
前端 映射管理页 按文件类别分 tab;四列(序号/源列名/目标字段/字段描述);描述只读(comment 口径);增改删齐备
网关层 BFF 规则表转发 前端按类别用不同路径前缀访问,BFF 按规则表转发到配置服务并注入 category 参数(带查询的规则前缀,路径后缀拼在 ? 之前)
服务 配置服务映射 CRUD category 枚举校验;源列名/目标字段必填;sort_no 自动递增;软删
存储 映射配置表 全局表;唯一约束防同类别源列名重复

八、导入引擎实现设计

8.1 关键类

类 职责
RowSourceFactory §五:按扩展名/字符集产出统一 RowSource(xlsx/xls/csv),含大小与行数上限校验
ImportParseService 读表头 + 数据行流
FieldRouter §四的匹配与路由表构造;输出 路由表 + 缺列/未知列清单
TypeConverter §六转换矩阵的唯一实现;错误信息结构化(行号/列/原值/原因)
ImportBillService 预览(全量试算 + 错误清单,不落库)与确认(批量 insert 目标明细表)
ImportLogService 导入日志(用户、时间、文件类别、期间、总行数/成功/失败、错误明细)

8.2 流程与边界规则

  1. 上传:multipart 到导入接口;仅暂存与解析,不落业务表。
  2. 预览:返回表头匹配报告(命中 N 列 / 缺列 / 忽略列)+ 全量试算结果(成功/失败行数 + 典型错误样例);缺列即失败,用户回映射页补配置后重传。
  3. 确认:批量落目标明细表(核心列 = 映射的 target_field + org_id + category + source_file + import_log_id + 审计时间列);行级错误跳过不入库,明细进日志。
  4. 防重:同类别 + 业务期间 + 来源文件指纹的重复导入给出明确提示(覆盖 or 拒绝,交互确认)。
  5. 事务口径:整单一个事务;行级错误不回滚已成功行(错误行不入库),以日志为准。

8.3 与映射配置的配合约定

  • 引擎只认 target_field;新增模板列 = 映射页加一行配置 + 目标明细表对应列存在;
  • 目标类型当前由转换器白名单 + 库列类型决定(§6.2),结构化为 target_type/parse_rule 列后由映射配置驱动;
  • 映射删除仅软删;被"导入中"任务引用的映射不删(应用层校验)。

九、边界与演进

  • 多模板共存:category 现为少数几类;同类多来源差异化模板出现时,扩展 template_code 维度,映射行按模板分组,引擎按模板路由——表加一列即可。
  • 组织差异化:当前全局一套;出现"某组织模板特殊"时加 org_id 入租户清单,结构不变。
  • 格式演进:图片型 PDF/扫描件走 OCR 通道(独立子流程,先识别为表格再进同一套路由/转换);JSON/XML 接口对接(系统直连)复用 §四/§六,仅换 RowSource 实现。
  • 自动化:后期可做"上传样本 → 识别表头 → 生成映射草稿 → 人工确认",把配置成本再降一级。

十、参考实现(Java)

依赖:org.apache.poi:poi-ooxml(含 poi 主包)、org.apache.commons:commons-csv、项目自身 Web/MyBatis 依赖。代码为可直接落地的骨架:核心算法类(FieldRouter / TypeConverter / RowSource)完整,CRUD 与流程类按各项目规范补齐细节即可。

10.0 代码结构总览

整个实现分两层、七个类,依赖方向单向:引擎层只读配置层,配置层不知道引擎的存在。

┌─ 配置层(映射维护:给人用的)─────────────────────────────────────┐
│                                                                  │
│  FieldMappingController ──► FieldMappingMapper ──► tb_field_mapping │
│   (REST 接口,给映射管理页)      (映射行的增删改查)              │
│                                                                  │
│  对引擎的唯一出口:FieldMapping 实体(引擎只读,不写配置)          │
└──────────────────────────────────────────────────────────────────┘
                              │
                              │ listByCategory(category)  ← 引擎启动管线时读取映射
                              ▼
┌─ 引擎层(导入执行:给机器跑的)───────────────────────────────────┐
│                                                                  │
│  ImportEngine(编排者:预览 preview / 确认 confirm 共用 route 管线)│
│    │                                                             │
│    ├─① RowSource.open(file)                                      │
│    │     └─ ExcelRowSource(POI:物理类型识别)                    │
│    │     └─ CsvRowSource(字符集/分隔符探测)                      │
│    │        产出:List<String> 表头 + Cell[] 数据行流              │
│    │                                                             │
│    ├─② FieldRouter.align(headers, mappings)                      │
│    │        纯算法:路由表 列号→目标字段 + 缺列/未知列清单          │
│    │                                                             │
│    ├─③ TypeConverter.convert(cell, targetType)                   │
│    │        纯算法:转换矩阵唯一实现,输出 Converted(值|错误原因)   │
│    │                                                             │
│    ├─④ TargetRowJdbc.batchInsert(rows)                           │
│    │        动态列批量写目标明细表(列集合由映射决定)              │
│    │                                                             │
│    └─⑤ ImportLogMapper(日志启停 + 错误明细 JSON)                 │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

一次导入的调用时序:

上传接口 → ImportEngine.preview(file)            ← 第①②③步全量试算,不落库
              返回:命中列数/缺列/忽略列/错误行数/典型错误
上传接口 → ImportEngine.confirm(file)            ← 同一管线重跑(§8.2:确认以试算通过为前提)
              缺列? 拒绝  │  错误占比>30%? 拒绝
              ImportLog.start → TargetRowJdbc.batchInsert → ImportLog.finish

各类一句话职责:

类 层 职责 被谁调用
FieldMappingController/Mapper 配置层 映射行 CRUD(页面维护映射) 映射管理页
Cell 引擎层 单元格统一表示(物理类型标签 + 原始值) RowSource 产出,TypeConverter 消费
RowSource(接口) 引擎层 格式抹平 SPI:表头 + 行流 ImportEngine 持有
ExcelRowSource / CsvRowSource 引擎层 两种物理格式的解析实现 RowSource.open 工厂分发
FieldRouter 引擎层 表头对齐 → 路由表(纯算法,无状态) ImportEngine
TypeConverter 引擎层 转换矩阵(纯算法,唯一实现) ImportEngine
ImportEngine 引擎层 编排:串起 ①②③④⑤,做预览/确认 上传接口(Controller)
TargetRowJdbc 引擎层 动态列批量落库 ImportEngine
ImportLog / ImportLogMapper 引擎层 导入日志实体与启停 ImportEngine

扩展点:新增文件格式 = 新增一个 RowSource 实现(路由/转换零改动);新增目标类型 = TypeConverter 加一个枚举分支(口径集中,不散落)。

10.1 映射配置(实体 / Mapper / Controller)

功能说明:配置层的全部——实现映射行的增删改查,是引擎的"数据来源"。Controller 对外提供 REST 接口(映射管理页调用),Mapper 直接读写 tb_field_mapping。与引擎层的唯一交点是 FieldMapping 实体:引擎启动管线时只读这些行,不写配置。

/* ============ entity/FieldMapping.java ============ */
/**
 * 字典:一行 = "源列名 → 目标字段" 的一条映射。
 * category 相同的若干行构成一个模板(引擎按类别整体加载)。
 */
@Data
public class FieldMapping {
    private Long id;
    /** 文件类别:如 A / B(各项目按枚举定义);同类文件的字段集合一致 */
    private String category;
    /** 源列名(表头原文)——引擎与文件表头匹配的键 */
    private String sourceHeader;
    /** 目标字段(目标明细表列名)——引擎路由的落点 */
    private String targetField;
    /** 字段描述(兼作解析提示,如"按文本解析") */
    private String remark;
    private Integer sortNo;
}

/* ============ mapper/FieldMappingMapper.java(注解版,正式项目可用 XML) ============ */
@Mapper
public interface FieldMappingMapper {

    String COLS = "id, category, source_header, target_field, remark, sort_no";

    /** 按类别取整套映射行(引擎管线第一步的数据源;全量返回——一个模板几十列,无需分页) */
    @Select("SELECT " + COLS + " FROM tb_field_mapping " +
            "WHERE category = #{category} AND deleted_at IS NULL ORDER BY sort_no ASC, id ASC")
    List<FieldMapping> listByCategory(@Param("category") String category);

    /** 同类别内源列名唯一性校验(建表时用唯一索引兜底,这里给友好报错) */
    @Select("SELECT count(*) FROM tb_field_mapping WHERE category = #{category} " +
            "AND source_header = #{header} AND deleted_at IS NULL")
    long countByHeader(@Param("category") String category, @Param("header") String header);

    /** 新增映射行;sort_no 自动接在当前类别末尾(展示顺序 = 文件列顺序) */
    @Insert("INSERT INTO tb_field_mapping (category, source_header, target_field, remark, sort_no) " +
            "VALUES (#{category}, #{sourceHeader}, #{targetField}, #{remark}, " +
            "(SELECT coalesce(max(sort_no),0)+1 FROM tb_field_mapping WHERE category=#{category} AND deleted_at IS NULL))")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int insert(FieldMapping mapping);

    /** 编辑只允许改源列名/目标字段/描述,不允许改类别(改类别=删除重录,防止半套迁移) */
    @Update("UPDATE tb_field_mapping SET source_header=#{sourceHeader}, target_field=#{targetField}, " +
            "remark=#{remark}, updated_at=now() WHERE id=#{id} AND deleted_at IS NULL")
    int update(FieldMapping mapping);

    /** 软删:被"导入中"任务引用的映射不删(应用层校验,§8.3) */
    @Update("UPDATE tb_field_mapping SET deleted_at=now(), updated_at=now() " +
            "WHERE id=#{id} AND deleted_at IS NULL")
    int deleteSoft(@Param("id") Long id);
}

/* ============ controller/FieldMappingController.java ============ */
/**
 * 映射管理的 REST 接口(映射管理页调用)。
 * 只做三件事:类别枚举校验、必填校验、唯一性校验——纯配置 CRUD,不涉及引擎逻辑。
 */
@RestController
@RequestMapping("/api/v1/field-mappings")
public class FieldMappingController {

    private static final Set<String> CATEGORIES = Set.of("A", "B"); // 按项目枚举扩展

    private final FieldMappingMapper mapper;

    public FieldMappingController(FieldMappingMapper mapper) { this.mapper = mapper; }

    /** 按类别取整套映射行(不分页:一个模板几十列,全量返回) */
    @GetMapping
    public Result<List<FieldMapping>> list(@RequestParam String category) {
        requireCategory(category);
        return Result.ok(mapper.listByCategory(category));
    }

    /** 新增:类别合法 + 源列名/目标字段必填 + 同类别源列名唯一 */
    @PostMapping
    public Result<FieldMapping> create(@RequestBody FieldMapping m) {
        requireCategory(m.getCategory());
        if (isBlank(m.getSourceHeader()) || isBlank(m.getTargetField())) {
            throw new BizException(ErrorCode.PARAM_ILLEGAL);
        }
        if (mapper.countByHeader(m.getCategory(), m.getSourceHeader().trim()) > 0) {
            throw new BizException(ErrorCode.BIZ_RECORD_DUPLICATED); // 同类别源列名唯一
        }
        m.setSourceHeader(m.getSourceHeader().trim());
        m.setTargetField(m.getTargetField().trim());
        mapper.insert(m);
        return Result.ok(m);
    }

    /** 编辑:同新增校验(category 不参与更新,见 Mapper) */
    @PutMapping("/{id}")
    public Result<Void> update(@PathVariable Long id, @RequestBody FieldMapping m) {
        if (isBlank(m.getSourceHeader()) || isBlank(m.getTargetField())) {
            throw new BizException(ErrorCode.PARAM_ILLEGAL);
        }
        m.setId(id);
        mapper.update(m);
        return Result.ok();
    }

    /** 删除:软删(配置可追溯) */
    @DeleteMapping("/{id}")
    public Result<Void> delete(@PathVariable Long id) {
        mapper.deleteSoft(id);
        return Result.ok();
    }

    private void requireCategory(String c) {
        if (c == null || !CATEGORIES.contains(c)) throw new BizException(ErrorCode.PARAM_ILLEGAL);
    }

    private static boolean isBlank(String v) { return v == null || v.isBlank(); }
}

10.2 RowSource 抽象与实现(xlsx / xls / csv)

功能说明:引擎的输入端——把三种物理文件格式抹平成统一的"表头 + 单元格流"。Cell 是最小数据单元(带物理类型标签,类型转换的依据);RowSource 是解析器 SPI:新增格式(JSON/XML/OCR 结果)只需新实现一个 RowSource,FieldRouter/TypeConverter 零改动。ExcelRowSource 负责物理类型识别(含日期型数值、公式缓存值);CsvRowSource 负责字符集与分隔符探测。两者都被 RowSource.open() 工厂按扩展名分发。

/* ============ engine/Cell.java:单元格统一表示(物理类型 + 原始值) ============ */
/**
 * 一个单元格的统一表示。物理类型(Kind)是 TypeConverter 分派的依据:
 * TEXT/NUMERIC/BOOLEAN 参与转换矩阵;BLANK 统一转 null;ERROR 直接记行错误。
 * 注意:这里只装"原始值",不做任何业务转换(转换口径全部集中在 TypeConverter)。
 */
public final class Cell {
    public enum Kind { TEXT, NUMERIC, BOOLEAN, BLANK, ERROR }

    private final Kind kind;
    private final String text;     // TEXT / ERROR 原文
    private final Double num;      // NUMERIC
    private final Boolean bool;    // BOOLEAN

    private Cell(Kind kind, String text, Double num, Boolean bool) {
        this.kind = kind; this.text = text; this.num = num; this.bool = bool;
    }
    public static Cell ofText(String s)   { return new Cell(s == null || s.isBlank() ? Kind.BLANK : Kind.TEXT, s, null, null); }
    public static Cell ofNumeric(double d){ return new Cell(Kind.NUMERIC, null, d, null); }
    public static Cell ofBool(boolean b)  { return new Cell(Kind.BOOLEAN, null, null, b); }
    public static Cell blank()            { return new Cell(Kind.BLANK, null, null, null); }
    public static Cell ofError(String raw){ return new Cell(Kind.ERROR, raw, null, null); }

    public Kind getKind() { return kind; }
    public String getText() { return text; }
    public Double getNum() { return num; }
    public Boolean getBool() { return bool; }
}

/* ============ engine/RowSource.java:表头 + 数据行流(格式无关) ============ */
/**
 * 解析器 SPI:任何文件格式实现"读出表头 + 逐行产出 Cell[]"即可接入引擎。
 * ImportEngine 只依赖本接口,不感知 xlsx/csv 之分(§九:JSON/XML 接入仅加实现,不改引擎)。
 */
public interface RowSource extends Closeable {
    /** 表头行(首行)原文列表 */
    List<String> headers();

    /** 逐行产出数据单元格(列数与表头对齐;无更多行返回 null) */
    Cell[] nextRow();

    /** 工厂入口:按扩展名分发到具体实现;不支持的格式在此明确报错(§5.1) */
    static RowSource open(Path file) throws IOException {
        String name = file.getFileName().toString().toLowerCase();
        if (name.endsWith(".xlsx") || name.endsWith(".xls")) {
            return new ExcelRowSource(file);
        }
        if (name.endsWith(".csv")) {
            return new CsvRowSource(file);
        }
        throw new BizException(ErrorCode.PARAM_ILLEGAL); // 扫描件/加密文件等不支持格式
    }
}

/* ============ engine/ExcelRowSource.java ============ */
/**
 * Excel(.xlsx/.xls)解析:POI 统一用 Workbook 接口抹平两代格式。
 * 关键点:① 日期型数值在物理层就识别出来(转 ISO 文本带出,最终转换仍走 TypeConverter 统一口径);
 *        ② 公式单元格取缓存结果值(不重算),按结果类型重新装 Cell;
 *        ③ DataFormatter 按显示原文读数值(防长编号精度丢失的第一道防线);
 *        ④ 大小/行数上限在入口校验(§5.2)。
 */
public class ExcelRowSource implements RowSource {
    /** 上限:单文件 20MB / 10 万行(约定 §5.2) */
    public static final long MAX_BYTES = 20L * 1024 * 1024;
    public static final int MAX_ROWS = 100_000;

    private final Workbook workbook;
    private final Sheet sheet;
    private final DataFormatter formatter = new DataFormatter(); // 数值按显示原文读,防长编号精度丢失的第一道防线
    private final List<String> headers;
    private int cursor = 1; // 数据从第二行开始(首行是表头)

    public ExcelRowSource(Path file) throws IOException {
        if (Files.size(file) > MAX_BYTES) {
            throw new BizException(ErrorCode.PARAM_ILLEGAL); // 文件超限
        }
        this.workbook = WorkbookFactory.create(Files.newInputStream(file));
        this.sheet = firstNonEmptySheet();
        this.headers = readRow(sheet.getRow(0));
    }

    /** 多 sheet 取首个非空 sheet(选择记录进导入日志,不静默) */
    private Sheet firstNonEmptySheet() {
        for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
            Sheet s = workbook.getSheetAt(i);
            if (s.getRow(0) != null && s.getPhysicalNumberOfRows() > 1) return s; // 首个非空 sheet
        }
        throw new BizException(ErrorCode.PARAM_ILLEGAL); // 空文件
    }

    /** 表头行按显示原文读(映射 sourceHeader 录入的就是显示原文,保证两侧同源) */
    private List<String> readRow(Row row) {
        List<String> out = new ArrayList<>();
        if (row == null) return out;
        for (int c = 0; c < row.getLastCellNum(); c++) {
            out.add(formatter.formatCellValue(row.getCell(c)).trim());
        }
        return out;
    }

    @Override public List<String> headers() { return headers; }

    /** 逐行产出 Cell[]:按 POI 单元格类型分派到 Cell 工厂 */
    @Override public Cell[] nextRow() {
        Row row = sheet.getRow(cursor++);
        if (row == null) return null;                    // 文件结束
        if (cursor - 1 > MAX_ROWS) throw new BizException(ErrorCode.PARAM_ILLEGAL); // 行数超限
        Cell[] cells = new Cell[headers.size()];
        for (int c = 0; c < cells.length; c++) {
            org.apache.poi.ss.usermodel.Cell sc = row.getCell(c);
            if (sc == null) { cells[c] = Cell.blank(); continue; }
            cells[c] = switch (sc.getCellType()) {
                case STRING  -> Cell.ofText(sc.getStringCellValue());
                case NUMERIC -> DateUtil.isCellDateFormatted(sc)
                        ? Cell.ofText(sc.getLocalDateTimeCellValue().toLocalDate().toString()) // 日期型数值按 ISO 文本带出,最终转换仍走 §六
                        : Cell.ofNumeric(sc.getNumericCellValue());
                case BOOLEAN -> Cell.ofBool(sc.getBooleanCellValue());
                case FORMULA -> switch (sc.getCachedFormulaResultType()) { // 公式取缓存结果,按物理类型重装
                    case STRING  -> Cell.ofText(sc.getStringCellValue());
                    case NUMERIC -> Cell.ofNumeric(sc.getNumericCellValue());
                    case BOOLEAN -> Cell.ofBool(sc.getBooleanCellValue());
                    case BLANK   -> Cell.blank();
                    default      -> Cell.ofError("formula:" + sc.getCellFormula());
                };
                case ERROR   -> Cell.ofError("error:" + sc.getErrorCellValue());
                case _NONE   -> Cell.blank();
            };
        }
        return cells;
    }

    @Override public void close() throws IOException { workbook.close(); }
}

/* ============ engine/CsvRowSource.java ============ */
/**
 * CSV 解析:CSV 一切皆文本(Cell 全为 TEXT,类型转换完全交给 TypeConverter)。
 * 两个探测点:① 字符集 UTF-8/GBK(Windows 导出常为 GBK,用替换符嗅探回退);
 *            ② 分隔符逗号/制表符(取首行出现多者)。
 */
public class CsvRowSource implements RowSource {
    private final CSVParser parser;
    private final Iterator<CSVRecord> it;
    private final List<String> headers;

    public CsvRowSource(Path file) throws IOException {
        this.parser = CSVFormat.DEFAULT.builder()
                .setDelimiter(detectDelimiter(file)).build()
                .parse(openReader(file)); // openReader 内做 BOM 检查 + UTF-8/GBK 字符集探测
        this.it = parser.iterator();
        this.headers = it.hasNext()
                ? StreamSupport.stream(it.next().spliterator(), false).map(String::trim).toList()
                : List.of();
    }

    /** 分隔符探测:首行中逗号与制表符谁出现得多 */
    private static char detectDelimiter(Path file) throws IOException {
        String first = Files.readString(file, StandardCharsets.ISO_8859_1).lines().findFirst().orElse("");
        return first.indexOf('\t') >= 0 && first.indexOf('\t') > first.indexOf(',') ? '\t' : ',';
    }

    /** 字符集探测:带 BOM 按 UTF-8;否则先按 UTF-8 读,出现替换符(￿)回退 GBK */
    private static Reader openReader(Path file) throws IOException {
        byte[] head = new byte[3];
        try (var in = Files.newInputStream(file)) { in.readNBytes(head, 0, 3); }
        boolean bom = (head[0] & 0xFF) == 0xEF && (head[1] & 0xFF) == 0xBB && (head[2] & 0xFF) == 0xBF;
        Charset cs = Charset.forName(bom ? "UTF-8" : guessCharset(file));
        return new InputStreamReader(Files.newInputStream(file), cs);
    }

    private static String guessCharset(Path file) throws IOException {
        String s = Files.readString(file, StandardCharsets.UTF_8);
        return s.indexOf('\uFFFD') >= 0 ? "GBK" : "UTF-8";
    }

    @Override public List<String> headers() { return headers; }

    /** CSV 无物理数值:全按 TEXT 装载("123456.0" 这类值由 TypeConverter 归一) */
    @Override public Cell[] nextRow() {
        if (!it.hasNext()) return null;
        CSVRecord r = it.next();
        Cell[] cells = new Cell[headers.size()];
        for (int c = 0; c < cells.length; c++) {
            cells[c] = c < r.size() ? Cell.ofText(r.get(c).trim()) : Cell.blank();
        }
        return cells;
    }

    @Override public void close() throws IOException { parser.close(); }
}

10.3 FieldRouter(表头对齐 + 路由表)

功能说明:纯算法类(无状态、无依赖,可独立单测)——输入文件表头 + 映射行,输出对齐结果。这是"配置怎么变成路由"的核心:先建文件侧规范化索引,再逐条映射查命中,最后反向清点未知列。normalize 的全角/空格处理是兜底匹配的关键,两侧(文件与映射)都必须过同一套规范化,才能对得上。

/* ============ engine/FieldRouter.java ============ */
/**
 * §四 的实现:精确匹配为主、规范化兜底。
 * 输入:文件表头(RowSource 来)+ 映射行(配置层来);输出:路由表 + 缺列/未知列清单。
 * 路由表 key 是"列号"——后续逐行取值只查列号,不再碰表头字符串(性能与确定性)。
 */
public final class FieldRouter {

    /** 对齐结果:route = 列号 → 目标字段;missing = 缺列;ignored = 未知列;hitHeaders = 命中的文件原文(日志展示用) */
    public record AlignResult(Map<Integer, String> route, List<String> missing,
                              List<String> ignored, Map<Integer, String> hitHeaders) {}

    /** 对齐主流程:先精确(规范化包含原样),再逐映射查命中;缺列/未知列按 §4-② 三分类 */
    public static AlignResult align(List<String> fileHeaders, List<FieldMapping> mappings) {
        // 文件侧规范化索引:normalized(header) → 列号(同头取首个,重复表头以首列为准)
        Map<String, Integer> byNorm = new HashMap<>();
        for (int c = 0; c < fileHeaders.size(); c++) {
            byNorm.putIfAbsent(normalize(fileHeaders.get(c)), c);
        }

        Map<Integer, String> route = new LinkedHashMap<>();
        Map<Integer, String> hitHeaders = new LinkedHashMap<>();
        List<String> missing = new ArrayList<>();
        Set<Integer> hit = new HashSet<>();

        for (FieldMapping m : mappings) {
            Integer col = byNorm.get(normalize(m.getSourceHeader())); // 两侧过同一套规范化
            if (col == null) { missing.add(m.getSourceHeader()); continue; } // 映射有、文件没有 → 缺列
            route.put(col, m.getTargetField());
            hitHeaders.put(col, fileHeaders.get(col));
            hit.add(col);
        }

        // 反向清点:文件有、映射没有 → 未知列(默认忽略,记日志)
        List<String> ignored = new ArrayList<>();
        for (int c = 0; c < fileHeaders.size(); c++) {
            if (!hit.contains(c) && !fileHeaders.get(c).isBlank()) ignored.add(fileHeaders.get(c));
        }
        return new AlignResult(route, missing, ignored, hitHeaders);
    }

    /** 规范化:trim → 全角转半角 → 多空格合一 → 转小写(文件侧与映射侧共用,保证对得上) */
    static String normalize(String s) {
        if (s == null) return "";
        StringBuilder sb = new StringBuilder(s.trim().length());
        boolean lastSpace = false;
        for (char ch : s.trim().toCharArray()) {
            char c = toHalfWidth(ch);
            if (Character.isWhitespace(c)) {
                if (lastSpace) continue;
                lastSpace = true; c = ' ';
            } else {
                lastSpace = false;
            }
            sb.append(Character.toLowerCase(c));
        }
        return sb.toString();
    }

    private static char toHalfWidth(char c) {
        if (c == '\u3000') return ' ';                    // 全角空格
        if (c >= '\uFF01' && c <= '\uFF5E') return (char) (c - 0xFEE0); // 全角 ASCII 区
        return c;
    }
}

10.4 TypeConverter(转换矩阵唯一实现)

功能说明:纯算法类——§6.3 转换矩阵的唯一实现。所有单元格到目标类型的转换必须走这里(禁止在各处散落 ad-hoc 转换),保证口径一致。Converted 把"成功值"与"错误原因"合一,由 ImportEngine 收集行错误(成功/失败一起表达,调用方不用 try-catch)。期间类(YYYYMM)在 TEXT 转换后追加 normalizePeriod 归一。

/* ============ engine/TypeConverter.java ============ */
/**
 * §6.3 转换矩阵的唯一实现。目标类型四类(TEXT/AMOUNT/DATE/INT)来自 §6.2:
 * 由字段白名单/库列类型登记(现阶段),演进为映射表 target_type 列(§6.2-③)。
 */
public final class TypeConverter {

    public enum TargetType { TEXT, AMOUNT, DATE, INT }

    /** 单元格转换结果:value = 已转换值(直接可入 JDBC);error = 行错误原因(二选一) */
    public record Converted(Object value, String error) {
        public static Converted ok(Object v) { return new Converted(v, null); }
        public static Converted err(String why) { return new Converted(null, why); }
    }

    private static final DateTimeFormatter[] DATE_PATTERNS = {
            DateTimeFormatter.ofPattern("yyyy-MM-dd"), DateTimeFormatter.ofPattern("yyyy/M/d"),
            DateTimeFormatter.ofPattern("yyyyMMdd"),   DateTimeFormatter.ofPattern("yyyy年M月d日") };
    private static final Pattern PERIOD_YM = Pattern.compile("\\d{6}"); // 期间类 YYYYMM

    /** 矩阵入口:按物理类型分派(BLANK/ERROR 在此统一处理,调用方无感知) */
    public static Converted convert(Cell cell, TargetType target) {
        return switch (cell.getKind()) {
            case BLANK -> Converted.ok(null);
            case ERROR -> Converted.err("单元格错误: " + cell.getText());
            case BOOLEAN -> convertBoolean(cell.getBool(), target);
            case TEXT    -> convertText(cell.getText(), target);
            case NUMERIC -> convertNumeric(cell.getNum(), target);
        };
    }

    /* 文本来源:trim 后按目标转换;金额/日期给多格式容错 */
    private static Converted convertText(String raw, TargetType t) {
        String s = raw.trim();
        return switch (t) {
            case TEXT -> Converted.ok(s);
            case AMOUNT -> toAmount(s);
            case DATE -> toDate(s);
            case INT -> {
                try { yield Converted.ok(Long.parseLong(s.replace(",", ""))); }
                catch (NumberFormatException e) { yield Converted.err("非整数: " + raw); }
            }
        };
    }

    /* 数值来源:TEXT 走防科学计数法(铁律①);AMOUNT 用 BigDecimal.valueOf(铁律②) */
    private static Converted convertNumeric(double d, TargetType t) {
        return switch (t) {
            // 文本目标:防科学计数法(整数去 .0;其余 toPlainString)
            case TEXT -> Converted.ok(BigDecimal.valueOf(d).stripTrailingZeros().toPlainString());
            case AMOUNT -> Converted.ok(BigDecimal.valueOf(d));
            case DATE -> {
                try { yield Converted.ok(readExcelDate(d)); }
                catch (Exception e) { yield Converted.err("非日期数值: " + d); }
            }
            case INT -> Converted.ok(Math.round(d));
        };
    }

    /* 布尔来源:仅文本/整数可用,其余明确报错(不给模糊默认值) */
    private static Converted convertBoolean(Boolean b, TargetType t) {
        return switch (t) {
            case TEXT -> Converted.ok(String.valueOf(b));
            case INT -> Converted.ok(Boolean.TRUE.equals(b) ? 1L : 0L);
            default -> Converted.err("布尔值不支持转为 " + t);
        };
    }

    /* ---- 具体规则 ---- */

    /** 金额文本:去货币符/千分位/空格 → BigDecimal;空串视为 null */
    private static Converted toAmount(String s) {
        String v = s.replace("¥", "").replace("¥", "").replace(",", "").replace(" ", "");
        if (v.isEmpty()) return Converted.ok(null);
        try { return Converted.ok(new BigDecimal(v)); }
        catch (NumberFormatException e) { return Converted.err("非金额: " + s); }
    }

    /** 日期文本:多格式依次尝试(口径见 §6.3),全不中 → 行错误 */
    private static Converted toDate(String s) {
        for (DateTimeFormatter p : DATE_PATTERNS) {
            try { return Converted.ok(LocalDate.parse(s, p)); } catch (DateTimeParseException ignored) { }
        }
        return Converted.err("非日期: " + s);
    }

    /** Excel 日期序列号(1900 系统)→ LocalDate */
    private static LocalDate readExcelDate(double serial) {
        return LocalDate.of(1899, 12, 30).plusDays((long) Math.floor(serial));
    }

    /** 期间类(YYYYMM)校验 + 归一:六位数字、月份 01-12;数值来源的 202411.0 一并归一(铁律③) */
    public static Converted normalizePeriod(String s) {
        String v = s == null ? "" : s.trim();
        if (v.endsWith(".0")) v = v.substring(0, v.length() - 2); // 数值来源的 202411.0 归一
        if (!PERIOD_YM.matcher(v).matches()) return Converted.err("期间须为 YYYYMM: " + s);
        int month = Integer.parseInt(v.substring(4));
        if (month < 1 || month > 12) return Converted.err("期间月份非法: " + s);
        return Converted.ok(v);
    }
}

10.5 ImportEngine(预览 / 确认 / 落库 / 日志)

功能说明:编排层——把 ①RowSource → ②FieldRouter → ③TypeConverter 串成一条管线,预览与确认共用同一条 route()(保证"预览看到什么,确认就是什么")。自身负责三件编排决策:缺列拒绝、错误占比阈值、日志启停。TargetRowJdbc 用 JdbcTemplate 动态列批量写——因为列集合由映射决定,不能用固定字段的 Mapper;ImportLog 承担追溯账。

/* ============ engine/ImportEngine.java ============ */
/**
 * 导入编排者。preview(全量试算不落库)与 confirm(整单事务落库)共用 route():
 * 同一份文件两次跑同一管线,结果必然一致——这是"预览即所见"的根基。
 */
@Service
@Slf4j
public class ImportEngine {

    /** 错误行占比阈值:超过判定模板不匹配,整单拒绝(§6.4,防"半错半对"入库) */
    private static final double MAX_ERROR_RATIO = 0.30;

    private final FieldMappingMapper mappingMapper; // 配置层唯一入口:只读映射行
    private final ImportLogMapper logMapper;
    private final TargetRowJdbc targetRowJdbc;      // 动态列批量写(列集合由映射决定)
    private final OrgContextProvider orgContext;    // 租户上下文(按项目 OrgContext/租户口径取)

    public ImportEngine(FieldMappingMapper mappingMapper, ImportLogMapper logMapper,
                        TargetRowJdbc targetRowJdbc, OrgContextProvider orgContext) {
        this.mappingMapper = mappingMapper;
        this.logMapper = logMapper;
        this.targetRowJdbc = targetRowJdbc;
        this.orgContext = orgContext;
    }

    /* ---- ①② 预览:路由对齐 + 全量试算,不落库 ---- */
    /**
     * 预览产出四块信息:表头匹配报告(命中/缺列/忽略列)+ 试算统计(总行/错误行)+
     * 典型错误样例(前 50 条)+ 可落库行数。缺列/超限在此不抛异常(预览要能"报告"失败原因)。
     */
    public PreviewResult preview(String category, Path file) throws IOException {
        AlignReport report = route(category, file);
        return new PreviewResult(report.hitCount(), report.missing(), report.ignored(),
                report.totalRows(), report.errorRows(), report.sampleErrors(), report.rows().size() < report.totalRows());
    }

    /* ---- ③ 确认:落库(整单事务;行级错误不入库) ---- */
    /**
     * 确认重跑同一管线(文件未变则结果必然一致),过两道闸门后落库:
     * 闸门一 缺列整单拒绝(预览阶段已报过,这里兜底);闸门二 错误占比超限整单拒绝。
     * 落库顺序:先插日志(拿到 log_id)→ 批量写明细(行携带 log_id 可追溯)→ 日志收尾。
     */
    @Transactional
    public ImportLog confirm(String category, Path file, String originalFilename) throws IOException {
        AlignReport report = route(category, file);
        if (!report.missing().isEmpty()) {
            throw new BizException(ErrorCode.PARAM_ILLEGAL); // 缺列整单拒绝(预览阶段已报过)
        }
        if (report.totalRows() > 0 && (double) report.errorRows() / report.totalRows() > MAX_ERROR_RATIO) {
            throw new BizException(ErrorCode.PARAM_ILLEGAL); // 错误占比超限,整单拒绝
        }

        ImportLog logRec = ImportLog.start(orgContext.orgId(), category, originalFilename,
                report.totalRows(), report.errorRows(), report.errorDetailJson());
        logMapper.insert(logRec);
        targetRowJdbc.batchInsert(category, report.rows(), orgContext.orgId(), logRec.getId());
        logMapper.finish(logRec.getId());
        return logRec;
    }

    /* ---- 路由 + 转换(预览/确认共用管线) ---- */
    /**
     * 管线四步:开文件 → 表头对齐 → 逐行(取值→转换→收集错误) → 汇总。
     * 行级错误:跳过该行继续(§6.4),错误明细截断收集(前 50 条进日志,全量不放大内存)。
     */
    private AlignReport route(String category, Path file) throws IOException {
        List<FieldMapping> mappings = mappingMapper.listByCategory(category); // ← 配置层唯一交互点
        Map<String, String> typeByField = TargetSchema.typeOf(category); // 目标字段 → TargetType(§6.2 白名单/库列类型)

        try (RowSource rs = RowSource.open(file)) {                 // ← ① 物理解析(格式无关)
            AlignResult aligned = FieldRouter.align(rs.headers(), mappings); // ← ② 表头对齐
            if (!aligned.missing().isEmpty()) {
                throw new BizException(ErrorCode.PARAM_ILLEGAL);    // 缺列:返回缺失清单由前端展示
            }

            List<Map<String, Object>> rows = new ArrayList<>();
            List<RowError> errors = new ArrayList<>();
            int total = 0, bad = 0, rowNo = 1;

            for (Cell[] cells; (cells = rs.nextRow()) != null; ) {  // ← ③ 逐行转换
                rowNo++;
                if (allBlank(cells)) continue;                  // 空行跳过
                total++;
                Map<String, Object> row = new LinkedHashMap<>();
                RowError first = null;
                for (var e : aligned.route().entrySet()) {       // 只按路由表取值(列号→字段)
                    String field = e.getValue();
                    Cell cell = e.getKey() < cells.length ? cells[e.getKey()] : Cell.blank();
                    Converted c = TypeConverter.convert(cell, typeByField.getOrDefault(field, TargetType.TEXT));
                    if (c.error() != null) {
                        if (first == null) first = new RowError(rowNo, field, rawText(cell), c.error()); // 行首错即记
                    } else {
                        row.put(field, c.value());
                    }
                }
                if (first != null) { bad++; if (errors.size() < 50) errors.add(first); continue; } // 行级错误跳过
                rows.add(row);
            }
            return new AlignReport(aligned, total, bad, errors, rows);
        }
    }

    private static boolean allBlank(Cell[] cells) {
        for (Cell c : cells) if (c.getKind() != Cell.Kind.BLANK) return false;
        return true;
    }

    private static String rawText(Cell c) {
        return switch (c.getKind()) {
            case NUMERIC -> String.valueOf(c.getNum());
            case BOOLEAN -> String.valueOf(c.getBool());
            case TEXT, ERROR, BLANK -> c.getText();
        };
    }

    /* 汇总对象(record 略):AlignReport{route结果,totalRows,errorRows,sampleErrors,rows} */
}
/* ============ 10.5.1 TargetRowJdbc:目标明细表批量写 ============ */
/**
 * 为什么不用 MyBatis Mapper:目标明细表的列集合由映射的 target_field 决定(运行期才知道),
 * 固定字段的 Mapper 表达不了。JdbcTemplate 动态拼列 + 批量(500 行/批)是这类"列可配置"场景的标配。
 * 安全性:列名全部来自映射配置(target_field 建列时已对齐),值全部走 setObject 参数绑定,无注入面。
 */
@Component
public class TargetRowJdbc {

    private final JdbcTemplate jdbc;

    public TargetRowJdbc(JdbcTemplate jdbc) { this.jdbc = jdbc; }

    /**
     * 批量插入目标明细表。列集合 = 该批行携带的 target_field + 固定列
     * (org_id / category / import_log_id;created_at/updated_at 由库默认)。
     */
    public void batchInsert(String category, List<Map<String, Object>> rows,
                            Long orgId, Long importLogId) {
        if (rows.isEmpty()) return;
        List<String> fields = new ArrayList<>(rows.get(0).keySet());
        String cols = String.join(", ", fields) + ", org_id, category, import_log_id";
        String marks = String.join(", ", java.util.Collections.nCopies(fields.size() + 3, "?"));
        String sql = "INSERT INTO tb_import_row (" + cols + ") VALUES (" + marks + ")";

        jdbc.batchUpdate(sql, rows, 500, (ps, row) -> {
            int i = 1;
            for (String f : fields) ps.setObject(i++, row.get(f)); // 已转换值:TEXT/BigDecimal/LocalDate/Long
            ps.setLong(i++, orgId);
            ps.setString(i++, category);
            ps.setLong(i, importLogId);
        });
    }
}

/* ============ 10.5.2 ImportLog:导入日志(谁、何时、哪个文件、成败与错误明细) ============ */
/**
 * 追溯账:一次 confirm 一条日志。start 产出待落库对象(insert 后得 log_id,明细行携带它建立关联),
 * finish 记收尾时间——start/finish 分离让"导入耗时"可观测。
 */
@Data
public class ImportLog {
    private Long id;
    private Long orgId;
    private String category;
    private String sourceFile;
    private Integer totalRows;
    private Integer errorRows;
    private String errorDetailJson; // 错误明细(行号/列/原值/原因),超长截断
    private LocalDateTime startedAt;
    private LocalDateTime finishedAt;

    public static ImportLog start(Long orgId, String category, String file, int total, int errors, String detail) {
        ImportLog l = new ImportLog();
        l.orgId = orgId; l.category = category; l.sourceFile = file;
        l.totalRows = total; l.errorRows = errors; l.errorDetailJson = detail;
        l.startedAt = LocalDateTime.now();
        return l;
    }
}

落位提示:① 目标明细表 tb_import_row 的列 = 各类别映射的 target_field(建表与映射对齐,§8.3)+ 固定列;② TargetSchema.typeOf(category) 即 §6.2 的字段→目标类型登记,演进为映射表 target_type 列后改由配置驱动;③ 防重(同类别+期间+文件指纹)在 confirm 前按项目口径补查询;④ 长编号列的源头约定(文本格式存储)写进入参校验与文档。


变更记录

日期 变更内容 作者
2026-10-18 v1.2 §十补注:新增 10.0 结构总览(分层图/调用时序/职责表)+ 各代码块功能说明与类间关系注释(依赖方向、扩展点、设计理由) zhoubo
2026-10-18 v1.1 新增 §十 参考实现:映射 CRUD、RowSource(Excel/CSV)、FieldRouter、TypeConverter、ImportEngine(预览/确认/日志/批量写),含长编号精度与错误上限口径 zhoubo
2026-10-18 v1.0 新建(通用模式):目的、数据流、映射配置模型、表头对齐原理、数据源格式支持、类型转换矩阵、错误处理口径、引擎设计 zhoubo
分享此文

评论 (0)

还没有评论,来抢沙发。

发表评论