大数据

next js 与fastapi交互demo

.env

BACKEND_BASE_URL=http://localhost:8000

src/config/default.tsx

export const BACKEND_BASE_URL=process.env.BACKEND_BASE_URL;
export const BACKEND_LOGIN_URL = `${BACKEND_BASE_URL}/auth/login`;
export const BACKEND_REFRESH_TOKEN_URL = `${BACKEND_BASE_URL}/auth/refresh`;
export const ACCESS_TOKEN_NAME = 'access_token';
export const REFRESH_TOKEN_NAME = 'refresh_token';

src/lib/auth.ts

import {cookies} from 'next/headers';
import {ACCESS_TOKEN_NAME, REFRESH_TOKEN_NAME} from "@/config/default";

// 存储 Tokens 到 Cookie
export async function setAuthTokens({accessToken, refreshToken}: {
    accessToken: string; refreshToken: string;
}) {
    (await cookies()).set(ACCESS_TOKEN_NAME, accessToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        maxAge: 60 * 15, // 15分钟
    });
    (await cookies()).set(REFRESH_TOKEN_NAME, refreshToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        maxAge: 60 * 60 * 24 * 7, // 7天
    });
}

// 获取 Tokens
export async function getAuthTokens() {
    return {
        accessToken: (await cookies()).get(ACCESS_TOKEN_NAME)?.value,
        refreshToken: (await cookies()).get(REFRESH_TOKEN_NAME)?.value,
    };
}

// 清除Token
export async function clearAuthTokens() {
    (await cookies()).delete(ACCESS_TOKEN_NAME);
    (await cookies()).delete(REFRESH_TOKEN_NAME);
}

src/lib/fetchWithAuth.ts

import {jwtDecode} from "jwt-decode";
import {cookies} from "next/headers";
import {redirect} from "next/navigation";
import {clearAuthTokens, getAuthTokens} from "@/lib/auth";
import {ACCESS_TOKEN_NAME, BACKEND_REFRESH_TOKEN_URL} from "@/config/default";

/**
 * 封装带自动 Token 管理的 fetch 请求
 * @param url 请求地址
 * @param options 请求配置(包含 method、body、headers 等)
 * @returns 响应数据
 */
export async function fetchWithAuth(
    url: string,
    options: RequestInit = {}
): Promise<Response> {
    // 1、从cookie获取token
    const {accessToken, refreshToken} = await getAuthTokens();

    // 2、检查token是否存在
    if (!accessToken || !refreshToken) {
        console.error("未找到Token,跳转登录页");
        redirect("/login");
    }

    // 3、检查access token是否过期
    let validToken = accessToken;
    try {
        const {exp} = jwtDecode(accessToken);
        if (exp * 1000 < Date.now()) {
            // Token过期 自动刷新
            console.log("Access Token 已过期,尝试自动刷新...")
            const refreshResponse = await fetch(BACKEND_REFRESH_TOKEN_URL, {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                    "Authorization": `Bearer ${refreshToken}`
                },
                body: JSON.stringify({})
            });

            if (!refreshResponse.ok) {
                throw new Error("刷新Token失败");
            }

            const {access_token: newAccessToken} = await refreshResponse.json();
            validToken = newAccessToken;

            // 更新cookie
            (await cookies()).set(ACCESS_TOKEN_NAME, newAccessToken, {
                httpOnly: true,
                maxAge: 60 * 15
            });
        }
    } catch (error) {
        console.error("Token 验证/刷新失败:",error);
        await clearAuthTokens();
    }

    // 4、发起请求 携带有效Token
    const headers = {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${validToken}`,
        ...options.headers,
    };

    return fetch(url, {
        ...options,
        headers,
        // 如果未指定 method,默认 POST(可根据需求调整)
        method: options.method || "POST"
    })
}

src/middleware.ts

import {NextResponse, NextRequest} from "next/server";
import {jwtDecode} from "jwt-decode";
import {ACCESS_TOKEN_NAME, BACKEND_REFRESH_TOKEN_URL, REFRESH_TOKEN_NAME} from '@/config/default'


export async function middleware(request: NextRequest) {
    const {pathname} = request.nextUrl;
    const accessToken = request.cookies.get(ACCESS_TOKEN_NAME)?.value;
    const refreshToken = request.cookies.get(REFRESH_TOKEN_NAME)?.value;
    console.log("middleware:", request.url);

    // 1、放行公开路由(无需认证)
    const publicPaths = ["/login", "/api/login"];
    if (publicPaths.includes(pathname)) {
        return NextResponse.next();
    }

    // 2、无token跳转登录
    if (!accessToken || !refreshToken) {
        const loginUrl = new URL("/login", request.url);
        // 如果不是登陆页面,添加next参数
        if (pathname !== "/login") {
            loginUrl.searchParams.set("next", pathname)
        }
        return NextResponse.redirect(loginUrl);
    }

    // 3、检查access token是否过期
    try {
        const {exp} = jwtDecode(accessToken);
        const isExpired = exp * 1000 < Date.now();

        // Token 有效 继续访问
        if (!isExpired) {
            return NextResponse.next();
        }

        // Token过期 尝试刷新
        const refreshResponse = await fetch(BACKEND_REFRESH_TOKEN_URL, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "Authorization": `Bearer ${refreshToken}`
            },
            body: JSON.stringify({})
        });

        if (refreshResponse.ok) {
            const {access_token: newAccessToken} = await refreshResponse.json();

            // 更新cookie并继续请求
            const response = NextResponse.next();
            response.cookies.set(ACCESS_TOKEN_NAME, newAccessToken, {
                httpOnly: true,
                maxAge: 60 * 15
            });
            return response;
        }
    } catch (error) {
        console.log("Token 验证失败", error);
    }

    // 4、刷新失效或token无效 清除cookie并跳转登录
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete(ACCESS_TOKEN_NAME);
    response.cookies.delete(REFRESH_TOKEN_NAME);
    return response;
}

// 配置中间件生效的路由(可选)
export const config = {
    matcher: ['/((?!_next/static|favicon.ico).*)'], // 匹配所有路由,排除静态文件
};

src/app/api/login/route.tsx

"use server"
import {BACKEND_LOGIN_URL} from '@/config/default'
import {NextRequest, NextResponse} from 'next/server'
import {setAuthTokens} from "@/lib/auth";


export async function POST(request: NextRequest) {
    const requestData = await request.json()
    const jsonData = JSON.stringify(requestData)
    const requestOptions = {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: jsonData
    }
    const response = await fetch(BACKEND_LOGIN_URL, requestOptions)
    const responseData = await response.json()
    if (response.ok) {
        // console.log("login/route.tsx:responseData", responseData)
        const {access_token: accessToken, refresh_token: refreshToken, username} = responseData
        await setAuthTokens({accessToken, refreshToken});
        return NextResponse.json({"loggedIn": true, "username": username}, {status: 200})
    }
    return NextResponse.json({"loggedIn": false, ...responseData}, {status: 400})
}

src/app/api/logout/route.tsx

import {clearAuthTokens} from "@/lib/auth";
import {NextRequest, NextResponse} from "next/server";


export async function POST(request: NextRequest) {
    await clearAuthTokens()
    return NextResponse.json({}, {status: 200})
}

src/app/login/page.tsx

"use client"

import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import React from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";

const LOGIN_URL = "/api/login/"

const isValidNextUrl = (url: string | null): boolean => {
    if (!url) {
        return false;
    }
    try {
        // 确保是相对路径或同源路径
        const parsedUrl = new URL(url, window.location.origin);
        return parsedUrl.origin === window.location.origin;
    } catch {
        return false;
    }
}

export default function Page() {
    const router = useRouter();
    const searchParams = useSearchParams();
    const nextUrl = searchParams.get("next");

    async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
        event.preventDefault()
        // console.log(event, event.target)
        const formData = new FormData(event.currentTarget)
        const objectFromForm = Object.fromEntries(formData)
        const jsonData = JSON.stringify(objectFromForm)
        const requestOptions = {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: jsonData
        }
        const response = await fetch(LOGIN_URL, requestOptions)
        if (response.ok) {
            console.log("logged in")
            const safeNextUrl = isValidNextUrl(nextUrl) ? nextUrl : "/"
            router.push(safeNextUrl)
        } else {
            console.log(await response.json())
        }
    }

    return (
        <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
            <div className="w-full max-w-sm">
                <div className="flex flex-col gap-6">
                    <Card>
                        <CardHeader>
                            <CardTitle>Login to your account</CardTitle>
                            <CardDescription>
                                Enter your email below to login to your account
                            </CardDescription>
                        </CardHeader>
                        <CardContent>
                            <form onSubmit={handleSubmit}>
                                <div className="flex flex-col gap-6">
                                    <div className="grid gap-3">
                                        <Label htmlFor="email">Username</Label>
                                        <Input
                                            id="username"
                                            name="username"
                                            type="text"
                                            placeholder="Your username"
                                            required
                                        />
                                    </div>
                                    <div className="grid gap-3">
                                        <div className="flex items-center">
                                            <Label htmlFor="password">Password</Label>
                                            <a
                                                href="#"
                                                className="hidden"
                                            >
                                                Forgot your password?
                                            </a>
                                        </div>
                                        <Input id="password" name="password" type="password" required/>
                                    </div>
                                    <div className="flex flex-col gap-3">
                                        <Button type="submit" className="w-full">
                                            Login
                                        </Button>
                                    </div>
                                </div>
                                <div className="mt-4 text-center text-sm">
                                    Don't have an account?{" "}
                                    <a href="#" className="underline underline-offset-4">
                                        Sign up
                                    </a>
                                </div>
                            </form>
                        </CardContent>
                    </Card>
                </div>
            </div>
        </div>
    )
}

src/app/logout/page.tsx

"use client"

import React from "react";
import { MouseEvent } from 'react';
import {useRouter} from "next/navigation";

const LOGOUT_URL = "/api/logout/"


export default function Page() {
    const router = useRouter();
    async function handleClick(event: MouseEvent<HTMLButtonElement>) {
        event.preventDefault()
        const requestOptions = {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: ""
        }
        await fetch(LOGOUT_URL, requestOptions)
        console.log("logged out")
        router.push("/login")
    }

    return <div className="h-[95vh]">
        <div className='max-w-md mx-auto py-5'>
            <h1>Are you sure you want to logout?</h1>
            <button className='bg-red-500 text-white hover:bg-red-300 px-3 py-2' onClick={handleClick}>Yes, logout
            </button>
        </div>
    </div>
}

src/app/articles/page.tsx

import {fetchWithAuth} from "@/lib/fetchWithAuth";
import {BACKEND_BASE_URL} from "@/config/default";

const ARTICLES_API_URL = `${BACKEND_BASE_URL}/articles`;


export default async function Page() {
    const res = await fetchWithAuth(ARTICLES_API_URL, {method: "GET"});
    const data = await res.json();
    return (
        <main className="max-w-md mx-auto py-5">
            {JSON.stringify(data)}
        </main>
    );
}