Day 46
项目规划与架构设计
Day 45 把博客的「可观测性」补齐了——结构化日志、错误上报、健康探针都就位,那套 blog-api 算是把一个单体应用「从能跑到能运维」的路完整走了一遍。但说实话,从 Day 17 到 Day 45,我们打磨的是一个单用户视角的 demo:没有真正的「多人协作」,文章的归属和可见性也不是核心问题。
从今天起换一个更贴近真实的产品:一个 SaaS 任务管理平台(参考 Trello/Linear 的轻量版)。它和博客的差别不是「功能更多」,而是多了「协作」这层——几个用户组成一个工作区(团队),共享里面的项目和任务。这层「数据归谁、谁能看见、谁能改」一旦写进数据模型、灌了真实数据,就很难回头重做。所以这一天的全部工作,是在动键盘之前,把架构想透、把决策钉死。
要先说清一条:这是一个普通的、带团队协作的 SaaS,不是企业级多租户。没有「不同客户的数据必须物理隔离」那套要求,也就不背
orgId冗余 + RLS 的复杂度。数据归属走最朴素的路子——项目属于工作区,能看见 = 你是成员。把这条边界画清楚,是今天第一个、也是最重要的一个决策。一句话目标:产出一份能支撑 Day 47 起开发的设计——数据模型(
schema.prisma)、系统架构与图(architecture.md)、API 契约(api-design.md)、以及五份关键决策记录(decisions/)。今天没有可运行的代码,验证方式是「这套设计经不经得起追问」。
📋 今日目标
- 想透为什么 SaaS 的第一天是设计而非代码:从「单用户 demo」到「多人协作」,哪些决策一旦定了就改不起
- 做一次需求砍刀:MVP 留什么、砍什么、为什么「不做」比「做」更需要纪律
- 把协作模型这个命门决策想死:工作区(团队)+ 成员身份,为什么不做多租户隔离
- 定下技术选型并讲清每一项的为什么:Next.js + tRPC(内部)/ REST(外部)+ PostgreSQL + Redis
- 设计数据模型:抓住「账号(User)跨工作区、工作区(Workspace)是协作单位」这条主轴,理清归属链
Task → Project → Workspace - 定下 API 契约:归属与权限从服务端解析、游标分页、统一错误信封、幂等与乐观锁各管什么
- 把以上取舍写成 ADR(架构决策记录),让「为什么」比图更 durable
配套代码:
solutions/saas/。全是设计产出,没有可运行代码——prisma/schema.prisma(数据模型)、docs/architecture.md(架构 + 组件/时序/ER/部署图)、docs/api-design.md(接口契约)、docs/decisions/ADR-001~005(五份决策记录)。脚手架和真实代码是 Day 47 的主题。
📖 核心知识点
1. 这天在解决什么:为什么 SaaS 的第一天不写代码
博客(Day 17-45)和这个 SaaS 摆在一起,差在哪:
| 博客(单用户视角) | SaaS 任务管理 | |
|---|---|---|
| 谁看什么 | 基本同一份全局内容 | 按工作区分,团队里的人共享,团队外的人进不来 |
| 数据归属 | 文章没有「属于哪个团队」的概念 | 每个任务都要回答「这是哪个工作区的」 |
| 权限 | 全局角色(user/admin) | 角色是**「某人在某个工作区里」**的属性,跨工作区不同 |
差别指向同一个词:协作。博客没有「几个人共享一份数据」的问题,所以可以边写边改;SaaS 不行——「数据归谁、谁能看见」一旦写进表结构和查询、灌了真实用户数据,再改就是伤筋动骨(要改归属关系、改每个查询的权限判断、迁移存量数据)。
这就是为什么 Day 46 不写代码。不是故弄玄虚,是「归属与权限模型」这个决策的改错成本高到值得先想清楚。 今天的产出是设计文档和 ADR——价值不在「好看」,而在「动键盘之前,把那些改不起的决策提前钉死」。
这条道理 Day 20(zod 校验环境变量)和 Day 41(Dockerfile 最小化)都讲过:越靠前、越难改的决策,越值得在第一天投入。归属与权限模型是这条道理在架构层的一个实例。
2. 需求砍刀:MVP 留什么、砍什么
一个「全功能」的任务管理工具能列出一百个功能:看板、甘特图、时间追踪、自动化、自定义字段、API、集成 Slack/GitHub、报表……。MVP 的纪律不是「挑重要的做」,而是敢砍——把「听起来该有、但验证不了核心假设」的功能全删掉。
核心假设只有一个:几个用户愿意组队,把内部任务搬到这套共享软件上协作。能验证它的最小功能集:
| 留(MVP) | 砍(留后面) | 砍的理由 |
|---|---|---|
| 工作区 / 项目 / 任务 / 评论 / 标签 | 甘特图、时间追踪、自定义字段 | 没它们照样能「管理任务」,它们是增强不是前提 |
| RBAC(Owner/Admin/Member/Viewer) | 细粒度权限(按项目授权) | 四档角色够协作;细粒度是「团队变大后」的痛 |
| 邮箱邀请加入工作区 | SSO / SCIM 企业账号 | 企业身份是「卖给大客户」才需要的 |
| 基础通知(@提及、状态变更邮件) | 实时协作(光标同步、CRDT 合并) | 见 ADR-002:同步优先,实时后置 |
| 软删除(任务可恢复) | 完整操作审计报表 | deletedAt 先记着,报表是「数据多了」才做 |
砍刀的标准只有一条:这个功能,不验证核心假设吧? 是 → 砍。听起来再「理所当然」,只要它不能帮你回答「有没有团队愿意用」,就是过早建设。多数 SaaS 死于过早建设,而非功能不足。
3. 协作模型:整个架构的命门
这是今天最该想透的一节,也是 ADR-001 的全部内容。问题很朴素:几个用户共享任务,数据归谁所有、谁能看见? 两种思路,复杂度差一个量级:
| 模型 | 做法 | 适合 |
|---|---|---|
| 多租户隔离 | 每个工作区当「互不信任的客户」,数据层硬隔离(orgId 冗余 + RLS) | 卖给企业、合规要求高的 SaaS |
| 协作分组(我们选这个) | 工作区当「一个团队」,归属走「项目属于工作区、能看见 = 你是成员」,应用层鉴权 | 团队内部协作的普通 SaaS |
我们选协作分组。理由是从「这个产品是什么」倒推的:它是一个团队内部协作的工具,工作区里的成员本来就互相可见——不存在「A 工作区的人不该看见 B 工作区」这种跨租户威胁,只有「工作区外的人不该进来」这一层,应用层鉴权足够。多租户那套(orgId 冗余 + RLS + 跨租户审计 + 每租户备份)的复杂度,只有在「客户之间互不信任且泄漏是合规事故」时才回本,对学习项目是纯负担。
授权判断就一句话:「你是这个资源所在工作区的成员吗?你的角色够做这个动作吗?」 顺着归属链 Task → Project → Workspace 回溯到工作区,查 Membership。不需要在每行冗余工作区 id,不需要 RLS——应用层把好这道关就够。
这个决策有个隐性保险:归属链已经是
Task → Project → Workspace,将来真有「数据绝不能互相看见」的合规需求时,迁移到「每行带 workspaceId + RLS」是机械工作,不是重写应用。先按真实威胁模型选复杂度,把多租户留给真有多租户需求的产品阶段。 详见decisions/ADR-001。
4. 技术选型:每一项的为什么
| 组件 | 选型 | 为什么是它 |
|---|---|---|
| 前端 | Next.js | React + SSR/RSC,SEO 友好,路由/布局/数据获取开箱即用 |
| 前后端缝合 | tRPC(内部) | 端到端类型,改后端字段前端立刻编译报错,省掉「接口对不上」一整类 bug |
| 领域逻辑壳 | REST/OpenAPI(留外部) | tRPC 只解决 TS 内部;将来开放 API 给第三方时,REST 是语言无关的契约 |
| 数据库 | PostgreSQL | 唯一真相源;JSONB(任务 meta)、数组、tsvector(搜索)开箱即用 |
| 缓存/队列/限流 | Redis | 降级层:缓存 miss 回退直连 PG,队列不通邮件降级补发(Day 36/38 套路) |
| 附件 | S3/R2 | 跨实例共享、不受容器生命周期影响(Day 44 §9 讲死的) |
这里最值得展开的是 tRPC vs NestJS——和博客的选择不一样。博客用 NestJS + REST,是为了练 HTTP/REST/DI/装饰器的基本功,NestJS 是最好的教具。这一弧线换 tRPC,是因为目标是快速搭一个类型安全、能演进的产品,tRPC 的端到端类型在产品开发里性价比更高。
但 NestJS 那套不白学:service 的组织方式、依赖注入的心智模型、模块化思想完全复用——只是「外壳」从 Nest controller 换成 tRPC router。领域逻辑写成框架无关的 service(tRPC procedure 只是薄壳:解析 input → 调 service → 返回),这样将来要加 REST、加 GraphQL,都不动核心。详见 decisions/ADR-003。
一句话:NestJS 教你「后端怎么组织」,tRPC 教你「前后端怎么缝合」。两个都值得会,这一弧线选后者,因为它解决的是 SaaS 前后端协作这个具体的痛。
5. 数据模型:归属链与成员身份
数据模型见 prisma/schema.prisma。读它时,始终带着两条横切的线:
线 A:账号 ↔ 团队 的三段式 User — Membership — Workspace。
model Membership {
userId String
workspaceId String
role Role @default(MEMBER) // OWNER/ADMIN/MEMBER/VIEWER
@@unique([userId, workspaceId]) // 一个人在一个团队里只有一条关系
}
Membership 是三合一:连接关系 + 角色 + 加入时间。最反直觉、也最关键的一点是——权限不在 User 上。一个人可以同时是 A 团队的 Admin、B 团队的 Viewer,所以「角色」不能挂在 User 上,只能挂在「这个人 × 这个团队」这条关系上。这是带协作的 SaaS 和单用户全局角色(user/admin)的分水岭。
线 B:归属链 Workspace → Project → Task。
model Task {
projectId String @map("project_id") // 任务属于项目
number Int // 项目内自增编号 → "ENG-42"
@@index([projectId, status, order]) // 看板视图主查询
}
一个项目属于一个工作区,一个任务属于一个项目。可见性顺着这条链回溯:「你是这个任务所在工作区的成员吗?」——这是应用层鉴权的核心判断。注意我们没有在 Task 上冗余 workspaceId:判断归属时顺着 Task → Project → Workspace 查一下即可,没必要为了省这一次 join 给每行塞冗余列(那是多租户才需要的优化,这里用不上)。
这两条线咬在一起,就是这套模型的全部:Membership 决定「你在哪个团队、是什么角色」,归属链决定「你看到的每个任务属于哪个团队」。把它想透,后面的 API 和权限都是它的自然推论。
6. API 设计:把数据模型暴露成契约
API 契约见 docs/api-design.md。四条贯穿全局的原则:
- 归属与权限由服务端解析,不来自请求体。请求里只给
projectId,「这项目属于哪个工作区、你有没有权限」由「projectId → 查库」推出——绝不让前端传归属,否则攻击者塞别人工作区的 id 就混进去了。前端的 workspaceId 只用来「展示我在哪个团队」,不用来「授权我能动哪个」。 - 资源是名词,关系是嵌套:
/workspaces/{ws}/projects/{key}/tasks/{number}。URL 就是资源的地址,从左到右是包含关系。任务用项目内编号(ENG-42)对外,对人友好;UUID 留内部。 - 列表一律游标分页,不用 offset。深翻页(offset 10000)要扫前 10000 行,越往后越慢;游标(keyset)用索引直接定位(Day 28 已讲透)。而且列表接口永远别返回 total——算
COUNT(*)要全扫,hasMore便宜得多。 - 写操作幂等靠
Idempotency-Key。用户网卡抖动连点两次「创建」,不该建出两张卡;客户端带唯一 key,服务端 24h 内见同一个 key 就返回首次结果。
7. 权限:成员身份 + RBAC
权限不是一道闸,是两道串起来的闸:
请求 → ① 成员闸:你是这个工作区的成员吗?(查 Membership 存不存在)
→ ② 角色闸:你的角色够做这个动作吗?(OWNER/ADMIN/MEMBER/VIEWER)
→ ③ 资源闸(部分动作):这资源是你负责的吗?(如删任务 = assignee 或 ADMIN)
- ① 是协作边界(第 3 节讲的),答「能不能看见」。
- ② 是 RBAC,答「看见了能不能动」。
- ③ 是细粒度的资源所有权,只在「删自己负责的任务」这类场景加,MVP 不上「按项目授权」那种完整 ACL。
四档角色(OWNER/ADMIN/MEMBER/VIEWER)的权限矩阵见 api-design.md 的端点表。有一条铁律值得在测试里钉死:OWNER 永远至少留一个——不能把唯一 OWNER 降级或移除,否则工作区没人能管成员,死锁。这种「不变量」靠应用层守 + 测试覆盖。
8. 关键决策:写成 ADR
今天最重要的产出不是图,是五份 ADR(Architecture Decision Record)——把「为什么这么选」记下来。图会演进、代码会重写,但「当时的取舍推理」是 durable 的,后人(包括未来的自己)改架构时,先读 ADR 才知道哪些约束不能动。
| ADR | 决策 | 一句话 |
|---|---|---|
| 001 | 协作模型 | 工作区 + 成员身份,不做多租户隔离,威胁模型没那个需求 |
| 002 | 实时协作 | 同步优先,把 CRDT 推到真需要时(YAGNI) |
| 003 | API 风格 | 内部 tRPC 拿端到端类型,外部留 REST |
| 004 | 软删除 | 内容软删可恢复(卖「单条粒度」),账号硬删 |
| 005 | 公开 ID | UUID 主键(不可枚举)+ 项目内编号对外(人好念) |
这五条不是孤立的:协作模型(001)定了归属链,授权就顺着它做;项目内编号(005)配合软删除(004)支撑「误删可恢复」;实时(002)和 API(003)的取舍都指向「先内部、把昂贵方案推迟」。读到后面会发现没有哪个决策是孤立的——这就是架构设计的味道。
改动清单(新建 solutions/saas)
| 文件 | 是什么 |
|---|---|
prisma/schema.prisma | 新增:完整数据模型。User/Workspace/Membership/Invitation/Project/Task/Comment/Label/TaskLabel/ProjectTaskCounter,归属链 Task→Project→Workspace,软删除、乐观锁、看板排序、项目内自增编号 |
docs/architecture.md | 新增:组件视图、创建任务请求时序(鉴权 + 授权)、ER 图、部署拓扑(复用 Day 44 托管 PG/Redis/S3)、同步/异步边界 |
docs/api-design.md | 新增:设计原则、鉴权与授权、资源端点表、游标分页/过滤/排序、统一错误信封、幂等与乐观锁、限流、tRPC/REST 映射 |
docs/decisions/ADR-001~005 | 新增:五份决策记录(协作模型 / 同步优先 / API 风格 / 软删除 / 公开 ID) |
README.md | 新增:solution 目录索引 + 阅读顺序 + 设计立场速览表 |
今天一行可运行代码都没写——这是有意的。Day 47(脚手架)会把这个设计落成能跑的 Next.js + Prisma 工程。把设计和实现分开,正是为了让「Day 46 的决策」不被「Day 47 的工程细节」淹没。
✅ 一份诚实清单
✅ 今天到位的:
- 想透了「单用户 demo」和「协作 SaaS」的差别,以及为什么第一天不写代码(归属/权限模型改不起)
- 做了一次需求砍刀:MVP 留/砍分明,且每条「砍」都有理由
- 协作模型命门决策钉死:工作区 + 成员身份,不做多租户隔离,讲清威胁模型和升级路径
- 技术选型逐项讲清为什么,尤其 tRPC vs NestJS 的取舍(基本功 vs 缝合)
- 数据模型抓住「账号跨工作区、归属链
Task→Project→Workspace」主轴,讲透 Membership 当 RBAC 载体 - API 契约定死:归属服务端解析、游标分页、错误信封、幂等/乐观锁分工
- 权限讲成「成员闸 + 角色闸 + 资源闸」三道串,OWNER 不变量点明
- 五份 ADR 落地,把「为什么」固化成 durable 文档
⚠️/❌ 还没做、留给后面的:
- 没有可运行代码:Day 47 才搭脚手架(Next.js + Prisma + Docker Compose),今天只有设计
- 授权 guard 还没实现:设计里「进 handler 前验成员身份」是 Day 47-48 落地的事;忘了写就有越权风险(ADR-001 认了这个代价)
- 实时协作:ADR-002 明确推后;CRDT/光标同步是独立大主题,本弧线不做
- 搜索:MVP 先用 PG
tsvector,上规模才接 Meilisearch/Elasticsearch - 计费 / 用量:SaaS 商业化核心,但和「能跑的产品」解耦,留独立模块
- 细粒度权限 / SSO / SCIM / 操作审计:都是「卖给大客户」才需要,MVP 砍了
💻 实践练习
今天没代码可跑,练习是「读设计 + 追问」。把每道题答上来,比写代码更能验证设计经不经得起。
-
读 schema,追问归属链(现在可做):
cat solutions/saas/prisma/schema.prisma- 判断「用户 X 能不能看任务 ENG-42」要走几步?(提示:Task → projectId → Project → workspaceId → 查 Membership(userId, workspaceId)。这就是归属链鉴权。)
Membership为什么用@@unique([userId, workspaceId])而不是给userId单独唯一?(提示:一个人能加入多个工作区吗?)- Task 的
@@index([projectId, status, order])服务的最热查询长什么样?(提示:看板视图)
-
追一个请求的全路径(现在可做):打开
docs/architecture.md§3 的时序图,回答:- 第 ② 步「workspaceId 由 projectId 查出」——如果让前端直接传
workspaceId来授权,能构造出什么攻击? - 任务编号自增(第 ④ 步)为什么必须在同一事务里?两个并发建卡会怎样?(提示:见 ADR-005 的计数器行锁。)
- 如果某天有个 handler 忘了先验成员身份、直接按 taskId 查了别人的任务,这套设计能拦住吗?为什么?(提示:ADR-001 认的代价——没有 RLS 兜底,靠应用层自觉 + 测试。)
- 第 ② 步「workspaceId 由 projectId 查出」——如果让前端直接传
-
做一次你自己的需求砍刀(现在可做):假设要加「甘特图」「时间追踪」「Slack 集成」「自定义字段」,对每个功能问:它验证核心假设(有团队愿意用)吗? 把答案和
decisions/ADR-002的 YAGNI 原则对照。 -
读一份 ADR,复述它的「坏的代价」(现在可做):好的 ADR 不只记「选了什么」,更记「认了什么代价」。挑
ADR-001或ADR-004,把它的「坏的 / 要认的代价」那段用自己的话复述——能复述出来,才算真读懂了那个决策。 -
思考题:
- 我们选了「协作分组」而不是「多租户隔离」。哪天第一个企业客户要求「我们的数据绝不能被别的客户看见」,最小改动是什么?为什么说「归属链已经是
Task→Project→Workspace,迁移是机械工作」? - 软删除(ADR-004)和唯一约束会冲突——软删的 Task 还占着编号
ENG-42,新建任务能不能再用 42?我们的解法是什么?另一种解法(唯一约束加deleted_at)有什么代价? GET /workspaces/{ws}/.../tasks/{number}查一个不属于你的工作区的任务,该返回 403 还是 404?为什么?(提示:向工作区外的人确认资源存在,可不可接受?)
- 我们选了「协作分组」而不是「多租户隔离」。哪天第一个企业客户要求「我们的数据绝不能被别的客户看见」,最小改动是什么?为什么说「归属链已经是
✅ 今日产出
- 读完
prisma/schema.prisma,能讲清归属链Task→Project→Workspace、Membership 当 RBAC 载体、为什么没冗余 workspaceId - 读完
docs/architecture.md,能复述「创建任务」请求穿过系统的全路径,说清鉴权 + 授权两步 - 读完
docs/api-design.md,记住四条原则(归属服务端解析 / 游标分页 / 错误信封 / 幂等键) - 至少精读两份 ADR,能复述每个决策「认了什么代价」
- 在笔记里写下:博客(单用户)和这个 SaaS(协作)的差别、为什么选协作分组而非多租户、为什么第一天不写代码
- 提交设计文档到 GitHub
本文内容来自 源仓库 day-46/README.md