@@ -113,42 +113,112 @@ interface ProfileResponse {
113113 * 让 Next error boundary 兜底,避免把"后端故障"伪装成"用户不存在"。
114114 */
115115function warnFetchProfile ( message : string , details ?: Record < string , unknown > ) {
116- if ( process . env . NODE_ENV !== "production" ) {
117- console . warn ( `[fetchProfile] ${ message } ` , details ?? { } ) ;
118- }
116+ // 生产环境也打印:在 Vercel runtime logs 排查偶发 403/5xx 需要看到上下文
117+ // (原先只在开发输出,导致线上 Cloudflare 拦截时只看到 Next 抛出的 Error 行,
118+ // 缺少 res 状态/cf-ray/响应体片段,排查成本高)
119+ console . warn ( `[fetchProfile] ${ message } ` , details ?? { } ) ;
119120}
120121
122+ /**
123+ * 带重试的 profile 抓取。
124+ * 背景:Cloudflare 偶发会对 Vercel SSR 出口返回 403(疑似 Bot 挑战/Managed Challenge),
125+ * 单次失败就 500 会让正常访问的用户直接看到 Next 默认 "Application error" 黑屏。
126+ *
127+ * 策略:
128+ * - 404 / 200 直接返回(用户不存在或成功)
129+ * - 其他非 2xx(403/5xx/网关异常)做最多 2 次重试,退避 300ms / 800ms
130+ * - 每次失败都 console.warn,记录 status / cf-ray / 响应体前 300 字符,便于 Vercel 日志定位
131+ * - 重试全败才抛,让 error.tsx 兜底(不再是裸露的 Application error 页)
132+ *
133+ * 重试走 cache: "no-store",避免把上次的 403 命中 Next Data Cache 导致 5 分钟内
134+ * 所有访问都拿到同一份错误。
135+ */
121136async function fetchProfile ( identifier : string ) : Promise < ProfileData | null > {
122137 const backendUrl = process . env . BACKEND_URL ;
123138 if ( ! backendUrl ) {
124139 // 关键配置缺失不能静默 notFound,给个可见错误
125140 throw new Error ( "BACKEND_URL is not configured" ) ;
126141 }
127- const res = await fetch (
128- `${ backendUrl } /api/user-center/profile/${ encodeURIComponent ( identifier ) } ` ,
129- { next : { revalidate : 300 } } ,
130- ) ;
131- // 404:用户确实不存在 → notFound
132- if ( res . status === 404 ) {
133- warnFetchProfile ( "backend 404" , { identifier } ) ;
134- return null ;
135- }
136- // 其他非 2xx 都抛,进 Next error boundary
137- if ( ! res . ok ) {
138- throw new Error (
139- `profile backend ${ res . status } ${ res . statusText } for ${ identifier } ` ,
140- ) ;
141- }
142- const json = ( await res . json ( ) ) as ProfileResponse ;
143- // 后端用 {success:false, message:"用户不存在"} 表示软 404
144- if ( ! json . success || ! json . data ) {
145- warnFetchProfile ( "backend success=false" , {
142+ const url = `${ backendUrl } /api/user-center/profile/${ encodeURIComponent ( identifier ) } ` ;
143+ const attempts : Array < { revalidate : number } | { noStore : true } > = [
144+ { revalidate : 300 } , // 首次命中:走 Next Data Cache(5min ISR),命中快
145+ { noStore : true } , // 第一次重试:绕过缓存
146+ { noStore : true } , // 第二次重试:再绕一次,防瞬时抖动
147+ ] ;
148+
149+ for ( let i = 0 ; i < attempts . length ; i ++ ) {
150+ const attempt = attempts [ i ] ;
151+ const init : RequestInit & { next ?: { revalidate : number } } =
152+ "noStore" in attempt
153+ ? { cache : "no-store" }
154+ : { next : { revalidate : attempt . revalidate } } ;
155+ // 显式设置 UA / Accept,降低被 Cloudflare 误判 bot 的概率
156+ // (Node 原生 fetch 默认 UA 在某些 CF 规则下会被挑战)
157+ init . headers = {
158+ accept : "application/json" ,
159+ "user-agent" : "InvolutionHell-SSR/1.0 (+https://involutionhell.com)" ,
160+ } ;
161+
162+ let res : Response ;
163+ try {
164+ res = await fetch ( url , init ) ;
165+ } catch ( networkErr ) {
166+ warnFetchProfile ( "fetch network error" , {
167+ identifier,
168+ attempt : i ,
169+ error : String ( networkErr ) ,
170+ } ) ;
171+ if ( i === attempts . length - 1 ) throw networkErr ;
172+ await sleep ( i === 0 ? 300 : 800 ) ;
173+ continue ;
174+ }
175+
176+ // 404:用户确实不存在 → notFound(不重试)
177+ if ( res . status === 404 ) {
178+ warnFetchProfile ( "backend 404" , { identifier } ) ;
179+ return null ;
180+ }
181+ if ( res . ok ) {
182+ const json = ( await res . json ( ) ) as ProfileResponse ;
183+ // 后端用 {success:false, message:"用户不存在"} 表示软 404
184+ if ( ! json . success || ! json . data ) {
185+ warnFetchProfile ( "backend success=false" , {
186+ identifier,
187+ message : json . message ,
188+ } ) ;
189+ return null ;
190+ }
191+ return json . data ;
192+ }
193+
194+ // 非 2xx:记录诊断信息,准备重试或最终抛错
195+ const bodySnippet = await res
196+ . text ( )
197+ . then ( ( t ) => t . slice ( 0 , 300 ) )
198+ . catch ( ( ) => "<read body failed>" ) ;
199+ warnFetchProfile ( "backend non-2xx" , {
146200 identifier,
147- message : json . message ,
201+ attempt : i ,
202+ status : res . status ,
203+ statusText : res . statusText ,
204+ cfRay : res . headers . get ( "cf-ray" ) ,
205+ cfMitigated : res . headers . get ( "cf-mitigated" ) ,
206+ contentType : res . headers . get ( "content-type" ) ,
207+ bodySnippet,
148208 } ) ;
149- return null ;
209+ if ( i === attempts . length - 1 ) {
210+ throw new Error (
211+ `profile backend ${ res . status } ${ res . statusText } for ${ identifier } (cf-ray=${ res . headers . get ( "cf-ray" ) ?? "none" } )` ,
212+ ) ;
213+ }
214+ await sleep ( i === 0 ? 300 : 800 ) ;
150215 }
151- return json . data ;
216+ // 理论上不会走到:上面循环要么 return,要么 throw
217+ throw new Error ( "profile fetch exhausted without resolution" ) ;
218+ }
219+
220+ function sleep ( ms : number ) : Promise < void > {
221+ return new Promise ( ( r ) => setTimeout ( r , ms ) ) ;
152222}
153223
154224/**
0 commit comments