Nuxt后端架构搭建流程

发布于 2026年6月10日

前言

传统的前后端分离项目需要维护两个独立仓库、两套构建流程、两次部署。而 Nuxt 3 内置的 Nitro 服务引擎让我们可以在同一个项目中编写前端页面和后端 API,共享类型定义、统一部署流程,大幅降低全栈开发的心智负担。

这篇文章以一个真实的博客系统(Jojo Blog)为例,完整记录从项目初始化到生产部署的后端架构搭建过程。如果你正在考虑用 Nuxt 做全栈项目,或者想了解一个中小型项目的后端如何组织代码,这篇文章应该能给你一些参考。

适合读者:有一定 Vue/Nuxt 基础,想了解如何用 Nuxt 搭建后端服务的前端开发者。


技术栈概览

技术版本用途
Nuxt 3^3.19.0全栈框架,提供服务端 API(Nitro)
Prisma^6.9.0ORM,数据库访问层
MySQL-主数据库
Redis (ioredis)^5.8.2缓存,防刷浏览量去重
InversifyJS^7.5.2IoC 容器,依赖注入
Zod^4.1.9请求参数校验
jsonwebtoken^9.0.2JWT 鉴权
nodemailer^7.0.10邮件发送

零、整体目录结构

在展开每个模块之前,先看一下后端代码在 server/ 目录下的组织方式,建立整体印象:

server/
├── api/                    # API 路由(Nitro 文件路由约定)
│   ├── blog/
│   │   ├── blogCreate.post.ts
│   │   ├── blogList.get.ts
│   │   ├── blogDelete.delete.ts
│   │   ├── blogUpdate.post.ts
│   │   └── blogAddView/
│   │       └── [id].put.ts
│   ├── user/
│   ├── tag/
│   ├── record/
│   ├── md/
│   ├── statistical/
│   ├── error/
│   └── sitemap/
├── core/                   # 基础设施单例
│   ├── prisma.ts
│   ├── redis.ts
│   └── container.ts        # IoC 容器
├── middleware/
│   └── auth.ts             # JWT 鉴权中间件
├── repositories/           # 数据访问层(业务逻辑在此)
│   ├── BlogRepository.ts
│   ├── UserRepository.ts
│   └── ...
├── services/               # Service 接口定义
│   ├── BlogService.ts
│   └── ...
├── serviceImpl/            # Service 接口实现(委托给 Repository)
│   ├── BlogServiceImpl.ts
│   └── ...
├── dto/                    # Zod 请求参数校验 Schema
│   ├── CreateBlogDto.ts
│   └── ...
└── utils/                  # 工具函数
    ├── jwt.ts
    ├── public.ts
    ├── error.ts
    ├── image-process.ts
    └── index.ts

这套分层结构参考了 Java Spring 的设计思路:Controller(api 路由)→ Service(接口)→ ServiceImpl(实现)→ Repository(数据访问)。在 TypeScript 项目里通过 InversifyJS 实现依赖注入,让各层之间保持松耦合、易测试。


一、项目初始化

bash
npx nuxi init jojo-blog
cd jojo-blog
npm install

安装后端核心依赖:

bash
npm install prisma @prisma/client mysql2 ioredis inversify reflect-metadata jsonwebtoken zod nodemailer sharp exifreader
npm install -D @types/jsonwebtoken @types/nodemailer

reflect-metadata 是 InversifyJS 的必要依赖,用于在运行时读取 TypeScript 的装饰器元数据。需要在入口文件顶部 import 'reflect-metadata'

另外,由于 InversifyJS 使用了装饰器,需要在 tsconfig.json 中开启以下选项:

json
{
	"compilerOptions": {
		"experimentalDecorators": true,
		"emitDecoratorMetadata": true
	}
}

二、数据库设计(Prisma Schema)

2.1 初始化 Prisma

bash
npx prisma init

.env 中配置数据库连接:

bash
DATABASE_URL="mysql://user:password@localhost:3306/jojo_blog"

2.2 数据模型设计

prisma/schema.prisma 定义了以下核心模型:

用户与标签

prisma
model user_info {
    id         Int        @id @default(autoincrement())
    user_name  String     @db.VarChar(10)
    password   String     @db.VarChar(20)
    avatar_url String     @db.VarChar(255)
    pet_name   String     @db.VarChar(20)
    describe   String     @db.Text
    sign       String     @db.Text
    tags       user_tag[]
}

model tag {
    id       Int        @id @default(autoincrement())
    name     String     @db.VarChar(50)
    icon     String     @db.VarChar(255)
    url      String     @db.VarChar(255)
    type     TagType    @default(BLOG)
    color    String     @default("#000000") @db.VarChar(20)
    blog_tag blog_tag[]
    users    user_tag[]

    @@unique([name, type])
}

enum TagType {
    BLOG
    PERSON
}

博客与浏览记录

prisma
model blog {
    id          Int        @id @default(autoincrement())
    title       String
    subtitle    String
    created_at  DateTime   @default(now())
    updated_at  DateTime   @updatedAt
    content     String     @db.Text
    date_path   String     @db.VarChar(255)
    front_cover String     @db.VarChar(255)
    views       Int        @default(0)
    tags        blog_tag[]
}

model blog_views_daily {
    id        Int      @id @default(autoincrement())
    blog_id   Int
    view_date DateTime
    views     Int      @default(0)

    @@unique([blog_id, view_date])
}

日记模块(record)record_groupsrecord_detailsrecord_images,三层级联结构。

错误上报error_report 记录用户提交的 bug 反馈。

2.3 生成并迁移数据库

bash
npx prisma migrate dev --name init
npx prisma generate

三、基础设施层(server/core)

3.1 Prisma 单例

server/core/prisma.ts 通过挂载到 globalThis 避免开发热更新时重复实例化:

typescript
const globalForPrisma = global as typeof globalThis & { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient({ log: ['query', 'info', 'warn', 'error'] });
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

3.2 Redis 单例

server/core/redis.ts 同理,连接本地 Redis:

typescript
export const redis = globalForRedis.redis || new Ioredis({ host: '127.0.0.1', port: 6379 });

3.3 IoC 容器(InversifyJS)

依赖注入(DI)的核心价值是解耦:API 路由不需要关心 BlogService 是怎么创建的,只需要向容器索取一个实现了 BlogService 接口的对象即可。这让后续替换实现、添加测试 Mock 都变得很简单。

server/core/container.ts 统一管理所有服务的依赖注入关系。每个业务模块按 Repository → ServiceImpl → Service 接口三层绑定:

typescript
const container = new Container();

// 绑定 Repository(单例)
container.bind(BlogRepository).toSelf().inSingletonScope();

// 绑定 Service,手动注入 Repository 依赖
container
	.bind(BLOG_SERVICE)
	.toDynamicValue(() => {
		return new BlogServiceImpl(container.get(BlogRepository));
	})
	.inSingletonScope();

inSingletonScope() 确保整个应用生命周期内只创建一个实例,避免每次请求都重复实例化。

当前注册的业务模块:UserTagBlogMdGroupRecordDetailErrorStatistical


四、分层架构

4.1 整体层次

API 路由 (server/api/**)
    ↓ 调用
Service 接口 (server/services/*.ts)
    ↓ 实现
ServiceImpl (server/serviceImpl/*.ts)
    ↓ 委托
Repository (server/repositories/*.ts)
    ↓ 访问
Prisma / Redis / 文件系统

4.2 Service 接口定义

每个业务模块定义一个 Symbol Token 和接口,供容器和路由使用:

typescript
// server/services/BlogService.ts
export const BLOG_SERVICE = Symbol('BlogService');

export interface BlogService {
	createBlog(data: CreateBlogDto): ReturnType<BlogRepository['createBlog']>;
	getBlogList(data: FindBlogParams): ReturnType<BlogRepository['getBlogList']>;
	deleteBlog(id: number): ReturnType<BlogRepository['deleteBlog']>;
	getBlogById(id: number): ReturnType<BlogRepository['getBlogById']>;
	updateBlog(data: Partial<CreateBlogDto>): ReturnType<BlogRepository['updateBlog']>;
	uploadfrontCover(
		files: ReturnFunction<typeof readMultipartFormData>,
	): ReturnType<BlogRepository['uploadfrontCover']>;
	addBlogView(id: number, ip: string, userAgent: string): ReturnType<BlogRepository['addBlogView']>;
}

4.3 ServiceImpl 实现

ServiceImpl 只做转发,不包含业务逻辑,业务逻辑统一下沉到 Repository:

typescript
// server/serviceImpl/BlogServiceImpl.ts
export class BlogServiceImpl implements BlogService {
	constructor(private blogRepository: BlogRepository) {}

	createBlog(data: CreateBlogDto) {
		return this.blogRepository.createBlog(data);
	}
	// ... 其他方法同理
}

4.4 Repository 实现要点

Repository 是真正的业务逻辑层,以博客为例包含以下几个核心设计:

事务处理

创建/更新/删除博客时,博客记录与标签关联表(blog_tag)在同一 $transaction 内完成,保证数据一致性:

typescript
async createBlog(data: CreateBlogDto) {
    return await prisma.$transaction(async (tx) => {
        const blog = await tx.blog.create({
            data: {
                title: data.title,
                content: data.content,
                // ...其他字段
            },
        });
        if (data.tags?.length) {
            await tx.blog_tag.createMany({
                data: data.tags.map((tagId) => ({ blog_id: blog.id, tag_id: tagId })),
            });
        }
        return blog;
    });
}

文件系统联动

删除博客时同步清理磁盘上的资源(封面图 + Markdown 内图片目录 file-system/mdfile/{date_path}/),避免产生孤儿文件。

Redis 防刷浏览量

浏览量增加使用 IP + UA fingerprint 的 MD5 作为 Redis Key,设置 24 小时过期,同一用户当天重复访问不计入:

typescript
const fingerprint = md5(`${ip}-${userAgent}`);
const key = `blog:view:${id}:${fingerprint}`;
const exists = await redis.exists(key);
if (!exists) {
	await redis.setex(key, 86400, '1');
	await prisma.blog.update({ where: { id }, data: { views: { increment: 1 } } });
}

五、请求校验(Zod DTO)

Zod 的核心优势是"Schema 即类型"——一份 Schema 定义同时承担运行时校验和 TypeScript 类型推导两个职责,不需要重复写类型声明。

每个写操作对应一个 DTO Schema,位于 server/dto/

typescript
// server/dto/CreateBlogDto.ts
export const CreateBlogSchema = z.object({
	front_cover: z.string().trim().min(1, '封面不能为空'),
	title: z.string().trim().min(1, '标题不能为空'),
	subtitle: z.string().trim().min(1, '副标题不能为空'),
	content: z.string().trim().min(1, '内容不能为空'),
	date_path: z.string().trim().min(1, '日期路径不能为空'),
	id: z.number().optional(),
	views: z.number().optional(),
	tags: z.array(z.number()).optional().default([]),
});

export type CreateBlogDto = z.infer<typeof CreateBlogSchema>;

路由中通过 validateData 工具函数统一校验,校验失败直接返回 400:

typescript
const result = validateData(CreateBlogSchema, body, (value) => {
	sendErrorWithMessage(event, 400, value);
	return null;
});

六、API 路由层(server/api)

Nitro 的文件路由约定非常直觉:文件名中的 .get.post.put.delete 后缀直接决定 HTTP 方法,目录结构即路由路径,方括号 [id] 表示动态参数。不需要任何额外的路由注册代码。

文件名HTTP 方法路由路径
blogCreate.post.tsPOST/api/blog/blogCreate
blogList.get.tsGET/api/blog/blogList
blogDelete.delete.tsDELETE/api/blog/blogDelete
blogUpdate.post.tsPOST/api/blog/blogUpdate
blogAddView/[id].put.tsPUT/api/blog/blogAddView/:id
blogPublicDetail/[id].get.tsGET/api/blog/blogPublicDetail/:id

每个路由处理函数遵循同一套模板:读取请求体 → Zod 校验 → 从容器取 Service → 调用业务方法 → 统一错误处理。

typescript
// server/api/blog/blogCreate.post.ts
export default defineEventHandler(async (event) => {
	const body = await readBody<CreateBlogDto>(event);

	const result = validateData(CreateBlogSchema, body, (value) => {
		sendErrorWithMessage(event, 400, value);
		return null;
	});
	if (!result) return null;

	try {
		const blogService = container.get<BlogService>(BLOG_SERVICE);
		return await blogService.createBlog(result);
	} catch {
		sendErrorWithMessage(event, 500, '博客创建失败');
		return null;
	}
});

动态路由参数通过 getRouterParam(event, 'id') 获取:

typescript
// server/api/blog/blogPublicDetail/[id].get.ts
export default defineEventHandler(async (event) => {
	const id = Number(getRouterParam(event, 'id'));
	if (isNaN(id)) {
		sendErrorWithMessage(event, 400, '无效的博客 ID');
		return null;
	}
	const blogService = container.get<BlogService>(BLOG_SERVICE);
	return await blogService.getBlogById(id);
});

七、鉴权中间件(server/middleware/auth.ts)

Nuxt 的 server/middleware/ 目录下的文件会自动对所有请求生效,不需要显式挂载。鉴权中间件统一拦截 /api/** 请求,通过白名单机制区分公开接口和需要鉴权的接口。

白名单配置(无需 Token 的接口):

typescript
const whitelist = [
	'/api/user/userPublicQuery',
	'/api/blog/blogPublicQuery',
	'/api/user/user-login',
	'/api/user/user-register',
	'/api/record/recordPublicQuery',
	// ...
];

// 带动态参数的路由用正则匹配
const whitelistPatterns = [
	/^\/api\/blog\/blogPublicDetail(\/\d+)?(\?.*)?$/,
	/^\/api\/blog\/blogAddView(\/\d+)?(\?.*)?$/,
];

Token 验证逻辑:

同一套接口需要同时支持 SSR 和纯客户端两种调用场景:

  • SSR 请求:Nuxt 在服务端执行 useFetch 时,浏览器 Cookie 会随请求带过来,从 Cookie userState 中解析 token
  • 客户端请求(如 Admin 后台):从请求头 Authorization: Bearer <token> 中获取
typescript
export default defineEventHandler(async (event) => {
	const url = getRequestURL(event).pathname;

	// 白名单直接放行
	if (whitelist.includes(url)) return;
	if (whitelistPatterns.some((pattern) => pattern.test(url))) return;

	// 优先从 Cookie 取(SSR 场景)
	const cookies = parseCookies(event);
	let token = cookies.userState;

	// 其次从 Authorization 头取(客户端场景)
	if (!token) {
		const authHeader = getRequestHeader(event, 'Authorization');
		token = authHeader?.replace('Bearer ', '');
	}

	if (!token) {
		throw createError({ statusCode: 401, message: '未登录' });
	}

	const config = useRuntimeConfig();
	const payload = verifyToken(token, config.jwtSecret);
	if (!payload) {
		throw createError({ statusCode: 401, message: 'Token 已过期或无效' });
	}

	// 将用户信息挂载到 context,供下游路由使用
	event.context.user = payload;
});

八、工具函数(server/utils)

文件职责
jwt.tsJWT 签发与验证(signToken / verifyToken
public.ts统一返回格式 returnData(code, message, data)
error.tssendErrorWithMessage 封装 H3 错误响应
image-process.ts图片处理(sharp,格式转换)
img-compress.ts图片压缩
image-metadata.ts读取图片 EXIF 元数据(exifreader)
index.ts公共工具导出(validateDatareturnData 等)

九、文件存储

上传的文件统一存储在项目根目录的 file-system/ 下,不纳入 Git 管理(.gitignore 中排除):

file-system/
├── frontcover/          # 博客封面图片
└── mdfile/
    └── {date_path}/     # 每篇博客对应一个目录,存放 Markdown 内的图片

nuxt.config.ts 中将此目录配置为 Nitro 的 publicAssets,使其通过 /file-system/** 路径对外暴露:

typescript
nitro: {
    publicAssets: [
        {
            baseURL: '/file-system',
            dir: './file-system',
            maxAge: 60 * 60 * 24 * 7, // 静态资源缓存 7 天
        },
    ],
},

图片上传时先用 sharp 做格式转换和压缩,再写入对应目录,控制磁盘占用。删除博客时通过 fs.rmSync 递归清理对应的 mdfile/{date_path}/ 目录。


十、Nuxt 运行时配置

核心敏感配置在 nuxt.config.tsruntimeConfig 中声明,实际值通过环境变量注入:

typescript
runtimeConfig: {
    jwtSecret: 'jojo-blog',          // JWT 密钥
    accessTokenExpiresIn: '7d',      // Token 有效期
    expiresin: 604800,               // 秒数
    email: {
        host: '...',
        auth: { user: '...', pass: '...' },
    },
    public: {
        baseUrl: '/api',
        siteUrl: 'https://www.polnareff.me',
    },
},

生产环境在 .env.production 中覆盖对应的 NUXT_* 变量。


十一、部署

11.1 构建与进程管理

项目使用 PM2 管理进程,ecosystem.config.cjs 配置如下:

javascript
// ecosystem.config.cjs
module.exports = {
	apps: [
		{
			name: 'jojo-blog',
			script: './.output/server/index.mjs',
			instances: 1,
			env: {
				NODE_ENV: 'production',
				PORT: 3000,
			},
		},
	],
};
bash
# 构建生产产物
npm run build

# 首次启动
pm2 start ecosystem.config.cjs

# 重启
pm2 restart jojo-blog

# 查看日志
pm2 logs jojo-blog

11.2 Nitro 配置

nuxt.config.ts 中的关键生产配置:

typescript
nitro: {
    preset: 'node-server',
    // 开启响应压缩
    compressPublicAssets: { gzip: true, brotli: true },
    // 静态文件目录
    publicAssets: [{ baseURL: '/file-system', dir: './file-system' }],
    // 路由规则
    routeRules: {
        '/admin/**': { ssr: false },  // Admin 后台纯 CSR
        '/api/**': { cors: true },
    },
},

Admin 后台关闭 SSR 的原因:后台页面无需 SEO,且需要频繁鉴权,纯客户端渲染可以避免服务端执行鉴权逻辑带来的复杂性。


十二、API 模块汇总

模块路由前缀主要功能
用户/api/user/登录、注册、用户信息查询与更新
博客/api/blog/增删改查、封面上传、浏览量统计
标签/api/tag/增删改查(支持博客 / 个人标签两类)
日记/api/record/分组 + 详情 + 图片三层结构管理
Markdown/api/md/图片上传、图片删除
统计/api/statistical/博客与日记浏览量数据图表
错误上报/api/error/错误列表查询、删除、邮件通知
Sitemap/api/sitemap/urls提供给 @nuxtjs/sitemap 的动态路由列表
cd . .
Copyright © 2025-present Joseph Joestar 蜀ICP备2025171383号