TTechSpecs

除錯舊程式碼 - Debug Legacy Code 實戰範例

本範例將展示如何運用 Superpowers 的除錯與驗證 skills 來處理 legacy code 中難以追蹤的 bug。Legacy code 通常缺乏測試、結構複雜、商業邏輯隱晦,是開發者最頭痛的挑戰之一。透過系統化的方法,我們可以把「猜測式除錯」轉變為「根因分析」。

專案概述

情境:接手一個 5 年歷史的電商後端專案,使用者回報「偶爾訂單金額計算錯誤」的問題,但 QA 團隊始終無法穩定重現。

目標:找出根因、修復 bug、建立回歸測試避免再次發生。

技術棧:Node.js + Express + MongoDB + Jest

核心挑戰

  • 訂單計算邏輯分散在 5 個檔案、12 個函式中
  • 沒有單元測試,只有少數整合測試
  • 浮點數運算混合貨幣處理
  • 折扣、稅金、運費的計算順序不明確

完整除錯流程

第 1 步:使用 Systematic Debugging Skill 建立除錯計畫

觸發

使用 systematic-debugging skill 處理這個訂單金額錯誤的問題

AI 回應摘要

鐵律:在沒有找到根因之前,不修復任何程式碼。

## 第 1 階段:重現問題
- 蒐集錯誤日誌與訂單樣本
- 找出能穩定重現的觸發條件
- 建立最小的重現案例(MRE)

## 第 2 階段:隔離問題
- 使用二分法縮小範圍
- 檢視最近的相關 commit
- 對照「正常」與「錯誤」訂單的差異

## 第 3 階段:提出假設
- 根據觀察提出可驗證的假設
- 每個假設都要有明確的驗證方式
- 排序假設,從最可能、最容易驗證的開始

## 第 4 階段:實作修復
- 確認根因後才動手修
- 修復需伴隨回歸測試
- 修復後需重新驗證原問題已解決

第 2 步:重現問題-蒐集證據

我們與 QA 合作蒐集了一週的訂單資料,發現錯誤訂單有以下共同特徵:

錯誤訂單特徵分析:
- 訂單包含 3 個以上商品:佔錯誤案例的 87%
- 有套用折扣碼:佔錯誤案例的 92%
- 建立時間落在 23:50 ~ 00:10 之間:佔錯誤案例的 64%
- 商品價格含小數(如 $19.99):佔錯誤案例的 78%

最小重現案例(MRE)

// reproduce-bug.test.js
const { calculateOrderTotal } = require('./order-calculator');

test('重現金額錯誤:跨日折扣 + 多商品 + 小數價格', () => {
  const order = {
    items: [
      { sku: 'A', price: 19.99, qty: 2 },
      { sku: 'B', price: 5.50, qty: 1 },
      { sku: 'C', price: 12.75, qty: 3 },
    ],
    discount: { code: 'NIGHT10', type: 'percentage', value: 10 },
    createdAt: '2024-01-15T23:58:00Z',  // 跨日時段
  };

  const result = calculateOrderTotal(order);
  
  // 預期:79.74 - 7.97 (折扣) + 0.8 (稅) = 72.57
  expect(result.total).toBe(72.57);
  // 實際:有時回傳 72.56、有時 72.58,偶爾 NaN
});

第 3 步:隔離問題-提出假設

根據蒐集到的證據,我們提出並排序假設:

假設 1(最可能):浮點數精度遺失

  • 證據:錯誤案例集中在小數價格
  • 驗證方式:將所有運算改用整數分(cents)計算,看問題是否消失

假設 2:跨日時區問題

  • 證據:錯誤集中於 00:00 前後
  • 驗證方式:固定時區測試、檢查 Date 用法

假設 3:折扣碼套用順序錯亂

  • 證據:錯誤案例大多有折扣
  • 驗證方式:檢查 applyDiscount 何時被呼叫、是否與並發有關

第 4 步:驗證假設 1

// 驗證浮點數假設
function demonstrateFloatIssue() {
  // 原本的計算方式
  const sum = 19.99 * 2 + 5.50 + 12.75 * 3;
  console.log(sum);                       // → 79.73200000000001
  console.log(sum * 0.9);                 // → 71.75880000000001
  
  // 改用整數分計算
  const sumCents = 1999 * 2 + 550 + 1275 * 3;
  console.log(sumCents);                  // → 7972(分)= 79.72 元(無精度誤差)
  const discountedCents = Math.round(sumCents * 0.9);
  console.log(discountedCents);           // → 7175(分)= 71.75 元
}

觀察發現:浮點數誤差確實存在,但量大小不足以解釋 0.01 ~ 0.02 的誤差。這表示假設 1 是「部分原因」但非「主要根因」。我們繼續驗證假設 2。

第 5 步:驗證假設 2-找到根因

檢查 order-calculator.js 中使用日期的程式碼:

// 問題程式碼
function applyDiscount(order, discount) {
  const now = new Date();                         // ❌ 使用本地時間
  const expiry = new Date(discount.expiresAt);   // ❌ 字串解析時區不一致
  
  if (now > expiry) return order;  // 過期不折扣
  
  // ... 計算折扣
}

根因確認

  • 伺服器時區設定為 UTC,但 new Date() 在某些 deployments 使用本地時區
  • 折扣碼的「跨日邊界」會導致 now > expiry 在毫秒之間翻盤
  • 當折扣「有時套用、有時不套用」時,金額就會出現誤差

第 6 步:使用 TDD 修正

觸發

使用 test-driven-development skill 修復這個跨日折扣計算問題

先寫測試(紅燈):

// order-calculator.test.js
describe('折扣碼跨日計算', () => {
  test('UTC 與本地時區應有相同結果', () => {
    const baseOrder = { items: [...], total: 79.72 };
    const discount = {
      code: 'NIGHT10',
      type: 'percentage',
      value: 10,
      expiresAt: '2024-01-16T00:00:00Z',
    };
    
    // 模擬不同時區的「同一時刻」
    process.env.TZ = 'UTC';
    const resultUTC = calculateOrderTotal({ ...baseOrder, createdAt: '2024-01-15T23:59:59Z' }, discount);
    
    process.env.TZ = 'Asia/Taipei';
    const resultTW = calculateOrderTotal({ ...baseOrder, createdAt: '2024-01-15T23:59:59Z' }, discount);
    
    expect(resultUTC.total).toBe(resultTW.total);
    // 目前:紅燈(兩者不同)
  });
  
  test('過期邊界應精確到毫秒', () => {
    // 過期前 1 毫秒:應套用折扣
    // 過期後 1 毫秒:不應套用折扣
  });
});

實作修復(綠燈):

// order-calculator.js(修復後)
function applyDiscount(order, discount) {
  // 統一使用 UTC時間戳比較
  const nowMs = Date.now();                        // ✅ 時區無關
  const expiryMs = Date.parse(discount.expiresAt); // ✅ ISO 8601 含時區
  
  if (nowMs > expiryMs) return order;
  
  // 改用整數分計算,避免浮點誤差
  const totalCents = Math.round(order.total * 100);
  const discountCents = Math.round(
    totalCents * (discount.value / 100)
  );
  
  return {
    ...order,
    total: (totalCents - discountCents) / 100,
  };
}

第 7 步:使用 Verification Skill 確認修復

觸發

使用 verification-before-completion skill 驗證這次的修復是否完整

驗證清單

✅ 根因確認
- 已隔離到時區比較邏輯 ✓
- 已驗證為主要誤差來源 ✓
- 假設 1(浮點數)為次因,一併修復 ✓

✅ 測試覆蓋
- 新增「跨時區一致性」測試 ✓
- 新增「過期邊界」測試 ✓
- 新增「整數分計算」測試 ✓
- 既有整合測試全數通過 ✓

✅ 回歸驗證
- 用蒐集到的歷史錯誤訂單重算:結果正確 ✓
- 監控一週,未再出現金計算錯誤告警 ✓

✅ 程式碼品質
- 無新的 lint 警告 ✓
- 無新的 TypeScript 錯誤 ✓
- 新增註解說明時區陷阱 ✓

✅ 文件更新
- 更新 discount 邏輯的 ADR ✓
- 在 commit message 中記錄根因分析 ✓

關鍵學習點

1. 不要急著改程式碼

Legacy code 的 bug 往往「症狀簡單、根因複雜」。看到 total 算錯,直覺是去改 order-calculator.js 的計算公式,但真正的問題在「時間比較」。Systematic Debugging skill 的鐵律「未找到根因前不修復」是這個案例能成功收斂的關鍵。

2. 證據驅動而非直覺驅動

如果憑直覺「看程式碼找錯」,可能會把心力耗在 calculateTotal 的浮點數問題而錯過時區這個關鍵。透過蒐集大量錯誤样本 → 找出共同特徵 → 推導假設,才能讓根因浮現。

3. 修復要包含防護網

單純改掉一段程式碼不算完成。真正的修復包含:

  • 回歸測試:確保同類問題不再發生
  • 根因文件:下一位維護者能理解脈絡
  • 監控機制:佈署後驗證問題真的消失

工具使用摘要

Skill 用途 在本例的作用
Systematic Debugging 系統化除錯 建立重現→隔離→假設→修復的流程
Test-Driven Development 測試先行 為修復建立回歸測試
Verification Before Completion 完成前驗證 確認根因已解決、無副作用

總結

處理 legacy code 的核心在於把「未知的未知」轉變為「已知的已知」

  • 透過蒐集證據,把「偶爾出錯」變成「可控重現」
  • 透過假設排序,把「胡亂排查」變成「有序驗證」
  • 透過 TDD,把「猜測式修復」變成「有防護的改良」
  • 透過驗證清單,把「自以為修好了」變成「可證明的修復」

下次遇到 legacy code 的玄學 bug,請記得:不要急、找證據、排序假設、修復含測試。 Superpowers 的除錯 skills 並非萬能,但能在混亂中提供紀律。