用 Claude Code 開發安全的回滾腳本:從「直接改 DB」到「唯讀查詢」的兩天演進


「我需要一個腳本,把上個月申報錯誤的保險點數改回來。」

聽起來很簡單——寫個 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 分支上發生了一段小插曲:

  1. 有人把這個腳本(和其他幾個功能)cherry-pick 到 release 分支
  2. 發現時機不對,revert 了整個 cherry-pick
  3. 一分鐘後,決定還是需要,又 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 中移除整個腳本

結語

回滾腳本的演進過程,其實就是信心逐步建立、風險逐步降低的過程:

  1. 先讓它能跑——即使不安全
  2. 加入安全閘門——dryrun、日期範圍
  3. 補足遺漏的邏輯——合計重算、表存在檢查
  4. 完成任務後拆除武器——移除寫入能力

Claude Code 最大的價值不是幫你寫出完美的第一版(第一版永遠不會完美),而是在每一步提醒你:「這裡還有風險,要不要處理?」

在有時間壓力的情況下,人會傾向「先跑再說」。有一個會主動指出風險的工具,可以讓你在「快」和「安全」之間找到更好的平衡點。