有些 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 欄位,而這個欄位並不存在。最後決定直接移除這個欄位,改用其他方式處理。
為什麼人眼找不到
- 錯誤訊息誤導:
Table 'invoice' doesn't exist讓你去找誰在查invoice表,但根本沒有人直接查它 - 偶發性:只有在特定表單送出時才觸發,其他頁面的欄位名稱沒有這個衝突
- 框架黑箱:不看
database.php的explode邏輯,你永遠想不到是欄位命名的問題
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_format | PHP 字串轉數字的截斷行為 | 看起來完全正常 |
| off-by-one | 0-based vs 1-based 在不同檔案 | 要同時讀兩個檔案 |
| 框架誤判 | 自製框架的 explode 推斷邏輯 | 要讀框架原始碼 |
| 變數殘留 | 動態變數不被 foreach 重置 | 3,000 行檔案中的作用域 |
| SQL Injection | 歷史寫法,當年沒有這個意識 | 到處都是,反而麻木 |
Claude Code 在這類問題上的優勢不是「比人聰明」,而是「比人耐心」。它會讀完每一行,不會因為「這段看起來沒問題」就跳過。在 Legacy 專案裡,最危險的 Bug 通常就藏在「看起來沒問題」的地方。