外观
验证管道 (Zod)
基于 Zod 的请求数据验证管道,Schema 即验证规则,自动推断 TypeScript 类型。
ZodPipe
参数级使用(推荐)
使用 Zod() 工厂函数搭配 NestJS 原生 @Body / @Query / @Param 装饰器:
typescript
import { z } from 'zod'
import { Zod, zStr, zStrReq, zInt } from '@maxtan/nest-core'
// 定义 Schema
const CreateUserSchema = z.object({
username: zStr.pipe(z.string().min(2).max(20)),
email: z.string().email(),
age: zInt.pipe(z.number().int().min(1).max(150).optional())
})
type CreateUser = z.infer<typeof CreateUserSchema>
@Controller('users')
export class UserController {
@Post()
create(@Body(Zod(CreateUserSchema)) dto: CreateUser) {
// dto 已验证、已推断类型
return this.userService.create(dto)
}
@Get()
list(@Query(Zod(ListSchema)) query: ListDto) {
return this.userService.list(query)
}
}
Zod(schema)等价于new ZodPipe(schema)的简写形式。
ZodPipe本身只负责safeParse和错误转换,不会默认对所有字符串做全局空串归一化。空串转undefined/null这类语义,应当由字段级 helper 显式表达。
全局注册
typescript
import { APP_PIPE } from '@nestjs/core'
import { ZodPipe } from '@maxtan/nest-core'
@Module({
providers: [
{ provide: APP_PIPE, useClass: ZodPipe }
]
})
export class AppModule {}全局注册时 ZodPipe 不绑定 Schema,不会对无 Schema 的参数做验证。推荐在参数级传入具体 Schema。
配置选项
typescript
Zod(schema, {
showAllErrors: true, // 返回所有错误(默认 false,只返回第一条)
errorSeparator: ' | ' // 错误分隔符(默认 '; ')
})| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
showAllErrors | boolean | false | 是否返回所有验证错误 |
errorSeparator | string | '; ' | 多条错误的分隔符 |
错误响应示例
管道内置中文错误翻译,无需在 Schema 里手写错误信息:
json
{
"code": 400,
"message": "username: 最少2个字符",
"success": false,
"timestamp": 1700000000000
}显示所有错误时:
json
{
"message": "username: 最少2个字符; email: 邮箱格式不正确"
}请求参数为空时:
json
{
"code": 400,
"message": "请求参数不能为空",
"success": false
}内置错误翻译对照:
| 场景 | 提示 |
|---|---|
| 字段未传 | username: 必填 |
| 字符串长度 | username: 最少2个字符 / 最多20个字符 |
| 数字范围 | age: 最小值1 / 最大值150 |
| 数组长度 | tags: 最少1项 / 最多10项 |
| 邮箱格式 | email: 邮箱格式不正确 |
| URL 格式 | website: URL 格式不正确 |
| 枚举值 | role: 值必须是 admin | user 之一 |
如需自定义错误信息,仍可在 Schema 里传入:
z.string().min(2, '用户名太短了')
Zod 预处理器
提供了一组 Zod 预处理器,用于自动转换前端传来的字符串参数。
基础预处理器默认选填(输出 T | undefined),通过 .pipe() 追加必填或约束规则。高频场景提供了 Req 简写。
Prisma 对齐辅助器默认必填(对齐 Prisma 非空列),通过 .optional() 变选填。
字符串字段现在推荐优先按语义选 helper,而不是直接写原生 z.string():
- 普通可选文本:
zStr - 必填文本:
zStrReq - 可空文本:
zStrNull - 可空
VarChar列:zVarCharNull(max, min?) - 字符串数组:
zStrArr(options?)
预处理器一览
| 分类 | 预处理器 | 输出类型 | 说明 |
|---|---|---|---|
| 字符串 | zStr | string? | trim + 空串→undefined |
zStrReq | string | trim 后必填字符串 | |
zStrNull | string | null | undefined | trim + 空串→null | |
zVarChar(max, min?) | string | 对齐 Prisma @db.VarChar(n) | |
zVarCharNull(max, min?) | string | null | undefined | 对齐可空 VarChar,空串→null | |
| 数字 | zNum() | number? | 安全数字转换(含小数) |
zNum(true) / zInt | number? | 安全整数转换(推荐用 zInt) | |
zNumReq | number | = zNum().pipe(z.number()),必填数字 | |
zIntReq | number | = zInt.pipe(z.number().int()),必填整数 | |
| 枚举 | zEnumInt(...values) | T | 对齐 Prisma @db.TinyInt,必填 |
zEnumStr(...values) | T | 对齐 Prisma enum,必填 | |
| 查询 | zQueryInt(options?) | number? | 查询整数,空串→undefined,非法值报错 |
zQueryId(options?) | string? | 查询 ID,空串→undefined | |
zQueryEnumInt(...values) | T? | 查询整数枚举,支持枚举对象 | |
zQueryEnumStr(...values) | T? | 查询字符串枚举,支持枚举对象 | |
| 布尔 | zBool | boolean? | 智能布尔转换 |
| 数组 | zStrArr(options?) | string[]? | 字符串数组逐项 trim,默认过滤空串 |
zArr | any[]? | 通用逗号分隔拆数组(自定义用 zArrOf()) | |
| 日期 | zDate | Date? | 字符串/时间戳→Date |
zDateReq | Date | = zDate.pipe(z.date()),必填日期 |
zStr — 可选字符串
去除首尾空格,空字符串转为 undefined:
typescript
import { zStr, zStrReq } from '@maxtan/nest-core'
const schema = z.object({
nickname: zStr, // 选填
bio: zStr,
name: zStrReq, // 必填(无约束)
code: zStrReq,
username: zStr.pipe(z.string().min(2)),
})
// ' hello ' → 'hello'
// ' ' → undefined推荐场景:
- 搜索关键词、备注、标题等普通可选文本
Update DTO里“不传就不更新”的字符串字段- 需要统一 trim,但不希望空串落库的字段
不建议用于:
- 业务上明确需要保留空串的字段
- 前端传空串表示“清空为 null”的字段
zStrNull / zVarCharNull — 可空字符串
当数据库列允许 NULL,且前端常用空串表示“清空字段”时,使用 zStrNull 或 zVarCharNull:
typescript
import { z } from 'zod'
import { zStrNull, zVarCharNull } from '@maxtan/nest-core'
const UpdateProfile = z.object({
intro: zStrNull,
nickname: zVarCharNull(50, 2)
})
// { intro: ' hi ' } -> { intro: 'hi' }
// { intro: ' ' } -> { intro: null }
// { nickname: '' } -> { nickname: null }
// { nickname: 'a' } -> 报错(最少 2 个字符)选型建议:
- 只需要“trim + 空串转 null”:用
zStrNull - 还要对齐
@db.VarChar(n)长度:用zVarCharNull(max, min?) - 如果空串应该表示“不传”,仍然用
zStr
zVarChar — 对齐 Prisma VarChar
自动 trim + 必填 + 长度约束,直接对齐 Prisma schema 列定义:
typescript
import { zVarChar } from '@maxtan/nest-core'
// schema.prisma: username String @db.VarChar(50)
const username = zVarChar(50, 2) // 必填 + min(2) + max(50)
// schema.prisma: nickname String? @db.VarChar(50)
const nickname = zVarChar(50).optional() // .optional() 对齐 Prisma ?
// schema.prisma: remark String? @db.VarChar(500)
const remark = zVarChar(500).optional()如果字段是可空列,且前端空串需要转成 null,不要再写 z.string().nullable().optional(),直接改用:
typescript
const intro = zVarCharNull(500)zNum / zInt — 安全数字转换
typescript
import { zNum, zInt, zNumReq, zIntReq } from '@maxtan/nest-core'
const schema = z.object({
price: zNum(), // 选填浮点数
amount: zNumReq, // 必填浮点数
page: zInt.pipe(z.number().int().min(1).default(1)), // 选填整数 + 默认值
age: zIntReq, // 必填整数
status: zInt.pipe(z.number().int().min(0).max(1).optional()), // 选填整数 + 约束
})
// '' → undefined(不会转为 0)
// 'abc' → undefined
// '99.9' → 99.9(zNum)/ 99(zInt)
zInt等价于zNum(true),推荐使用zInt,更简洁。
zQueryInt / zQueryId — 查询参数专用基础类型
这两个 helper 会把 undefined、null、空串、空白串都视为“未传”,但不会把其它非法值静默吞成 undefined:
typescript
import { zQueryId, zQueryInt } from '@maxtan/nest-core'
const ListRoomSchema = z.object({
buildingNo: zQueryInt({ min: 1 }),
month: zQueryInt({ min: 1, max: 12 }),
ownerId: zQueryId(),
roleId: zQueryId(64)
})
// null / '' / ' ' -> undefined
// '12' -> 12
// 'abc' -> 报错
// true / {} -> 报错适用场景:
- 后台列表筛选中的楼栋编号、月份、类型、状态等整数条件
- 关联对象筛选中的
roleId、ownerId、groupId之类可选 ID - 前端表单初始值常用
null,但后端语义是“不传就不过滤”
不建议用于:
@Param('id')这类必填路径参数- 创建、更新接口中的实体字段写入
- 需要把
null当作真实业务值的场景
zEnumInt / zEnumStr — 对齐 Prisma 枚举
类型安全的枚举校验,返回必填 schema,查询/更新场景按需 .optional():
typescript
import { zEnumInt, zEnumStr } from '@maxtan/nest-core'
// 对齐 Prisma @db.TinyInt 离散值
const status = zEnumInt(0, 1) // 必填,类型 0 | 1
const type = zEnumInt(1, 2, 3) // 必填,类型 1 | 2 | 3
// 对齐 Prisma enum
const role = zEnumStr('ADMIN', 'USER', 'GUEST') // 必填,类型 'ADMIN' | 'USER' | 'GUEST'
// 在 Schema 中按需可选
const ListSchema = zPage().extend({
status: status.optional(), // 查询过滤 → 选填
role: role.optional() // 查询过滤 → 选填
})
const CreateSchema = z.object({
status, // 创建时必填
role: role.optional() // DB 有默认值 → 选填
})推荐搭配
as const枚举常量使用,参见下方 Prisma 集成模式。
zQueryEnumInt / zQueryEnumStr — 查询枚举过滤
用于列表查询、筛选条件、统计接口过滤项。与 zEnumInt / zEnumStr 的区别是:null 和空串都会视为未传,不参与过滤。
typescript
import { zQueryEnumInt, zQueryEnumStr, zPage } from '@maxtan/nest-core'
export const UserStatus = { DISABLED: 0, ENABLED: 1 } as const
export const UserRole = { ADMIN: 'ADMIN', USER: 'USER', GUEST: 'GUEST' } as const
const ListUserSchema = zPage().extend({
status: zQueryEnumInt(UserStatus),
role: zQueryEnumStr(UserRole)
})
// { status: null, role: ' ' } -> { status: undefined, role: undefined }
// { status: '1', role: 'ADMIN' } -> { status: 1, role: 'ADMIN' }
// { status: '3' } -> 报错
// { role: 'ROOT' } -> 报错如果只是创建、更新或命令式接口里的字段写入,仍然使用 zEnumInt / zEnumStr。
典型应用场景:
- 用户列表中的
status、role下拉筛选 - 订单列表中的
type、source、payStatus过滤 - 统计接口中的离散条件过滤,例如“按状态统计”“按角色统计”
不建议用于:
- 创建接口里“字段必须是合法枚举”的场景
- 更新接口里“空值必须报错”的场景
- 需要把空字符串识别成非法输入的命令式接口
写入字段 vs 查询过滤
同样是枚举或整数字段,写入场景和查询场景的语义并不相同。推荐按下面的边界使用:
| 维度 | 写入字段 | 查询过滤 |
|---|---|---|
| 推荐 helper | zEnumInt / zEnumStr / zIntReq / zId | zQueryEnumInt / zQueryEnumStr / zQueryInt / zQueryId |
| 典型位置 | @Body() 创建、更新 DTO | @Query() 列表、筛选、统计 DTO |
'' | 非法输入 | 视为未传 |
null | 非法输入 | 视为未传 |
| 非法值 | 报错 | 报错 |
| 目标语义 | 保证字段值有效 | 保证过滤条件有效,同时允许“不筛选” |
可以直接按这个判断:
- 这个字段最终会写进数据库列:优先用写入 helper
- 这个字段只是决定要不要加 where 条件:优先用 query helper
- 如果前端常给下拉框初始值
null或'':应该用 query helper,而不是在业务层手动兼容
选型示例
typescript
import { z } from 'zod'
import {
zId,
zEnumInt,
zQueryId,
zQueryEnumInt,
zPageWithKeyword
} from '@maxtan/nest-core'
const STATUS_VALUES = [0, 1] as const
// 写入:字段要落库,非法值必须报错
export const CreateUser = z.object({
managerId: zId,
status: zEnumInt(...STATUS_VALUES)
})
// 查询:没选状态或负责人时,不加过滤条件
export const ListUser = zPageWithKeyword().extend({
managerId: zQueryId(),
status: zQueryEnumInt(...STATUS_VALUES)
})上面这组写法的区别是:
CreateUser.status = ''会直接报错ListUser.status = ''会被当成未传,不参与过滤CreateUser.managerId = null会报错ListUser.managerId = null会被当成未传
zBool — 智能布尔值转换
typescript
import { zBool } from '@maxtan/nest-core'
const schema = z.object({
isActive: zBool // 直接用,已是 boolean | undefined
})
// 'true' / '1' / 'yes' → true
// 'false' / '0' / 'no' / '' → false
// null / undefined → undefinedzStrArr — 字符串数组专用 helper
相比通用 zArr,这个 helper 会对数组内每个字符串元素执行 trim,并且默认过滤空串:
typescript
import { z } from 'zod'
import { zStrArr } from '@maxtan/nest-core'
const schema = z.object({
tags: zStrArr({ min: 1 }),
userIds: zStrArr({ unique: true })
})
// [' a ', ' ', 'b'] -> ['a', 'b']
// 'a, ,b,a' -> ['a', 'b', 'a']
// zStrArr({ unique: true }) -> ['a', 'b']推荐场景:
- 标签列表、用户 ID 列表、人群值列表
- 既可能传数组,也可能传逗号分隔字符串的接口
- 需要消除
['', ' ']这类无效元素的场景
常用选项:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
separator | string | ',' | 字符串输入时的分隔符 |
unique | boolean | false | 是否去重 |
filterEmpty | boolean | true | 是否过滤空串元素 |
min | number | - | 最少保留元素个数 |
max | number | - | 最多保留元素个数 |
zArr — 通用数组预处理器
typescript
import { zArr, zArrOf } from '@maxtan/nest-core'
const schema = z.object({
tags: zArr, // 逗号分隔(常量)
ids: zArrOf({ separator: '|', unique: true }) // 自定义分隔符 + 去重
})
// 'a,b,c' → ['a', 'b', 'c']
// 'a|b|a' → ['a', 'b'](unique: true)zDate — 日期转换
typescript
import { zDate, zDateReq } from '@maxtan/nest-core'
const schema = z.object({
startDate: zDate, // 选填
birthday: zDateReq, // 必填
})
// '2025-01-01' → Date
// 1704067200000 → Date(13 位时间戳)
// 1704067200 → Date(10 位,自动 ×1000)
// '1704067200000' → Date(字符串时间戳)zPage — 分页查询
内置分页 Schema 工厂函数,自动处理字符串到整数的转换和默认值。支持自定义默认配置。
typescript
import { z } from 'zod'
import { zPage, zStr, Zod } from '@maxtan/nest-core'
import type { ZPage } from '@maxtan/nest-core'
// 默认配置:current=1, size=20
const ListUserSchema = zPage().extend({
keyword: zStr,
status: zEnumInt(0, 1).optional()
})
type ListUser = z.infer<typeof ListUserSchema>
@Controller('users')
export class UserController {
@Get()
list(@Query(Zod(ListUserSchema)) query: ListUser) {
// query.current: number (默认 1)
// query.size: number (默认 20)
return this.userService.list(query)
}
}自定义分页默认值
typescript
// 日志查询:默认每页 50 条,最多 500 条
const ListLogSchema = zPage({ defaultSize: 50, maxSize: 500 }).extend({
level: zStr,
startDate: zDate
})
// 后台管理:默认每页 10 条
const ListOrderSchema = zPage({ defaultSize: 10 }).extend({
status: zInt
})配置选项
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
defaultCurrent | number | 1 | 默认页码 |
defaultSize | number | 20 | 默认每页数量 |
maxSize | number | 2000 | 每页最大数量 |
Schema 复用组合器
为了降低业务侧 Schema 手工成本,推荐优先使用以下组合器:
| 组合器 | 说明 | 典型场景 |
|---|---|---|
zId | 通用字符串 ID | @Param('id')、删除/详情接口 |
zQueryId() | 可选 ID 查询条件 | 列表筛选、关联对象过滤 |
zIds | ID 数组(支持逐项 trim + 空串过滤) | 批量删除、批量查询 |
zQueryInt() | 可选整数查询条件 | 状态、月份、楼栋编号 |
zPageWithKeyword() | 分页 + keyword 的列表查询基座 | 后台列表页 |
zDateRange() | 开始/结束时间范围(内置先后校验) | 日志、审计、报表查询 |
zId / zIds 示例
typescript
import { z } from 'zod'
import { zId, zIds } from '@maxtan/nest-core'
const DeleteSchema = z.object({ id: zId })
const BatchDeleteSchema = z.object({ ids: zIds })
// ids 支持:
// 'a,b,c' -> ['a', 'b', 'c']
// [' a ', ' ', 'b'] -> ['a', 'b']zPageWithKeyword 示例
typescript
import { zPageWithKeyword, zQueryEnumInt } from '@maxtan/nest-core'
const ListUser = zPageWithKeyword().extend({
status: zQueryEnumInt(0, 1)
})
// 自定义:关键词字段名 + 分页默认值
const ListOrder = zPageWithKeyword({
keywordKey: 'q',
keywordMaxLength: 120,
defaultCurrent: 1,
defaultSize: 50,
maxSize: 500
})zPageWithKeyword(options) 选项:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
keywordKey | string | 'keyword' | 关键词字段名 |
keywordMaxLength | number | 100 | 关键词最大长度 |
defaultCurrent | number | 1 | 默认页码(继承 zPage) |
defaultSize | number | 20 | 默认每页数量(继承 zPage) |
maxSize | number | 2000 | 每页最大数量(继承 zPage) |
zDateRange 示例
typescript
import { zPage, zDateRange } from '@maxtan/nest-core'
const ListLog = zPage().merge(zDateRange())
// 默认字段:startAt / endAt
// 自动校验:startAt <= endAt
// 自定义字段名
const ListAudit = zPage().merge(
zDateRange({
startKey: 'beginAt',
endKey: 'finishAt'
})
)zDateRange(options) 选项:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
startKey | string | 'startAt' | 开始时间字段名 |
endKey | string | 'endAt' | 结束时间字段名 |
validateOrder | boolean | true | 是否校验开始时间不晚于结束时间 |
zPage() 生成的字段:
| 字段 | 类型 | 默认值 | 范围 |
|---|---|---|---|
current | number | defaultCurrent | >= 1,整数 |
size | number | defaultSize | 1 ~ maxSize,整数 |
Prisma 集成模式
推荐的 Zod Schema 与 Prisma Schema 对齐模式:字段约束定义一次,Schema 按需组合。
1. 定义枚举常量
使用 as const 对象作为枚举值的单一来源,Schema 和业务逻辑共享:
typescript
/** 用户状态:0-禁用 1-启用(对应 @db.TinyInt) */
export const UserStatus = { DISABLED: 0, ENABLED: 1 } as const
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus] // 0 | 1
/** 用户角色(对应 Prisma enum Role) */
export const UserRole = { ADMIN: 'ADMIN', USER: 'USER', GUEST: 'GUEST' } as const
export type UserRole = (typeof UserRole)[keyof typeof UserRole]2. 对齐 Prisma 列定义
每个字段约束只定义一次,注释标注对应的 Prisma 列:
typescript
// ─── Prisma User 模型字段约束 ───
// 规则:非空列 → 必填 schema;可空列(Prisma ?)→ .optional()
/** username String @unique @db.VarChar(50) */
const username = zVarChar(50, 2)
/** password String @db.VarChar(255) */
const password = z.string().min(6).max(50)
/** nickname String? @db.VarChar(50) — 可空列 */
const nickname = zVarChar(50).optional()
/** status Int @default(1) @db.TinyInt — 非空(有默认值) */
const status = zEnumInt(UserStatus.DISABLED, UserStatus.ENABLED)
/** role Role @default(USER) — 非空(有默认值) */
const role = zEnumStr(UserRole.ADMIN, UserRole.USER, UserRole.GUEST)3. 组合 Schema
Schema 层根据场景(查询/创建/更新)决定字段的必填/选填:
typescript
/** 列表查询:所有过滤条件都是选填 */
export const ListUser = zPage().extend({
keyword: zStr,
status: zQueryEnumInt(UserStatus),
role: zQueryEnumStr(UserRole)
})
/** 创建:必填字段直接引用,有 DB 默认值的加 .optional() */
export const CreateUser = z.object({
username, // 必填
password, // 必填
nickname, // 已在字段定义处标记 .optional()
role: role.optional() // DB 有 @default(USER) → 选填
})
/** 更新:基于 CreateUser 全部可选 + 扩展更新独有字段 */
export const UpdateUser = CreateUser.partial().extend({
status: status.optional()
})设计原则
| 原则 | 说明 |
|---|---|
| 字段 = 列 | 字段约束对齐 Prisma 列:zVarChar(50) ↔ @db.VarChar(50) |
| 非空 = 必填 | Prisma 非空列 → 必填 schema,可空列(?)→ .optional() |
| Schema = 场景 | 同一字段在不同 Schema 中按需 .optional(),无需重复定义约束 |
| 枚举 = 常量 | as const 对象同时给 Schema 校验和业务逻辑使用 |
使用规则速查
| 场景 | 写法 | 说明 |
|---|---|---|
| 选填字符串 | zStr | trim + 空串 -> undefined |
| 必填字符串 | zStrReq | 无约束时的简写 |
| 可空字符串 | zStrNull | trim + 空串 -> null |
| 必填 + 约束 | zStr.pipe(z.string().min(2).max(50)) | 不需要写中文 message |
| Prisma VarChar | zVarChar(50, 2) | 必填,.optional() 变选填 |
| 可空 VarChar | zVarCharNull(50, 2) | 可空列,空串表达清空 |
| 选填整数 | zInt | 等价于 zNum(true) |
| 查询整数 | zQueryInt({ min: 1 }) | null / 空串不过滤,非法值报错 |
| 必填整数 | zIntReq | 无约束时的简写 |
| 选填整数 + 约束 | zInt.pipe(z.number().int().min(0).max(1).optional()) | .optional() 放 pipe 内 |
| 必填数字 | zNumReq | 含小数 |
| 整数枚举 | zEnumInt(0, 1) | 必填,.optional() 变选填 |
| 查询整数枚举 | zQueryEnumInt(UserStatus) | 查询过滤专用,支持枚举对象 |
| 字符串枚举 | zEnumStr('A', 'B') | 必填,.optional() 变选填 |
| 查询字符串枚举 | zQueryEnumStr(UserRole) | 查询过滤专用,支持枚举对象 |
| 查询 ID | zQueryId(64) | null / 空串不过滤,长度可限制 |
| 选填布尔 | zBool | 直接用 |
| 选填日期 | zDate | 直接用 |
| 必填日期 | zDateReq | 简写 |
| 字符串数组 | zStrArr({ min: 1 }) | 逐项 trim,默认过滤空串 |
| 选填数组 | zArr | 自定义用 zArrOf(options) |
| 分页查询 | zPage().extend({ ... }) | 支持自定义默认值 |
列表查询场景推荐优先组合:
- 分页:
zPage()或zPageWithKeyword() - 可选整数:
zQueryInt() - 可选 ID:
zQueryId() - 可选枚举:
zQueryEnumInt()/zQueryEnumStr() - 时间范围:
zDateRange()
对象清理边界:
cleanObject适合在 service/query 层移除null、undefined、空串等无效值cleanObject不负责深度字符串语义归一化,不等价于“递归 trim 所有 JSON 字符串”- 字符串字段的语义仍应由
zStr、zStrNull、zVarCharNull、zStrArr这类 helper 显式表达
辅助工具 getZodError
从 ZodError 中提取格式化的错误信息:
typescript
import { getZodError } from '@maxtan/nest-core'
const result = schema.safeParse(data)
if (!result.success) {
const msg = getZodError(result.error) // 只取第一条
const all = getZodError(result.error, true) // 所有错误,用 '; ' 分隔
const custom = getZodError(result.error, true, ' | ') // 自定义分隔符
}