前言:當 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 |
用流程圖來看更清楚:
當你帶著一張過期的會員卡進入公共圖書館時,管理員看到過期的卡,直接把你擋在門外——連公開區域都不讓你進。但如果你什麼卡都不帶,反而可以進去使用公開資源。
解決方案:精準的後端 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 的三個教訓
- 不要假設:最近的 commit 不一定是兇手,要用證據說話
- 理解認證機制:帶無效憑證比不帶憑證更糟糕
- 了解你的系統:同一個後端可能被多個前端使用,修改時要考慮全局影響
下次遇到類似問題時,記住:真正的 bug 往往藏在你最不懷疑的地方。