TTeamDesk 学习手册
本章目录 展开导航 ↓

03 · 从创建表单到数据库:第一次完整写入#

本章围绕一个输入:标题“无法登录”,描述“输入密码后一直转圈”,优先级 NORMAL。目标是理解提交后的每一步,而不只是知道调用了一个 mutation。

1. 先分清哪些字段由谁决定#

用户提供标题、描述、优先级;当前团队由页面选择。客户端不应该决定数据库主键、初始版本、创建者身份、创建时间和任意初始状态。

数据 来源 服务端如何使用
title / description / priority 表单 Zod 校验后进入创建逻辑
teamId 当前团队上下文 检查登录用户是否为该团队成员
userId 服务端验证过的会话 作为创建者,不能相信浏览器自报
status / version 模型默认值 新工单从 OPEN、版本 0 开始
id / number / 时间 Prisma 默认生成或数据库 / ORM 默认机制 不由创建表单填写

这就是服务端开发的第一个习惯:先问数据从哪来,再问是否可信。 即使 teamId 的 TypeScript 类型正确,也可能是别人伪造的真实团队 ID。

2. 表单校验让用户尽早发现问题#

打开 workspace.tsx,先找 CreateTicket 和 submit。

Ant Design Form 管理字段值、错误提示和提交事件,onFinish 在表单规则通过后调用。submit 还会执行共享规则:

ts
// 源码节选:src/shared/ticket.ts
export const createTicketSchema = z.object({
  title: z.string().trim().min(1, '请输入工单标题').max(100, '标题最多 100 字'),
  description: z
    .string()
    .trim()
    .min(1, '请描述遇到的问题')
    .max(10000, '问题描述最多 10000 字'),
  priority: z.enum(ticketPriorities),
});

trim() 先去掉两端空白,因此“只输入几个空格”也不能通过。safeParse(values) 返回成功或失败的结果;失败时用 form.setFields 把错误映射回字段,成功时提交 result.data,也就是清理和校验后的值。

前端表单的 required、maxLength 提供即时体验,Zod 提供共享规则。两者可以互补,但服务端还必须独立执行同一套输入校验。

拓展阅读TypeScript 为什么不够?

CreateTicketInput = z.infer<typeof createTicketSchema> 能在写代码时约束参数;编译后类型会消失。浏览器控制台、脚本或另一个客户端都可以发任意 JSON。Zod 则在程序运行时检查真实值,解决的是不同的问题。

当前文本长度按 JavaScript 字符串长度判断,不能把“100 字”严格理解成 100 个可见字符;某些 emoji 会占多个 UTF-16 码元。需要精确的用户可见字符计数时,应单独定义产品规则并协调各层约束。

3. tRPC 把“函数感”转换成 HTTP 请求#

当前提交处最终调用:

ts
// 源码节选:CreateTicket 内
create.mutate({ ...result.data, teamId: team.id });

create 来自 useMutation(trpc.ticket.create.mutationOptions(...))。tRPC 根据服务端 AppRouter 推导输入输出,TanStack Query 管理这次变更的等待、成功与失败状态。

浏览器仍通过 client.tsx 的 httpBatchLink({ url: '/api/trpc' }) 发请求;它没有跨进程直接执行服务函数。批处理可以合并多次调用的传输,但合并成一个 HTTP 请求不等于这些业务共享一个数据库事务。

请求等待时,页面使用 create.isPending 禁用表单、展示 loading,防止连续点击。这是交互控制,不是服务端幂等保证。第一阶段的创建接口没有回复接口那样的幂等键;如果请求已经写库但响应丢失,用户手动再次提交仍可能创建另一张工单。因此客户端默认不自动重试 mutation。

4. HTTP 入口建立可信上下文#

route.ts 导出 GET 与 POST,把标准 Request 交给 tRPC 的 fetchRequestHandler。

当前版本会:

  1. 对带 Origin 的 POST 检查来源是否等于 BETTER_AUTH_URL。
  2. 使用 Better Auth 检查 Cookie 会话。
  3. 建立 { db: getDb(), userId: session?.user.id ?? null }。

ctx 可类比组件树中的 Context:让下游获得统一依赖。但这里是服务端请求上下文,不是 React Context,也不会自动传到浏览器。db 实例可跨请求复用,userId 则从这次请求的会话确定。

trpc.ts 中 protectedProcedure 拒绝没有身份的调用;来源检查不替代登录,登录也不替代团队授权。

5. router 先检查输入,再交给业务服务#

在 router.ts 中找到 ticket.create:

ts
// 源码节选:当前创建接口
create: protectedProcedure
  .input(createTicketSchema.merge(teamScopeSchema))
  .mutation(({ ctx, input }) =>
    createTicketService(ctx.db, ctx.userId, input.teamId).create({
      title: input.title,
      description: input.description,
      priority: input.priority,
    }),
  ),

.input(...) 在处理函数之前验证参数;.mutation(...) 表明这是修改数据的业务操作。接口明确只把三个内容字段传给创建方法,团队和用户则作为服务工厂的上下文传入。

共享 schema 能减少前后端规则漂移,却不能消除业务校验。例如 teamId 是合法 UUID,并不能证明该用户属于这个团队。schema 检查输入形状;service 检查操作资格和业务事实。

6. 服务层负责把业务事实写成数据库操作#

打开 services/ticket.ts,定位 create。

最初第一阶段的核心只是 db.ticket.create({ data: input, select: ticketSelect })。当前实现外围增加事务,依次锁定团队、检查成员、写工单、写操作记录、保存提醒任务。创建者来自服务端 userId,提醒截止时间由优先级计算。

这里先掌握两个动作:

  • data 表达“要保存哪些业务数据”。
  • select 表达“允许读出并返回哪些字段”。

显式 select 像给接口制定字段白名单。模型以后增加内部备注或敏感字段时,不会因为直接返回整个对象而意外公开。Prisma 类型安全不代表整个数据库模型都适合暴露给前端。

当前为什么需要事务#

当前“创建成功”的含义还包括操作记录和提醒任务已保存。这几笔数据库写入要一起成功;中间失败时一起回滚,避免只创建工单却没有对应记录。

事务保护 PostgreSQL 内的这些写入,不能把 HTTP 响应送达、浏览器跳转和 Redis 入队都变成同一个原子操作。当前先把提醒任务存进数据库,Worker 之后投递和执行;后续章节再学习这种安排如何处理跨系统失败。

数据库连接为什么要复用#

db.ts 的 getDb() 延迟创建 Prisma Client,并放入服务端全局对象复用。开发热更新会重新加载模块,如果反复创建连接池,连接可能持续增加。

连接池可类比可重复使用的网络通道集合。当前 max: 5 是单个池的上限;部署多个 Web / Worker 进程时会有多个池,不是整个系统最多五条连接。每次请求结束都断开 Prisma,会抵消复用价值。

7. 数据通过明确的成功或错误路径返回#

服务层把时间通过 toISOString() 转成字符串;HTTP JSON 不会保留 JavaScript Date 实例。前端用 Day.js 格式化为用户本地时间。带时区的时间点与“生日”这样的纯日期含义不同,不应混用。

成功后,当前创建组件关闭弹窗、清空表单和草稿,让列表缓存失效,提示成功并跳到 /tickets/<UUID>?team=<团队ID>。数据库写入是业务结果;跳转与提示是 UI 对结果的响应。

失败则走 onError:字段错误映射到表单,其他错误显示消息。统一错误格式器会隐藏内部错误堆栈,将内部异常转为通用提示;原始服务端异常留在服务端入口的日志中排查。

失败位置 例子 前端应看到的结果
schema 空白标题、非法优先级 字段提示或 BAD_REQUEST
会话检查 未登录 UNAUTHORIZED
资源授权 不属于请求团队 FORBIDDEN
数据库 / 未知异常 连接失败 通用服务不可用提示

不要把“请求返回了”理解成“业务成功了”,还要区分成功数据与错误响应。

8. 用一次操作串起来#

创建“无法登录”后,在浏览器 Network 中找到 ticket.create 对应的 tRPC 请求,确认标题、描述、优先级和团队 ID;请求可能带批处理外壳,不一定是扁平 JSON。再用 Prisma Studio 查到对应记录,最后刷新详情。

你应能复述:表单收集并初步校验 → HTTP 入口确认会话 → tRPC 校验参数 → 服务层确认团队成员并执行业务事务 → Prisma 写 PostgreSQL → 返回序列化结果 → 页面更新。

如果只在页面 state 里新增了一行,刷新就消失;如果数据库已有记录但页面仍旧,是另一条“读与缓存”链路的问题,进入下一章继续分析。

把知识变成自己的理解

完成本章练习,再继续下一站。

TEAMDESK HANDBOOK · 从一张工单,走进全栈开发

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。