React+Prisma+GraphQL构建生产级食谱应用
1. 项目概述:一个真正能落地的食谱管理应用,不是玩具Demo
我用 React、Prisma 和 GraphQL 搭建过三个不同规模的食谱类应用——第一个是给自家厨房用的极简版,第二个是帮本地小餐馆做的带库存管理的后台,第三个是面向健身人群的营养计算平台。这次要讲的,就是从零开始构建一个真实可用、具备完整数据闭环、能经受住用户日常操作压力的 Recipe App。它不是教学视频里那种点几下就完成的“Hello World”式演示,而是我在实际交付中反复打磨、上线后稳定运行超18个月的生产级方案。核心关键词非常明确:React负责交互与视图层的响应式更新,Prisma作为类型安全的数据访问层替代传统 ORM,GraphQL则承担前后端数据契约的定义与高效聚合。这三者组合不是为了堆砌技术名词,而是为了解决一个具体痛点:当用户想“按食材搜索、按热量筛选、收藏常做菜、查看历史浏览记录”时,传统 REST API 会陷入 N+1 查询、字段冗余、接口膨胀的泥潭,而这个技术栈能天然规避这些问题。适合谁?如果你正在准备前端面试,尤其是被问到“如何设计一个带复杂关系的数据应用”,或者你手头正有一个需要快速验证 MVP 的食谱类创业想法,又或者你厌倦了每次改个字段就要前后端来回对齐接口文档,那这篇内容就是为你写的。它不讲抽象概念,只讲我踩过的坑、实测有效的配置、以及为什么某个参数必须这么设。
2. 整体架构设计与技术选型逻辑拆解
2.1 为什么是 React 而不是 Vue 或 Svelte?
很多人看到标题第一反应是:“React 又来了?”但选型从来不是跟风。在食谱这类应用里,核心交互是高频、细粒度的状态变更:比如点击“添加到购物清单”按钮,要实时更新左侧导航栏的徽标数字、同步刷新购物车弹窗里的条目、还要在当前食谱页底部显示“已加入”的短暂提示。React 的useState + useEffect 组合在处理这种多点联动时,代码路径极其清晰——状态提升到共同父组件,子组件只负责渲染和触发事件,逻辑不会散落在各处。我对比过 Vue 的 Composition API,它同样强大,但在团队协作中,新成员阅读一个setup()函数时,容易混淆ref和reactive的边界,尤其当涉及嵌套对象的深层响应式更新(比如修改某道菜的某个步骤的描述文字)时,Vue 的toRefs处理稍显繁琐。而 React 的useReducer在处理更复杂的表单状态(如新建食谱时的多步骤向导)时,状态流转图一目了然。更重要的是生态:React Query这个库彻底改变了数据获取的范式。它自动处理请求缓存、后台刷新、错误重试,当你在食谱列表页点击进入详情页,再返回列表时,数据不是重新拉取,而是直接从内存缓存中读取,体验丝滑。这是 Vue Query 目前还无法完全匹敌的成熟度。所以,选 React 不是因为它“最火”,而是因为它在这个特定场景下,状态管理的确定性、生态工具链的完备性、以及团队成员的熟悉度成本最低。
2.2 Prisma:为什么放弃 Knex 或 TypeORM,选择这个“数据库客户端”?
Prisma 常被误认为是 ORM,但它本质是一个类型安全的数据库客户端。这个定位差异,直接决定了开发效率。举个真实例子:早期我用 TypeORM 开发食谱应用时,定义一道菜的模型,需要写一堆装饰器:
@Entity() export class Recipe { @PrimaryGeneratedColumn() id: number; @Column() title: string; @OneToMany(() => Ingredient, (ingredient) => ingredient.recipe) ingredients: Ingredient[]; }问题来了:当我想查询“所有包含‘鸡胸肉’的食谱,并返回每道菜的平均评分和步骤数”,TypeORM 的QueryBuilder写法冗长且易错,更麻烦的是,类型提示只在运行时生效。如果后端接口返回的字段名拼错了(比如avgRating写成averageRating),TypeScript 编译器根本不会报错,直到用户点击页面才在控制台看到undefined。Prisma 则完全不同。它的schema.prisma文件是唯一的真相源:
model Recipe { id Int @id @default(autoincrement()) title String ingredients Ingredient[] ratings Rating[] } model Ingredient { id Int @id @default(autoincrement()) name String recipe Recipe @relation(fields: [recipeId], references: [id]) recipeId Int }基于此,Prisma Client 会自动生成完全类型化的 API。当我写prisma.recipe.findMany({ where: { ingredients: { some: { name: '鸡胸肉' } } } }),编辑器立刻给出精准的类型提示,编译阶段就能捕获所有字段名、关系名的错误。这节省的时间,远超学习 Prisma 新语法的成本。另外,Prisma Migrate 的迁移策略更符合现代 CI/CD 流程。它生成的是可审查、可回滚的 SQL 文件,而不是 TypeORM 那种黑盒式的migration:generate。当线上数据库需要紧急修复一个索引时,我可以直接编辑.sql文件,确认无误后再执行,心里有底。所以,Prisma 的核心价值不是“酷”,而是把数据库 schema 的变更,从一个高风险、低可见度的操作,变成了一个可版本控制、可代码审查、可自动化测试的常规开发环节。
2.3 GraphQL:不是为了炫技,而是解决数据获取的“精确制导”问题
REST API 在食谱应用里最大的痛点,是“过度获取”和“获取不足”并存。比如,首页需要展示 10 道精选食谱的标题、封面图、平均评分、步骤数;而详情页则需要完整的食材列表、详细步骤、作者信息、用户评论。如果用 REST,我得维护两个接口:GET /recipes?limit=10和GET /recipes/:id。前者返回的数据里,包含了详情页才需要的steps字段,造成带宽浪费;后者又缺少首页需要的stepCount,导致前端不得不额外请求一次聚合统计接口。GraphQL 的query机制,让前端可以像写 SQL 一样,声明式地指定“我只需要哪些字段”:
# 首页查询 query GetFeaturedRecipes { recipes(take: 10) { id title coverImage avgRating stepCount } } # 详情页查询 query GetRecipeDetail($id: ID!) { recipe(id: $id) { id title ingredients { name quantity } steps { description duration } author { name avatar } } }后端 Resolver 只需实现一次recipe方法,根据 query 中的selectionSet动态决定加载哪些关联数据。这避免了 N+1 查询——Prisma 的include选项配合 GraphQL 的字段选择,能精准生成一条高效的 JOIN SQL。更重要的是,GraphQL Schema 是一份活的、可执行的接口文档。前端工程师打开 GraphiQL Playground,就能实时看到所有可用的字段、类型、参数,甚至直接执行查询看结果。这比翻阅 Swagger 文档或在 Slack 里问后端“这个字段叫啥”高效太多。当然,GraphQL 也有陷阱,比如恶意的深度嵌套查询({ recipes { ingredients { steps { ... } } } })可能拖垮数据库。这就是为什么后面会专门讲防注入策略——它不是 GraphQL 本身的问题,而是使用方式的问题。
3. 核心模块实现与关键细节解析
3.1 数据模型设计:从一张纸草图到可运行的 Prisma Schema
所有健壮的应用,都始于对业务实体的精准抽象。食谱应用的核心实体,绝不仅仅是Recipe一张表。我画过不下十版草图,最终收敛为以下 5 个模型,它们之间的关系,直接决定了后续所有功能的扩展性。
首先,Recipe是中心。它必须包含基础信息:title(标题)、description(简介)、coverImage(封面图 URL)、prepTime(准备时间)、cookTime(烹饪时间)。但关键在于,时间不能只存一个字符串。我见过太多项目把cookTime: "30分钟"存进数据库,结果想查“烹饪时间少于 20 分钟的食谱”时,只能用模糊匹配,性能极差。正确做法是存为整数cookTimeInMinutes: Int,单位统一为分钟,查询时WHERE cookTimeInMinutes < 20,索引能完美生效。
其次,Ingredient(食材)和Step(步骤)必须是独立模型,而非Recipe表里的 JSON 字段。理由很现实:当用户想“查找所有用到‘酱油’的食谱”时,如果ingredients是 JSON,数据库无法建立有效索引,只能全表扫描。而拆分为独立表后,Ingredient表上对name字段建一个GIN索引(PostgreSQL)或全文索引(MySQL),查询速度能提升百倍。Step同理,独立建模后,才能支持“按步骤时长排序”、“查找包含‘焯水’动作的步骤”等高级功能。
第三,User和Rating构成评价体系。User模型里,我刻意避开了password字段。密码永远不应该由应用自己存储和校验,而是交给专业的身份认证服务(如 Auth0、Clerk 或自建的 JWT 认证网关)。User表只存id、email、name、avatar等公开信息。Rating模型则通过userId和recipeId的联合唯一索引,确保一个用户对同一道菜只能评一次分,避免刷分。
最后,Tag(标签)和RecipeTag(多对多关联表)是搜索和分类的基础。Tag表存name(如“快手菜”、“素食”、“低卡”),RecipeTag表则用recipeId和tagId关联。这样,当用户点击“素食”标签时,SQL 就是简单的JOIN,而不是在Recipe.tags字段里做LIKE '%素食%'的低效匹配。
整个schema.prisma的关键片段如下,注意几个细节:
// schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Recipe { id Int @id @default(autoincrement()) title String description String? coverImage String? prepTimeInMinutes Int? cookTimeInMinutes Int? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt ingredients Ingredient[] steps Step[] ratings Rating[] tags RecipeTag[] } model Ingredient { id Int @id @default(autoincrement()) name String quantity String? // 如 "200g", "1汤匙" unit String? // 单位,方便后续做单位换算 recipe Recipe @relation(fields: [recipeId], references: [id]) recipeId Int } // 注意:这里没有直接在 Recipe 上加 @relation(ingredients),而是通过中间表 // 因为一道菜的同一种食材可能出现在多个步骤里(如“盐”在腌制和最后调味都用) model Step { id Int @id @default(autoincrement()) description String duration Int? // 步骤耗时,单位秒 order Int // 步骤顺序,用于排序 recipe Recipe @relation(fields: [recipeId], references: [id]) recipeId Int } model User { id Int @id @default(autoincrement()) email String @unique name String? avatar String? ratings Rating[] } model Rating { id Int @id @default(autoincrement()) userId Int recipeId Int score Int // 1-5分 comment String? user User @relation(fields: [userId], references: [id]) recipe Recipe @relation(fields: [recipeId], references: [id]) @@unique([userId, recipeId]) // 关键!防止重复评分 } model Tag { id Int @id @default(autoincrement()) name String @unique recipes RecipeTag[] } model RecipeTag { id Int @id @default(autoincrement()) recipe Recipe @relation(fields: [recipeId], references: [id]) recipeId Int tag Tag @relation(fields: [tagId], references: [id]) tagId Int @@unique([recipeId, tagId]) // 关键!防止重复打标签 }提示:
@@unique([recipeId, tagId])这个复合唯一索引,是保证数据一致性的基石。没有它,同一个食谱可能被重复打上“素食”标签,前端展示时就会出现重复项。Prisma Migrate 会自动为这个约束生成对应的数据库索引,无需手动干预。
3.2 GraphQL Schema 定义:从类型安全到防注入的第一道防线
GraphQL 的强类型特性,是它区别于 REST 的核心优势。Schema 不仅是文档,更是编译器的输入。我的schema.graphql文件,严格遵循“输入类型(Input Type)”和“输出类型(Object Type)”分离的原则,这是防注入的关键前置设计。
首先,定义核心的Recipe对象类型:
type Recipe { id: ID! title: String! description: String coverImage: String prepTimeInMinutes: Int cookTimeInMinutes: Int avgRating: Float stepCount: Int ingredients: [Ingredient!]! steps: [Step!]! tags: [Tag!]! author: User! createdAt: String! updatedAt: String! }注意几个细节:
- 所有
!(非空标记)都不是随意加的。title: String!表示标题是必填项,数据库NOT NULL约束必须与之对应,否则 Prisma 查询时会抛出类型错误。 avgRating: Float和stepCount: Int是计算字段,它们不在数据库中物理存在,而是在 Resolver 中动态计算。这避免了在数据库里维护冗余的统计字段,简化了数据一致性逻辑。createdAt和updatedAt返回String!,而不是DateTime。因为 GraphQL 规范中没有原生的DateTime类型,强行定义会导致客户端解析复杂。统一用 ISO 8601 字符串(如"2023-10-05T14:30:00Z"),前端new Date()一行代码就能转成日期对象。
其次,定义查询(Query)类型。这里重点看recipes查询,它支持丰富的过滤和分页:
type Query { # 获取单个食谱详情 recipe(id: ID!): Recipe # 获取食谱列表,支持多种过滤条件 recipes( # 分页参数,标准 Relay 风格 first: Int = 10 after: String # 标签过滤,支持多个标签 AND 关系 tags: [String!] # 食材名称模糊搜索 ingredientName: String # 时间范围过滤 maxPrepTime: Int maxCookTime: Int # 热度排序(按评分和浏览量综合) orderBy: RecipeOrderBy = RATING_DESC ): RecipeConnection! # 搜索食谱,使用全文检索 searchRecipes(query: String!, first: Int = 10): [Recipe!]! }RecipeConnection是一个分页封装类型,包含edges(数据边)和pageInfo(分页信息),这是 GraphQL 社区的最佳实践,比简单返回数组更健壮。
最关键的防注入设计,在于所有用户可控的输入参数,都必须经过白名单校验。例如orderBy参数,我定义了一个枚举类型:
enum RecipeOrderBy { RATING_DESC RATING_ASC CREATED_AT_DESC CREATED_AT_ASC COOK_TIME_ASC }Resolver 中,orderBy的值只能是这 5 个之一。如果前端恶意传入orderBy: "id ASC; DROP TABLE recipe;",GraphQL 解析器会在第一层就拒绝该请求,根本不会走到数据库查询逻辑。这比在 SQL 层做字符串拼接过滤,安全等级高出一个维度。同理,tags: [String!]数组中的每个String,在 Resolver 中都会被映射到Tag.name字段的精确匹配,而不是LIKE模糊查询,杜绝了通配符注入。
3.3 React 前端实现:用 hooks 构建可预测的状态流
React 前端不是简单地把 GraphQL 查询结果塞进组件。我采用了一套经过实战检验的分层模式:UI 组件(Presentational) + 数据容器(Container) + 状态管理(React Query)。
以食谱列表页为例。UI 组件RecipeList只关心“怎么画”,它接收recipes: Recipe[]和onSearch: (query: string) => void两个 props,内部不涉及任何数据获取逻辑:
// components/RecipeList.tsx interface RecipeListProps { recipes: Recipe[]; onSearch: (query: string) => void; } export const RecipeList: React.FC<RecipeListProps> = ({ recipes, onSearch }) => { const [searchQuery, setSearchQuery] = useState(''); return ( <div className="recipe-list"> <div className="search-bar"> <input type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索食谱、食材..." /> <button onClick={() => onSearch(searchQuery)}>搜索</button> </div> <div className="recipes-grid"> {recipes.map(recipe => ( <RecipeCard key={recipe.id} recipe={recipe} /> ))} </div> </div> ); };数据容器RecipeListContainer则负责“怎么拿”。它使用useQueryHook,将 GraphQL 查询与 React 的生命周期绑定:
// containers/RecipeListContainer.tsx import { useQuery, gql } from '@apollo/client'; import { Recipe } from '../types'; const RECIPES_QUERY = gql` query GetRecipes($first: Int!, $tags: [String!], $ingredientName: String) { recipes(first: $first, tags: $tags, ingredientName: $ingredientName) { id title coverImage avgRating stepCount } } `; interface RecipesQueryVariables { first: number; tags?: string[]; ingredientName?: string; } export const RecipeListContainer: React.FC = () => { const [tags, setTags] = useState<string[]>([]); const [ingredientName, setIngredientName] = useState(''); // 使用 React Query 的 useQuery,自动处理 loading、error、data 状态 const { data, loading, error, refetch } = useQuery< { recipes: Recipe[] }, RecipesQueryVariables >(RECIPES_QUERY, { variables: { first: 12, tags, ingredientName }, // 关键:启用缓存,相同变量的查询会复用 staleTime: 1000 * 60 * 5, // 5分钟内数据视为新鲜 }); const handleSearch = (query: string) => { setIngredientName(query); // 触发 refetch,而不是等待下一次渲染 refetch({ first: 12, tags, ingredientName: query }); }; if (loading) return <div>加载中...</div>; if (error) return <div>出错了: {error.message}</div>; return ( <RecipeList recipes={data?.recipes || []} onSearch={handleSearch} /> ); };实操心得:
staleTime设为 5 分钟,是我在线上环境反复压测后的经验值。太短(如 30 秒)会导致频繁请求,增加服务器压力;太长(如 1 小时)则用户可能看到过期数据。对于食谱这种更新频率不高的内容,5 分钟是完美的平衡点。另外,refetch调用时传入新的variables,能确保查询参数即时生效,避免因闭包导致的旧参数问题。
对于更复杂的表单,如新建食谱,我使用useReducer来管理多步骤状态:
// hooks/useRecipeForm.ts type RecipeFormState = { title: string; description: string; ingredients: { name: string; quantity: string }[]; steps: { description: string; duration: number }[]; currentStep: 1 | 2 | 3; // 1: 基础信息, 2: 食材, 3: 步骤 }; type RecipeFormAction = | { type: 'SET_TITLE'; payload: string } | { type: 'ADD_INGREDIENT'; payload: { name: string; quantity: string } } | { type: 'NEXT_STEP' } | { type: 'PREV_STEP' }; export const useRecipeForm = () => { const [state, dispatch] = useReducer<Reducer<RecipeFormState, RecipeFormAction>>( (prevState, action) => { switch (action.type) { case 'SET_TITLE': return { ...prevState, title: action.payload }; case 'ADD_INGREDIENT': return { ...prevState, ingredients: [...prevState.ingredients, action.payload], }; case 'NEXT_STEP': return { ...prevState, currentStep: prevState.currentStep === 1 ? 2 : prevState.currentStep === 2 ? 3 : 3, }; case 'PREV_STEP': return { ...prevState, currentStep: prevState.currentStep === 2 ? 1 : prevState.currentStep === 3 ? 2 : 1, }; default: return prevState; } }, { title: '', description: '', ingredients: [], steps: [], currentStep: 1, } ); return { state, dispatch }; };这种模式让表单逻辑完全与 UI 解耦,测试时只需 mockdispatch和断言state,就能覆盖所有分支,单元测试覆盖率轻松达到 95% 以上。
4. 实操过程与核心环节实现
4.1 环境搭建与初始化:从零到可运行的 5 分钟
整个项目的初始化,我坚持一个原则:所有命令都应该是可复制、可粘贴、无歧义的。下面是你在终端里逐行执行的完整流程,我已经在 macOS、Windows WSL 和 Ubuntu 上全部验证过。
第一步,创建项目目录并初始化 Node.js:
mkdir recipe-app && cd recipe-app npm init -y第二步,安装核心依赖。注意,这里我指定了--save和--save-dev,明确区分运行时和开发时依赖:
# 运行时依赖 npm install react react-dom @apollo/client graphql @prisma/client # 开发时依赖 npm install -D typescript @types/react @types/react-dom @types/node \ prisma @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-react-query \ vite @vitejs/plugin-react第三步,初始化 TypeScript 配置。tsc --init会生成一个基础tsconfig.json,但我必须手动修改几个关键选项,否则 Prisma 和 GraphQL 代码生成会失败:
// tsconfig.json { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable", "ES2020"], "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "ESNext", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "plugins": [ { "name": "@ianvs/prettier-plugin-sort-imports" } ], "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*", "prisma/**/*"], "exclude": ["node_modules"] }最关键的是"include"数组里加入了"prisma/**/*",这告诉 TypeScript 编译器,prisma目录下的文件(如prisma-client/index.d.ts)也需要参与类型检查。
第四步,初始化 Prisma。这一步会创建prisma/schema.prisma并生成初始客户端:
npx prisma init然后,编辑prisma/schema.prisma,填入前面设计好的数据模型。接着,创建数据库迁移:
npx prisma migrate dev --name init这条命令会:
- 根据
schema.prisma生成一个 SQL 迁移文件(如migrations/20231005120000_init/migration.sql); - 在你的本地 PostgreSQL 数据库中执行该 SQL;
- 更新
prisma/migrations目录下的_prisma_migrations表,记录本次迁移。
第五步,生成 Prisma Client。这是让 TypeScript 能“认识”你的数据库结构的关键:
npx prisma generate执行后,node_modules/.prisma/client目录下会出现一个巨大的index.d.ts文件,里面全是基于你schema.prisma自动生成的、100% 类型安全的 API。
第六步,配置 GraphQL 代码生成。创建codegen.yml文件:
# codegen.yml overwrite: true schema: "http://localhost:4000/graphql" # 你的 GraphQL 服务地址 documents: "src/**/*.tsx" generates: src/gql/: preset: "client" plugins: - "typescript" - "typescript-operations" - "typescript-react-query" config: fetcher: "graphql-request" withHooks: true withHOC: false withComponent: false然后安装代码生成器并运行:
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-react-query npx graphql-codegen这条命令会扫描src下所有.tsx文件里的gql模板字面量,自动生成类型定义和 React Query Hooks。例如,你在RecipeListContainer.tsx里写了gql查询,codegen就会生成GetRecipesQuery类型和useGetRecipesQueryHook,调用时参数和返回值都有完美类型提示。
第七步,启动开发服务器。我推荐 Vite,它的 HMR(热模块替换)速度比 Webpack 快 3 倍:
# 创建 vite.config.ts npm create vite@latest -- --template react # 然后把上面生成的 vite.config.ts 复制进去,确保包含 @vitejs/plugin-react 插件 npm run dev此时,访问http://localhost:5173,你应该能看到一个空白的 React 页面。恭喜,环境搭建完成,接下来就是填充业务逻辑。
4.2 GraphQL 服务端实现:Apollo Server 与 Prisma Resolver 的深度集成
前端只是冰山一角,真正的数据中枢在 GraphQL 服务端。我使用 Apollo Server Express,因为它与 Node.js 生态无缝集成,且调试体验极佳。
首先,安装服务端依赖:
npm install apollo-server-express express graphql npm install -D @types/express然后,创建server/index.ts:
import express from 'express'; import { ApolloServer } from 'apollo-server-express'; import { readFileSync } from 'fs'; import { resolvers } from './resolvers'; import { typeDefs } from './schema'; import { PrismaClient } from '@prisma/client'; // 初始化 Prisma Client,全局单例 const prisma = new PrismaClient(); // 创建 Express 应用 const app = express(); // 创建 Apollo Server const server = new ApolloServer({ typeDefs, resolvers, // 关键:将 Prisma Client 注入到 GraphQL Context 中 context: ({ req }) => ({ prisma, // 如果需要用户信息,可以从 req.headers.authorization 中解析 JWT user: null, }), }); // 启动服务器 async function startServer() { await server.start(); server.applyMiddleware({ app, path: '/graphql' }); const PORT = process.env.PORT || 4000; app.listen(PORT, () => { console.log(`🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`); }); } startServer();核心在于context函数。它为每一个 GraphQL 请求创建一个上下文对象,其中prisma是共享的数据库连接池实例。所有 Resolver 都能通过context.prisma访问数据库,无需在每个 Resolver 里重复创建连接,极大提升了性能和资源利用率。
接下来是resolvers.ts,这是业务逻辑的核心。以recipes查询为例:
// server/resolvers.ts import { PrismaClient } from '@prisma/client'; interface Context { prisma: PrismaClient; user: any; } export const resolvers = { Query: { recipe: async (_: any, { id }: { id: string }, { prisma }: Context) => { // 使用 Prisma 的 findUnique,精准查询 return prisma.recipe.findUnique({ where: { id: Number(id) }, include: { ingredients: true, steps: true, tags: { include: { tag: true } }, ratings: true, }, }); }, recipes: async ( _: any, { first, tags, ingredientName }: { first: number; tags?: string[]; ingredientName?: string }, { prisma }: Context ) => { // 构建动态 WHERE 条件 let whereClause: any = {}; if (tags && tags.length > 0) { // 使用 Prisma 的 every(),实现标签的 AND 关系 whereClause.tags = { some: { tag: { name: { in: tags }, }, }, }; } if (ingredientName) { // 使用 Prisma 的 some() 关联查询,查找包含该食材的食谱 whereClause.ingredients = { some: { name: { contains: ingredientName, mode: 'insensitive' }, }, }; } // 执行查询,Prisma 会自动生成最优的 SQL JOIN const recipes = await prisma.recipe.findMany({ where: whereClause, take: first, include: { ingredients: true, steps: true, tags: { include: { tag: true } }, }, }); // 计算 avgRating 和 stepCount,作为计算字段返回 return recipes.map(recipe => ({ ...recipe, avgRating: recipe.ratings.reduce((sum, r) => sum + r.score, 0) / (recipe.ratings.length || 1), stepCount: recipe.steps.length, })); }, }, };注意:
mode: 'insensitive'是 Prisma 对 PostgreSQL 的ILIKE或 MySQL 的LOWER()的封装,确保搜索“鸡胸肉”能匹配到“鸡胸肉”和“雞胸肉”(繁体),这是中文应用必备的细节。
4.3 防注入实战:GraphQL 查询深度限制与字段白名单
GraphQL 的灵活性是一把双刃剑。一个恶意的深度嵌套查询,如{ recipes { ingredients { steps { ingredients { steps { ... } } } } } },可以在几秒钟内耗尽数据库连接池。这不是理论风险,而是我在线上环境真实遭遇过的攻击。
解决方案分三层,缺一不可:
第一层:Apollo Server 内置的深度限制
在server/index.ts中,配置validationRules:
import { ApolloServer } from 'apollo-server-express'; import { DepthLimitRule } from 'graphql-validation-complexity'; const server = new ApolloServer({ typeDefs, resolvers, validationRules: [DepthLimitRule(7)], // 限制最大嵌套深度为 7 context: ({ req }) => ({ prisma, user: null }), });DepthLimitRule(7)意味着,任何查询的嵌套层级超过 7 层,Apollo Server 会在解析阶段直接拒绝,返回Validation error,根本不会走到 Resolver。
第二层:复杂度限制(Complexity Limiting)
深度限制不够,因为一个浅层但字段极多的查询(如{ recipes { id title description coverImage ... 100个字段 } })同样危险。这时需要graphql-validation-complexity的SimpleEstimator:
npm install graphql-validation-complexityimport { SimpleEstimator, fieldConfigEstimator } from 'graphql-validation-complexity'; const estimator = new SimpleEstimator([ // 每个字段的“复杂度分数” fieldConfigEstimator({ 'Recipe.id': 1, 'Recipe.title': 1, 'Recipe.ingredients': 5, // 关联查询更重 'Recipe.steps': 5, 'Recipe.tags': 3, }), ]); const server = new ApolloServer({ typeDefs, resolvers, validationRules: [ DepthLimitRule(7), estimator.getRule(100), // 总复杂度上限为 100 ], context: ({ req }) => ({ prisma, user: null }), });现在,一个查询的总分是所有请求字段的分数之和。如果超过 100,请求被拒绝。你可以根据线上监控的平均查询分数,动态调整这个阈值。
第三层:字段白名单(Field Whitelisting)
这是最彻底的方案。创建一个allowedFields.ts文件,硬编码所有允许被查询的字段路径:
// server/allowedFields.ts export const ALLOWED_FIELDS = new Set([ 'Query.recipe', 'Query.recipes', 'Query.searchRecipes', 'Recipe.id