Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-27 16:23:48 +07:00
co-authored by Claude Opus 5.5
commit 8cdbfee08f
345 changed files with 20773 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
import { ApiError, decodeEnvelope } from "./protocol";
import type { Envelope, HeaderNavigation, HomeData } from "./types";
import { cache } from "react";
import { headers } from 'next/headers';
import { publicCachePolicy, publicDataCache } from './public-cache';
import { getApiBaseUrl, usesCurrentApiOrigin, UPSTREAM_HOP_HEADER, type ApiRequestContext } from './config';
export { DEFAULT_API_BASE_URL } from './config';
export const PUBLIC_PATHS = new Set([
"playlet/home", "config/info", "config/footer/info", "config/richtext/list",
"navigation/header", "theme/list", "playlet/tag/options", "translation/category/list",
"recharge/package/list", "applist/list", "playlet/all/list", "playlet/hot/list",
"playlet/hot/rank/list", "playlet/topic/list", "playlet/topic/detail", "playlet/tag/list",
"playlet/guess-like/list", "playlet/hot-like",
]);
const GET_PATHS = [
...PUBLIC_PATHS, "auth/getInfo", "playlet/detail", "playlet/chapter/list", "playlet/chapter/detail",
"playlet/search", "playlet/search/suggest", "playlet/collect/list", "playlet/chapter/collect/list",
"playlet/history/list", "playlet/like/list", "playlet/bought/list", "user/center/data", "coin/orders", "coin/trans/list",
"vip/orders", "comment/list",
];
const POST_PATHS = [
"auth/guest", "auth/login", "auth/register", "auth/resetPassword", "auth/profile",
"playlet/collect/set", "playlet/like/set", "playlet/chapter/collect/set", "playlet/chapter/like/set",
"playlet/chapter/unlock", "playlet/chapter/watch/report", "playlet/forward/heat",
"playlet/history/clear", "playlet/history/delete", "comment/send", "comment/like/set",
"coin/buy", "vip/pay", "feedback/add", "upload/image",
];
export function allowedRequest(path: string, method: string): boolean {
return (method === "GET" ? GET_PATHS : method === "POST" ? POST_PATHS : []).includes(path);
}
export async function getServerApiBaseUrl(request?: ApiRequestContext): Promise<string> {
return getApiBaseUrl(request || (usesCurrentApiOrigin() ? {headers: await headers()} : undefined));
}
export async function upstream<T>(path: string, options: {method?: string; body?: unknown; token?: string; device?: string; userAgent?: string; clientIp?: string; tracking?:Record<string,string>; timeoutMs?:number; request?:ApiRequestContext} = {}): Promise<Envelope<T>> {
const url=new URL(`/api/app/${path}`,await getServerApiBaseUrl(options.request));
const policy=publicCachePolicy(url.pathname.slice('/api/app/'.length));
if(policy&&(!options.method||options.method==='GET')&&!options.token&&options.body===undefined&&!Object.keys(options.tracking||{}).length){
url.searchParams.sort();
return publicDataCache.read(url.href,policy.ttl,()=>requestUpstream<T>(url,options),{stale:policy.stale,accept:result=>result.code===200&&!!result.data&&typeof result.data==='object'&&!Array.isArray(result.data)});
}
return requestUpstream<T>(url,options);
}
async function requestUpstream<T>(url:URL,options:Parameters<typeof upstream>[1]={}):Promise<Envelope<T>> {
const device = (options.device || "server-public").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 100);
const rawUA = options.userAgent || "91-drama-web/1.0";
const sys = /Android/i.test(rawUA) ? "h5_android" : /iPhone|iPad|iPod/i.test(rawUA) ? "h5_ios" : "h5_pc";
const headers: Record<string,string> = {
Accept: "application/json", "User-Agent": rawUA,
[UPSTREAM_HOP_HEADER]: '1',
"X-User-Agent": `DevID=${device};DevType=OTHER;SysType=${sys};Ver=1.0.0;BuildID=web`,
};
if (options.token) headers.Authorization = options.token;
if (options.clientIp) headers['X-Real-IP'] = options.clientIp;
for(const key of ['X-SDK-Common','X-User-Agent','X-Tracker-Request-Id','X-Tracker-Event-Name','X-DC-Request-Context'])if(options.tracking?.[key])headers[key]=options.tracking[key];
const multipart = options.body instanceof FormData;
if (options.body && !multipart) headers["Content-Type"] = "application/json";
let response: Response;
try {
response = await fetch(url, {
method: options.method || "GET", headers, cache: "no-store", redirect: "error",
body: options.body ? multipart ? options.body as FormData : JSON.stringify(options.body) : undefined,
signal: AbortSignal.timeout(options.timeoutMs||20000),
});
} catch (error) {
const timeout = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
throw new ApiError(timeout ? "接口请求超时,请稍后重试" : "接口连接失败,请稍后重试", timeout ? 504 : 502);
}
if (!response.ok) {
if (response.status === 502 && response.headers.get(UPSTREAM_HOP_HEADER) === '1') throw new ApiError('上游请求返回本站,请检查 Nginx 的 /api/app/ 后端转发配置',502);
throw new ApiError(`接口暂时不可用(${response.status})`, response.status);
}
try { return decodeEnvelope<T>(await response.json()); }
catch (error) { if (error instanceof ApiError) throw error; throw new ApiError("接口返回数据无法解析", 502); }
}
export const getHome = cache(async (): Promise<{data: HomeData; error?: string}> => {
try {
const result = await upstream<HomeData>("playlet/home");
if (result.code !== 200) throw new ApiError(result.tip || result.msg || "首页加载失败",result.code);
if (!result.data || typeof result.data !== 'object' || Array.isArray(result.data)) throw new ApiError('首页返回数据无效',502);
return {data: result.data};
} catch (error) { return {data: {}, error: error instanceof Error ? error.message : "首页加载失败"}; }
});
export const getHeaderNavigation=cache(async ():Promise<{data:HeaderNavigation;error?:boolean}>=>{
try{
const response=await upstream<HeaderNavigation>('navigation/header',{timeoutMs:5000});
if(response.code!==200||!response.data||typeof response.data!=='object')throw new Error('导航加载失败');
return {data:response.data};
}catch{return {data:{categoryList:[]},error:true};}
});