feat: 重构环境配置与 API 处理逻辑
更新 .env.example,补充玩家端本地开发配置说明,并新增直连 Laravel 服务及局域网访问相关配置选项。 重构 middleware.ts:使用新的 API 请求路径构建方法,提升代码清晰度与可维护性。 移除 next.config.ts 中已弃用的 API_BASE_URL 配置,简化 API 请求处理流程。 调整 lottery-http 以适配新的 API 基础地址解析机制,提升代码维护性。 优化 CSP(内容安全策略)配置,精简连接来源白名单管理,进一步增强安全性。
This commit is contained in:
30
.env.example
30
.env.example
@@ -1,36 +1,28 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 玩家端本地配置示例:复制为 .env.local 后按需修改
|
# 玩家端本地配置示例:复制为 .env.local 后按需修改
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 三端联调速查见 lotteryadmin/.env.example;本端默认端口 3800。
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Laravel API(Next rewrites:/api/* → ${API_BASE_URL}/api/*)
|
# Laravel API
|
||||||
|
# - 浏览器始终请求同源 /api/v1
|
||||||
|
# - 宝塔线上负责把 /api/* 转发到 Laravel
|
||||||
|
# - 本地 npm run dev 时,Next 临时把 /api/* 代理到 LOTTERY_API_UPSTREAM
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# 手动切换环境:保留一个生效,另一个注释掉
|
# 本地 Laravel
|
||||||
|
LOTTERY_API_UPSTREAM=http://127.0.0.1:8000
|
||||||
# 测试
|
# 本地前端连线上 API(开发代理,无需改线上 CORS)
|
||||||
API_BASE_URL=http://127.0.0.1:8000
|
# LOTTERY_API_UPSTREAM=https://lotterylaravel.tanumo.com
|
||||||
# 线上
|
|
||||||
# API_BASE_URL=https://api.your-production-domain.com
|
|
||||||
|
|
||||||
# 可选:直连 Laravel(不经 Next 反代)
|
|
||||||
# NEXT_PUBLIC_LOTTERY_API_BASE_URL=http://127.0.0.1:8000
|
|
||||||
# NEXT_PUBLIC_LOTTERY_API_PROXY_DISABLED=true
|
|
||||||
|
|
||||||
# Next 开发:局域网 IP 访问(逗号分隔 host,无协议)
|
# Next 开发:局域网 IP 访问(逗号分隔 host,无协议)
|
||||||
# ALLOWED_DEV_ORIGINS=192.168.0.101
|
# ALLOWED_DEV_ORIGINS=192.168.0.101
|
||||||
|
|
||||||
# 可选:大厅「玩法与赔率」接口 `/api/v1/play/effective` 的 ?currency=(如 NPR);不设则由后端选默认可下注币种。
|
# 可选:大厅 play/effective 的 ?currency=
|
||||||
# NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY=NPR
|
# NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY=NPR
|
||||||
|
|
||||||
# 可选:入口授权失败时“返回主站重新进入”的地址。
|
# 可选:入口授权失败时返回主站
|
||||||
# NEXT_PUBLIC_MAIN_SITE_URL=http://localhost:5173
|
# NEXT_PUBLIC_MAIN_SITE_URL=http://localhost:5173
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# Reverb:本地全栈联调时取消注释,并 php artisan reverb:start;不配则走轮询
|
||||||
# Laravel Reverb(WebSocket)。不配则 Echo 为空,会一直显示「降级模式 / 轮询」。
|
|
||||||
# 须与 lotterLaravel .env 的 REVERB_APP_KEY / REVERB_HOST / REVERB_PORT / REVERB_SCHEME 一致。
|
|
||||||
# Laravel 终端另开:`php artisan reverb:start`
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# NEXT_PUBLIC_REVERB_APP_KEY=
|
# NEXT_PUBLIC_REVERB_APP_KEY=
|
||||||
# NEXT_PUBLIC_REVERB_HOST=127.0.0.1
|
# NEXT_PUBLIC_REVERB_HOST=127.0.0.1
|
||||||
# NEXT_PUBLIC_REVERB_PORT=8080
|
# NEXT_PUBLIC_REVERB_PORT=8080
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
import { LOTTERY_API_V1_BASE } from "./src/api/paths";
|
import { lotteryApiOrigin } from "./src/lib/lottery-api-base";
|
||||||
import { generateCSP, nonCspSecurityHeaders } from "./src/lib/csp-config";
|
import { generateCSP, nonCspSecurityHeaders } from "./src/lib/csp-config";
|
||||||
|
|
||||||
type RuntimeOriginsEnvelope = {
|
type RuntimeOriginsEnvelope = {
|
||||||
@@ -10,9 +10,9 @@ type RuntimeOriginsEnvelope = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadRuntimeOrigins(request: NextRequest): Promise<string[]> {
|
async function loadRuntimeOrigins(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const url = new URL(`${LOTTERY_API_V1_BASE}/integration/runtime-origins`, request.url);
|
const url = `${lotteryApiOrigin()}/api/v1/integration/runtime-origins`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@@ -31,9 +31,9 @@ async function loadRuntimeOrigins(request: NextRequest): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function middleware(request: NextRequest): Promise<NextResponse> {
|
export async function middleware(_request: NextRequest): Promise<NextResponse> {
|
||||||
const response = NextResponse.next();
|
const response = NextResponse.next();
|
||||||
const runtimeOrigins = await loadRuntimeOrigins(request);
|
const runtimeOrigins = await loadRuntimeOrigins();
|
||||||
|
|
||||||
response.headers.set("Content-Security-Policy", generateCSP(runtimeOrigins));
|
response.headers.set("Content-Security-Policy", generateCSP(runtimeOrigins));
|
||||||
for (const header of nonCspSecurityHeaders) {
|
for (const header of nonCspSecurityHeaders) {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import type { NextConfig } from "next";
|
|||||||
import { nonCspSecurityHeaders } from "./src/lib/csp-config";
|
import { nonCspSecurityHeaders } from "./src/lib/csp-config";
|
||||||
import { parseAllowedDevOrigins } from "./src/lib/next-dev-origins";
|
import { parseAllowedDevOrigins } from "./src/lib/next-dev-origins";
|
||||||
|
|
||||||
const lotteryApiProxyTarget =
|
|
||||||
process.env.API_BASE_URL?.trim() || "http://127.0.0.1:8000";
|
|
||||||
const allowedDevOrigins = parseAllowedDevOrigins(process.env.ALLOWED_DEV_ORIGINS);
|
const allowedDevOrigins = parseAllowedDevOrigins(process.env.ALLOWED_DEV_ORIGINS);
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
@@ -20,15 +18,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
async rewrites() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/api/:path*",
|
|
||||||
destination: `${lotteryApiProxyTarget}/api/:path*`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 3800",
|
"dev": "next dev --port 3800",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start --port 3800",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
16
src/app/api/[...path]/route.ts
Normal file
16
src/app/api/[...path]/route.ts
Normal 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;
|
||||||
@@ -65,11 +65,9 @@ export function generateCSP(extraParentOrigins: string[] = []): string {
|
|||||||
// 字体允许同源
|
// 字体允许同源
|
||||||
"font-src": ["'self'"],
|
"font-src": ["'self'"],
|
||||||
|
|
||||||
// 连接允许同源和 API 域名
|
// 连接允许同源;线上 API 由宝塔挂在同源 /api 下
|
||||||
"connect-src": [
|
"connect-src": [
|
||||||
"'self'",
|
"'self'",
|
||||||
process.env.NEXT_PUBLIC_API_URL || "",
|
|
||||||
process.env.API_BASE_URL || "",
|
|
||||||
// WebSocket 连接
|
// WebSocket 连接
|
||||||
"ws:",
|
"ws:",
|
||||||
"wss:",
|
"wss:",
|
||||||
|
|||||||
19
src/lib/lottery-api-base.ts
Normal file
19
src/lib/lottery-api-base.ts
Normal 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";
|
||||||
|
}
|
||||||
73
src/lib/lottery-api-dev-proxy.ts
Normal file
73
src/lib/lottery-api-dev-proxy.ts
Normal 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/* → Laravel(Turbopack 下 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,13 +1,4 @@
|
|||||||
/**
|
/** 是否已配置可请求的 Laravel API(默认有本地 origin,极少用 PROXY_DISABLED 关闭)。 */
|
||||||
* 玩家端 API 连接方式(与管理端一致):
|
|
||||||
* - 默认:同源 `/api` + Next `rewrites` → Laravel(`API_BASE_URL` 写在 .env.local)。
|
|
||||||
* - 可选:`NEXT_PUBLIC_LOTTERY_API_BASE_URL` 直连。
|
|
||||||
*/
|
|
||||||
export function hasLotteryPlayerApiBaseUrl(): boolean {
|
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";
|
return process.env.NEXT_PUBLIC_LOTTERY_API_PROXY_DISABLED !== "true";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ function ensurePusherOnWindow(): void {
|
|||||||
let echoSingleton: Echo<"reverb"> | null = null;
|
let echoSingleton: Echo<"reverb"> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NEXT_PUBLIC_REVERB_APP_KEY:与 Laravel .env `REVERB_APP_KEY` 相同
|
* NEXT_PUBLIC_REVERB_* 须与当前 Reverb 服务一致,并 `php artisan reverb:start`。
|
||||||
* NEXT_PUBLIC_REVERB_HOST / PORT / SCHEME:浏览器连 Reverb WebSocket(通常 localhost:8080 + http/ws)
|
* 未配置时默认不连接 Reverb(大厅会走轮询降级)。
|
||||||
*/
|
*/
|
||||||
export function getLotteryEcho(): Echo<"reverb"> | null {
|
export function getLotteryEcho(): Echo<"reverb"> | null {
|
||||||
if (typeof window === "undefined") return 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 key = process.env.NEXT_PUBLIC_REVERB_APP_KEY;
|
||||||
const host = process.env.NEXT_PUBLIC_REVERB_HOST;
|
const host = process.env.NEXT_PUBLIC_REVERB_HOST;
|
||||||
if (!key?.length || !host?.length) {
|
if (!key?.length || !host?.length) {
|
||||||
@@ -36,10 +40,10 @@ export function getLotteryEcho(): Echo<"reverb"> | null {
|
|||||||
broadcaster: "reverb",
|
broadcaster: "reverb",
|
||||||
key,
|
key,
|
||||||
wsHost: host,
|
wsHost: host,
|
||||||
wsPort: forceTLS ? 443 : port,
|
wsPort: port,
|
||||||
wssPort: forceTLS ? port : 443,
|
wssPort: port,
|
||||||
forceTLS,
|
forceTLS,
|
||||||
enabledTransports: ["ws", "wss"],
|
enabledTransports: forceTLS ? ["wss"] : ["ws"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import {
|
|||||||
} from "@/types/api/errors";
|
} from "@/types/api/errors";
|
||||||
import { isApiEnvelope } from "@/types/api/envelope";
|
import { isApiEnvelope } from "@/types/api/envelope";
|
||||||
import { useErrorStore } from "@/stores/error-store";
|
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`)。
|
* **第一层**:`baseURL` 对齐 Laravel `api/v1`;各 `api/*.ts` 只写业务 path(如 `/currencies`)。
|
||||||
*/
|
*/
|
||||||
export const lotteryHttp = axios.create({
|
export const lotteryHttp = axios.create({
|
||||||
baseURL: LOTTERY_API_V1_BASE,
|
baseURL: resolveLotteryApiV1Base(),
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user