fix(regression): resolve dogfood findings across marketing, auth, e2e and docs
- Fix list-to-detail navigation on product/service/solution/case pages - Fix soft 404 on service detail by removing (marketing)/loading.tsx and using force-dynamic - Fix contact form submission feedback and news placeholder image handling - Unify SSR/client authentication state in auth.ts - Add "新闻动态" to main navigation - Fix Playwright storageState path and Firefox footer link flakiness - Add E2E coverage for nav dropdown, cases filter and auth token parsing - Update visual regression baselines (desktop/tablet/mobile, chromium/webkit/firefox) - Update README, lessons-learned and add REGRESSION_REPORT_2026-07-27.md - Ignore .lighthouseci/ and heading-hierarchy-report.json
This commit is contained in:
@@ -172,7 +172,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
|
||||
company: formData.subject,
|
||||
});
|
||||
trackConversion('contact_form_submission');
|
||||
setToastMessage(data.successMessage || '');
|
||||
setToastMessage(data.successMessage || '提交成功!我们会尽快与您联系。');
|
||||
setToastType('success');
|
||||
setShowToast(true);
|
||||
setIsSubmitted(true);
|
||||
@@ -373,8 +373,8 @@ function ContactFormContent({ data }: { data: ContactData }) {
|
||||
<div className="w-20 h-20 bg-brand rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle2 className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-ink mb-3">{data.successTitle || ''}</h4>
|
||||
<p className="text-text-secondary">{data.successMessage || ''}</p>
|
||||
<h4 className="text-2xl font-bold text-ink mb-3">{data.successTitle || '提交成功'}</h4>
|
||||
<p className="text-text-secondary">{data.successMessage || '感谢您的留言,我们会尽快与您联系。'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex items-center justify-center">
|
||||
<div className="page-transition-loader">
|
||||
<div className="page-transition-loader__bar" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { getPublishedItemBySlug, getPublishedItems, getAllPublishedSlugs } from
|
||||
import type { NewsItem } from '@/lib/constants/news';
|
||||
import NewsDetailContentV3 from '../news-detail-content-v3';
|
||||
|
||||
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
|
||||
export const revalidate = 3600;
|
||||
// 使用 force-dynamic 确保未找到 slug 返回硬 404,同时允许 CMS 新发布内容在首次请求时动态渲染
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await getAllPublishedSlugs('news');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useMemo, ChangeEvent } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, Calendar, ArrowRight, Newspaper, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
@@ -15,6 +16,7 @@ const categories = ['全部', '公司新闻', '研发动态'];
|
||||
const ITEMS_PER_PAGE = 9;
|
||||
|
||||
function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -22,17 +24,18 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ duration: 0.5, delay: index * 0.08, ease: EASE_OUT }}
|
||||
>
|
||||
<a
|
||||
<StaticLink
|
||||
href={`/news/${newsItem.id}`}
|
||||
className="group relative block border border-border-primary hover:border-brand/30 bg-white transition-all duration-500 hover:-translate-y-2 overflow-hidden h-full"
|
||||
>
|
||||
<div className="absolute top-0 left-0 bottom-0 w-1 bg-brand scale-y-0 group-hover:scale-y-100 transition-transform duration-500 origin-top" />
|
||||
|
||||
{newsItem.image ? (
|
||||
{newsItem.image && !imageError ? (
|
||||
<div className="aspect-video bg-bg-secondary overflow-hidden">
|
||||
<img
|
||||
src={newsItem.image}
|
||||
alt={newsItem.title}
|
||||
onError={() => setImageError(true)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
/>
|
||||
</div>
|
||||
@@ -66,7 +69,7 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
|
||||
<ArrowRight className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-ser
|
||||
import type { Product } from '@/lib/constants/products';
|
||||
import ProductDetailContentV3 from '../product-detail-content-v3';
|
||||
|
||||
export const revalidate = 3600;
|
||||
// 使用 force-dynamic 确保未找到 slug 返回硬 404,同时允许 CMS 新发布内容在首次请求时动态渲染
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await getAllPublishedSlugs('product');
|
||||
|
||||
@@ -5,6 +5,7 @@ import { motion, useInView } from 'framer-motion';
|
||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||
import { EASE_OUT } from '@/components/ui/page-decoration';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { ArrowRight, Package, Cpu, BarChart3, Users, Settings, FileText } from 'lucide-react';
|
||||
import type { Product } from '@/lib/constants/products';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
@@ -149,45 +150,48 @@ function ProductCard({ product }: { product: Product }) {
|
||||
const bundleLabel = product.bundle === 'enterprise' ? '企业套装' : '专业产品';
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
href={`/products/${product.id}`}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
transition={{ duration: 0.6, ease: EASE_OUT }}
|
||||
className="group block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary transition-all duration-500 flex flex-col h-full"
|
||||
>
|
||||
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="text-xs text-text-muted mb-1">{bundleLabel}</div>
|
||||
<h3 className="text-lg font-bold text-text-primary">{product.title}</h3>
|
||||
</div>
|
||||
<span className="px-2 py-1 text-xs bg-brand/10 text-brand" aria-hidden="true">{bundleLabel}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-text-secondary mb-4 leading-relaxed flex-1">
|
||||
{product.scenario || product.description}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
{product.metrics?.map((m) => (
|
||||
<div key={m.label} className="text-center p-2 bg-bg-secondary">
|
||||
<div className="text-lg font-bold text-text-primary">{m.value}</div>
|
||||
<div className="text-xs text-text-muted">{m.label}</div>
|
||||
<StaticLink
|
||||
href={`/products/${product.id}`}
|
||||
className="group block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary transition-all duration-500 flex flex-col h-full"
|
||||
>
|
||||
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="text-xs text-text-muted mb-1">{bundleLabel}</div>
|
||||
<h3 className="text-lg font-bold text-text-primary">{product.title}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="px-2 py-1 text-xs bg-brand/10 text-brand" aria-hidden="true">{bundleLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.capabilities?.map((cap) => (
|
||||
<span key={cap} className="px-2 py-1 text-xs border border-border-primary text-text-secondary">
|
||||
{cap}
|
||||
</span>
|
||||
))}
|
||||
<p className="text-sm text-text-secondary mb-4 leading-relaxed flex-1">
|
||||
{product.scenario || product.description}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
{product.metrics?.map((m) => (
|
||||
<div key={m.label} className="text-center p-2 bg-bg-secondary">
|
||||
<div className="text-lg font-bold text-text-primary">{m.value}</div>
|
||||
<div className="text-xs text-text-muted">{m.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.capabilities?.map((cap) => (
|
||||
<span key={cap} className="px-2 py-1 text-xs border border-border-primary text-text-secondary">
|
||||
{cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.a>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-ser
|
||||
import type { Service } from '@/lib/constants/services';
|
||||
import { ServiceDetailClient } from './client';
|
||||
|
||||
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
|
||||
export const revalidate = 3600;
|
||||
// 使用 force-dynamic 确保未找到 slug 返回硬 404,同时允许 CMS 新发布内容在首次请求时动态渲染
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await getAllPublishedSlugs('service');
|
||||
@@ -38,4 +38,4 @@ export default async function ServiceDetailPage({ params }: { params: Promise<{
|
||||
|
||||
const service = { ...item.data, title: item.data.title || item.title } as unknown as Service;
|
||||
return <ServiceDetailClient service={JSON.parse(JSON.stringify(service))} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowUpRight, CheckCircle2 } from 'lucide-react';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import type { Service } from '@/lib/constants/services';
|
||||
|
||||
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
|
||||
@@ -192,48 +193,51 @@ function ServicesGridSection({ services }: { services?: Service[] }) {
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-px bg-border-primary">
|
||||
{displayServices.map((service, i) => (
|
||||
<motion.a
|
||||
<motion.div
|
||||
key={service.id}
|
||||
href={`/services/${service.id}`}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
transition={{ duration: 0.6, delay: i * 0.08, ease: EASE_OUT }}
|
||||
className="group relative block p-8 sm:p-10 md:p-12 transition-all duration-500 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-transparent hover:border-border-primary"
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-ink group-hover:text-brand transition-colors duration-300 mb-3">
|
||||
{service.title}
|
||||
</h3>
|
||||
<StaticLink
|
||||
href={`/services/${service.id}`}
|
||||
className="group relative block p-8 sm:p-10 md:p-12 transition-all duration-500 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-transparent hover:border-border-primary"
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-ink group-hover:text-brand transition-colors duration-300 mb-3">
|
||||
{service.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-text-secondary leading-relaxed mb-6 text-[15px]">
|
||||
{service.description}
|
||||
</p>
|
||||
<p className="text-text-secondary leading-relaxed mb-6 text-[15px]">
|
||||
{service.description}
|
||||
</p>
|
||||
|
||||
<ul className="space-y-2 mb-6">
|
||||
{service.capabilities.map((cap) => (
|
||||
<li key={cap} className="flex items-start gap-2 text-sm text-text-secondary">
|
||||
<CheckCircle2 className="w-4 h-4 text-brand shrink-0 mt-0.5" />
|
||||
{cap}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className="space-y-2 mb-6">
|
||||
{service.capabilities.map((cap) => (
|
||||
<li key={cap} className="flex items-start gap-2 text-sm text-text-secondary">
|
||||
<CheckCircle2 className="w-4 h-4 text-brand shrink-0 mt-0.5" />
|
||||
{cap}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex gap-6 pt-5 border-t border-border-primary mb-6">
|
||||
{service.metrics.map((m) => (
|
||||
<div key={m.label}>
|
||||
<div className="text-xl font-bold text-ink">{m.value}</div>
|
||||
<div className="text-xs text-text-muted mt-1">{m.label}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-6 pt-5 border-t border-border-primary mb-6">
|
||||
{service.metrics.map((m) => (
|
||||
<div key={m.label}>
|
||||
<div className="text-xl font-bold text-ink">{m.value}</div>
|
||||
<div className="text-xs text-text-muted mt-1">{m.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors duration-300">
|
||||
<span>了解详情</span>
|
||||
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors duration-300">
|
||||
<span>了解详情</span>
|
||||
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.a>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { Solution } from '@/lib/constants/solutions';
|
||||
import type { Product } from '@/lib/constants/products';
|
||||
import { SolutionDetailClient } from './client';
|
||||
|
||||
export const revalidate = 3600;
|
||||
// 使用 force-dynamic 确保未找到 slug 返回硬 404,同时允许 CMS 新发布内容在首次请求时动态渲染
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await getAllPublishedSlugs('solution');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowUpRight, Factory, ShoppingCart, Heart, GraduationCap, Shield, Truck, type LucideIcon } from 'lucide-react';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { type Solution } from '@/lib/constants/solutions';
|
||||
import type { Product } from '@/lib/constants/products';
|
||||
|
||||
@@ -115,55 +116,58 @@ function SolutionCard({ solution, index, products }: { solution: Solution; index
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
href={`/solutions/${solution.id}`}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
transition={{ duration: 0.6, delay: index * 0.08, ease: EASE_OUT }}
|
||||
className="group relative block transition-all duration-500 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-border-primary hover:border-brand/20"
|
||||
>
|
||||
<div className="relative z-10 p-8 sm:p-10 md:p-12">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Icon className="w-5 h-5 text-text-muted" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-bold text-text-primary">
|
||||
{solution.industry}
|
||||
</h3>
|
||||
</div>
|
||||
<StaticLink
|
||||
href={`/solutions/${solution.id}`}
|
||||
className="group relative block transition-all duration-500 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-border-primary hover:border-brand/20"
|
||||
>
|
||||
<div className="relative z-10 p-8 sm:p-10 md:p-12">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Icon className="w-5 h-5 text-text-muted" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-bold text-text-primary">
|
||||
{solution.industry}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-text-muted mb-2">核心痛点</div>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">
|
||||
{solution.painPoints?.join('、')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-text-muted mb-2">核心痛点</div>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">
|
||||
{solution.painPoints?.join('、')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
{solution.outcomes?.map((o) => (
|
||||
<div key={o.label} className="p-2 bg-bg-secondary">
|
||||
<div className="text-base font-bold text-brand">{o.value}</div>
|
||||
<div className="text-xs text-text-muted">{o.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border-primary">
|
||||
<div className="text-xs text-text-muted mb-2">推荐产品组合</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recommendedProductNames.map((name) => (
|
||||
<span key={name} className="px-2 py-1 text-xs bg-bg-secondary text-text-secondary">
|
||||
{name.replace('睿新', '')}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
{solution.outcomes?.map((o) => (
|
||||
<div key={o.label} className="p-2 bg-bg-secondary">
|
||||
<div className="text-base font-bold text-brand">{o.value}</div>
|
||||
<div className="text-xs text-text-muted">{o.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors duration-300 mt-6">
|
||||
<span>了解详情</span>
|
||||
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
|
||||
<div className="pt-4 border-t border-border-primary">
|
||||
<div className="text-xs text-text-muted mb-2">推荐产品组合</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recommendedProductNames.map((name) => (
|
||||
<span key={name} className="px-2 py-1 text-xs bg-bg-secondary text-text-secondary">
|
||||
{name.replace('睿新', '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors duration-300 mt-6">
|
||||
<span>了解详情</span>
|
||||
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.a>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
process.env.JWT_SECRET = 'test-jwt-secret';
|
||||
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({
|
||||
verify: jest.fn(),
|
||||
sign: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.unmock('./auth');
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { authenticateRequest, getTokenFromRequest, getTokenFromCookie } from './auth';
|
||||
import type { JwtPayload } from './auth';
|
||||
|
||||
const mockJwtVerify = jwt.verify as jest.MockedFunction<typeof jwt.verify>;
|
||||
|
||||
type NextRequestInit = NonNullable<ConstructorParameters<typeof NextRequest>[1]>;
|
||||
|
||||
function createRequest(init?: NextRequestInit): NextRequest {
|
||||
const request = new NextRequest('http://localhost/api/admin/models', init);
|
||||
const cookieHeader = init?.headers && 'cookie' in init.headers ? init.headers.cookie : undefined;
|
||||
const cookieMap = new Map<string, string>();
|
||||
if (typeof cookieHeader === 'string') {
|
||||
cookieHeader.split(';').forEach((pair) => {
|
||||
const [name, value] = pair.trim().split('=');
|
||||
if (name && value !== undefined) cookieMap.set(name, value);
|
||||
});
|
||||
}
|
||||
Object.defineProperty(request, 'cookies', {
|
||||
value: {
|
||||
get: (name: string) => {
|
||||
const value = cookieMap.get(name);
|
||||
return value !== undefined ? { name, value } : undefined;
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
describe('getTokenFromRequest', () => {
|
||||
it('returns token from Authorization header', () => {
|
||||
const request = createRequest({
|
||||
headers: { Authorization: 'Bearer valid-token' },
|
||||
});
|
||||
expect(getTokenFromRequest(request)).toBe('valid-token');
|
||||
});
|
||||
|
||||
it('returns null when Authorization header is missing', () => {
|
||||
const request = createRequest();
|
||||
expect(getTokenFromRequest(request)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when Authorization header is not Bearer', () => {
|
||||
const request = createRequest({
|
||||
headers: { Authorization: 'Basic valid-token' },
|
||||
});
|
||||
expect(getTokenFromRequest(request)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenFromCookie', () => {
|
||||
it('returns token from novalon_token cookie', () => {
|
||||
const request = createRequest({
|
||||
headers: { cookie: 'novalon_token=cookie-token; other=value' },
|
||||
});
|
||||
expect(getTokenFromCookie(request)).toBe('cookie-token');
|
||||
});
|
||||
|
||||
it('returns null when novalon_token cookie is missing', () => {
|
||||
const request = createRequest({
|
||||
headers: { cookie: 'other=value' },
|
||||
});
|
||||
expect(getTokenFromCookie(request)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no cookies are present', () => {
|
||||
const request = createRequest();
|
||||
expect(getTokenFromCookie(request)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticateRequest', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('authenticates from Authorization header', () => {
|
||||
const payload: JwtPayload = { userId: 'user-1', username: 'admin', role: 'super_admin' };
|
||||
mockJwtVerify.mockReturnValue(payload as unknown as void);
|
||||
|
||||
const request = createRequest({
|
||||
headers: { Authorization: 'Bearer header-token' },
|
||||
});
|
||||
|
||||
expect(authenticateRequest(request)).toEqual(payload);
|
||||
expect(mockJwtVerify).toHaveBeenCalledWith('header-token', 'test-jwt-secret');
|
||||
});
|
||||
|
||||
it('falls back to cookie when Authorization header is missing', () => {
|
||||
const payload: JwtPayload = { userId: 'user-2', username: 'editor', role: 'editor' };
|
||||
mockJwtVerify.mockReturnValue(payload as unknown as void);
|
||||
|
||||
const request = createRequest({
|
||||
headers: { cookie: 'novalon_token=cookie-token' },
|
||||
});
|
||||
|
||||
expect(authenticateRequest(request)).toEqual(payload);
|
||||
expect(mockJwtVerify).toHaveBeenCalledWith('cookie-token', 'test-jwt-secret');
|
||||
});
|
||||
|
||||
it('prefers Authorization header over cookie when both are present', () => {
|
||||
const payload: JwtPayload = { userId: 'user-3', username: 'admin', role: 'super_admin' };
|
||||
mockJwtVerify.mockReturnValue(payload as unknown as void);
|
||||
|
||||
const request = createRequest({
|
||||
headers: {
|
||||
Authorization: 'Bearer header-token',
|
||||
cookie: 'novalon_token=cookie-token',
|
||||
},
|
||||
});
|
||||
|
||||
authenticateRequest(request);
|
||||
expect(mockJwtVerify).toHaveBeenCalledWith('header-token', 'test-jwt-secret');
|
||||
});
|
||||
|
||||
it('returns null when no token is present', () => {
|
||||
const request = createRequest();
|
||||
expect(authenticateRequest(request)).toBeNull();
|
||||
expect(mockJwtVerify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when token verification fails', () => {
|
||||
mockJwtVerify.mockImplementation(() => {
|
||||
throw new Error('invalid token');
|
||||
});
|
||||
|
||||
const request = createRequest({
|
||||
headers: { Authorization: 'Bearer invalid-token' },
|
||||
});
|
||||
|
||||
expect(authenticateRequest(request)).toBeNull();
|
||||
});
|
||||
});
|
||||
+16
-3
@@ -47,6 +47,8 @@ export function verifyRefreshToken(token: string): JwtPayload {
|
||||
return jwt.verify(token, JWT_REFRESH_SECRET!) as JwtPayload;
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_COOKIE = 'novalon_token';
|
||||
|
||||
// ============ 请求认证 ============
|
||||
|
||||
export function getTokenFromRequest(request: NextRequest): string | null {
|
||||
@@ -57,8 +59,19 @@ export function getTokenFromRequest(request: NextRequest): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求 cookie 中读取 access token。
|
||||
* 中间件(Edge Runtime)与 API 路由(Node Runtime)均使用同一 cookie 名,
|
||||
* 保证 SSR/页面路由与 API 调用的认证状态一致。
|
||||
*/
|
||||
export function getTokenFromCookie(request: NextRequest): string | null {
|
||||
return request.cookies.get(ACCESS_TOKEN_COOKIE)?.value ?? null;
|
||||
}
|
||||
|
||||
export function authenticateRequest(request: NextRequest): JwtPayload | null {
|
||||
const token = getTokenFromRequest(request);
|
||||
// 优先使用 Authorization 头(adminApi 客户端显式携带),同时兼容 cookie 认证
|
||||
// 使中间件与 API 路由共享同一认证来源,避免 SSR/客户端状态不一致。
|
||||
const token = getTokenFromRequest(request) || getTokenFromCookie(request);
|
||||
if (!token) return null;
|
||||
try {
|
||||
return verifyAccessToken(token);
|
||||
@@ -85,11 +98,11 @@ export function setTokenCookie(
|
||||
refreshToken: string,
|
||||
secure = false,
|
||||
): void {
|
||||
response.headers.set('Set-Cookie', buildCookie('novalon_token', accessToken, 86400, secure));
|
||||
response.headers.set('Set-Cookie', buildCookie(ACCESS_TOKEN_COOKIE, accessToken, 86400, secure));
|
||||
response.headers.append('Set-Cookie', buildCookie('novalon_refresh', refreshToken, 604800, secure));
|
||||
}
|
||||
|
||||
export function clearTokenCookie(response: Response, secure = false): void {
|
||||
response.headers.set('Set-Cookie', buildCookie('novalon_token', '', 0, secure));
|
||||
response.headers.set('Set-Cookie', buildCookie(ACCESS_TOKEN_COOKIE, '', 0, secure));
|
||||
response.headers.append('Set-Cookie', buildCookie('novalon_refresh', '', 0, secure));
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export const NAVIGATION_V2: NavigationItemV2[] = [
|
||||
{ id: 'solutions', label: '解决方案', href: '/solutions', hasDropdown: true, dropdownKey: 'solutions' },
|
||||
{ id: 'services', label: '服务', href: '/services' },
|
||||
{ id: 'cases', label: '案例', href: '/cases' },
|
||||
{ id: 'news', label: '新闻动态', href: '/news' },
|
||||
{ id: 'about', label: '关于我们', href: '/about' },
|
||||
{ id: 'contact', label: '联系我们', href: '/contact' },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user