- 看板首页 / :Zen Minimal × Future Organic 融合(用户裁定),编号索引+柔光光晕+GSAP克制动效,白皮书气质 - 素材库首页整体迁移至 /materials(含colocated测试),全站返回素材库链接同步清扫 - 组织架构页上移为顶层 /organization(保留云采石场契约注释),旧路径 /admin/org 服务端跳转;API 迁移 /api/organization(逻辑零改动) - 后台删除组织架构跳转按钮(白屏问题源头消失);middleware 增 /materials 登录门、/organization 管理员门 - 品牌统一:浅色顶栏用 loginLogoUrl(深色图案版),标题「由壹点·品牌中心」;字体自托管 @fontsource - 可访问性收尾(评审fix项):全文对比度过WCAG、焦点环、加载骨架防闪灰、移动端状态可见、::selection - PRODUCT.md/DESIGN.md 记录方向契约;测试基线 1815 通过/6 既有失败(无劣化)
201 lines
7.3 KiB
TypeScript
201 lines
7.3 KiB
TypeScript
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'
|
||
|
||
function getAllowedCorsOrigins(request: NextRequest): Set<string> {
|
||
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 (
|
||
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 (
|
||
pathname === '/' ||
|
||
pathname === '/materials' ||
|
||
pathname.startsWith('/admin') ||
|
||
pathname.startsWith('/organization') ||
|
||
pathname.startsWith('/share') ||
|
||
pathname.startsWith('/edit') ||
|
||
isAuthEntryPage ||
|
||
pathname.endsWith('.html')
|
||
) {
|
||
if (isAuthEntryPage || shouldDisableCache) {
|
||
// 有新版本:禁用缓存,强制浏览器获取最新代码
|
||
response.headers.set('Cache-Control', '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: [
|
||
'/',
|
||
'/login',
|
||
'/materials',
|
||
'/organization/:path*',
|
||
'/admin/:path*',
|
||
'/api/:path*',
|
||
'/dingtalk-entry',
|
||
'/dingtalk-callback',
|
||
'/performance-survey/:path*',
|
||
],
|
||
}
|