Skip to content

Commit 8fe1a8c

Browse files
sudoghutclaude
andcommitted
異體字落地替換 S2:Codes UI 全表串接
執行 docs/CHAR_VARIANT_MAP_TEXT_COLUMN_ROLLOUT_PLAN.md 的 S2。Codes UI 的 5 條寫入路徑 (performStore/performUpdate/performProposalStore/performProposalUpdate/ proposalUpdateExisting)全部掛上落地替換;Blade 與 React 共用 perform*,一次涵蓋兩個入口。 ## 掛鉤與通知 - 替換一律只碰「要寫入的值」,不碰「用來定位既有列的條件」(D7 第四類): performUpdate 的 $conditions 來自 URL 主鍵、在 extractFormData 之前就組好; 提案路徑的主鍵查重則在替換之後,所以查重看到的是替換後的值。 - 成功與**失敗**分支都 flash 通知。失敗分支特別重要:唯一鍵衝突/「未偵測到任何修改內容」 可能是替換自己造成的,而 withInput() 回填的是**替換前**的原始輸入——不附通知的話 使用者會看到「你什麼都沒改」而完全無從理解。 ## D7「兩形並存」的去重(實作時發現,原計畫漏了) `ALTNAME_DATA.c_alt_name_chn` 同時是**文本主鍵成員**又在替換範圍內(strict),而 ALTNAME_DATA 是 Codes UI 可寫的表——這是 Codes UI 裡唯一會踩到 D7 的形狀。 D6 不回溯,所以既有列的主鍵可能還是變體形,只用替換後的值查重會錯過它、鑄出語義重複的 第二列,而資料庫唯一鍵擋不住(不同字形是不同鍵值)——**比不替換更糟**。 正確做法(前兩版都是錯的,記錄下來避免重演): - ❌ 只探「輸入值 + 替換後值」兩形:對照是多對一,既有列可能是**另一個**變體 (既有「菁客」、新輸入「靑客」,兩者都歸一成「青客」)。 - ❌ 列舉所有等價字形再逐一查:查詢次數等於等價字形數,而為避免組合爆炸設的上限 **本身是正確性缺口**(超過就退回只比對正規形=完全失去去重),且可由合法對照表資料 觸發(一個參考字 6 個變體、主鍵含 2 個這樣的字就是 7×7=49)。 - ✅ 把**不在替換範圍內**的主鍵欄固定在 SQL 條件裡取回那一小群候選列,再在 PHP 端把 它們的文本主鍵歸一後比對:精確(不管對照表什麼形狀)、只要一次查詢、無上限可調。 待審提案那一側同理——`hasActiveCreateProposalConflict()` 是拿 resource_id 做完全相等比對, S2 之前留下的 pending 提案帶變體形 resource_id、新提案帶歸一後的 ⇒ 不會衝突 ⇒ 兩筆並存、 依序核准就落成兩種字形的兩筆列。改以 resource_id 的**位置式 LIKE 樣式**收斂 + lazyById() 分批。 兩個陷阱:前導前綴在 production 的 ALTNAME_DATA 會失效(主鍵第一欄就是可替換的文字欄); cursor() 在 PDO MySQL 預設 buffered query 下記憶體並非有界。 ## 其他實質修正 - **缺表降級**:落地替換現在掛在 Codes UI 全部寫入路徑上,若因缺表拋錯會讓**整個代碼表的 寫入功能 500**——為加值功能讓核心錄入停擺是錯的取捨。只在「表不存在」時降級(確定性、 可快取、reset() 會清),其餘錯誤一律往上拋。早期寫法對所有 Throwable 降級並快取空 map, 後果是一次瞬時錯誤就讓整個 process 不再替換,只留一行 warning。 - **新增 VariantMappingException**:QueryException 繼承 PDOException 繼承 RuntimeException, 而 assertWritable() 內部會查表——catch(\RuntimeException) 會把資料庫錯誤當成驗證失敗、 把原始 SQL 顯示給使用者,而且該次寫入被靜默跳過而不是誠實 500。 - **proposalUpdateExisting 的 excludeId 改用權威來源** operation.resource_id,而非使用者 送出的 body id(body 的 id 可能被改、可能是空字串 ⇒ (int) 變 0 ⇒ 不排除舊邊 ⇒ 合法修改 被誤報成環)。 - **getKeyColumns() 的方法內 static cache 改成可重置**:那是既有的測試隔離地雷(測試會為 同一個表名建不同的合成 schema,一旦被快取成錯的主鍵欄,後面的測試就會拿到污染值, 症狀是與該測試無關的「請確認主鍵欄位已填寫完整」)。生產語義不變。 ## 既有測試的調整 CodesCharVariantMapAuditTest 原本把 c_reference_char 設成「新參考字」(4 個字),新 guard 會 擋下多字元對照。改成單一字元——該值本來就不是合法的「參考字」,而該測試的主體是稽核紀錄。 ## 驗證 三輪 review agent + 六輪 codex。實測鑑別力(neuter 對應機制後會紅的測試數): 替換 hook 6/22、通知 5、去重 4、提案側去重 1、定位器 1。 全量 phpunit 2845 tests/15190 assertions 全過;php-cs-fixer(已刪 cache、用 dist config)0 fixes。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a821dea commit 8fe1a8c

8 files changed

Lines changed: 1403 additions & 32 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace App\Exceptions;
4+
5+
/**
6+
* `char_variant_map` 的結構驗證失敗(單一 codepoint、不成環、payload 完整)。
7+
*
8+
* **為什麼需要一個專屬型別而不是直接用 `\RuntimeException`**:
9+
* `Illuminate\Database\QueryException` 繼承 `PDOException`、而 `PDOException`
10+
* 繼承 `\RuntimeException`。`CharVariantMapService::assertWritable()` 內部會查兩次
11+
* `char_variant_map`,所以呼叫端若 `catch (\RuntimeException)`,任何資料庫錯誤都會被
12+
* 當成「驗證失敗」,把原始 SQLSTATE 與 SQL 字串 flash 給使用者(資訊洩漏 + 無法據以行動
13+
* 的訊息),而且該次寫入會被靜默跳過而不是誠實地 500。
14+
*
15+
* 呼叫端一律只 catch 這個型別,讓真正的資料庫錯誤照常往上冒。
16+
*/
17+
class VariantMappingException extends \RuntimeException {
18+
}

app/Http/Controllers/CodesController.php

Lines changed: 433 additions & 9 deletions
Large diffs are not rendered by default.

app/Services/CharVariantMapService.php

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
namespace App\Services;
44

5+
use App\Exceptions\VariantMappingException;
56
use App\Support\VariantReplaceScope;
67
use Illuminate\Http\JsonResponse;
78
use Illuminate\Support\Facades\DB;
89
use Illuminate\Support\Facades\Log;
10+
use Illuminate\Support\Facades\Schema;
911

1012
/**
1113
* 異體字落地替換服務
@@ -33,6 +35,19 @@ class CharVariantMapService {
3335
*/
3436
protected static ?array $strictMap = null;
3537

38+
/**
39+
* 對照表是否缺表(null = 尚未判定)。
40+
*
41+
* 缺表是**確定性**條件,所以可以在 process 內快取;reset() 會清掉。
42+
* 不快取的代價是逐欄放大:replaceRow() 對整列每個欄位都會走一次 map 載入,
43+
* 缺表時就變成「每欄一次 metadata 查詢 + 每欄一行 warning」——一次 20 欄的儲存
44+
* =20 次查詢,而 S3 接上批次匯入後會變成「列數 × 欄數」。
45+
*
46+
* 這與「不快取瞬時錯誤」的決定不衝突:瞬時錯誤現在一律往上拋(見 loadEdges()),
47+
* 所以根本沒有「把失敗結果快取住」的路徑。
48+
*/
49+
protected static ?bool $tableMissing = null;
50+
3651
/**
3752
* 寬鬆模式:對整段文字做落地替換,表裡任何一筆都套用(忽略 c_strict_excluded)。
3853
*
@@ -183,7 +198,7 @@ public static function replaceFor(string $table, string $column, string $value):
183198
* 表有 id=5 `乙→甲`、id=9 `甲→丙`,把 id=5 改成 `丙→乙`
184199
* 是合法的 `甲→丙→乙`,但把舊邊算進去會看到 `乙→甲→丙→乙`
185200
*
186-
* @throws \RuntimeException 驗證不通過
201+
* @throws VariantMappingException 驗證不通過
187202
*/
188203
public static function assertWritable(array $row, ?int $excludeId = null): void {
189204
$current = [];
@@ -208,15 +223,15 @@ public static function assertWritable(array $row, ?int $excludeId = null): void
208223
// 只送了單邊字元欄、又沒有既有列可以 merge:這是呼叫端沒傳 id 的問題,
209224
// 報「必須是單一字元」會誤導(使用者根本沒動另一欄)。
210225
if ($variant === '' || $reference === '') {
211-
throw new \RuntimeException(__('variant.incomplete_payload'));
226+
throw new VariantMappingException(__('variant.incomplete_payload'));
212227
}
213228

214229
if (mb_strlen($variant) !== 1 || mb_strlen($reference) !== 1) {
215-
throw new \RuntimeException(__('variant.single_codepoint_required'));
230+
throw new VariantMappingException(__('variant.single_codepoint_required'));
216231
}
217232

218233
if ($variant === $reference) {
219-
throw new \RuntimeException(__('variant.self_reference_not_allowed'));
234+
throw new VariantMappingException(__('variant.self_reference_not_allowed'));
220235
}
221236

222237
// 把待寫入的邊放進現有邊集(排除被取代的舊邊),看會不會成環。
@@ -233,7 +248,7 @@ public static function assertWritable(array $row, ?int $excludeId = null): void
233248
$node = $edges[$node];
234249
}
235250
if (isset($edges[$node]) && isset($seen[$node])) {
236-
throw new \RuntimeException(__('variant.cycle_not_allowed', ['char' => $node]));
251+
throw new VariantMappingException(__('variant.cycle_not_allowed', ['char' => $node]));
237252
}
238253
}
239254

@@ -243,6 +258,7 @@ public static function assertWritable(array $row, ?int $excludeId = null): void
243258
public static function reset(): void {
244259
self::$lenientMap = null;
245260
self::$strictMap = null;
261+
self::$tableMissing = null;
246262
}
247263

248264
/**
@@ -335,12 +351,11 @@ protected static function replaceUsing(string $text, array $map): array {
335351
*/
336352
protected static function lenientMap(): array {
337353
if (self::$lenientMap === null) {
338-
self::$lenientMap = self::resolveMap(
339-
DB::table('char_variant_map')
340-
->pluck('c_reference_char', 'c_variant_char')
341-
->all(),
342-
'lenient'
343-
);
354+
$edges = self::loadEdges(null);
355+
if ($edges === null) {
356+
return [];
357+
}
358+
self::$lenientMap = self::resolveMap($edges, 'lenient');
344359
}
345360

346361
return self::$lenientMap;
@@ -351,18 +366,51 @@ protected static function lenientMap(): array {
351366
*/
352367
protected static function strictMap(): array {
353368
if (self::$strictMap === null) {
354-
self::$strictMap = self::resolveMap(
355-
DB::table('char_variant_map')
356-
->where('c_strict_excluded', 0)
357-
->pluck('c_reference_char', 'c_variant_char')
358-
->all(),
359-
'strict'
360-
);
369+
$edges = self::loadEdges(0);
370+
if ($edges === null) {
371+
return [];
372+
}
373+
self::$strictMap = self::resolveMap($edges, 'strict');
361374
}
362375

363376
return self::$strictMap;
364377
}
365378

379+
/**
380+
* 讀對照表的邊集。
381+
*
382+
* **只在「表不存在」時降級為不替換**(回 null,呼叫端不快取這個結果)。
383+
* 這一步從 S2 起變成必要:落地替換現在掛在 Codes UI 全部 5 條寫入路徑上,若在
384+
* 尚未 migrate/部分遷移的環境因為缺表而拋錯,**整個代碼表的寫入功能都會 500**
385+
* ——為了一個正規化的加值功能讓核心錄入功能停擺是不對的取捨。
386+
*
387+
* **其餘錯誤一律往上拋**(連線問題、欄位被改名、權限…)。早期版本對所有 Throwable
388+
* 都降級並把空 map 快取起來,後果是「一次瞬時錯誤就讓這個 worker 之後所有寫入都
389+
* 不再替換」,而且只留下一行 warning——那正是本階段最想避免的靜默失效。
390+
*
391+
* @param int|null $strictExcluded null = 不過濾(lenient);0 = 只取可用於人名的(strict)
392+
* @return array<string,string>|null null = 表不存在,本次不替換(且不要快取)
393+
*/
394+
protected static function loadEdges(?int $strictExcluded): ?array {
395+
if (self::$tableMissing === null) {
396+
self::$tableMissing = !Schema::hasTable('char_variant_map');
397+
if (self::$tableMissing) {
398+
Log::warning('char_variant_map 不存在,本次不做落地替換');
399+
}
400+
}
401+
402+
if (self::$tableMissing) {
403+
return null;
404+
}
405+
406+
$query = DB::table('char_variant_map');
407+
if ($strictExcluded !== null) {
408+
$query->where('c_strict_excluded', $strictExcluded);
409+
}
410+
411+
return $query->pluck('c_reference_char', 'c_variant_char')->all();
412+
}
413+
366414
/**
367415
* 把一份**已按模式過濾**的邊集,解析成可安全重複套用的對照表。
368416
*

docs/CHAR_VARIANT_MAP_TEXT_COLUMN_ROLLOUT_PLAN.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,21 @@ D6 之下,同一個概念會同時以變體形(既有列)與參考形(
132132

133133
1. **兩欄必須是單一 codepoint**`mb_strlen() === 1`)。幂等論證只在單字元 key 下成立:`甲乙→丙丁` + `丁→戊`,閉包接不起來(exact match),第一次得 `丙丁`、第二次得 `丙戊`。非 BMP 不會誤擋(mbstring 算 1)。**此決定取代第一階段 `:49``:53` 那句「varchar(10) 為變體選擇符留餘裕」**——在實作出替代不變式(「value 集不得含任何 key 為子字串」)之前不收錄組合字符;欄位長度不變,只是多一道驗證。
134134
2. **先按模式過濾、再各自算閉包與環**,且 `$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` 的唯一用途。
135-
3. **執行順序必須是「按模式過濾 → 偵測並移除環上出邊 → 對剩餘無環圖算閉包」**。反過來(先算閉包再偵環)**不可實作**:對 `A→B`+`B→A` 或自環 `A→A`,閉包在環移除前沒有定義,一般的走鏈寫法會**無限迴圈**
136-
4. **環的處置:只丟棄構成該環的邊 + 記 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` 要保留**
135+
3. **對照表缺表時降級為「不替換」,其餘錯誤一律往上拋**(S2 實作時新增的決定)。
136+
落地替換從 S2 起掛在 Codes UI 全部 5 條寫入路徑上,若在尚未 migrate/部分遷移的環境
137+
因為缺表而拋錯,**整個代碼表的寫入功能都會 500**——為了加值功能讓核心錄入功能停擺
138+
是錯的取捨。但降級**只限「表不存在」這個確定性條件**:早期寫法對所有 Throwable 都降級
139+
並把空 map 快取起來,後果是「一次瞬時錯誤就讓這個 worker 之後所有寫入都不再替換」,
140+
只留一行 warning——那正是本階段最想避免的靜默失效。**瞬時錯誤現在一律往上拋**
141+
所以沒有「把失敗結果快取住」的路徑。
142+
「表不存在」這個確定性結果**可以**在 process 內快取(`$tableMissing``reset()` 會清):
143+
不快取的話 `replaceRow()` 會逐欄各做一次 `Schema::hasTable()`,一次 20 欄的儲存=20 次
144+
metadata 查詢,S3 接上批次匯入後放大成「列數 × 欄數」。
145+
**已知限制**:PHP-FPM 每個 request 會重建 static,所以這個快取實務上只活在單一 request 內;
146+
長駐程序(queue worker)若在**尚未 migrate** 的環境啟動過一次,之後即使建了表也會持續
147+
不替換,需重啟 worker(部署本來就會重啟)。
148+
4. **執行順序必須是「按模式過濾 → 偵測並移除環上出邊 → 對剩餘無環圖算閉包」**。反過來(先算閉包再偵環)**不可實作**:對 `A→B`+`B→A` 或自環 `A→A`,閉包在環移除前沒有定義,一般的走鏈寫法會**無限迴圈**
149+
5. **環的處置:只丟棄構成該環的邊 + 記 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` 要保留**
137150

138151
### D9:搜尋路徑完全沒有異體字歸一化(必須明講)
139152

@@ -186,9 +199,14 @@ public static function replaceFor(string $table, string $column, string $value):
186199
* 表有 id=5 `乙→甲`、id=9 `甲→丙`,把 id=5 改成 `丙→乙` 是合法的 `甲→丙→乙`,
187200
* 但把舊邊算進去會看到 `乙→甲→丙→乙`。
188201
* $row 可能是部分 payload(restoreUpdate 用歷史快照),需與現有列 merge 後再驗。 */
189-
public static function assertWritable(array $row, ?int $excludeId = null): void;
202+
public static function assertWritable(array $row, ?int $excludeId = null): void; // throws VariantMappingException
190203
```
191204

205+
**`assertWritable()` 必須拋專屬型別 `App\Exceptions\VariantMappingException`,呼叫端也只能 catch 它。**
206+
`Illuminate\Database\QueryException` 繼承 `PDOException` 繼承 `\RuntimeException`,而 `assertWritable()`
207+
內部會查兩次 `char_variant_map`——呼叫端若 `catch (\RuntimeException)`,任何資料庫錯誤都會被當成
208+
「驗證失敗」,把原始 SQLSTATE 與 SQL 字串顯示給使用者,而且該次寫入會被靜默跳過而不是誠實地 500。
209+
192210
### 通知通道與 i18n
193211

194212
| 介面 | 通道 |
@@ -264,6 +282,16 @@ D3 已內嵌一份已查證的代碼鍵清單,直接進排除常數。本步
264282

265283
- direct 兩條:`replaced` 非空時把 `buildNotices()` 的每則訊息 `flash(...,'info')`**不要自己組字**,見上方 S1 約定);它們之後才呼叫 `applyColumnDefaultsForBlanks()``:1769``:1352`),**三條提案路徑不呼叫它**,只需在記 operation 之前。
266284
- 提案三條:替換後的值進 `operations.resource_data``$table`**目標表**,不是 `operations`)。
285+
- **D7 兩形並存的去重(S2 實作時發現、原本漏掉)**`ALTNAME_DATA.c_alt_name_chn` 同時是**文本主鍵成員**又在替換範圍內(strict),而 `ALTNAME_DATA` 是 Codes UI 可寫的表——這是 Codes UI 裡唯一會踩到 D7 的形狀。D6 之下既有列的主鍵可能還是變體形,只用**替換後**的值查重就會錯過它、鑄出語義重複的第二列(**比不替換更糟**),而資料庫唯一鍵擋不住(兩個字形是不同的鍵值)。因此三條 create 路徑(`performStore``performProposalStore``proposalUpdateExisting` 的 create 分支)都要**再以主鍵的「等價字形集合」查一次**
286+
⚠️ **只探「輸入值 + 替換後值」兩形是不夠的**:對照是**多對一**`c_variant_char` 有唯一鍵、`c_reference_char` 沒有),所以既有列可能是**另一個**變體——既有 `菁客`(菁→青)、新輸入 `靑客`(靑→青),兩者都歸一成 `青客`,但拿 `靑客``青客` 去查都找不到 `菁客`
287+
⚠️ **也不要用「列舉所有等價字形再逐一查」**(第二版這樣寫、被 codex 抓到):查詢次數等於等價字形數(最壞數十次),而為了避免組合爆炸設的上限**本身是正確性缺口**——超過上限就退回只比對正規形,等於完全失去這道去重,且那是可由合法對照表資料觸發的(一個參考字有 6 個變體、主鍵含 2 個這樣的字就是 7×7=49)。
288+
**正確做法**:把**不在替換範圍內**的主鍵欄固定在 SQL 條件裡取回那一小群候選列,再在 PHP 端把它們的文本主鍵歸一後比對。這是**精確**的(不管對照表什麼形狀)、而且只要**一次查詢**。全部主鍵欄都可替換的表會退化成全表掃描——目前沒有這種表,真的出現時記 warning 並跳過,不要靜默掃全表。
289+
待審提案那一側則是以 `resource_id`**位置式 LIKE 樣式**收斂(在替換範圍內的欄位放 `%`、其餘放實際值)+ `lazyById()` 分批。兩個實作陷阱:
290+
(a) **不能只做「前導前綴」**——production 的 `ALTNAME_DATA` 主鍵順序是 `(c_alt_name_chn, c_alt_name_type_code, c_personid)`,第一欄正是可替換的文字欄,前導前綴會直接失效而退回全掃。
291+
(b) **不能用 `cursor()` 當作「記憶體有界」**——PDO MySQL 預設是 buffered query(`config/database.php` 沒關 `MYSQL_ATTR_USE_BUFFERED_QUERY`),整個結果集仍會先進 PHP 記憶體;要用 `lazyById()`
292+
**測試 fixture 的主鍵順序必須與 production 一致**,否則就會像第一版那樣測不到 (a)。
293+
⚠️ **待審提案也要用等價字形比對**`hasActiveCreateProposalConflict()` 是拿 `resource_id` 做完全相等比對,S2 之前留下的 pending 提案帶的是變體形 `resource_id`,新提案帶歸一後的 ⇒ 不會衝突 ⇒ 兩筆待審並存,依序核准就落成兩種字形的兩筆列。
294+
update 路徑不受影響——它的定位器來自 URL 主鍵。
267295
- 同時涵蓋 Blade 與 React(共用 `perform*`)。
268296
- **`char_variant_map` 的 guard**`performStore()``performUpdate()` 落庫前呼叫 `CharVariantMapService::assertWritable($data, $id)`(違反回 flash error 且不寫入),成功後呼叫 `CharVariantMapService::reset()`。三條提案路徑寫的是 `operations`、不改對照表,**不需要** reset,但仍建議在提案建立時先跑 `assertWritable()` 提早拒絕。
269297
- 測試:`ADDR_CODES.c_name_chn` 含「淸」→ 落庫「清」+ flash;拼音欄拉丁字串 no-op;數字欄不變;`char_variant_map` 自身 `c_notes` 含「淸」****被替換;`pinyin` 表新增「峯」讀音 → `c_chn` 保持「峯」;提案 `resource_data` 是替換後值且 `resource_id` 未被改寫。
@@ -317,6 +345,7 @@ D3 已內嵌一份已查證的代碼鍵清單,直接進排除常數。本步
317345

318346
- **主分支先說清**`applyProposal():405``HANDLER_ROUTED_RESOURCES` 內的資源導到 `applyViaMutationHandler():483` 以 v2 direct handler 重放,所以**大多數人物子資源提案由 S3 的基底掛鉤自動覆蓋**,走不到下面兩條。
319347
- `applyCreateProposal():744``applyUpdateProposal():772`(服務代碼表與尚未遷移的表):**掛鉤點在方法最上方、`buildKeyConditions()` 之前**,不是「insert/update 之前」。`applyCreateProposal()` 的重複檢查在 `:752`,早於 `:757` 的 insert;寫成「落庫前」會重演 §1.3 禁止的錯位(查重用替換前值、落庫用替換後值)。`applyUpdateProposal()``$conditions` 同理(update `:798`)。今天實際影響小(該分支的文本型 PK 成員都在排除清單),但**措辭會被複製**
348+
- **`char_variant_map` 的核准也要 guard + 清快取**:該表**不在** `HANDLER_ROUTED_RESOURCES`,所以它的提案核准就走上面那兩條泛用分支(`applyCreateProposal():757` insert/`applyUpdateProposal():798` update)。S2 已在 Codes UI 的 direct 路徑補了 `assertWritable()``CharVariantMapService::reset()`,同樣的理由適用於核准端——核准一筆對照之後若不清快取,這個 worker 之後的替換都還在用舊對照。**兩者要一起補,別只補 guard 漏掉 reset。**
320349
- `applyKinshipProposal():597``applyAssocProposal():637`:不重放 handler、直接呼叫 `BiogMainRepository` 的 kinship/assoc 寫入方法,S3 覆蓋不到,必須獨立補。部分 repository 方法只是薄轉發,真正寫入在 `OfficePostingRepository.php``EventStatusRepository.php`,掛鉤要放在真正落庫那層。
321350
- 實體聚合提案核准(`:226 approveEntityAggregateProposal()``:273` 以 direct 重放,注入的正是 S4 那兩個 ImportService)由 S4 覆蓋,本步只需驗證——**含 S4 新增的三個 v2 lookup site**
322351
- 雙保險:提案建立端(S2/S3/S5)已替換,核准端再替換一次,依 D8 幂等。與 S2 不衝突:S2 讓存進 payload 的已是替換後值,本步是對歷史遺留 payload 補網。

0 commit comments

Comments
 (0)