feat: Introduce a web frontend with new city API routes and refactor bot city query logic into a dedicated service.

This commit is contained in:
2569718930@qq.com
2026-03-11 08:46:32 +08:00
parent b1e75d13d8
commit af4bee12f5
15 changed files with 893 additions and 633 deletions
+5
View File
@@ -1 +1,6 @@
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
# Optional dashboard guard (Next.js middleware)
# If set, open dashboard with: /?access_token=<token>
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
# Shared secret forwarded by Next API routes to backend
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
+2 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -12,7 +13,7 @@ export async function GET() {
try {
const res = await fetch(`${API_BASE}/api/cities`, {
headers: { Accept: "application/json" },
headers: buildBackendRequestHeaders(),
next: { revalidate: 120 },
});
if (!res.ok) {
+2 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -26,7 +27,7 @@ export async function GET(
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
headers: buildBackendRequestHeaders(),
cache: "no-store",
});
if (!res.ok) {
+2 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -19,7 +20,7 @@ export async function GET(
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
headers: buildBackendRequestHeaders(),
cache: "no-store",
});
if (!res.ok) {
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -19,7 +20,7 @@ export async function GET(
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
headers: buildBackendRequestHeaders(),
cache: "no-store",
});
if (!res.ok) {
+2 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -18,7 +19,7 @@ export async function GET(
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
headers: buildBackendRequestHeaders(),
cache: "no-store",
});
if (!res.ok) {
@@ -0,0 +1,46 @@
type Props = {
searchParams?: Promise<{ next?: string }>;
};
export default async function EntitlementRequiredPage({ searchParams }: Props) {
const params = (await searchParams) || {};
const nextPath = params.next || "/";
return (
<main
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background:
"radial-gradient(circle at 20% 20%, #13264f 0%, #071127 45%, #040812 100%)",
color: "#d6e2ff",
padding: "24px",
}}
>
<section
style={{
width: "100%",
maxWidth: 720,
border: "1px solid rgba(68, 92, 140, 0.45)",
borderRadius: 16,
padding: 24,
background: "rgba(9, 18, 36, 0.88)",
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
}}
>
<h1 style={{ margin: 0, fontSize: 28, lineHeight: 1.2 }}>
Entitlement Required
</h1>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
This dashboard is protected. Append{" "}
<code>?access_token=&lt;your-token&gt;</code> to the URL once, and
the session cookie will be set automatically.
</p>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
Requested path: <code>{nextPath}</code>
</p>
</section>
</main>
);
}
+14
View File
@@ -0,0 +1,14 @@
export const BACKEND_ENTITLEMENT_HEADER = "x-polyweather-entitlement";
export function buildBackendRequestHeaders(): HeadersInit {
const headers: HeadersInit = {
Accept: "application/json",
};
const token = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
if (token) {
headers[BACKEND_ENTITLEMENT_HEADER] = token;
}
return headers;
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
const SESSION_COOKIE = "polyweather_entitlement";
function isStaticAsset(pathname: string) {
return (
pathname.startsWith("/_next/") ||
pathname.startsWith("/favicon") ||
pathname.startsWith("/robots.txt") ||
pathname.startsWith("/sitemap.xml") ||
pathname.startsWith("/icons/") ||
pathname.startsWith("/images/") ||
pathname.startsWith("/static/")
);
}
function isPublicPage(pathname: string) {
return pathname === "/entitlement-required";
}
export function middleware(request: NextRequest) {
const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim();
if (!requiredToken) {
return NextResponse.next();
}
const { pathname, searchParams } = request.nextUrl;
if (isStaticAsset(pathname) || isPublicPage(pathname)) {
return NextResponse.next();
}
const cookieToken = request.cookies.get(SESSION_COOKIE)?.value;
if (cookieToken && cookieToken === requiredToken) {
return NextResponse.next();
}
const queryToken = searchParams.get("access_token");
if (queryToken && queryToken === requiredToken) {
const cleanUrl = request.nextUrl.clone();
cleanUrl.searchParams.delete("access_token");
const response = NextResponse.redirect(cleanUrl);
response.cookies.set(SESSION_COOKIE, requiredToken, {
httpOnly: true,
sameSite: "lax",
secure: cleanUrl.protocol === "https:",
path: "/",
maxAge: 60 * 60 * 12,
});
return response;
}
if (pathname.startsWith("/api/")) {
return NextResponse.json(
{ error: "Unauthorized", detail: "Entitlement token required" },
{ status: 401 },
);
}
const deniedUrl = request.nextUrl.clone();
deniedUrl.pathname = "/entitlement-required";
deniedUrl.search = "";
deniedUrl.searchParams.set("next", pathname);
return NextResponse.redirect(deniedUrl);
}
export const config = {
matcher: ["/((?!_next/static|_next/image).*)"],
};