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

56 KiB

可配置化 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: 安装数据库相关依赖

npm install drizzle-orm @libsql/client
npm install -D drizzle-kit

步骤 2: 安装认证相关依赖

npm install next-auth@beta @auth/drizzle-adapter
npm install bcryptjs
npm install -D @types/bcryptjs

步骤 3: 安装富文本编辑器

npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link @tiptap/pm

步骤 4: 安装其他工具库

npm install nanoid date-fns
npm install -D @types/nanoid

步骤 5: 验证安装

运行: npm list drizzle-orm next-auth @tiptap/react

预期: 所有依赖包版本正确显示

步骤 6: 提交

git add package.json package-lock.json
git commit -m "chore: 添加 CMS 系统所需依赖包"

Task 2: 配置数据库连接

文件:

  • 创建: src/db/index.ts
  • 创建: .env.local
  • 修改: .gitignore

步骤 1: 创建环境变量文件

创建 .env.local:

# 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:

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:

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: 删除测试文件

rm src/db/test-connection.ts

步骤 6: 提交

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:

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:

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.jsonscripts 中添加:

{
  "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: 提交

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: 提交

git add drizzle/
git commit -m "feat: 生成数据库迁移文件"

Task 5: 创建数据库种子数据

文件:

  • 创建: src/db/seed.ts

步骤 1: 创建种子数据脚本

创建 src/db/seed.ts:

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: 提交

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:

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:

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:

'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:

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:

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: 提交

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:

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:

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:

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: 提交

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:

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:

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:

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:

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: 提交

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:

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:

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: 提交

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:

'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:

'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:

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: 提交

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:

'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: 提交

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:

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:

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: 提交

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:

'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:

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: 提交

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:

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:

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: 提交

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:

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 中添加:

{
  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: 提交

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 相关内容:

## 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:

# 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: 提交

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