导入失败很少是代码写错了,多半是契约没写。
一、打不开的十四列 CSV
2026 年 9 月,陆青禾收到客户发来的 export.csv。双击打开是一屏乱码;用文本编辑器看,第一行最前面有一个看不见的 BOM,第三列表头写着「负责人(邮箱/姓名)」,每一格是「张三 zhangsan@example.com
」这种格式。
以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。
他先用 Excel 打开,中文全是问号;换成文本编辑器才看出文件是 GBK 编码。客户说「我们系统导出来就这样」,语气里没有抱歉,因为在他的认知里,这就是标准导出。
第三列更麻烦:邮箱和姓名挤在一格,有的人只有姓名,有的人只有邮箱,还有 7 行整格为空。任务板的负责人是必填字段,这 7 行无论怎么解析都进不去。
他写了个按逗号切分的脚本,第二行就切错了:一个任务标题里带逗号,外面包着引号,简单 split(',') 把一行切成五段,后面的列全部错位。
改用 CSV 解析库之后,引号和换行的问题解决了,但新的问题冒出来:客户前后发过 3 个版本,其中 2 个表头不一致,一个列序不同,一个多了一列。脚本按位置取值,第二个文件把「优先级」读成了「截止日期」。
那天下午他没有继续写代码,而是把三份文件并排放在一起,开始倒推:一份合格的文件应该长什么样。编码、表头、必填列、类型、重复,这五件事必须在文件到达之前说清楚。
他把这份说明命名为「导入契约」,附在迁移确认邮件后面,要求客户导出时对照检查。第一版只有半页,后来每遇到一个新问题就补一条。
契约不是给工程师看的接口文档,而是给客户看的交件标准。
二、十四列与十一个字段的归属
客户的源文件有 14 列,任务板的目标表有 11 个字段。两边不是一一对应,先把每一列的归属列清楚,后面的校验才有依据。
| 源列(14) | 目标字段(11) | 处理方式 |
|---|---|---|
| task_id | task_id | 直接 |
| title | title | 直接 |
| description | description | 直接 |
| status | status | 直接,值映射在下一层 |
| priority | priority | 直接 |
| due_date | due_at | 直接,需解析日期 |
| created_at | created_at | 直接 |
| updated_at | updated_at | 直接 |
| project_name | project_id | 直接,按名称查项目 |
| assignee_email | assignee_id | 合并 |
| assignee_name | assignee_id | 合并 |
| notes | — | 丢弃,内部备注 |
| legacy_flag | — | 丢弃,旧系统标记 |
| owner_dept | — | 丢弃,导入不需要部门 |
9 个直接列按名称或简单查表落到 9 个目标字段;assignee_email 与 assignee_name 合成 1 个 assignee_id;notes、legacy_flag、owner_dept 三列不进入目标表,成功行不留存原值,只有失败行才在清单里带上。第一节那个把邮箱和姓名挤在一格的第三列,在契约里被拆成两列,合并格式不再接受,因为拆分规则会随客户系统版本变化。
第 11 个字段是生成的:external_id 由来源标识和 task_id 一起做哈希,写进唯一索引。它不来自任何一列,却决定了同一份文件重跑时会不会重复插入。
合并列是最容易出错的地方。assignee_email 单独看是唯一标识,assignee_name 单独看会重名;先按邮箱匹配账号,匹配不到再用姓名,姓名也没有命中就建占位账号并标记待认领。这条规则要写进契约,不能只留在代码里。
丢弃列要写明「为什么不导入」。客户看到 owner_dept 没进来会问,提前写一句「部门信息不参与任务归属,导入后不保留」比事后解释省事,也符合最小化收集的口径。
表格之外还有一条:列名大小写敏感。Task_ID 和 task_id 在客户的文件里都出现过,前者被当成未知列,整份文件拒绝。
三、五条契约与三级拒绝
契约一共五条,每条都要有硬要求和拒绝方式。只写「请使用 UTF-8」不够,因为客户并不知道自己的文件是什么编码。
| 条款 | 硬要求 | 失败样例 | 拒绝方式 |
|---|---|---|---|
| 编码 | UTF-8,允许 BOM 但解析前剥离 | GBK 文件打开是乱码;BOM 未剥离时表头匹配失败 | 整文件拒绝 |
| 表头 | 第一行 14 个列名,顺序与大小写固定 | 列序不同、多 1 列、Task_ID 改名 | 整文件拒绝 |
| 必填 | task_id、title、status 三列不可为空 | 7 行缺必填,整格为空或只有空格 | 逐行拒绝 |
| 类型 | 枚举与日期按白名单解析 | priority 写成「高」;日期写成「下周三」 | 逐行拒绝 |
| 幂等 | task_id 来源内唯一,external_id 稳定 | 同一文件重跑后行数翻倍 | 重复行跳过并记账 |
五条里最容易妥协的是表头。客户说「就多一列,你忽略掉不就行了」,但忽略一列意味着解析器要猜;这次能猜对,下次列序变了就猜错。表头不符的正确处理是整文件拒绝,并把期望的 14 个列名原样返回。
拒绝分三级:整文件拒绝用于编码和表头,一行都不写;逐行拒绝用于必填缺失和类型错误,好行照常入库,坏行进失败清单;警告放行用于可选列为空,只记录不阻断。
失败清单至少四列:行号、列名、原值、原因。行号用文件里的物理行号,不是解析后的序号,客户才能在自己的 Excel 里定位。原值要原样保留,不要转义成 [NULL] 之类看不懂的标记。
必填三列选 task_id、title、status 的理由是:没有 task_id 无法幂等,没有 title 任务没有名字,没有 status 看板不知道放哪一列。其他列都可以为空,包括负责人。
类型与单位要写成白名单。priority 只接受 P0 到 P3;due_date 接受 ISO 8601、DD/MM/YYYY、Excel 序列号和 Unix 秒四种格式,其余一律进失败清单。自然语言日期不在契约里,因为解析结果不可复现。
幂等键单独一条,因为它跨越多次导入。同一份文件里 task_id 重复出现就是重复行;external_id 用来源加 task_id 哈希,保证跨文件也稳定。没有这一条,客户重跑一次导出就是灾难。
四、17,360 格与 16.8% 的空值
把客户的矩阵摊开:1,240 行 × 14 列 = 17,360 格。空值 2,914 格,占 16.8%。只看这个比例,容易以为文件质量很差,但空值集中在 3 个丢弃列和可选的描述列,真正阻断导入的必填缺失只有 7 行,占 0.56%。
这组数字说明「导入失败」要分层:文件级失败一条就拒绝整份;行级失败只影响那一行;单元格级空值大多数不是错误。三层混在一起统计,就会得出「16.8% 的数据有问题」这种没法行动的结论。
表头不符的有 2 个文件:一个列序不同,一个多 1 列。修复方式不是让解析器兼容,而是把标准表头发回去让客户重导;修复后 14/14 列可读。这 2 个文件如果靠位置解析放过去,错列会静默写进数据库,比拒绝难查得多。
契约和 schema 是两件事。schema 描述数据库内部的列名、类型、约束和索引;契约描述客户交来的文件长什么样。把 assignee_id 这种内部字段名写进给客户的模板,等于让客户替你维护外键;客户给不出,就会在模板里乱填。
下一篇要处理的是值层面的映射:7 种状态怎么并成 4 种、4 种日期格式怎么识别、负责人姓名匹配不上怎么办。那是契约之后的第二道关;契约只保证文件能读进来,不保证值有业务含义,详见字段映射与类型:七种状态、四种日期与十二行失败 。
复算:
源 14 列 = 直接 9 + 合并 2 + 丢弃 3
目标 11 字段 = 直接 9 + 合并 1 + 生成 1(external_id)
矩阵 1,240 × 14 = 17,360 格;空值 2,914 = 16.8%
必填 3 列缺失 7 行 = 7 ÷ 1,240 = 0.56%
表头不符 2 个文件(列序不同、多 1 列);修复后 14/14 列可读
17,360 格里 16.8% 是空值,但空值不等于错误;真正需要客户重导的是 2 个表头文件,真正需要逐行处理的是 7 行必填缺失。把这三个数字分开,才能决定哪一步该自动化、哪一步该找人。
本篇只讨论文件进入解析器之前的那一层。数据库 schema 怎么改归架构组的数据模型与迁移:三十四张表与一次只能动一处的改动 ;法定导出与删除归隐私组;备份与恢复归资产组。三件事都不在导入契约里。
五、行动清单与复算验收
- 收到第一个文件之前,先写一页导入契约,列出 14 列与 11 个目标字段。
- 用文本编辑器确认编码,剥离 BOM,非 UTF-8 文件先转码再解析。
- 固定表头 14 列的名称与顺序,缺列、多列、改名一律整文件拒绝。
- 标出 3 个必填列,缺失行进入失败清单,不静默补空。
- 为每个枚举与日期列写明允许的值和格式,不接受自然语言。
- 用来源加
task_id生成external_id,并在数据库建唯一索引。 - 失败清单至少包含行号、列名、原值、原因四列,且可下载。
- 拿同一份文件重跑一次,确认新增为 0 行。
验收标准:14 列全部有明确归属,11 个目标字段全部有来源,3 个必填列缺失的行全部进入可下载失败清单,且同一文件重跑新增 0 行。
八条里最容易被跳过的是第一条和第七条。没有契约,解析器只能拿到什么猜什么;没有可下载的失败清单,客户只能反复发邮件问「到底哪一行错了」。
契约写完那天,他把半页说明放进迁移确认邮件,附上一个只有表头的 template.csv。之后再收到的文件,乱码少了,表头对了,他的下午也回来了。
使用边界与实操验收
契约不是 schema。 契约描述客户交来的文件,schema 描述数据库内部结构;把内部字段名、外键和索引暴露给客户,只会让模板变成第二个需要维护的接口。
契约不替客户猜业务语义。 archived 是否等于 done、空白负责人是否等于未分配,这些要问清楚再写进映射;猜错一次,后面每一条数据都跟着错。
编码问题不能靠 try/except 吞掉。 解码失败就整文件拒绝并提示转码,不要逐行跳过;跳过的那几行往往正是客户最在意的记录。
必填列的判断要写进契约。 写在代码里,客户看不到;写在契约里,客户导出时就能自查,失败清单也会少一大半。
本篇不做什么。 字段值映射归本组第三篇,数据库 schema 迁移归架构组,法定导出与删除归隐私组,备份与恢复归资产组;四者边界不要混。
关键参考与核验范围
- RFC 4180 :用于核对 CSV 的引号、逗号与换行转义规则。
- W3C CSV on the Web 入门 :用于核对表头与数据行的模型说明。
- Unicode TR15 :用于核对字符规范化与 BOM 相关处理。
- Stripe 幂等请求 :用于核对幂等键的设计与重试语义。
- Jira CSV 导入 :用于核对商用产品如何描述必填列与映射步骤。
以上链接于 2026-10-26 实测可公开访问;未核验版本、价格与配额。核验日期:2026-10-26。
上述文档只用于核对 CSV 引号规则、表头模型、字符规范化与幂等设计;14 列、11 个字段、17,360 格与全部比例均取自本组教学基线,与文档内容无对应关系。
系列导航
- 回到本组目录:一个人的数据搬家:导入、去重、迁移与退出
- 上一篇:迁移是一本账:四十八次咨询、二十三次完成与四笔成本
- 下一篇:字段映射与类型:七种状态、四种日期与十二行失败
- 相关:数据模型与迁移:三十四张表与一次只能动一处的改动
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。