1. 首页
  2. /
  3. 60天Node.js
  4. /
  5. Day 46
🎯 阶段 5 · 全栈项目实战

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.jsReact + 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。四条贯穿全局的原则:

  1. 归属与权限由服务端解析,不来自请求体。请求里只给 projectId,「这项目属于哪个工作区、你有没有权限」由「projectId → 查库」推出——绝不让前端传归属,否则攻击者塞别人工作区的 id 就混进去了。前端的 workspaceId 只用来「展示我在哪个团队」,不用来「授权我能动哪个」。
  2. 资源是名词,关系是嵌套/workspaces/{ws}/projects/{key}/tasks/{number}。URL 就是资源的地址,从左到右是包含关系。任务用项目内编号(ENG-42)对外,对人友好;UUID 留内部。
  3. 列表一律游标分页,不用 offset。深翻页(offset 10000)要扫前 10000 行,越往后越慢;游标(keyset)用索引直接定位(Day 28 已讲透)。而且列表接口永远别返回 total——算 COUNT(*) 要全扫,hasMore 便宜得多。
  4. 写操作幂等靠 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)
003API 风格内部 tRPC 拿端到端类型,外部留 REST
004软删除内容软删可恢复(卖「单条粒度」),账号硬删
005公开 IDUUID 主键(不可枚举)+ 项目内编号对外(人好念)

这五条不是孤立的:协作模型(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 砍了

💻 实践练习

今天没代码可跑,练习是「读设计 + 追问」。把每道题答上来,比写代码更能验证设计经不经得起。

  1. 读 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]) 服务的最热查询长什么样?(提示:看板视图)
  2. 追一个请求的全路径(现在可做):打开 docs/architecture.md §3 的时序图,回答:

    • 第 ② 步「workspaceId 由 projectId 查出」——如果让前端直接传 workspaceId 来授权,能构造出什么攻击?
    • 任务编号自增(第 ④ 步)为什么必须在同一事务里?两个并发建卡会怎样?(提示:见 ADR-005 的计数器行锁。)
    • 如果某天有个 handler 忘了先验成员身份、直接按 taskId 查了别人的任务,这套设计能拦住吗?为什么?(提示:ADR-001 认的代价——没有 RLS 兜底,靠应用层自觉 + 测试。)
  3. 做一次你自己的需求砍刀(现在可做):假设要加「甘特图」「时间追踪」「Slack 集成」「自定义字段」,对每个功能问:它验证核心假设(有团队愿意用)吗? 把答案和 decisions/ADR-002 的 YAGNI 原则对照。

  4. 读一份 ADR,复述它的「坏的代价」(现在可做):好的 ADR 不只记「选了什么」,更记「认了什么代价」。挑 ADR-001ADR-004,把它的「坏的 / 要认的代价」那段用自己的话复述——能复述出来,才算真读懂了那个决策。

  5. 思考题

    • 我们选了「协作分组」而不是「多租户隔离」。哪天第一个企业客户要求「我们的数据绝不能被别的客户看见」,最小改动是什么?为什么说「归属链已经是 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 45 | ➡️ Day 47

本文内容来自 源仓库 day-46/README.md