#CRUD API
一个完整的 RESTful CRUD API 示例,展示 VextJS 的服务层、中间件、参数校验、错误处理等核心能力。
#项目结构
crud-api/
├── src/
│ ├── config/
│ │ └── default.ts
│ ├── middlewares/
│ │ └── auth.ts
│ ├── routes/
│ │ ├── index.ts
│ │ └── users.ts
│ ├── services/
│ │ └── user.ts
│ └── index.ts
├── test/
│ └── users.test.ts
├── package.json
└── tsconfig.json#1. 初始化项目
npx vextjs create crud-api
cd crud-api
pnpm install#2. 配置
// src/config/default.ts
export default {
port: 3000,
adapter: 'native',
logger: {
level: 'debug',
pretty: true,
},
cors: {
enabled: true,
origins: ['*'],
},
rateLimit: {
enabled: true,
max: 100,
window: 60,
},
response: {
wrap: true,
hideInternalErrors: false, // 开发环境显示错误详情
},
openapi: {
enabled: true,
title: 'CRUD API 示例',
version: '1.0.0',
description: '一个完整的用户管理 RESTful API',
tags: [
{ name: '基础', description: '基础接口' },
{ name: '用户', description: '用户管理接口' },
],
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: '使用 Bearer Token 认证',
},
},
guardSecurityMap: {
auth: 'bearerAuth',
},
},
// 路由级中间件白名单
middlewares: [
{ name: 'auth' },
],
};Tip
config.openapi.guardSecurityMap 将路由中间件名称 'auth' 映射到 OpenAPI 安全方案 bearerAuth。当路由声明 middlewares: ['auth'] 时,OpenAPI 文档会自动标记该接口需要 Bearer Token 认证。
#3. 认证中间件
// src/middlewares/auth.ts
import { defineMiddleware } from 'vextjs';
/**
* 简易认证中间件
*
* 生产环境中应使用 JWT 库(如 jose)进行令牌验证。
* 此处为了演示简化为静态 token 校验。
*/
export default defineMiddleware(async (req, _res, next) => {
const authorization = req.headers.authorization;
if (!authorization) {
req.app.throw(401, '未提供认证令牌');
}
const token = authorization.replace('Bearer ', '');
if (!token || token === 'undefined') {
req.app.throw(401, '认证令牌格式无效');
}
// 模拟 JWT 解码(生产环境应使用 jose/jsonwebtoken 等库)
try {
// 简单示例:token 格式为 "user-{id}-{role}"
const parts = token.split('-');
if (parts.length < 3 || parts[0] !== 'user') {
req.app.throw(401, '认证令牌无效');
}
req.user = {
id: parts[1],
role: parts[2],
};
} catch {
req.app.throw(401, '认证令牌解析失败');
}
await next();
});为 req.user 添加类型声明:
// types/vext.d.ts
declare module 'vextjs' {
interface VextRequest {
user?: {
id: string;
role: string;
};
}
}#4. 服务层
// src/services/user.ts
import type { VextApp, VextLogger } from 'vextjs';
/**
* 用户数据接口
*/
interface User {
id: string;
name: string;
email: string;
age?: number;
role: string;
createdAt: string;
updatedAt: string;
}
/**
* 用户服务
*
* 使用内存存储演示 CRUD 操作。
* 生产环境中应替换为数据库操作(如 Drizzle ORM / Prisma)。
*/
export default class UserService {
private logger: VextLogger;
private users: Map<string, User> = new Map();
private nextId = 1;
constructor(private app: VextApp) {
this.logger = app.logger.child({ service: 'UserService' });
// 初始化一些测试数据
this.seed();
}
/**
* 填充初始测试数据
*/
private seed(): void {
const seedUsers = [
{ name: 'Alice', email: 'alice@example.com', age: 28, role: 'admin' },
{ name: 'Bob', email: 'bob@example.com', age: 32, role: 'user' },
{ name: 'Charlie', email: 'charlie@example.com', role: 'user' },
];
for (const u of seedUsers) {
const id = String(this.nextId++);
const now = new Date().toISOString();
this.users.set(id, { id, ...u, createdAt: now, updatedAt: now });
}
this.logger.info({ count: this.users.size }, '初始数据已加载');
}
/**
* 分页查询用户列表
*/
async findAll(options: {
page: number;
limit: number;
keyword?: string;
}): Promise<{
items: User[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
this.logger.debug(options, '查询用户列表');
let allUsers = Array.from(this.users.values());
// 关键词搜索(按 name 或 email 模糊匹配)
if (options.keyword) {
const kw = options.keyword.toLowerCase();
allUsers = allUsers.filter(
(u) =>
u.name.toLowerCase().includes(kw) ||
u.email.toLowerCase().includes(kw),
);
}
const total = allUsers.length;
const totalPages = Math.ceil(total / options.limit);
const start = (options.page - 1) * options.limit;
const items = allUsers.slice(start, start + options.limit);
return { items, total, page: options.page, limit: options.limit, totalPages };
}
/**
* 根据 ID 查询用户
*/
async findById(id: string): Promise<User | null> {
this.logger.debug({ userId: id }, '查询用户');
return this.users.get(id) ?? null;
}
/**
* 创建用户
*/
async create(data: {
name: string;
email: string;
age?: number;
role?: string;
}): Promise<User> {
this.logger.info({ email: data.email }, '创建用户');
// 检查邮箱唯一性
for (const user of this.users.values()) {
if (user.email === data.email) {
this.app.throw(409, '邮箱已注册', 10001);
}
}
const id = String(this.nextId++);
const now = new Date().toISOString();
const user: User = {
id,
name: data.name,
email: data.email,
age: data.age,
role: data.role ?? 'user',
createdAt: now,
updatedAt: now,
};
this.users.set(id, user);
this.logger.info({ userId: id, email: data.email }, '用户创建成功');
return user;
}
/**
* 更新用户
*/
async update(
id: string,
data: { name?: string; email?: string; age?: number },
): Promise<User> {
this.logger.info({ userId: id }, '更新用户');
const user = this.users.get(id);
if (!user) {
this.app.throw(404, '用户不存在');
}
// 如果更新邮箱,检查唯一性
if (data.email && data.email !== user.email) {
for (const u of this.users.values()) {
if (u.email === data.email) {
this.app.throw(409, '邮箱已被其他用户使用', 10002);
}
}
}
const updated: User = {
...user,
...data,
updatedAt: new Date().toISOString(),
};
this.users.set(id, updated);
this.logger.info({ userId: id }, '用户更新成功');
return updated;
}
/**
* 删除用户
*/
async delete(id: string): Promise<void> {
this.logger.info({ userId: id }, '删除用户');
if (!this.users.has(id)) {
this.app.throw(404, '用户不存在');
}
this.users.delete(id);
this.logger.info({ userId: id }, '用户删除成功');
}
/**
* 统计用户数量
*/
async count(): Promise<number> {
return this.users.size;
}
}Tip
VextJS 的服务层通过约定式目录自动加载。将 class 或对象放在 src/services/ 目录下,框架会自动实例化并注入到 app.services 中。文件名即服务名:user.ts → app.services.user。
服务的 constructor 接收 app: VextApp 参数,可以访问 app.logger、app.config、app.throw 等框架能力。
#5. 路由
#根路由(健康检查)
// src/routes/index.ts
import { defineRoutes } from 'vextjs';
export default defineRoutes((app) => {
// GET / → 健康检查
app.get('/', {
docs: {
summary: '健康检查',
tags: ['基础'],
},
}, async (_req, res) => {
const userCount = await app.services.user.count();
res.json({
status: 'ok',
uptime: Math.floor(process.uptime()),
users: userCount,
timestamp: new Date().toISOString(),
});
});
});#用户路由(完整 CRUD)
// src/routes/users.ts
import { defineRoutes } from 'vextjs';
export default defineRoutes((app) => {
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// GET /users/list — 分页查询用户列表(公开)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.get('/list', {
validate: {
query: {
page: 'number:1-', // 页码,最小值 1
limit: 'number:1-100', // 每页条数,1-100
keyword: 'string?', // 搜索关键词(可选)
},
},
docs: {
summary: '用户列表',
description: '分页查询用户列表,支持按姓名或邮箱模糊搜索。',
tags: ['用户'],
responses: {
200: {
description: '查询成功',
example: {
items: [
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' },
],
total: 3,
page: 1,
limit: 10,
totalPages: 1,
},
},
},
},
}, async (req, res) => {
const { page, limit, keyword } = req.valid('query');
const result = await app.services.user.findAll({ page, limit, keyword });
res.json(result);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// GET /users/:id — 根据 ID 查询用户(公开)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.get('/:id', {
validate: {
param: { id: 'string:1-' },
},
docs: {
summary: '获取用户详情',
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', // 必填,长度 1-50
email: 'email', // 必填,邮箱格式
age: 'number:0-200?', // 可选,0-200
role: 'enum:admin,user?', // 可选,枚举值
},
},
middlewares: ['auth'],
docs: {
summary: '创建用户',
description: '创建一个新用户。需要 Bearer Token 认证。',
tags: ['用户'],
responses: {
201: {
description: '创建成功',
example: {
id: '4',
name: 'Diana',
email: 'diana@example.com',
role: 'user',
createdAt: '2026-03-05T00:00:00.000Z',
updatedAt: '2026-03-05T00:00:00.000Z',
},
},
400: { description: '参数校验失败' },
401: { description: '未认证' },
409: { description: '邮箱已注册' },
},
},
}, async (req, res) => {
const body = req.valid('body');
app.logger.info(
{ operator: req.user?.id, email: body.email },
'操作员创建用户',
);
const user = await app.services.user.create(body);
res.json(user, 201);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// PUT /users/:id — 更新用户(需认证)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.put('/:id', {
validate: {
param: { id: 'string:1-' },
body: {
name: 'string:1-50?', // 可选
email: 'email?', // 可选
age: 'number:0-200?', // 可选
},
},
middlewares: ['auth'],
docs: {
summary: '更新用户',
description: '更新指定用户的信息。需要 Bearer Token 认证。只需传入需要更新的字段。',
tags: ['用户'],
responses: {
200: { description: '更新成功' },
400: { description: '参数校验失败' },
401: { description: '未认证' },
404: { description: '用户不存在' },
409: { description: '邮箱已被其他用户使用' },
},
},
}, async (req, res) => {
const { id } = req.valid('param');
const body = req.valid('body');
app.logger.info(
{ operator: req.user?.id, targetUser: id },
'操作员更新用户',
);
const user = await app.services.user.update(id, body);
res.json(user);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// DELETE /users/:id — 删除用户(需认证)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app.delete('/:id', {
validate: {
param: { id: 'string:1-' },
},
middlewares: ['auth'],
docs: {
summary: '删除用户',
description: '删除指定用户。需要 Bearer Token 认证。此操作不可逆。',
tags: ['用户'],
responses: {
204: { description: '删除成功(无响应体)' },
401: { description: '未认证' },
404: { description: '用户不存在' },
},
},
}, async (req, res) => {
const { id } = req.valid('param');
app.logger.info(
{ operator: req.user?.id, targetUser: id },
'操作员删除用户',
);
await app.services.user.delete(id);
res.status(204).json(null);
});
});#6. 入口文件
// src/index.ts
import { bootstrap } from 'vextjs';
bootstrap().catch((err) => {
console.error('启动失败:', err);
process.exit(1);
});#7. 测试
// test/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createTestApp } from 'vextjs/testing';
import type { TestApp } from 'vextjs';
describe('用户 CRUD', () => {
let testApp: TestApp;
const AUTH_TOKEN = 'user-1-admin'; // 模拟管理员 token
beforeEach(async () => {
testApp = await createTestApp();
});
afterEach(async () => {
await testApp?.close();
});
// ── 查询 ──────────────────────────────────────
describe('GET /users/list', () => {
it('应返回分页用户列表', async () => {
const res = await testApp.request
.get('/users/list')
.query({ page: 1, limit: 10 });
expect(res.status).toBe(200);
expect(res.body.code).toBe(0);
expect(Array.isArray(res.body.data.items)).toBe(true);
expect(res.body.data.total).toBeGreaterThan(0);
expect(res.body.data.page).toBe(1);
});
it('支持关键词搜索', async () => {
const res = await testApp.request
.get('/users/list')
.query({ page: 1, limit: 10, keyword: 'alice' });
expect(res.status).toBe(200);
expect(res.body.data.items.length).toBe(1);
expect(res.body.data.items[0].name).toBe('Alice');
});
it('分页参数校验失败应返回 400', async () => {
const res = await testApp.request
.get('/users/list')
.query({ page: 0, limit: 10 }); // page 最小值为 1
expect(res.status).toBe(400);
});
});
describe('GET /users/:id', () => {
it('存在时应返回用户详情', async () => {
const res = await testApp.request.get('/users/1');
expect(res.status).toBe(200);
expect(res.body.data).toMatchObject({
id: '1',
name: 'Alice',
email: 'alice@example.com',
});
});
it('不存在时应返回 404', async () => {
const res = await testApp.request.get('/users/999');
expect(res.status).toBe(404);
expect(res.body.message).toBe('用户不存在');
});
});
// ── 创建 ──────────────────────────────────────
describe('POST /users', () => {
it('认证后应成功创建用户', async () => {
const res = await testApp.request
.post('/users')
.set('Authorization', `Bearer ${AUTH_TOKEN}`)
.send({
name: 'Diana',
email: 'diana@example.com',
age: 25,
});
expect(res.status).toBe(201);
expect(res.body.code).toBe(0);
expect(res.body.data).toMatchObject({
name: 'Diana',
email: 'diana@example.com',
age: 25,
role: 'user',
});
expect(res.body.data.id).toBeDefined();
expect(res.body.data.createdAt).toBeDefined();
});
it('未认证应返回 401', async () => {
const res = await testApp.request
.post('/users')
.send({ name: 'Test', email: 'test@example.com' });
expect(res.status).toBe(401);
});
it('邮箱重复应返回 409', async () => {
const res = await testApp.request
.post('/users')
.set('Authorization', `Bearer ${AUTH_TOKEN}`)
.send({
name: 'Alice Copy',
email: 'alice@example.com', // 已存在
});
expect(res.status).toBe(409);
expect(res.body.code).toBe(10001);
});
it('name 为空应返回 400', async () => {
const res = await testApp.request
.post('/users')
.set('Authorization', `Bearer ${AUTH_TOKEN}`)
.send({
name: '',
email: 'new@example.com',
});
expect(res.status).toBe(400);
expect(res.body.errors).toBeDefined();
});
it('email 格式无效应返回 400', async () => {
const res = await testApp.request
.post('/users')
.set('Authorization', `Bearer ${AUTH_TOKEN}`)
.send({
name: 'Valid Name',
email: 'not-an-email',
});
expect(res.status).toBe(400);
});
});
// ── 更新 ──────────────────────────────────────
describe('PUT /users/:id', () => {
it('认证后应成功更新用户', async () => {
const res = await testApp.request
.put('/users/1')
.set('Authorization', `Bearer ${AUTH_TOKEN}`)
.send({ name: 'Alice Updated' });
expect(res.status).toBe(200);
expect(res.body.data.name).toBe('Alice Updated');
expect(res.body.data.email).toBe('alice@example.com'); // 未修改的字段保持不变
});
it('更新不存在的用户应返回 404', async () => {
const res = await testApp.request
.put('/users/999')
.set('Authorization', `Bearer ${AUTH_TOKEN}`)
.send({ name: 'Ghost' });
expect(res.status).toBe(404);
});
it('邮箱冲突应返回 409', async () => {
const res = await testApp.request
.put('/users/1')
.set('Authorization', `Bearer ${AUTH_TOKEN}`)
.send({ email: 'bob@example.com' }); // Bob 的邮箱
expect(res.status).toBe(409);
expect(res.body.code).toBe(10002);
});
});
// ── 删除 ──────────────────────────────────────
describe('DELETE /users/:id', () => {
it('认证后应成功删除用户', async () => {
const res = await testApp.request
.delete('/users/2')
.set('Authorization', `Bearer ${AUTH_TOKEN}`);
expect(res.status).toBe(204);
// 确认已删除
const getRes = await testApp.request.get('/users/2');
expect(getRes.status).toBe(404);
});
it('删除不存在的用户应返回 404', async () => {
const res = await testApp.request
.delete('/users/999')
.set('Authorization', `Bearer ${AUTH_TOKEN}`);
expect(res.status).toBe(404);
});
it('未认证应返回 401', async () => {
const res = await testApp.request.delete('/users/1');
expect(res.status).toBe(401);
});
});
// ── 健康检查 ──────────────────────────────────
describe('GET /', () => {
it('应返回服务状态', async () => {
const res = await testApp.request.get('/');
expect(res.status).toBe(200);
expect(res.body.data).toMatchObject({
status: 'ok',
users: expect.any(Number),
});
});
});
});#8. 运行
#开发模式
pnpm dev启动后可以:
- 访问
http://localhost:3000/查看健康检查 - 访问
http://localhost:3000/docs查看自动生成的 Scalar API 文档 - 使用
curl测试各个接口
#运行测试
pnpm test#9. 接口测试
# 健康检查
curl http://localhost:3000/
# → {"code":0,"data":{"status":"ok","users":3,...},"requestId":"..."}
# 查询用户列表
curl "http://localhost:3000/users/list?page=1&limit=10"
# → {"code":0,"data":{"items":[...],"total":3,"page":1,"limit":10,"totalPages":1},"requestId":"..."}
# 搜索用户
curl "http://localhost:3000/users/list?page=1&limit=10&keyword=alice"
# → {"code":0,"data":{"items":[{"id":"1","name":"Alice",...}],...},"requestId":"..."}
# 查询单个用户
curl http://localhost:3000/users/1
# → {"code":0,"data":{"id":"1","name":"Alice","email":"alice@example.com",...},"requestId":"..."}
# 创建用户(需认证)
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"Diana","email":"diana@example.com","age":25}'
# → 201 {"code":0,"data":{"id":"4","name":"Diana",...},"requestId":"..."}
# 更新用户(需认证)
curl -X PUT http://localhost:3000/users/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"Alice Updated"}'
# → {"code":0,"data":{"id":"1","name":"Alice Updated",...},"requestId":"..."}
# 删除用户(需认证)
curl -X DELETE http://localhost:3000/users/2 \
-H "Authorization: Bearer user-1-admin"
# → 204 No Content
# 未认证访问受保护接口
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"test@example.com"}'
# → 401 {"code":-1,"message":"未提供认证令牌","requestId":"..."}
# 参数校验失败
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"","email":"invalid"}'
# → 400 {"code":-1,"message":"Validation failed","errors":[...],"requestId":"..."}#10. 关键概念总结
#请求-响应流程
客户端请求
→ requestId 中间件(生成/透传请求 ID)
→ CORS 中间件(处理跨域)
→ body-parser 中间件(解析请求体)
→ access-log 中间件(记录开始时间)
→ rate-limit 中间件(速率限制检查)
→ response-wrapper 中间件(开启出口包装)
→ auth 中间件(验证 token,注入 req.user) ← 仅受保护路由
→ validate 中间件(参数校验) ← 有 validate 配置时
→ handler(业务逻辑)
→ 出口包装({ code: 0, data, requestId })
→ 响应返回#错误处理流程
handler 中 app.throw(404, '用户不存在')
→ 抛出 HttpError
→ error-handler 中间件捕获
→ 转换为标准错误响应
→ {"code":-1,"message":"用户不存在","requestId":"..."}
→ HTTP 404#设计模式
| 模式 | 说明 |
|---|---|
| 三段式路由 | app.method(path, options, handler) — 声明式配置 |
| 服务层分离 | 业务逻辑封装在 src/services/ 中,路由只做编排 |
| 中间件白名单 | 路由级中间件必须在 config.middlewares 中声明 |
| 声明式校验 | validate 使用 schema-dsl DSL 语法,自动类型转换 |
| 统一错误处理 | app.throw() 抛出错误,框架自动转为标准格式 |
| 出口包装 | 所有成功响应自动包装为 { code: 0, data, requestId } |
| OpenAPI 自动生成 | 从 validate 和 docs 配置自动生成 API 文档 |
#下一步
- 📖 Zod 校验集成 — 使用 Zod 替换内置的 schema-dsl 校验
- 📖 Drizzle ORM 集成 — 接入 Drizzle ORM 实现真实数据库操作
- 📖 Prisma ORM 集成 — 接入 Prisma ORM 实现真实数据库操作
- 📖 测试 — 深入了解 VextJS 测试工具的高级用法
- 📖 OpenAPI 文档 — 深入了解 OpenAPI 自动生成的配置选项