前言:當 Banner 在眾目睽睽下消失

如果你曾經盯著一個「昨天還好好的」功能,然後花了幾小時才發現問題根本不在你想的地方——恭喜你,你已經正式成為資深工程師了。

這次的主角是一個 Flutter App 的首頁輪播 Banner。用戶回報說「Banner 不見了」,而我的第一反應是:「一定是最近的 commit 搞壞的!」

(劇透:不是。)

第一階段:追蹤嫌疑犯

嫌疑人一號:最近的 Git Commit

最近剛好有一個 commit 修改了後端的 middleware,用來處理認證相關的端點。自然而然,我先從這裡開始查:

git show abc123
# fix: add middleware to strip auth header for public endpoints

看起來這個 middleware 只處理 /api/auth/forgot-password 這類認證端點,跟 Banner API 完全無關。

結論:無罪釋放。

嫌疑人二號:Strapi 權限設定

接下來檢查 Strapi Admin 的權限設定。Public 角色的 app-home-page 權限:

角色find 權限
Authenticated✅ 已開啟
Public✅ 已開啟

權限設定完全正確。

結論:也不是兇手。

第二階段:真相大白

測試 REST API

直接用 curl 測試 REST API:

curl "https://api.example.com/api/app-home-page?populate=banners.image"
# 回傳 200 OK,資料完整

REST API 完全正常!那問題出在哪?

發現關鍵線索:Flutter 用的是 GraphQL

仔細檢查 Flutter App 的程式碼後,發現它呼叫的是 GraphQL 端點,不是 REST API:

class GraphQLService {
  GraphQLService() {
    final httpLink = HttpLink(
      AppConfig.gqlUrl,
      defaultHeaders: {
        'Authorization': 'Bearer ${dotenv.env['API_TOKEN']}',  // 靜態 API Token
      },
    );
    _client = GraphQLClient(link: httpLink, cache: GraphQLCache());
  }
}

原來 Flutter App 使用了一個靜態的 API Token 來呼叫所有 GraphQL 查詢。

驗證假設

測試這個 API Token 是否有效:

# 帶 API Token
curl -X POST "https://api.example.com/graphql" \
  -H "Authorization: Bearer sk-invalid-token-12345..." \
  -d '{"query":"query { appHomePage { banners { title } } }"}'

# 回傳 401 Unauthorized
{"error":{"status":401,"message":"Missing or invalid credentials"}}
# 不帶 Token
curl -X POST "https://api.example.com/graphql" \
  -d '{"query":"query { appHomePage { banners { title } } }"}'

# 回傳 200 OK,資料完整
{"data":{"appHomePage":{"banners":[...]}}}

破案了! API Token 是無效的(可能過期或從未正確設定),導致 GraphQL 查詢回傳 401 錯誤。

關鍵洞察:帶無效憑證比不帶憑證更糟糕

這裡有一個反直覺的事實:

情境結果
不帶 Authorization header✅ 以 Public 角色通過
帶有效 JWT Token✅ 以 Authenticated 角色通過
帶無效 Token❌ 401 Unauthorized

用流程圖來看更清楚:

Mermaid Diagram

當你帶著一張過期的會員卡進入公共圖書館時,管理員看到過期的卡,直接把你擋在門外——連公開區域都不讓你進。但如果你什麼卡都不帶,反而可以進去使用公開資源。

解決方案:精準的後端 Middleware

為什麼不直接更新 App 的 Token?

因為這是一個已上架的 App,更新程式碼需要經過 App Store 審核(1-3 天)。我們需要一個不用送審的解決方案。

實作精準攔截

在 Strapi 後端新增 middleware,只移除那個特定的無效 Token

// src/middlewares/strip-invalid-token.ts

// 特定的無效 API Token(來自 Flutter App 的 .env)
const INVALID_FLUTTER_TOKEN = 'sk-invalid-token-12345...';

export default (config, { strapi }) => {
  return async (ctx, next) => {
    if (ctx.request.header.authorization) {
      const token = ctx.request.header.authorization
        .replace('Bearer ', '')
        .trim();

      // 只移除這個特定的無效 Token
      // 其他有效的 Token(Vue.js、用戶 JWT)都保留
      if (token === INVALID_FLUTTER_TOKEN) {
        strapi.log.debug(`Removing invalid Flutter token for: ${ctx.request.path}`);
        delete ctx.request.header.authorization;
        delete ctx.request.headers.authorization;
      }
    }

    await next();
  };
};

為什麼要這麼精準?

在調查過程中,我發現同一個後端服務被多個前端使用:

前端Token 類型狀態
Flutter App靜態 API Token❌ 無效
Vue.js Web靜態 API Token✅ 有效
已登入用戶JWT Token✅ 有效

如果我們移除「所有非 JWT 格式的 Token」,會把 Vue.js 的有效 Token 也一起移除,導致網頁版壞掉。

所以必須精準鎖定那個特定的無效 Token。

長期解決方案

後端的 middleware 是臨時修復。真正的解決方案是修改 Flutter App:

// 新增一個不帶 Authorization header 的公開 Client
final publicHttpLink = HttpLink(AppConfig.gqlUrl);
_publicClient = GraphQLClient(link: publicHttpLink, cache: GraphQLCache());

// Banner 查詢使用公開 Client
Future<List<BannerData>> fetchBannerList() async {
  final options = QueryOptions(document: gql(Queries.bannerFetch));
  // 使用不帶 Token 的 Client
  final result = await _publicClient.query(options);
  // ...
}

這個修改已經準備好了,等下次 App 更新時一起送審。

結論:Debug 的三個教訓

  1. 不要假設:最近的 commit 不一定是兇手,要用證據說話
  2. 理解認證機制:帶無效憑證比不帶憑證更糟糕
  3. 了解你的系統:同一個後端可能被多個前端使用,修改時要考慮全局影響

下次遇到類似問題時,記住:真正的 bug 往往藏在你最不懷疑的地方。