@@ -28,40 +28,93 @@ export const metadata: Metadata = {
2828} ;
2929
3030/**
31- * 从后端拉取 APPROVED 的链接列表。
32- * category 为空时拉全部,否则按 slug 过滤。
31+ * 从后端拉取 APPROVED 的链接列表,带 Cloudflare Managed Challenge 重试。
32+ *
33+ * 背景:Vercel SSR 出口偶发被 CF 403 挑战(同 fetchProfile 的坑)。
34+ * 单次失败就 throw 会让首页/feed 显示 500。
35+ *
36+ * 策略(对齐 fetchProfile):
37+ * - 第 1 次:走 Next Data Cache(revalidate: 120),命中快
38+ * - 第 2/3 次:cache: no-store 绕过缓存,分别退避 300ms / 800ms
39+ * - 全败返回 [] 而非抛错——让页面降级展示空态,不崩
40+ * - 每次失败记录 status / cf-ray,便于 Vercel 日志定位
3341 */
3442async function fetchLinks ( category ?: string ) : Promise < SharedLinkView [ ] > {
3543 const backendUrl = process . env . BACKEND_URL ;
3644 if ( ! backendUrl ) {
37- // 配置缺失时给清晰错误,而非静默空列表
38- throw new Error ( "BACKEND_URL is not configured" ) ;
45+ console . error ( "[feed/page] BACKEND_URL is not configured" ) ;
46+ return [ ] ;
3947 }
4048
41- // 构造查询参数
4249 const params = new URLSearchParams ( { limit : "50" , offset : "0" } ) ;
4350 if ( category ) params . set ( "category" , category ) ;
51+ const url = `${ backendUrl } /api/community/links?${ params . toString ( ) } ` ;
52+
53+ const attempts : Array < { revalidate : number } | { noStore : true } > = [
54+ { revalidate : 120 } ,
55+ { noStore : true } ,
56+ { noStore : true } ,
57+ ] ;
58+
59+ for ( let i = 0 ; i < attempts . length ; i ++ ) {
60+ const attempt = attempts [ i ] ;
61+ const init : RequestInit & { next ?: { revalidate : number } } =
62+ "noStore" in attempt
63+ ? { cache : "no-store" }
64+ : { next : { revalidate : attempt . revalidate } } ;
65+ // 显式 UA 降低被 Cloudflare 误判 bot 的概率
66+ init . headers = {
67+ accept : "application/json" ,
68+ "user-agent" : "InvolutionHell-SSR/1.0 (+https://involutionhell.com)" ,
69+ } ;
70+
71+ let res : Response ;
72+ try {
73+ res = await fetch ( url , init ) ;
74+ } catch ( err ) {
75+ console . warn ( "[feed/page] fetch network error" , {
76+ attempt : i ,
77+ error : String ( err ) ,
78+ } ) ;
79+ if ( i === attempts . length - 1 ) return [ ] ;
80+ await sleep ( i === 0 ? 300 : 800 ) ;
81+ continue ;
82+ }
4483
45- const res = await fetch (
46- `${ backendUrl } /api/community/links?${ params . toString ( ) } ` ,
47- {
48- next : { revalidate : 120 } ,
49- headers : {
50- accept : "application/json" ,
51- "user-agent" : "InvolutionHell-SSR/1.0 (+https://involutionhell.com)" ,
52- } ,
53- } ,
54- ) ;
84+ if ( res . ok ) {
85+ try {
86+ const json = ( await res . json ( ) ) as ApiResponse < SharedLinkView [ ] > ;
87+ return json . success && json . data ? json . data : [ ] ;
88+ } catch ( err ) {
89+ // 2xx 但非 JSON(例如 CF 偶发返回 200 的 challenge HTML)
90+ console . warn ( "[feed/page] non-JSON 2xx response" , {
91+ attempt : i ,
92+ cfRay : res . headers . get ( "cf-ray" ) ,
93+ contentType : res . headers . get ( "content-type" ) ,
94+ error : String ( err ) ,
95+ } ) ;
96+ if ( i === attempts . length - 1 ) return [ ] ;
97+ await sleep ( i === 0 ? 300 : 800 ) ;
98+ continue ;
99+ }
100+ }
55101
56- if ( ! res . ok ) {
57- // 后端 5xx / 网络错误才抛,前端会走 error.tsx(如果有的话)
58- throw new Error (
59- `/api/community/links backend ${ res . status } ${ res . statusText } ` ,
60- ) ;
102+ // 非 2xx(含 403 CF challenge / 5xx):记录 + 重试
103+ console . warn ( "[feed/page] backend non-2xx" , {
104+ attempt : i ,
105+ status : res . status ,
106+ cfRay : res . headers . get ( "cf-ray" ) ,
107+ cfMitigated : res . headers . get ( "cf-mitigated" ) ,
108+ } ) ;
109+ if ( i === attempts . length - 1 ) return [ ] ;
110+ await sleep ( i === 0 ? 300 : 800 ) ;
61111 }
62112
63- const json = ( await res . json ( ) ) as ApiResponse < SharedLinkView [ ] > ;
64- return json . success && json . data ? json . data : [ ] ;
113+ return [ ] ;
114+ }
115+
116+ function sleep ( ms : number ) : Promise < void > {
117+ return new Promise ( ( r ) => setTimeout ( r , ms ) ) ;
65118}
66119
67120interface FeedPageProps {
@@ -85,16 +138,16 @@ export default async function FeedPage({ searchParams }: FeedPageProps) {
85138 console . error ( "[feed/page] fetchLinks failed:" , err ) ;
86139 }
87140
88- /**
89- * 预计算每条链接的分类显示名(i18n)。
90- * 在 server 端翻译,避免 LinkCard(server component)里调 useTranslations(client hook) 。
91- */
92- function getCategoryLabel ( slug : CategorySlug | null ) : string {
93- if ( ! slug ) return "" ;
141+ // Server 端预计算 slug → 中文显示名 map。传给 FeedAuthWrapper(client)
142+ // 时必须是纯数据(函数 prop 在 Next 16 会报 "Functions cannot be passed to
143+ // Client Components")。8 个 slug 一次翻译完毕,零额外开销 。
144+ const { CATEGORY_SLUGS } = await import ( "@/app/feed/types" ) ;
145+ const categoryLabels : Partial < Record < CategorySlug , string > > = { } ;
146+ for ( const slug of CATEGORY_SLUGS ) {
94147 try {
95- return tCategory ( slug ) ;
148+ categoryLabels [ slug ] = tCategory ( slug ) ;
96149 } catch {
97- return slug ;
150+ categoryLabels [ slug ] = slug ;
98151 }
99152 }
100153
@@ -148,10 +201,7 @@ export default async function FeedPage({ searchParams }: FeedPageProps) {
148201 </ div >
149202 ) : (
150203 // FeedAuthWrapper 是 client 组件,负责读取登录态后注入到 LinkCard
151- < FeedAuthWrapper
152- links = { links }
153- getCategoryLabel = { getCategoryLabel }
154- />
204+ < FeedAuthWrapper links = { links } categoryLabels = { categoryLabels } />
155205 ) }
156206 </ div >
157207 </ main >
0 commit comments