feat: 重构环境配置与 API 处理逻辑

更新 .env.example,补充玩家端本地开发配置说明,并新增直连 Laravel 服务及局域网访问相关配置选项。
重构 middleware.ts:使用新的 API 请求路径构建方法,提升代码清晰度与可维护性。
移除 next.config.ts 中已弃用的 API_BASE_URL 配置,简化 API 请求处理流程。
调整 lottery-http 以适配新的 API 基础地址解析机制,提升代码维护性。
优化 CSP(内容安全策略)配置,精简连接来源白名单管理,进一步增强安全性。
This commit is contained in:
2026-05-29 11:48:33 +08:00
parent 03faed1db6
commit 55bd56116d
11 changed files with 138 additions and 56 deletions

View File

@@ -0,0 +1,16 @@
import { type NextRequest } from "next/server";
import { proxyLotteryApiInDev } from "@/lib/lottery-api-dev-proxy";
type RouteContext = { params: Promise<{ path: string[] }> };
async function handle(request: NextRequest, context: RouteContext) {
const { path } = await context.params;
return proxyLotteryApiInDev(request, path);
}
export const GET = handle;
export const POST = handle;
export const PUT = handle;
export const PATCH = handle;
export const DELETE = handle;

View File

@@ -65,11 +65,9 @@ export function generateCSP(extraParentOrigins: string[] = []): string {
// 字体允许同源
"font-src": ["'self'"],
// 连接允许同源 API 域名
// 连接允许同源;线上 API 由宝塔挂在同源 /api 下
"connect-src": [
"'self'",
process.env.NEXT_PUBLIC_API_URL || "",
process.env.API_BASE_URL || "",
// WebSocket 连接
"ws:",
"wss:",

View File

@@ -0,0 +1,19 @@
const DEFAULT_LOTTERY_API_ORIGIN = "http://127.0.0.1:8000";
/** Laravel 根地址(无尾部 `/`),仅供 Next 本地开发代理与 middleware 服务端请求。 */
export function lotteryApiOrigin(): string {
const configured =
process.env.LOTTERY_API_UPSTREAM?.trim() ||
process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim();
return (configured || DEFAULT_LOTTERY_API_ORIGIN).replace(/\/$/, "");
}
/**
* axios `baseURL`
* - 浏览器始终请求同源 `/api/v1`
* - 线上由宝塔转发 `/api/*` 到 Laravel
* - 本地开发由 `app/api/[...path]/route.ts` 临时代理到 `LOTTERY_API_UPSTREAM`
*/
export function resolveLotteryApiV1Base(): string {
return "/api/v1";
}

View File

@@ -0,0 +1,73 @@
import { type NextRequest, NextResponse } from "next/server";
import { lotteryApiOrigin } from "@/lib/lottery-api-base";
const HOP_BY_HOP = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
]);
/** fetch 会解压 gzip须去掉这些头否则浏览器 ERR_CONTENT_DECODING_FAILED */
const STRIP_RESPONSE_HEADERS = [
...HOP_BY_HOP,
"content-encoding",
"content-length",
];
/** 本地开发:/api/* → LaravelTurbopack 下 next.config rewrites 常不生效)。 */
export async function proxyLotteryApiInDev(
request: NextRequest,
pathSegments: string[],
): Promise<NextResponse> {
if (process.env.NODE_ENV !== "development") {
return NextResponse.json({ msg: "Not Found" }, { status: 404 });
}
const path = pathSegments.join("/");
const target = `${lotteryApiOrigin()}/api/${path}${request.nextUrl.search}`;
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("connection");
headers.delete("accept-encoding");
const init: RequestInit = {
method: request.method,
headers,
redirect: "manual",
};
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = await request.arrayBuffer();
}
let upstream: Response;
try {
upstream = await fetch(target, init);
} catch (error: unknown) {
return NextResponse.json(
{
msg: "Upstream Laravel unreachable",
target,
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 502 },
);
}
const responseHeaders = new Headers(upstream.headers);
for (const name of STRIP_RESPONSE_HEADERS) {
responseHeaders.delete(name);
}
return new NextResponse(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
}

View File

@@ -1,13 +1,4 @@
/**
* 玩家端 API 连接方式(与管理端一致):
* - 默认:同源 `/api` + Next `rewrites` → Laravel`API_BASE_URL` 写在 .env.local
* - 可选:`NEXT_PUBLIC_LOTTERY_API_BASE_URL` 直连。
*/
/** 是否已配置可请求的 Laravel API默认有本地 origin极少用 PROXY_DISABLED 关闭)。 */
export function hasLotteryPlayerApiBaseUrl(): boolean {
const direct = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim();
if (direct !== undefined && direct !== "") {
return true;
}
return process.env.NEXT_PUBLIC_LOTTERY_API_PROXY_DISABLED !== "true";
}

View File

@@ -14,12 +14,16 @@ function ensurePusherOnWindow(): void {
let echoSingleton: Echo<"reverb"> | null = null;
/**
* NEXT_PUBLIC_REVERB_APP_KEY与 Laravel .env `REVERB_APP_KEY` 相同
* NEXT_PUBLIC_REVERB_HOST / PORT / SCHEME浏览器连 Reverb WebSocket通常 localhost:8080 + http/ws
* NEXT_PUBLIC_REVERB_* 须与当前 Reverb 服务一致,并 `php artisan reverb:start`。
* 未配置时默认不连接 Reverb大厅会走轮询降级
*/
export function getLotteryEcho(): Echo<"reverb"> | null {
if (typeof window === "undefined") return null;
if (process.env.NEXT_PUBLIC_REVERB_ENABLED === "false") {
return null;
}
const key = process.env.NEXT_PUBLIC_REVERB_APP_KEY;
const host = process.env.NEXT_PUBLIC_REVERB_HOST;
if (!key?.length || !host?.length) {
@@ -36,10 +40,10 @@ export function getLotteryEcho(): Echo<"reverb"> | null {
broadcaster: "reverb",
key,
wsHost: host,
wsPort: forceTLS ? 443 : port,
wssPort: forceTLS ? port : 443,
wsPort: port,
wssPort: port,
forceTLS,
enabledTransports: ["ws", "wss"],
enabledTransports: forceTLS ? ["wss"] : ["ws"],
});
}

View File

@@ -13,13 +13,13 @@ import {
} from "@/types/api/errors";
import { isApiEnvelope } from "@/types/api/envelope";
import { useErrorStore } from "@/stores/error-store";
import { LOTTERY_API_V1_BASE } from "@/api/paths";
import { resolveLotteryApiV1Base } from "@/lib/lottery-api-base";
/**
* **第一层**`baseURL` 对齐 Laravel `api/v1`;各 `api/*.ts` 只写业务 path如 `/currencies`)。
*/
export const lotteryHttp = axios.create({
baseURL: LOTTERY_API_V1_BASE,
baseURL: resolveLotteryApiV1Base(),
timeout: 30_000,
headers: { Accept: "application/json" },
});