Skip to content

Commit 8b4d899

Browse files
sudoghutclaude
andcommitted
補回 React 殼的 SQL 查詢明細並收斂收集成本,刪除使用者恢復兩段確認
- 舊版 Blade layout 底部的「本次查詢共 N 筆」+管理員明細 modal 在 React 遷移時漏移植, 而 DB::listen 仍無條件把每筆 SQL 與 bindings 累進記憶體——照樣收集卻沒人看得到。 - 權限分界對齊舊版:摘要行對所有人顯示(舊版該行無權限閘,含訪客),明細只給管理員。 故閘的是「保留明細」而非「整個收集」,flag 回退到 Blade 的頁面行為亦不變。 - 顯示端(HandleInertiaRequests)自己再檢查一次 isAdmin,不只依賴收集端閘門。 - shared prop 必須延後求值:inertia-laravel 在 $next() 之前呼叫 share(),直接求值只會 拿到 session/撈使用者那兩筆(實測 2 筆 vs 實際 6~7 筆);再包 Inertia::always 讓 局部重載也更新筆數,但刻意不夾帶明細(props 會進 window.history.state)。 - 前端沿用舊明細的條件由後端明說(details_omitted),非管理員永遠為 false, 避免登出/被降權後同一個 React 殼繼續顯示管理員的 SQL。 - QueryProfile 加明細上限(筆數與耗時另計、永遠精確)、先切再編碼,改為 scoped 綁定 且在回呼內解析;View::composer 由 '*' 收窄到 layouts.dashboard-v3。 - 閘門明確用 web guard(預設 guard 會被 OptionalAuthentication 改寫)、不做跨請求記憶、 例外只 report 第一次且 report 自身再包一層 try/catch。 - /app/manage/{id}/edit 刪除使用者恢復兩段確認(對齊舊版兩道 confirm),payload 不變。 測試: - 新增 tests/Feature/QueryProfileGateTest.php(15 tests) - 已執行 ./vendor/bin/phpunit(2493 tests 全綠)、npx vitest run(51 tests) - 已執行 ./vendor/bin/php-cs-fixer fix 與 npm run build - 另以 headless Chrome 對真實庫驗證 11 項(6 項查詢明細含非管理員 payload 無 SQL、 5 項兩段刪除含第一段繼續不刪除/第二段取消不刪除/兩段確認才刪除) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f106118 commit 8b4d899

11 files changed

Lines changed: 762 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@
44

55
## 2026-08
66

7+
### 補回 React 殼的 SQL 查詢明細,並把收集成本收斂到看得到的人身上;刪除使用者恢復兩段確認
8+
- 起因:`AppServiceProvider``DB::listen` **無條件**把每筆查詢的 SQL 與 bindings 累進記憶體,而 React layout 從來沒有渲染這份資料——舊版 `layouts/dashboard-v3.blade.php` 底部的「本次查詢共 N 筆,耗時 X ms」+管理員可開的明細 modal 在遷移時漏移植。等於每個 request(含 artisan 匯入、訪客瀏覽)都照樣收集、卻沒人看得到。
9+
- **權限分界完全對齊舊版**,而不是順手改掉:舊版那條摘要行 **沒有任何權限閘**`dashboard-v3.blade.php:346`,訪客也看得到),只有「查看詳細」連結與 modal 限管理員(同檔 `:348``:369`)。因此閘的是「**保留明細**」而不是「整個收集」:筆數與耗時一律累計,每筆 SQL/bindings 只在使用者已解析為 `isAdmin()` 時保留。若連筆數都不收,flag 回退到 Blade 的頁面也會少一行,那不在本次範圍。
10+
- **顯示端自己再授權一次**,不只依賴收集端:`HandleInertiaRequests::queryProfile()` 獨立檢查 `isAdmin()`,非管理員的 `queries` 為空陣列。收集端的閘門長在全域 `DB::listen` 回呼裡、還包著 try/catch,一旦有人為了「讓筆數回到舊版數字」把它放寬,原始 SQL 與 bind 值就會直接出現在每個訪客的 `data-page` JSON(AGENTS.md §5:授權不該只有一道,更不該只長在除錯收集器內)。已用瀏覽器驗證非管理員的 payload 裡完全沒有 `"sql":`
11+
- **shared prop 必須延後求值**(實際踩到並修掉的 bug):inertia-laravel 的 `Middleware::handle` 是在 `$next($request)` **之前**呼叫 `share()`,此刻控制器一筆查詢都還沒跑。原本直接呼叫 `queryProfile()`,摘要永遠只有 session/撈使用者那兩筆——畫面上看起來有東西、數字卻是錯的(實測 codes 頁 2 筆 vs 實際 6~7 筆)。改為 closure,由 `Response::resolveArrayableProperties``toResponse()` 才求值。
12+
- 再包一層 `Inertia::always()`:局部重載只回傳 `only` 指定的 props,其餘 shared props 會被丟掉、前端沿用舊值——除錯輔助顯示上一次請求的筆數比不顯示更誤導。此行為以**真正的局部重載**測試鎖住(帶 `X-Inertia-Partial-Data` 指定別的 prop),換回普通 closure 即紅。
13+
- **局部重載只更新摘要、不夾帶明細**:Inertia 會把整份 page props 存進 `window.history.state`,而切換人物分頁這類局部重載在編目工作中極頻繁;每個 XHR 都夾帶上百句 SQL 與 bind 值,只為了一個偶爾打開的 modal,並不划算,也會把 bind 值留在瀏覽器歷史。前端記住最後一次拿到的明細,讓「查看詳細」不會在局部更新後忽然消失,並在 modal 內明示「以下明細為本頁載入時的查詢」。兩個方向都有測試(局部重載回應不得出現 `"sql"`;整頁載入必須給管理員明細,否則「不夾帶」會退化成「永遠拿不到」)。
14+
- 閘門判斷**刻意不做跨請求記憶**`ServiceProvider` 是長生命週期物件,在 Octane/RoadRunner 這類常駐 worker 下會活過請求邊界,一旦某個管理員請求把「可留明細」記成 true,後續訪客請求就會開始保留 SQL——那是跨請求外洩。省下的只是每筆查詢幾個不碰資料庫的屬性讀取(`hasResolvedGuards()` 是 count、`hasUser()``is_null``user()` 走已快取屬性、`isAdmin()``in_array`),不值得用正確性去換。同理 `QueryProfile``singleton` 改為 **`scoped`**:它記的是「本次請求的查詢」,語意上就該隨請求結束;傳統 php-fpm 下兩者等價,常駐 worker 下 singleton 會讓筆數愈跑愈大、且管理員留下的明細可能被後續請求讀到。
15+
- 前端沿用舊明細的條件由後端明說(`details_omitted`),不是「本次沒帶就沿用」:同一個 React 殼會活過登出/被降權/換 session,那時回應是 `queries: []``details_omitted=false`,前端因此會**清掉**先前的明細,而不是把管理員的 SQL 繼續顯示給已經不是管理員的人。非管理員的回應永遠 `details_omitted=false`(有測試明文鎖住)。
16+
- 收集端加上記憶體上限(`QueryProfile::MAX_STORED = 200`),但**筆數與總耗時另行累計、永遠精確**——否則管理員在一個跑幾千筆查詢的頁面上只看到「200 筆」,反而掩蓋了要抓的效能問題。`summary()` 改為先切再編碼(先前編碼 200 筆再丟掉一半),`View::composer``'*'` 收窄到真正使用該變數的 `layouts.dashboard-v3`(先前一個 Blade 頁面渲染數十個 partial 就重複編碼數十遍)。
17+
- 閘門判斷本身的兩個修正:**明確指定 web guard**`OptionalAuthentication` 會在執行期 `Auth::shouldUse('sanctum')` 改寫預設 guard,否則「帶 token 打 API 的管理員」也開始留明細,而 JSON 回應永遠不顯示這份資料——正是要消除的浪費);try/catch 只 **report 第一次**(每筆都 report 會淹沒日誌,完全不記錄則會讓閘門壞掉、功能無聲消失卻查不出原因),且 `report()` 自身再包一層 try/catch——它若丟出去就繞過了外層 catch,讓一個純除錯輔助弄壞請求。
18+
- 判斷絕不能呼叫 `Auth::check()``Auth::user()`:那會在「解析使用者」那句 select 的監聽器裡再次觸發解析(遞迴)。改用 `hasResolvedGuards()` + `hasUser()`(皆不碰資料庫)。此性質改由**真的從 session 解析**的測試守住——先前的測試用 `actingAs()`,那是直接 `setUser()`、永遠走不到解析路徑,把閘門換成 `Auth::user()` 照樣綠燈。已知代價:解析完成前的查詢沒有明細(筆數仍算),故明細列數略少於總筆數,訊息文案因此不寫「前 N 筆」。
19+
- 顯示端沿用共用 `Modal`(Radix:focus trap/Esc/a11y 內建)而非自製對話框,補回舊版 modal 底部的「關閉」鈕,總毫秒數用千分位(對齊舊版 `number_format`)。
20+
- **刪除使用者恢復兩段確認**:舊版 `manage/edit.blade.php` 有兩道 `confirm()`(第一段說明不可恢復、第二段最後確認),React 遷移時收斂成一道。改用兩個 `ConfirmDialog` 串接,沿用同一組翻譯鍵;第一段的 `\n\n``whitespace-pre-line` 保住斷行(舊版走 `window.confirm`,空行就是重點強調)。送出 payload(`delete_user=1`)與後端契約未改動。
21+
- 收集端與顯示端**用同一個 guard**(皆為 `web`):兩端若各自看執行期可被改寫的預設 guard,會出現「有收集卻不給看」的不一致。
22+
- 回歸測試 `QueryProfileGateTest`(15 tests:訪客/一般/眾包使用者有筆數但無明細、超級管理員與專家有明細、非管理員的 shared prop 不帶任何 SQL、延後求值含控制器查詢、真正的局部重載仍帶此 prop 但不帶明細、整頁載入才給明細、`details_omitted` 僅對「本來看得到明細的人」為 true、明細上限與筆數精確、記憶體上限不扭曲總計、從 session 解析使用者時不遞迴且解析前不留明細、summary 形狀穩定)。另以 headless Chrome 對真實庫驗證 11 項(6 項查詢明細,含非管理員 payload 無 SQL;5 項兩段刪除,含第一段按繼續不會刪除、第二段取消不會刪除、兩段都確認才真的刪除)。
23+
724
### codes 表單的人物欄改為可搜尋的人物選擇器(判準改用外鍵)
825
- 承上一則:泛用 codes 表單漏移植的最後一項。舊版 `codes/edit.blade.php` 把人物欄渲染成 select2(姓名或 ID 皆可查),React 版是純數字輸入框——使用者必須先知道人物 ID 才能填。
926
- **判準改為「外鍵實際指向 `BIOG_MAIN`」,以 schema 宣告為唯一權威**`CodesController::personFkColumns()`)。舊版是按欄名硬編碼 `c_personid``c_kin_id`,會漏掉 `ASSOC_DATA``c_assoc_id`(社會關係「對方是誰」)、`c_assoc_kin_id``c_assoc_claimer_id``c_tertiary_personid``ENTRY_DATA.c_assoc_id` 共 5 個真人物欄——恰恰是最需要用姓名搜尋的地方。改用外鍵後涵蓋 **17 張碼表、25 個欄位**,且隨 schema 自動跟上,不需維護人工白名單。

app/Http/Middleware/HandleInertiaRequests.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Support\Navigation;
66
use Illuminate\Http\Request;
7+
use Inertia\Inertia;
78
use Inertia\Middleware;
89

910
class HandleInertiaRequests extends Middleware {
@@ -72,6 +73,19 @@ public function share(Request $request): array {
7273
// flash 訊息橋接:把 laracasts/flash 的 session 訊息轉成陣列,
7374
// 由 React AppShell 統一渲染 toast/alert(取代 Blade flash::message partial)。
7475
'flash' => $this->flashMessages(),
76+
// SQL 查詢明細(管理員的效能除錯輔助)。舊版 Blade layout 有這一區、React layout 沒有,
77+
// 於是 DB::listen 照樣收集卻沒人看得到;連同收集端的閘門一起補回。null=不顯示。
78+
//
79+
// ⚠️ 必須是 closure(且用 Inertia::always 包起來),不可直接呼叫:
80+
// inertia-laravel 的 Middleware::handle 是在 `$next($request)` **之前**
81+
// 呼叫 share(),此刻控制器一筆查詢都還沒跑,直接求值只會拿到 session/撈使用者
82+
// 那兩筆,摘要永遠顯示個位數。closure 由 Response::resolveArrayableProperties
83+
// 以 App::call() 在 toResponse()(控制器跑完之後)才求值,才是本次請求的真實筆數。
84+
//
85+
// always():局部重載(partial reload,如切換人物分頁)只回傳 `only` 指定的 props,
86+
// 其餘 shared props 會被丟掉,前端於是沿用舊值——除錯輔助顯示上一次請求的筆數
87+
// 比不顯示更誤導。包成 AlwaysProp 讓每個回應都帶上當次的實際筆數。
88+
'query_profile' => Inertia::always(fn () => $this->queryProfile($request)),
7589
// ⚠️ 頁面特定翻譯群組(views、codes、operations、admin)
7690
// 請由控制器以 'page_translations' key 傳入,不可複用此 'translations' key,
7791
// 否則 inertia-laravel 的淺合併會覆蓋此處的 shared 翻譯。
@@ -105,6 +119,64 @@ protected function profileUrl(): ?string {
105119
return null;
106120
}
107121

122+
/**
123+
* SQL 查詢明細,供 React layout 顯示。
124+
*
125+
* 筆數與耗時給所有人(對齊舊版 Blade 那一行本來就沒有權限閘);**每筆 SQL 與 bindings
126+
* 只給管理員**。這裡自己再檢查一次 isAdmin,不只依賴收集端的閘門
127+
* (AppServiceProvider::shouldRetainQueryDetails()):那個閘門長在全域 DB::listen 回呼裡、
128+
* 還包著 try/catch,一旦有人為了「讓筆數回到舊版的數字」把它放寬,原始 SQL 與 bind 值
129+
* 就會直接出現在每個訪客的 data-page JSON 裡。授權不該只有一道,且不該只長在除錯收集器內
130+
* (AGENTS.md §5)。此處不在 DB::listen 內,可以安全呼叫 Auth。
131+
*
132+
* 明細只取前 100 筆(與舊版 modal 的 array_slice 一致)並回報是否被截斷,避免把一頁上千筆
133+
* 查詢全部塞進 Inertia props。沒有任何查詢時回 null,前端就不渲染這一區。
134+
*
135+
* **局部重載不帶明細**:Inertia 會把整份 page props 存進 window.history.state,而
136+
* 局部重載(切換人物分頁等)在管理員操作中非常頻繁;每次都夾帶上百句 SQL 與 bind 值,
137+
* 等於為了一個偶爾打開的 modal 讓每個 XHR 都變胖、並把 bind 值留在瀏覽器歷史裡。
138+
* 摘要(筆數/耗時)仍每次更新,明細則以整頁載入那次為準。
139+
*
140+
* @return array<string, mixed>|null
141+
*/
142+
protected function queryProfile(Request $request): ?array {
143+
$profiler = app(\App\Services\QueryProfile::class);
144+
if ($profiler->count() === 0) {
145+
return null;
146+
}
147+
148+
// 與收集端同一個 guard(AppServiceProvider::shouldRetainQueryDetails() 用 web):
149+
// OptionalAuthentication 會在執行期改寫預設 guard,兩端若各看各的預設值,就會出現
150+
// 「有收集卻不給看」這種不一致。
151+
$user = \Illuminate\Support\Facades\Auth::guard('web')->user();
152+
$isAdmin = $user instanceof \App\Models\User && $user->isAdmin();
153+
// 兩個 partial header 都要在:inertia-laravel 的 Response::isPartial() 除了 partial-data
154+
// 還要求 partial-component 與本次元件相符,只看單一 header 會把畸形請求也當成局部重載
155+
// (後果只是不給明細,不會外洩,但沒必要)。
156+
$isPartial = $request->hasHeader('X-Inertia-Partial-Data')
157+
&& $request->hasHeader('X-Inertia-Partial-Component');
158+
$canSeeDetails = $isAdmin && !$isPartial;
159+
160+
$limit = 100;
161+
$summary = $profiler->summary($canSeeDetails ? $limit : 0);
162+
163+
return [
164+
'count' => $summary['count'],
165+
'time_ms' => round((float) $summary['time_ms'], 2),
166+
// 「這次刻意不送明細,但你本來看得到」——前端據此保留上一次整頁載入的明細。
167+
// 非管理員永遠是 false,因此降權/登出後的回應會讓前端**清掉**先前的明細,
168+
// 而不是繼續顯示(同一個 React 殼會活過這種身分變化)。
169+
'details_omitted' => $isAdmin && $isPartial,
170+
// 非管理員沒有明細,但那不叫「被截斷」——前端據此決定要不要顯示「查看詳細」。
171+
'truncated' => $canSeeDetails && $summary['count'] > count($summary['queries']),
172+
'queries' => array_map(static fn (array $q): array => [
173+
'time' => round((float) $q['time'], 2),
174+
'sql' => $q['sql'],
175+
'bindings' => $q['bindings_json'],
176+
], $summary['queries']),
177+
];
178+
}
179+
108180
/**
109181
* 將 laracasts/flash 的 session 訊息(session key `flash_notification`)
110182
* 正規化成前端可消費的陣列。flash 訊息屬一次性 session flash data,

0 commit comments

Comments
 (0)