#Prisma ORM 集成
本示例展示如何将 Prisma 集成到 VextJS 项目中,通过编写一个数据库插件实现类型安全的数据库操作。
#为什么选择 Prisma?
| 特性 | 说明 |
|---|---|
| 类型安全 | 代码生成的 Prisma Client,查询和结果自动类型推断 |
| 直观 API | 高级查询 API,支持嵌套创建、关联查询、事务等 |
| 数据库迁移 | 内置 prisma migrate 进行声明式 schema 迁移 |
| 多数据库 | 支持 PostgreSQL、MySQL、SQLite、MongoDB、CockroachDB |
| 可视化工具 | Prisma Studio 提供数据库图形化管理界面 |
| 生态丰富 | 广泛的社区插件和第三方集成 |
Tip
Prisma vs Drizzle:Prisma 提供更高层次的抽象和更丰富的查询 API(如 include、select、嵌套写入),适合需要快速开发和复杂关联操作的项目。Drizzle 更贴近 SQL 原生语义,适合对 SQL 控制力要求更高的场景。VextJS 两者都支持,选择取决于团队偏好。
#项目结构
prisma-orm/
├── prisma/
│ ├── schema.prisma ← Prisma schema 定义
│ ├── seed.ts ← 种子数据脚本
│ └── migrations/ ← 迁移文件(prisma migrate 生成)
├── src/
│ ├── config/
│ │ ├── default.ts
│ │ └── production.ts
│ ├── plugins/
│ │ └── database.ts ← 数据库插件
│ ├── routes/
│ │ ├── index.ts
│ │ ├── users.ts
│ │ └── posts.ts
│ ├── services/
│ │ ├── user.ts
│ │ └── post.ts
│ └── index.ts
├── types/
│ └── vext.d.ts ← 类型声明扩展
├── package.json
└── tsconfig.json#1. 安装依赖
npx vextjs create prisma-orm-demo
cd prisma-orm-demo
# Prisma
pnpm add @prisma/client
pnpm add -D prisma#2. 初始化 Prisma
npx prisma init --datasource-provider sqlite这会创建 prisma/schema.prisma 文件和 .env 文件。
#3. 定义 Prisma Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// ── 用户模型 ────────────────────────────────────────────────
model User {
id Int @id @default(autoincrement())
name String
email String @unique
age Int?
role String @default("user")
bio String?
avatarUrl String? @map("avatar_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 关联
posts Post[]
comments Comment[]
@@map("users")
}
// ── 文章模型 ────────────────────────────────────────────────
model Post {
id Int @id @default(autoincrement())
title String
content String
published Boolean @default(false)
viewCount Int @default(0) @map("view_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 关联
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int @map("author_id")
comments Comment[]
tags Tag[]
@@map("posts")
}
// ── 评论模型 ────────────────────────────────────────────────
model Comment {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now()) @map("created_at")
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int @map("author_id")
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId Int @map("post_id")
@@map("comments")
}
// ── 标签模型(多对多)──────────────────────────────────────
model Tag {
id Int @id @default(autoincrement())
name String @unique
posts Post[]
@@map("tags")
}#生成 Prisma Client
# 创建迁移并生成 Client
npx prisma migrate dev --name init
# 或仅生成 Client(不迁移)
npx prisma generate#环境变量
# .env
DATABASE_URL="file:./data/app.db"#4. 种子数据
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('正在填充种子数据...');
// 清空数据(按依赖顺序)
await prisma.comment.deleteMany();
await prisma.post.deleteMany();
await prisma.tag.deleteMany();
await prisma.user.deleteMany();
// 创建标签
const tagBackend = await prisma.tag.create({ data: { name: '后端' } });
const tagFrontend = await prisma.tag.create({ data: { name: '前端' } });
const tagDevOps = await prisma.tag.create({ data: { name: 'DevOps' } });
const tagTypeScript = await prisma.tag.create({ data: { name: 'TypeScript' } });
// 创建用户 + 文章(嵌套创建)
const alice = await prisma.user.create({
data: {
name: 'Alice',
email: 'alice@example.com',
age: 28,
role: 'admin',
bio: '全栈开发者,热爱开源',
posts: {
create: [
{
title: 'VextJS 入门指南',
content: '本文介绍如何使用 VextJS 构建高性能 RESTful API。VextJS 提供 Adapter 架构、插件系统、约定式路由等企业级特性...',
published: true,
tags: { connect: [{ id: tagBackend.id }, { id: tagTypeScript.id }] },
},
{
title: 'Prisma ORM 最佳实践',
content: '深入探索 Prisma 的高级查询技巧,包括关联查询、事务、中间件等...',
published: true,
tags: { connect: [{ id: tagBackend.id }] },
},
{
title: '未发布的草稿',
content: '这是一篇还在编写中的文章...',
published: false,
},
],
},
},
});
const bob = await prisma.user.create({
data: {
name: 'Bob',
email: 'bob@example.com',
age: 32,
role: 'user',
bio: '前端工程师',
posts: {
create: [
{
title: 'React 18 新特性',
content: '探索 React 18 中的并发特性、Suspense 改进和自动批量更新...',
published: true,
tags: { connect: [{ id: tagFrontend.id }, { id: tagTypeScript.id }] },
},
],
},
},
});
const charlie = await prisma.user.create({
data: {
name: 'Charlie',
email: 'charlie@example.com',
age: 25,
role: 'editor',
bio: '技术博客作者',
},
});
// 创建评论
const alicePosts = await prisma.post.findMany({
where: { authorId: alice.id, published: true },
});
if (alicePosts.length > 0) {
await prisma.comment.createMany({
data: [
{
content: '写得太好了!收藏了 👍',
authorId: bob.id,
postId: alicePosts[0].id,
},
{
content: '请问有配套的视频教程吗?',
authorId: charlie.id,
postId: alicePosts[0].id,
},
{
content: '非常实用的 Prisma 教程',
authorId: charlie.id,
postId: alicePosts[1].id,
},
],
});
}
console.log('种子数据填充完成 ✅');
console.log(` 用户: ${await prisma.user.count()}`);
console.log(` 文章: ${await prisma.post.count()}`);
console.log(` 评论: ${await prisma.comment.count()}`);
console.log(` 标签: ${await prisma.tag.count()}`);
}
main()
.catch((e) => {
console.error('种子数据填充失败:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());在 package.json 中配置种子命令:
{
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"scripts": {
"db:seed": "npx prisma db seed",
"db:studio": "npx prisma studio",
"db:migrate": "npx prisma migrate dev",
"db:reset": "npx prisma migrate reset"
}
}运行种子:
pnpm db:seed#5. 数据库插件
// src/plugins/database.ts
import { definePlugin } from 'vextjs';
import { PrismaClient } from '@prisma/client';
export default definePlugin({
name: 'database',
async setup(app) {
app.logger.info('正在初始化 Prisma Client...');
const prisma = new PrismaClient({
log:
app.config.logger.level === 'debug'
? [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'info' },
{ emit: 'stdout', level: 'warn' },
{ emit: 'stdout', level: 'error' },
]
: [
{ emit: 'stdout', level: 'warn' },
{ emit: 'stdout', level: 'error' },
],
});
// 开发环境打印 SQL 查询
if (app.config.logger.level === 'debug') {
prisma.$on('query' as any, (e: any) => {
app.logger.debug(
{ query: e.query, params: e.params, duration: `${e.duration}ms` },
'Prisma SQL',
);
});
}
// 连接数据库
await prisma.$connect();
app.logger.info('Prisma Client 连接成功');
// 挂载到 app
app.extend('prisma', prisma);
// 就绪钩子:验证连接
app.onReady(async () => {
try {
await prisma.$queryRaw`SELECT 1`;
const userCount = await prisma.user.count();
app.logger.info({ userCount }, '数据库连接验证成功');
} catch (err) {
app.logger.error({ error: err }, '数据库连接验证失败');
throw err;
}
});
// 关闭钩子:断开连接
app.onClose(async () => {
await prisma.$disconnect();
app.logger.info('Prisma Client 已断开连接');
});
},
});#类型声明扩展
// types/vext.d.ts
import type { PrismaClient } from '@prisma/client';
declare module 'vextjs' {
interface VextApp {
prisma: PrismaClient;
}
interface VextRequest {
user?: {
id: string;
role: string;
};
}
}#6. 配置
// src/config/default.ts
export default {
port: 3000,
adapter: 'native',
logger: {
level: 'debug',
pretty: true,
},
cors: {
enabled: true,
origins: ['*'],
},
response: {
wrap: true,
hideInternalErrors: false,
},
openapi: {
enabled: true,
title: 'Prisma ORM 示例',
version: '1.0.0',
description: '使用 Prisma ORM 集成的 VextJS RESTful API',
tags: [
{ name: '基础', description: '基础接口' },
{ name: '用户', description: '用户管理接口' },
{ name: '文章', description: '文章管理接口' },
],
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
guardSecurityMap: {
auth: 'bearerAuth',
},
},
middlewares: [
{ name: 'auth' },
],
};// src/config/production.ts
export default {
logger: {
level: 'info',
pretty: false,
},
response: {
hideInternalErrors: true,
},
openapi: {
enabled: false,
},
};#7. 服务层
#用户服务
// src/services/user.ts
import type { VextApp, VextLogger } from 'vextjs';
import type { User, Prisma } from '@prisma/client';
export default class UserService {
private logger: VextLogger;
constructor(private app: VextApp) {
this.logger = app.logger.child({ service: 'UserService' });
}
/**
* 分页查询用户列表
*
* 支持关键词搜索、角色筛选和排序。
*/
async findAll(options: {
page: number;
limit: number;
keyword?: string;
role?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}) {
this.logger.debug(options, '查询用户列表');
const prisma = this.app.prisma;
// 构建 WHERE 条件
const where: Prisma.UserWhereInput = {};
if (options.keyword) {
where.OR = [
{ name: { contains: options.keyword } },
{ email: { contains: options.keyword } },
{ bio: { contains: options.keyword } },
];
}
if (options.role) {
where.role = options.role;
}
// 并行查询数据和总数
const [items, total] = await Promise.all([
prisma.user.findMany({
where,
skip: (options.page - 1) * options.limit,
take: options.limit,
orderBy: {
[options.sortBy ?? 'createdAt']: options.sortOrder ?? 'desc',
},
select: {
id: true,
name: true,
email: true,
age: true,
role: true,
bio: true,
avatarUrl: true,
createdAt: true,
updatedAt: true,
_count: {
select: { posts: true, comments: true },
},
},
}),
prisma.user.count({ where }),
]);
return {
items,
total,
page: options.page,
limit: options.limit,
totalPages: Math.ceil(total / options.limit),
};
}
/**
* 根据 ID 查询用户详情
*
* 包含已发布的文章列表和统计信息。
*/
async findById(id: number) {
this.logger.debug({ userId: id }, '查询用户详情');
const user = await this.app.prisma.user.findUnique({
where: { id },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
published: true,
viewCount: true,
createdAt: true,
tags: {
select: { id: true, name: true },
},
_count: {
select: { comments: true },
},
},
},
_count: {
select: {
posts: true,
comments: true,
},
},
},
});
return user;
}
/**
* 创建用户
*/
async create(data: {
name: string;
email: string;
age?: number;
role?: string;
bio?: string;
}): Promise<User> {
this.logger.info({ email: data.email }, '创建用户');
// Prisma 的 unique 约束会自动检测重复邮箱
// 但我们提前检查以返回友好的业务错误码
const existing = await this.app.prisma.user.findUnique({
where: { email: data.email },
select: { id: true },
});
if (existing) {
this.app.throw(409, '邮箱已注册', 10001);
}
const user = await this.app.prisma.user.create({
data: {
name: data.name,
email: data.email,
age: data.age,
role: data.role ?? 'user',
bio: data.bio,
},
});
this.logger.info({ userId: user.id, email: user.email }, '用户创建成功');
return user;
}
/**
* 更新用户
*/
async update(
id: number,
data: {
name?: string;
email?: string;
age?: number;
bio?: string;
avatarUrl?: string;
},
): Promise<User> {
this.logger.info({ userId: id }, '更新用户');
// 检查用户是否存在
const existing = await this.app.prisma.user.findUnique({
where: { id },
select: { id: true, email: true },
});
if (!existing) {
this.app.throw(404, '用户不存在');
}
// 邮箱唯一性检查
if (data.email && data.email !== existing.email) {
const emailTaken = await this.app.prisma.user.findUnique({
where: { email: data.email },
select: { id: true },
});
if (emailTaken) {
this.app.throw(409, '邮箱已被其他用户使用', 10002);
}
}
const user = await this.app.prisma.user.update({
where: { id },
data,
});
this.logger.info({ userId: id }, '用户更新成功');
return user;
}
/**
* 删除用户
*
* 由于 schema 设置了 onDelete: Cascade,关联的文章和评论会自动删除。
*/
async delete(id: number): Promise<void> {
this.logger.info({ userId: id }, '删除用户');
const existing = await this.app.prisma.user.findUnique({
where: { id },
select: { id: true },
});
if (!existing) {
this.app.throw(404, '用户不存在');
}
await this.app.prisma.user.delete({ where: { id } });
this.logger.info({ userId: id }, '用户删除成功(关联数据已级联删除)');
}
/**
* 统计用户数量
*/
async count(): Promise<number> {
return this.app.prisma.user.count();
}
}#文章服务
// src/services/post.ts
import type { VextApp, VextLogger } from 'vextjs';
import type { Post, Prisma } from '@prisma/client';
export default class PostService {
private logger: VextLogger;
constructor(private app: VextApp) {
this.logger = app.logger.child({ service: 'PostService' });
}
/**
* 查询已发布的文章列表
*/
async findPublished(options: {
page: number;
limit: number;
keyword?: string;
tag?: string;
}) {
this.logger.debug(options, '查询文章列表');
const prisma = this.app.prisma;
const where: Prisma.PostWhereInput = {
published: true,
};
if (options.keyword) {
where.OR = [
{ title: { contains: options.keyword } },
{ content: { contains: options.keyword } },
];
}
if (options.tag) {
where.tags = {
some: { name: options.tag },
};
}
const [items, total] = await Promise.all([
prisma.post.findMany({
where,
skip: (options.page - 1) * options.limit,
take: options.limit,
orderBy: { createdAt: 'desc' },
include: {
author: {
select: { id: true, name: true, avatarUrl: true },
},
tags: {
select: { id: true, name: true },
},
_count: {
select: { comments: true },
},
},
}),
prisma.post.count({ where }),
]);
return {
items,
total,
page: options.page,
limit: options.limit,
totalPages: Math.ceil(total / options.limit),
};
}
/**
* 查询文章详情(含评论)
*/
async findById(id: number) {
this.logger.debug({ postId: id }, '查询文章详情');
const prisma = this.app.prisma;
const post = await prisma.post.findUnique({
where: { id },
include: {
author: {
select: { id: true, name: true, avatarUrl: true, bio: true },
},
tags: {
select: { id: true, name: true },
},
comments: {
orderBy: { createdAt: 'desc' },
include: {
author: {
select: { id: true, name: true, avatarUrl: true },
},
},
},
_count: {
select: { comments: true },
},
},
});
if (!post) return null;
// 增加浏览计数
await prisma.post.update({
where: { id },
data: { viewCount: { increment: 1 } },
});
return post;
}
/**
* 创建文章
*/
async create(data: {
title: string;
content: string;
authorId: number;
published?: boolean;
tags?: string[];
}): Promise<Post> {
this.logger.info({ authorId: data.authorId, title: data.title }, '创建文章');
const prisma = this.app.prisma;
// 验证作者是否存在
const author = await prisma.user.findUnique({
where: { id: data.authorId },
select: { id: true },
});
if (!author) {
this.app.throw(404, '作者不存在');
}
// 处理标签:connectOrCreate(已有则关联,没有则创建)
const tagConnections = data.tags?.map((tagName) => ({
where: { name: tagName },
create: { name: tagName },
}));
const post = await prisma.post.create({
data: {
title: data.title,
content: data.content,
authorId: data.authorId,
published: data.published ?? false,
tags: tagConnections
? { connectOrCreate: tagConnections }
: undefined,
},
include: {
author: { select: { id: true, name: true } },
tags: { select: { id: true, name: true } },
},
});
this.logger.info({ postId: post.id }, '文章创建成功');
return post;
}
/**
* 更新文章
*/
async update(
id: number,
data: {
title?: string;
content?: string;
published?: boolean;
tags?: string[];
},
) {
this.logger.info({ postId: id }, '更新文章');
const prisma = this.app.prisma;
const existing = await prisma.post.findUnique({
where: { id },
select: { id: true },
});
if (!existing) {
this.app.throw(404, '文章不存在');
}
// 如果提供了 tags,先断开所有现有标签,再关联新标签
const updateData: Prisma.PostUpdateInput = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.content !== undefined) updateData.content = data.content;
if (data.published !== undefined) updateData.published = data.published;
if (data.tags !== undefined) {
updateData.tags = {
set: [], // 清空现有标签
connectOrCreate: data.tags.map((name) => ({
where: { name },
create: { name },
})),
};
}
const post = await prisma.post.update({
where: { id },
data: updateData,
include: {
author: { select: { id: true, name: true } },
tags: { select: { id: true, name: true } },
},
});
this.logger.info({ postId: id }, '文章更新成功');
return post;
}
/**
* 删除文章
*/
async delete(id: number): Promise<void> {
this.logger.info({ postId: id }, '删除文章');
const existing = await this.app.prisma.post.findUnique({
where: { id },
select: { id: true },
});
if (!existing) {
this.app.throw(404, '文章不存在');
}
await this.app.prisma.post.delete({ where: { id } });
this.logger.info({ postId: id }, '文章删除成功');
}
/**
* 添加评论
*/
async addComment(data: {
postId: number;
authorId: number;
content: string;
}) {
this.logger.info({ postId: data.postId, authorId: data.authorId }, '添加评论');
const prisma = this.app.prisma;
// 验证文章存在且已发布
const post = await prisma.post.findUnique({
where: { id: data.postId },
select: { id: true, published: true },
});
if (!post) {
this.app.throw(404, '文章不存在');
}
if (!post.published) {
this.app.throw(400, '不能评论未发布的文章');
}
const comment = await prisma.comment.create({
data: {
content: data.content,
authorId: data.authorId,
postId: data.postId,
},
include: {
author: { select: { id: true, name: true } },
},
});
this.logger.info({ commentId: comment.id }, '评论添加成功');
return comment;
}
}#8. 路由
#根路由
// src/routes/index.ts
import { defineRoutes } from 'vextjs';
export default defineRoutes((app) => {
app.get('/', {
docs: {
summary: '健康检查',
tags: ['基础'],
},
}, async (_req, res) => {
const userCount = await app.services.user.count();
res.json({
status: 'ok',
database: 'connected',
users: userCount,
uptime: Math.floor(process.uptime()),
timestamp: new Date().toISOString(),
});
});
});#用户路由
// src/routes/users.ts
import { defineRoutes } from 'vextjs';
export default defineRoutes((app) => {
// GET /users/list — 分页查询
app.get('/list', {
validate: {
query: {
page: 'number:1-',
limit: 'number:1-100',
keyword: 'string?',
role: 'string?',
},
},
docs: {
summary: '用户列表',
description: '分页查询用户列表,支持关键词搜索和角色筛选。返回每个用户的文章和评论计数。',
tags: ['用户'],
},
}, async (req, res) => {
const { page, limit, keyword, role } = req.valid('query');
const result = await app.services.user.findAll({ page, limit, keyword, role });
res.json(result);
});
// GET /users/:id — 查询详情(含文章列表)
app.get('/:id', {
validate: {
param: { id: 'number:1-' },
},
docs: {
summary: '获取用户详情',
description: '查询用户详情,包含已发布文章列表及统计信息。',
tags: ['用户'],
responses: {
200: { description: '查询成功' },
404: { description: '用户不存在' },
},
},
}, async (req, res) => {
const { id } = req.valid('param');
const user = await app.services.user.findById(id);
if (!user) app.throw(404, '用户不存在');
res.json(user);
});
// POST /users — 创建用户(需认证)
app.post('/', {
validate: {
body: {
name: 'string:1-50',
email: 'email',
age: 'number:0-200?',
role: 'enum:admin,user,editor?',
bio: 'string?',
},
},
middlewares: ['auth'],
docs: {
summary: '创建用户',
tags: ['用户'],
responses: {
201: { description: '创建成功' },
400: { description: '参数校验失败' },
401: { description: '未认证' },
409: { description: '邮箱已注册' },
},
},
}, async (req, res) => {
const body = req.valid('body');
const user = await app.services.user.create(body);
res.json(user, 201);
});
// PUT /users/:id — 更新用户(需认证)
app.put('/:id', {
validate: {
param: { id: 'number:1-' },
body: {
name: 'string:1-50?',
email: 'email?',
age: 'number:0-200?',
bio: 'string?',
},
},
middlewares: ['auth'],
docs: {
summary: '更新用户',
tags: ['用户'],
responses: {
200: { description: '更新成功' },
404: { description: '用户不存在' },
409: { description: '邮箱已被占用' },
},
},
}, async (req, res) => {
const { id } = req.valid('param');
const body = req.valid('body');
const user = await app.services.user.update(id, body);
res.json(user);
});
// DELETE /users/:id — 删除用户(需认证)
app.delete('/:id', {
validate: {
param: { id: 'number:1-' },
},
middlewares: ['auth'],
docs: {
summary: '删除用户',
description: '删除用户及其所有文章和评论(级联删除)。',
tags: ['用户'],
responses: {
204: { description: '删除成功' },
404: { description: '用户不存在' },
},
},
}, async (req, res) => {
const { id } = req.valid('param');
await app.services.user.delete(id);
res.status(204).json(null);
});
});#文章路由
// src/routes/posts.ts
import { defineRoutes } from 'vextjs';
export default defineRoutes((app) => {
// GET /posts/list — 查询已发布文章
app.get('/list', {
validate: {
query: {
page: 'number:1-',
limit: 'number:1-100',
keyword: 'string?',
tag: 'string?',
},
},
docs: {
summary: '文章列表',
description: '查询已发布文章列表,支持关键词搜索和标签筛选。',
tags: ['文章'],
},
}, async (req, res) => {
const { page, limit, keyword, tag } = req.valid('query');
const result = await app.services.post.findPublished({ page, limit, keyword, tag });
res.json(result);
});
// GET /posts/:id — 文章详情(含评论)
app.get('/:id', {
validate: {
param: { id: 'number:1-' },
},
docs: {
summary: '文章详情',
description: '查询文章详情,包含作者信息、标签、评论列表。每次访问自动增加浏览计数。',
tags: ['文章'],
responses: {
200: { description: '查询成功' },
404: { description: '文章不存在' },
},
},
}, async (req, res) => {
const { id } = req.valid('param');
const post = await app.services.post.findById(id);
if (!post) app.throw(404, '文章不存在');
res.json(post);
});
// POST /posts — 创建文章(需认证)
app.post('/', {
validate: {
body: {
title: 'string:1-200',
content: 'string:1-',
published: 'boolean?',
},
},
middlewares: ['auth'],
docs: {
summary: '创建文章',
description: '创建一篇新文章。可通过请求体中的 tags 字段关联标签(字符串数组)。',
tags: ['文章'],
responses: {
201: { description: '创建成功' },
401: { description: '未认证' },
},
},
}, async (req, res) => {
const body = req.valid('body');
// 从认证信息中获取作者 ID
const authorId = parseInt(req.user?.id ?? '0', 10);
if (!authorId) {
app.throw(401, '无法确定用户身份');
}
// tags 从原始 body 中获取(不在 validate schema 中声明,因为 schema-dsl 不支持数组校验)
const rawBody = req.body as any;
const tags = Array.isArray(rawBody?.tags) ? rawBody.tags : undefined;
const post = await app.services.post.create({
title: body.title,
content: body.content,
authorId,
published: body.published,
tags,
});
res.json(post, 201);
});
// PUT /posts/:id — 更新文章(需认证)
app.put('/:id', {
validate: {
param: { id: 'number:1-' },
body: {
title: 'string:1-200?',
content: 'string?',
published: 'boolean?',
},
},
middlewares: ['auth'],
docs: {
summary: '更新文章',
tags: ['文章'],
responses: {
200: { description: '更新成功' },
404: { description: '文章不存在' },
},
},
}, async (req, res) => {
const { id } = req.valid('param');
const body = req.valid('body');
const rawBody = req.body as any;
const tags = Array.isArray(rawBody?.tags) ? rawBody.tags : undefined;
const post = await app.services.post.update(id, { ...body, tags });
res.json(post);
});
// DELETE /posts/:id — 删除文章(需认证)
app.delete('/:id', {
validate: {
param: { id: 'number:1-' },
},
middlewares: ['auth'],
docs: {
summary: '删除文章',
tags: ['文章'],
responses: {
204: { description: '删除成功' },
404: { description: '文章不存在' },
},
},
}, async (req, res) => {
const { id } = req.valid('param');
await app.services.post.delete(id);
res.status(204).json(null);
});
// POST /posts/:id/comments — 添加评论(需认证)
app.post('/:id/comments', {
validate: {
param: { id: 'number:1-' },
body: {
content: 'string:1-1000',
},
},
middlewares: ['auth'],
docs: {
summary: '添加评论',
description: '为指定文章添加评论。文章必须已发布。',
tags: ['文章'],
responses: {
201: { description: '评论成功' },
400: { description: '不能评论未发布的文章' },
404: { description: '文章不存在' },
},
},
}, async (req, res) => {
const { id: postId } = req.valid('param');
const { content } = req.valid('body');
const authorId = parseInt(req.user?.id ?? '0', 10);
if (!authorId) {
app.throw(401, '无法确定用户身份');
}
const comment = await app.services.post.addComment({
postId,
authorId,
content,
});
res.json(comment, 201);
});
});#9. 入口文件
// src/index.ts
import { bootstrap } from 'vextjs';
bootstrap().catch((err) => {
console.error('启动失败:', err);
process.exit(1);
});#10. 运行与验证
# 初始化数据库
npx prisma migrate dev --name init
# 填充种子数据
pnpm db:seed
# 启动开发服务器
pnpm dev#接口验证
# 健康检查
curl http://localhost:3000/
# → {"code":0,"data":{"status":"ok","database":"connected","users":3,...},...}
# 用户列表(含文章/评论计数)
curl "http://localhost:3000/users/list?page=1&limit=10"
# → {"code":0,"data":{"items":[{"id":1,"name":"Alice",...,"_count":{"posts":3,"comments":0}},...],...},...}
# 用户详情(含文章列表和标签)
curl http://localhost:3000/users/1
# → {"code":0,"data":{"id":1,"name":"Alice","posts":[{"id":1,"title":"VextJS 入门指南","tags":[{"name":"后端"},...]},...]},...}
# 文章列表(按标签筛选)
curl "http://localhost:3000/posts/list?page=1&limit=10&tag=TypeScript"
# → 只返回包含 "TypeScript" 标签的文章
# 文章详情(含评论)
curl http://localhost:3000/posts/1
# → {"code":0,"data":{"id":1,"title":"VextJS 入门指南","viewCount":1,"comments":[...],"author":{"name":"Alice"},...},...}
# 创建文章(含标签)
curl -X POST http://localhost:3000/posts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{
"title": "Node.js 性能优化",
"content": "分享 Node.js 性能优化的经验...",
"published": true,
"tags": ["后端", "性能"]
}'
# → 201 {"code":0,"data":{"id":4,"title":"Node.js 性能优化","tags":[{"name":"后端"},{"name":"性能"}],...},...}
# 添加评论
curl -X POST http://localhost:3000/posts/1/comments \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-2-user" \
-d '{"content": "非常实用的文章!学到了很多"}'
# → 201 {"code":0,"data":{"id":4,"content":"非常实用的文章!...","author":{"name":"Bob"}},...}
# 关键词搜索
curl "http://localhost:3000/posts/list?page=1&limit=10&keyword=VextJS"
# → 只返回标题或内容包含 "VextJS" 的文章
# 打开 Prisma Studio(可视化管理数据库)
pnpm db:studio#Prisma 高级用法
#中间件(Soft Delete)
Prisma 中间件可以拦截查询,实现软删除等功能:
// src/plugins/database.ts 中添加
prisma.$use(async (params, next) => {
// 软删除:delete 操作改为 update deletedAt
if (params.action === 'delete') {
params.action = 'update';
params.args.data = { deletedAt: new Date() };
}
// 查询时自动过滤已删除记录
if (params.action === 'findMany' || params.action === 'findFirst') {
if (!params.args.where) params.args.where = {};
params.args.where.deletedAt = null;
}
return next(params);
});#事务
// 交互式事务
const result = await prisma.$transaction(async (tx) => {
// 在事务中创建用户和初始文章
const user = await tx.user.create({
data: { name: 'Diana', email: 'diana@example.com' },
});
const post = await tx.post.create({
data: {
title: '我的第一篇文章',
content: '欢迎来到我的博客!',
authorId: user.id,
published: true,
},
});
return { user, post };
});
// 批量事务(数组形式)
const [updatedUser, newPost] = await prisma.$transaction([
prisma.user.update({
where: { id: 1 },
data: { name: 'Alice Updated' },
}),
prisma.post.create({
data: {
title: '新文章',
content: '内容',
authorId: 1,
},
}),
]);#原生 SQL
// 复杂统计查询可以使用原生 SQL
const stats = await prisma.$queryRaw`
SELECT
u.name,
COUNT(p.id) as post_count,
COALESCE(SUM(p.view_count), 0) as total_views
FROM users u
LEFT JOIN posts p ON p.author_id = u.id AND p.published = 1
GROUP BY u.id
ORDER BY total_views DESC
LIMIT 10
`;#优化查询
// 使用 select 只查询需要的字段(减少数据传输)
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
// 不查询 bio、avatarUrl 等大字段
},
});
// 使用 cursor 分页(大数据量时比 skip/take 高效)
const posts = await prisma.post.findMany({
take: 10,
cursor: { id: lastPostId },
skip: 1, // 跳过 cursor 本身
orderBy: { id: 'desc' },
});#测试
// test/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createTestApp } from 'vextjs/testing';
import type { TestApp } from 'vextjs';
describe('用户 CRUD (Prisma)', () => {
let testApp: TestApp;
const AUTH = 'Bearer user-1-admin';
beforeEach(async () => {
testApp = await createTestApp({
plugins: true,
});
});
afterEach(async () => {
await testApp?.close();
});
it('GET /users/list 应包含用户统计', async () => {
const res = await testApp.request
.get('/users/list')
.query({ page: 1, limit: 10 });
expect(res.status).toBe(200);
expect(res.body.data.items[0]).toHaveProperty('_count');
expect(res.body.data.items[0]._count).toHaveProperty('posts');
});
it('GET /users/:id 应包含关联文章和标签', async () => {
const res = await testApp.request.get('/users/1');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.data.posts)).toBe(true);
if (res.body.data.posts.length > 0) {
expect(res.body.data.posts[0]).toHaveProperty('tags');
expect(res.body.data.posts[0]).toHaveProperty('_count');
}
});
it('POST /users 应创建用户', async () => {
const res = await testApp.request
.post('/users')
.set('Authorization', AUTH)
.send({
name: 'Diana',
email: 'diana@example.com',
bio: '新用户',
});
expect(res.status).toBe(201);
expect(res.body.data.name).toBe('Diana');
expect(res.body.data.role).toBe('user');
expect(res.body.data.createdAt).toBeDefined();
});
it('DELETE /users/:id 应级联删除文章和评论', async () => {
const deleteRes = await testApp.request
.delete('/users/1')
.set('Authorization', AUTH);
expect(deleteRes.status).toBe(204);
// 确认关联文章也被删除
const postsRes = await testApp.request
.get('/posts/list')
.query({ page: 1, limit: 100 });
const alicePosts = postsRes.body.data.items.filter(
(p: any) => p.author.id === 1,
);
expect(alicePosts).toHaveLength(0);
});
});
describe('文章与评论 (Prisma)', () => {
let testApp: TestApp;
const AUTH = 'Bearer user-1-admin';
beforeEach(async () => {
testApp = await createTestApp({ plugins: true });
});
afterEach(async () => {
await testApp?.close();
});
it('GET /posts/list 应支持标签筛选', async () => {
const res = await testApp.request
.get('/posts/list')
.query({ page: 1, limit: 10, tag: 'TypeScript' });
expect(res.status).toBe(200);
for (const post of res.body.data.items) {
const tagNames = post.tags.map((t: any) => t.name);
expect(tagNames).toContain('TypeScript');
}
});
it('POST /posts/:id/comments 应添加评论', async () => {
const res = await testApp.request
.post('/posts/1/comments')
.set('Authorization', AUTH)
.send({ content: '测试评论' });
expect(res.status).toBe(201);
expect(res.body.data.content).toBe('测试评论');
expect(res.body.data.author).toHaveProperty('name');
});
it('GET /posts/:id 应自增浏览计数', async () => {
const res1 = await testApp.request.get('/posts/1');
const viewCount1 = res1.body.data.viewCount;
const res2 = await testApp.request.get('/posts/1');
const viewCount2 = res2.body.data.viewCount;
expect(viewCount2).toBe(viewCount1 + 1);
});
});#项目模式总结
| 层级 | 职责 | 文件 |
|---|---|---|
| Schema | Prisma 数据模型定义 | prisma/schema.prisma |
| 种子数据 | 初始数据填充 | prisma/seed.ts |
| 迁移 | 数据库结构版本管理 | prisma/migrations/ |
| 插件 | 初始化 PrismaClient、挂载 app.prisma、清理 | src/plugins/database.ts |
| 服务层 | 业务逻辑 + Prisma 查询 | src/services/*.ts |
| 路由层 | 请求校验 + 调用服务 + 响应 | src/routes/*.ts |
| 类型声明 | app.prisma 类型扩展 | types/vext.d.ts |
#与 Drizzle 方案的对比
| 维度 | Prisma | Drizzle |
|---|---|---|
| Schema 定义 | .prisma DSL 文件 | TypeScript 代码 |
| 类型生成 | 代码生成(prisma generate) | Schema 推断($inferSelect) |
| 查询 API | 高级 ORM API(include、嵌套写入) | SQL-like API(接近原生 SQL) |
| 关联查询 | include / select 嵌套 | Relations API / 手动 JOIN |
| 迁移 | prisma migrate(声明式) | drizzle-kit(声明式) |
| 事务 | $transaction | db.transaction |
| 可视化工具 | Prisma Studio | Drizzle Studio |
| 性能 | 中等(有查询层抽象) | 较高(直接编译 SQL) |
| 包体积 | 较大(生成的 Client) | 轻量 |
| 学习曲线 | 低(文档丰富) | 中(需要 SQL 基础) |
两种方案在 VextJS 中的集成模式完全相同:通过插件初始化连接 → 挂载到 app → 服务层封装查询 → 路由层编排调用。选择哪种取决于团队偏好和项目需求。
#下一步
- 📖 Drizzle ORM 集成 — 另一种 ORM 集成方案
- 📖 Zod 校验集成 — 更强大的参数校验
- 📖 插件 — 深入了解 VextJS 插件系统
- 📖 服务层 — 深入了解服务层设计模式
- 📖 测试 — 测试数据库相关的业务逻辑