:統(tǒng)一請求頭與防篡改簽名)
1. 攔截器解決的四個真實場景從統(tǒng)一請求頭到防篡改簽名先講一個很典型的痛點。我之前在給某開放平臺做聯(lián)調(diào)的時候要對接十幾個第三方接口每個接口都要求帶clientId、timestamp、sign這些公共參數(shù)而且服務端驗簽特別嚴格。最開始圖省事我直接在每一個 Feign 方法里手動塞請求頭代碼寫成這樣PostMapping(value /order/create, headers {X-Client-Idclient-001, X-Signabc}) Order createOrder(RequestBody OrderCreateRequest request);表面上看沒什么問題但真跑起來就難受了。簽名里往往要帶上時間戳和請求體內(nèi)容headers注解里寫的是靜態(tài)值根本無法根據(jù)每次請求動態(tài)計算。后來只能改成在方法內(nèi)部用RequestContextHolder拿請求對象、再塞進 Feign 的配置里……繞了一大圈最后還是繞回到RequestInterceptor上。我當時的結(jié)論是只要你的 Feign 客戶端需要做“每個請求都要做一遍”的事情就應該考慮攔截器而不是在業(yè)務代碼里到處復制粘貼。具體來說有四類場景特別適合用RequestInterceptor統(tǒng)一處理場景典型需求不用攔截器的后果令牌傳遞下游接口要求Authorization: Bearer xxx每個方法單獨處理漏一個就 401鏈路追蹤透傳traceId、spanId等鏈路標識排查問題時很難串起整條調(diào)用鏈多環(huán)境隔離聯(lián)調(diào)環(huán)境、預發(fā)環(huán)境路由到不同實例或帶不同標識改一處要重新編譯部署接口簽名請求頭附帶timestamp、nonce、sign防篡改防重放動態(tài)簽名根本沒法用靜態(tài)注解實現(xiàn)其中簽名這個場景最復雜后面我會專門用一整節(jié)來講。先記住一個最核心的判斷標準只要這個動作是“所有 Feign 請求都要做”的就落入RequestInterceptor的職責范圍。2. RequestInterceptor 的執(zhí)行時機為什么它在編碼器之前又為什么日志里看不到很多人第一次寫RequestInterceptor時都會有一個疑惑我明明加上了Component程序也能啟動但請求日志里看不到我設置的 header到底執(zhí)行了沒有要回答這個問題得先搞清楚 Feign 處理一次請求的完整鏈路。2.1 Feign 請求執(zhí)行的順序概括來說一次 Feign 調(diào)用的流程是通過 JDK 動態(tài)代理攔截你的接口調(diào)用MethodHandler 將方法參數(shù)解析成RequestTemplate對象依次執(zhí)行你配置的RequestInterceptor鏈對RequestTemplate進行改寫通過Encoder把方法參數(shù)編碼成請求體交給Client發(fā)起真正的 HTTP 請求通過Decoder解析響應。注意第 3 步和第 4 步的順序。RequestInterceptor是在編碼器之前執(zhí)行的也就是說攔截器拿到的是還沒有被編碼的RequestTemplate。此時如果你設置了template.body()你會發(fā)現(xiàn)它拿的是參數(shù)對象的原始字符串形式通常是 JSON 序列化之前的樣子而不是最終 HTTP 報文里的字節(jié)流。這個“先攔截、后編碼”的順序有個很實際的影響如果你想在攔截器里讀取請求體做簽名拿到的可能是LinkedHashMap序列化后的字符串而不是你最終發(fā)送的 body。我一開始在這里栽過跟頭后面講簽名時再重點展開。2.2 攔截器生效的兩種注冊方式RequestInterceptor有兩條注冊路徑很多人只知道第一種// 方式一使用默認的全局配置 Configuration public class FeignGlobalConfig { Bean public RequestInterceptor globalRequestInterceptor() { return new MyRequestInterceptor(); } }// 方式二細粒度指定給某個 FeignClient FeignClient(name order-service, configuration OrderFeignConfig.class) public interface OrderClient { // ... }方式一是全局生效所有通過 Feign 發(fā)出的請求都會走過這個攔截器方式二是局部生效只對指定的 client 生效。這里面有一個很多初學者容易踩的坑如果OrderFeignConfig類被放在了啟動類可以掃描到的包路徑下而且你又在類上加了Configuration那么這個局部配置其實會變成全局配置影響所有 Feign 客戶端。這不是玄學是 Spring 的組件掃描機制導致的。2.3 RequestTemplate 的核心操作 API攔截器的入?yún)⑹荝equestTemplate核心操作無非這幾種public class HeaderInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { // 添加請求頭 template.header(X-Api-Key, your-api-key); // 覆蓋請求頭如果不覆蓋重復調(diào)用會追加值 template.header(Authorization, Bearer token); // 獲取某個請求頭 CollectionString values template.headers().get(Content-Type); // 修改請求體注意body 在攔截器階段還是 String沒有經(jīng)過 Encoder 編碼 template.body({\name\:\test\}); // 修改請求路徑 template.uri(/v2 template.path()); // 獲取 query 參數(shù) MapString, CollectionString queries template.queries(); } }這里有個細節(jié)值得注意template.header()是追加語義。如果同名的 header 已經(jīng)存在再次調(diào)用會把新值追加到后面而不是替換。實際場景里下游服務如果只取第一個值可能會看不到你后來追加的。所以我習慣在設置關鍵 header 之前先template.removeHeader(X-Api-Key)或者確認原始請求不會帶這個 header。還有一個細節(jié)RequestTemplate暴露的 header 名稱在查詢時區(qū)分大小寫但 HTTP 協(xié)議本身不區(qū)分。如果你在排查時發(fā)現(xiàn)明明設置了 header 卻拿不到先檢查一下是不是大小寫不一致。3. 手寫統(tǒng)一請求頭攔截器從第一版到可上線的完整代碼這一節(jié)直接提供可抄作業(yè)的代碼。我先把一個通用的實現(xiàn)寫出來再逐步加上健壯性處理。3.1 第一版從配置文件讀取請求頭需求是給所有 Feign 請求加上X-Client-Id和X-Api-Key并且這兩個值在不同環(huán)境本地、聯(lián)調(diào)、預發(fā)不一樣所以不能寫死在代碼里。Component public class ClientInfoInterceptor implements RequestInterceptor { private static final Logger log LoggerFactory.getLogger(ClientInfoInterceptor.class); private final ClientInfoProperties properties; public ClientInfoInterceptor(ClientInfoProperties properties) { this.properties properties; } Override public void apply(RequestTemplate template) { template.header(X-Client-Id, properties.getClientId()); template.header(X-Api-Key, properties.getApiKey()); // 可選加上環(huán)境標識方便下游區(qū)分布 template.header(X-Env, properties.getEnv()); log.debug(Feign request intercepted, uri: {}, template.url()); } }對應的配置類ConfigurationProperties(prefix openapi.client) public class ClientInfoProperties { private String clientId; private String apiKey; private String env dev; // getter / setter 省略 }然后在application.yml里配置openapi: client: client-id: client-001 api-key: sk-abc123 env: local這一版能解決 80% 的靜態(tài)請求頭需求。但如果你只是需要透傳用戶身份 Token還需要繼續(xù)看下一節(jié)。3.2 第二版從當前線程上下文透傳 Token常見的業(yè)務場景是用戶 A 發(fā)起請求到你的 Spring MVC 接口這個接口內(nèi)部又通過 Feign 調(diào)用了下游服務。下游服務同樣需要校驗用戶身份所以要把用戶的Authorizationheader 原樣透傳過去。這時候攔截器里要讀取當前的ServletRequest獲取 header再設置到RequestTemplate上Component public class TokenPropagationInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { // 1. 從 Spring 的線程上下文中拿當前請求 RequestAttributes requestAttributes RequestContextHolder.getRequestAttributes(); if (requestAttributes instanceof ServletRequestAttributes) { HttpServletRequest request ((ServletRequestAttributes) requestAttributes).getRequest(); String authz request.getHeader(Authorization); if (StringUtils.hasText(authz)) { template.header(Authorization, authz); return; } } // 2. 拿不到就使用兜底 Token一般來自服務間調(diào)用憑證 String fallback environment.getProperty(openapi.client.service-token, ); if (StringUtils.hasText(fallback)) { template.header(Authorization, Bearer fallback); } } }這個實現(xiàn)里有個容易掉進去的坑異步線程丟上下文。如果你在業(yè)務代碼里用了Async或自定義線程池RequestContextHolder里是拿不到當前請求的因為 Spring 默認的ThreadLocal不會跨線程傳遞。遇到這種情況要么在進入異步之前手動把 Token 存入ThreadLocal并在攔截器里讀取要么使用HystrixRequestVariableDefault這類方案。我現(xiàn)在項目的做法是封裝了一個UserContextHolder在請求入口處解析用戶信息存入自定義上下文異步任務里也能拿到。3.3 加上開關控制聯(lián)調(diào)/生產(chǎn)一鍵切換另一個實踐中的剛需是攔截器要能開關。比如你在聯(lián)調(diào)時下游并不驗證簽名但一上生產(chǎn)就要驗。如果把邏輯做成硬編碼每次切換環(huán)境都要改代碼。我的做法是給攔截器加一個配置開關openapi: client: sign-enabled: trueComponent public class SigningInterceptor implements RequestInterceptor { private final ClientInfoProperties properties; Override public void apply(RequestTemplate template) { if (!properties.isSignEnabled()) { return; } // 簽名邏輯... } }同時在配置中心可以動態(tài)調(diào)整非常方便。4. 簽名場景的完整鏈路時間戳、防重放與參數(shù)排序簽名是最能體現(xiàn)RequestInterceptor價值的場景。我把它單獨拆開講因為這里面坑最深也最容易出錯。4.1 簽名流程總覽一個相對規(guī)范的開放平臺簽名方案通常包含這幾部分客戶端生成timestamp毫秒級時間戳客戶端生成nonce隨機字符串防重放將請求路徑、查詢參數(shù)、請求體等按約定規(guī)則拼接成待簽名字符串使用約定的密鑰通常是appSecret或私鑰對字符串做HMAC-SHA256或MD5簽名將timestamp、nonce、sign放入請求頭傳給服務端服務端校驗時間戳是否過期、nonce 是否第一次使用、簽名是否一致。下面這個簽名攔截器是綜合了以上要點的完整實現(xiàn)Component public class SigningInterceptor implements RequestInterceptor { private static final Logger log LoggerFactory.getLogger(SigningInterceptor.class); private final ClientInfoProperties properties; public SigningInterceptor(ClientInfoProperties properties) { this.properties properties; } Override public void apply(RequestTemplate template) { if (!properties.isSignEnabled()) { return; } String timestamp String.valueOf(System.currentTimeMillis()); String nonce UUID.randomUUID().toString().replace(-, ); // 1. 拼接待簽名內(nèi)容 String content buildSignContent(template); // 2. 計算簽名 String sign sign(timestamp, nonce, content); // 3. 放入請求頭 template.header(X-Timestamp, timestamp); template.header(X-Nonce, nonce); template.header(X-Sign, sign); template.header(X-Api-Key, properties.getApiKey()); } private String buildSignContent(RequestTemplate template) { StringBuilder sb new StringBuilder(); // 請求路徑 sb.append(template.method()).append(\n); sb.append(template.path()).append(\n); // 查詢參數(shù)排序拼接核心難點1 MapString, CollectionString queries template.queries(); if (queries ! null !queries.isEmpty()) { ListString queryParts new ArrayList(); for (Map.EntryString, CollectionString entry : queries.entrySet()) { for (String value : entry.getValue()) { queryParts.add(entry.getKey() value); } } Collections.sort(queryParts); sb.append(String.join(, queryParts)).append(\n); } // 請求體核心難點2編碼時機問題 String body template.body() null ? : new String(template.body(), StandardCharsets.UTF_8); sb.append(body); return sb.toString(); } private String sign(String timestamp, String nonce, String content) { String raw properties.getApiKey() \n timestamp \n nonce \n content \n properties.getApiSecret(); return DigestUtils.sha256Hex(raw); } }4.2 參數(shù)排序為什么是核心難點很多簽名校驗失敗問題多半出在參數(shù)順序不一致。服務端在驗簽時對查詢參數(shù)的常見處理是先按 key 字典序排序再用keyvalue形式拼接多個參數(shù)用連接。而RequestTemplate.queries()返回的是MapString, CollectionStringkey 的順序不保證是字典序。如果不排序同一個請求在客戶端簽名的順序和服務端驗簽的順序就可能不同導致簽名對不上。我建議在拼接前強制排序一次ListString queryParts new ArrayList(); // ... 拼出 keyvalue 形式 Collections.sort(queryParts);對于參數(shù)值里有中文、特殊字符的情況還涉及 URL 編碼。通常簽名用的字符串是原始值未編碼還是編碼后的值取決于接口文檔。這個必須提前和服務端對齊否則兩邊各做各的簽名永遠對不上。4.3 body 簽名的編碼時機問題前面提到攔截器的執(zhí)行時機在編碼器之前所以在攔截器里template.body()拿到的是未經(jīng) Encoder 處理的內(nèi)容。如果Encoder用的是默認的SpringEncoder它接收的是RequestBody注解的對象通常會被MappingJackson2HttpMessageConverter序列化成 JSON。這里有個非常隱蔽的問題簽名時你拿到的字符串和傳輸時真正序列化后的字符串可能不一致。例如對象中的LocalDateTime在序列化前和序列化后的格式不同字段為null時可能被JsonInclude注解過濾掉。如果你簽名用的是序列化前的原始 JSON而服務端驗簽用的卻是收到報文里的 JSON兩邊自然對不上。針對這個問題的常見解法有兩種方案一在調(diào)用 Feign 之前先把請求體序列化成字符串再傳給 Feign 方法這樣攔截器里拿到 body 就是最終字符串。String json objectMapper.writeValueAsString(orderRequest); orderClient.createOrder(json);PostMapping(/order/create) Order createOrder(RequestBody String body);這種做法的缺點是簽名語義和業(yè)務方法簽名綁定得比較緊但能保證簽名內(nèi)容與傳輸內(nèi)容完全一致。方案二注冊自定義 Encoder在 Encoder 階段統(tǒng)一序列化并在序列化過程中做簽名。這種方式鏈路更完整但實現(xiàn)復雜度高適合在真正需要精細控制的場景下使用。我現(xiàn)在項目里用的是方案一操作簡單、定位問題也快。4.4 防重放timestamp nonce 的雙保險簽名只解決了“內(nèi)容有沒有被篡改”的問題但沒有解決“同一個請求被原樣重放”的問題。如果黑客截獲了一個有效請求過幾分鐘再原樣發(fā)一次服務端驗簽依然通過。所以必須加上時間戳和 nonce 雙保險timestamp服務端校驗客戶端時間與服務器時間差在允許范圍內(nèi)通常 5 分鐘。超出范圍的直接拒絕。nonce隨機字符串服務端需要記錄一段時間內(nèi)使用過的 nonce。如果同一個 nonce 再次出現(xiàn)說明是重放請求直接拒絕。nonce 的存儲服務端可以放在 Redis 里設置與時間戳窗口相同的過期時間。這里要注意并發(fā)場景如果同一毫秒內(nèi)兩個請求生成了相同 nonce概率極低但存在。因此生成 nonce 時建議加上隨機數(shù)或線程安全手段String nonce Long.toHexString(System.nanoTime()) UUID.randomUUID().toString().replace(-, );4.5 服務端驗簽的簡易實現(xiàn)思路雖然標題講的是客戶端攔截器但服務端驗簽邏輯決定了客戶端怎么簽名。我貼一個簡單的 Controller 攔截器來模擬服務端驗簽流程方便你本地驗證Component public class VerifySignInterceptor extends HandlerInterceptorAdapter { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String timestamp request.getHeader(X-Timestamp); String nonce request.getHeader(X-Nonce); String sign request.getHeader(X-Sign); if (!StringUtils.hasText(timestamp) || !StringUtils.hasText(nonce) || !StringUtils.hasText(sign)) { response.setStatus(401); return false; } // 1. 校驗時間戳 long ts Long.parseLong(timestamp); if (Math.abs(System.currentTimeMillis() - ts) 5 * 60 * 1000) { response.setStatus(401); return false; } // 2. 校驗 nonce 是否使用過此處應查 Redis if (nonceUsed(nonce)) { response.setStatus(401); return false; } // 3. 校驗簽名偽代碼真實場景需要讀取 body String content request.getRequestURI() \n readQuerySorted(request); String expectSign DigestUtils.sha256Hex(canonicalize(content)); if (!MessageDigest.isEqual(expectSign.getBytes(StandardCharsets.UTF_8), sign.getBytes(StandardCharsets.UTF_8))) { response.setStatus(401); return false; } return true; } }看到?jīng)]服務端驗簽和客戶端簽名是一體兩面的設計??蛻舳嗽趺雌醋址斩司捅仨氃趺雌醋址畠蛇叺腸anonicalize函數(shù)必須完全一致這是簽名聯(lián)調(diào)中最容易出問題的地方。5. 自帶攔截器的“繼承陷阱”子類庫重寫 Feign 配置時為何突然失聯(lián)這一節(jié)是我自己項目的血淚經(jīng)驗。我們有一個基礎 SDK 模塊里面定義了一個公共的 Feign 配置類并注冊了一個簽名攔截器。業(yè)務模塊引入這個 SDK 后只需要寫接口和調(diào)用即可。// 基礎 SDK 里的公共配置 Configuration public class BaseFeignConfig { Bean public RequestInterceptor signInterceptor() { return new SigningInterceptor(); } }某一天新業(yè)務方接入了 SDK但他們自己定義了 Feign 客戶端時指定了一個CustomFeignConfigFeignClient(name order-service, configuration OrderFeignConfig.class) public interface OrderClient { }結(jié)果上線后聯(lián)調(diào)一直報 401簽名校驗失敗。排查了很久最后發(fā)現(xiàn)OrderFeignConfig里沒有繼承BaseFeignConfig而是自己定義了一套配置Configuration public class OrderFeignConfig { // 只配置了自定義的 decode/encoder忘了把父類的簽名攔截器帶進來 }在這個配置類生效的 Feign 客戶端上BaseFeignConfig的全局攔截器沒有生效因為 Feign 對每個 client 都會獨立構(gòu)建一套攔截器鏈局部配置可以覆蓋或替換全局配置。5.1 Feign 攔截器集合的構(gòu)建規(guī)則深入了解后我發(fā)現(xiàn)FeignClientsRegistrar在注冊每個 FeignClient 時會讀取你指定的configuration類并把這些類中的 Bean 注冊到對應 client 的 Spring 子容器中。這個子容器和全局容器是隔離的RequestInterceptor的集合也基于這個子容器構(gòu)建。所以如果局部配置類里自己也定義了一個RequestInterceptor這個攔截器會替換全局的攔截器而不是疊加。這一點很容易被忽略因為項目能正常啟動看起來沒有任何報錯。5.2 排查過程從現(xiàn)象到根因當時我們的排查鏈路是這樣的先看下游服務端日志發(fā)現(xiàn)收到的請求頭里沒有X-Sign確認攔截器確實沒有執(zhí)行在SigningInterceptor.apply()里加斷點發(fā)現(xiàn)根本沒走到檢查是否注冊了多個RequestInterceptor用ApplicationContext.getBeansOfType(RequestInterceptor.class)查看只有全局的攔截器一個但業(yè)務模塊的 FeignClient 用的并不是這個最后檢查OrderFeignConfig發(fā)現(xiàn)問題出在局部配置覆蓋了全局配置且沒有把簽名攔截器重新注冊進去。修正方式很簡單讓業(yè)務方的OrderFeignConfig繼承 SDK 的BaseFeignConfigConfiguration public class OrderFeignConfig extends BaseFeignConfig { // 業(yè)務方自己的配置簽名攔截器自動繼承 }或者更穩(wěn)妥的做法在業(yè)務模塊中不要重復定義RequestInterceptor而是通過Import(BaseFeignConfig.class)把公開配置引入。5.3 設計層面的三點建議經(jīng)歷這次踩坑后我在設計 SDK 時總結(jié)了三條原則不要在基礎配置類里寫業(yè)務相關的攔截器必要時拆成多個配置類按場景分別引入如果確定攔截器必須全局生效把它注冊成基礎配置中的一個 Bean并且讓所有局部配置類繼承基礎配置在 FeignClient 的自定義配置類上盡量不再定義RequestInterceptor類型的 Bean如果一定要就要清楚它是在替換全局攔截器而非追加。6. 調(diào)試與驗證從日志全開到一個完整的測試用例寫完了攔截器關鍵是怎么驗證它真的按照預期工作。如果只是“感覺沒問題”一旦聯(lián)調(diào)簽不上排查起來極其痛苦。6.1 打開 Feign 的日志Feign 自帶的 Logger 可以輸出請求頭、請求體等關鍵調(diào)試信息。步驟是在配置類里指定日志級別Configuration public class FeignLogConfig { Bean Logger.Level feignLoggerLevel() { return Logger.Level.FULL; // NONE、BASIC、HEADERS、FULL } }在application.yml里開啟對應包的日志輸出logging: level: com.example.openapi.client: debug然后把Logger.Level設為FULL就可以在日志中看到[OrderClient#createOrder] --- POST http://order-service/order/create HTTP/1.1 [OrderClient#createOrder] X-Timestamp: 1735023600123 [OrderClient#createOrder] X-Nonce: 6f2a5c4d8e1b4f2a [OrderClient#createOrder] X-Sign: 3b4c5d6e7f8a9b0c... [OrderClient#createOrder] Content-Type: application/json [OrderClient#createOrder] --- END HTTP (56-byte body)日志級別對照表級別輸出內(nèi)容NONE不輸出任何日志BASIC請求方法、URL、響應狀態(tài)碼、耗時HEADERSBASIC 請求和響應頭FULLHEADERS 請求和響應體如果開了 DEBUG 日志但沒看到任何輸出先檢查是不是logging.level寫的包名不對。Feign 日志的 Logger name 是FeignClient 接口的完整類名必須對應到實際接口的包名。6.2 單測不啟動服務端直接驗證攔截器最方便的驗證方式其實是寫一個單元測試直接構(gòu)造RequestTemplate然后調(diào)用攔截器的apply方法。這樣不用啟動 Spring 容器也不依賴下游服務。public class SigningInterceptorTest { Test public void testApply() { // 構(gòu)造一個請求模板 RequestTemplate template new RequestTemplate(); template.method(HttpMethod.POST); template.uri(/order/create); template.query(orderId, 10086); template.body({\name\:\張三\}); // 初始化攔截器 ClientInfoProperties properties new ClientInfoProperties(); properties.setApiKey(client-001); properties.setApiSecret(secret); properties.setSignEnabled(true); SigningInterceptor interceptor new SigningInterceptor(properties); // 執(zhí)行攔截邏輯 interceptor.apply(template); // 斷言 assertNotNull(template.headers().get(X-Timestamp)); assertNotNull(template.headers().get(X-Nonce)); assertNotNull(template.headers().get(X-Sign)); // 打印簽名結(jié)果以便和服務端算法對照 System.out.println(X-Timestamp: template.headers().get(X-Timestamp).iterator().next()); System.out.println(X-Sign: template.headers().get(X-Sign).iterator().next()); } }這種方法的好處是本地調(diào)試極其快速還能在 CI 中跑回歸。凡是涉及簽名規(guī)則調(diào)整我都會先跑一遍單測確認簽名串和服務端一致再去做接口聯(lián)調(diào)。6.3 跨庫請求體的特殊處理Content-Type 為 form 表單最后提一個我在實際項目中遇到的坑。如果 Feign 方法接收的是RequestBody MapString, Object攔截器里template.body()拿到的字符串是 JSON 格式。但如果某個接口的 Content-Type 是application/x-www-form-urlencodedbody 就是keyvaluekey2value2格式。這時候簽名字符串的拼接規(guī)則就完全不同了。針對不同 Content-Type我建議在攔截器里做分支String contentType template.headers().getOrDefault(Content-Type, List.of(application/json)).iterator().next(); if (contentType.contains(application/x-www-form-urlencoded)) { // 表單格式直接拼原始 urlencoded 字符串 } else { // JSON 格式直接拼 JSON 字符串 }表單參數(shù)在RequestTemplate中的body()拿到的可能是類字段的形式未必是最終的 urlencoded 結(jié)果這一點要特別小心。最穩(wěn)妥的方式是在調(diào)用 Feign 之前就用UriComponentsBuilder等工具手動構(gòu)造好表單字符串這樣簽名和傳輸完全一致。最后分享一個小技巧調(diào)試簽名類攔截器我強烈建議在本地啟動一個僅用于打印請求信息的最小 Mock 服務。方法很簡單寫一個 Spring Boot 接口把所有 header 和 body 打出來然后讓 Feign 指向它。這樣你能在幾分鐘內(nèi)看出攔截器到底設置哪些 header、body 是什么、簽名原文長什么樣。另外如果你的項目同時依賴了多個 Feign 庫以及重試組件要特別小心RequestInterceptor的執(zhí)行次數(shù)。遇到重試時攔截器會隨每次重試重新執(zhí)行所以簽名中的時間戳在重試時也會更新。服務端如果做了嚴格的時間窗口校驗重試太頻繁可能觸發(fā)時間戳超過允許范圍。我在項目中就是把這個時間窗口放寬到 1 分鐘同時把 nonce 的 Redis 過期時間設為 5 分鐘既防重放又不誤傷正常重試。這套RequestInterceptor的方案我已經(jīng)在多個項目中穩(wěn)定運行核心思路就是統(tǒng)一收口、動態(tài)計算、可配置開關、測試兜底。照著這個思路做你的 Feign 客戶端在接入第三方接口時會省掉很多重復勞動簽名這類復雜邏輯也能在一個文件里維護清楚。