适用场景与输入合同
接口要求商品编号为 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 的接受范围更大。编号合同若允许其他文字,应单独设计规则,不要直接套用本文字符集合。
排查顺序
- 在服务端入口确认值的真实类型,排除 JSON 数字、布尔值、数组和 null。
- 在本地脱敏样本上检查
len(value)、match.span()与匹配结束位置;结束位置小于长度就是只匹配了一部分。 - 检查是否启用了
MULTILINE、是否调用了strip()、是否把search()当成校验。查找、提取与字段校验应使用不同函数。 - 在 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 框架、数据库、网关解码或生产性能。输入长度检查也不能替代网关对请求体大小的限制。
完整字段校验的关键是明确字符集合、长度与规范化合同,再要求完整匹配。保留能让前缀匹配缺陷重新暴露的短后缀与换行测试,才能防止后续重构重新放宽入口。
Discussion
评论