不要直接使用 QR 码的读取值——纠错通过也不保证值是正确的

· 更新日期: · · QR 码, 条码, 纠错, 输入验证, 数据质量, 业务系统, C#, 设计, 现场运维

更新记录(仅首版,2026年07月25日 发布)
首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22175113)

以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。

Go Komura(2026)。《不要直接使用 QR 码的读取值——纠错通过也不保证值是正确的》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/qr-decoded-value-validation/

DOI(已登记存档)
10.5281/zenodo.22175113
DOI(上次登记版本)
10.5281/zenodo.22175114

仓库的验货终端“嘀”的一声。收到 QR 码读取成功的信号后,库存系统匹配出单据,确定出货。这里要分清的是:“解码器返回了一个值”与“可以拿这个值推进业务”

QR 码带有从污损和破损中恢复数据的纠错机制。从级别 L 到 H,可以恢复约 7% 到约 30% 的码字。1 但由此并不能说“返回的值一定正确”。

本文基于实际的 QR 码样本和两种解码器的实测,依次说明会发生什么、纠错的职责到哪里为止、应用要验证什么。文中登载的 QR 码可以用手边的读码器试读。用来展示“读不出来”的样例,会在相应位置注明。

我们还准备了可以在浏览器中切换 jsQR 与 OpenCV.js 的 QR 码读取对比工具。你可以在上面确认样本的读取结果,以及字符编码带来的差异。不过,后文提到的 Python 版 OpenCV 与浏览器版之间的差异,请分开看待。

1. 先说结论

读取到的值,要和键盘输入一样,当作“未经验证的外部输入”来接收。 基本做法是形式检查 → 校验位 → 业务验证三段。纠错不能代替这些验证。

要确认的事 仅凭“通过了”无法判断的事 接收方的处理
能否解码出 QR 码 是否与原值一致,是否是分割 QR 码的片段或乱码 用完全匹配检查长度、字符种类、前缀等形式
在编码体系上是否自洽 是否发生了多个字符的变化,是否是另一个合法编号 校验位之后再与主数据和业务上下文比对
是否存在可用的单据 是否是此刻该处理的箱子、班次、作业所对应的单据 与通过其他途径掌握的作业对象进行核对
当前是否处于待出货状态 是否有另一台终端正在同时推进同样的处理 在执行时用原子的状态迁移或幂等键防止重复处理

实测数据怎么读,也先分清楚。随机翻转模块、随机破坏码字的试验中,合计 9,700 次里返回错误值的有 0 次。另一方面,在特意构造的损伤下,26 个码字中只要有 7 个发生变化,004873 就会被读成 104873,两种解码器返回了同样的错误。“存在会导致误读的图样”和“在现场会发生多少”是两回事

分割 QR 码、字符编码、把视野内的另一个 QR 码认错,这些都发生在纠错的外侧。到底表现为警告、异常还是空字符串也取决于实现,因此不要仅凭没有报错就判定为成功

按目的选择读哪里

想了解的内容 阅读位置
是否真的会返回不同的值 第 2 章的实物样本
为什么纠错防不住 第 3 章的机制与第 4 章的实测和极限
在业务中会变成什么样的故障 第 5 章的 4 种失败模式
应用要怎么改 第 6 章的验证设计、C# 示例与剩余对策
标签和现场流程怎么定 第 7 章的运维与第 8 章的复现步骤

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 16 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

2. 样本——看起来几乎一样的两张 QR 码

2.1. 看着像读取成功,单据编号却不同

下面两张,在本文的验证中都能无报错地读取。下方的 B 是为了让它被读成另一个值而特意构造损伤的样本,并不代表常见污损的发生概率。请先确认“返回了值,值却是错的”这一结果。

上方是正常的 QR 码,下方是带有一条竖向条带状损伤的 QR 码,外观几乎相同

试之前提醒一句:iOS 自带相机有时不会有反应。读不出来的话请用 QR 读码应用试试(原因写在下面的注记里)。

  • A(上)NO:20260725-004873
  • B(下)NO:20260725-104873

因为是实物,可以直接用手边的读码器试。这个单据编号采用“受订日 8 位 + 流水号 5 位 + 校验位 1 位”的体系,所以变化的是流水号部分——00487 变成了 10487,指向的是相差 1 万号的另一张单据

iOS 自带相机有时不会有反应。自带相机的设计会优先处理像 URL 这样可以执行“打开”操作的内容,对本文这种只有字符串的 QR 码,可能什么都不显示。用 QR 读码应用就能读出来。同一张图像,读取方的实现不同,行为就不同——本文的主题本身,在第一个样本上就能亲身体验到。

2.2. 变化的只有 31 个模块,却没有任何提示

B 与 A 的差别,只有从左数第 10~14 列、一条竖向条带内的 31 个模块。相当于码字区域 208 个模块的 15%。

用红框标出 B 中相对 A 发生变化的 31 个模块的图,它们沿竖直方向细长分布

而关键在于,解码器对两张都不返回错误。连“已纠正”这样的提示都没有。从应用的角度看,两张都是同样成功的读取。

要理解这个结果,不能只看损伤的面积,还要看它作为码字靠近了哪一种图样。第 3 章确认机制,4.2 节说明构造方法,4.3 节说明它与现实污损之间的距离。

3. 为什么会发生——纠错的内部机制

3.1. 纠错的单位,以及规格留出的防误读余量

QR 码的纠错采用里德-所罗门码,以码字(8 位为一单位)为单位起作用。本次使用的版本 1、纠错级别 M(21×21 模块)的构成如下。2

码字总数 数据码字 纠错码字 规格规定的纠错能力
26 16 10 4 个码字

引人注目的是最后一列。有 10 个纠错码字,作为里德-所罗门码本可以纠正到 5 个。但规格规定的纠错能力是 4。差出来的这 1 个,被有意留作防误读用码字 p(版本 1-M 中 p = 2)。规格的表 13 也在脚注中明确写道“为降低误读的概率,将纠错能力设定为小于纠错码字数的一半”。2

也就是说,规格本身就是按照“纠错可能给出错误的值”这一前提设计的。同一节里还有这样一段。

由于 QR 码是矩阵式符号,把模块由暗变明(或者相反)的缺陷,会导致相应的符号字符被误解码为一个明显有效但不同的码字2

3.2. 它并不知道“原来的值”,只是在找可纠正的候选

原因就在纠错的原理本身。里德-所罗门解码做的是,在与接收到的图样相距一定距离(纠错能力)以内的范围里,查找是否存在码字。找到就把它作为答案返回,找不到就以“读不出来”收场。它并不是无论损坏到什么程度都要找出最接近的码字。

由于这个性质,结果分成两种。随机损伤会散落在离任何码字都很远的地方,因此多半倒向“找不到=读不出来”。危险的是损伤恰好落进另一个码字邻域的时候。此时解码器会判定那个码字才是正确答案并返回。返回的码字本身完全自洽,所以看不出它是错的。

4. 能纠正到哪里,从哪里开始危险

这里把随机损伤与特意向另一个值靠拢的损伤分开测量。仅凭损伤的量,无法判断返回值是否正确。 同样的纠错机制,因损伤落点不同,结果分成了“读不出来”和“返回另一个值”。

在看数字之前,先汇总本章的实验条件。这也是在自己手边复现时的前提。

项目 内容
目标符号 版本 1-M / 21×21(26 个码字 = 数据 16 + 纠错 10)
生成 segno 1.6.6 / Python 3.11
解码器 1 OpenCV 5.0.0 cv2.QRCodeDetector(Python 3.11)
解码器 2 jsQR 1.4.0(Node.js 22)
施加损伤的方式(4.1 节) 从码字区域 208 个模块中随机选取并翻转/以码字为单位随机破坏/对整个符号施加均匀的模糊、噪声与对比度下降
施加损伤的方式(4.2 节) 对向目标值 B 靠拢的码字组合做穷举试验
试验次数 模块翻转 3,900 次(每档 300 次)、码字破坏 1,800 次(每档 200 次)+超出纠错能力的补充试验 4,000 次、画质劣化 1,000 次(每种条件 200 次)。4.2 节是 792 种和 495 种的穷举
判定 按解码器的返回值分类:与期望值一致为“正确读取”,得不到值为“读不出来”,返回非空的另一个值为“错误的值”

包含第 5 节样本在内的全文环境,汇总在文末的“验证环境”中。

4.1. 随机污损会倒向“读不出来”

随机翻转模块

对版本 1-M 的符号,从码字区域的 208 个模块中随机翻转若干个,并对结果进行分类(每档 300 次,共 3,900 次)。

翻转 6 个模块与翻转 9 个模块的 QR 码对比,上方能读取,下方读不出来

上方翻转了 6 个,下方翻转了 9 个。上方能正确读取,下方完全读不出来。3 个模块的差别,人眼几乎分辨不出。分界线存在于外观看不出来的地方。

翻转的模块数 正确读取 读不出来 错误的值
0~5 1,799 1 0
6 131 169 0
7 32 268 0
8 11 289 0
9~12 0 1,200 0

能否读取在 5 个到 6 个之间崩塌,9 个以上则全军覆没。而且错误的值一例都没有出现。纠正不了的损伤会倒向“读不出来”,这是个不打折扣的好消息。jsQR 的数字也基本相同(0~5 的 1,800 次全部正确,6 以上与 OpenCV 相差在 1 次以内)。

以码字为单位破坏,比较规格能力与实现的行为

同样的事情以码字为单位来看,分界更清楚(每档 200 次,共 1,800 次)。

破坏的码字数 正确读取 读不出来 错误的值
0~5 1,194 6 0
6~8 0 600 0

一直到 5 个码字都被纠正了。如上一节所述,规格规定的纠错能力是 4,剩下的名额是留给“不纠正、只做检测”的。jsQR 也把 0~5 的 1,200 次全部正确读出,两个实现都把这部分名额用在了纠错上。规格作为防误读措施留出的余量,在实现层面靠不住。

对明显超出纠错能力的破坏(6 个码字和 8 个码字)也各补充试验了 2,000 次,错误的值依然是 0 次,全部以“读不出来”收场。

对整个符号施加均匀的画质劣化

来自相机的劣化也是同样的倾向。对整个符号施加均匀的模糊、噪声与对比度下降后的结果(每种 200 次,共 1,000 次)。

模糊 σ 噪声 σ 对比度 正确读取 读不出来 错误的值
0 0 1.00 200 0 0
1.5 10 0.90 183 17 0
3.0 20 0.70 1 199 0
4.5 30 0.50 0 200 0
6.0 40 0.35 0 200 0

结果要么是“正确读取”,要么是“读不出来”,没有中间态。劣化加剧时正确率下降,但减少的部分全部变成了读取失败。

不过这说的是均匀的劣化。实际的手抖是有方向的,斜向拍摄和照明不均只会破坏图像的一部分。正如接下来会看到的,危险的是损伤出现偏向,所以请不要把它一般化成“只要是画质问题就不会误读”。

4.2. 落点不好时,一定会变成另一个值

第 2 节的样本 B,就是特意构造出的这种“有偏向的损伤”。

NO:20260725-004873(A)与 NO:20260725-104873(B)的 26 个码字并排比较,有差异的是 12 处。其中数据码字 2 个,以及受其牵连的纠错码字 10 个。

在这 12 处中把 7 处改成 B 的值,得到的图样与 A 相距 7 个码字,与 B 相距 5 个码字。如果纠错能力是 5,解码器就会把它理解成“B 被污损了 5 处”,并纠正为 B

条件 试验的组合数 被误读为 B 的数量
把 7 个码字改向 B(与 B 的距离为 5) 792 种 792 种(100%)
把 8 个码字改向 B(与 B 的距离为 4) 495 种 495 种(100%)

本次试验的组合无一例外地都被误读为 B。下面一行与 B 的距离是 4,也就是说从 B 来看仍在规格规定的纠错能力以内。即使是尊重防误读用码字 p 的实现,只要损伤再多 1 个码字,结果也一样。p 只是降低概率,并没有防住。

第 2 节登载的 B,是其中损伤能聚成一条竖带的组合(31 个模块)。做最小化处理还可以减到 23 个模块。OpenCV 5.0.0 与 jsQR 1.4.0 这两个互不相干的实现,都会返回 NO:20260725-104873

4.3. 该如何看待这个差距

老实说,这种损伤在随机情况下很难发生。胡乱的污损试了 9,700 次,误读 0 次。这不是“明天就会发生”的事。

尽管如此,仍有 3 个理由不能忽视它。

  • 现实中的损伤并不随机。折痕沿直线延伸,搬运中的摩擦集中在同一条边,打印头堵塞会形成竖条纹。本次的条带状损伤,就是这类“位置上有偏向的损伤”的一个例子。不过 B 包含白→黑 17 个模块、黑→白 14 个模块这样双向的变化,因此只靠墨量减少的故障是复现不出来的。双向变化同时出现的,是折痕的阴影让二值化判定发生偏移、污渍与漏印重叠、上面又局部贴了另一张贴纸这类情况。
  • 扫描次数的量级完全不同。单次来看可以忽略的概率,到了一天要读几万次的现场就是另一回事。何况误读不报错,不会留下记录,最后只会被当作原因不明的盘点差异处理掉。
  • 能够刻意制造,就意味着别人也能制造。本次的图样是先定下目标值,再机械地构造出来的。对于价签、优惠券这类有篡改动机的 QR 码,这就成了一种攻击手法。

5. 与纠错无关的“读出来了却是错的”

下面 4 种问题,即使纠错正常工作也会发生:只收集到了片段、字符的解释不同、选中了另一个 QR 码、标签本身就不对。实务中更常踩到的是这一类,而且仅凭解码器的解码结果无法检测出来。

5.1. 只读了分割 QR 码的第一张

QR 码有一种把长数据拆分到多个符号、由读取方拼接起来的机制(Structured Append)。下面是把 NO:20260725-004873/LOT:AB-77/QTY:120/EXP:20270131 分成 3 张后的第 1 张

分割 QR 码 3 张中的第 1 张,单独读取会返回末尾缺失的单据编号

外观就是普通的 QR 码,看不出它是 3 张中的 1 张。单独读取它时,OpenCV 返回的是这样。

NO:20260725-00487

没有错误,没有警告。这是一个只丢了末尾校验位 3、看上去完全像模像样的单据编号。第 2 张和第 3 张分别是 3/LOT:AB-77/QTY:120/EXP:20270131。把同一张图像交给 jsQR,返回的是空字符串。没有考虑分割 QR 码的应用碰巧扫到第 1 张时会发生什么,取决于解码器。

在业务中会这样表现出来。如果画面上是用前缀匹配或 LIKE 检索单据编号,那么少了末位的 NO:20260725-00487 会直接命中原来的单据,“读出来了,单据也出来了”,于是没人察觉异常。只要没有按固定位数做检查,这个片段就会一路作为正常读取流下去。

5.2. 字符编码与 ECI

下面是把 部品番号 東-004873 以 Shift_JIS 编码、不指定 ECI 放进去的 QR 码。

用 Shift_JIS 且不指定 ECI 生成的 QR 码,读取后会出现乱码

这同样是实物。用手边的读码器读,是显示 部品番号 東-004873、显示乱码字符串,还是什么都不返回——由此就能知道你用的读码器采用了哪种解释。

QR 码读取对比工具读这张图像,会并排列出 jsQR 取出的原始字节序列,以及把它按 UTF-8 / Shift_JIS / EUC-JP 等重新解释后的结果。同一串字节因字符编码不同而变成完全不同的东西,可以当场确认。

改变生成条件,比较解码器的结果

把同样的内容改变字符编码与 ECI 指定后生成,交给两个解码器读取,结果如下。这 4 种条件的符号全部收录在工具的样本里。不过表中的值是用 Python 的 cv2 测得的,只有第 3 行与浏览器版结果不同(紧接着这张表会详细说明)。工具上能确认的是浏览器版的行为,因此要复现第 3 行的“抛异常中断”,需要 Python 的 cv2

生成条件 OpenCV 5.0.0 jsQR 1.4.0
Shift_JIS / 无 ECI 把乱码字符串作为成功返回 空字符串
UTF-8 / 无 ECI 部品番号 東-004873 部品番号 東-004873
Shift_JIS / 有 ECI 给出警告并解码失败 空字符串
UTF-8 / 有 ECI 部品番号 東-004873 部品番号 東-004873

第 1 行最糟。OpenCV 把字节序列按 Latin-1 解释,没有报错就返回了 \x95\x94\x95i... 这样的损坏字符串。从应用看这是一次正常读取,直接写进数据库就会生成一条乱码记录。

在业务中会这样表现出来。入库实绩的品名栏里登记进了乱码字符串,第二天就变成“用那个品号搜不到实绩”的咨询。重新读同一张标签得到的还是同一个值,于是现场报告说“系统的检索有问题”,在弄清原因出在读取方的字符编码之前,双方会反复来回。

不指定 ECI 的情况,以及指定了实现也处理不了的情况

这不是 OpenCV 的缺陷,而是完全符合现行规格所规定的默认解释(ISO/IEC 8859-1)的行为3 偏离规格的,是不加 ECI 就放入 Shift_JIS 的那一方。

第 3 行也不能放过。明明正确指定了 ECI(显式声明字符编码的机制),OpenCV 却给出 QR: ECI is not supported properly 的警告,无法把返回值按 UTF-8 解释,最终抛出异常中断。越是忠实于规格制作的 QR 码反而越读不出来,这样的颠倒确实会发生。

不要把 Python 版与浏览器版当作同样的结果

而且这第 3 行,即使是同一个 OpenCV,结果也会随语言绑定而变。上表是 Python 的 cv2 的结果,而把同一张图像交给浏览器版(opencv.js 5.0.0),不会抛出异常,而是把 ���i��� ��-004873 这样满是替换字符的字符串作为成功返回。原因是 Emscripten 把 std::string 按 UTF-8 转换时,会把非法字节落成 U+FFFD 而不是抛出异常。版本和图像都相同,变的只有调用方的语言。能靠异常察觉的 Python 还算好的,浏览器版是一边说“读出来了”一边返回损坏的值。可以在工具的样本上实际确认。

5.3. 画面中存在多个 QR 码

单据上印着多个 QR 码、旁边箱子的标签进入视野——这都是很常见的情况。我们把 3 个 QR 码横向排开做了试验。

把 3 个 QR 码横向排列的图像,从左到右依次是 NO:、ITEM:、LOT:

单个读取 API 并不保证“拒绝多张”

首先,对这张图像OpenCV 的单个读取 API 什么都没有返回。但这并不是“有多个就会帮你拦下来”的保证。文档只写了单个读取 API 会检测并解码一个 QR 码,并没有写它会拒绝多张,视排布不同也可能返回其中某一个。请不要用单个读取 API 有没有返回值,来代替对多个码的检测。

即使用多个读取 API,结果的顺序也不能用来识别

就算改用多个读取 API,也不等于可以依赖返回顺序。

下面是直接使用上面那张图(从左到右为 NO: / ITEM: / LOT:)、只改变像素数后读取的结果。这相当于实际扫描时与对象的距离或相机分辨率发生变化的情况。

图像宽度 返回顺序
1,001px NO: / LOT: / ITEM:
1,502px 什么都不返回
2,002px NO: / LOT: / ITEM:
3,003px NO: / ITEM: / LOT:
4,004px LOT: / NO: / ITEM:

同一张图像,仅仅分辨率不同,顺序就会变。既不是从左到右,也不是按大小排列。甚至还有读不出来的分辨率。顺序由检测算法的内部实现决定,既然没有规定,就会是这个样子。

也就是说,认定“第一个应该就是单据编号”而使用索引 0 的代码,今天碰巧能跑,明天只要把相机凑近一点就会抓到另一个码。这是一类难以复现、原因也难以追查的故障。

在业务中会这样表现出来。在验货台上,目标单据和旁边箱子的标签同时进入视野。采用索引 0 的应用会匹配到旁边那张单据,出货指示就按它确定下来。作业人员以为自己把相机对准了正确的标签,因此在出货后被告知“收到的商品不对”之前,没人会察觉。

对策是按内容挑选,并确认候选只有一个

接收方需要的不是按顺序,而是按内容挑选。用多个读取 API 把全部结果取回,只采用前缀与形式都匹配的那一个,命中数为 0 个或 2 个以上时报错——这才是安全的写法。

5.4. 内容本身未必就是正确的

还有一层是图像处理在原理上就检测不到的:印刷源数据本身就是错的、更换前的旧标签还留在箱子上、混进了其他客户的标签、标签被复制了。

QR 码只能告诉你“上面写了什么”。“它是否正确”“是不是本公司签发的”,只能由接收方自己去确认。

在业务中会这样表现出来。重复使用的箱子侧面还留着上一次的标签,扫到的不是顶面的新标签而是它。作为值来说这是一张完全正确的 QR 码,所以形式检查、校验位,以及只确认存在性的主数据比对全都能通过,货物就发往了上一次的收货方。只检查值,是无法判断它是不是眼前这个箱子的标签的。这需要第 6 章说明的、与此刻该处理的对象进行核对。

6. 如何处理接收到的值

对策归根结底就是在纠错的外侧叠加自己的验证

验证内容 能抓住的问题
1. 形式检查 长度、字符种类、分隔符、前缀的完全匹配 读到别的码、分割 QR 码的片段、乱码
2. 自我验证 校验位 1 个字符的变化一定能检出,多个字符会有漏检
3. 业务验证 主数据比对,以及是否与此刻该处理的对象一致 旧标签、其他公司的标签、错拿成另一张单据

样本中的单据编号是 NO: + 受订日 8 位 + 流水号 5 位 + 校验位 1 位,末位采用与 GS1 相同的模 10、权重 3 方式。第 2 节的误读 NO:20260725-104873 会在这里被挡住。因为 2026072510487 的正确校验位是 0,与标签上的 3 不一致。

这三层守不住的东西

在进入实现之前,先确认每一层的极限。校验位、存在与状态的确认、处理的执行,各自承担不同的职责。

多个字符的变化,单靠校验位挡不住

校验位会漏掉多个字符同时变化的情况。模 10、权重 3 能确实抓住的是 1 个字符的错误。实际上,20260725004872026072517487 的校验位都是 3,因此 NO:20260725-174873 会直接通过第 2 层。误纠正改变的未必只有 1 个字符,所以第 3 层不能省。

即使存在与状态都正确,也未必是当前的作业对象

不要让主数据比对停留在“存在确认”上。后面给出的 C# 示例中的 repo.Find() 和状态检查,只确认了“某处存在一张可用的单据”。如果作业人员扫的是旁边箱子的标签,那张标签同样满足形式、校验位和待出货状态,于是照样通过。读取到的值,需要与此刻本应处理的对象核对——是否与拣货单上的下一条一致、是否关联到已扫描的容器 ID、收货方是否与正在作业的班次相同。与什么核对因业务而异,所以只有这一部分写不成通用代码。

重复处理要在验证之后由执行方防止

重复处理靠验证防不住。两台终端几乎同时读取同一张标签时,两边都会在确认“待出货”之后再更新状态,于是两边都能通过。防止它是执行方的工作:把 UPDATE ... WHERE status = '待出货' 这类带条件的状态迁移做成一次原子操作,或者用幂等键吸收重复执行。验证是入口处的判断,不能代替互斥控制。

6.1. 先把字符编码和形式定下来

在写代码之前,请先把字符编码的前提定下来。下面的实现以单据编号只由 ASCII 数字构成为前提,用“字符 − '0'”计算校验位。如果全角数字,或者 5.2 节看到的乱码字节序列到达了这一步计算,结果就毫无意义。因此形式检查的正则表达式写的是 [0-9] 而不是 \d,并且把顺序安排成不让非 ASCII 的数字到达校验位计算。先定前提,再用形式检查来保证它,计算放在最后。这个顺序一乱,后面的检查就全都空转。

6.2. 用 C# 实现入口处的验证

下面是针对单个读取值做形式检查、校验位、单据存在性与状态确认的示例。ISlipRepositorySlipStatus 以及单据的类型使用业务侧的实现。仅凭这个示例,并不能把与作业对象的一致性核对和重复处理防止也一并做完。 解码器抛出异常时的处理,也要由调用方另行处理。

using System.Linq;
using System.Text.RegularExpressions;

public sealed record ScanOutcome(bool Accepted, string? SlipNo, string Reason);

public static class SlipScanValidator
{
    // NO: + 受订日 8 位 + '-' + 流水号 5 位 + 校验位 1 位
    // 结尾用 \z 而不是 $。.NET 的 $ 也会匹配末尾换行符之前的位置,
    // 会让 "NO:20260725-004873\n" 通过。
    // 数字用 [0-9] 而不是 \d。.NET 的 \d 会匹配全角数字等 Unicode 中的
    // 各种数字,而后面的校验位计算是以 ASCII 为前提的
    private static readonly Regex Format =
        new(@"\ANO:(?<date>[0-9]{8})-(?<seq>[0-9]{5})(?<cd>[0-9])\z", RegexOptions.Compiled);

    public static ScanOutcome Validate(string? raw, ISlipRepository repo)
    {
        // 1. 不要把“没读出来”当成“空的成功”。
        //    解码器把失败返回为 null / 空字符串 / 异常中的哪一种,因实现而异
        if (string.IsNullOrEmpty(raw))
            return new(false, null, "未能读取,请重新扫描一次");

        // 2. 形式检查。不用前缀匹配,而是包含长度在内的完全匹配。
        //    分割 QR 码的片段 "NO:20260725-00487" 会在这里被挡下
        var m = Format.Match(raw);
        if (!m.Success)
            return new(false, null, $"不是单据 QR 码的格式({Describe(raw)})");

        // 3. 自我验证。若因误纠正导致数字变样,会在这里被抓住
        var body = m.Groups["date"].Value + m.Groups["seq"].Value;
        if (Modulus10Weight3(body) != m.Groups["cd"].Value[0] - '0')
            return new(false, null, "校验位不一致,请检查标签是否污损");

        // 4. 业务验证。是否真实存在,以及是否处于此刻可以处理的状态
        var slip = repo.Find(raw);
        if (slip is null)
            return new(false, null, "没有相符的单据");
        if (slip.Status != SlipStatus.WaitingForShipment)
            return new(false, null, $"该单据处于“{slip.Status}”,不是出货对象");

        return new(true, raw, "OK");
    }

    // 与 GS1 相同的模 10、权重 3。从最右端起按 3,1,3,1... 的权重相乘
    private static int Modulus10Weight3(string body)
    {
        var sum = 0;
        for (var i = 0; i < body.Length; i++)
        {
            var weight = (body.Length - i) % 2 == 1 ? 3 : 1;
            sum += (body[i] - '0') * weight;
        }
        return (10 - sum % 10) % 10;
    }

    // 读取值是外部输入。输出到画面或日志之前,只保留可打印的 ASCII。
    // char.IsControl 只会滤掉 Unicode 的 Control(Cc),像 U+202E(强制改为
    // 从右向左书写)这类 Format(Cf) 会被放行。要用允许列表而不是排除列表来写
    private static string Describe(string raw)
    {
        var kept = raw.Where(c => c >= ' ' && c <= '~').Take(40).ToArray();
        if (kept.Length == 0) return "(无法显示的字符串)";
        var safe = new string(kept);
        return kept.Length < raw.Length ? safe + "…(已去除部分字符)" : safe;
    }
}

校验位本身的设计,在业务系统的编码设计与校验位中整理了算式与选型方法。在本文的语境下,它的价值在于这项检查不仅对人的输入错误有效,对机器的误读同样有效

“差错”与“攻击”是两个不同的问题

到这里为止的验证,都是针对污损导致的误读、分割 QR 码的漏读、乱码、旧标签混入这类差错的对策。设计时要连同与作业对象的核对、执行方的重复处理防止一起考虑。另一方面,针对故意篡改值的对手,还需要另外的对策。

另一方面,在存在篡改动机的用途(价签、优惠券、入场券、支付)上,这些验证起不到防护作用。攻击者可以满足形式、重新算出校验位,自由地制作出指向另一个真实存在的编号的 QR 码。主数据比对只看存在性,因此会被直接放行。

防伪造需要能确认签发方的机制

如果持有 QR 码就意味着价值或权限,就需要让值本身具备真实性。要么改用服务器签发的不可猜测的令牌(足够长的随机数),使人无法由一个编号推出另一个编号;要么在载荷上附加带密钥的 MAC 或电子签名,由接收方用密钥验证。无论哪种做法,都要在服务器一侧管理已使用状态,防止复制品被重复使用。

真 QR 码被调包,要靠与对象的绑定来防止

不过只有真实性,挡不住“调包”。把正规的 QR 码从便宜商品上揭下来贴到贵的商品上,令牌和签名都还是真的。像可以撕换的价签那样介质被反复使用的场景,已使用判定也不起作用。这里同样有效的是与第 3 层一样的思路:用与值不同的途径确认这张 QR 码是不是眼前这个对象的——用其他手段取得商品侧的标识再核对、与交易的上下文(收银小票明细、入场时段)比对、用一撕就破的标签做物理绑定,都属于这类办法。

校验位也好,主数据比对也好,对真实性都不做任何保证。而真实性本身,也不保证这个值属于眼前的对象。关键在于不要试图用同一套机制同时解决“防误读”“防伪造”“防调包”

7. 运维一侧需要事先定好的事

运维上要定的事,分成标签的设计、读取失败时的处理、确定前的确认三类,会更容易梳理。

7.1. 在标签设计上梳理读不出来的原因与确认手段

  • 务必在 QR 码下方同时印上人可读的字符串。这与 GS1 规定的 HRI(Human Readable Interpretation)的思路相同。4 当怀疑发生误读时,人还留有可以核对的手段。在实际运维中,这成为唯一发现手段的情况并不少见。条码整体的现场运维,整理在GS1 条码标准的基础与现场运维注意事项中。
  • 不要使用分割 QR 码。如果数据装不下,就提高版本,或者在 QR 码里只放标识符、其余内容从主数据取。后者还有让标签更小、修改内容无需重新发行标签的好处。
  • 字符编码的问题,用“不放进去”来解决。业务用途的 QR 码控制在 ASCII 范围内。字节模式在没有 ECI 指定时并没有声明字符编码,而默认解释随规格的版本而变。3 只是用 UTF-8 写并不能获得互操作性,遇到按别的编码解释的扫描器就会乱码。如果无论如何都要放入非 ASCII,按规格正确的做法是用 UTF-8 并附上 ECI 指定,但正如 5.2 节所示,现实中确实存在 ECI 处理可疑的实现,因此在预定机型的实际设备上验证是绕不开的。

7.2. 定好读不出来时的流程,并留下可供调查的记录

  • 事先定好“读不出来”时的流程。重新扫描的次数上限、退回手工录入的做法,以及它的审批人。这里含糊不清的话,现场就会朝着“换角度反复试到读出来为止”这种提高误读概率的方向去做。
  • 把被拦下的值记入日志。如果集中在特定的标签或终端上,就能及早发现打印机或扫描器的故障。不过请不要把原始字符串直接写进面向行的日志。含有换行符或控制字符的值,会伪造日志的行或破坏显示。原始内容要截断长度并转义后保存到结构化日志的字段或数据库的列里,给人看的行只输出无害化后的表示——这种分离要从一开始就做进去。条件允许时也把读取到的图像保存下来。

7.3. 撤销代价越大的操作,确定前的确认就要越厚

  • 在不可逆的操作之前插入确认。对于出货确定、库存扣减、收款核销这类撤销代价很大的操作,要把根据读取值查出的品名和金额显示在画面上让人过目。因为误读的值作为值看上去合理,放到业务上下文里却往往显得不自然。

验证要做到什么程度,由出错时的损失决定。

用途 形式检查 校验位 主数据比对 人工确认
公司内部的库位、货架编号 必需 可选 推荐 不需要
出入库、盘点 必需 推荐 必需 不需要
出货确定、库存扣减 必需 必需 必需 推荐
开票、收款核销 必需 必需 必需 必需
防止药品、危险品拿错 必需 必需 必需 必需

8. 总结

QR 码的纠错,是用来从印刷出来的图样恢复原始码字的机制。这件事它做得很称职。实测中,面对随机污损和均匀的画质劣化,在能纠正的范围内都正确读出,超出范围时就干脆放弃读取。

但这与应用接收到的字符串在业务上是否正确是两回事。视损伤的落点不同,纠错生效的结果就是得到另一个同样合理的值。至于分割 QR 码、字符编码、画面内的另一个码,那是纠错外侧的问题,甚至谈不上概率。

规格自己写下“会被误解码为一个明显有效但不同的码字”,并特意预留出防误读用的码字,这一点清楚地表明了这个结构。即便做到这一步,也只能降低概率——这就是规格能到达的位置。剩下的部分,只有接收到值的应用程序才能补上。

读取到的值,是来自外部的未经验证的输入。把它和从键盘敲进来的字符串同等对待——我认为这才是与 QR 码打交道的正确方式。

用自己的 QR 码试验的 3 个步骤

在自家的标签上,也要固定正常值和读取所用的实现来确认。下面是调查读取行为的步骤,并不意味着只要加上损伤就一定会误读。4.2 节的误读样本,是特意构造出来的。

  1. 生成。取一个实际在用的格式的值,用手边的 QR 生成工具做成 QR 码(本文的样本是用 segno 1.6.6 生成的)。先确认在无损伤状态下能读出来。
  2. 加上损伤。用图像编辑软件,画一条模拟折痕或打印头堵塞的竖向条带。重要的不是量而是偏向,所以不要在整体上撒一层薄噪声,而要集中在狭窄的范围内。
  3. 用两个解码器对比。交给 QR 码读取对比工具读取,对照 jsQR 与 OpenCV.js 的结果。只有一方返回值、两方返回相同的值却与原值不符,这类行为都能当场确认。

不要只看两个结果是否一致,还要确认它们是否与生成时的期望值一致。因为像第 2 章那样,两边都返回同一个错误值的情况是存在的。如果两个解码器的结果出现分歧,那个条件就是自家的验证设计中必须照顾到的条件。“我们现场的扫描器有没有问题”,值得在桌面上先试一次。


验证环境

考察纠错行为的第 2~4 节统一使用版本 1-M(21×21 模块)。第 5 节的样本会随数据量改变版本,因此按节分别列出。

项目 内容
解码器 1 OpenCV 5.0.0 cv2.QRCodeDetector
解码器 2 jsQR 1.4.0(Node.js 22)
生成 segno 1.6.6 / Python 3.11
第 2~4 节的符号 版本 1-M / 21×21(26 个码字 = 数据 16 + 纠错 10)
5.1 节(分割 QR 码) 3 张都是版本 1-M / 21×21
5.2 节(字符编码与 ECI) Shift_JIS 是版本 2-Q,UTF-8 是版本 2-M(都是 25×25)。因为 18~23 字节的日文放不进版本 1-M 的 16 个数据码字
5.3 节(多个码) NO:ITEM: 是版本 1-M;LOT:AB-77 因数据较短、segno 会提高纠错级别,所以是版本 1-H(都是 21×21)
  1. DENSO WAVE 株式会社,关于纠错功能|QRcode.com 

  2. ISO/IEC 18004 Information technology — Automatic identification and data capture techniques — QR code bar code symbology specification。纠错能力的公式 e + 2t ≦ d - p、防误读用码字 p 的取值,以及“会被误解码为一个明显有效但不同的码字”这一表述,见 8.5.1 Error correction capacity;版本 1-M 的 (26,16,4) 和脚注“为降低误读的概率,把纠错能力设定为小于纠错码字数的一半”见 Table 13(引用依据 2000 年版)。现行版是 ISO/IEC 18004:2024。  2 3

  3. 字节模式下没有 ECI 指定时的默认解释,随规格的版本而变。ISO/IEC 18004:2000 的 8.3.1 规定“The default interpretation for QR Code is ECI 000020 representing the JIS8 and Shift JIS character sets”,而 2006 年版(QR Code 2005)以后默认是 ECI 000003,即 ISO/IEC 8859-1。这也意味着,依赖未被声明的默认值,仅仅规格换版就可能改变解释。  2

  4. GS1,GS1 General Specifications 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

把纠错级别设为 H,就能防止误读吗?
防不住。提高级别可以增加能够纠正的污损量,但“纠错生效后变成另一个值”这一现象本身不会消失。里德-所罗门解码的动作是“在与接收到的图样相距纠错能力以内的范围里查找是否存在码字”,因此当损伤落进另一个码字的邻域时,它就把那个码字当作正确答案返回(偏离很远的损伤则以“读不出来”收场)。ISO/IEC 18004 对此明确写有“会被误解码为一个明显有效但不同的码字”,并单独预留了防误读用的码字,但这同样只是降低概率的措施,而不是保证。能够抓住误读的,只有接收到该值的应用一侧。
实际上,QR 码的误读到底会以多高的频率发生?
如果是随机污损,基本不会发生。本文的实测中,随机翻转模块的 3,900 次,以及随机破坏码字的 5,800 次,全部都没有出现返回错误值的情况。纠正不了的损伤会倒向“读不出来”。不过现实中的损伤并不随机,而是像折痕、摩擦、打印头堵塞那样在位置上带有偏向。此外,分割 QR 码的漏读和多个码的张冠李戴甚至算不上概率问题,只要条件凑齐就每次都会发生。
QR 码明明有纠错,还需要校验位吗?
需要。两者防护的层面不同。纠错处理的是符号内部的一致性,而且它能保证复原的范围只到纠错能力以内。超出这个范围的损伤,有可能被“复原”成另一个同样有效的码字。校验位检查的则是“应用接收到的字符串在编码体系上是否成立”。1 个字符的变化一定能抓住,但多个字符同时变化时会有漏检。模 10 的检查值只有 10 种,本文中也举出了 2 位数字变化后校验位仍然一致的例子。因此请把校验位看作挡下大部分误读的一层,最终判断则交给主数据比对和业务上的核对。手工录入、转抄、从其他渠道导入的数据也能用同一套检查来防护,这是它的优点。
用手机相机应用都能读出来了,那这个值应该是正确的吧?
“读出来了”只意味着“解码器返回了一个非空字符串”,对内容是否正确没有任何说明。失败的返回方式也因实现而异,异常、空字符串、null 都有可能,因此把“没有抛出异常”当成成功的判定依据同样危险。本文的实测中,同一张图像让 OpenCV 和 jsQR 给出不同结果的例子有好几处。对于用 Shift_JIS 写入日文的 QR 码,一方把乱码字符串当作成功返回,另一方返回的是空字符串。分割 QR 码的第一张,结果同样出现了分歧。能不能读出来,对于值是否正确没有任何说明。
业务中最好不要使用分割 QR 码(Structured Append)吗?
没有特别理由的话,避开更稳妥。分割 QR 码是把数据拆到多个符号中、由读取方收集并拼接起来的机制,但不支持该机制的解码器只读到第一张时的表现取决于实现。本文的实测中,OpenCV 把末尾缺失的单据编号毫无报错地返回,而 jsQR 返回的是空字符串。既然存在会把片段当作像模像样的值放行的实现,那么只要不打算使用分割 QR 码,就需要有能拦下不完整结果的验证。如果数据装不下,提高 QR 码的版本,或者缩短编码、改为通过主数据引用,会更安全。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表