異體字落地替換 S0+S1:範圍判定基礎設施 - #1271
Merged
Merged
Conversation
執行 docs/CHAR_VARIANT_MAP_TEXT_COLUMN_ROLLOUT_PLAN.md 的 S0(代碼鍵掃描)與 S1(基礎設施)。本步只建機制與把關,**還沒有任何生產路徑呼叫 replaceRow()**—— 實際串接由 S2–S7 逐步進行。 ## 新增 App\Support\VariantReplaceScope 回答「(表, 欄位) 該用哪個替換模式」的單一權威來源: - **D1 以欄位型別判定**(varchar/char/text 類才過),不以命名規律、不預判有沒有中文。 對照表 key 全是 CJK,所以套在拼音/羅馬字欄上必然 no-op;型別是 schema 的權威事實、 會自動跟上演進,不像命名規律會漏掉 8 個無後綴的中文欄。 - **D2 fail-closed**:未知表一律不替換;表名/欄位名比對大小寫不敏感(config 用小寫 char_variant_map/pinyin、CBDB 表用全大寫、客戶端還可能傳任意大小寫)。 - **D4 預設 lenient**,strict 只用於人名/別名欄且是**逐欄位**例外——同一列 BIOG_MAIN 裡 姓名欄 strict、c_notes lenient。寫反會造成「全庫靜默少替換」這種極難察覺的退化, 所以有專門的測試釘住預設方向。 ## S0 掃描:補上 15 個代碼鍵欄位 手寫清單只涵蓋 7 組 *_CODE_TYPE_REL ↔ *_TYPES 裡的 1 組。新增的其中三個值得記錄: - OFFICE_TYPE_TREE.c_office_type_node_id 是**真實風險**而非理論風險——值域含中文且被 LIKE '%q%' 做中文搜尋;REL 側欄名(c_office_tree_id)與 PK 側不同名,只掃 node_id 會漏 - TEXT_TYPE.c_text_type_code——原本排了 parent 卻沒排被 parent 指的 PK,關聯照樣斷 - STATUS_TYPES.c_status_type_parent_code——後綴是 _parent_code,按 *_parent_id 掃會漏 並加了一支從 CodesController::$tableJoinConfigurations 推導的守衛測試:那份 config 本身 就是「哪些文字欄被當 join 鍵」的權威列表,且每次有人加 join 都會更新,比人工清單可靠。 ## CharVariantMapService - 對照表載入改為「按模式過濾 → 移除環上出邊 → 傳遞閉包」。順序不可顛倒:先算閉包會在 環上無限迴圈。閉包讓幂等性不再依賴表的內容;**必須按模式各自算**,否則 X→峯(excluded=0) + 峯→峰(excluded=1) 會讓 strict 透過傳遞吃到 strict-excluded 的邊, 廢掉 c_strict_excluded 的唯一用途。 - 環的處置是**只丟環上出邊 + 記 error log**,不拋錯也不回空 map——這兩個 map 方法是所有 替換的唯一入口,在此 throw 會讓 Codes UI 80 表、所有 v2 mutate、批次匯入、眾包核准 一起爆(一筆 峰→峯 或打錯字的 A→A 就夠);回空 map 則等於全站靜默不替換。 - 新增 replaceRow()/replaceFor()(單值入口,給手上不是「欄位名為鍵的整列」的掛鉤點)/ assertWritable()(單 codepoint、不成環)/mergeReplaced()/flattenReplaced()。 - buildNotices() 改走 __() 並補兩份翻譯檔(它即將出現在 80+ 張代碼表與所有 v2 回應)。 api 群組沒掛 SetLocaleMiddleware,所以 token API 回應與改動前逐字相同,API.md 不需更新。 ## restore 的結構把關 char_variant_map 明文登記在 resourceKeyColumns(),所以 restore 對它是刻意支援的; 但它同時是所有落地替換的資料來源,還原一筆壞對照會讓全站的替換降級。restore 是 10 個 寫入入口中唯一不在 S2/S5/S6 編輯範圍內的那條,所以在此補。**這與「restore 不做內容 替換」不衝突**:那是不對還原內容套落地替換(保留歷史字形),這裡是對這張表的寫入做結構 驗證——有一支測試把這個區分釘住。 ## review 過程修掉的實質問題 - **registry 漂移守衛原本是假綠**:測試沒有 RefreshDatabase,Schema::getTables() 只回傳 合成表 ⇒ 永遠抓不到「新 migration 建了 CBDB 表卻忘了登記」。移到 Feature 測試後立刻 抓到 8 張未分類的表,已逐筆分類;另加表數下限與白名單互斥兩道防退化斷言。 - **replaced 用 += 合併會回報錯誤的參考字**:同一變體在 strict 與 lenient 的閉包終點可以 不同,+= 保留先出現者 ⇒ 通知說「已正規化為 峯」而 c_notes 實際變成「峰」。抽出 mergeReplaced()(衝突保留兩個)與 flattenReplaced()(供結構化 payload),並把三個既有 消費點一併改掉——否則 S3 接線時會打壞前端 variant_replacements 契約。 - **restore guard 的大小写不對稱會誤擋合法還原**、modeFor() 慢 90 倍(已加 memo, 200,000 次呼叫 1.17s → 12ms,因為 S2–S7 還要把它掛進逐列迴圈)。 驗證:三輪 review agent + codex,全量 phpunit 2821 tests/15116 assertions 全過, php-cs-fixer(已刪 cache、用 dist config)0 fixes。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
執行
docs/CHAR_VARIANT_MAP_TEXT_COLUMN_ROLLOUT_PLAN.md的 S0(代碼鍵掃描)+ S1(基礎設施)。本步只建機制與把關,還沒有任何生產路徑呼叫
replaceRow()—— 實際串接由 S2–S7 逐步進行。所以這個 PR 對使用者可見行為的改動幾乎為零(唯一例外是buildNotices()改走__(),而輸出字串逐字相同)。核心:
App\Support\VariantReplaceScope回答「(表, 欄位) 該用哪個替換模式」的單一權威來源。三個設計決定:
以欄位型別判定,不以命名規律。 這個判準自帶安全論證 —— 對照表 key 全是 CJK,所以套在拼音/羅馬字欄上必然是 no-op,不需要再疊一層「這欄是不是中文」的判斷。而且型別是 schema 的權威事實、會自動跟上演進;用
_chn/_hz命名規律會漏掉 8 個無後綴的中文欄。fail-closed。 未知表一律不替換,比對大小寫不敏感 —— config 用小寫
char_variant_map/pinyin、CBDB 表用全大寫、客戶端還可能傳任意大小寫。預設 lenient,strict 是逐欄位例外。 同一列 BIOG_MAIN 裡姓名欄 strict、
c_noteslenient。寫反會造成「全庫靜默少替換」這種極難察覺的退化(每個欄位看起來都有在替換,只是少了幾筆規則),所以有專門的測試釘住預設方向。S0 掃描補上 15 個代碼鍵欄位
手寫清單只涵蓋 7 組
*_CODE_TYPE_REL↔*_TYPES裡的 1 組。三個值得記錄的:OFFICE_TYPE_TREE.c_office_type_node_idLIKE '%q%'做中文搜尋;REL 側欄名(c_office_tree_id)與 PK 側不同名,只掃 node_id 會漏TEXT_TYPE.c_text_type_codeparent_id卻沒排被 parent 指的 PK —— parent 排了、PK 沒排,關聯照樣斷STATUS_TYPES.c_status_type_parent_code_parent_code,按*_parent_id掃會漏比清單更有價值的是:
CodesController::$tableJoinConfigurations本身就是「哪些文字欄被當 join 鍵」的機器可讀權威列表,而且每次有人加 join 都會更新。所以加了一支從它推導的守衛測試,日後新增 join 設定卻忘了登記就會紅。幂等性的三條配套規則(D8)
對照表載入改為「按模式過濾 → 移除環上出邊 → 傳遞閉包」,順序不可顛倒(先算閉包會在環上無限迴圈)。
X→峯(excluded=0) +峯→峰(excluded=1) 會讓 strict 透過傳遞吃到一條 strict-excluded 的邊,直接廢掉c_strict_excluded的唯一用途(保護「峯」)。峰→峯或打錯字的A→A就夠);回空 map 則等於全站靜默不替換。降級要局部且有聲音。A→B、B→C、C→B)只丟環上節點的出邊,A→B要保留。restore 的結構把關
char_variant_map明文登記在resourceKeyColumns(),所以 restore 對它是刻意支援的;但它同時是所有落地替換的資料來源,還原一筆壞對照會讓全站的替換降級。restore 是 10 個寫入入口中唯一不在 S2/S5/S6 編輯範圍內的那條。這與「restore 不做內容替換」不衝突 —— 那是不對還原內容套落地替換(保留歷史字形),這裡是對這張表的寫入做結構驗證。有一支測試把這個區分釘住,避免日後有人「順手補上」。
Review 過程修掉的實質問題
三輪 review agent + codex。幾個值得記錄的:
RefreshDatabase,Schema::getTables()只回傳合成表 ⇒ 永遠抓不到「新 migration 建了 CBDB 表卻忘了登記」。而計畫 D2 明文說 fail-closed 之所以安全「是現況巧合,所以 S1 必須補漂移守衛」—— 交付的守衛是空的。移到 Feature 測試後立刻抓到 8 張未分類的表,已逐筆分類;另加「表數 > 50」與「白名單互斥」兩道防退化斷言。replaced用+=合併會回報錯誤的參考字。 同一變體在 strict 與 lenient 的閉包終點可以不同 ⇒ 通知說「已正規化為 峯」而c_notes實際變成「峰」。抽出mergeReplaced()/flattenReplaced(),並把三個既有消費點一併改掉 —— 否則 S3 接線時會打壞前端variant_replacements契約。resourceKeyColumns()是字面查表)。modeFor()慢 90 倍 → 加 memo(200,000 次呼叫 1.17s → 12ms),因為 S2–S7 還要把它掛進逐列迴圈。驗證
phpunit:2821 tests / 15116 assertions 全過php-cs-fixer(已刪 cache、用 dist config):0 fixesAGENTS.md§6)api群組沒掛SetLocaleMiddleware,token API 回應與改動前逐字相同,API.md不需更新🤖 Generated with Claude Code