Files
novalon-website/docs/plans/2026-03-08-configurable-cms-implementation.md
T
张翔 6a2c4fdae8 docs: 添加可配置化 CMS 系统实施计划
- 详细规划 16 个实施任务
- 包含完整的代码示例和测试用例
- 分 6 个阶段,预计 7-8 天完成
2026-03-08 20:10:50 +08:00

2183 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 可配置化 CMS 系统实施计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**目标:** 为 Novalon Website 构建一个轻量级、可配置的内容管理系统,实现新闻、产品、服务、案例的动态管理和配置。
**架构:** 采用 Next.js API Routes + SQLite (libsql) + Drizzle ORM + NextAuth.js 的技术栈,实现前后端分离的 CMS 架构。管理后台使用客户端渲染(CSR),前端页面使用增量静态再生(ISR)和服务器端渲染(SSR)混合模式。
**技术栈:** Next.js 16, React 19, TypeScript, SQLite (libsql), Drizzle ORM, NextAuth.js, Tiptap, shadcn/ui, Zod, Vitest, Playwright
---
## 前置准备
### 环境要求
- Node.js 18+
- npm / pnpm
- Git
### 设计文档
参考:`docs/plans/2026-03-08-configurable-cms-design.md`
---
## 阶段一:基础架构搭建(预计 2 天)
### Task 1: 安装依赖包
**文件:**
- 修改: `package.json`
**步骤 1: 安装数据库相关依赖**
```bash
npm install drizzle-orm @libsql/client
npm install -D drizzle-kit
```
**步骤 2: 安装认证相关依赖**
```bash
npm install next-auth@beta @auth/drizzle-adapter
npm install bcryptjs
npm install -D @types/bcryptjs
```
**步骤 3: 安装富文本编辑器**
```bash
npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link @tiptap/pm
```
**步骤 4: 安装其他工具库**
```bash
npm install nanoid date-fns
npm install -D @types/nanoid
```
**步骤 5: 验证安装**
运行: `npm list drizzle-orm next-auth @tiptap/react`
预期: 所有依赖包版本正确显示
**步骤 6: 提交**
```bash
git add package.json package-lock.json
git commit -m "chore: 添加 CMS 系统所需依赖包"
```
---
### Task 2: 配置数据库连接
**文件:**
- 创建: `src/db/index.ts`
- 创建: `.env.local`
- 修改: `.gitignore`
**步骤 1: 创建环境变量文件**
创建 `.env.local`:
```env
# Database
DATABASE_URL="file:./data.db"
# NextAuth
NEXTAUTH_SECRET="your-super-secret-key-change-in-production"
NEXTAUTH_URL="http://localhost:3000"
# Admin (初始管理员账号)
ADMIN_EMAIL="admin@novalon.cn"
ADMIN_PASSWORD="admin123456"
```
**步骤 2: 更新 .gitignore**
`.gitignore` 中添加:
```
# Database
*.db
*.db-journal
data.db
# Environment
.env.local
.env.*.local
```
**步骤 3: 创建数据库连接文件**
创建 `src/db/index.ts`:
```typescript
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
const client = createClient({
url: process.env.DATABASE_URL || 'file:./data.db',
});
export const db = drizzle(client);
```
**步骤 4: 验证数据库连接**
创建临时测试文件 `src/db/test-connection.ts`:
```typescript
import { db } from './index';
async function testConnection() {
try {
await db.run('SELECT 1');
console.log('✅ Database connection successful');
} catch (error) {
console.error('❌ Database connection failed:', error);
}
}
testConnection();
```
运行: `npx tsx src/db/test-connection.ts`
预期: 输出 "✅ Database connection successful"
**步骤 5: 删除测试文件**
```bash
rm src/db/test-connection.ts
```
**步骤 6: 提交**
```bash
git add src/db/index.ts .gitignore
git commit -m "feat: 配置数据库连接"
```
---
### Task 3: 定义数据库 Schema
**文件:**
- 创建: `src/db/schema.ts`
- 创建: `drizzle.config.ts`
**步骤 1: 创建 Schema 文件**
创建 `src/db/schema.ts`:
```typescript
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash'),
name: text('name').notNull(),
role: text('role', { enum: ['admin', 'editor', 'viewer'] }).notNull().default('editor'),
avatar: text('avatar'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const content = sqliteTable('content', {
id: text('id').primaryKey(),
type: text('type', { enum: ['news', 'product', 'service', 'case'] }).notNull(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
excerpt: text('excerpt'),
content: text('content').notNull(),
coverImage: text('cover_image'),
category: text('category'),
tags: text('tags', { mode: 'json' }).$type<string[]>(),
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
publishedAt: integer('published_at', { mode: 'timestamp' }),
authorId: text('author_id').notNull().references(() => users.id),
sortOrder: integer('sort_order').default(0),
metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const contentVersions = sqliteTable('content_versions', {
id: text('id').primaryKey(),
contentId: text('content_id').notNull().references(() => content.id),
version: integer('version').notNull(),
title: text('title').notNull(),
content: text('content').notNull(),
changes: text('changes', { mode: 'json' }).$type<Record<string, any>>(),
changedBy: text('changed_by').notNull().references(() => users.id),
changedAt: integer('changed_at', { mode: 'timestamp' }).notNull(),
});
export const siteConfig = sqliteTable('site_config', {
id: text('id').primaryKey(),
key: text('key').notNull().unique(),
value: text('value', { mode: 'json' }).notNull(),
category: text('category', { enum: ['feature', 'style', 'seo', 'general'] }).notNull(),
description: text('description'),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
updatedBy: text('updated_by').references(() => users.id),
});
export const auditLogs = sqliteTable('audit_logs', {
id: text('id').primaryKey(),
userId: text('user_id').references(() => users.id),
action: text('action', { enum: ['create', 'update', 'delete', 'publish', 'login'] }).notNull(),
resourceType: text('resource_type').notNull(),
resourceId: text('resource_id'),
details: text('details', { mode: 'json' }).$type<Record<string, any>>(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
content: many(content),
versions: many(contentVersions),
logs: many(auditLogs),
}));
export const contentRelations = relations(content, ({ one, many }) => ({
author: one(users, {
fields: [content.authorId],
references: [users.id],
}),
versions: many(contentVersions),
}));
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Content = typeof content.$inferSelect;
export type NewContent = typeof content.$inferInsert;
export type SiteConfig = typeof siteConfig.$inferSelect;
export type NewSiteConfig = typeof siteConfig.$inferInsert;
```
**步骤 2: 创建 Drizzle 配置文件**
创建 `drizzle.config.ts`:
```typescript
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './drizzle',
driver: 'libsql',
dbCredentials: {
url: process.env.DATABASE_URL || 'file:./data.db',
},
} satisfies Config;
```
**步骤 3: 更新 package.json scripts**
`package.json``scripts` 中添加:
```json
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
}
}
```
**步骤 4: 验证 Schema 定义**
运行: `npm run db:generate`
预期: 在 `drizzle/` 目录生成迁移文件
**步骤 5: 提交**
```bash
git add src/db/schema.ts drizzle.config.ts package.json
git commit -m "feat: 定义数据库 Schema"
```
---
### Task 4: 生成并执行数据库迁移
**文件:**
- 创建: `drizzle/0000_initial_migration.sql` (自动生成)
- 创建: `data.db` (自动创建)
**步骤 1: 生成迁移文件**
运行: `npm run db:generate`
预期: 生成 SQL 迁移文件
**步骤 2: 执行迁移**
运行: `npm run db:push`
预期: 创建 `data.db` 文件,包含所有表
**步骤 3: 验证表结构**
运行: `sqlite3 data.db ".tables"`
预期: 显示所有表名 (users, content, content_versions, site_config, audit_logs)
**步骤 4: 提交**
```bash
git add drizzle/
git commit -m "feat: 生成数据库迁移文件"
```
---
### Task 5: 创建数据库种子数据
**文件:**
- 创建: `src/db/seed.ts`
**步骤 1: 创建种子数据脚本**
创建 `src/db/seed.ts`:
```typescript
import { db } from './index';
import { users, siteConfig } from './schema';
import { nanoid } from 'nanoid';
import bcrypt from 'bcryptjs';
async function seed() {
console.log('🌱 开始种子数据...');
// 创建管理员用户
const hashedPassword = await bcrypt.hash('admin123456', 10);
await db.insert(users).values({
id: nanoid(),
email: 'admin@novalon.cn',
passwordHash: hashedPassword,
name: '系统管理员',
role: 'admin',
createdAt: new Date(),
updatedAt: new Date(),
});
console.log('✅ 创建管理员用户: admin@novalon.cn');
// 创建默认配置
const defaultConfigs = [
{
id: nanoid(),
key: 'feature_news',
value: {
enabled: true,
displayCount: 6,
categories: ['公司新闻', '产品发布', '合作动态', '行业资讯'],
sortOrder: 'desc',
},
category: 'feature',
description: '新闻模块配置',
updatedAt: new Date(),
},
{
id: nanoid(),
key: 'feature_products',
value: {
enabled: true,
showPricing: true,
featuredProducts: ['erp', 'crm'],
},
category: 'feature',
description: '产品模块配置',
updatedAt: new Date(),
},
{
id: nanoid(),
key: 'feature_services',
value: {
enabled: true,
items: ['software', 'cloud', 'data', 'security'],
},
category: 'feature',
description: '服务模块配置',
updatedAt: new Date(),
},
{
id: nanoid(),
key: 'seo_default',
value: {
title: '四川睿新致远科技有限公司 - 企业数字化转型服务商',
description: '以智慧连接数字趋势,以伙伴身份陪您成长——您的数字化转型同行者',
keywords: ['数字化转型', '软件开发', '云服务', '数据分析', '信息安全'],
},
category: 'seo',
description: '默认 SEO 配置',
updatedAt: new Date(),
},
];
for (const config of defaultConfigs) {
await db.insert(siteConfig).values(config);
console.log(`✅ 创建配置: ${config.key}`);
}
console.log('🎉 种子数据完成!');
}
seed().catch((error) => {
console.error('❌ 种子数据失败:', error);
process.exit(1);
});
```
**步骤 2: 执行种子数据**
运行: `npm run db:seed`
预期: 创建管理员用户和默认配置
**步骤 3: 验证数据**
运行: `sqlite3 data.db "SELECT email, role FROM users"`
预期: 显示管理员邮箱和角色
**步骤 4: 提交**
```bash
git add src/db/seed.ts
git commit -m "feat: 添加数据库种子数据脚本"
```
---
### Task 6: 配置 NextAuth.js
**文件:**
- 创建: `src/app/api/auth/[...nextauth]/route.ts`
- 创建: `src/lib/auth.ts`
- 修改: `src/app/layout.tsx`
- 创建: `src/providers/session-provider.tsx`
**步骤 1: 创建认证配置**
创建 `src/lib/auth.ts`:
```typescript
import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import EmailProvider from 'next-auth/providers/email';
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: '邮箱密码',
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await db.select().from(users).where(eq(users.email, credentials.email)).limit(1);
if (user.length === 0) {
return null;
}
const isValid = await bcrypt.compare(credentials.password, user[0].passwordHash || '');
if (!isValid) {
return null;
}
return {
id: user[0].id,
email: user[0].email,
name: user[0].name,
role: user[0].role,
};
},
}),
EmailProvider({
server: {
host: process.env.SMTP_HOST || 'smtp.example.com',
port: parseInt(process.env.SMTP_PORT || '587'),
auth: {
user: process.env.SMTP_USER || '',
pass: process.env.SMTP_PASSWORD || '',
},
},
from: process.env.SMTP_FROM || 'noreply@novalon.cn',
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
pages: {
signIn: '/admin/login',
error: '/admin/login',
},
session: {
strategy: 'jwt',
},
};
```
**步骤 2: 创建 API Route**
创建 `src/app/api/auth/[...nextauth]/route.ts`:
```typescript
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
```
**步骤 3: 创建 Session Provider**
创建 `src/providers/session-provider.tsx`:
```typescript
'use client';
import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react';
import { ReactNode } from 'react';
export function SessionProvider({ children }: { children: ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
}
```
**步骤 4: 更新根布局**
修改 `src/app/layout.tsx`, 在 `<body>` 内添加 SessionProvider:
```typescript
import { SessionProvider } from '@/providers/session-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body>
<SessionProvider>
{children}
</SessionProvider>
</body>
</html>
);
}
```
**步骤 5: 创建类型定义**
创建 `src/types/next-auth.d.ts`:
```typescript
import { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession['user'];
}
interface User {
role: string;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
role: string;
}
}
```
**步骤 6: 提交**
```bash
git add src/lib/auth.ts src/app/api/auth/ src/providers/ src/types/next-auth.d.ts src/app/layout.tsx
git commit -m "feat: 配置 NextAuth.js 认证系统"
```
---
### Task 7: 创建权限检查工具
**文件:**
- 创建: `src/lib/auth/permissions.ts`
- 创建: `src/lib/auth/middleware.ts`
**步骤 1: 定义权限矩阵**
创建 `src/lib/auth/permissions.ts`:
```typescript
export const PERMISSIONS = {
admin: {
content: ['create', 'read', 'update', 'delete', 'publish'],
config: ['read', 'update'],
users: ['create', 'read', 'update', 'delete'],
logs: ['read'],
},
editor: {
content: ['create', 'read', 'update', 'publish'],
config: ['read'],
users: [],
logs: ['read'],
},
viewer: {
content: ['read'],
config: ['read'],
users: [],
logs: [],
},
} as const;
export type Role = keyof typeof PERMISSIONS;
export type Resource = keyof typeof PERMISSIONS.admin;
export type Action = 'create' | 'read' | 'update' | 'delete' | 'publish';
export function hasPermission(
role: Role,
resource: Resource,
action: Action
): boolean {
const permissions = PERMISSIONS[role];
if (!permissions) return false;
const resourcePermissions = permissions[resource];
if (!resourcePermissions) return false;
return resourcePermissions.includes(action as never);
}
```
**步骤 2: 创建权限检查 Hook**
创建 `src/lib/auth/check-permission.ts`:
```typescript
import { getServerSession } from 'next-auth';
import { authOptions } from './auth';
import { hasPermission, Role, Resource, Action } from './permissions';
export async function checkPermission(
resource: Resource,
action: Action
): Promise<{ allowed: boolean; userId?: string; role?: Role }> {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return { allowed: false };
}
const userRole = session.user.role as Role;
const allowed = hasPermission(userRole, resource, action);
return {
allowed,
userId: session.user.id,
role: userRole,
};
}
export async function requirePermission(
resource: Resource,
action: Action
): Promise<{ userId: string; role: Role }> {
const result = await checkPermission(resource, action);
if (!result.allowed) {
throw new Error('无权限执行此操作');
}
return {
userId: result.userId!,
role: result.role!,
};
}
```
**步骤 3: 创建测试**
创建 `src/lib/auth/__tests__/permissions.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { hasPermission } from '../permissions';
describe('Permissions', () => {
it('should allow admin to do everything', () => {
expect(hasPermission('admin', 'content', 'create')).toBe(true);
expect(hasPermission('admin', 'config', 'update')).toBe(true);
expect(hasPermission('admin', 'users', 'delete')).toBe(true);
});
it('should restrict editor from managing users', () => {
expect(hasPermission('editor', 'content', 'create')).toBe(true);
expect(hasPermission('editor', 'users', 'create')).toBe(false);
});
it('should restrict viewer to read-only', () => {
expect(hasPermission('viewer', 'content', 'read')).toBe(true);
expect(hasPermission('viewer', 'content', 'create')).toBe(false);
expect(hasPermission('viewer', 'config', 'update')).toBe(false);
});
});
```
**步骤 4: 运行测试**
运行: `npm test src/lib/auth/__tests__/permissions.test.ts`
预期: 所有测试通过
**步骤 5: 提交**
```bash
git add src/lib/auth/
git commit -m "feat: 创建权限检查工具和测试"
```
---
## 阶段二:内容管理 API(预计 2 天)
### Task 8: 创建内容 CRUD API
**文件:**
- 创建: `src/app/api/v1/content/route.ts`
- 创建: `src/app/api/v1/content/[id]/route.ts`
- 创建: `src/lib/validators/content.ts`
**步骤 1: 创建验证 Schema**
创建 `src/lib/validators/content.ts`:
```typescript
import { z } from 'zod';
export const ContentCreateSchema = z.object({
type: z.enum(['news', 'product', 'service', 'case']),
title: z.string().min(1).max(200),
slug: z.string().min(1).max(200).regex(/^[a-z0-9-]+$/),
excerpt: z.string().max(500).optional(),
content: z.string().min(1),
coverImage: z.string().url().optional(),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
status: z.enum(['draft', 'published', 'archived']).default('draft'),
publishedAt: z.coerce.date().optional(),
sortOrder: z.number().int().optional(),
metadata: z.record(z.any()).optional(),
});
export const ContentUpdateSchema = ContentCreateSchema.partial();
export const ContentQuerySchema = z.object({
type: z.enum(['news', 'product', 'service', 'case']).optional(),
status: z.enum(['draft', 'published', 'archived']).optional(),
category: z.string().optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(10),
sort: z.string().default('-publishedAt'),
});
```
**步骤 2: 创建列表 API**
创建 `src/app/api/v1/content/route.ts`:
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { content } from '@/db/schema';
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { ContentQuerySchema, ContentCreateSchema } from '@/lib/validators/content';
import { checkPermission } from '@/lib/auth/check-permission';
import { nanoid } from 'nanoid';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const query = ContentQuerySchema.parse(Object.fromEntries(searchParams));
const whereConditions = [];
if (query.type) whereConditions.push(eq(content.type, query.type));
if (query.status) whereConditions.push(eq(content.status, query.status));
if (query.category) whereConditions.push(eq(content.category, query.category));
const sortField = query.sort.startsWith('-') ? query.sort.slice(1) : query.sort;
const sortDirection = query.sort.startsWith('-') ? desc : asc;
const [items, [{ count }]] = await Promise.all([
db.select()
.from(content)
.where(whereConditions.length > 0 ? and(...whereConditions) : undefined)
.orderBy(sortDirection(content[sortField as keyof typeof content] as any))
.limit(query.pageSize)
.offset((query.page - 1) * query.pageSize),
db.select({ count: sql<number>`count(*)` })
.from(content)
.where(whereConditions.length > 0 ? and(...whereConditions) : undefined),
]);
return NextResponse.json({
success: true,
data: items,
meta: {
total: count,
page: query.page,
pageSize: query.pageSize,
totalPages: Math.ceil(count / query.pageSize),
},
});
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'INVALID_QUERY', message: error.message } },
{ status: 400 }
);
}
}
export async function POST(request: NextRequest) {
try {
const permission = await checkPermission('content', 'create');
if (!permission.allowed) {
return NextResponse.json(
{ success: false, error: { code: 'FORBIDDEN', message: '无权限创建内容' } },
{ status: 403 }
);
}
const body = await request.json();
const validatedData = ContentCreateSchema.parse(body);
const newContent = await db.insert(content).values({
...validatedData,
id: nanoid(),
authorId: permission.userId!,
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: validatedData.status === 'published' ? new Date() : undefined,
}).returning();
return NextResponse.json({ success: true, data: newContent[0] }, { status: 201 });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'CREATE_FAILED', message: error.message } },
{ status: 500 }
);
}
}
```
**步骤 3: 创建单个内容 API**
创建 `src/app/api/v1/content/[id]/route.ts`:
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { content } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { ContentUpdateSchema } from '@/lib/validators/content';
import { checkPermission } from '@/lib/auth/check-permission';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const item = await db.select()
.from(content)
.where(eq(content.id, params.id))
.limit(1);
if (item.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NOT_FOUND', message: '内容不存在' } },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: item[0] });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'FETCH_FAILED', message: error.message } },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const permission = await checkPermission('content', 'update');
if (!permission.allowed) {
return NextResponse.json(
{ success: false, error: { code: 'FORBIDDEN', message: '无权限更新内容' } },
{ status: 403 }
);
}
const body = await request.json();
const validatedData = ContentUpdateSchema.parse(body);
const updated = await db.update(content)
.set({
...validatedData,
updatedAt: new Date(),
publishedAt: validatedData.status === 'published' ? new Date() : undefined,
})
.where(eq(content.id, params.id))
.returning();
if (updated.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NOT_FOUND', message: '内容不存在' } },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: updated[0] });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'UPDATE_FAILED', message: error.message } },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const permission = await checkPermission('content', 'delete');
if (!permission.allowed) {
return NextResponse.json(
{ success: false, error: { code: 'FORBIDDEN', message: '无权限删除内容' } },
{ status: 403 }
);
}
const deleted = await db.delete(content)
.where(eq(content.id, params.id))
.returning();
if (deleted.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NOT_FOUND', message: '内容不存在' } },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: deleted[0] });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'DELETE_FAILED', message: error.message } },
{ status: 500 }
);
}
}
```
**步骤 4: 创建 API 测试**
创建 `src/app/api/v1/content/__tests__/route.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GET, POST } from '../route';
describe('Content API', () => {
it('should return empty list initially', async () => {
const request = new NextRequest('http://localhost:3000/api/v1/content');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(data.data).toEqual([]);
});
it('should create content with valid data', async () => {
const request = new NextRequest('http://localhost:3000/api/v1/content', {
method: 'POST',
body: JSON.stringify({
type: 'news',
title: '测试新闻',
slug: 'test-news',
content: '这是测试内容',
}),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.success).toBe(true);
expect(data.data.title).toBe('测试新闻');
});
});
```
**步骤 5: 运行测试**
运行: `npm test src/app/api/v1/content/__tests__/route.test.ts`
预期: 测试通过(可能需要 mock 数据库)
**步骤 6: 提交**
```bash
git add src/app/api/v1/content/ src/lib/validators/
git commit -m "feat: 创建内容 CRUD API"
```
---
### Task 9: 创建配置管理 API
**文件:**
- 创建: `src/app/api/v1/config/route.ts`
- 创建: `src/app/api/v1/config/[key]/route.ts`
**步骤 1: 创建配置列表 API**
创建 `src/app/api/v1/config/route.ts`:
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { siteConfig } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { checkPermission } from '@/lib/auth/check-permission';
export async function GET() {
try {
const configs = await db.select().from(siteConfig);
return NextResponse.json({
success: true,
data: configs,
});
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'FETCH_FAILED', message: error.message } },
{ status: 500 }
);
}
}
```
**步骤 2: 创建单个配置 API**
创建 `src/app/api/v1/config/[key]/route.ts`:
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { siteConfig } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { checkPermission } from '@/lib/auth/check-permission';
import { z } from 'zod';
const ConfigUpdateSchema = z.object({
value: z.any(),
});
export async function GET(
request: NextRequest,
{ params }: { params: { key: string } }
) {
try {
const config = await db.select()
.from(siteConfig)
.where(eq(siteConfig.key, params.key))
.limit(1);
if (config.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NOT_FOUND', message: '配置不存在' } },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: config[0] });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'FETCH_FAILED', message: error.message } },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { key: string } }
) {
try {
const permission = await checkPermission('config', 'update');
if (!permission.allowed) {
return NextResponse.json(
{ success: false, error: { code: 'FORBIDDEN', message: '无权限更新配置' } },
{ status: 403 }
);
}
const body = await request.json();
const { value } = ConfigUpdateSchema.parse(body);
const updated = await db.update(siteConfig)
.set({
value,
updatedAt: new Date(),
updatedBy: permission.userId,
})
.where(eq(siteConfig.key, params.key))
.returning();
if (updated.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NOT_FOUND', message: '配置不存在' } },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: updated[0] });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: { code: 'UPDATE_FAILED', message: error.message } },
{ status: 500 }
);
}
}
```
**步骤 3: 提交**
```bash
git add src/app/api/v1/config/
git commit -m "feat: 创建配置管理 API"
```
---
## 阶段三:管理后台界面(预计 2 天)
### Task 10: 创建管理后台布局
**文件:**
- 创建: `src/app/admin/layout.tsx`
- 创建: `src/components/admin/sidebar.tsx`
- 创建: `src/components/admin/header.tsx`
**步骤 1: 创建侧边栏组件**
创建 `src/components/admin/sidebar.tsx`:
```typescript
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
FileText,
Package,
Briefcase,
Trophy,
Settings,
Users,
History,
} from 'lucide-react';
const menuItems = [
{ icon: LayoutDashboard, label: '仪表盘', href: '/admin' },
{ icon: FileText, label: '新闻管理', href: '/admin/content/news' },
{ icon: Package, label: '产品管理', href: '/admin/content/products' },
{ icon: Briefcase, label: '服务管理', href: '/admin/content/services' },
{ icon: Trophy, label: '案例管理', href: '/admin/content/cases' },
{ icon: Settings, label: '配置中心', href: '/admin/config' },
{ icon: Users, label: '用户管理', href: '/admin/users' },
{ icon: History, label: '操作日志', href: '/admin/logs' },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="w-64 bg-white border-r border-gray-200 min-h-screen">
<div className="p-6">
<h1 className="text-xl font-bold text-gray-900"></h1>
</div>
<nav className="px-4 space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-[#C41E3A] text-white'
: 'text-gray-700 hover:bg-gray-100'
)}
>
<Icon className="w-5 h-5" />
{item.label}
</Link>
);
})}
</nav>
</aside>
);
}
```
**步骤 2: 创建顶部栏组件**
创建 `src/components/admin/header.tsx`:
```typescript
'use client';
import { useSession, signOut } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import { Bell, User, LogOut } from 'lucide-react';
import Link from 'next/link';
export function Header() {
const { data: session } = useSession();
return (
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
<div className="flex items-center gap-4">
<Link href="/" className="text-sm text-gray-600 hover:text-gray-900">
</Link>
</div>
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon">
<Bell className="w-5 h-5" />
</Button>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-gray-600" />
</div>
<div className="text-sm">
<p className="font-medium">{session?.user?.name}</p>
<p className="text-xs text-gray-500">{session?.user?.role}</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => signOut({ callbackUrl: '/admin/login' })}
>
<LogOut className="w-4 h-4" />
</Button>
</div>
</div>
</header>
);
}
```
**步骤 3: 创建管理后台布局**
创建 `src/app/admin/layout.tsx`:
```typescript
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { Sidebar } from '@/components/admin/sidebar';
import { Header } from '@/components/admin/header';
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/admin/login');
}
return (
<div className="flex min-h-screen bg-gray-50">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />
<main className="flex-1 p-6">{children}</main>
</div>
</div>
);
}
```
**步骤 4: 提交**
```bash
git add src/app/admin/layout.tsx src/components/admin/
git commit -m "feat: 创建管理后台布局"
```
---
### Task 11: 创建登录页面
**文件:**
- 创建: `src/app/admin/login/page.tsx`
**步骤 1: 创建登录页面**
创建 `src/app/admin/login/page.tsx`:
```typescript
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Mail, Lock, ArrowRight } from 'lucide-react';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('邮箱或密码错误');
} else {
router.push('/admin');
router.refresh();
}
} catch (err) {
setError('登录失败,请重试');
} finally {
setIsLoading(false);
}
};
const handleMagicLink = async () => {
if (!email) {
setError('请输入邮箱地址');
return;
}
setIsLoading(true);
try {
await signIn('email', { email, redirect: false });
setError('登录链接已发送到您的邮箱');
} catch (err) {
setError('发送失败,请重试');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold"></CardTitle>
<CardDescription>使 Magic Link </CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="email"
placeholder="admin@novalon.cn"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
/>
</div>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 p-3 rounded-lg">{error}</p>
)}
<Button
type="submit"
className="w-full bg-[#C41E3A] hover:bg-[#A01830]"
disabled={isLoading}
>
{isLoading ? '登录中...' : '登录'}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500"></span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleMagicLink}
disabled={isLoading}
>
Magic Link
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
```
**步骤 2: 测试登录功能**
运行: `npm run dev`
访问: `http://localhost:3000/admin/login`
使用: `admin@novalon.cn` / `admin123456` 登录
预期: 成功登录并跳转到管理后台
**步骤 3: 提交**
```bash
git add src/app/admin/login/
git commit -m "feat: 创建管理后台登录页面"
```
---
### Task 12: 创建仪表盘页面
**文件:**
- 创建: `src/app/admin/page.tsx`
- 创建: `src/components/admin/stats-card.tsx`
**步骤 1: 创建统计卡片组件**
创建 `src/components/admin/stats-card.tsx`:
```typescript
import { Card, CardContent } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
interface StatsCardProps {
title: string;
value: string | number;
icon: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
}
export function StatsCard({ title, value, icon: Icon, trend }: StatsCardProps) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-3xl font-bold text-gray-900 mt-2">{value}</p>
{trend && (
<p className={`text-sm mt-2 ${trend.isPositive ? 'text-green-600' : 'text-red-600'}`}>
{trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}%
</p>
)}
</div>
<div className="w-12 h-12 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center">
<Icon className="w-6 h-6 text-[#C41E3A]" />
</div>
</div>
</CardContent>
</Card>
);
}
```
**步骤 2: 创建仪表盘页面**
创建 `src/app/admin/page.tsx`:
```typescript
import { db } from '@/db';
import { content, users } from '@/db/schema';
import { sql } from 'drizzle-orm';
import { StatsCard } from '@/components/admin/stats-card';
import { FileText, Package, Briefcase, Users } from 'lucide-react';
export default async function DashboardPage() {
const [newsCount, productsCount, servicesCount, usersCount] = await Promise.all([
db.select({ count: sql<number>`count(*)` }).from(content).where(sql`type = 'news'`),
db.select({ count: sql<number>`count(*)` }).from(content).where(sql`type = 'product'`),
db.select({ count: sql<number>`count(*)` }).from(content).where(sql`type = 'service'`),
db.select({ count: sql<number>`count(*)` }).from(users),
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1"></p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="新闻数量"
value={newsCount[0].count}
icon={FileText}
/>
<StatsCard
title="产品数量"
value={productsCount[0].count}
icon={Package}
/>
<StatsCard
title="服务数量"
value={servicesCount[0].count}
icon={Briefcase}
/>
<StatsCard
title="用户数量"
value={usersCount[0].count}
icon={Users}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-lg border p-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<p className="text-gray-500 text-sm"></p>
</div>
<div className="bg-white rounded-lg border p-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-2 gap-4">
<a href="/admin/content/news/create" className="p-4 border rounded-lg hover:bg-gray-50 transition-colors">
<FileText className="w-6 h-6 text-[#C41E3A] mb-2" />
<p className="font-medium"></p>
</a>
<a href="/admin/content/products/create" className="p-4 border rounded-lg hover:bg-gray-50 transition-colors">
<Package className="w-6 h-6 text-[#C41E3A] mb-2" />
<p className="font-medium"></p>
</a>
</div>
</div>
</div>
</div>
);
}
```
**步骤 3: 提交**
```bash
git add src/app/admin/page.tsx src/components/admin/stats-card.tsx
git commit -m "feat: 创建管理后台仪表盘页面"
```
---
## 阶段四:内容管理界面(预计 1 天)
### Task 13: 创建内容列表页面
**文件:**
- 创建: `src/app/admin/content/news/page.tsx`
- 创建: `src/components/admin/content-table.tsx`
**步骤 1: 创建内容表格组件**
创建 `src/components/admin/content-table.tsx`:
```typescript
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Edit, Trash2, Eye } from 'lucide-react';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
interface ContentItem {
id: string;
title: string;
category?: string;
status: string;
publishedAt?: Date;
createdAt: Date;
}
interface ContentTableProps {
data: ContentItem[];
type: 'news' | 'product' | 'service' | 'case';
onDelete: (id: string) => void;
}
export function ContentTable({ data, type, onDelete }: ContentTableProps) {
const getStatusBadge = (status: string) => {
const variants: Record<string, 'default' | 'secondary' | 'outline'> = {
published: 'default',
draft: 'secondary',
archived: 'outline',
};
const labels: Record<string, string> = {
published: '已发布',
draft: '草稿',
archived: '已归档',
};
return (
<Badge variant={variants[status] || 'secondary'}>
{labels[status] || status}
</Badge>
);
};
return (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.title}</TableCell>
<TableCell>{item.category || '-'}</TableCell>
<TableCell>{getStatusBadge(item.status)}</TableCell>
<TableCell>
{item.publishedAt
? format(new Date(item.publishedAt), 'yyyy-MM-dd', { locale: zhCN })
: '-'}
</TableCell>
<TableCell>
{format(new Date(item.createdAt), 'yyyy-MM-dd', { locale: zhCN })}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button size="sm" variant="ghost" asChild>
<Link href={`/admin/content/${type}/${item.id}/edit`}>
<Edit className="w-4 h-4" />
</Link>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(item.id)}
>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
```
**步骤 2: 创建新闻列表页面**
创建 `src/app/admin/content/news/page.tsx`:
```typescript
import { db } from '@/db';
import { content } from '@/db/schema';
import { eq, desc } from 'drizzle-orm';
import { Button } from '@/components/ui/button';
import { ContentTable } from '@/components/admin/content-table';
import { Plus } from 'lucide-react';
import Link from 'next/link';
export default async function NewsListPage() {
const news = await db.select()
.from(content)
.where(eq(content.type, 'news'))
.orderBy(desc(content.createdAt));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1"></p>
</div>
<Button asChild className="bg-[#C41E3A] hover:bg-[#A01830]">
<Link href="/admin/content/news/create">
<Plus className="w-4 h-4 mr-2" />
</Link>
</Button>
</div>
<div className="bg-white rounded-lg border">
<ContentTable
data={news}
type="news"
onDelete={async (id) => {
'use server';
await db.delete(content).where(eq(content.id, id));
}}
/>
</div>
</div>
);
}
```
**步骤 3: 提交**
```bash
git add src/app/admin/content/news/ src/components/admin/content-table.tsx
git commit -m "feat: 创建内容列表页面"
```
---
## 阶段五:测试和部署(预计 1 天)
### Task 14: 编写 E2E 测试
**文件:**
- 创建: `e2e/tests/admin/login.spec.ts`
- 创建: `e2e/tests/admin/content.spec.ts`
**步骤 1: 创建登录测试**
创建 `e2e/tests/admin/login.spec.ts`:
```typescript
import { test, expect } from '@playwright/test';
test.describe('管理后台登录', () => {
test('应该显示登录页面', async ({ page }) => {
await page.goto('/admin/login');
await expect(page.locator('h2')).toContainText('管理后台登录');
});
test('应该成功登录', async ({ page }) => {
await page.goto('/admin/login');
await page.fill('input[type="email"]', 'admin@novalon.cn');
await page.fill('input[type="password"]', 'admin123456');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/admin');
await expect(page.locator('h1')).toContainText('仪表盘');
});
test('应该显示错误提示', async ({ page }) => {
await page.goto('/admin/login');
await page.fill('input[type="email"]', 'wrong@example.com');
await page.fill('input[type="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('text=邮箱或密码错误')).toBeVisible();
});
});
```
**步骤 2: 创建内容管理测试**
创建 `e2e/tests/admin/content.spec.ts`:
```typescript
import { test, expect } from '@playwright/test';
test.describe('内容管理', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin/login');
await page.fill('input[type="email"]', 'admin@novalon.cn');
await page.fill('input[type="password"]', 'admin123456');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/admin');
});
test('应该显示新闻列表', async ({ page }) => {
await page.click('text=新闻管理');
await expect(page).toHaveURL('/admin/content/news');
await expect(page.locator('h1')).toContainText('新闻管理');
});
test('应该创建新闻', async ({ page }) => {
await page.click('text=新闻管理');
await page.click('text=发布新闻');
await page.fill('input[name="title"]', '测试新闻标题');
await page.fill('input[name="slug"]', 'test-news-slug');
await page.fill('textarea[name="excerpt"]', '这是测试新闻摘要');
await page.fill('[name="content"]', '这是测试新闻内容');
await page.click('button[type="submit"]');
await expect(page.locator('text=创建成功')).toBeVisible();
});
});
```
**步骤 3: 运行测试**
运行: `cd e2e && npm test tests/admin/`
预期: 所有测试通过
**步骤 4: 提交**
```bash
git add e2e/tests/admin/
git commit -m "test: 添加管理后台 E2E 测试"
```
---
### Task 15: 性能优化和安全检查
**文件:**
- 修改: `src/app/api/v1/content/route.ts`
- 创建: `src/middleware.ts`
**步骤 1: 添加 API 速率限制**
创建 `src/middleware.ts`:
```typescript
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const rateLimit = new Map<string, { count: number; resetTime: number }>();
export function middleware(request: NextRequest) {
// 只对 API 路由进行速率限制
if (request.nextUrl.pathname.startsWith('/api/')) {
const ip = request.ip || 'unknown';
const now = Date.now();
const windowMs = 60 * 1000; // 1 分钟
const maxRequests = 100; // 每分钟最多 100 次请求
const userLimit = rateLimit.get(ip);
if (userLimit) {
if (now > userLimit.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs });
} else if (userLimit.count >= maxRequests) {
return NextResponse.json(
{ success: false, error: { code: 'RATE_LIMIT_EXCEEDED', message: '请求过于频繁' } },
{ status: 429 }
);
} else {
userLimit.count++;
}
} else {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs });
}
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};
```
**步骤 2: 添加安全头**
修改 `next.config.ts`, 在 `headers` 中添加:
```typescript
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;",
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
```
**步骤 3: 提交**
```bash
git add src/middleware.ts next.config.ts
git commit -m "feat: 添加 API 速率限制和安全头"
```
---
## 阶段六:文档和部署
### Task 16: 更新项目文档
**文件:**
- 修改: `README.md`
- 创建: `docs/cms-usage.md`
**步骤 1: 更新 README**
`README.md` 中添加 CMS 相关内容:
```markdown
## CMS 管理后台
### 访问地址
- 管理后台:http://localhost:3000/admin
- 默认账号:admin@novalon.cn
- 默认密码:admin123456
### 功能模块
- **内容管理**:新闻、产品、服务、案例的增删改查
- **配置中心**:功能开关、样式配置、SEO 设置
- **用户管理**:管理员、编辑、查看者角色管理
- **操作日志**:审计追踪、安全监控
### 数据库管理
\`\`\`bash
# 生成迁移
npm run db:generate
# 执行迁移
npm run db:migrate
# 查看数据库
npm run db:studio
# 初始化数据
npm run db:seed
\`\`\`
```
**步骤 2: 创建使用文档**
创建 `docs/cms-usage.md`:
```markdown
# CMS 使用手册
## 1. 登录管理后台
访问 `/admin/login`,使用邮箱密码或 Magic Link 登录。
## 2. 内容管理
### 2.1 发布新闻
1. 点击左侧菜单「新闻管理」
2. 点击右上角「发布新闻」按钮
3. 填写标题、摘要、内容等信息
4. 选择分类和标签
5. 点击「发布」按钮
### 2.2 编辑产品
1. 点击左侧菜单「产品管理」
2. 找到需要编辑的产品,点击「编辑」按钮
3. 修改内容后点击「保存」
## 3. 配置管理
### 3.1 功能开关
在「配置中心」可以启用或禁用各功能模块。
### 3.2 SEO 配置
配置网站标题、描述、关键词等 SEO 信息。
## 4. 用户管理
管理员可以创建、编辑、删除用户,并分配角色权限。
## 5. 操作日志
查看所有操作记录,包括创建、更新、删除等。
```
**步骤 3: 提交**
```bash
git add README.md docs/cms-usage.md
git commit -m "docs: 更新 CMS 使用文档"
```
---
## 完成清单
### 功能完成标准
- [ ] 用户可以使用邮箱密码或 Magic Link 登录
- [ ] 管理员可以创建、编辑、删除内容
- [ ] 配置更新实时生效
- [ ] 权限控制正常工作
- [ ] E2E 测试全部通过
- [ ] 性能指标达标(响应时间 < 500ms)
- [ ] 安全检查通过
### 代码质量标准
- [ ] TypeScript 类型覆盖率 ≥ 90%
- [ ] ESLint 规则通过率 100%
- [ ] 单元测试覆盖率 ≥ 80%
- [ ] E2E 测试覆盖率 ≥ 60%
### 文档完成标准
- [ ] README 更新完整
- [ ] API 文档完善
- [ ] 使用手册清晰
- [ ] 部署文档详细
---
## 后续优化建议
1. **性能优化**
- 添加 Redis 缓存层
- 实现图片 CDN 加速
- 优化数据库查询
2. **功能增强**
- 实现内容定时发布
- 添加内容审核工作流
- 支持多语言内容
3. **安全加固**
- 实现双因素认证
- 添加 IP 白名单
- 完善审计日志
4. **运维监控**
- 集成 Sentry 错误监控
- 添加性能监控
- 实现自动备份
---
**计划完成!** 🎉
**保存位置:** `docs/plans/2026-03-08-configurable-cms-implementation.md`