倉庫的驗貨終端機發出「嗶」的一聲。這是單據上的QR Code讀取成功的訊號。讀到的字串會直接傳給庫存系統,單據被引當,出貨指示隨即確定。QR Code既然有錯誤更正,就算有些髒污,也能返回正確的值 ── 建立在這個前提上的系統,絕不罕見。
前半段是對的。QR Code在設計上就能在有髒污或破損的情況下復原資料,從錯誤更正等級L到H,可以復原約7%到約30%的碼字。1 問題出在後半段 ──「所以返回的值就是正確的」這個推論。
本文將以實際生成的QR Code樣本圖片,以及兩種解碼器的實測結果為基礎,整理錯誤更正通過也不保證值正確的原因,以及業務系統端應該驗證哪些內容。文中所附的QR Code全部都是實物。凡是能讀取的,都可以直接用手邊的QR讀取器確認(其中也包含用來展示「讀不出來」的樣本,會在該處明確標註)。我們還準備了可以在瀏覽器上切換 jsQR 與 OpenCV.js 來試讀的QR Code讀取比較工具。本文的實測結果都可以在該工具上重現。
1. 先講結論
- 錯誤更正是「復原」,而不是「驗證」。規格本身寫明,受污損的模組會「被誤解碼為明顯有效但不同的碼字」。2
- 如果是隨機髒污,多半會變成「讀不出來」。實測9,700次中,返回錯誤值的情況為0次。
- 但如果損傷集中在一處,就必定會變成另一個值。26個碼字中只要有7個出錯,
004873就會被讀成104873,兩個互不相干的解碼器給出了完全相同的錯誤結果。 - 錯誤更正範圍之外,還存在更簡單的陷阱。分割QR、字元編碼、畫面內的其他代碼,這些都可能在不報錯的情況下發生。有些解碼器會發出警告或拋出例外,但實際表現取決於實作,確實存在不會報錯的組合。
- 因此,讀取到的值應當作未經驗證的輸入來處理。基本做法是分三段接收:形式檢查 → 檢查碼 → 業務驗證。
2. 樣本 ── 幾乎長得一樣的兩張QR Code
首先請看實物。以下這兩張QR Code,都能無錯誤地讀取。
試讀前先說一句:iOS的標準相機可能不會有反應。如果讀不出來,請改用QR讀取器App試試(原因寫在下方的注記中)。
- A(上) →
NO:20260725-004873 - B(下) →
NO:20260725-104873
由於是實物,可以直接用手邊的讀取器試讀。這個單據編號採用「受訂日8位 + 流水號5位 + 檢查碼1位」的體系,發生變化的是流水號部分 ── 00487 變成了 10487,指向了一張相差1萬號的另一張單據。
iOS的標準相機可能不會有反應。標準相機的設計會優先處理像URL這種可以「開啟」的內容,對於本文這種純字串的QR Code,有時什麼反應都不會有。改用QR讀取器App就能讀取。同一張圖片,卻因讀取端實作不同而表現各異 ── 這正好讓讀者在第一個樣本上,就親身體驗到本文的主題。
B與A的差異,僅在於從左數來第10〜14列、一條縱向帶狀區域內的31個模組。這相當於碼字區域208個模組中的15%。
而關鍵在於,解碼器對這兩者都不會返回錯誤。甚至連「已進行更正」的提示都沒有。從應用程式的角度看,兩者都是同樣成功的讀取。
先聲明一點,這個損傷是刻意構造出來的,偶然形成這種情況的機率並不高。具體的構造方式,以及它與現實髒污之間的差距,將在第4節討論。
3. 為什麼會發生 ── 錯誤更正的內部原理
QR Code的錯誤更正採用里德-所羅門碼,以碼字(以8位元為單位)為單位運作。本文使用的版本1・錯誤更正等級M(21×21模組)的構成如下。2
| 總碼字數 | 資料碼字 | 錯誤更正碼字 | 規格規定的更正能力 |
|---|---|---|---|
| 26 | 16 | 10 | 4個碼字 |
引人注目的是最後一欄。如果有10個錯誤更正碼字,按里德-所羅門碼的原理最多可以更正5個。然而規格所規定的更正能力卻是4。相差的這1個,是被刻意留作防誤讀碼字 p(版本1-M中 p = 2)。規格表13的註腳也明確寫道:「為了降低誤讀機率,將更正能力設定為小於錯誤更正碼字數的一半」。2
也就是說,規格本身就是在「錯誤更正可能給出錯誤的值」這個前提下設計的。同一節中還寫道:
由於QR Code是矩陣符號,使模組由暗變明(或由明變暗)的缺陷,會導致對應的符號字元被誤解碼為明顯有效但不同的碼字。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個模組。上方能夠正確讀取,下方則完全無法讀取。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,剩下的那1個原本是留作「只偵測、不更正」的餘量。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的距離為4,也就是在規格所規定的更正能力範圍之內。即便是尊重防誤讀碼字 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 Code,這會成為一種攻擊手法。
5. 與錯誤更正無關的「讀到了,但不對」
在實務中更常踩到的其實是這一類問題。即便錯誤更正完美地發揮了作用,它依然會發生,甚至根本不是機率問題。
5.1. 只讀取分割QR的第一張
QR Code具備將較長的資料拆分成多個符號、由讀取端進行連接的機制(Structured Append,分割QR)。以下是把 NO:20260725-004873/LOT:AB-77/QTY:120/EXP:20270131 拆分成3張後,其中的第一張。
外觀上就是一張普通的QR Code,沒有任何線索顯示它是三張中的一張。單獨讀取這張圖,OpenCV會返回:
NO:20260725-00487
沒有錯誤,沒有警告。只是末尾的檢查碼 3 掉了而已,是一個完全說得通的單據編號。第二張、第三張分別是 3/LOT:AB-77/QTY: 和 120/EXP:20270131。把同一張圖片交給jsQR,返回的是空字串。沒有考慮分割QR情境的應用程式,碰巧只掃到第一張時會發生什麼,取決於解碼器而定。
在業務中會這樣表現出來。如果畫面上是用前方一致或 LIKE 來檢索單據編號,那麼末尾少了1位的 NO:20260725-00487 會照樣命中原本的單據,由於「讀取成功了,單據也查出來了」,沒有人會察覺到異常。只要沒有依固定位數進行檢查,這個不完整的片段就會一路作為正常的讀取結果流轉下去。
5.2. 字元編碼與ECI
以下是把 部品番号 東-004873 用Shift_JIS編碼、且不指定ECI寫入的QR Code。
這也是實物。用手邊的讀取器讀取,會得到 部品番号 東-004873,或是亂碼字串,或者什麼都讀不出來 ── 由此可以知道您所用的讀取器採用的是哪種解讀方式。
用QR Code讀取比較工具讀取這張圖片,會並排顯示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... 這樣一串損壞的字串。從應用程式的角度看這是一次正常的讀取,如果就這樣寫入資料庫,就會產生一筆亂碼記錄。
在業務中會這樣表現出來。入庫記錄的品名欄位登記了亂碼字串,隔天就會收到「用那個品號搜尋不到記錄」的詢問。由於重新讀取同一張標籤也會得到相同的值,現場會回報「系統的搜尋出問題了」,直到查明原因出在讀取端的字元編碼之前,雙方會持續來回溝通。
這並不是OpenCV的錯誤,而是符合現行規格所規定的預設解讀方式(ISO/IEC 8859-1)的行為。3 偏離規格的一方,是不指定ECI就寫入Shift_JIS的那一側。
第3行同樣不容忽視。明明正確指定了ECI(用來明示字元編碼的機制),OpenCV卻給出了 QR: ECI is not supported properly 的警告,無法將返回值以UTF-8解讀而以例外告終。忠實遵循規格製作的QR Code反而讀不出來,這種逆轉是真實會發生的。
此外,即使是同一個OpenCV,第3行的結果也會因語言繫結不同而改變。上表是Python的 cv2 的結果,但把同一張圖片交給瀏覽器版(opencv.js 5.0.0),則不會拋出例外,而是把 ���i��� ��-004873 這種滿是替代字元的字串當作成功返回。這是因為Emscripten在把 std::string 轉換為UTF-8時,會把不合法的位元組替換為U+FFFD,而不是拋出例外。版本與圖片都相同,改變的只是呼叫端使用的語言。能靠例外察覺問題的Python版還算好的,瀏覽器版則是一邊宣稱「讀取成功」一邊返回損壞的值。這一點可以在工具的樣本中實際確認。
5.3. 畫面內存在多個QR
單據上印有多個QR Code,或是隔壁箱子的標籤進入了取景範圍 ── 這是常見的情形。我們把3個QR Code橫向排列進行了試驗。
首先,對於這張圖片,OpenCV的單一讀取API什麼都沒有返回。但這並不保證「只要存在多個就會被擋下」。單一讀取API的文件只說明它用於偵測並解碼單一QR Code,並沒有寫明會拒絕多張,依排列方式的不同,也有可能返回其中某一個。請不要用單一讀取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 Code只能告訴你「上面寫了什麼」。至於「這是否正確」、「是否由本公司發出」,只能靠接收端自己去確認。
在業務中會這樣表現出來。重複利用的箱子側面殘留著上一次的標籤,讀取到的是那張標籤而不是頂面的新標籤。由於這個值本身完全正確,形式檢查、檢查碼、主檔比對全部都會通過,貨物會被發往上一次的出貨地。唯獨這一層,任何針對讀取值的檢查都無法捕捉到。
6. 該如何處理接收到的值
對策可以歸結為一句話:在錯誤更正之外,自行疊加驗證。
| 階段 | 驗證內容 | 能夠捕捉的問題 |
|---|---|---|
| 1. 形式檢查 | 長度・字元種類・分隔符・前綴的完全一致 | 讀取到其他代碼、分割QR的片段、亂碼 |
| 2. 自我驗證 | 檢查碼 | 能確實偵測1個字元的變化。多個字元的變化可能有漏檢 |
| 3. 業務驗證 | 主檔比對,以及是否與目前應處理的對象一致 | 舊標籤、他公司標籤、單據張冠李戴 |
樣本的單據編號是 NO: + 受訂日8位 + 流水號5位 + 檢查碼1位,末尾採用了與GS1相同的模數10・權重3方式。第2節的誤讀結果 NO:20260725-104873 會在這一步被擋下 ── 因為 2026072510487 正確的檢查碼是 0,與標籤上的 3 不一致。
寫程式碼之前,請先確定字元編碼方面的前提。以下的實作假定單據編號只由ASCII數字構成,檢查碼是用「字元 − '0'」來計算的。如果全形數字,或是5.2節中看到的那種亂碼位元組序列送達這一步計算,結果就沒有意義了。因此形式檢查的正規表示式沒有用 \d,而是用 [0-9] 來寫,讓順序變成不允許ASCII以外的數字到達檢查碼計算這一步。先確定前提,再由形式檢查來確保這個前提,計算放在最後 ── 這個順序一旦被打亂,後面的檢查就會全部落空。
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;
}
}
關於檢查碼設計本身,我們在《業務系統的代碼設計與檢查碼》一文中已經整理了算式與選型方法。在本文的語境下,這項檢查的價值在於:它不僅對人的輸入錯誤有效,對機器的誤讀同樣有效。
這三段驗證防不住的問題
即便三段驗證都齊備了,仍然有3個漏洞。每一個都可以透過「把驗證設計再深化一層」來堵上,請一併掌握。
檢查碼會漏掉多個字元的變化。模數10・權重3方式能夠確實抓出的,是單一字元的錯誤。實際上,2026072500487 與 2026072517487 的檢查碼都是 3,因此 NO:20260725-174873 會直接穿過第二段驗證。由於誤更正所改變的未必只是1個字元,第三段驗證不能省略。
不要讓主檔比對止步於「確認是否存在」。上面程式碼中的 repo.Find() 與狀態檢查,只確認了「某處存在一張可用的單據」這一點。如果作業人員讀取的是隔壁箱子的標籤,那張標籤同樣滿足形式、檢查碼、待出貨狀態的所有條件,就會照樣通過。讀取到的值,需要與目前應當處理的對象進行比對 ── 是否與揀貨清單上的下一筆一致,是否關聯著已掃描的容器ID,出貨地是否與正在作業的班次相同。具體要比對什麼因業務而異,唯獨這一部分無法寫成通用程式碼。
重複處理無法靠驗證來防止。如果兩台終端幾乎同時讀取了同一張標籤,兩邊都會先確認「待出貨」狀態再更新,結果兩者都會通過。防止重複處理是執行端的工作 ── 要麼把 UPDATE ... WHERE status = '待出貨' 這類帶條件的狀態遷移做成一次原子操作,要麼用冪等鍵來吸收重複執行。驗證只是入口處的判斷,不能替代互斥控制。
「事故」與「攻擊」是兩回事
到目前為止的這三段驗證,防的是事故。因髒污造成的誤讀、分割QR的漏讀、亂碼、混入舊標籤 ── 這些無惡意的錯誤,靠這些就能擋住。
但另一方面,在存在竄改動機的用途(價格標籤、優惠券、入場券、支付)中,這些起不到防禦作用。攻擊者可以讓格式合規、重新計算檢查碼,自由製作出指向另一個真實存在的號碼的QR Code。由於主檔比對只看是否存在,會被直接放行。
如果持有QR Code就意味著價值或權限,那就需要讓值本身具備真正性。可以把值做成伺服器所發行的、無法推測的權杖(足夠長度的亂數),使其無法從一個號碼推測出另一個號碼;也可以在酬載中附加帶金鑰的MAC或電子簽章,由接收端用金鑰進行驗證。無論哪種方式,都需要在伺服器端管理已使用狀態,以防止複製品被重複使用。
不過,僅憑真正性無法阻止「調包」。把正牌QR Code從便宜商品上撕下貼到高價商品上,權杖與簽章依然是真的。在可以更換的價格標籤這種媒體會被重複使用的場景下,已使用判定同樣不起作用。這裡同樣要靠與第三段相同的思路來應對:透過值以外的途徑確認這個QR Code是否屬於眼前這個對象。具體方法包括:用其他手段取得商品端的識別資訊進行比對、與交易脈絡(收銀明細、入場時段)進行核對、用撕下即破損的標籤實現物理綁定等。
無論是檢查碼還是主檔比對,都無法對真正性做出任何保證。而真正性本身,也無法保證這個值就屬於眼前這個對象。關鍵在於,不要試圖用同一套機制來同時兼顧「防誤讀」、「防偽造」、「防調包」。
7. 運用端需要事先決定的事項
有些部分光靠程式碼是堵不住的。
- QR Code下方務必並排標示人可讀的字串。這與GS1所定義的HRI(Human Readable Interpretation)概念相同。4 當懷疑發生誤讀時,就會留有一種可供人工核對的手段。在實際運用中,這往往是唯一的發現手段。條碼整體的現場運用,已在GS1 條碼規格的基礎與現場運用注意事項一文中整理過。
- 事先確定「讀不出來」時的處理流程。重新掃描次數的上限、回退到手動輸入的路徑、以及核准者。如果這裡含糊不清,現場就會朝著「不斷變換角度反覆嘗試直到能讀出來為止」這種會提高誤讀機率的方向發展。
- 把被擋下的值記錄下來。如果集中出現在特定標籤或特定終端上,就能及早發現印表機或掃描器的故障。不過不要把原始字串原樣寫入以行為單位的記錄。包含換行符或控制字元的值,會偽造記錄的行或破壞顯示。應當把原始內容限定長度並轉義後,保存到結構化記錄的欄位或資料庫的欄位中,供人閱讀的行只輸出經過無害化處理的表示形式 ── 這種分離要從一開始就納入設計。條件允許的話,也應保留讀取當下的圖像。
- 不使用分割QR。如果資料放不下,要麼提高版本,要麼讓QR Code中只放識別碼,其餘內容從主檔中查詢。後一種方式還能把標籤做得更小,並且具備「內容的修正不需要重新發行標籤」這項優點。
- 字元編碼問題,用「不放入」來解決。業務用途的QR Code應控制在ASCII範圍內。位元組模式在沒有ECI指定的情況下不會宣告字元編碼,而預設解讀方式會隨規格版本不同而改變。3 僅僅用UTF-8寫入並不能獲得互通性,用不同解讀方式的掃描器讀取會出現亂碼。如果無論如何都要放入非ASCII字元,依規格來說正確做法是連同ECI指定一起宣告UTF-8,但正如5.2節所示,現實中確實存在對ECI處理不完善的實作,因此終究免不了要在預定機型上進行實機確認。
- 在不可逆的操作前插入一次確認。對於出貨確定、庫存扣減、收款沖銷這類撤銷成本很高的操作,應當把根據讀取值查到的品名或金額顯示在畫面上,讓人來核實。因為誤讀的值本身看起來往往合理,但放在業務脈絡中,很多時候會顯得不自然。
驗證要做到什麼程度,取決於出錯時造成的損失大小。
| 用途 | 形式檢查 | 檢查碼 | 主檔比對 | 人工確認 |
|---|---|---|---|---|
| 公司內部場所・貨架編號 | 必須 | 任意 | 建議 | 不需要 |
| 出入庫・盤點 | 必須 | 建議 | 必須 | 不需要 |
| 出貨確定・庫存扣減 | 必須 | 必須 | 必須 | 建議 |
| 請款・收款沖銷 | 必須 | 必須 | 必須 | 必須 |
| 防止藥品・危險物張冠李戴 | 必須 | 必須 | 必須 | 必須 |
8. 總結
QR Code的錯誤更正,是一種從印刷出來的圖案復原原始碼字的機制。這項工作它完成得很出色。實測中,面對隨機髒污與均勻畫質劣化,在可以更正的範圍內都正確讀出了值,在無法更正的範圍內也乾脆地放棄了讀取。
但這與應用程式接收到的字串在業務上是否正確,是兩回事。依損傷的位置不同,更正生效的結果可能會給出另一個同樣有效的值。至於分割QR、字元編碼、畫面內的其他代碼,則屬於錯誤更正範圍之外的問題,甚至根本不是機率問題。
規格本身寫明「會被誤解碼為明顯有效但不同的碼字」,並且特意保留了防誤讀用的碼字,這一點簡明地體現了這種結構。即便做到這一步,也只能降低機率而已 ── 這就是規格能達到的極限。剩下的部分,只能由接收到這個值的應用程式來填補。
讀取到的值,是來自外部的未經驗證的輸入。把它當作從鍵盤敲入的字串一樣對待 ── 我認為這才是與QR Code正確相處的方式。
親自試驗自家QR的3個步驟
到目前為止的內容,都可以直接用貴公司的標籤來驗證。
- 生成。用手邊的QR生成工具,把一個實際運用中格式的值製作成QR Code(本文的樣本使用segno 1.6.6生成)。首先確認在完好無損的狀態下能夠讀取。
- 加上損傷。用影像編輯軟體畫出一條縱向帶狀痕跡,模擬摺痕或列印頭堵塞的效果。重要的是偏向性而不是數量,所以不要把雜訊稀薄地散布在整體上,而要把它集中在狹窄的範圍內。
- 用兩種解碼器比較。把圖片交給QR Code讀取比較工具,比較jsQR與OpenCV.js的結果。諸如只有一方返回值、兩邊返回了相同的值但與原值不同,這類現象都可以當場確認。
如果兩種解碼器給出的結果出現分歧,那個條件就是貴公司的驗證設計中必須妥善處理的條件。事先在桌上試驗一次「我們現場的掃描器沒問題吧」,是值得花這個功夫的。
驗證環境
用於觀察錯誤更正行為的第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) |
-
株式会社デンソーウェーブ, 關於錯誤更正功能|QRcode.com ↩
-
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 ↩4 -
位元組模式在沒有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
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
業務系統的代碼設計 ── 商品代碼・客戶代碼的訂定方式與檢查碼
商品代碼・客戶代碼等業務系統代碼體系的實務指南。有意義代碼與無意義流水號的判斷表、JAN・Luhn等檢查碼算式與C#實作、Excel的0消失對策,乃至位數溢位與遷移一次整理清楚。
多執行緒的實務最佳實踐 .NET篇 ── 在增加執行緒之前先決定的事
針對 .NET/C# 整理能防止「建立執行緒後偶爾當機、卡死」的設計定石。內容涵蓋不自行建立執行緒而改用 Task、減少共享可變狀態、鎖的紀律、以 CancellationToken 設計停止機制,以及 UI 執行緒的處理方式。
如何理解 Windows 的工作階段隔離 ── Session 0・RDP・多使用者同時執行
整理 Windows 應用程式開發者容易混淆的「工作階段」概念。說明服務無法顯示 UI 的 Session 0 隔離原因、RDP 連線時工作階段的行為、具名物件的工作階段隔離,以及共用 PC・RDS 環境常見的設計失誤,從實務角度解說。
Windows 的行程間通訊該怎麼選 ── 具名管道 / TCP / gRPC / 共享記憶體 / COM 判斷表
整理 Windows 應用程式之間該如何選擇溝通方式。以判斷表整理具名管道、本機 TCP、gRPC、共享記憶體、檔案協作、COM 各自的強項與陷阱,並從實務角度說明 UI+服務分離・32bit/64bit 橋接・權限邊界等典型架構,以及具名管道的實作範例。
Windows應用程式資料儲存位置怎麼選 ── SQLite / JSON / 登錄檔 / Access 判斷表
Windows桌面應用程式的資料該存在哪裡、用什麼格式儲存?本文整理AppData/ProgramData的使用區分,以及SQLite、JSON檔案、登錄檔、Access(.accdb)各自的優勢與陷阱,並附判斷表,從實務角度說明防止資料損毀與位元數問題等注意事項。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
技術諮詢 & 設計審查
協助釐清設計方向、架構邊界、生命週期責任,以及既有 Windows 資產的處理方式。
常見問題
整理諮詢這個主題時常見的問題。
- 把錯誤更正等級設為H,就能防止誤讀嗎?
- 無法防止。提高等級可以增加能夠更正的髒污量,但「更正生效後變成另一個值」這個現象本身並不會消失。里德-所羅門解碼的動作是「在接收到的模式中,於更正能力範圍內尋找是否存在碼字」,因此如果損傷恰好落入另一個碼字的鄰近範圍,就會把該碼字當作正確答案返回(距離較遠的損傷則會以「讀不出來」收場)。ISO/IEC 18004明確寫明這會「被誤解碼為明顯有效但不同的碼字」,並另外保留了防誤讀用的碼字,但這同樣只是降低機率的措施,不是保證。能夠抓出誤讀的,只有接收到這個值的應用程式一方。
- 實際上,QR的誤讀會以多高的頻率發生?
- 如果是隨機髒污,基本上不會發生。本文的實測中,隨機翻轉模組的3,900次試驗,以及隨機破壞碼字的5,800次試驗,全部都沒有出現返回錯誤值的情況。無法完全更正的損傷會歸結為「讀不出來」。不過,現實中的損傷並非隨機,而是像摺痕、磨損、列印頭堵塞那樣在位置上帶有偏向性。此外,分割QR的漏讀,以及多個代碼的張冠李戴,甚至根本不是機率問題,只要條件具備就每次都會發生。
- QR Code明明有錯誤更正,還需要檢查碼嗎?
- 需要。兩者守護的層面不同。錯誤更正處理的是符號內部的一致性,而且它能保證的復原範圍,僅限於更正能力之內。超出這個範圍的損傷,有可能被「復原」成另一個同樣有效的碼字。檢查碼這邊,檢查的則是「應用程式接收到的字串,是否在代碼體系上成立」。它能確實抓出1個字元的變化,但當多個字元同時變化時會有漏檢。模數10的檢查值只有10種,本文也舉出了2位數變化後檢查碼仍然一致的例子。因此,請把檢查碼視為能擋住大部分誤讀的一層,最終的判斷則交給主檔比對與業務上的核對。它的另一個優點是,手動輸入、抄寫、從其他路徑匯入的資料,也能用同一套檢查來守護。
- 手機相機App都能讀出來了,那這個值應該是正確的吧?
- 「讀出來了」這件事,意味的只是「解碼器返回了一個非空字串」,對其內容是否正確沒有任何說明。失敗的返回方式也因實作而異,例外、空字串、null都有可能出現,因此把「沒有拋出例外」當作成功的判斷依據同樣危險。本文的實測中,針對同一張圖片,OpenCV與jsQR返回不同結果的例子有好幾處。對於用Shift_JIS寫入日文的QR Code,一方把亂碼字串當作成功返回,另一方返回的是空字串。分割QR的第一張,結果同樣出現了分歧。讀不讀得出來,對於值是否正確沒有任何說明。
- 分割QR(Structured Append)最好不要用在業務上嗎?
- 如果沒有特別的理由,避免使用比較穩妥。分割QR是把資料拆分成多個符號、由讀取端收集並串接起來的機制,但當不支援該機制的解碼器只讀到第一張時,實際表現取決於實作。本文的實測中,OpenCV把結尾缺失的單據編號在沒有報錯的情況下返回,而jsQR返回的是空字串。既然存在會把片段當作看似合理的值放行的實作,只要沒有考慮分割QR的情境,就需要有能夠擋下不完整結果的驗證。如果資料放不下,提高QR的版本,或是把代碼縮短、改成從主檔查詢,會比較安全。