状态:通用模式定稿 + 参考实现(配置链路已有参考实现落地;导入引擎按本设计与 §十 代码实现)
定位:描述"外部数据文件 → 结构化落库"这一类需求的通用模式:字段映射如何建模、表头如何对齐、单元格数据类型如何转换、错误如何处理。适用于一切"以文件为载体、列结构由外部决定、且会变化"的导入场景。
角色约定:
源列名= 文件表头原文;目标字段= 目标表列名;映射配置表= 存储两者对应关系的配置表;目标明细表= 数据最终落入的业务表。代码口径:§十 为 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 | 数量、时长 |
字段→目标类型的登记方式(演进三步):
- 现阶段:转换器内置字段白名单——已知按文本处理的字段(长编号、期间、单号等)强制文本;金额/日期字段按 §6.3 矩阵默认转换;
- 过渡:映射行
remark里的人工提示("按文本解析")作为复核依据; - 结构化:映射配置表增加
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 流程与边界规则
- 上传:multipart 到导入接口;仅暂存与解析,不落业务表。
- 预览:返回表头匹配报告(命中 N 列 / 缺列 / 忽略列)+ 全量试算结果(成功/失败行数 + 典型错误样例);缺列即失败,用户回映射页补配置后重传。
- 确认:批量落目标明细表(核心列 = 映射的 target_field +
org_id+category+source_file+import_log_id+ 审计时间列);行级错误跳过不入库,明细进日志。 - 防重:同类别 + 业务期间 + 来源文件指纹的重复导入给出明确提示(覆盖 or 拒绝,交互确认)。
- 事务口径:整单一个事务;行级错误不回滚已成功行(错误行不入库),以日志为准。
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)
还没有评论,来抢沙发。