@pylonts/fastify
v1.1.8
Published
A thin wrapper on top of Fastify — decorator-based routing, JWT auth, HMAC signature, JSON Schema validation, DB connection pooling, auto-scanned controllers.
Readme
fastify-api
Fastify 之上的一层薄封装。开发者只需写 controller / service / dao,路由注册、token 鉴权、JSON Schema 校验、统一异常处理全部自动处理。token 还原(Redis)由独立包提供(@pylonts/token-redis-driver),框架本身零持久化依赖。
核心理念:极简。不包装 Happy Path,不做 { code, data, message } 统一响应格式。Controller 返回什么就是什么,异常由框架统一捕获。
安装
npm install @pylonts/fastify快速开始
1. 入口文件
// src/main.ts
import { createApp } from '@pylonts/fastify';
import config from './config';
const app = await createApp(config);
await app.listen({ port: 3000 });// src/config.ts
import type { AppConfig } from '@pylonts/fastify';
const config: AppConfig = {
auth: {
token: {
// 还原 token 平面对象:Redis key = `${appName}.${token}`
// 用 @pylonts/token-redis-driver 的 getToken 接入
resolveToken: async (appName: string, token: string) => {
throw new Error('resolveToken not wired — configure Redis-backed token restore');
},
// app 级引导密钥(登录入口无 token 时验签,初始密钥方案 2)
bootstrapSecrets: {
admin: process.env.BOOTSTRAP_SECRET_ADMIN || 'dev-bootstrap-change-me',
},
// refreshToken 派生密钥盐
refreshKeySalt: process.env.REFRESH_KEY_SALT || 'dev-refresh-salt-change-me',
},
},
};
export default config;多环境部署详见多环境配置。
2. 写 Controller
// src/modules/user/controller/UserController.ts
import { Controller, Get, Post, Public, Login, Valid } from '@pylonts/fastify';
import type { FastifyRequest } from 'fastify';
@Controller('/api/users')
export class UserController {
@Post('/')
@Public()
@Valid({
body: {
type: 'object',
required: ['name'],
properties: { name: { type: 'string', minLength: 1 } },
},
})
async create(request: FastifyRequest) {
// request.body 已通过 JSON Schema 校验
return { id: '1', name: request.body.name };
}
@Get('/me')
@Login()
async profile(request: FastifyRequest) {
// request.token 已注入(token 鉴权通过后)
return request.token;
}
@Get('/admin')
@Login('admin')
async adminOnly() {
return { secret: 42 };
}
}
// Controller 只需 request,无需 reply:
// - 返回值自动序列化为 JSON
// - 文件下载用 @Stream 装饰器文件放到约定目录即自动发现,无需手动注册路由。
3. RPC 风格 Controller
加 @Rpc(name) 将类切换为 RPC 风格。无需 @Controller,无需在每个方法上加 @Post——框架自动扫描所有 public 方法,URL 由模块目录 + 控制器名 + 方法名自动推导。
import { Rpc, Login, Body } from '@pylonts/fastify';
import type { TokenObject } from '@pylonts/fastify';
import { Type } from '@sinclair/typebox';
@Rpc('users') // 控制器名,URL: /{module}/users/*
@Login() // 类级别:所有方法默认需要登录(可被方法级 @Login/@Public 覆盖)
export class UsersController {
// POST /{module}/users/create
@Body(Type.Object({ name: Type.String() }))
@Response('创建用户', Type.Object({ id: Type.String() }))
async create(body: { name: string }, token: TokenObject): Promise<{ id: string }> {
return { id: 'u_001' };
}
// POST /{module}/users/login — 登录入口,覆盖类级 @Login
@LoginEntry('users')
@Body(Type.Object({ code: Type.String() }))
@Response('登录', Type.Object({ token: Type.String() }))
async login(body: { code: string }): Promise<{ token: string }> {
return { token: 'generated-token' };
}
// POST /{module}/users/profile — 继承类级 @Login
@Response('获取用户信息', Type.Object({ id: Type.String() }))
async profile(_body: Record<string, never>, token: TokenObject): Promise<{ id: string }> {
return { id: String(token.id) };
}
}RPC 方法签名 (body, token):第一个参数是已校验的请求体,第二个参数是 request.token(token 鉴权后注入的平面对象)。RPC 方法必须声明为 async(返回 Promise),框架类型 RpcHandler 强制此约束。
路由推导规则:
| 来源 | 推导逻辑 |
|------|----------|
| module | 模块文件夹名,可通过 route.config.ts 覆盖 |
| controller | @Rpc(name) 参数,默认为类名 |
| method | 方法名,保持不变 |
// src/modules/user/route.config.ts
export const module = 'u';
// → URL: /u/{controller}/{method}urlPrefix 是完整的路径前缀段,支持多级路径。 可以包含 / 构造嵌套路由:
// src/modules/user-rpc/route.config.ts
export const urlPrefix = 'api/urpc';
// → URL: /api/urpc/{controller}/{method}不配置时 URL 前缀默认使用模块目录名。仅在 @Rpc 控制器中生效,@Controller 不受影响。
模块级 token 隔离:route.config.ts 可配置 module,作为 @Login/@LoginEntry 缺省时的 app 名(Redis key 前缀),防止跨模块 token 滥用:
// src/modules/admin/route.config.ts
export const urlPrefix = 'api/admin'; // URL 路径(可选,默认使用模块目录名)
export const module = 'admin'; // 模块归属,兼作缺省 app 名@Login() 的参数与模块配置的 module 是同一概念——app 名字(模块名字)。区别仅在于作用范围。优先级:方法级 > 类级 > 模块级:
| 配置 | 校验 | 作用范围 |
|------|------|----------|
| @Login('admin')(方法上) | token 签名 + app 归属 admin | 单个方法 |
| @Login('admin')(类上) | 同上 | 整个 Controller,可被方法级覆盖 |
| 模块 module:'admin' | 同上 | 整个模块,可被类/方法级覆盖 |
| @Login()(无参数) | token 签名(app 名回落 x-app-key) | 方法/类 |
| @Public() | 跳过签名校验 | 单个方法 |
| @LoginEntry('admin') | 登录入口(无 token),app 级引导密钥验签 | 方法/类 |
| 限制 | 说明 |
|------|------|
| HTTP 方法 | 全部使用 @Post,不可更改 |
| 参数 | 只用 @Body,不支持 @Query/@Params |
| URL | 不支持路径参数 :id |
4. 服务端参数注入(__inject)
RPC handler 签名 (body, user) 中的 user 只传给 controller,默认进不了 DTO。多租户等场景需要把 user.id(或其他服务端上下文)写入请求体字段时,用 __inject 适配函数声明——框架在调用 controller 前自动执行,controller / service 无感知。
机制:在任意 TypeBox schema(基类)上挂一个不可枚举的 __inject 属性,值为 (body, user) => void 适配函数。@Body 检测到后存入路由元数据,RPC 调用前逐个执行。框架只提供机制,不内置基类——基类由项目自己定义。
// ① 项目定义注入基类(任意字段名、任意类型,适配逻辑在函数内)
// src/dto_schema/common/tenant-context.ts
import { Type } from '@sinclair/typebox';
import type { User } from '@pylonts/fastify';
export const TenantContext = Object.defineProperty(
Type.Object({ merId: Type.Optional(Type.Number()) }),
'__inject',
{
value: (body: Record<string, unknown>, user: User) => {
body.merId = Number(user.id); // string user.id → number 字段
},
enumerable: false, // 非枚举:swagger / JSON 序列化 / AJV 编译全部跳过
},
);
// ② DTO include 基类(生成器渲染为 Type.Intersect,原样保留 __inject)
// src/dto_schema/xxx/create.dto.ts
export const CreateRequest = dto
.input({ name: dto.create().ref(merchant.name) })
.include({ from: './tenant-context', name: 'TenantContext' })
.as('CreateRequest');
// ③ Controller 无感知——body 已含注入字段
@Rpc('merchant')
@Login()
class MerchantController {
@Body(CreateRequest)
@Response('新增商户', Type.Object({ id: Type.Number() }))
async add(body: CreateRequest, _user: User) {
return service.add(body); // body.merId 已由框架注入
}
}要点:
__inject值是适配函数(body, user) => void——字段名、类型转换(如Number(user.id))、多字段注入全部由函数负责,框架不解析任何类型enumerable: false必须设置——避免__inject泄漏进 swagger / JSON 序列化- 多个注入基类可叠加(
Type.Intersect保留各基类引用,框架递归收集所有__inject) - 注入发生在 AJV 校验之后、controller 调用之前,客户端传值会被服务端值覆盖(租户安全)
- 仅 RPC 风格生效(
@Controller的(request, reply)风格无(body, user)分离,不支持注入)
5. contextPath
部署在反向代理子路径下时,配置 contextPath,Controller 代码无需变动:
const app = await createApp({
contextPath: '/myapp',
});
// @Controller('/api/users') → 响应地址:/myapp/api/users搭配 nginx:
location /myapp/ {
proxy_pass http://127.0.0.1:3000;
}配置项
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| auth.token.resolveToken | (appName, token) => Promise<TokenObject \| null> | 否 | token 还原:Redis key ${appName}.${token},查不到/过期返回 null(401 code=login)。@pylonts/token-redis-driver 提供 |
| auth.token.bootstrapSecrets | Record<string, string> | 否 | app 级引导密钥(登录入口无 token 时验签,初始密钥方案 2):appName → secret |
| auth.token.refreshKeySalt | string | 否 | refreshToken 派生密钥盐(refresh 登录入口验签) |
| auth.token.timestampWindowMs | number | 否 | 时间戳容忍窗口,默认 5 分钟 |
| auth.token.nonceStore | NonceStore | 否 | 防重放存储,默认内存 MemoryNonceStore |
| contextPath | string | 否 | 路径前缀,如 /myapp,Controller 代码无需变动 |
| swagger | object | 否 | Swagger / OpenAPI 文档配置(title / description / version / uiPrefix) |
| static | object | 否 | 静态文件服务(root / prefix),SPA fallback 到 index.html |
| port / host | number / string | 否 | 监听地址(也可在 app.listen() 传入) |
多环境配置
源码保留 3 份环境配置文件作为参考,构建时只复制目标那份为 config.ts 参与编译。
目录结构
src/
├── main.ts
├── config.ts ← 构建时由脚本写入(.gitignore)
└── config/
├── development.ts ← 源码参考:本地开发
├── test.ts ← 源码参考:测试环境
└── production.ts ← 源码参考:生产环境构建脚本
{
"scripts": {
"build:dev": "copy src\\config\\development.ts src\\config.ts >nul 2>&1 & npx tsc",
"build:test": "copy src\\config\\test.ts src\\config.ts >nul 2>&1 & npx tsc",
"build:prod": "copy src\\config\\production.ts src\\config.ts >nul 2>&1 & npx tsc"
}
}配置文件写法
// src/config/production.ts
import type { AppConfig } from '@pylonts/fastify';
const config: AppConfig = {
auth: {
token: {
resolveToken: async (appName: string, token: string) => {
// 用 @pylonts/token-redis-driver 的 getToken 接入
throw new Error('resolveToken not wired');
},
bootstrapSecrets: {
admin: process.env.BOOTSTRAP_SECRET_ADMIN!, // 敏感信息从环境变量注入
},
refreshKeySalt: process.env.REFRESH_KEY_SALT!,
},
},
swagger: false, // 生产环境关闭文档
};
export default config;入口文件
// src/main.ts
import { createApp } from '@pylonts/fastify';
import config from './config';
const app = await createApp(config);
await app.listen({ port: 3000 });Docker 部署
FROM node:24-alpine
WORKDIR /app
COPY . .
RUN npm install && npm run build:prod
CMD ["node", "dist/main.js"]敏感信息通过环境变量注入,不写在配置文件里:
# 测试环境(生产用 build:prod,方法同上)
docker run -d \
-e BOOTSTRAP_SECRET_ADMIN=test-bootstrap \
-e REFRESH_KEY_SALT=test-refresh-salt \
my-app:latest数据库与 Redis 连接(
@pylonts/dao/@pylonts/token-redis-driver)同样通过环境变量注入,见各自包文档。
项目结构
src/
├── main.ts
└── modules/
└── user/
├── controller/UserController.ts ← @Controller/@Get/@Post 声明路由
├── service/UserService.ts ← 纯业务流程编排
├── dao/
│ └── UserDao.ts ← knex 查询(@pylonts/dao)
├── dto/
│ ├── CreateUserRequest.ts
│ └── UserResponse.ts
├── entity/UserEntity.ts
└── enums/UserStatus.ts装饰器
| 装饰器 | 作用 |
|--------|------|
| @Controller('/prefix') | 声明路由前缀,类必须 export |
| @Get('/path') | GET 路由 |
| @Post('/path') | POST 路由 |
| @Put('/path') | PUT 路由 |
| @Delete('/path') | DELETE 路由 |
| @Login() | 需要 token 签名验证 + 登录身份(security-only 匿名 token 拒绝)。支持类级别(作用于所有方法)和方法级别(覆盖类级设置) |
| @Login('admin') | 需要 token 签名 + app 归属 admin(app 名字 = 模块名字)+ 登录身份。支持类级别和方法级别 |
| @LoginEntry('admin') | 标记 app 的登录入口(login/refresh,无 token),app 级引导密钥 / refreshToken 派生密钥验签 |
| @Public() | 跳过签名校验(公开接口) |
| @Valid(schema) | 绑定 Fastify JSON Schema 校验 |
| @Body(def) | 声明式请求体校验 |
| @Query(def) | 声明式查询参数校验 |
| @Params(def) | 声明式路径参数校验 |
| @Response(description, def, statusCode?) | 声明响应 Schema,用于 OpenAPI 文档生成,默认 200 |
| @Rpc(name?) | RPC 风格控制器。无需 @Controller,方法自动扫描,签名 (body, user)。name 覆盖控制器名,默认类名。 |
| @Stream(description?) | 流式下载端点,方法返回 StreamResponse { name, content }。description 注入 OpenAPI 响应描述。 |
| @File(description?) | 文件下载端点,方法返回 FileResponse { name, path }。description 注入 OpenAPI 响应描述。 |
| @Multipart() | 标记 multipart/form-data 文件上传端点。替代 @Body(输入层),仍需 @Response(输出层)。 |
声明式校验
比手写 JSON Schema 更简洁的类型工具:
import {
Body, Query, Params,
String, Number, Boolean, Email, Required, ArrayOf, Enum, Min, Max,
} from '@pylonts/fastify';
@Controller('/api/users')
class UserController {
@Post('/')
@Body({
name: Required(String),
email: Required(Email),
age: Number,
tags: ArrayOf(String),
status: Enum(['active', 'inactive']),
})
create(request: FastifyRequest) {
// request.body 已校验
}
@Get('/')
@Query({ page: Number, keyword: String })
list(request: FastifyRequest) { ... }
@Get('/:id')
@Params({ id: Required(String) })
show(request: FastifyRequest) { ... }
}@Body/@Query/@Params 自动生成标准 JSON Schema,与 @Valid 等价但可读性更高。
Swagger / OpenAPI
配置 swagger 即可自动生成 OpenAPI 文档,路由、参数、Schema 全部自动提取:
const app = await createApp({
swagger: {
title: 'My API',
description: 'API 文档',
version: '1.0.0', // 可选,默认读取 package.json version
uiPrefix: '/docs', // 可选,默认 /docs
},
});启动后访问 /docs 即可浏览 Swagger UI,/docs/json 获取 OpenAPI JSON。
配置 contextPath 时自动联动,例如 contextPath: '/myapp' → Swagger UI 在 /myapp/docs。
依赖自动补充 @fastify/swagger + @fastify/swagger-ui。
@Response — 声明响应 Schema
@Response(description, def, statusCode?) 声明接口的响应结构,写入路由的 OpenAPI responses,不参与运行时校验。def 引用 DTO 而非内联——DTO 由 dto_schema DSL 生成(npm run dto:gen → api-dto/src/**/*.dto.gen.ts):
import { Rpc, Login, Body, Response } from '@pylonts/fastify';
import type { User } from '@pylonts/fastify';
import {
BdListRequest,
BdListResponse,
BdRow,
BdDetail,
} from '@your-app/api-dto/admin/bd';
import { EmptyBody } from '@pylonts/core';
@Rpc('bd')
class BdController {
@Login()
@Body(BdListRequest)
@Response('BD列表', BdListResponse)
list(body: BdListRequest, user: User): Promise<BdListResponse> { ... }
@Login()
@Body(BdAddRequest)
@Response('新增BD', BdRow)
add(body: BdAddRequest, user: User): Promise<BdRow> { ... }
// 非 200 状态码 — 第三个参数指定 statusCode
@Response('校验失败', SomeErrorResponse, 422)
validate(body: SomeRequest, user: User): Promise<SomeErrorResponse> { ... }
}description 缺省时按状态码使用默认文案(200 → OK、422 → Unprocessable Entity 等)。
@Stream / @File 内部写入同一 response 槽位(content type 为 application/octet-stream),与 @Response 互斥,不要叠加使用(后写覆盖先写)。
统一响应
Happy path:Controller return data,原样输出 JSON,不做包装。
{ "id": "1", "name": "张三" }Error path:自动捕获异常,统一格式:
HTTP 401 ← token 鉴权失败 / 签名失败
HTTP 403 ← 角色权限不足
HTTP 404 ← 路由不存在
HTTP 422 ← BusinessException、NotFoundException、Schema 校验失败
HTTP 500 ← ServiceException、未预期异常(message 通用化,不暴露堆栈)业务/校验异常:
{ "error": "手机号已存在" }鉴权异常:
{ "code": "login", "error": "Authorization token is invalid" }
{ "code": "error", "error": "签名验证失败" }
{ "code": "retry", "error": "凭证无效或已过期" }异常
import { BusinessException, NotFoundException, ServiceException } from '@pylonts/fastify';
throw new BusinessException('库存不足'); // → 422 { error: '库存不足' }
throw new NotFoundException('用户不存在'); // → 422 { error: '用户不存在' }
throw new ServiceException('推送失败'); // → 500 { error: '服务器内部错误' }Token 鉴权
统一 token 鉴权链(TokenStrategy),替代旧 HMAC + JWT 双层体系。所有接口默认需要签名验证(@Public() 标记的接口跳过签名)。@Login() 标记的接口在签名基础上要求登录身份(token 对象必须含 identity 字段,security-only 匿名 token 拒绝)并限定 app 归属。无任何标注的接口 = 匿名浏览:仅验签(x-token + 对象 secret),匿名 sign token 可通过,不要求身份。
三种验证模式
| 模式 | 判定 | 验签密钥 | 身份要求 |
|------|------|---------|---------|
| 无标注接口(匿名浏览) | 请求头带 x-token | resolveToken(appName, token) 还原 Redis 平面对象,取 object.secret | ❌(匿名 sign token 可过) |
| @Login 业务接口 | 请求头带 x-token | 同上 | ✅(token 必须含 identity 字段) |
| login 入口(@LoginEntry,无 token) | body 无 refreshToken | app 级引导密钥 bootstrapSecrets[appName](初始密钥方案 2) | 登录入口 |
| refresh 入口(@LoginEntry,无 token) | body 携带 refreshToken | deriveRefreshKey(refreshToken, refreshKeySalt)——能正确签名 == 持有 refreshToken | 登录入口 |
app 名解析:@Login(module) / @LoginEntry(app) 的参数,缺省回落 x-app-key 请求头。
@Public() 只允许出现在第三方回调 controller(src/modules/{thirdApi}/,外部系统不持有平台密钥,第三方自身验签由业务手写)——pylonts lint controller 强制此白名单。
token 按 app 隔离——Redis key 前缀 {appName}.{token},admin 的 token 无法访问其他 app 的接口。
服务端配置
const app = await createApp({
auth: {
token: {
// token 还原(必配,否则业务路由 500)
resolveToken: async (appName: string, token: string) => {
const obj = await tokenStore.get(`${appName}.${token}`);
return obj ?? null; // null → 401 { code: 'login', error: '登录已过期' }
},
// app 级引导密钥(login 入口验签)
bootstrapSecrets: { admin: 'dev-bootstrap-secret' },
// refreshToken 派生密钥盐(refresh 入口验签)
refreshKeySalt: 'dev-refresh-salt',
timestampWindowMs: 5 * 60 * 1000, // 可选,默认 5 分钟
},
},
});resolveToken 可对接任意存储(Redis 用 @pylonts/token-redis-driver 的 getToken)。
路由使用
默认所有接口需签名,request.token 为还原的平面对象:
@Controller('/api/app')
export class AppController {
@Get('/data')
async data(request: FastifyRequest) {
return { clientId: request.token.appKey };
}
@Get('/profile')
@Login()
async profile(request: FastifyRequest) {
// 签名通过 + token 校验通过 → request.token 为平面对象
return request.token;
}
}签名/鉴权失败统一返回 401 { code, error }:
code: 'error'— 不可重试(签名不匹配、缺少参数、timestamp/nonce 异常)code: 'login'— token 缺失/过期,客户端跳转登录code: 'retry'— 签名凭据无效,客户端刷新后重试
服务端日志会详细记录失败原因和客户端数据。支持 nonce 防重放(默认内存 MemoryNonceStore,可注入自定义 NonceStore)、timestamp 时间窗口校验、constant-time 签名比对。
文件上传
上传时签名逻辑不变:将 JSON 元数据签名后以 _sign_body 字段嵌入 multipart/form-data,文件以 file 字段附带:
@Controller('/api/app')
class AppController {
@Post('/upload')
async upload(request: FastifyRequest) {
const body = request.body as Record<string, unknown>;
// body.file → { type: 'file', fieldname, filename, mimetype, file, _buf }
// body._sign_body → { type: 'field', value: '{"fileName":"a.png",...}' }
}
}文件下载
import { Controller, Get, Stream } from '@pylonts/fastify';
import type { StreamResponse } from '@pylonts/fastify';
@Controller('/api/report')
class ReportController {
@Get('/export')
@Stream('导出CSV报表')
async exportCSV(): Promise<StreamResponse> {
const csv = ['name,phone', '张三,138xxxx'].join('\n');
return { name: 'report.csv', content: csv };
}
}Controller 只需返回 { name, content },框架自动设置 Content-Disposition 并流式返回。无需 reply 参数。
content 支持 string 和 Buffer,适用 CSV / Excel / 图片等任意二进制数据。
Token 工具
deriveRefreshKey — refreshToken 派生密钥
import { deriveRefreshKey } from '@pylonts/fastify';
const key = deriveRefreshKey(refreshToken, refreshKeySalt);HMAC(refreshToken, 固定盐)——能正确签名 == 持有 refreshToken,签名与凭据一体、每用户独立。
DB 工具(@pylonts/dao)
数据库访问已从框架拆出,由 @pylonts/dao 提供(knex 实例 + 连接池工厂 + 迁移):
import { knex } from '@pylonts/dao';
// 查多行
const users = await knex('users').select('*').where({ status: 0 });
// 查单行
const user = await knex('users').where({ id }).first();
// 增删改
await knex('users').update({ name: '李四' }).where({ id });无需手动 getConnection / release,连接池由工厂自动管理。迁移工具见 @pylonts/dao 的 migrate。
Redis Token 存储(@pylonts/token-redis-driver)
token 还原已拆到 @pylonts/token-redis-driver:
import { initRedisClient } from 'ioredis';
import { initTokenRedis, createToken, getToken } from '@pylonts/token-redis-driver';
const redis = new Redis({ host: '127.0.0.1', port: 6379 });
initTokenRedis(redis); // 应用启动时注册
const { token } = await createToken('admin', { secret, cipher }); // login 写侧
const obj = await getToken('admin', token); // 请求读侧initTokenRedis(redis)— 注册 ioredis 客户端(启动时)createToken/attachIdentity/rotateToken/deleteToken/getToken— token 生命周期- key =
{appName}.{token},TTL 30 分钟,纯随机 hash(256-bit hex)
token 还原接入框架:
auth: {
token: {
resolveToken: getToken, // (appName, token) → TokenObject | null
bootstrapSecrets: { admin: '...' },
refreshKeySalt: '...',
},
}分页
import type { PageResult } from '@pylonts/fastify';
@Get('/')
async list(): Promise<PageResult<UserResponse>> {
return {
items: [...],
total: 100,
page: 1,
pageSize: 20,
};
}枚举规范
// DB number → 数值枚举
export enum UserStatus {
NORMAL, // 0
LOCKED, // 1
}
// DB string → 字符串枚举
export enum OrderType {
NORMAL = 'NORMAL',
RUSH = 'RUSH',
}所见即所得,无额外 code/description 字段。
二次开发
框架设计为可扩展,以下是在其之上做二次开发的常见模式。
定制校验错误消息
框架内置 errorRules,覆盖 24 个 JSON Schema 关键字的默认中文提示。可通过 addRule 覆盖:
import { addRule } from '@pylonts/fastify';
// 全局生效,框架启动前调用一次即可
addRule('required', (field) => `${field} 是必填的`);
addRule('pattern', (field) => `${field} 格式不对`);
addRule('type', (field) => `${field} 类型不匹配`);不调用则使用内置默认(password 必填、phone 格式错误、age 类型错误 等)。未覆盖的关键字回退到 AJV 原始英文。
完整的关键字列表见 src/common/validation/errorRules.ts。
自定义 AJV 校验关键字
标准 JSON Schema 关键字不够用时,通过 addKeyword 注册自己的规则:
// src/main.ts — createApp 之前调用
import Ajv from 'ajv';
const ajv = new Ajv();
ajv.addKeyword({
keyword: 'isPhone',
validate: (_schema, data) => /^1[0-9]{10}$/.test(data),
errors: true,
error: {
message: (cxt) => `${cxt.instancePath.replace(/^\//, '')} 手机号格式不正确`,
},
});然后在 Controller 中使用:
// Typebox 风格
const Request = Type.Object({
phone: Type.String({ isPhone: true }),
});
// SchemaProp 风格
@Body({ phone: { type: 'string', isPhone: true } })关键字注册时自带的 error.message 会自动被 errorRules.formatError 使用,无需额外配置。
自定义 SchemaProp 辅助
在 schema.ts 中新增类型辅助,在项目中任何 Controller 直接使用:
// src/common/validation/schema.ts
export const Phone: SchemaProp = { type: 'string', pattern: '^1[0-9]{10}$' };
export const IdCard: SchemaProp = {
type: 'string',
pattern: '^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$',
};// Controller
@Body({
phone: Required(Phone),
idCard: Required(IdCard),
})自定义 resolveToken
token 还原可对接任何存储系统(Redis、DB、内存):
import { knex } from '@pylonts/dao';
createApp({
auth: {
token: {
resolveToken: async (appName: string, token: string) => {
const row = await knex('tokens').where({ app_name: appName, token }).first();
return row
? { secret: row.secret, ...JSON.parse(row.payload) }
: null;
},
},
},
});自定义 NonceStore
签名防重放实现可替换(默认 MemoryNonceStore 为单实例内存实现,多实例部署必须注入共享存储,否则各实例 nonce 相互独立、重放防护失效):
import { RedisNonceStore } from '@pylonts/sign-redis-driver';
class MyNonceStore {
async checkAndSet(nonce: string, ttlSeconds: number): Promise<boolean> { /* ... */ }
}
createApp({
auth: {
token: {
resolveToken,
nonceStore: new RedisNonceStore(), // 多实例共享(Redis SET NX EX)
// nonceStore: new MyNonceStore(), // 或自定义实现
},
},
});自定义 AuthStrategy
TokenStrategy 已覆盖 token 体系全部验证模式(业务 / login 入口 / refresh 入口)。对接 Session、OAuth 等非 token 鉴权时,实现 AuthStrategy 并替换工厂:
import type { AuthStrategy, TokenObject } from '@pylonts/fastify';
class SessionStrategy implements AuthStrategy {
async authenticate(request: FastifyRequest, _reply: FastifyReply): Promise<TokenObject> {
const sessionId = request.cookies.session;
// 用 @pylonts/dao 的 knex 或任意存储查询会话
const user = await yourSessionStore.get(sessionId);
if (!user) throw new AuthException('会话已过期');
return { id: user.id, secret: 'session-secret' };
}
}自定义 Controller 元数据扫描
框架通过 Reflect.defineMetadata / getMetadata 管理路由元数据。装饰器和扫描逻辑在 src/common/scanner/ 中,侵入式修改可参考:
decorators.ts— 新增装饰器(如@RateLimit、@Cache)index.ts— 扫描和路由注册(scanControllers)
框架内置的 @Response、@Stream 均按此模式实现,是添加新装饰器的范本。
新增装饰器示例:
const RATE_LIMIT_META = Symbol('route:rateLimit');
export function RateLimit(max: number, windowMs: number): MethodDecorator {
return (target, propertyKey) => {
Reflect.defineMetadata(RATE_LIMIT_META, { max, windowMs }, target, propertyKey);
};
}// scanner/index.ts 中读取
const rateLimitMeta = Reflect.getMetadata(RATE_LIMIT_META, exported, route.handlerName);
if (rateLimitMeta) {
routeDef.preHandler = [/* rate limiter hook */];
}数据库迁移(@pylonts/dao)
迁移能力已拆到 @pylonts/dao 的 migrate(工厂创建连接后执行 migrations/*.sql,追踪已执行文件)。框架本身不执行 DDL。
import { createPool, migrate } from '@pylonts/dao';
const pool = createPool({ /* mysql config */ });
await migrate(pool, path.join(projectRoot, 'src/migrations'));依赖
- Fastify 5
- @fastify/multipart(文件上传)
- @fastify/static(静态文件,可选)
- @fastify/swagger + @fastify/swagger-ui(API 文档,可选)
- reflect-metadata(装饰器元数据)
- fast-glob(Controller 自动扫描)
token 还原 / DB 为外部可选依赖:@pylonts/token-redis-driver、@pylonts/dao。
TypeScript experimentalDecorators 和 emitDecoratorMetadata 必须启用。
参考
待实现
| 特性 | 说明 |
|------|------|
| CORS | @fastify/cors,跨域支持 |
| 安全头 | @fastify/helmet,CSP / HSTS / X-Frame-Options 等 |
| 请求 ID / 链路追踪 | 为每个请求注入 trace-id,日志可串联 |
| 限流 | 基于令牌桶或滑动窗口的速率限制 |
| 缓存抽象 | 防穿透(空值缓存)、防击穿(单飞锁)、防雪崩(随机 TTL) |
