Claude Code 抓 PHP 隱形 Bug:五個人眼看不到的真實案例


有些 Bug 不會讓程式爆錯,不會噴 500,甚至不會被使用者發現——直到某天對帳對不起來,或者某個欄位的值莫名其妙差了一截。

這篇收集了五個從真實 Legacy PHP 專案中找到的隱形 Bug。它們有個共同特點:人眼 code review 幾乎不可能發現,但 Claude Code 讀完程式碼後幾秒就指出來了。


Bug 1:number_format 把 -30,000 變成 -30

症狀

費用明細表的預收款扣抵金額不對。客戶預繳了 30,000 元,系統只扣了 30 元。差了整整三個零。

根因

// ❌ 看起來很正常的加總
$Total += number_format($prepay);

問題出在 number_format() 的回傳值。當 $prepay = -30000 時:

number_format(-30000);  // 回傳字串 "-30,000"

然後 PHP 的 += 運算子會把字串轉成數字。PHP 怎麼轉?從左邊開始讀,遇到非數字字元就停止

"-30,000" + 0;  // PHP 讀到逗號就停 → -30

所以 -30,000(字串)被 PHP 解讀成 -30(數字)。

修復

// ✅ 加總用原始數值,顯示時才格式化
$Total += $prepay;

// 顯示的時候
echo number_format($Total);

Claude Code 怎麼找到的

> 費用明細表的金額計算有誤,幫我檢查所有跟金額加總相關的程式碼

Claude Code 逐行分析後指出:「number_format() 回傳的是格式化字串,不應該拿來做數學運算。當數值包含千分位逗號時,PHP 的型別轉換會在逗號處截斷。」

教訓

number_format() 是顯示用的,不是計算用的。 先算完,最後才格式化。


Bug 2:off-by-one——障礙級別永遠少一級

症狀

列印綜合評估表時,障礙級別的顯示永遠少一級。「中度」印出來變「輕度」,「重度」變「中度」。

根因

表單儲存用 0-based 索引:

意義
0
1輕度
2中度
3重度

但列印用的 draw_option() 函式用 1-based 索引比對:

// ❌ draw_option 內部從 1 開始比對
// 當 $Q38 = 2(中度)時,draw_option 認為 2 = 第二個選項 = 輕度
draw_option($Q38, ['無', '輕度', '中度', '重度']);

draw_option() 的邏輯是:if ($value == $index) 其中 $index 從 1 開始。所以 $Q38 = 2 匹配到的是第二個選項「輕度」而不是「中度」。

修復

// ✅ 傳入前 +1 對齊
draw_option($Q38 + 1, ['無', '輕度', '中度', '重度']);

為什麼人眼找不到

因為 draw_option() 是共用函式,定義在另一個檔案裡。你看表單程式碼的時候不會去追這個函式的實作,只會看到「嗯,傳了值進去,應該沒問題」。而且這個 Bug 只在列印時出現,畫面上的表單是用另一套 HTML radio button 顯示的,所以畫面正確,印出來錯

Claude Code 能同時讀取表單程式碼和 draw_option() 的實作,一對就發現 0-based vs 1-based 的衝突。


Bug 3:欄位名稱讓框架誤判表名

症狀

某個頁面偶發出現 Table 'invoice' doesn't exist 的錯誤。但資料庫裡根本沒有 invoice 這張表,程式碼裡也沒有人查它。

根因

系統使用一個自製的 database.php 框架,它用 explode("_") 來解析表單欄位名稱,推斷要寫入哪張表:

// database.php 的邏輯
foreach ($_POST as $key => $value) {
    $parts = explode("_", $key);
    $tableName = $parts[0];  // 取底線前的第一段當表名
    // INSERT INTO $tableName ...
}

問題出在一個叫 invoice_address 的欄位:

<!-- ❌ 欄位名稱的底線前綴被誤判為表名 -->
<input name="invoice_address" value="..." />

explode("_", "invoice_address")["invoice", "address"] → 框架認為要寫入 invoice 表 → 爆錯。

修復

第一版嘗試加表名前綴:

<!-- 嘗試修正:加上正確的表名前綴 -->
<input name="case01a_invoice_address" value="..." />

但這會讓 database.php 把它寫入 case01a 表的 invoice_address 欄位,而這個欄位並不存在。最後決定直接移除這個欄位,改用其他方式處理。

為什麼人眼找不到

  1. 錯誤訊息誤導Table 'invoice' doesn't exist 讓你去找誰在查 invoice 表,但根本沒有人直接查它
  2. 偶發性:只有在特定表單送出時才觸發,其他頁面的欄位名稱沒有這個衝突
  3. 框架黑箱:不看 database.phpexplode 邏輯,你永遠想不到是欄位命名的問題

Claude Code 讀完 database.php 和表單程式碼後,直接指出了 explode("_") 的推斷機制和 invoice_address 的命名衝突。


Bug 4:變數殘留跨迭代——處理 B 個案時用了 A 的資料

症狀

批次上傳個案資料到第三方 API 時,偶爾會出現某個個案的資料欄位混入前一個個案的值。不是每次都發生,只有在特定表單欄位為空時才會。

根因

原始程式碼用動態變數在迴圈中處理每個個案:

// ❌ 所有表單資料都用動態變數,在迴圈外作用域
foreach ($patients as $patient) {
    // 處理 QC102 表單
    $formData = getFormData($patient['id'], 'QC102');
    ${'evaluation_Q1'} = $formData['Q1'] ?? '';
    ${'evaluation_Q2'} = $formData['Q2'] ?? '';
    // ... 30 個欄位
    
    // 處理 QC103 表單
    $formData = getFormData($patient['id'], 'QC103');
    ${'assessment_Q1'} = $formData['Q1'] ?? '';
    // ... 
    
    // 組合 API payload
    $payload = buildPayload(${'evaluation_Q1'}, ${'evaluation_Q2'}, ...);
    sendToAPI($payload);
}

問題:當個案 B 的 QC102 表單是空的(getFormData 回傳空陣列),${'evaluation_Q1'} 不會被覆寫,保留了個案 A 的值

修復

Claude Code 建議把每種表單的處理封裝成獨立函式,利用 local scope 隔離:

// ✅ 封裝成函式,local scope 自動隔離
function buildQC102Data($patientId) {
    $formData = getFormData($patientId, 'QC102');
    return [
        'Q1' => $formData['Q1'] ?? '',
        'Q2' => $formData['Q2'] ?? '',
        // ...
    ];
}

foreach ($patients as $patient) {
    $qc102 = buildQC102Data($patient['id']);
    $qc103 = buildQC103Data($patient['id']);
    $payload = buildPayload($qc102, $qc103);
    sendToAPI($payload);
}

為什麼人眼找不到

  • 動態變數 ${'evaluation_Q1'} 在 3,000 行的檔案裡散落各處
  • 只有在「前一個個案有值、當前個案該欄位為空」時才觸發
  • API 端通常不會驗證資料歸屬,所以上傳不會報錯

Claude Code 讀完整個 3,000 行檔案後指出:「動態變數在迴圈迭代間沒有重置,當某個表單查詢回傳空值時,變數會保留上一次迭代的值。」


Bug 5:SQL Injection——藏在費用管理頁

症狀

沒有症狀——這是安全漏洞,不是功能 Bug。直到 Claude Code 做安全掃描才被發現。

根因

// ❌ 直接拼接使用者輸入
$cateID = $_REQUEST['cateID'];
$sql = "SELECT * FROM billing WHERE category = '$cateID'";

$area = $_GET['area'];
$sql2 = "SELECT * FROM billing1 WHERE area = $area";

$cateID 是 varchar 欄位,攻擊者可以注入 ' OR 1=1 --$area 雖然應該是整數,但沒有做型別轉換。

修復

// ✅ varchar 用 escape,int 用 intval
$cateID = escape_string($_REQUEST['cateID']);
$sql = "SELECT * FROM billing WHERE category = '$cateID'";

$area = intval($_GET['area']);
$sql2 = "SELECT * FROM billing1 WHERE area = $area";

Claude Code 怎麼找到的

> 幫我掃描這個檔案的 SQL injection 風險

Claude Code 會找出所有 $_GET$_POST$_REQUEST 直接拼接進 SQL 的地方,標注每一個的風險等級。

在 Legacy PHP 專案中,這種直接拼接的寫法非常普遍——因為專案啟動時可能還沒有 PDO prepared statement,或者開發者不知道有 SQL injection 這回事。Claude Code 可以一次掃完整個目錄,找出所有有風險的檔案。


系統性掃描的 Prompt 範本

不只是一個一個檔案看,你可以讓 Claude Code 做全局掃描:

金額計算

> 搜尋所有檔案中 number_format 的回傳值被拿來做數學運算的地方

索引對齊

> 找出所有 draw_option / draw_checkbox 的呼叫,
  檢查傳入值和選項陣列的索引是 0-based 還是 1-based

框架陷阱

> 讀 database.php 的 POST 處理邏輯,
  然後找出所有表單中欄位命名可能觸發誤判的 name 屬性

變數作用域

> 找出所有 foreach 迴圈中使用動態變數 ${} 的地方,
  檢查是否有跨迭代殘留的風險

SQL Injection

> 掃描所有 PHP 檔案,找出 $_GET/$_POST/$_REQUEST 
  直接拼接進 SQL 字串的地方

結語

這五個 Bug 有個共同特點:它們都不是邏輯錯誤,而是語言特性和框架行為的認知盲區

Bug根因人眼難度
number_formatPHP 字串轉數字的截斷行為看起來完全正常
off-by-one0-based vs 1-based 在不同檔案要同時讀兩個檔案
框架誤判自製框架的 explode 推斷邏輯要讀框架原始碼
變數殘留動態變數不被 foreach 重置3,000 行檔案中的作用域
SQL Injection歷史寫法,當年沒有這個意識到處都是,反而麻木

Claude Code 在這類問題上的優勢不是「比人聰明」,而是「比人耐心」。它會讀完每一行,不會因為「這段看起來沒問題」就跳過。在 Legacy 專案裡,最危險的 Bug 通常就藏在「看起來沒問題」的地方。