import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { getToken } from 'next-auth/jwt' import { getAuthSessionCookieCandidates, resolveSecureAuthCookieFromRequest, } from '@/lib/auth-cookie' const API_CORS_ALLOWED_HEADERS = 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, Cookie' const DEPARTMENT_PAGE_PREFIXES = [ '/assets/material-library', '/workspaces', ] function isDepartmentPage(pathname: string): boolean { return pathname === '/' || DEPARTMENT_PAGE_PREFIXES.some((prefix) => pathname.startsWith(prefix)) } function getAllowedCorsOrigins(request: NextRequest): Set { const values = [ request.nextUrl.origin, process.env.APP_PUBLIC_URL, process.env.NEXTAUTH_URL, ...(process.env.CORS_ALLOWED_ORIGINS || '').split(','), ] return new Set(values.map((value) => String(value || '').trim().replace(/\/+$/, '')).filter(Boolean)) } function isAllowedCorsOrigin(request: NextRequest, requestOrigin: string | null): boolean { if (!requestOrigin) return true return getAllowedCorsOrigins(request).has(requestOrigin.replace(/\/+$/, '')) } // 注意:不在middleware中启动内存监控 // middleware使用Edge Runtime,不支持Node.js API // 内存监控会在lib/memory-monitor.ts模块加载时自动启动(仅生产环境) export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl const requestOrigin = request.headers.get('origin') const isAuthEntryPage = pathname === '/login' || pathname === '/dingtalk-entry' || pathname === '/dingtalk-callback' || pathname.startsWith('/performance-survey') // 处理API路由的CORS if (pathname.startsWith('/api/')) { const corsOriginAllowed = isAllowedCorsOrigin(request, requestOrigin) if (!corsOriginAllowed) { return NextResponse.json({ error: '不允许的跨域来源' }, { status: 403 }) } // 处理OPTIONS预检请求 if (request.method === 'OPTIONS') { const preflightResponse = new NextResponse(null, { status: 204, headers: { 'Access-Control-Allow-Methods': 'GET,DELETE,PATCH,POST,PUT,OPTIONS', 'Access-Control-Allow-Headers': API_CORS_ALLOWED_HEADERS, 'Access-Control-Max-Age': '86400', 'Vary': 'Origin', }, }) if (requestOrigin) { preflightResponse.headers.set('Access-Control-Allow-Credentials', 'true') preflightResponse.headers.set('Access-Control-Allow-Origin', requestOrigin) } return preflightResponse } // 为所有API响应添加CORS头 const response = NextResponse.next() response.headers.set('Vary', 'Origin') if (requestOrigin) { response.headers.set('Access-Control-Allow-Credentials', 'true') response.headers.set('Access-Control-Allow-Origin', requestOrigin) } response.headers.set('Access-Control-Allow-Methods', 'GET,DELETE,PATCH,POST,PUT,OPTIONS') response.headers.set('Access-Control-Allow-Headers', API_CORS_ALLOWED_HEADERS) return response } // 处理需要认证的页面 // /materials(素材库)与 /organization(组织管理)与首页同级,需登录; // /organization 沿用管理员角色门(与 /admin 一致) if ( isDepartmentPage(pathname) || pathname === '/materials' || pathname.startsWith('/admin') || pathname.startsWith('/organization') ) { const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request) const cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie) let token = null for (const cookieName of cookieCandidates) { token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET, cookieName, secureCookie: cookieName.startsWith('__Secure-'), }) if (token) { break } } if (!token) { const url = new URL('/login', request.url) const redirectResponse = NextResponse.redirect(url) // 为重定向响应也添加缓存控制头 redirectResponse.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0') redirectResponse.headers.set('Pragma', 'no-cache') redirectResponse.headers.set('Expires', '0') return redirectResponse } // 管理员页面需要管理员权限 if (pathname.startsWith('/admin') || pathname.startsWith('/organization')) { const role = String((token as any)?.role || '') if (!['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(role)) { const url = new URL('/', request.url) const redirectResponse = NextResponse.redirect(url) // 为重定向响应也添加缓存控制头 redirectResponse.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0') redirectResponse.headers.set('Pragma', 'no-cache') redirectResponse.headers.set('Expires', '0') return redirectResponse } } } // 检查是否有新版本部署(通过环境变量控制) // 只有在部署新版本时才禁用缓存,否则允许浏览器缓存以提高性能 const disableCache = process.env.DISABLE_CACHE === 'true' const deploymentVersion = process.env.DEPLOYMENT_VERSION || process.env.BUILD_ID || '' // 获取客户端存储的版本号(通过 cookie) const clientVersion = request.cookies.get('app-version')?.value || '' // 判断是否需要禁用缓存:有新版本部署且客户端版本与服务器版本不同 const shouldDisableCache = disableCache && deploymentVersion && clientVersion !== deploymentVersion const response = NextResponse.next() // 如果检测到新版本,设置版本号 cookie if (shouldDisableCache && deploymentVersion) { response.cookies.set('app-version', deploymentVersion, { path: '/', maxAge: 60 * 60 * 24 * 365, // 1年 sameSite: 'lax', }) } // 对于 HTML 页面,根据是否有新版本决定缓存策略 if ( isDepartmentPage(pathname) || pathname === '/materials' || pathname.startsWith('/admin') || pathname.startsWith('/organization') || pathname.startsWith('/share') || pathname.startsWith('/edit') || isAuthEntryPage || pathname.endsWith('.html') ) { if (isDepartmentPage(pathname) || pathname.startsWith('/admin') || isAuthEntryPage || shouldDisableCache) { // 认证页面或有新版本时禁用缓存,避免用户范围数据被共享缓存 response.headers.set('Cache-Control', 'private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0') response.headers.set('Pragma', 'no-cache') response.headers.set('Expires', '0') } else { // 无新版本:允许浏览器缓存,提高性能 response.headers.set('Cache-Control', 'public, max-age=3600, must-revalidate') // 缓存1小时 } } // 对于 JS/CSS 等静态资源,使用版本号或时间戳来强制更新 if (pathname.match(/\.(js|css|json|ico|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$/)) { if (shouldDisableCache) { // 有新版本:禁用缓存 response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0') } else { // 无新版本:长期缓存(Next.js 会自动添加版本号到文件名) response.headers.set('Cache-Control', 'public, max-age=31536000, immutable') } } return response } export const config = { matcher: [ '/', '/assets/material-library/:path*', '/workspaces/:path*', '/login', '/materials', '/organization/:path*', '/admin/:path*', '/api/:path*', '/dingtalk-entry', '/dingtalk-callback', '/performance-survey/:path*', ], }