库存调整 CSV 看起来有三列,导入后却只剩两个字段;另一份文件的 sku 明明存在,程序却报 KeyError。这类问题先查文件边界,别急着给字典取值加默认值。默认值会把格式错误变成库存错误。

本文约定一个具体接口:上传 UTF-8 的 CSV,列为 sku,quantity,note,SKU 只能包含大写字母、数字和连字符,数量为 0 到 1000000 的整数,备注允许引号内换行。允许 UTF-8 BOM 和列顺序变化,拒绝重复、缺失及额外列,整批验证成功后才允许写入数据库。代码适用于 Python 3.12 及以上,只用标准库。

一、先复现“没有异常,但字段已经丢了”

import csv
import io

source = io.StringIO("sku,quantity,quantity\nA-01,3,900\n", newline="")
print(next(csv.DictReader(source)))
# 输出:{'sku': 'A-01', 'quantity': '900'}

重复表头映射到同一个字典键,前一个值被覆盖。等业务层拿到字典,再检查字段数量,已经无法恢复被覆盖的数据。

另外两类现象需要分别定位:

  • UTF-8 BOM 被普通 utf-8 解码保留,首列表头可能是 \ufeffsku。检查表头的 repr(),肉眼看表格通常发现不了。
  • DictReader 遇到较长行会把剩余值放到 restkey 对应的键中,默认键是 None;较短行使用 restval,默认值也是 None。这些情况不会自动成为业务校验错误。该行为见 Python csv 官方文档。

排查时记录导入批次、记录序号、物理行号、预期列数和实际列数即可。不要直接打印上传文件、备注和整行内容。表头也是外部输入,生产日志更适合记录校验错误类型,而不是无限制输出原文。

二、修复顺序:先检查原始列,再构造字典

这里采用 csv.reader,在表头校验成功且每行长度一致后才执行 zip。strict=True 用于识别部分 CSV 语法错误,它不会替你检查业务字段、重复表头或列数。

将下面代码保存为 inventory_csv.py。文件由调用方打开,解析器只接收文本流,测试无需真实网络或数据库。

import csv
import re
from dataclasses import dataclass
from typing import TextIO


class ImportFormatError(ValueError):
    """上传文件不符合约定时,阻止整批数据进入写入阶段。"""


@dataclass(frozen=True)
class InventoryRow:
    """保留通过校验的库存调整数据,不携带原始上传行。"""

    sku: str
    quantity: int
    note: str


def parse_inventory(source: TextIO) -> list[InventoryRow]:
    """先完整校验小批量文件,避免解析到一半已经更新库存。"""
    expected = {"sku", "quantity", "note"}
    reader = csv.reader(source, strict=True)
    result: list[InventoryRow] = []
    seen_skus: set[str] = set()
    try:
        headers = next(reader, None)
        if headers is None:
            raise ImportFormatError("文件为空")
        if len(headers) != len(set(headers)):
            raise ImportFormatError("表头存在重复列")
        if set(headers) != expected:
            raise ImportFormatError("表头必须包含且仅包含 sku、quantity、note")

        for record_number, values in enumerate(reader, start=1):
            location = f"第 {record_number} 条记录(结束于物理行 {reader.line_num})"
            if record_number > 10000:
                raise ImportFormatError("记录数超过 10000 条")
            if len(values) != len(headers):
                raise ImportFormatError(f"{location}的列数不匹配")
            row = dict(zip(headers, values, strict=True))
            sku = row["sku"]
            raw_quantity = row["quantity"]
            note = row["note"]
            if re.fullmatch(r"[A-Z0-9][A-Z0-9-]{0,31}", sku) is None:
                raise ImportFormatError(f"{location}的 SKU 格式无效")
            if sku in seen_skus:
                raise ImportFormatError(f"{location}存在重复 SKU")
            if re.fullmatch(r"(?:0|[1-9][0-9]{0,6})", raw_quantity) is None:
                raise ImportFormatError(f"{location}的数量必须为无前导零的非负整数")
            quantity = int(raw_quantity)
            if quantity > 1000000:
                raise ImportFormatError(f"{location}的数量超过上限")
            if len(note) > 200 or "\x00" in note:
                raise ImportFormatError(f"{location}的备注过长或包含空字符")
            seen_skus.add(sku)
            result.append(InventoryRow(sku, quantity, note))
    except csv.Error as exc:
        raise ImportFormatError(
            f"物理行 {reader.line_num} 附近存在 CSV 语法错误"
        ) from exc
    if not result:
        raise ImportFormatError("文件没有数据记录")
    return result

表头使用精确比较, sku、SKU 和 sku 不视为同一字段。不要对所有字段一律 strip():那会改变 SKU 和备注内容,也可能把坏模板悄悄变成“能导入的模板”。如果业务确实允许别名或空格,先定义映射,再对映射后的表头检查重复。

数量先检查长度和 ASCII 十进制语法,再调用 int。这样 +3、3.0、03、全角数字以及超长数字都不会混入整数边界。数量 0 合法;库存是否允许归零,需要权限和业务规则另行控制。

三、把编码处理放在文件入口

将下面代码放在调用方。utf-8-sig 能读取带 BOM 和不带 BOM 的 UTF-8 文件;它不是 GBK 自动检测器。签名处理见 Python 编码文档。

from pathlib import Path

from inventory_csv import ImportFormatError, InventoryRow, parse_inventory


def load_inventory(path: Path) -> list[InventoryRow]:
    """只接受明确约定的编码,不通过丢弃字节掩盖坏文件。"""
    try:
        with path.open(encoding="utf-8-sig", errors="strict", newline="") as source:
            return parse_inventory(source)
    except UnicodeDecodeError as exc:
        raise ImportFormatError("文件编码不是有效的 UTF-8,请按模板重新导出") from exc

newline="" 让 CSV 模块处理字段内部换行。不要使用 splitlines() 后逐行 split(","):备注中的逗号、双引号和换行都有 CSV 自己的转义规则。

上述函数返回的是验证后的列表,此时还没有修改库存。数据落库仍须参数化 SQL、验证操作者和每个 SKU 所属租户,并在事务中写入。解析通过不代表 SKU 存在,也不代表调用者有权修改它。

四、用回归测试守住格式边界

保存为 test_inventory_csv.py。测试直接检查原始字节入口的 BOM、非法编码,以及解析器的正常、边界和失败路径。

import io
import unittest

from inventory_csv import ImportFormatError, InventoryRow, parse_inventory


def parse_bytes(payload: bytes) -> list[InventoryRow]:
    """模拟文件入口,让编码与换行行为参与回归测试。"""
    with io.TextIOWrapper(
        io.BytesIO(payload), encoding="utf-8-sig", errors="strict", newline=""
    ) as source:
        return parse_inventory(source)


class InventoryCsvTests(unittest.TestCase):
    """验证结构错误不会转化为可写入的库存数据。"""

    def test_valid_bom_and_reordered_headers(self):
        payload = b'\xef\xbb\xbfnote,sku,quantity\r\n"a,b",A-01,0\r\n'
        rows = parse_bytes(payload)
        self.assertEqual((rows[0].sku, rows[0].quantity, rows[0].note),
                         ("A-01", 0, "a,b"))

    def test_quoted_multiline_note(self):
        rows = parse_bytes(b'sku,quantity,note\nA-01,2,"a\nb"\n')
        self.assertEqual(rows[0].note, "a\nb")

    def test_reject_invalid_headers(self):
        for header in ("sku,quantity,quantity", "sku,quantity",
                       "sku,quantity,note,extra", " sku,quantity,note"):
            with self.subTest(header=header), self.assertRaises(ImportFormatError):
                parse_bytes((header + "\nA-01,2,x\n").encode())

    def test_reject_wrong_width_and_blank_record(self):
        for line in ("A-01,2", "A-01,2,x,extra", ""):
            with self.subTest(line=line), self.assertRaises(ImportFormatError):
                parse_bytes(("sku,quantity,note\n" + line + "\n").encode())

    def test_quantity_boundaries(self):
        self.assertEqual(parse_bytes(b"sku,quantity,note\nA-01,1000000,\n")[0].quantity,
                         1000000)
        for quantity in ("", "-1", "+1", "01", "1.0", "1000001", "1"):
            with self.subTest(quantity=quantity), self.assertRaises(ImportFormatError):
                parse_bytes(f"sku,quantity,note\nA-01,{quantity},\n".encode())

    def test_reject_duplicate_sku_and_bad_sku(self):
        for body in ("A-01,2,\nA-01,3,\n", "a-01,2,\n", ",2,\n"):
            with self.subTest(body=body), self.assertRaises(ImportFormatError):
                parse_bytes(("sku,quantity,note\n" + body).encode())

    def test_empty_and_header_only(self):
        for payload in (b"", b"sku,quantity,note\n"):
            with self.subTest(payload=payload), self.assertRaises(ImportFormatError):
                parse_bytes(payload)

    def test_malformed_quote_and_note_limit(self):
        for body in ('A-01,2,"unfinished', "A-01,2," + "x" * 201,
                     "A-01,2,a\x00b"):
            with self.subTest(body=body), self.assertRaises(ImportFormatError):
                parse_bytes(("sku,quantity,note\n" + body).encode())

    def test_invalid_utf8(self):
        with self.assertRaises(UnicodeDecodeError):
            parse_bytes(b"sku,quantity,note\nA-01,2,\xff\n")

    def test_record_limit(self):
        body = "".join(f"A-{number},1,\n" for number in range(10001))
        with self.assertRaisesRegex(ImportFormatError, "10000"):
            parse_bytes(("sku,quantity,note\n" + body).encode())


if __name__ == "__main__":
    unittest.main()

在两个文件所在目录运行:

python -m unittest -v test_inventory_csv.py

十个测试方法必须全部通过。重复表头测试特别重要:如果改回“不校验表头直接使用 DictReader”,这份坏数据会重新被接受。

五、上线时还需要补哪些边界

本例适合最多 10000 条的小批量导入,调用方还应在上传入口限制请求体,例如最大 5 MiB,并对每条记录施加业务授权。备注长度检查发生在 CSV 解析之后,不能替代请求体大小限制。csv.field_size_limit() 是进程共享设置,不宜每个请求临时调高再还原;默认解析上限触发时,本例会转换为格式错误。

大文件可先完整解析到隔离的暂存表,校验成功再事务性合并。不要边解析边提交正式表,否则末行错误会留下半批写入。批次幂等键用于防止重复提交,同一个文件内的重复 SKU 校验不能替代批次幂等。

保留批次状态、错误类别、记录序号及结束物理行号。reader.line_num 统计物理行,因此多行备注会使它大于记录序号;给用户定位时应同时展示两者。重新导出为电子表格的内容还要单独评估公式执行风险,CSV 解析成功并不提供该项防护。

本文示例在本地标准库环境验证,不包含真实上传网关和数据库事务测试。接入系统后需要补充上传大小、租户权限、失败回滚及重复批次测试。

六、总结

CSV 导入的关键是保留检查顺序:明确编码和方言,检查原始表头,检查每行列数,再校验业务字段,最后进入事务写入。这样才能在信息被字典覆盖之前拒绝坏文件,也能让“模板错误”和“库存规则错误”分别得到可定位的反馈。