- 后端
- 前端
- CRM
- 人工智能
- AI Agent
【免费下载链接】crm
Comp AI CRM is an open source, CRM designed for AI agents. Agentic-first CRM.
在 Comp AI CRM(Agentic-first CRM,开源仓库根目录 README.md)这类以 AI Agent 为主要使用者的后端系统中,API 响应里每一个多余字段都可能被 Agent 当成可用的业务信号,因此"响应即契约"比传统人用系统更关键。本文以仓库内置的 NestJS 最佳实践规则 .agents/skills/nestjs-best-practices/rules/api-use-dto-serialization.md 为骨架,完整讲解:为什么禁止从 Controller 直接返回实体对象、如何用 class-transformer 的@Exclude()/@Expose()与响应 DTO 精确控制出参、如何用序列化分组实现按角色/场景裁剪字段,并结合本仓库实际代码(ValidationPipe 全局管道、zod 输出契约、REST bridge)给出可落地的工程实践。读完你将掌握一套"实体不裸奔、出参有契约、敏感字段零泄漏"的 NestJS 序列化方案。
一、为什么"直接返回实体"是危险的默认行为
规则文档开宗明义:永远不要从 Controller 直接返回实体对象。它给出的反面例子非常典型:
// Return entities directly @Controller('users') export class UsersController { @Get(':id') async findOne(@Param('id') id: string): Promise<User> { return this.usersService.findById(id); // Returns: { id, email, passwordHash, ssn, internalNotes, ... } // Exposes sensitive data! } }只要 Service 返回的是 ORM 实体(如 TypeORM 的User),NestJS 就会把实体的全部可枚举属性序列化进 JSON 响应——passwordHash、ssn、internalNotes这类字段会原样出现在响应体里。而在 Comp AI CRM 这样的业务里,联系人社交资料、内部备注、API 密钥等数据一旦泄漏给不合适的调用方(包括越权的 Agent 工具调用),后果远不止"不好看"。
另一种常见但同样错误的做法是手动展开(manual spreading):
// Manual object spreading (error-prone) @Get(':id') async findOne(@Param('id') id: string) { const user = await this.usersService.findById(id); return { id: user.id, email: user.email, name: user.name, // Easy to forget to exclude sensitive fields // Hard to maintain across endpoints }; }手动白名单看似可控,但每新增一个字段、每新增一个端点都要手写一遍;漏写一个字段就是一次安全事故,字段在多端点之间也无法复用。
二、正确姿势第一步:全局启用 ClassSerializerInterceptor
规则文档给出的正确做法,第一步是在应用启动时全局挂载 class-transformer 的序列化拦截器:
// Enable class-transformer globally async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); await app.listen(3000); }ClassSerializerInterceptor是 NestJS 内建拦截器,它会拦截所有 Controller 的返回值,并调用 class-transformer 的instanceToPlain做序列化——这正是@Exclude()、@Expose()、@Transform()等装饰器能够生效的前提。
结合仓库看全局配置的完整形态:本仓库的 API 应用(apps/api/src/create-app.ts)在启动时同时配置了全局管道与安全中间件:
app.use(helmet()); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, transformOptions: { enableImplicitConversion: true }, }), );其中transform: true会启用 class-transformer 的对象转换能力,配合whitelist: true与forbidNonWhitelisted: true实现"请求里出现未声明字段直接报错"——这与响应侧"只输出声明字段"是同一套纪律的两面:入参白名单化,出参契约化。应用入口 apps/api/src/main.ts 中通过createApp()构建应用(监听端口默认3001,可用PORT环境变量覆盖)。值得留意的是,该仓库的 tRPC 层还额外配置了ValidationPipe之外的输入校验(见下文第四节),因此在createApp()里没有重复注册ClassSerializerInterceptor,而是把"输出契约"下沉到了 tRPC 路由层的 zod schema——这并不违背本规则,而是把同一原则迁移到了另一套传输协议上。
三、实体级@Exclude():让敏感字段"天生不可见"
全局拦截器就位后,就可以在实体上用装饰器声明序列化策略。规则文档给出的示例:
// Entity with serialization control @Entity() export class User { @PrimaryGeneratedColumn('uuid') id: string; @Column() email: string; @Column() name: string; @Column() @Exclude() // Never include in responses passwordHash: string; @Column({ nullable: true }) @Exclude() ssn: string; @Column({ default: false }) @Exclude({ toPlainOnly: true }) // Exclude from response, allow in requests isAdmin: boolean; @CreateDateColumn() createdAt: Date; @Column() @Exclude() internalNotes: string; }这里有几个关键细节值得展开:
@Exclude()默认双向生效:即从对象转纯对象(响应)时排除,从纯对象转对象(入参)时也排除。规则文档特别演示了@Exclude({ toPlainOnly: true })这个变体:toPlainOnly表示只在序列化(对象→plain object,即出参)时排除,反序列化(入参)时仍然接受——适用于isAdmin这类"服务端内部写、客户端不能读"的字段。- 装饰器与字段声明共存:ORM 列装饰器(
@Column、@CreateDateColumn)负责持久化,class-transformer 装饰器负责传输可见性,两者职责正交、互不干扰。 - 默认策略 vs 白名单策略:当实体上出现任意
@Expose()时,class-transformer 会切换到"仅暴露被标记字段"的白名单模式(配合excludeExtraneousValues: true会更严格);而全实体只有少数@Exclude()时,则采用黑名单模式。团队应在规则层面统一选择一种,避免混用造成认知负担。
完成上述改造后,Controller 代码保持原样即可获得安全响应:
// Now returning entity is safe @Controller('users') export class UsersController { @Get(':id') async findOne(@Param('id') id: string): Promise<User> { return this.usersService.findById(id); // Returns: { id, email, name, createdAt } // Sensitive fields excluded automatically } }这正是规则文档强调的核心收益:默认安全(secure by default)——即使后续有人新增端点时忘记手写字段白名单,实体上的@Exclude()依然兜底。
四、显式响应 DTO:为不同端点定制不同形状
实体级@Exclude()适合"全局黑名单",但当不同端点需要完全不同的响应形状(例如列表页只需要postCount聚合值,详情页需要完整的posts数组)时,规则文档推荐使用显式 DTO:
// For different response shapes, use explicit DTOs export class UserResponseDto { @Expose() id: string; @Expose() email: string; @Expose() name: string; @Expose() @Transform(({ obj }) => obj.posts?.length || 0) postCount: number; constructor(partial: Partial<User>) { Object.assign(this, partial); } } export class UserDetailResponseDto extends UserResponseDto { @Expose() createdAt: Date; @Expose() @Type(() => PostResponseDto) posts: PostResponseDto[]; } // Controller with explicit DTOs @Controller('users') export class UsersController { @Get() @SerializeOptions({ type: UserResponseDto }) async findAll(): Promise<UserResponseDto[]> { const users = await this.usersService.findAll(); return users.map(u => plainToInstance(UserResponseDto, u)); } @Get(':id') async findOne(@Param('id') id: string): Promise<UserDetailResponseDto> { const user = await this.usersService.findByIdWithPosts(id); return plainToInstance(UserDetailResponseDto, user, { excludeExtraneousValues: true, }); } }几个要点补充说明:
@Expose()白名单模式:DTO 上只标注需要输出的字段,其余一律不输出,天然杜绝"忘了排除"。@Transform:用于派生字段(如postCount),接收({ obj })拿到源对象做计算,让"列表只需要计数、不需要全部 posts"这种裁剪成为声明式表达。@Type(() => PostResponseDto):让 class-transformer 在嵌套对象上递归应用嵌套 DTO 的序列化规则,否则嵌套实体又会裸奔。plainToInstance(UserResponseDto, user, { excludeExtraneousValues: true }):excludeExtraneousValues: true会丢弃源对象中 DTO 未声明的字段,实现"严格白名单"。@SerializeOptions({ type: UserResponseDto }):显式告知拦截器目标类型;若不写,拦截器会按返回值本身推断。- 继承组合:
UserDetailResponseDto extends UserResponseDto展示了"基础 DTO + 详情扩展"的组合模式,避免每个端点重复声明公共字段。
序列化分组:一套 DTO 应对多角色多场景
规则文档还给出了更进阶的**分组序列化(groups)**方案——同一个 DTO,按调用方角色返回不同字段集:
// Groups for conditional serialization export class UserDto { @Expose() id: string; @Expose() name: string; @Expose({ groups: ['admin'] }) email: string; @Expose({ groups: ['admin'] }) createdAt: Date; @Expose({ groups: ['admin', 'owner'] }) settings: UserSettings; } @Controller('users') export class UsersController { @Get() @SerializeOptions({ groups: ['public'] }) async findAllPublic(): Promise<UserDto[]> { // Returns: { id, name } } @Get('admin') @UseGuards(AdminGuard) @SerializeOptions({ groups: ['admin'] }) async findAllAdmin(): Promise<UserDto[]> { // Returns: { id, name, email, createdAt } } @Get('me') @SerializeOptions({ groups: ['owner'] }) async getProfile(@CurrentUser() user: User): Promise<UserDto> { // Returns: { id, name, settings } } }分组机制的价值在于字段可见性与业务角色绑定:/users公共列表只暴露id与name;带AdminGuard的管理端点额外暴露email、createdAt;/me个人中心则暴露settings。注意settings同时属于admin和owner两组,意味着两个角色都能看到——这种"多组归属"能力让一套 DTO 服务 N 种场景,配合@UseGuards做权限与字段裁剪的双重校验,是"按需最小化暴露"的推荐工程形态。
五、仓库落地对照:同一原则在 tRPC/zod 栈上的映射
Comp AI CRM 的 API 层以 tRPC 为核心传输(apps/api/src/trpc/trpc.module.ts 中TRPCModule.forRoot({ basePath: "/api/trpc", ... })),并借助trpc-to-openapi在REST_BRIDGE_PATH上生成 REST 桥(见 apps/api/src/create-app.ts 的createOpenApiExpressMiddleware)。在这个架构里,"响应 DTO"的职责由zod 输出 schema承担,但设计哲学与本规则完全一致:显式声明输出形状、绝不透传内部对象。
以工作区契约为例(apps/api/src/workspace/workspace.contracts.ts):
export const workspaceOutput = z.object({ id: z.string(), slug: z.string(), name: z.string(), website: z.string().nullable(), onboarded: z.boolean(), viewerRole: z.enum(WORKSPACE_ROLES).nullable(), canRename: z.boolean(), canChangeRoles: z.boolean(), }); export const workspaceMemberOutput = z.object({ id: z.string(), userId: z.string(), name: z.string(), email: z.string(), image: z.string().nullable(), role: z.enum(WORKSPACE_ROLES), joinedAt: z.string(), isViewer: z.boolean(), });而 apps/api/src/generated/server.ts 中每个 tRPC procedure 都通过.output(timelineOutput)、.output(companyDetailOutput)等显式声明出参形状——任何未出现在输出 schema 中的内部字段(如passwordHash、内部备注)在 tRPC 层根本没有机会进入响应,这与@Expose()白名单 +excludeExtraneousValues: true达到的是同一个效果,且类型完全静态推导(z.infer<typeof workspaceOutput>)。
同样值得注意的反向印证是 apps/api/src/config/env.validation.ts:环境变量校验正是用 class-transformer 的plainToInstance+Type与 class-validator 装饰器实现的:
const validated = plainToInstance(EnvironmentVariables, config, { ... });这说明本仓库的依赖栈(class-transformer、class-validator)确实在运转——只是序列化职责被 tRPC 的 zod 输出层吸收,而不是在 Controller 层用ClassSerializerInterceptor。对采用 REST 控制器的模块而言,规则文档中的方案完全适用;对 tRPC 模块而言,等价实现就是"每个 procedure 都必须有显式.output()schema"。
另外两处与"响应形状"相关的工程细节也值得关联:
- 错误响应的形状同样是契约:全局异常过滤器 apps/api/src/logging/all-exceptions.filter.ts 将
HttpException归一化为{ statusCode, message, requestId }的固定结构,并区分 4xx/5xx 日志级别——错误体也是 DTO,不能把异常堆栈直接抛给客户端。 - 校验错误的可读化:tRPC 的 apps/api/src/trpc/error-formatter.ts 会把
ZodError的多条 issue 折叠成一条可读 sentence,并避免跨包instanceof失效问题——说明"对外输出什么信息"在 Comp AI CRM 是被当作一等工程问题处理的。
六、落地自检清单
把规则文档与本仓库实践整合成一张可直接用于 Code Review 的清单:
- Controller 返回值检查:是否出现"直接返回 ORM 实体"或"手写展开对象"?应改为实体级
@Exclude()或显式响应 DTO。 - 全局拦截器:REST 模块是否启用了
ClassSerializerInterceptor(或等价机制)?没有它,所有@Expose/@Exclude都不生效。 - 白名单优先:新响应 DTO 一律用
@Expose()显式声明字段,并考虑excludeExtraneousValues: true兜底。 - 敏感字段零出口:
passwordHash、ssn、API 密钥、内部备注等字段必须出现在实体级@Exclude()中,且 tRPC 的.output()schema 中不得引用相关内部类型。 - 嵌套对象处理:凡 DTO 嵌套实体/数组,必须用
@Type(() => NestedDto)递归声明,防止嵌套裸奔。 - 角色分组:字段可见性与角色相关的,用
@Expose({ groups: [...] })+@SerializeOptions({ groups })表达,并始终搭配@UseGuards。 - 错误响应形状:确认错误响应走统一过滤器,字段固定、不携带堆栈与内部细节。
这套方法论的收益可以总结为一句话:把"响应"当作一份需要版本化、可审查、默认安全的契约来管理——无论是 REST 时代的@Expose()/@Exclude(),还是 Comp AI CRM 中 tRPC 时代的 zod.output()schema,殊途同归,都是为了让"客户端(包括 AI Agent)能看到什么"由代码显式声明,而不是由实体结构的偶然性决定。
更完整的规则集合(包含输入校验、Guards、异常过滤器、模块拆分等配套实践)位于 .agents/skills/nestjs-best-practices/rules 目录,其中 security-sanitize-output.md 与本规则互为补充。
- 后端
- 前端
- CRM
- 人工智能
- AI Agent
【免费下载链接】crm
Comp AI CRM is an open source, CRM designed for AI agents. Agentic-first CRM.
相关推荐
LunaTranslator 视觉小说翻译器使用指南:HOOK 提取、OCR 识别与多引擎翻译
LunaTranslator 视觉小说翻译器使用指南:HOOK 提取、OCR 识别与多引擎翻译 LunaTranslator 是一款开源的视觉小说(Visual
教育后端前端get-shit-done 多源覆盖审计(Source Audit)与规划器权限边界实战指南
get shit done 多源覆盖审计(Source Audit)与规划器权限边界实战指南 本文讲解 TÂCHES 的 get shit done(GSD)—
后端前端CRM人工智能AI AgentComp AI CRM 实战:nestjs-trpc 中间件与上下文的完整指南
Comp AI CRM 实战:nestjs trpc 中间件与上下文的完整指南 导读 本指南以 nestjs trpc 官方技能文档为骨架,结合 Comp AI
后端前端CRM人工智能AI Agent
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考