「我需要一個腳本,把上個月申報錯誤的保險點數改回來。」
聽起來很簡單——寫個 UPDATE,跑一下就好。但在多租戶架構下,這個「跑一下」要改的是幾十個資料庫裡的上千筆記錄。改錯一個數字,就是整批申報資料作廢。
這篇記錄一個回滾腳本在兩天內經歷 10+ 次 commit 的安全演進過程。每一次 commit 都在縮小爆炸半徑,直到最後變成一個純唯讀的查詢工具。
背景
保險機構公告更新了部分服務代碼的點數值。已經用舊點數申報出去的記錄需要回滾修正。
涉及的範圍:
- 多租戶架構,幾十個機構各有獨立資料庫
- 兩張主要的申報資料表需要更新
- 每筆記錄的合計點值(含加成)需要重新計算
- 時間壓力:月底前要處理完
我請 Claude Code 幫我寫這個腳本。接下來發生的事,是一堂關於「安全邊界」的課。
第一版:天真的 UPDATE(Day 1, 10:26)
// 第一版:直接遍歷所有資料庫,直接 UPDATE
$databases = $db->query("SHOW DATABASES LIKE 'demo-org-%'");
$updateData = [
'05310C' => 792,
'05315C' => 1050,
'05320C' => 924,
// ... 共 63 個代碼
];
foreach ($databases as $dbName) {
foreach ($updateData as $code => $points) {
$sql = "UPDATE {$dbName}.service_record_03
SET Q7 = {$points}
WHERE code = '{$code}'";
$db->query($sql);
}
}
問題清單:
- ❌ 沒有 dryrun 模式——跑了就改了
- ❌ 沒有日期範圍限制——全部歷史記錄都會被改
- ❌ 沒有合計點值重算
- ❌ 沒有輸出報告——改了什麼完全不知道
- ❌ 點數值是手動輸入的
八分鐘後,第二個 commit 就來了——因為點數值打錯了。
八分鐘後:資料就錯了(Day 1, 10:34)
- '05310C' => 660,
+ '05310C' => 792,
- '05315C' => 700,
+ '05315C' => 1050,
改了約 15 個點數值,都是對照官方公告後發現的錯誤。
教訓 #1:手動輸入的資料一定會有錯。如果第一版有 dryrun,這些錯誤可以在修正後再跑,不會影響任何資料。
這裡我讓 Claude Code 重新審視了腳本:
> 這個腳本太危險了,我需要加入安全機制。
至少要有 dryrun 模式和日期範圍限制。
第二版:加入 dryrun 和日期範圍(Day 1, 13:16)
Claude Code 產生了一個完全重寫的版本:
// 第二版:從機構資料表取得 DB 清單,加入 dryrun
$mode = $_GET['mode'] ?? 'dryrun'; // 預設 dryrun
$dateStart = '2026-03-31 08:00:00';
$dateEnd = '2026-03-31 12:00:00';
// 新舊值對照(不只是新值,還記錄舊值用於驗證)
$pointMapping = [
'05310C' => ['old' => 660, 'new' => 792],
'05315C' => ['old' => 700, 'new' => 1050],
// ...
];
foreach ($orgDatabases as $dbName) {
// 先查詢受影響的記錄
$sql = "SELECT * FROM {$dbName}.service_admin_01 a
INNER JOIN {$dbName}.patient p ON a.patientID = p.patientID
WHERE a.service_date BETWEEN '{$dateStart}' AND '{$dateEnd}'
AND a.code IN ('" . implode("','", array_keys($pointMapping)) . "')";
$affected = $db->query($sql);
// 輸出報告(不管什麼模式都輸出)
echo "<table><tr><th>DB</th><th>代碼</th><th>舊值</th><th>新值</th></tr>";
foreach ($affected as $row) {
echo "<tr><td>{$dbName}</td><td>{$row['code']}</td>...";
}
// 只有 execute 模式才真的改
if ($mode === 'execute') {
// UPDATE ...
}
}
改進:
- ✅ 預設 dryrun,要手動加
?mode=execute才會改 - ✅ 日期範圍限制
- ✅ 新舊值對照,可以驗證改的是不是對的
- ✅ HTML 報告表格
但還有問題:
- ❌ 沒有重算合計點值
- ❌ 某些資料庫沒有 patient 表,JOIN 會直接報錯
防禦性修正:跳過不存在的表(Day 1, 13:38)
上線測試時炸了——某些機構的資料庫結構不完整,沒有 patient 表。
// ✅ 先檢查表是否存在
$tables = $db->query("SHOW TABLES FROM `{$dbName}` LIKE 'patient'");
if (empty($tables)) {
echo "<p>⚠️ {$dbName}: 無 patient 表,跳過</p>";
continue;
}
教訓 #2:多租戶架構下,不要假設每個資料庫的 schema 都一樣。
大重構:合計點值計算(Day 1, 16:46)
這是最大的一次改動。Claude Code 指出了一個我漏掉的問題:
> 你只改了明細的點數,但合計欄位(A31)是根據明細算出來的。
如果不重算 A31,合計和明細會對不上。
合計的計算公式:A31 = ROUND(點數 × 加成比例1 × 加成比例2)
// ✅ 計算 A31 差異值
function calcA31Delta($oldPoints, $newPoints, $surcharge1, $surcharge2) {
$oldA31 = round($oldPoints * $surcharge1 * $surcharge2);
$newA31 = round($newPoints * $surcharge1 * $surcharge2);
return $newA31 - $oldA31;
}
// 更新合計
if ($mode === 'execute') {
$delta = calcA31Delta(
$mapping['old'], $mapping['new'],
$row['surcharge_1'], $row['surcharge_2']
);
$sql = "UPDATE {$dbName}.service_admin
SET A31 = A31 + {$delta}
WHERE id = '{$row['adminId']}'";
}
同時也做了效能優化:
- WHERE 從 70 個 OR 條件改成
(code, points) IN (...)的 tuple 比較 - 一次查詢所有資料庫的表結構,取代逐個
SHOW TABLES
轉折點:鎖定 dryrun(Day 2, 11:06)
到這裡,腳本功能上已經完成。但我越想越不安——?mode=execute 這個 URL 參數太容易被誤觸了。如果有人不小心點了書籤、或者瀏覽器自動補完 URL…
我跟 Claude Code 說:
> 這個腳本我已經在測試環境跑過 execute 了,
現在要部署到正式環境。
幫我把 execute 模式整個移除,只保留 dryrun。
// ✅ 強制 dryrun,移除 execute 連結
$isDryRun = true; // 不再從 URL 讀取
// 頁面上不再顯示 "點此執行" 的連結
日期範圍也從整個月縮小到合理區間。
最終形態:移除所有 UPDATE(Day 2, 14:24)
正式環境的修正已經用其他方式處理完了。這個腳本的角色變成「驗證工具」——只需要查詢哪些記錄被影響,不需要修改任何東西。
Claude Code 幫我做了最後的手術:
> 把所有 UPDATE 語句從程式碼中完全移除,
這個腳本從此只做查詢,不做任何寫入。
- $isDryRun = true;
- if (!$isDryRun) {
- $db->query("UPDATE ...");
- }
+ // 純查詢模式,無任何寫入操作
+ $found = $db->query("SELECT COUNT(*) ...");
+ echo "找到 {$found} 筆受影響記錄";
標題也從「回滾」改成「查詢」。
最後一刀:縮小到單日(Day 2, 15:15)
- $dateEnd = '2026-04-30 23:59:59';
+ $dateEnd = '2026-04-01 23:59:59';
一行改動,把查詢範圍從整個四月縮小到 4/1 單日。因為確認只有那一天的資料需要處理。
插曲:Revert 的 Revert
在這段演進中間,release 分支上發生了一段小插曲:
- 有人把這個腳本(和其他幾個功能)cherry-pick 到 release 分支
- 發現時機不對,revert 了整個 cherry-pick
- 一分鐘後,決定還是需要,又 revert 了那個 revert
Revert → Revert of Revert,Git 歷史裡留下了完美的「我改主意了」記錄。
這跟腳本本身的安全性無關,但說明了一件事:在有時間壓力的情況下,Git 操作也會變得混亂。如果腳本本身不夠安全,這種混亂就可能讓危險的程式碼被意外部署。
演進時間線
Day 1
10:26 v1 - 直接 UPDATE,無安全機制
10:34 v2 - 修正錯誤的點數值(手動輸入出錯)
13:16 v3 - 加入 dryrun 模式 + 日期範圍
13:38 v4 - 防禦性修正:跳過不存在的表
16:46 v5 - 大重構:合計點值計算 + 效能優化
Day 2
11:06 v6 - 強制 dryrun,移除 execute 模式
14:24 v7 - 移除所有 UPDATE 語句
15:15 v8 - 縮小查詢範圍至單日
每一步都在做同一件事:縮小爆炸半徑。
Claude Code 在這個過程中扮演的角色
1. 提醒遺漏的安全邊界
第一版寫完時,我只想著「趕快把點數改回來」。Claude Code 主動指出:
- 「需要 dryrun 模式」
- 「需要日期範圍限制」
- 「合計欄位需要重算」
這些不是我忘了,是在時間壓力下沒想到。
2. 處理多租戶的複雜度
遍歷幾十個資料庫、處理 schema 差異、批次查詢最佳化——這些瑣碎但重要的工程細節,Claude Code 比人更不容易漏掉。
3. 做減法
後半段的工作全是「移除」——移除 execute 模式、移除 UPDATE 語句、縮小範圍。這些改動很簡單,但需要有人提醒你去做。我跟 Claude Code 的對話基本上是:
> 這個腳本已經完成任務了,幫我把它變安全。
然後 Claude Code 會列出所有還能移除的危險部分。
回滾腳本安全檢查清單
從這次經驗整理出的檢查清單,未來寫任何修改線上資料的腳本都適用:
寫入前
- 預設 dryrun 模式,require 明確的參數才能 execute
- 限制日期範圍,不要碰不該碰的歷史資料
- 限制影響的資料庫範圍
- 檢查目標表是否存在再操作
- 輸出受影響記錄的報告,先 review 再決定是否執行
- 記錄舊值和新值,方便驗證
寫入後
- 驗證合計欄位是否需要連動更新
- 確認沒有其他下游依賴被影響
任務完成後
- 移除 execute 模式
- 如果不再需要修改功能,移除所有 UPDATE/DELETE 語句
- 縮小查詢範圍到實際需要的最小範圍
- 考慮是否要從 codebase 中移除整個腳本
結語
回滾腳本的演進過程,其實就是信心逐步建立、風險逐步降低的過程:
- 先讓它能跑——即使不安全
- 加入安全閘門——dryrun、日期範圍
- 補足遺漏的邏輯——合計重算、表存在檢查
- 完成任務後拆除武器——移除寫入能力
Claude Code 最大的價值不是幫你寫出完美的第一版(第一版永遠不會完美),而是在每一步提醒你:「這裡還有風險,要不要處理?」
在有時間壓力的情況下,人會傾向「先跑再說」。有一個會主動指出風險的工具,可以讓你在「快」和「安全」之間找到更好的平衡點。