前置文件:第一階段(schema)、第二階段(3 個呼叫點)。 本階段完成第二階段「不在本次範圍內」列出的待辦:把落地替換擴張成所有文本型別欄位的通用機制,並留下防止日後再漏的常規。
文件結構約定:「決策」只寫規則;所有要做的事都在「實作步驟」。同一件事不重複兩處。
S0~S9 全部完成,逐步 rebase merge 進 develop(線性歷史):
| 步驟 | commit | 內容 |
|---|---|---|
| S0+S1 | a821dea0 |
varchar 代碼鍵補充掃描、VariantReplaceScope/replaceRow()/replaceFor()/assertWritable() |
| S2 | 8fe1a8cb |
Codes UI 全表串接(含提案) |
| S3 | 45e701a8 |
人物子資源與 BIOG_MAIN 全面串接,補上兩形並存查重 |
| S4 | d6381042 |
官職/社會機構聚合串接,標籤對照表兩側歸一 |
| S5 | fb9b76db |
token API 代碼表 create/update |
| S6 | c1ffa4a4 |
提案核准的直接寫庫分支,補上核准端的兩形並存守衛 |
| S7 | 9f16c24c |
眾包回填、v1 端點、複製與修復工具 |
| S8 | b0dde593 |
規則(AGENTS.md §1.3、兩份 skill)與機械化把關測試 |
| S9 | 本次 | 文件收尾:API.md/docs/openapi/openapi.yaml/CHANGELOG.md/本文件狀態 |
- D7(兩形並存)的份量被嚴重低估。原計畫把它寫成一條決策;實作中它是每一步都要重做一次
的工作:v2 create 的查重、mutation 的改鍵偵測、ASSOC 鏡像改名、機構名去重鍵、標籤→代碼查表、
提案核准的落庫前檢查,每一處都要嘛兩形都探、要嘛顯式排除。為此抽出
app/Support/VariantEquivalentLookup.php(原計畫沒有這個檔案)與app/Support/VariantLabelMap.php。 多數 review/codex 的 HIGH 都落在這一類:替換把原本乾淨的 409/1062 變成靜默的重複列。 - 「只在真的改鍵時檢查」是後來才明確的。無條件做兩形並存檢查會讓「歷史上就已兩形並存」的
資料變成任何更新都做不了(false 409)。鏡像那一側還要進一步只看在範圍內的 PK 欄,
因為鏡像同步本來就會重寫數值型的
c_kin_id/c_assoc_kin_id。 - 稽核鏈的字形一致性比預期難。除了「替換要早於
resource_id/row_pk組裝」,S6/S7 還發現 刪除路徑必須用實際被刪的那一列去組resource_id與兩份快照——否則同一筆稽核的 id 與 內容字形互相矛盾,還原會重建一列從未存在過的資料。 VariantReplaceScope的表名要正規化兩次。fail-closed 閘門對大小寫/空白不敏感,但查 schema 時若用原字串,會把空的欄位集快取住、讓該表在整個 process 之後都不替換(靜默失效)。 因此補了canonicalTableName();而在兩個外部入口(v1 token API、眾包回填)刻意要求精確拼寫, 不做正規化派發——那會擴大未經信任的輸入能寫到的表。- BIOG_MAIN 的替換必須只針對「使用者這次真的改的欄位」。原計畫的整列
replaceRow()在 v2 等於偷偷做 D6 明文排除的回溯校正,因此updateById()多了$variantFields參數。 Duplicate_Collateral_Info()需要 per-table 去重。歸一可能把兩列塌到同一個文本型 PK 上, 整份複製會因 1062 全數回滾;改為跳過並Log::warning。- 修復工具只替換非主鍵欄。鏡像的定位鍵單邊歸一會讓偵測永遠找不到那一對、修復失去幂等。 同理 pair-only 鏡像修復刻意整列照抄既有列。
- 通知(notices)的覆蓋面比原計畫大:成功之外,409/422 也要帶——被擋下來時使用者更需要
知道「我輸入的字被正規化了」。基底一律用
mergeReplaced()(直接 assign 會把上游通知靜默吃掉)。 - S8 的把關做得比原計畫重。原計畫只要求「列舉 handler + 例外清冊」;實測那樣有四個假綠管道
(例外條目短路掉真實檢查、只
use不呼叫、字串字面值冒充掛鉤、把寫入搬進非 handler 檔案), 因此改成全體 handler 都要交代、比對走 PHP tokenizer、掛鉤點逐檔記數。 - 不做的維持不做:D6(回溯校正)、
restore的內容替換、D9(搜尋端歸一)、MergePreviewController產生的帶外 SQL、已被閘門下架的 legacy 寫入路徑。 docs/openapi/openapi.yaml原本完全沒有notices(不只是沒同步這次的擴張),S9 一併補上。
第一階段的目標宣告是把 TITLE_VARIANT_MAP 擴充成「所有表的錄入端都能查詢的通用對照」。第二階段採逐點手掛,因此目前全庫只有這些生產呼叫:
| 呼叫點 | 模式 | 位置 |
|---|---|---|
| BIOG_MAIN 姓名(v2 update/proposal) | strict | BiogMainMutationHandler.php:218-224 |
| BIOG_MAIN 姓名(create/update,v2 與 legacy 共用同一 repository 方法) | strict | BiogMainRepository.php:253-254、:379-386 |
| ALTNAME_DATA 別名(v2 create/update) | strict | AltnameCreateHandler.php:87、AltnameMutationHandler.php:90 |
| 書名批次匯入 | lenient | AdminBatchLoadBookTitlesController.php:616 |
(另有兩處掛在 legacy Blade 專屬路徑上,隨 legacy 頁面淘汰,本階段不維護、不擴充。)
| # | 缺口 | 影響 |
|---|---|---|
| G1 | Codes UI 的 5 條寫入路徑零替換(config/codes.php 註冊 82 張表,扣掉唯讀與實體聚合封寫後約 76 張可由 UI 寫入;D2 的「已知表」聯集是 83 張,含 UI 未註冊但有 PK schema 的表) |
ADDR_CODES.c_name_chn、APPOINTMENT_CODES.c_appt_desc_chn、KINSHIP_CODES.c_kinrel_chn、EVENT_CODES.c_event_name_chn 等數十個中文欄原樣入庫 |
| G2 | 21 個人物子資源 v2 handler 只有 Altname 的 2 個掛了 | ASSOC_DATA.c_text_title(PK 成員)、BIOG_SOURCE_DATA.c_pages(PK 成員)、EVENTS_DATA.c_event/c_role、POSSESSION_DATA.c_possession_desc_chn、ENTRY_DATA.c_exam_rank/c_exam_field、STATUS_DATA.c_supplement、全表通用的 c_notes/c_pages |
| G3 | 實體聚合(官職/社會機構)零替換 | OFFICE_CODES.c_office_chn;SOCIAL_INSTITUTION_NAME_CODES.c_inst_name_hz 更嚴重——它同時是去重鍵 |
| G4 | token API 代碼表 create 零替換 | TEXT_CODES.c_title_chn 走 CodeTableCreateHandler 不替換、走書名批次匯入會替換——同欄位兩路徑行為不一致(既有 bug) |
| G5 | 提案核准的直接寫庫分支原樣回寫 payload | 代碼表走 applyCreateProposal()/applyUpdateProposal();KIN_DATA/ASSOC_DATA 走 applyKinshipProposal()/applyAssocProposal() |
| G6 | 眾包核准回填不替換 | CrowdsourcingController::confirm() 繞過 repository 與 handler |
| G7 | v1 api/operations/* 提案端點零替換 |
token 認證、仍在服役;v2 的人物 create 提案回 501,沒有替代品 |
| G8 | 人物複製工具零替換,且未被任何閘門攔 | BasicInformationController::saveas()(:1963/:1981)、Duplicate_Collateral_Info()(:2006,寫 8 張表);routes/web.php:161-162 未掛 legacy.form |
| G9 | 單向關係修復工具零替換 | UnidirectionalRelationshipRepairController::executeRepair()(insert :190) |
文本型別(varchar/char/tinytext/text/mediumtext/longtext)一律過機制;其餘不過。不預判該欄有沒有中文——對照表 key 全是 CJK,套在拼音/羅馬字欄上必然 no-op。型別是 schema 的權威事實、自動跟上演進,不像命名規律(_chn/_hz)會漏掉 8 個無後綴中文欄(EVENTS_DATA.c_event/c_role、ENTRY_DATA.c_exam_rank/c_exam_field、STATUS_DATA.c_supplement、ASSOC_DATA.c_text_title、TEXT_INSTANCE_DATA.c_publisher/c_pub_loc)。
已用 docs/DATABASE_SCHEMA.md 驗證:全庫型別只有 varchar(369)/smallint(343)/int(106)/longtext(40)/datetime(40)/timestamp(16)/double(8)/bigint(8)/text(7)/tinyint(4)/varbinary(2)/char(1),無 enum/set/json/blob。type_name 兩個 driver 都已歸一為小寫(MySqlGrammar.php:146、SQLiteProcessor.php:35)。
DATABASE_SCHEMA.md:該文件自己就漏了 CHAR_VARIANT_MAP/KINREL_REDUCTION/TEXT_DATA 三張表。上面的型別統計因此是參考值,真正兜底的是 S1 的漂移守衛。
- 表名/欄位名比對一律大小寫不敏感。
- 未知表不替換(fail closed)。「已知」=
config/codes.php['tables']∪CompositePrimaryKey::SCHEMAS∪config/code_table_writes.php['tables']∪config/code_table_mutations.php['tables']。框架表、紀錄表、客戶端亂傳的字串自然落在未知而跳過。 - 絕不用未驗證的外部輸入當 registry 鍵(v1 端點的
resource必須先過已知表判定)。
已查證 fail-closed 不會靜默廢掉本計畫:G1–G9 所有目標表都在聯集內(實測 83 張),包括容易漏想的 OFFICE_CODE_TYPE_REL/OFFICE_TYPE_TREE/SOCIAL_INSTITUTION_*/EVENTS_ADDR/POSSESSION_ADDR/POSTED_TO_ADDR_DATA/MERGED_PERSON_DATA,以及 G8 Duplicate_Collateral_Info() 寫的那 8 張。但這是現況巧合——新增 CBDB 表若忘了登記進四份 registry 任一,fail-closed 就會靜默跳過它,所以 S1 必須補漂移守衛。
理由:查表若寫成字面精確比對會 fail-open 且不對稱地壞——排除清單漏中 ⇒ 對照表自我吞噬;strict 清單漏中 ⇒ 靜默降級成 lenient ⇒ 人名裡的「峯」被改寫。這不是假想:codes.php 用小寫 char_variant_map/pinyin,CBDB 表用全大寫,而 Api\OperationsController.php:68 的 resource 是客戶端任意字串。
一般性原則:本身就是做文本替換/字形對照用途的地方,以及語義上必須保留原字的地方,一律不掛。 判斷新表/新欄問兩題:(a) 內容「就是字形本身」嗎?(b) 存在目的是「忠實保留當時的原字」嗎?任一為是 ⇒ 排除。
| 排除對象 | 理由 |
|---|---|
一切對照/映射表(char_variant_map、pinyin.c_chn) |
內容就是字形本身,替換等於自我吞噬。pinyin.c_chn 另有第一階段明文設計「異體字各自有讀音」。寫成一般性規則——已下架的 CBDB__TRAD_SIMP_MAP 當年不被誤動純粹因為型別是 varbinary;日後若有人建 varchar 的對照表就會落入範圍 |
CBDB__NAME_FTS.* |
唯讀派生索引,刻意同時保存繁簡兩形供檢索命中 |
| 跨表 join/樹狀關聯的「代碼鍵」(清單見下) | 判準是「這個值是用來跟別表對上的代碼」,不是「是不是 varchar PK 成員」。ALTNAME_DATA.c_alt_name_chn/ASSOC_DATA.c_text_title/BIOG_SOURCE_DATA.c_pages 同樣是 varchar PK 成員,但是內容、必須替換 |
BIOG_MAIN 9 個拉丁人名欄+ALTNAME_DATA 4 個(共 13 欄) |
見 D4 |
稽核欄 c_created_by/c_created_date/c_modified_by/c_modified_date/created_at/updated_at |
依 AGENTS.md §1.2 由系統蓋章。這是署名不是內容。(OperationsProposalController::AUDIT_COLUMNS(:474)只列前 4 個,本清單是其超集) |
URL 欄 c_url_api/c_url_api_coda/c_url_homepage |
識別碼/位址而非文句 |
派生表 ADDRESSES.* 與 DB view(View_BiogInstData、View_PossessionsData) |
由源頭派生重建;改派生物只會與源頭不一致。(Schema::getColumns() 會回傳 view,故需明文排除) |
operations.resource_id/resource/resource_data/resource_original |
resource_id 是序列化複合主鍵、內含中文 PK 成員,改寫會讓提案與目標列脫鉤。更根本的規則:掛鉤一律以「目標表」為 $table,永不以 operations 呼叫;此列僅縱深防禦 |
紀錄/帳號表 users/nl_query_logs/ai_fill_logs/audit_log |
紀錄的語義是「當時實際發生了什麼」,改寫等於偽造紀錄。(D2 fail-closed 已涵蓋,明文列出以固定意圖) |
已查證的代碼鍵清單(直接進排除常數,不必等 S0):
BIOG_MAIN.c_index_year_type_code(varchar(255) →INDEXYEAR_TYPE_CODESvarchar(191) PK)ENTRY_CODE_TYPE_REL.c_entry_type↔ENTRY_TYPES.c_entry_type(有真 FK,且被前綴階層比對where('c_entry_type','like',$id.'%')——Api/ApiController.php:409/:413、EntryTypeTree.tsx;替換會打斷 LIKE 樹走訪)KINSHIP_CODES.c_kinrel/c_kinrel_simplified(join 來源側:ApiController4:119、4_1:208、4_2:235、4_2:406-407)KIN_MOURNING.c_kinrel、KIN_MOURNING_STEPS.c_kinrel(上者的對面側——只排一邊就是「只改 join 一邊會打斷關聯」)KINREL_REDUCTION.c_kinrel_target/c_kinrel_replacement(須與KINSHIP_CODES.c_kinrel對齊)、c_sex(varchar(1) PK 成員,值 M/F/B,CompositePrimaryKey.php:195-196)OFFICE_TYPE_TREE.c_office_type_node_id/c_parent_id(樹狀關聯)- 各
*_TYPE_REL/*_TYPES家族的 varchar 代碼 PK - varchar 自參照樹狀父鍵(非 PK、schema 未宣告 FK)——已掃過全庫 migration,共 5 個:
ENTRY_TYPES.c_entry_type_parent_id(import_cbdb_schema.php:889,指向同表c_entry_type;EntryTypeTree.tsx:32-38以精確 array-key 建樹)、ASSOC_TYPES.c_assoc_type_parent_id(:367)、TEXT_BIBLCAT_TYPES.c_text_cat_type_parent_id(:2024)、TEXT_TYPE.c_text_type_parent_id(:2165)、STATUS_TYPES.c_status_type_parent_code(:1936,指向同表 PKc_status_type_code:1933)——注意它的後綴是_parent_code而非_parent_id,只掃*_parent_id會漏掉。 這一類是 S0 三個結構式條件都抓不到的:不是 PK、schema 沒有宣告 FK,而比對關係散在前端(ENTRY_TYPES在resources/js/)與後端(ASSOC_TYPES/TEXT_BIBLCAT_TYPES在 PHP)兩邊都有,不能只掃一邊。若其中出現可替換字,通用 Codes UI 會只改父鍵或節點鍵之一而直接斷樹
以上現值多為 ASCII、今天替換是 no-op,但那是靠內容安全、不是靠設計安全——沒有機制阻止有人填中文,而只改 join 一邊會直接打斷關聯/樹。S0 是補充掃描(找這份清單之外的),不是從零開始。
DYNASTIES.c_dynasty_chn、SOCIAL_INSTITUTION_TYPES.c_inst_type_hz/c_inst_type_py、NIAN_HAO.c_nianhao_chn 語義上是內容、照常替換(不進排除常數),但同時是「標籤→代碼」的精確比對鍵,處理方式見 S4。誤加進排除常數會讓代碼表側永不正規化,等於把問題凍結。
載體:app/Support/VariantReplaceScope.php 常數 + 測試斷言清單與理由註解同步。不放 config——這些排除是程式正確性前提,不應可由部署設定關掉。
| 欄位 | 模式 |
|---|---|
| 預設:所有文本欄 | lenient(全量 7 筆) |
BIOG_MAIN.c_name_chn/c_surname_chn/c_mingzi_chn |
strict(6 筆,排除「峯→峰」) |
ALTNAME_DATA.c_alt_name_chn |
strict |
BIOG_MAIN 的 c_surname/c_mingzi/c_name/c_surname_proper/c_mingzi_proper/c_name_proper/c_surname_rm/c_mingzi_rm/c_name_rm,ALTNAME_DATA 的 c_alt_name/c_alt_name_pinyin/_pinyin2/_pinyin3 |
排除(共 13 欄,使用者確認) |
modeFor()的預設回傳是'lenient';只有命中 strict 清單才'strict',命中排除/未知表/非文本欄才null。不得寫成「查不到就 strict」或「整張 BIOG_MAIN/ALTNAME_DATA 都 strict」。- strict/lenient 是逐欄位:同一列 BIOG_MAIN 裡姓名欄 strict、
c_noteslenient;ALTNAME_DATA 裡c_alt_name_chnstrict、c_noteslenient。 - 13 個拉丁人名欄「排除」而非 strict,兩個依據:(1) strict 仍會套那 6 筆規則,真有人填漢字時 愼→慎/靑→青 照樣被改;只有排除能真正不碰。(2) 排除順帶消掉一個組合欄失步——
BiogMainRepository::updateById()的c_name/c_name_proper/c_name_rm是從$request重組的(:260/:264/:265),若分欄在$data被替換而組合欄從未替換的$request組出,就破壞:250-252註解保護的 invariant。排除 ⇒ 無替換 ⇒ 無失步。 - (語義補充)羅馬字轉寫的用途就是保留錄入者寫的拼法;中文源頭欄已歸一,轉寫欄不需要、也不該被字形正規化改寫。
⚠️ 不要引用docs/PINYIN_SAVE_NORMALIZE_DESIGN.md當本決定的依據:那份文件管的是 ü/v 拼音歸一化、對異體字替換沒有管轄權,而且它的表格把c_surname/c_mingzi(:37)與c_name(:38、:50)明列為 Tier 1(要轉)。曾有一輪誤引它當「既有規則早就這樣定了」,此警示是為了避免再犯。- 排除不造成中文/拼音失步,三條路徑理由不同(
auto_pinyin()全庫只在store():392被呼叫):create 由auto_pinyin()在替換後從c_name_chn全權重算;update/proposal 從不重新派生拼音(:260由$request組、:225-227由$payload組),拼音欄純屬使用者輸入,一致性責任本來就在使用者,排除=維持現況。
c_notes/c_pages/c_supplement/c_tertiary_type_notes/c_posting_notes/c_autogen_notes 全部 lenient。第二階段曾提「逐字抄錄史料原貌」的保留意見,使用者明確決定全部替換,含備註欄。(c_self_bio 不是文字欄——只存在於 BIOG_SOURCE_DATA、型別 smallint(6),按型別本來就不在範圍。)
只處理「往後新增/修改時」。這個決定有連鎖後果,見 D7——既有列保留變體字形,而新寫入歸一到參考字,任何精確比對都會踩到。
D6 之下,同一個概念會同時以變體形(既有列)與參考形(新列)存在。任何拿文本欄做精確比對的地方都必須處理,否則替換會製造新的分裂:
- 去重/重用查詢(
resolveNameCode()、標籤→代碼 map):必須兩形都探(或把 map 鍵在記憶體內正規化)。只替換傳入值會讓查詢錯過它本來會命中的既有列,於是鑄出第二個碼——比不替換更糟。 - PK 改名(
ALTNAME_DATA.c_alt_name_chn/ASSOC_DATA.c_text_title/BIOG_SOURCE_DATA.c_pages):編輯既有變體形列時替換等於改名,這是想要的「觸碰即歸一」。既有 PK 衝突偵測會處理撞號,但必須確認回乾淨的 409 而非 500。 - 鏡像對面列:見 S3。
- 歷史快照/序列化 PK 當定位器(第四類,最容易寫出 bug):凡是拿舊值去定位既有列的地方,都不可替換那個定位器:
applyUpdateProposal()用buildKeyConditions($keyColumns, $original)(:778)定位既有列。$data要替換、$original絕對不可替換——既有列在 D6 下永遠是變體形,替換定位器會落空、拋「資料不存在或已被刪除」,而:792的改鍵碰撞偵測根本跑不到。- PK 被歸一後,該列所有既有
operations.resource_id(序列化複合主鍵)會與目標列脫鉤:OperationRepository的where('resource_id', …)(:83/:104/:125)、restore()的buildKeyConditions、提案列表的現況比對都會找不到列。D3 只涵蓋「不可改寫resource_id」,反方向(PK 被改寫導致 resource_id 陳舊)是本階段的已知後果,需在 S3 測試涵蓋並在 PR 說明。 - 客戶端/React 編輯器手上的
target.pk在一次歸一化 update 之後即失效(PK 重同步是既有的已知陷阱),S3 要驗證前端有重同步。
c_variant_char 有唯一鍵(migration :18)⇒ 圖是 out-degree ≤ 1 的 functional graph,不會分叉,閉包唯一確定;閉包後 key 集 ∩ value 集 = ∅ ⇒ 重複套用是不動點。三條配套規則:
- 兩欄必須是單一 codepoint(
mb_strlen() === 1)。幂等論證只在單字元 key 下成立:甲乙→丙丁+丁→戊,閉包接不起來(exact match),第一次得丙丁、第二次得丙戊。非 BMP 不會誤擋(mbstring 算 1)。此決定取代第一階段:49/:53那句「varchar(10) 為變體選擇符留餘裕」——在實作出替代不變式(「value 集不得含任何 key 為子字串」)之前不收錄組合字符;欄位長度不變,只是多一道驗證。 - 先按模式過濾、再各自算閉包與環,且
$lenientMap/$strictMap維持兩份獨立快取。今天的實作已滿足(strictMap():153在 SQL 層where(...):156),這條是擋住「共用 loader 載入全表算一次閉包、strict 再按 flag 過濾」那個誘人重構的護欄——別去找現存的 bug,沒有。若做了該重構:X→峯(0)+峯→峰(1) ⇒ 全表閉包得X→峰,其 flag 為 0 於是留在 strict map ⇒ strict 透過傳遞把 strict-excluded 的邊套進人名欄,廢掉c_strict_excluded的唯一用途。 - 對照表缺表時降級為「不替換」,其餘錯誤一律往上拋(S2 實作時新增的決定)。
落地替換從 S2 起掛在 Codes UI 全部 5 條寫入路徑上,若在尚未 migrate/部分遷移的環境
因為缺表而拋錯,整個代碼表的寫入功能都會 500——為了加值功能讓核心錄入功能停擺
是錯的取捨。但降級只限「表不存在」這個確定性條件:早期寫法對所有 Throwable 都降級
並把空 map 快取起來,後果是「一次瞬時錯誤就讓這個 worker 之後所有寫入都不再替換」,
只留一行 warning——那正是本階段最想避免的靜默失效。瞬時錯誤現在一律往上拋,
所以沒有「把失敗結果快取住」的路徑。
「表不存在」這個確定性結果可以在 process 內快取(
$tableMissing,reset()會清): 不快取的話replaceRow()會逐欄各做一次Schema::hasTable(),一次 20 欄的儲存=20 次 metadata 查詢,S3 接上批次匯入後放大成「列數 × 欄數」。 已知限制:PHP-FPM 每個 request 會重建 static,所以這個快取實務上只活在單一 request 內; 長駐程序(queue worker)若在尚未 migrate 的環境啟動過一次,之後即使建了表也會持續 不替換,需重啟 worker(部署本來就會重啟)。 - 執行順序必須是「按模式過濾 → 偵測並移除環上出邊 → 對剩餘無環圖算閉包」。反過來(先算閉包再偵環)不可實作:對
A→B+B→A或自環A→A,閉包在環移除前沒有定義,一般的走鏈寫法會無限迴圈。 - 環的處置:只丟棄構成該環的邊 + 記 error log,其餘照常。兩個 map 方法是所有替換的唯一入口,在此 throw 會讓 Codes UI 80 表、所有 v2 mutate、三支批次匯入、眾包核准、提案核准一起爆(一個
峰→峯或打錯字的A→A就夠);回空 map 則等於全站靜默不替換。定位精確:functional graph 下「環上節點」=「從自己出發能走回自己」,逐 key 走鏈 + visited set 即可,A→A自然當長度 1 的環丟掉;改動侷限在兩個 map 方法,不需動replaceUsing()。「鏈進入環」(A→B、B→C、C→B)只丟環上節點(B、C)的出邊,A→B要保留。
D6 之下,新資料是「清」、舊資料是「淸」,而搜尋路徑上沒有任何異體字歸一化 ⇒ 使用者用任一形都搜不到另一形。落差本來就在,但本階段會顯著放大它。
成因要指對:VariantCharNormalizer 的 6 個呼叫點(AdminBatchLoadBookTitlesController:586/:649、ApiController:651、BiogMainRepository:4101/:4132/:4144)做的全是拼音派生,不是查詢端歸一化;NameSearchService/NameSearchIndexService/PersonBrowserService/PinyinSearchNormalizer 零引用它;CBDB__NAME_FTS.is_simplified 只覆蓋繁簡(OpenCC),不覆蓋異體字。真正的槓桿在姓名搜尋/FTS 建索引。
(量化依據:VariantCharNormalizer::$fallbackMap 是硬編的 7 字 菴攷嶽愼註于槀,與 char_variant_map 的變體集僅 2 筆交集(愼、槀)——就算把它接上對照表也解決不了搜尋落差,因為搜尋根本不經過它。)
本階段不動搜尋路徑(牽動姓名搜尋與 FTS 重建,風險性質不同),但 S9 必須把後果寫進 PR/CHANGELOG,S8a 必須把「新增對照要評估搜尋端(不是改 VariantCharNormalizer)」寫進 AGENTS.md,並列為下一階段候選。
final class VariantReplaceScope {
public const TEXT_TYPES = ['varchar','char','tinytext','text','mediumtext','longtext'];
public const STRICT_COLUMNS = [
'BIOG_MAIN' => ['c_name_chn','c_surname_chn','c_mingzi_chn'],
'ALTNAME_DATA' => ['c_alt_name_chn'],
];
// 排除清單見 D3(整表/逐欄/任何表都排除三組常數)
/** @return 'strict'|'lenient'|null null = 不替換(排除/未知表/非文本欄) */
public static function modeFor(string $table, string $column): ?string;
public static function textColumns(string $table): array; // 按表快取
public static function isKnownDataTable(string $table): bool;
public static function reset(): void; // 必須在 TestCase::setUp() 呼叫
}registry 抽取的形狀陷阱:codes.php['tables'] 與 code_table_writes.php['tables'] 是以表名為鍵的 map(array_keys());code_table_mutations.php['tables'] 是list of maps、表名在 'table' 值(array_column($t,'table'))。對第三份誤用 array_keys() 會得到 "0".."13" 並漏掉 14 張真表——今天恰好被掩蓋(那 14 張也都在 codes.php),純屬巧合。實測聯集 = 83 張表(僅在大小寫不敏感下成立;敏感會算成 84,差的是 CHAR_VARIANT_MAP vs char_variant_map)。
型別探測與快取:Schema::getColumns($table) 的 type_name。注意 getColumns() 在本專案是全新用法(全庫只用過 getColumnListing()/getColumnType()),但 type_name 只有它才有,Laravel 12 兩個 driver 都支援。必須按表快取(批次匯入逐列迴圈,否則 N 次 metadata 查詢)。
/** 整列替換:逐欄查 modeFor()。非字串值(int/null/陣列)原樣跳過——刻意的淺層掃描,
* POSTED_TO_ADDR_DATA 的 resource_data['rows'] 與 __proposal_aux 這類嵌套/非欄位鍵不該被當欄位處理。 */
public static function replaceRow(array $data, string $table): array; // {data, replaced}
/** 單值替換。掛鉤點手上常常不是「以欄位名為鍵的整列」——OfficeImportService 在 buildPinyin() 之前
* 拿到的 $input 鍵是 name/name_alt/notes(欄位名只在 officeColumns() 的 return、:113-124 才出現),
* resolveNameCode(string $name) 更是裸字串。對這些位置呼叫 replaceRow() 會靜默 no-op。 */
public static function replaceFor(string $table, string $column, string $value): array; // {text, replaced}
/** char_variant_map 專用結構驗證:單 codepoint、不成環。
* $excludeId:更新/還原既有列時要排除該列的舊邊,否則會誤報環——
* 表有 id=5 `乙→甲`、id=9 `甲→丙`,把 id=5 改成 `丙→乙` 是合法的 `甲→丙→乙`,
* 但把舊邊算進去會看到 `乙→甲→丙→乙`。
* $row 可能是部分 payload(restoreUpdate 用歷史快照),需與現有列 merge 後再驗。 */
public static function assertWritable(array $row, ?int $excludeId = null): void; // throws VariantMappingExceptionassertWritable() 必須拋專屬型別 App\Exceptions\VariantMappingException,呼叫端也只能 catch 它。
Illuminate\Database\QueryException 繼承 PDOException 繼承 \RuntimeException,而 assertWritable()
內部會查兩次 char_variant_map——呼叫端若 catch (\RuntimeException),任何資料庫錯誤都會被當成
「驗證失敗」,把原始 SQLSTATE 與 SQL 字串顯示給使用者,而且該次寫入會被靜默跳過而不是誠實地 500。
| 介面 | 通道 |
|---|---|
Codes UI(Blade + React 共用 perform*) |
flash($msg,'info') |
| v2 JSON API | 頂層可選 notices,用既有 withNotices() |
| 批次匯入 | 逐列 variant_replacements,比照書名匯入 |
| 眾包回填、提案核准、v1 端點、複製工具 | 無通知(無互動使用者在場,記錄在 operations/audit 可查) |
buildNotices() 目前硬編繁中,S1 一併改走 __() + 同步兩份翻譯檔(依 AGENTS.md §6)——它即將出現在 82 張代碼表與所有 v2 回應。
422/409 的錯誤回應也必須帶 notices:替換在 hasEffectiveChanges() 之前、且替換後的值才用於查重,所以使用者會遇到「只把『淸』改成庫裡已有的『清』→ 422 no_effective_changes」與「輸入自認不同的標題、替換後撞既有列 → 409」。不帶說明的話使用者無從得知系統改了字。
每步完成後:review agent 檢查到沒有嚴重 issue →
codex exec --dangerously-bypass-approvals-and-sandbox(PowerShell +Write-Output "..." |管道傳 prompt)檢查到沒有嚴重 issue → commit、PR、rebase merge 保持線性 git log → 才進下一步。
D3 已內嵌一份已查證的代碼鍵清單,直接進排除常數。本步是找那份清單之外的:對聯集內 83 張表的每一個 varchar/char 欄,凡 (a) 是 PK 成員、(b) 任一側有宣告的 FK、或 (c) 在應用碼裡被 exact 或 prefix(LIKE 'x%')比對到別表,就逐一判定「代碼鍵 vs 內容」。命名式判準不足(c_index_year_type_code 與 c_entry_type 都不符命名規律卻是代碼鍵),所以判準必須是結構式的。
*_parent_id 也有 *_parent_code(STATUS_TYPES),所以按後綴掃也會漏。
本步的額外掃描要用語義判準而非命名:「這個 varchar 欄的值是否對應同表的文字型 code PK」;並同時掃 app/ 與 resources/js/ 對代碼表欄位做 array-key/=== 比對的地方。(D3 那 5 個已是全庫 migration 掃描的結果,本步是確認沒有第 6 個。)
注意 (a) 單獨不足以判定排除——ALTNAME_DATA.c_alt_name_chn/ASSOC_DATA.c_text_title/BIOG_SOURCE_DATA.c_pages 都是 PK 成員但屬內容、必須替換。三個條件只是篩出候選。
另跑一次 prod schema 欄位比對(不是 migrations):型別導向的範圍會靜默納入 prod-only 文本欄,而 TEXT_TYPES 守衛與已知表守衛都看不到它們(D2 的 caveat 只涵蓋 prod-only 的表)。已知 prod-only:ALTNAME_DATA.c_alt_name_pinyin/_pinyin2/_pinyin3(已排除)與 c_alt_name_role(待歸類;只在合成測試表出現,string(...,50),所以上面那個讀 live schema 的結構式掃描碰不到它)。
VariantReplaceScope、replaceRow()、replaceFor()、assertWritable()(見實作設計)。- 對照表載入改為先按模式過濾 → 偵測並移除環上出邊(記 error log,不拋錯、不回空 map)→ 對剩餘無環圖算傳遞閉包。順序不可顛倒(見 D8 第 3 點:先算閉包會在環上無限迴圈)。
buildNotices()改__()+ 兩份翻譯檔。- 在
OperationsController::restore()掛assertWritable():char_variant_map有 10 個寫入入口,restore 是唯一不在 S2/S5/S6 編輯範圍內的那條(restoreUpdate():1196update($payload)、restoreDelete():1227updateOrInsert/:1229insert;該表明文登記在resourceKeyColumns():1505)。superadmin 還原一筆對照就能重新引入環/多字元 key、繞過其餘 9 個 guard。這與「restore 不做內容替換」不衝突:那是不對 restore 的內容做落地替換,這裡是對這張表的寫入做結構驗證。restore()已有 try/catch →flash(...,'error')(:1109-1140),拋錯會乾淨降級。 - 在
TestCase::setUp()加VariantReplaceScope::reset()(與既有CharVariantMapService::reset()並列)。必須做:測試自建簡化合成表,同一表名在不同檔案有不同欄位集(ApiV2CreateBiogMainTest.php:146-151vsBiogMainProposalTest.php:59-60),PHPUnit 單一行程 ⇒ 前一檔案暖起來的型別快取對下一檔案是錯的,結果會依檔案順序漂移。 - guard 與 reset 的入口數不同,別混用:
assertWritable()掛 10 條(所有能寫char_variant_map的應用層路徑,含 3 條只寫operations的提案路徑——在那裡擋是提早拒絕)。掛載分工:本步只做 restore,其餘由 S2(Codes UI 5)、S5(token API 2)、S6(提案核准 2)各自完成。CharVariantMapService::reset()只需 7 條真正落庫點:Codes UI 2(performStore()insert:1772/performUpdate()update:1355)+ token API 2(CodeTableCreateHandler:101、ConfigCodeTableMutationHandler)+ 提案核准 2 + restore 1。三條提案路徑不改對照表,reset 沒有意義。- migration 兩者都繞過,靠資料不變式測試兜底。
- 不能用 Eloquent observer:
app/Models/下沒有char_variant_map的 model,每條寫入都是DB::table();observer 攔不到,要攔就得把刻意 table-agnostic 的泛用 CRUD 為這張表特例化。單 codepoint 那一半可選配用 DB CHECK 做成真落庫級(連 migration 都涵蓋),但 SQLite 不支援對既有表加約束(需整表重建)且函式名不同(CHAR_LENGTHvslength,依AGENTS.md§1 要is_mysql()/is_sqlite()分支)——列為選配硬化,非必須。 - 測試(
tests/Unit/VariantReplaceScopeTest.php、CharVariantMapServiceRowTest.php):- 型別判定:varchar/char/text/longtext 是;integer/smallint/datetime/varbinary 不是(部分型別全庫不存在,用合成表)。
TEXT_TYPES漂移守衛——⚠️ 天真實作在 SQLite 上抓不到目標:Laravel 把->enum()編成varchar+check、->json()編成text,守衛全綠而 MariaDB 端真的逃出範圍。三者擇二:(a) driver-aware 並在 CI 對 MariaDB 跑;(b) 掃database/migrations出現->enum(/->json(/->binary(即紅;(c) fail-closed——observedtype_name必須全在明文分類表內,未分類即紅。- 已知表 registry 漂移守衛(與上者對稱):live schema 每張表要嘛在聯集內、要嘛在明文的「非 CBDB 資料表」清單內。邊界要註明:(i) 測試 schema 來自 migrations,抓的是「新 migration 忘了登記」,抓不到 prod-only 表;(ii)
Schema::getTables()在 MySQL 某些版本含 view、SQLite 另有getViews(),driver 不對稱要明確處置。 - 預設方向(擋「預設寫成 strict」):未登記的文本欄(如
EVENT_CODES.c_event_name_chn)→ lenient,「峯」→「峰」。 BIOG_MAIN.c_surname_chn→ strict(「峯」不變);同一列c_notes→ lenient(「峯」變)。ALTNAME_DATA.c_alt_name_chn→ strict 但同表c_notes→ lenient(驗證逐欄位、非逐表)。- 登記完整性:strict 4 欄與排除 13 欄逐欄逐一斷言(抽驗擋不住漏登)。
- 大小寫不敏感:
biog_main.C_SURNAME_CHN仍 strict。fail-closed:未知表回 null。 - 排除逐筆:
char_variant_map.c_variant_char、pinyin.c_chn、c_modified_by、c_url_homepage、operations.resource_id。 - 幂等:套兩次 == 套一次,且在對照表被人為插入成鏈時仍成立。
- 環:
A→B+B→A→ 丟這兩條、其餘生效、記 log、不無限迴圈;A→A同樣;A→B+B→C+C→B時A→B必須保留。 - 鏈跨越 excluded 邊界:
X→峯(0)+峯→峰(1) ⇒ lenient 對X得「峰」、strict 對X得「峯」、strict 對「峯」不動。 - 單 codepoint 驗證擋下多字元;現有 7 筆種子與既有 fixture 全為單一 codepoint(已查證,不會弄紅既有測試)。
- 資料不變式:現有 7 筆無鏈無環——變體集
愼槀峯靑頴淸厰與參考集慎稿峰青穎清廠無交集(這是 D8 幂等論證的資料側前提,也是 migration 繞過 guard 時的唯一兜底)。 - 四份 registry 抽取形狀:斷言各自抽出的表名數量與內容符合預期(擋「對
code_table_mutations誤用array_keys()」那個今天恰好被掩蓋的陷阱)。 assertWritable()的$excludeId:上面乙→甲/甲→丙/改成丙→乙的合法案例不得誤報。- 哨兵值不變式:
[n/a]/-9999/<待删除>與c_variant_char集合無交集(今天成立是資料巧合)。 - 淺層掃描:嵌套陣列值原樣保留。
S1 已完成的實作約定(S2–S7 必讀):
replaced的值在衝突時會是 list —— 同一個變體在 strict 欄與 lenient 欄的閉包終點可以不同(龴→峯(excluded=0) +峯→峰(excluded=1):strict 得「峯」、lenient 得「峰」)。因此:
- 只能經
CharVariantMapService::buildNotices()/withNotices()/flattenReplaced()消費, 不可直接foreach取值或implode(會拋 "Array to string conversion",或把陣列 JSON 化進前端 payload 而打壞契約)。- 合併兩份
replaced一律用CharVariantMapService::mergeReplaced(), 不可用+=或array_merge(兩者都會靜默丟掉一個參考字,讓通知與實際落庫的字形不一致)。- 需要結構化 payload(例如批次匯入結果頁的
variant_replacements)時用flattenReplaced()。
5 條路徑在現有 normalizeCodeTablePinyin() 呼叫的下一行插入 replaceRow($data, $table)::1766(performStore)、:1349(performUpdate)、:1512(performProposalStore)、:1843(performProposalUpdate)、:1624(proposalUpdateExisting)。
- direct 兩條:
replaced非空時把buildNotices()的每則訊息flash(...,'info')(不要自己組字,見上方 S1 約定);它們之後才呼叫applyColumnDefaultsForBlanks()(:1769/:1352),三條提案路徑不呼叫它,只需在記 operation 之前。 - 提案三條:替換後的值進
operations.resource_data($table傳目標表,不是operations)。 - D7 兩形並存的去重(S2 實作時發現、原本漏掉):
ALTNAME_DATA.c_alt_name_chn同時是文本主鍵成員又在替換範圍內(strict),而ALTNAME_DATA是 Codes UI 可寫的表——這是 Codes UI 裡唯一會踩到 D7 的形狀。D6 之下既有列的主鍵可能還是變體形,只用替換後的值查重就會錯過它、鑄出語義重複的第二列(比不替換更糟),而資料庫唯一鍵擋不住(兩個字形是不同的鍵值)。因此三條 create 路徑(performStore/performProposalStore/proposalUpdateExisting的 create 分支)都要再以主鍵的「等價字形集合」查一次。⚠️ 只探「輸入值 + 替換後值」兩形是不夠的:對照是多對一(c_variant_char有唯一鍵、c_reference_char沒有),所以既有列可能是另一個變體——既有菁客(菁→青)、新輸入靑客(靑→青),兩者都歸一成青客,但拿靑客/青客去查都找不到菁客。⚠️ 也不要用「列舉所有等價字形再逐一查」(第二版這樣寫、被 codex 抓到):查詢次數等於等價字形數(最壞數十次),而為了避免組合爆炸設的上限本身是正確性缺口——超過上限就退回只比對正規形,等於完全失去這道去重,且那是可由合法對照表資料觸發的(一個參考字有 6 個變體、主鍵含 2 個這樣的字就是 7×7=49)。 正確做法:把不在替換範圍內的主鍵欄固定在 SQL 條件裡取回那一小群候選列,再在 PHP 端把它們的文本主鍵歸一後比對。這是精確的(不管對照表什麼形狀)、而且只要一次查詢。全部主鍵欄都可替換的表會退化成全表掃描——目前沒有這種表,真的出現時記 warning 並跳過,不要靜默掃全表。 待審提案那一側則是以resource_id的位置式 LIKE 樣式收斂(在替換範圍內的欄位放%、其餘放實際值)+lazyById()分批。兩個實作陷阱: (a) 不能只做「前導前綴」——production 的ALTNAME_DATA主鍵順序是(c_alt_name_chn, c_alt_name_type_code, c_personid),第一欄正是可替換的文字欄,前導前綴會直接失效而退回全掃。 (b) 不能用cursor()當作「記憶體有界」——PDO MySQL 預設是 buffered query(config/database.php沒關MYSQL_ATTR_USE_BUFFERED_QUERY),整個結果集仍會先進 PHP 記憶體;要用lazyById()。 測試 fixture 的主鍵順序必須與 production 一致,否則就會像第一版那樣測不到 (a)。⚠️ 待審提案也要用等價字形比對:hasActiveCreateProposalConflict()是拿resource_id做完全相等比對,S2 之前留下的 pending 提案帶的是變體形resource_id,新提案帶歸一後的 ⇒ 不會衝突 ⇒ 兩筆待審並存,依序核准就落成兩種字形的兩筆列。 update 路徑不受影響——它的定位器來自 URL 主鍵。 - 同時涵蓋 Blade 與 React(共用
perform*)。 char_variant_map的 guard:performStore()/performUpdate()落庫前呼叫CharVariantMapService::assertWritable($data, $id)(違反回 flash error 且不寫入),成功後呼叫CharVariantMapService::reset()。三條提案路徑寫的是operations、不改對照表,不需要 reset,但仍建議在提案建立時先跑assertWritable()提早拒絕。- 測試:
ADDR_CODES.c_name_chn含「淸」→ 落庫「清」+ flash;拼音欄拉丁字串 no-op;數字欄不變;char_variant_map自身c_notes含「淸」不被替換;pinyin表新增「峯」讀音 →c_chn保持「峯」;提案resource_data是替換後值且resource_id未被改寫。
在兩個抽象基底類別插入通用掛鉤,21 個子類自動生效:
AbstractPersonSubresourceCreateHandler::121呼叫preprocessCreateData()之前。:124才extractPkFromRow()、:127才findExistingRow(),所以 PK 成員替換後的值自然成為新 PK 且查重看到替換後值。(白名單過濾:112、validateFields:115都早於掛鉤點;已逐一確認 24 個preprocess*覆寫沒有任何一個從其他欄位反推中文欄或還原 PK 成員。)AbstractPersonSubresourceMutationHandler::137呼叫preprocessUpdateData()之前(早於:141 hasEffectiveChanges()與:227 buildNewPk())。- 三個體系外例外各自補掛、同樣在 PK 計算/查重之前:
PossessionCreateHandler、PostingCreateHandler、SourceMutationHandler(後者寫的BIOG_SOURCE_DATAPK 第三欄就是文本欄c_pages)。 replaced必須由通用掛鉤收集並放到基底類別的 protected 屬性,子類改讀它,並移除子類重複的replaceStrict()呼叫(保留BracketNormalizer/PinyinUmlaut順序)。否則通用掛鉤先跑、值已正規化,AltnameCreateHandler:82-89的replaced恆為[],別名替換通知靜默消失。既有屬性在AltnameCreateHandler:23/AltnameMutationHandler:24,使用點:104/:111與:137/:144;沒有其他子類自己呼叫CharVariantMapService。基底必須用 merge 而非 assign,否則日後子類覆寫會靜默吃掉通知(正是本問題的失效模式)。補「別名通知不消失」回歸斷言。- BIOG_MAIN 也不在基底體系內,必須單獨處理:
BiogMainCreateHandler:26/BiogMainMutationHandler:23都extends AbstractMutationHandler;實寫在BiogMainRepository::store()/updateById(),只手掛了三個姓名欄(:253-254、:379-386),c_notes/c_tribe/c_fl_ey_notes/c_fl_ly_notes全漏;且BIOG_MAIN在HANDLER_ROUTED_RESOURCES(:57-68,11 筆)、提案核准重放同一條,S6 也蓋不到。- 三處手掛改用
replaceRow($data,'BIOG_MAIN'):store()、updateById()、以及BiogMainMutationHandler::prepareProposalPayload()(:214,replaceStrict在:218-219)——後者不經 repository,只改前兩處會讓非姓名文本欄在提案 payload 裡不被替換,使用者在審核畫面看到的字形與核准後落庫的不一致,S6 宣稱的雙保險對它不成立。 store():378-389的分支必須原樣保留:只有在c_surname_chn/c_mingzi_chn之一以 key 形式存在時才由分欄重組c_name_chn(:378是array_key_exists(...) || array_key_exists(...)),否則走:385-388直接替換c_name_chn本身。:370-377有長註解、ApiV2CreateBiogMainTest.php:374(testDirectBiogMainCreateWithOnlyNameChnDoesNotClearItWhenPartsAreAbsent)就是只送c_name_chn的案例。天真地「先replaceRow()再無條件相加」會把c_name_chn抹成空字串——第二階段 review 已抓過一次的資料損毀 bug。組字要讀替換後的$data/$payload,不可讀$surnameReplaced['text']或$request。- 回傳 key 名不一致,四個消費者都要改:
store():414回'replaced'、updateById():340回'variant_replaced';消費者BiogMainCreateHandler:158、BiogMainMutationHandler:134、BasicInformationController:1761、BasicInformationController:1938。統一命名時漏一處就是靜默掉通知或 undefined key。
- 三處手掛改用
ASSOC_DATA.c_text_title替換會改寫對面鏡像列的 PK 成員:AssociationMutationHandler::afterDirectUpdate():124把$targetPk['c_text_title'](替換前的 URL pk)當定位器傳給BiogMainRepository::syncAssocMirrorOnUpdate():2650,該查詢用舊值定位、而$dataMirror帶替換後的標題去update()。所以定位不會落空、#66/#70 不會誤觸發,鏡像會收斂——但有兩個後果要處理:(a) 那個 update 改的是對面那個人的列的 PK 成員,該側沒有 PK 衝突檢查,若參考形的鏡像列已存在會撞唯一鍵而冒成 500 而非乾淨的 409;(b) 這是對既有資料的回溯改寫,與 D6 精神有張力,明文記錄為「觸碰即歸一」的刻意例外。(定位器見RelationshipMirrorService::locateOppositeEdges():135/reverseRelationExists():193(c_text_title條件在:212)與BasicInformationProposalController:622;不存在mirrorExists()這個方法。)補回歸測試涵蓋 (a)。- v2 回應以
withNotices()帶notices;422/409 也要帶。 - 測試(
tests/Feature/ApiV2MutateVariantReplacementTest.php):ASSOC_DATA.c_text_title(PK 成員 + 鏡像撞號回 409)、BIOG_SOURCE_DATA.c_pages(PK 第三欄)、EVENTS_DATA.c_event、POSSESSION_DATA.c_possession_desc_chn、ENTRY_DATA.c_exam_rank、STATUS_DATA.c_supplement、各表c_notes;BIOG_MAIN 姓名 strict 而同列c_notes/c_tribelenient(與上面replaceRow的決定是同一個原子變更);編輯既有變體形列時的 D7「PK 改名」行為。
實作時 review 找出四個計畫原本沒列、但必須一起處理的問題,都已在同一步完成:
-
D7「兩形並存」查重必須同時接到 v2 create 側(原本只在 S2 為 Codes UI 做)。 既有列可能存變體形(D6 不做回溯校正),只比對替換後的值會讓「原樣重送變體形」 INSERT 出第二列語義重複資料,而唯一鍵擋不住(不同字形=不同鍵值)——比不替換更糟, 而且落地替換上線前這種輸入本來是乾淨的 409。 做法:把
CodesController的四個 helper 抽成App\Support\VariantEquivalentLookup(controller 改為薄委派),接到AbstractPersonSubresourceCreateHandler的既有列查重 與待審提案防呆、以及SourceMutationHandler的 create 兩處。 踩到的陷阱:resource_id格式依表而異——代碼表是buildCompositeId()的位置式'_._'串接(可用位置式 LIKE 樣式收斂),人物子資源是CompositePrimaryKey::buildStoredResourceId()=http_build_query()查詢字串, 位置式樣式對它完全無效(會靜默失去這道查重)。後者改用有索引的operations.c_personid收斂候選集。 -
BIOG_MAIN 的替換範圍收到「使用者本次實際變更的欄」。
BiogMainMutationHandler是唯一在伺服器端把「原列 ∪ changes」合成整列再送進 repository 的資源,整列替換會 (a) 回溯改寫使用者沒碰過的既有欄(違反 D6)、(b) 讓updated_fields與實際落庫不一致, 提案審核 diff 出現提案人沒改過的欄、(c) 讓某個舊欄被歸一而把「完全沒改」的存檔變成 一筆真實 UPDATE。做法:updateById()與prepareProposalPayload()各加一個$variantFields參數(null=整列,legacy Blade 表單路徑用;v2 傳$updatedFields)。 這也讓 BIOG_MAIN 與其他 20 個資源(只替換$updateData/$rowData)語義一致。 -
VariantReplaceScope::loadColumnTypes()不再快取瞬時錯誤的負結果。原本任何Throwable都回[],而該結果會被$columnTypes/$textColumnCache/$modeCache三層快取住 ⇒ 一次連線抖動就讓該表在這個 process 的剩餘生命週期都不做替換(長生命 週期的 queue worker/artisan 批次匯入尤其致命),資料靜默留變體形且沒有錯誤回應。 改為:只有Schema::hasTable()確認「表確實不存在」才降級並快取,其餘一律 rethrow ——與CharVariantMapService::loadEdges()的策略對齊。 -
char_variant_map的結構守衛下移到落庫層。它登記在config/code_table_mutations.php,所以/api/v2/create//api/v2/mutate就能寫, 而assertWritable()/reset()原本只掛在CodesController與OperationsController⇒ 用 API 新增一筆成環的對照(表裡已有峯→峰,再送峰→峯)會落庫成功,之後dropCycleEdges()把環上兩條邊一起丟掉、只留Log::error,這組字的替換在全站靜默停止。 S3 把替換面擴到所有人物寫入路徑後,影響面從 Codes UI 擴到全站。做法:新增Concerns\GuardsCharVariantMapWrites,掛在CodeTableCreateHandler、AbstractCodeTableMutationHandler::handleDirect()、CodeTableDeleteHandler。
第二輪 review 又找出四項,同樣已在本步修完:
-
D7 查重必須同時接到改鍵(update)側,不是只有 create。DB 唯一鍵只擋同字形;把某列 的文本型 PK 成員改成「歸一後等於另一既有變體形列」的值時,精確比對查不到、唯一鍵也不 衝突 ⇒ 一樣落成兩形並存。修在
AbstractPersonSubresourceMutationHandler::handle()(分派 direct/proposal 之前,一次涵蓋兩條路)與SourceMutationHandler的 update 分支。 自我排除必須比對「實際命中那一列的 PK 值」而非$targetPk:payload 的 PK 可能帶哨兵 別名(sources的c_textid=-999實際落庫是 0),拿別名比會把原列自己誤判成別列而回假 409 (testDirectSourceUpdateSupportsSelect2ZeroTextIdAliasWithoutChangesPersonId抓到過一次)。 -
meta.__內部鍵不可由客戶端帶進來。__approving_operation_id是核准重放時「排除待審 的自己那一筆」的內部訊號(核准端直接呼叫 handler、不經 API 控制器)。原本 API 把meta原樣傳進 handler ⇒ 眾包使用者只要塞上自己那筆待審提案的 id,就能讓待審重複防呆(含 D7 等價字形查重)放行,再送一筆變體形的重複提案。修法:Api\MutationController的 5 個入口 統一以sanitizeClientMeta()剝掉__前綴鍵(同時堵住SourceMutationHandler既有的同型洞)。 -
char_variant_map的守衛還要補提案與通用核准兩條路。它不在OperationsProposalController::HANDLER_ROUTED_RESOURCES,核准走通用applyUpdateProposal()的 raw update:提案時不驗、核准時也不驗、且不重置快取。修法:AbstractCodeTableMutationHandler::handleProposal()提交即擋,applyUpdateProposal()落庫前assertWritable()、落庫後reset()。 -
順手修掉一個既有的姓名清空 bug(develop 上行為相同,但正好在本次重寫的那幾行):
updateById()/prepareProposalPayload()原本無條件c_name_chn = 姓 . 名,而 v2 的 payload 是「原列 ∪ changes」⇒ 沒有明確姓氏的歷史人物(兩個分欄 NULL、c_name_chn存完整 姓名)只要被更新任何一欄,姓名就被靜默清成空字串。store()早有等價保護,現在三處一致: 兩個分欄都空時不重組。
測試 harness 另修三處(FakeQueryBuilder 把 != 當等值比較會讓語義完全反過來、
FakeSchemaBuilder 缺 getColumns()/getForeignKeys())——都是假 driver 與真實 Laravel API
的落差,不是生產碼問題;補上後 personFkColumns() 那段也不再被 catch(\Throwable) 靜默吞掉。
另記兩件刻意接受的行為(不修,S9 一併寫進文檔):
ASSOC_DATA.c_text_title被替換時,syncAssocMirrorOnUpdate()會把對面鏡像列的同名 PK 成員一起改名(定位器用替換前的舊值,不會落空)。這是對既有資料的回溯改寫,與 D6 精神有張力,明文記為「觸碰即歸一」的刻意例外。撞既有參考形鏡像列時是乾淨的 409 + 整筆 回滾(走基底的isUniqueConstraintViolation),已有回歸測試。- 文本型 PK 成員改名後,改名前的
operations.resource_id/audit_log.row_pk仍指向舊 字形,同一筆資料的歷史被切成兩個身分。這是任何 PK 改名共有的性質(手動改別名一樣 會發生),不是替換引入的新機制;若要處理應該是「改名時遷移舊 resource_id」的獨立工作, 不是收窄替換範圍。
這一步的所有精確比對都要按 D7 處理兩形並存。
OfficeImportService::officeColumns()(app/Services/Import/OfficeImportService.php:100):對c_office_chn/c_office_chn_alt/c_notes/c_pages用replaceFor()(不是replaceRow()——此處$input鍵不是欄位名),必須在buildPinyin()(:106、:109)之前。已查證buildPinyin()從中文逐字派生(Concerns/SharesImportHelpers.php:41-57),且pinyin.c_chn被排除、異體字保有自己讀音 ⇒ 先替換才拿到參考字的讀音,正是想要的。SocialInstituteImportService::resolveNameCode()(:166,where('c_inst_name_hz',$name):170)必須兩形都探。⚠️ 只替換傳入值會製造新的分裂:既有列字面是「淸…」在 D6 之下永不改寫,把匯入值正規化成「清…」會讓精確比對錯過它本來會命中的那一列,於是鑄出第二個 name code——比不替換更糟。做法:以替換前後兩個值查一次(whereIn),命中既有列就複用其碼;都沒有才新建(用參考形)。既有實作已是->orderBy('c_inst_name_code')->lockForUpdate()->first()(:170-173),沿用該排序即「最小碼優先」,兩形都在時的選擇是確定的——不要自行換排序。呼叫端create():244/update():313。 由此產生一個刻意的不對稱,要明文記錄:兩形都在時複用既有變體形列,該列的c_inst_name_hz仍是「淸…」、永不歸一,與 D7 第二類「PK 改名=觸碰即歸一」的語義相反。這是為了不製造重複碼而接受的取捨。- 「標籤→代碼」精確比對(D3 標註的三張表),兩件事都要做:
- 建 map 時就把 map 的鍵在記憶體內正規化(與 S2、與 D6 無關)。
⚠️ 前一版計畫說「S2 會把代碼表那側正規化,所以只替換傳入標籤就夠」是錯的:D6 之下既有DYNASTIES列若字面是「淸」則永遠是「淸」,而使用者標籤「清」本來就是參考字、替換後不變 ⇒ 照樣落空。 - 查表前替換傳入標籤。兩者合起來才讓「表格寫淸/代碼表寫清」與「表格寫清/代碼表寫淸」兩個方向都命中。
- 鍵碰撞必須保留所有 value,且呼叫端契約要一起定:這些 map 的值同時是代碼白名單(
ResolvesOfficeAggregateInput.php:77、SocialInstitutionAggregateDefinition.php:89/:92的in_array((int)$code, $map, true))。若正規化讓兩列的鍵塌成一個,被丟掉那個c_dy/c_inst_type_code會從 map 消失,一個完全合法的代碼開始被判 invalid。⚠️ 但不能只改成label => code[]就了事——那會打壞 6 個期待純量的呼叫端:ResolvesOfficeAggregateInput:62、SocialInstitutionAggregateDefinition:69/:79($map[$label]期待純量)、:77/:89/:92(in_array期待扁平純量 value)、以及批次匯入AdminBatchLoadOfficesController:91/AdminBatchLoadSocialInstitutesController:96-97(無檢查純量存取)。 明訂契約:map 維持label => code(單一純量),碰撞時確定性取最小碼並記 warning;另建allCodes(): int[]扁平集,把in_array(...)那三處改讀它。這樣呼叫端形狀不變、白名單不再遺漏合法碼。(注意今天mapWithKeys+orderByasc 是 last-wins =最大者,與「取最小」相反,所以要顯式處理、不能靠既有順序。) - map builder 有五個、lookup site 有五個,全部都要改(前一版只列三處、且宣稱「一步同時修好 React 編輯器」是假的):
- builder:
SharesImportHelpers.php:24(traitdynastyMap()——OfficeImportService:33與SocialInstituteImportService:43都use這個 trait,所以改這一處同時覆蓋兩個 ImportService 與 office 聚合,是最有效的收斂點;該 service 沒有自己的dynastyMap())、AdminBatchLoadOfficesController:206(getDynastyMap())、AdminBatchLoadSocialInstitutesController:250(getDynastyMap())/:220-243(getTypeMap())、SocialInstituteImportService::typeMap():52 - lookup(批次匯入):parse 階段
AdminBatchLoadSocialInstitutesController:201-202/AdminBatchLoadOfficesController:187是正確的單一插入點——標籤在此寫進$rows[],而validateLookups()/validateDynasties()(:272/:276、:226)在交易前對同一批 row 值跑,所以在這裡正規化就能讓:96-97/:91的無檢查陣列存取安全;只在驗證階段正規化會讓那裡拋 "Undefined array key" 而非乾淨的逐列錯誤 - lookup(v2/React 路徑,前一版完全漏掉):
ResolvesOfficeAggregateInput.php:62($dynastyMap[$label])、SocialInstitutionAggregateDefinition.php:69($typeMap[$label])/:79($dynastyMap[$label])。這三處在 parse 階段之外,S6 的approveEntityAggregateProposal重放也走它們 PostingAutofillService.php:1454/:1463(where('c_nianhao_chn',$name))同類,一併處理
- builder:
- 搜尋範圍(供判斷是否窮舉):已掃過
app/全部pluck('c_x','c_*chn|hz|py')與where('c_*chn|hz|_name|_title', $var)兩種形狀;office type/appointment type/addr 名稱都用數字代碼解析、不受影響。注意這兩種 grep 對陣列索引式查表是盲的(v2 那三處就是這樣漏掉的),所以新增 lookup 時不能只靠 grep。
- 建 map 時就把 map 的鍵在記憶體內正規化(與 S2、與 D6 無關)。
- 批次匯入結果頁補
variant_replacements,比照書名匯入。 - 測試:
AdminBatchLoadOfficesTest/AdminBatchLoadSocialInstitutesTest補——(a) 表格寫「淸」、代碼表「清」→ 成功;(b) 表格寫「清」、代碼表「淸」→ 成功(前一版會漏的方向);(c) 代碼表同時有兩形 → 有 warning 且兩個代碼都仍可用;(d) 既有 name code 是「淸…」、匯入「淸…」→ 複用既有碼、不新建,且該列名稱文本保持「淸…」原形(鎖住上面那個刻意的不對稱);(e) 兩列分別寫「淸」「清」的同一機構名 → 收斂到同一個碼。
計畫列的項目都做了(VariantLabelMap 兩側歸一 + 碰撞取最小碼 + 扁平白名單、5 個 map builder、
5 個 lookup site、officeColumns() 早於 buildPinyin()、resolveNameCode() 兩形都探、
批次結果頁 variant_replacements)。review 另外找出九項計畫沒列到的問題,一併修在本步:
- v2/React 路徑回傳的是使用者原輸入、且完全沒有 notices。
OfficeAggregateDefinition/SocialInstitutionAggregateDefinition的result()用$input['name']組回應 ⇒ DB 寫參考形、 回應回變體形,React 編輯器儲存後畫面與資料庫不一致,使用者也不知道字被改過(批次匯入使用者 反而看得到提示)。改為回$serviceResult['name'],並以內部鍵__variant_replaced讓AbstractEntityAggregateHandler::envelope()掛上 notices。 SOCIAL_INSTITUTION_ADDR.c_pages/c_notes漏替換:同一次 update 裡機構層的c_notes被歸一、地址列的原樣入庫。這兩欄同樣是已知表的文本欄,本來就在範圍內。VariantReplaceScope/白名單語義不可順手放寬:allCodes只收「有標籤」的列。原本連 無標籤列(含c_dy為 NULL 被折成 0 的佔位列)也收,等於讓dynasty_code=0從被擋變成通過。- 碰撞 warning 只在「原始標籤互不相同」時記:否則庫裡本來就有的重複字面(舊碼一直靜默
last-wins)會開始每次建 map 就刷一行。另外
dynastyMapAndCodes()/typeMapAndCodes()加 per-instance memo——同一請求會同時用到*Map()與*Codes(),不 memo 就是重複查詢、重複 build、重複 warning。 typeCodes()要取 hz/py 兩份的聯集:schema 允許c_inst_type_hz為 null(只有拼音名), 舊typeMap()也是任一有值就收;只取 hz 那份會讓這種列的合法type_code被判 invalid(422)。resolveNameCode()改成「先精確探原輸入、落空才擴成兩形」:直接whereIn+ 最小碼優先, 會讓輸入「清溪書院」時被字面不同的「淸溪書院」(較小的碼)搶走,機構掛到另一個既有名稱碼上。resolveNianhaoId()/fuzzyMatchOffice()同理,而且朝代條件優先於字形條件:直接whereIn兩形會讓候選從 1 筆變 2 筆而觸發「無法消歧就回 null」的靜默降級;而「每輪名稱候選 就先做不限朝代 fallback」會回別朝代的原字形、忽略指定朝代的等價形。正確順序是:先把所有 名稱候選在指定朝代內試完,全落空才放寬朝代。- 改名護欄要問 resolver,不能比字串:
SocialInstitutionAggregateDefinition::guardWrite()原本比$input['name']與$existing['name']。純字串相等會把「只換字形」(兩形都探 ⇒ 同一個 code)誤報成 409;只比「歸一後字串」又會漏掉反方向(輸入參考形、既有列是另一個變體形 ⇒ 歸一後 看起來相同,但會新建 code ⇒ 正是護欄要擋的既存引用失配)。改用唯讀的SocialInstituteImportService::findExistingNameCode()判斷「這次儲存會不會換掉 name_code」。 - 替換紀錄不得跨列殘留:
SocialInstituteImportService的累積器是 merge 寫入,而批次匯入是 同一個 service 實例逐列呼叫 ⇒ 第二列的結果頁會顯示第一列的替換。create()/update()進入 時重置,並補回歸測試。 - 批次匯入的錯誤訊息改用保留下來的原始標籤(
*_label_raw):查表用歸一後的值,但訊息裡 顯示歸一後的字會讓使用者一頭霧水(他從沒打過那個字)。
明文記錄的已知邊界(不是本步造成的回歸,也沒被本步修好):
resolveNameCode() 的去重是單向的,只涵蓋「輸入變體形、既有列參考形」。反方向(輸入參考形、
既有列是另一個變體形)無法用精確比對命中——那需要列舉輸入的所有「前像」(組合爆炸,本計畫
D7 已明確否決該做法)或在表上加一個歸一後的影子欄。該情境會像 S4 之前一樣新建一個名稱碼;
測試 testReferenceFormInputDoesNotMergeIntoExistingVariantRow 把這個行為釘死,避免被誤以為
已解決。真正的修法(影子欄或一次性資料合併)屬獨立工作。
另一個刻意的不對稱(計畫本來就寫明):兩形都在時複用既有的變體形列,該列的 c_inst_name_hz
永不歸一,與 D7 第二類「觸碰即歸一」相反——為了不製造重複碼而接受的取捨,由測試
test_existing_variant_form_name_code_is_reused_and_not_normalized 鎖住。
提案核准重放(approveEntityAggregateProposal → 以 direct 重放同一 handler)走的是同一條
validate 與 service,所以替換與標籤歸一自動生效;已補
testApprovingProposalAppliesVariantReplacementAndLabelNormalization 作回歸鎖。順帶記錄:
提案存的是原始 changes、替換發生在核准當下(§4.5「存意圖」),所以中間若有人改了
char_variant_map/DYNASTIES,提交時與核准時的結果會不同——這是既有設計,不是缺陷。
CodeTableCreateHandler:目前無前處理掛鉤點,在:86 array_intersect_key之後、:101落庫之前插入(抽出 protected 掛鉤方法)。:99的ToolsRepository::timestamp()蓋稽核欄,掛在其前即可(稽核欄本來也在排除清單)。ConfigCodeTableMutationHandler::preprocessUpdateData()(:97-101):在既有PinyinUmlaut::normalizeFields()之後加。char_variant_map的 guard:兩個 handler 落庫前呼叫assertWritable($row, $id)(違反回 422),成功後CharVariantMapService::reset()。- 修掉 G4 的不一致:
TEXT_CODES.c_title_chn兩路徑對齊。 - 測試:create 含「淸」的
c_title_chn→ 落庫「清」,且與書名批次匯入同輸入同結果;char_variant_map自身不被替換;單 codepoint 驗證回 422。
char_variant_map的 guard 在 S3 已經做完(守衛與快取重置下移到CodeTableCreateHandler、AbstractCodeTableMutationHandler的 direct 與 proposal、以及通用核准的applyCreateProposal()/applyUpdateProposal()),S5 只補替換本身。- G4 的不一致真正發生在 create 路徑:
config/code_table_writes.php(create 用)有TEXT_CODES.c_title_chn,而config/code_table_mutations.php(update 用)沒有中文標題欄。 三條路徑(Codes UI/書名批次匯入/token API create)現在對同一個「淸嘉錄」都得「清嘉錄」。 - 一個曾經寫錯、由 review 糾正的判斷:我原本以為 update config「只開放拼音/拉丁欄」、
掛鉤是 no-op。實際上
TEXT_INSTANCE_DATA.c_publisher是不帶_chn後綴的中文欄 (本計畫 D3 自己列的 8 個同類欄之一,且不在任何排除清單),所以 update 掛鉤今天就是活的。 相關註解已改正,測試主鎖也換成c_publisher的真實情境(另有 422 那條鎖住「替換跑在 變更偵測之前」、proposal 那條鎖住 payload 與核准重放一致)。 - 代碼表 create 今天沒有 D7 缺口,但前提要寫下來:
code_table_writes.php只登錄TEXT_CODES(c_textidint)與char_variant_map(idint),且 handler 一律(int)轉主鍵, 文本型主鍵的表根本無法登錄。已在該 config 加註:日後要登錄文本型主鍵的代碼表,必須先 移除那個(int)轉型並補 D7 查重——VariantEquivalentLookup::findExistingRow()在 「主鍵全部都在替換範圍內」時只記 warning 就跳過(沒有能收斂候選集的 SQL 條件), 單一文本主鍵正好落在那個分支。 API.md已在本步同步(依 AGENTS.md 的文檔維護原則,API 改動不延後):notices 的涵蓋 範圍改成「人物主檔+所有人物子資源+代碼表 create/update+實體聚合」,並明寫 失敗回應(409/422)也可能帶 notices;同時修掉兩處已過時的敘述——「只有c_alt_name_chn會產生 notices」與「異體字只做嚴格替換」(S3 之後同列的一般文本欄走 全量規則,所以會出現「別名欄保留某異體字、同列c_notes裡同一個字被替換」的刻意差異)。- 尚未對齊的非字形差異(不屬 G4,S9 記一句):書名批次匯入獨有的標點/空白收斂
(
normalizeTitle())、簡體字形與無拼音把關、拼音派生,token API create 沒有這些門檻。
- 主分支先說清:
applyProposal():405把HANDLER_ROUTED_RESOURCES內的資源導到applyViaMutationHandler():483以 v2 direct handler 重放,所以大多數人物子資源提案由 S3 的基底掛鉤自動覆蓋,走不到下面兩條。 applyCreateProposal():744/applyUpdateProposal():772(服務代碼表與尚未遷移的表):掛鉤點在方法最上方、buildKeyConditions()之前,不是「insert/update 之前」。applyCreateProposal()的重複檢查在:752,早於:757的 insert;寫成「落庫前」會重演 §1.3 禁止的錯位(查重用替換前值、落庫用替換後值)。applyUpdateProposal()的$conditions同理(update:798)。今天實際影響小(該分支的文本型 PK 成員都在排除清單),但措辭會被複製。char_variant_map的核准也要 guard + 清快取:該表不在HANDLER_ROUTED_RESOURCES,所以它的提案核准就走上面那兩條泛用分支(applyCreateProposal():757insert/applyUpdateProposal():798update)。S2 已在 Codes UI 的 direct 路徑補了assertWritable()+CharVariantMapService::reset(),同樣的理由適用於核准端——核准一筆對照之後若不清快取,這個 worker 之後的替換都還在用舊對照。兩者要一起補,別只補 guard 漏掉 reset。applyKinshipProposal():597/applyAssocProposal():637:不重放 handler、直接呼叫BiogMainRepository的 kinship/assoc 寫入方法,S3 覆蓋不到,必須獨立補。部分 repository 方法只是薄轉發,真正寫入在OfficePostingRepository.php/EventStatusRepository.php,掛鉤要放在真正落庫那層。- 實體聚合提案核准(
:226 approveEntityAggregateProposal()→:273以 direct 重放,注入的正是 S4 那兩個 ImportService)由 S4 覆蓋,本步只需驗證——含 S4 新增的三個 v2 lookup site。 - 雙保險:提案建立端(S2/S3/S5)已替換,核准端再替換一次,依 D8 幂等。與 S2 不衝突:S2 讓存進 payload 的已是替換後值,本步是對歷史遺留 payload 補網。
char_variant_map的 guard:applyCreateProposal()/applyUpdateProposal()落庫前呼叫assertWritable(),成功後CharVariantMapService::reset()——該表不在HANDLER_ROUTED_RESOURCES,所以它的提案核准就是走這兩條。OperationsController::restore()不做內容替換(S1 只在它掛assertWritable()結構驗證,兩者不同)。本步測試含「restore 後歷史字形原樣保留」的負向斷言,鎖住這個刻意的不對稱。
計畫列的兩項在前面步驟已完成:char_variant_map 在通用核准路徑的 guard + 清快取(S3 做的),
實體聚合提案核准(S4 覆蓋,且已有回歸鎖)。本步真正新增的是四件事,以及 review 找出的五項。
- 通用分支
applyCreateProposal()/applyUpdateProposal()的替換掛在方法最上方、buildKeyConditions()之前(計畫已強調不可寫成「落庫前」)。價值是對歷史遺留 payload 補網——提案建立端在 S2/S3/S5 已替換過。 - 親屬/社會關係的四個 repository 寫入方法(
kinshipStoreById/kinshipUpdateById/assocStoreById/assocPerformUpdate)各自掛鉤:核准不重放 v2 handler,S3 覆蓋不到。assocUpdateById只替換$data,定位器($row/$id解析出的舊 PK)不動。 - 新增 D7 守衛
assertNoVariantEquivalentRow()(review 抓到的 HIGH):ASSOC_DATA.c_text_title是主鍵成員,替換等於改鍵。既有列可能存變體形,於是「原樣重送同一 變體形」在替換後 PK 值就不同了 ⇒ insert 成功、唯一鍵擋不住 ⇒ 靜默鑄出兩形並存的重複列。 在加上替換之前,同一筆輸入是乾淨的 1062(approve()有專屬 QueryException catch 回滾), 所以少了守衛等於把「乾淨拒絕」換成「靜默重複」。update 側排除自己(交給 lookup 內部) 且只在真的改鍵時檢查(否則歷史上就已兩形並存的列會變成永遠改不了)。KIN_DATA 也掛—— 三個主鍵欄全數值 ⇒$inScope === []直接 return null =零額外查詢,防日後主鍵變動時漏掉。 通用分支也接上同一個 lookup,理由相同(今天零成本,但把日後的缺口永久封掉)。 applyDeleteProposal()的過期提案:本階段起文本型 PK 真的會被核准改寫,於是「待審 delete 提案指向舊字形、目標列已被另一筆核准改名」從理論變成常態。直接當成冪等成功會讓approve()寫下一筆 DELETE 稽核並標記 approved,而資料列還在——稽核鏈記錄了一件沒發生的事。 改為:先用歸一後的 PK 再探一次;兩種字形都找不到才維持冪等並記 warning。 連帶修好logFinalOperation():DELETE 的resource_id與兩份快照都改用實際刪除的那一列, 否則 resource_id(新字形)、resource_data(舊字形)、audit row_pk(新字形)三者互相矛盾, 還原時會以舊字形重建一列不存在過的資料。- restore 不做內容替換(負向斷言,含正向對照):restore 的語義是「恢復成歷史快照當時的
樣子」,順手歸一就不是還原而是改寫歷史。S1 只在 restore 掛了
char_variant_map的結構 驗證(assertWritable),與內容替換是兩件不同的事。負向測試自己先斷言「替換機制在本環境 是活的」,否則機制整體死掉時它會假綠。
驗證覆蓋:通用分支 create/update、親屬與社會關係的 create 與 update(assoc 那條斷言落庫列、
operations.resource_id、audit_log.row_pk、鏡像列四處同一字形)、D7 守衛擋下重複列且提案
維持 pending、非改鍵不被誤擋、delete 的兩形探測與三處字形一致、restore 原樣保留。
CrowdsourcingController::confirm():202落庫前套replaceRow(),寫入點:BiogMain::create:238、OfficeCode::create:250、OfficeCodeTypeRel::create:263、OfficeTypeTree::create:273(c_office_type_desc_chn是中文欄)、$biog->update($data)五處(:293/:303/:313/:322/:340)。Api\OperationsController(v1 token API,仍在服役):add_operations():58/update_operations():92。$keyword['json']是原始 JSON 字串,目標表來自客戶端任意的resource(add在:68、update在:102,皆無白名單)。做法:decode →replaceRow()→ 以JSON_UNESCAPED_UNICODEre-encode;必須先過isKnownDataTable(),未知 resource 原樣存入。另兩點:(a)json_decode失敗要 fallback 原樣存入(維持現況「原樣存、到confirm()才爆」),不可變成"null"或當場拋錯;(b)resource_original(:144/:205,歷史快照)不替換。要接受 re-encode 改變既存位元內容(鍵序/escaping)並在測試固定預期形狀。storeProcess():215不要動——全庫零引用、未掛路由的死碼(附記:它完全沒有 token/權限檢查就BiogMain::create(),既有問題,不在本階段修)。BasicInformationController::saveas():1963(BiogMain::create:1981)/Duplicate_Collateral_Info():2006(依序寫 BIOG_MAIN:2027、BIOG_ADDR_DATA、BIOG_SOURCE_DATA、KIN_DATA ×2、ASSOC_DATA ×2、BIOG_INST_DATA、STATUS_DATA 共 8 張):routes/web.php:161-162未掛legacy.form、不受 flag 影響、現在就是活的且無 React 替代品。文字是複製既有列而非新錄入,但既然要複製就該複製成正規化後的字形。UnidirectionalRelationshipRepairController::executeRepair()(insert:190):建鏡像列時逐字複製c_text_title/c_notes,落庫前套。- 測試:v1 端點提交含「淸」的人物提案 → payload 已替換;經
confirm()回填後BIOG_MAIN為「清」;另補「提案 payload 未替換(模擬歷史資料)→ 回填時仍替換」鎖住雙保險。
四個區塊都串上了,但 review 找出兩個「修一半比不修更糟」的形狀,以及三項邊界問題:
- 修復工具只能替換非主鍵欄(HIGH)。
ASSOC_DATA.c_text_title既是主鍵成員、也是鏡像的 定位鍵(RelationshipMirrorService::reverseRelationExists()/locateOppositeEdges()都拿 正向列的原字形做精確比對)。只把補建的鏡像歸一會造成:(a) 缺邊偵測仍以正向列的變體形去找 ⇒ 找不到新鏡像 ⇒ 這對關係永遠被報成單向、修復按鈕修不掉;(b) 再按一次修復同樣找不到 ⇒ 再插一次同樣的列 ⇒ 撞唯一鍵、整筆回滾,工具失去幂等;(c) 對面若已有另一個變體形的鏡像, 兩者 PK 不同又都歸一成同一形 ⇒ 靜默鑄出重複列。 所以修復路徑排除主鍵成員(c_notes/c_pages等內容欄照樣歸一),鏡像與正向列保持同形; 真正的收斂留給「使用者下次經編輯器觸碰這段關係」(S3 的觸碰即歸一,那條路有 D7 守衛)。 confirm()的替換不可溢出到「只刪不寫」的分支。op_type=4的三個OFFICE_*分支只做$biog->delete()、完全不寫$data,卻仍把它存成 operations 快照——而那份快照是restoreDelete()重建被刪列的依據。替換它等於「還原出一列從未存在過的字形」,也違反 「快照=當時實際發生什麼」(v1 的resource_original同理不替換)。改為保留未替換的$originalData給這三個分支。VariantReplaceScope的型別快取毒化(S1 註解自己警告過的失效模式,S7 是第一個把 未驗證外部字串送進來的呼叫點)。isKnownDataTable()大小寫/空白不敏感,但textColumns()以正規化名當快取鍵、卻用原始字串查 schema ⇒" BIOG_MAIN "會查到空欄位集 並被三層快取記住,該表在這個 process 之後都不替換。修法:loadKnownTables()改存 canonical 拼法、新增canonicalTableName()、textColumns()一律用 canonical 名查 schema。 但兩個外部入口(v1/confirm)刻意收斂成「表名必須與註冊表拼法完全相同才替換」—— 下游分派本來就是精確比對,把 canonical 名套進分派等於放寬未驗證字串能觸發寫入的範圍。- 複製工具的歸一撞鍵:
Duplicate_Collateral_Info()逐列複製時,替換會把「同一人底下只差 字形的兩列」壓成同一個主鍵,第二次 insert 撞唯一鍵讓整個複製交易回滾 ⇒ 該功能對這些人物 永久失敗且沒有可行動訊息(違反 §1.1「撞鍵轉友好報錯」)。改為保留第一列、跳過等價列並記 warning。 - 已知且刻意接受:v1「payload 已替換 vs
resource_original未替換」會讓審核頁的 diff 出現 使用者沒動過的欄位(含變體字的c_notes等)——這是掛鉤在提交端的必然結果,S9 的 CHANGELOG 會記一句。另外 re-encode 除了鍵序/escaping,理論上還會把{}變[];所有現有消費者都是 decode 後使用,assoc 模式下不可見,判定無實害。
硬性關卡,不得因時間壓力跳過。 本階段價值有一半在「以後不會再漏」——第二階段之所以漏掉 19 個 handler,正是因為當時沒有任何機制會在漏掉時發出聲音。
8a. AGENTS.md 新增 §1.3(與 §1.1/§1.2 並列為資料完整性規則):
- 任何會把文本寫進資料庫的新路徑,落庫前必須經過
CharVariantMapService::replaceRow($data,$table)(或單值的replaceFor())。 - 範圍由型別決定,呼叫端不需自己判斷「有沒有中文」、不要自己維護欄位清單。
- 掛鉤位置硬性要求:必須在 PK 計算、查重、去重鍵查詢、拼音派生之前。已知文本型 PK 成員:
ALTNAME_DATA.c_alt_name_chn、ASSOC_DATA.c_text_title、BIOG_SOURCE_DATA.c_pages;已知去重鍵:SOCIAL_INSTITUTION_NAME_CODES.c_inst_name_hz;已知標籤→代碼鍵:DYNASTIES.c_dynasty_chn、SOCIAL_INSTITUTION_TYPES.c_inst_type_hz/_py、NIAN_HAO.c_nianhao_chn;已知由中文派生:OFFICE_CODES/TEXT_CODES拼音欄。 - 精確比對必須處理兩形並存(D7):既有列保留變體形、新列是參考形,所以去重/重用查詢要兩形都探,否則替換會製造新分裂。
$table一律傳目標資料表,永不傳operations。- 模式不需呼叫端選,且預設是寬鬆(全量);strict 是逐欄位例外,不要假設「人物相關的表就該用 strict」。
- 繼承既有基底類別即自動生效;不繼承的新寫入路徑才需自己掛。
- 不該替換的東西:指向
VariantReplaceScope排除清單與其原則——本身做文本替換/字形對照,或語義上必須保留原字的地方一律不掛。新增任何對照/映射性質的表、或任何需保留原始字形的欄位時必須加進排除清單。 - 「不要再改舊 Blade」的例外:資料完整性規則(§1.1/§1.2/§1.3)適用於所有仍在服役的寫入路徑,不分新舊;只有已被閘門下架且有替代品的路徑才豁免。否則下一個代理會把 S7 的改動判為違規。
- 新增對照時要評估搜尋端(D9):該字的新舊資料會互相搜不到,需評估姓名搜尋/
CBDB__NAME_FTS建索引。不要去改VariantCharNormalizer——它做的是拼音派生。 - 「提交前最低檢查」補一行:新增或修改文本寫入路徑時,確認已掛上落地替換。
8b. Skill:.claude/skills/mutation-api-record-editing.md 補實作步驟(掛哪個 hook、replaced 怎麼接 buildNotices()/withNotices()、「子類自己再呼叫一次會讓通知消失」的陷阱、測試該斷言什麼含「同列 strict/lenient 混用」)。.claude/skills/database-schema.md 補交叉引用:新增文本欄自動進入範圍;新增對照/映射表必須加排除。
8c. 機械化把關:tests/Unit/VariantReplaceHookCoverageTest.php 列舉 app/Services/Mutations/ 下所有寫 CBDB 表的 handler,斷言每個都繼承已掛鉤的基底類別或明文列在例外清冊(每筆寫理由)。新增繞過基底的 handler 時這支測試會紅。
- 更新本文件的完成狀態與實作偏差。更新第二階段文件「不在本次範圍內」指向本文件。
API.md必須同步:notices適用範圍從 BIOG_MAIN/ALTNAME 擴到所有子資源與代碼表,依AGENTS.md「文檔維護原則」屬必須同步的 API 改動;docs/openapi/openapi.yaml一併更新。CHANGELOG.md記一則(行為擴張,非修 bug),含 D9 的搜尋落差後果。- 跑全量
./vendor/bin/phpunit。
- 大範圍新行為,不是重構。經上述路徑寫入的文本欄,含這 7 個異體字者都會被靜默改寫(人名/別名 6 筆、其餘 7 筆)。PR 描述必須標註為行為擴張。
- D6 + 精確比對 = 可能製造新分裂(D7)。最尖銳的是
resolveNameCode()(會鑄出第二個 name code)與標籤→代碼查表(會讓整列匯入失敗)。所有身分/去重比對都必須兩形都探,這是本階段最容易做錯的地方。 - 文本型 PK 成員替換改變列身分:
ALTNAME_DATA.c_alt_name_chn、ASSOC_DATA.c_text_title、BIOG_SOURCE_DATA.c_pages(PK =c_personid+c_textid+c_pages,而c_pages依 D5 在範圍內)。掛鉤必須在 PK 計算與查重之前。ASSOC_DATA.c_text_title另會改寫對面那個人的鏡像列 PK 成員、該側無衝突檢查(S3)。 SOCIAL_INSTITUTION_NAME_CODES.c_inst_name_hz是去重鍵:S4 上線後同機構名的異體字寫法會收斂到同一碼(想要的行為),但既有已分裂的重複碼不會自動合併(D6)。- 對照表增修的風險放大:新增一筆就影響全庫所有文本欄的錄入。D8 保證幂等但不保證語義正確;
c_strict_excluded預設 1(人名不受影響)的既有設計要維持。 - 靠內容巧合安全的地方:各
*_TYPE_REL/*_TYPES/KINREL_REDUCTION/KIN_MOURNING的 varchar 代碼 PK、三個哨兵字面值——現值全 ASCII/不含那 7 字。S0 掃描與 S1 不變式測試處理,但 review 時值得再掃一次。 - 行程內快取陳舊:對照表與型別兩份快取都是行程內靜態陣列。S1 已要求 10 個寫入入口呼叫
reset();migration 仍繞過,靠不變式測試兜底。
- 已被閘門下架、且有 React/v2 替代品的 legacy Blade 寫入路徑(使用者決定:這些頁面之後都會刪除)。指
BasicInformation*Controller的 12 組子資源store()/update()/updateQuery()/destroy*()(含BasicInformationEntriesController::update():379/updateQuery():648與BasicInformationSocialInstController:289/:481那幾份與 repository 並存的獨立實作)、BasicInformationController::store()/update()、BasicInformationProposalController::normalizePayloadForTable()(它自己直接呼叫replaceStrict()、不經 repository,所以 S3 的 repository 改動不會弄壞它,且會自動繼承 S1 對strictMap()的閉包改動)、以及只被它們呼叫的BiogMainRepository::altnameStoreById()/altnameUpdateById()。已查證config/migration_flags.php全部 flag 皆為new,這些端點的非 GET 都被LegacyBladeFormGate擋成 410,且 12 個子資源都有 React 編輯器與 v2 handler。 - 既有資料的批次回溯校正(D6)。
OperationsController::restore()的內容替換(S1 仍在它掛assertWritable()結構驗證)。- 眾包 create 提交的 Web UI 端:
basicinformation.editorflag =new(config/migration_flags.php:51)⇒ legacy POST 被 410;v2BiogMainCreateHandler:112-115對mode=proposal回 501。現行設定下不可達。(token API 那條仍活著且無替代品,在 S7 範圍內。) - 紀錄/帳號類表與框架表(D2 fail-closed + D3)。
- 搜尋路徑/FTS 的異體字歸一化(D9,列為下一階段候選)。
VariantCharNormalizer類別的刪除清理(前兩階段皆列為獨立任務)。MergePreviewController產生的帶外 SQL 腳本(routes/web.php:381-382):它產生但不執行 SQL,其中INSERT INTO MERGED_PERSON_DATA (… c_notes …)(:350)的c_notes在 PHP 端由兩人c_notes串接組出(:465-485),由 DBA 帶外執行 ⇒ 繞過所有應用層掛鉤。同表的 v2 路徑(MergedPersonCreateHandler,繼承 subresource 基底)會被 S3 覆蓋 ⇒ 兩條路徑行為不一致,與 G4 同構。記錄以免誤以為已覆蓋。- 已查證的死碼,不補掛:
Api\OperationsController::storeProcess()、AltCodeRepository::updateById()、AddrCodeRepository::updateById()(後兩者用$request->all()無白名單寫ALTNAME_CODES/ADDR_CODES,全庫零呼叫端)、CodesController::performDestroy()早退後的程式碼、CodeTableDeleteHandler403 之後的程式碼。 - Access 時代的 metadata 表(
COPYTABLES/TABLESFIELDS/FOREIGNKEYS等,全庫零引用)。
| 路徑 | 為何仍活著 | 處置 |
|---|---|---|
POST /api/operations/add/update(v1 token API) |
routes/api.php:113/:114 仍註冊(群組 :106、del :115);v2 人物 create 提案回 501,無替代品 |
S7 |
CrowdsourcingController::confirm()/reject() |
routes/web.php:424-425 仍註冊、未被任何閘門攔;眾包提案回填 BIOG_MAIN 的唯一路徑 |
S7 |
BasicInformationController::saveas()/Duplicate_Collateral_Info() |
routes/web.php:161-162 未掛 legacy.form |
S7 |
CodesController 的 Blade 寫入路由 |
LegacyBladeFormGate 只覆蓋 basicinformation、不覆蓋 codes |
S2(與 React 共用 perform*,一次涵蓋兩者) |
三支批次匯入 controller 的 store() |
Blade 與 React 共用同一 controller 方法 | S4/S5 |
applyKinshipProposal()/applyAssocProposal() |
入口是提案核准(活的),只是內部呼叫 repository 而非重放 handler | S6 |
BiogMainRepository::store()/updateById() |
v2 handler 與 legacy 共用 | S3 改為 replaceRow() |