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.tsapp.services.user

服务的 constructor 接收 app: VextApp 参数,可以访问 app.loggerapp.configapp.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 自动生成validatedocs 配置自动生成 API 文档

下一步

  • 📖 Zod 校验集成 — 使用 Zod 替换内置的 schema-dsl 校验
  • 📖 Drizzle ORM 集成 — 接入 Drizzle ORM 实现真实数据库操作
  • 📖 Prisma ORM 集成 — 接入 Prisma ORM 实现真实数据库操作
  • 📖 测试 — 深入了解 VextJS 测试工具的高级用法
  • 📖 OpenAPI 文档 — 深入了解 OpenAPI 自动生成的配置选项