02 · 架构与第一阶段地图:一张工单经过哪些层#
第一阶段不是把四个按钮拼出来,而是让一次操作具备完整输入、规则、存储、结果和失败反馈。本章先建立地图,之后每章沿地图走一条业务路径。
1. 先用用户语言定义范围#
一个客户遇到问题,填写标题、描述和优先级;系统保存工单,列表能找到它,点进去能看到详情;客服按允许的路径推进状态。
| 用户动作 | 接口 | 服务方法 | 最终结果 |
|---|---|---|---|
| 提交问题 | ticket.create |
create |
数据库产生工单,页面打开详情 |
| 查看工作台 | ticket.list |
list |
返回本页数据与匹配总数 |
| 打开某一张工单 | ticket.detail |
detail |
返回工单或明确的错误 |
| 推进处理进度 | ticket.updateStatus |
updateStatus |
合法状态写入,版本递增,页面刷新 |
没有删除接口不妨碍这个阶段闭环:业务中的“关闭”是保留工单并结束处理,不等于删除数据库记录。
2. 请求沿这张地图移动#
浏览器:Ant Design Form / Table
↓ useMutation / useQuery + tRPC options
src/trpc/client.tsx:HTTP 客户端与查询缓存
↓ HTTP /api/trpc(httpBatchLink 可以合并请求)
src/app/api/trpc/[trpc]/route.ts:HTTP 适配、会话、上下文
↓
src/server/api/trpc.ts:登录检查、错误格式
↓
src/server/api/router.ts:选择接口、校验输入、转交服务
↓
src/server/services/ticket.ts:权限、业务规则、事务、结果转换
↓ Prisma Client → PostgreSQL
返回:JSON → 查询缓存 / 变更回调 → React 界面入口中的会话验证先建立身份;进入 procedure 后还要经过登录中间件、输入解析,再运行接口处理函数。各层任一检查失败都会中断后续步骤。
前端可以把这理解为“组件 → 请求封装 → controller → service → 数据访问”。类比只帮助定位职责:tRPC 提供类型推导和调用约定,但底层仍然有 HTTP、序列化、网络失败和服务端执行。
3. 目录按职责拆开有什么价值#
| 位置 | 职责 | 阅读入口 |
|---|---|---|
src/app |
路由和页面壳、HTTP 入口 | 首页、详情页 |
src/features/tickets |
工单 UI、表单、查询与交互 | workspace.tsx |
src/shared |
前后端都能使用的规则和类型 | ticket.ts |
src/trpc |
浏览器侧 tRPC 和 Query Provider | client.tsx |
src/server/api |
接口定义与通用保护 | router.ts、trpc.ts |
src/server/services |
用例、资源授权和数据库操作 | ticket.ts |
src/server/db.ts |
连接池与 Prisma 实例 | db.ts |
prisma |
模型与迁移历史 | schema |
共享 schema 不依赖数据库,所以浏览器可以安全使用它。服务层接收 db 而不是自己在每个方法里创建连接,测试也能注入明确的数据库实例。当前服务层使用 TRPCError,仍与 tRPC 有一定耦合;如果以后要复用到 CLI,可再考虑独立业务错误类型,不必在第一阶段先搭建复杂抽象。
4. Server Component 与 Client Component#
本项目 page.tsx、layout.tsx 默认是 Server Component;需要表单事件、Hooks、浏览器缓存的工作台标注 'use client'。
服务端页面可以渲染客户端工作台。详情页面的一个关键步骤是:
// 源码节选:src/app/tickets/[id]/page.tsx
async function Detail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <TicketDetail id={id} />;
}当前版本的 params 是 Promise,需要先读取。外层 Suspense 提供等待时的页面占位。不要直接套用旧教程的同步 params.id。Suspense 也不会自动接管所有 TanStack Query 状态;工作台仍显式显示 loading、error 和 empty。
'use client' 声明客户端模块边界,不代表组件首次只能在浏览器渲染。它允许交互相关代码进入客户端构建;数据库模块不能跟着进入这条导入链。db.ts 的 import 'server-only' 帮助阻止误用。
客户端这一行很重要:
import type { AppRouter } from '@/server/api/router';import type 只参与编译期类型检查,运行时代码会被移除,所以没有把 Prisma 和服务端业务打包进浏览器。类型路径指向服务端文件,与运行时导入该文件是两件事。
5. Provider 负责提供运行环境#
layout.tsx 与 providers.tsx 共同提供:
AntdRegistry:收集并注入 antd 首屏样式。StyleProvider layer、ConfigProvider:样式层级、中文语言包、主题。- antd
App:供App.useApp()获取消息等上下文能力。 ApiProvider:tRPC 客户端与 TanStack Query 缓存。NuqsAdapter、TeamWorkspace:当前版本的 URL 状态与团队/登录上下文。
Provider 像给组件插上公共电源;它不自动替组件保存工单。只有真正调用接口并成功写库,工单才持久化。globals.css 声明 CSS layer 顺序,配合 antd 的 layer 让 Tailwind 和组件样式按明确层级协作;遇到样式覆盖问题,应检查层级与选择器,而非不断加 !important。
6. 第一阶段的数据模型#
先关注最初迁移中的九个核心字段,参见 第一份 SQL:
| 字段 | 含义 | 设计原因 |
|---|---|---|
id |
UUID 主键 | 程序稳定识别记录,详情 URL 使用它 |
number |
自增、唯一编号 | 页面展示 #0001,便于沟通 |
title |
最多 100 字符的标题列 | 短文本,列表快速识别问题 |
description |
TEXT 描述 | 保存较长问题正文 |
priority |
优先级枚举 | 限定为低、普通、高、紧急 |
status |
状态枚举,初始 OPEN |
明确处理阶段 |
version |
初始 0 的整数 | 检测旧页面修改 |
createdAt |
创建时间 | 排序、展示 |
updatedAt |
更新时间 | 反映修改发生时间 |
当前模型还带团队、创建者、负责人、操作记录与提醒关系。理解核心字段后,再回到这些扩展,不要把最初 SQL 覆盖到当前 schema。
拓展阅读UUID、编号和授权。
UUID 不容易猜测,但“难猜”不是权限。当前查询仍限定团队,客户还限定创建者。number 当前是全表自增,团队内出现不连续编号正常;数据库序列也可能因事务回滚产生空缺,它不能用作记录总数。
拓展阅读ORM 与数据库分别保证什么?
Prisma 将模型变成类型安全的访问方法;PostgreSQL 真正保存数据并执行约束。TypeScript 类型不会阻止外部 HTTP 客户端发送错误数据;数据库枚举只限定状态值,并不知道 OPEN 能不能直接变成 RESOLVED。合法状态路径仍须由业务服务检查。
7. 用四个问题检查理解#
- 改标题长度限制,为什么要检查共享 schema、表单和数据库列?因为它们分别约束运行时请求、用户体验和存储能力。
- 想查某条工单,应在哪里检查团队?当前服务层的数据库过滤条件中必须带上;不能只靠页面隐藏入口。
- 为什么 router 很薄?它负责接口边界,复杂业务集中到 service,便于阅读和测试。
- 为什么不在浏览器直接调用 Prisma?连接凭据与可信规则属于服务端,浏览器无法成为数据库授权边界。
下一章从“提交一张工单”开始,把这张地图走一遍。