diff --git a/.env.example b/.env.example index 8389770..f7045b0 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +1,28 @@ # ============================================================================= # 玩家端本地配置示例:复制为 .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 # ----------------------------------------------------------------------------- -# 手动切换环境:保留一个生效,另一个注释掉 - -# 测试 -API_BASE_URL=http://127.0.0.1:8000 -# 线上 -# 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 +# 本地 Laravel +LOTTERY_API_UPSTREAM=http://127.0.0.1:8000 +# 本地前端连线上 API(开发代理,无需改线上 CORS) +# LOTTERY_API_UPSTREAM=https://lotterylaravel.tanumo.com # Next 开发:局域网 IP 访问(逗号分隔 host,无协议) # ALLOWED_DEV_ORIGINS=192.168.0.101 -# 可选:大厅「玩法与赔率」接口 `/api/v1/play/effective` 的 ?currency=(如 NPR);不设则由后端选默认可下注币种。 +# 可选:大厅 play/effective 的 ?currency= # NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY=NPR -# 可选:入口授权失败时“返回主站重新进入”的地址。 +# 可选:入口授权失败时返回主站 # NEXT_PUBLIC_MAIN_SITE_URL=http://localhost:5173 -# ----------------------------------------------------------------------------- -# Laravel Reverb(WebSocket)。不配则 Echo 为空,会一直显示「降级模式 / 轮询」。 -# 须与 lotterLaravel .env 的 REVERB_APP_KEY / REVERB_HOST / REVERB_PORT / REVERB_SCHEME 一致。 -# Laravel 终端另开:`php artisan reverb:start` -# ----------------------------------------------------------------------------- +# Reverb:本地全栈联调时取消注释,并 php artisan reverb:start;不配则走轮询 # NEXT_PUBLIC_REVERB_APP_KEY= # NEXT_PUBLIC_REVERB_HOST=127.0.0.1 # NEXT_PUBLIC_REVERB_PORT=8080 diff --git a/middleware.ts b/middleware.ts index abd79f5..699f15d 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,6 +1,6 @@ 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"; type RuntimeOriginsEnvelope = { @@ -10,9 +10,9 @@ type RuntimeOriginsEnvelope = { }; }; -async function loadRuntimeOrigins(request: NextRequest): Promise { +async function loadRuntimeOrigins(): Promise { 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, { headers: { Accept: "application/json" }, cache: "no-store", @@ -31,9 +31,9 @@ async function loadRuntimeOrigins(request: NextRequest): Promise { } } -export async function middleware(request: NextRequest): Promise { +export async function middleware(_request: NextRequest): Promise { const response = NextResponse.next(); - const runtimeOrigins = await loadRuntimeOrigins(request); + const runtimeOrigins = await loadRuntimeOrigins(); response.headers.set("Content-Security-Policy", generateCSP(runtimeOrigins)); for (const header of nonCspSecurityHeaders) { diff --git a/next.config.ts b/next.config.ts index ea2dff0..b4fe078 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,8 +3,6 @@ import type { NextConfig } from "next"; import { nonCspSecurityHeaders } from "./src/lib/csp-config"; 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 nextConfig: NextConfig = { @@ -20,15 +18,6 @@ const nextConfig: NextConfig = { }, ]; }, - - async rewrites() { - return [ - { - source: "/api/:path*", - destination: `${lotteryApiProxyTarget}/api/:path*`, - }, - ]; - }, }; export default nextConfig; diff --git a/package.json b/package.json index c765ff3..fc91bfd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev --port 3800", "build": "next build", - "start": "next start", + "start": "next start --port 3800", "lint": "eslint" }, "dependencies": { diff --git a/src/app/api/[...path]/route.ts b/src/app/api/[...path]/route.ts new file mode 100644 index 0000000..0c9f3f1 --- /dev/null +++ b/src/app/api/[...path]/route.ts @@ -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; diff --git a/src/lib/csp-config.ts b/src/lib/csp-config.ts index e5f9852..6b9f473 100644 --- a/src/lib/csp-config.ts +++ b/src/lib/csp-config.ts @@ -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:", diff --git a/src/lib/lottery-api-base.ts b/src/lib/lottery-api-base.ts new file mode 100644 index 0000000..f49c745 --- /dev/null +++ b/src/lib/lottery-api-base.ts @@ -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"; +} diff --git a/src/lib/lottery-api-dev-proxy.ts b/src/lib/lottery-api-dev-proxy.ts new file mode 100644 index 0000000..3292dd1 --- /dev/null +++ b/src/lib/lottery-api-dev-proxy.ts @@ -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 { + 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, + }); +} diff --git a/src/lib/lottery-api-env.ts b/src/lib/lottery-api-env.ts index 5b08786..b7833e7 100644 --- a/src/lib/lottery-api-env.ts +++ b/src/lib/lottery-api-env.ts @@ -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"; } diff --git a/src/lib/lottery-echo.ts b/src/lib/lottery-echo.ts index c468040..bdfe29e 100644 --- a/src/lib/lottery-echo.ts +++ b/src/lib/lottery-echo.ts @@ -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"], }); } diff --git a/src/lib/lottery-http.ts b/src/lib/lottery-http.ts index 91fabe4..ee5194b 100644 --- a/src/lib/lottery-http.ts +++ b/src/lib/lottery-http.ts @@ -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" }, });