适用场景与输入合同

接口要求商品编号为 3~16 位 ASCII 大写字母或数字,首位必须是大写字母。例如 ABC123 合法,ABC123/extra、小写字母、全角数字、前后空白都应拒绝。本文使用 Python 3.10 及以上版本的标准库,不依赖 Web 框架。

常见故障是接口返回“校验通过”,查询数据库却找不到编号;审计记录里又出现换行或额外后缀。先核对校验函数实际接受的范围,不要直接扩大数据库字段或给输入统一加 strip。

复现:有匹配不等于整个输入合法

保存为 match_demo.py,运行 python match_demo.py:

import re

pattern = re.compile(r"[A-Z][A-Z0-9]{2,15}")
value = "ABC123/extra"
match = pattern.match(value)
assert match is not None
print(match.span(), len(value))  # 匹配只覆盖前六个字符。
assert match.span() == (0, 6)
assert pattern.fullmatch(value) is None
assert re.match(r"^[A-Z][A-Z0-9]{2,15}$", "ABC123\n") is not None
assert pattern.fullmatch("ABC123\n") is None

match() 从字符串起点尝试匹配,允许后面还剩内容。$ 在默认模式下也能匹配末尾换行之前的位置,因此简单补 ^...$ 仍可能接受末尾换行。fullmatch() 要求整个字符串符合表达式。以上语义可查 Python 3.14 re 官方文档。

这里使用 [0-9] 明确规定 ASCII 数字;默认 Unicode 模式下 \d 的接受范围更大。编号合同若允许其他文字,应单独设计规则,不要直接套用本文字符集合。

排查顺序

  1. 在服务端入口确认值的真实类型,排除 JSON 数字、布尔值、数组和 null。
  2. 在本地脱敏样本上检查 len(value)、match.span() 与匹配结束位置;结束位置小于长度就是只匹配了一部分。
  3. 检查是否启用了 MULTILINE、是否调用了 strip()、是否把 search() 当成校验。查找、提取与字段校验应使用不同函数。
  4. 在 URL 解码或其他协议解码完成后校验最终业务值。禁止校验一种表示、存储另一种表示。

不要在生产日志直接打印原始请求体或业务编号;可记录请求追踪号、字段名和稳定的错误码。换行等控制字符尤其容易干扰非结构化日志。

修复:在单一入口验证完整值

保存为 product_code.py。长度先行判断能快速拒绝大字符串,错误说明不回显输入。外部类型用 object 接收,验证后返回 str,调用方只使用返回值。

import re

PRODUCT_CODE_PATTERN = re.compile(r"[A-Z][A-Z0-9]{2,15}")


def validate_product_code(value: object) -> str:
    """验证业务编号,拒绝隐式转换及未经约定的规范化。"""
    if not isinstance(value, str):
        raise ValueError("商品编号必须是字符串")
    if not 3 <= len(value) <= 16:
        raise ValueError("商品编号长度必须为 3 到 16 个字符")
    if PRODUCT_CODE_PATTERN.fullmatch(value) is None:
        raise ValueError("商品编号必须以大写字母开头且仅包含 ASCII 大写字母或数字")
    return value

接口适配层捕获这里的 ValueError,映射为现有客户端参数错误响应;数据访问层仍需参数化 SQL,并独立执行权限校验。格式正确不表示编号存在,也不表示调用人有访问权限。

不要用 str(value) 掩盖输入类型错误;也不要先 strip() 再校验,除非产品合同明确允许空白规范化。若允许规范化,必须对规范化后的值校验、查询、存储并返回,避免不同层使用不同编号。

回归测试:必须包括短后缀和换行

保存为 test_product_code.py,与实现放在同一目录,执行 python -m unittest -v test_product_code。

import re
import unittest

from product_code import validate_product_code


class ProductCodeTests(unittest.TestCase):
    """覆盖完整匹配、输入类型及 ASCII 合同。"""

    def test_valid_boundaries(self) -> None:
        for value in ("ABC", "A" + "9" * 15, "ABC123"):
            with self.subTest(value=value):
                self.assertEqual(validate_product_code(value), value)

    def test_reject_suffix_and_newline(self) -> None:
        for value in ("ABC/", "ABC\n", "ABC\r\n", "ABC xyz"):
            with self.subTest(value=value):
                with self.assertRaises(ValueError):
                    validate_product_code(value)

    def test_reject_wrong_length(self) -> None:
        for value in ("", "AB", "A" * 17, "A" * 10000):
            with self.subTest(length=len(value)):
                with self.assertRaises(ValueError):
                    validate_product_code(value)

    def test_reject_wrong_alphabet(self) -> None:
        for value in ("abc", "1AB", "A12", "A中B", "ABC_", " ABC"):
            with self.subTest(value=value):
                with self.assertRaises(ValueError):
                    validate_product_code(value)

    def test_reject_wrong_type(self) -> None:
        for value in (None, True, 123, b"ABC", ["ABC"]):
            with self.subTest(type_name=type(value).__name__):
                with self.assertRaises(ValueError):
                    validate_product_code(value)

    def test_dollar_anchor_allows_final_newline(self) -> None:
        self.assertIsNotNone(re.match(r"^[A-Z][A-Z0-9]{2,15}$", "ABC\n"))
        with self.assertRaises(ValueError):
            validate_product_code("ABC\n")


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

故意把实现中的 .fullmatch(value) 改回 .match(value) 后,非法后缀和末尾换行测试必须失败;若只测试超过 16 位的后缀,长度判断就会提前拒绝,无法发现匹配方式退化。恢复后再执行全部测试。

上线与边界

替换所有重复校验入口,发布前用已有数据的脱敏样本检查是否存在历史非法编号。不要自动裁剪已有编号:ABC/ 和 ABC 可能映射到不同记录,应先核对引用关系,再按正常数据修复流程处理。

若上线后客户端错误率增加,按调用方与错误码定位旧客户端;需要回滚时回退入口代码版本,评估重新接受非法输入的影响,避免同步执行不可逆的数据规范化。

本文验证范围是标准库正则、纯函数输入校验和六项本地单元测试;未覆盖真实 HTTP 框架、数据库、网关解码或生产性能。输入长度检查也不能替代网关对请求体大小的限制。

完整字段校验的关键是明确字符集合、长度与规范化合同,再要求完整匹配。保留能让前缀匹配缺陷重新暴露的短后缀与换行测试,才能防止后续重构重新放宽入口。