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 自动生成的配置选项