Joshua Chen Personal Blog

Back

📝 Note ✅ Ready · next.js 全栈 typescript

3天速通全栈项目

通过一个文档,一个项目,加深对全栈项目的理解

views | comments

这个项目来源于【睿迄科技】全栈开发3天挑战#

https://docs.qq.com/doc/DVkFxSmZxemVXQXZ1

演示仓库:JoshuaChen2008/Healthy-test: A Healthy test’s project

这个文档里面详细的罗列了技术栈要求,数据流和边界要求,作为一个项目很适合新手配合AI辅助理解和学习全栈的概念 例如:

  • 前端:Next.js(App Router)或任何你擅长的框架。我们不评判动效细节和像素级还原,但前端要让真实用户愿意一路填到付费弹窗——基础视觉、文案、节奏、信任感不能糊弄,以吸引用户的角度考量

  • 后端(Core):Node.js(Next.js API Routes / NestJS / Express 任选)+ TypeScript

  • 数据库:Supabase / Prisma + PostgreSQL

  • 测试:任选主流框架(Jest / Vitest / Playwright 等),覆盖第四阶段要求的核心逻辑与流程

  • 部署:必须提供一个公网可达、可完整演示的线上链接(我们会直接在线上跑一遍 funnel + 模拟支付的全流程)

我让opus帮我解读文档,然后依据项目内容以面试的层次拷打我,下面是他提出来的6个问题,想办法回答出来以后让我对数据流和接口有了进一步的理解


Q1.#

一个新用户从打开页面到”付费解锁完整结果”,后端一共被调用了哪几个接口?按顺序列出来,并说明每个接口产出的那个 id 是干嘛的userIdassessmentId 分别在后面哪些请求里被用到)。

session → assessment → PATCH(多次) → submit → result(脱敏) → pay → result(完整)

session接口产出userID,因为session的业务是创建一个新用户,assessment POST是根据userID在assessments表创建对应assessment数据,PATCH接口则是分步保存body提交assessment数据,submit的POST是根据assessmentID查assessment,然后计算bmi和健康值等数据返回,result 的GET接口是根据assessmentID查询assessment表,拿到userID,然后根据UserID查询subscription表的对应user的数据,再根据subscription.status的状态返回对应结果,没付费的时候状态是free,走Pay的POST接口付费以后会把subscription.status的状态更新为active,然后再次调用result GET接口,获取付费后的完整数据内容


A:

打开页面

1. 创建新用户,调用 api/session 接口#

session 的 POST 接口先校验请求 body 是否合法,要求 body 是空对象 {}。 校验通过后,在 users 表创建一个新用户,并同时在 subscriptions 表为这个用户创建一条 free 状态的订阅记录。 最后返回 userId 和 201 状态码。

User 本身没有 free 状态 Subscription 才有 free 状态

2. 根据用户ID创建一份测评表草稿#

assessment 的 POST 接口先校验请求 body 里必须有合法的 userId。

然后去 users 表查这个 userId 是否存在。 如果不存在,返回 404。

如果存在,就在assessments表创建一条新的测评草稿记录,关联到这个userID

最后返回assessment和201状态码

3. 分步PATCH保存答案#

调用:ID里的PATCH接口,前端传来数据 先校验id,再读取body,读取body的时候会防止坏JSON,然后再对body字段做业务校验 然后检查id对应的assessment表是否存在

拆分answers和核心字段,再合并answers,更新数据库

这里的拆分字段是JS的结构语法

const {answers, ...coreFileds} = parsed.data;
js

把parsed.data里的answers这个字段单独拿出来,是一个语法糖 相当于

const answers = parsed.data.answers;

const coreFields = {
  currentStep: parsed.data.currentStep,
  age: parsed.data.age,
  heightCm: parsed.data.heightCm,
};
js

因为answer是分步保存,需要合并新老数据,不能直接覆盖,所以要从parsed.data这个前端传来的新数据单独拆出来进行处理 然后创建data,先放没拆的那一部分 然后检查检查数据库里的 existing.answers 是不是普通对象。如果是,就拿他当旧answers,如果不是,就用{}当旧answers,然后都执行:旧answers+新answers合并

const existingAnswers = isPlainObject(existing.answers) ? existing.answers : {};

data.answers = {
  ...existingAnswers,
  ...answers,
};
js

结果放回data.answers,最后拿这个data更新数据库

4. 调用submit的POST接口,更新bmi等需要计算的数据#

根据assessmentID查assessment,检查核心字段是否完整,然后调用Health算法,算出结果以后更新assessment里的计算结果字段 因为这里assessment里面 bmi / recommendedCalories / targetDate 原本是空的 status 原本不是空的,而是 in_progress 所以这里是更新

把status改成completed,返回bmi和recommendedCalories

这里的status状态是为了表示这份测评已经正式提交完成,没completed前的status是in_progress状态,即草稿状态

同时也是给result接口等其他接口判断用,不是为了防止重复计算。

返回 bmi 和 recommendedCalories 是返回给谁?

是返回给”调用这个接口的人”。

在实际产品里,调用这个接口的一般是前端页面:后端返回一个JSON,JSON可以被前端拿到

总的来说 submit接口负责计算并把草稿变成完成状态

5. 调用result接口,返回测评结果#

通过GET请求URL里面的:id拿assessmentID 也就是 GET /api/assessments/:id/result 这里的:id就是assessmentID

检查assessmentID的合法性 通过assessmentID去assesment表查这条assessment 从assessment记录里面拿到userIDl 再从这个userID去subscriptions表查这个用户的订阅status 这里subscriptions是单独的一个表,存放用户订阅状态

查userID里的subscription字段的status是free还是active 如果没查到订阅,则默认当free

const membership = subscription?.status ?? "free";
js

subscription?.status 如果subscription存在,就取它的status

如果不存在,就得到undefine

?? "free" 如果前面是null或者underfined,就用free

最后: 如果 membership 是 active,就返回完整版数据,包含 targetDate。 否则返回免费版数据,不包含 targetDate,只返回 locked.targetDate = true。

这样设计是为了支持同一个页面短时间内二次请求结果: 例如用户先看到免费版结果,点击支付后 subscription.status 变成 active, 前端再次调用同一个 result 接口,就能拿到完整版结果。


Q2.#

数据库为什么是 usersassessmentssubscriptions 三张表而不是一张大表?把它们的关系说清楚:为什么一个 user 能对多个 assessment,而 subscription 却是一个 user 只有一条?

A:

user是用户信息,存着id / createdAt / updatedAt,分别对应用户/会话/锚点(分步保存用),assessments是用户信息对应的身体数据,subscription是用户信息对应的订阅状态

业务上设计一个user可以有多个健康数据,但是订阅状态只会存在一个,一个用户不可能有多个订阅状态

一个 user 有多条 assessment,如果合成一张表,同一个用户的 id/订阅状态就得在每行里重复冗余,改订阅状态要改 N 行——这就是为什么要拆表


Q3.#

assessments 表里几乎所有字段(age、heightCm、gender…)都是可空的。这个”可空”设计,直接支撑了 PRD 里的哪个功能?如果这些字段设成”不可空/必填”,哪个功能会立刻崩掉,为什么?

A:

可空设计支撑了PRD里面的分步保存功能,如果不可空/必填,则分步保存的功能会崩掉,因为新建 assessment 时,一行数据里绝大多数列都还是空的(用户才填第 1 步)。如果这些列是 NOT NULL(必填),数据库在 INSERT 那一刻就直接拒绝——因为必填列没值,这条”半成品”记录根本存不进去。可空 = 允许”残缺的行”先存在、以后慢慢补。


Q4.#

PATCH 分步保存时,answers 字段是合并而不是覆盖。举个具体例子:用户第 3 步存了 {sleep_hours: "6_7"},第 5 步存 {diet: "balanced"}。如果 Codex 当初写成”覆盖”,最后数据库里会剩下什么?这会导致什么后果?

A:

最后只会剩下diet:“balanced”,导致用户信息必须一次性填完,否则中途修改会导致用户老信息被新选项抹去,只剩下新选项,失去业务功能


Q5.#

target_date(目标达成日期)是”被保护字段”。用大白话说:为什么偏偏是这个字段要藏起来,而 bmi 就可以直接给非会员看?这背后的商业逻辑是什么?

A:

  1. 直接原因,target_date的隐藏是目标网站的设计,这里是模仿目标网站的思路
  • bmi / 卡路里:是”通用、用户自己也能大概算”的东西 → 免费给,当诱饵,证明”我们家算得挺专业”,建立信任。
  • target_date:是”个性化、用户最想知道、自己算不出来”的那个高潮答案(我到底哪天能瘦到目标)→ 正因为它最想要,才要锁起来,制造”就差一步”的心痒,逼你付费。

Q6.#

订阅是”账号级别”的。意思是:用户 A 付了一次钱,他之前做过的 3 次测评结果会怎样?他之后再做的第 4 次测评呢?为什么代码能做到这点?(提示:想想 result 接口是拿什么去查订阅状态的)

A:

前三次测评结果可以正常查看对应的付费内容测评,第四次也可以,因为result的GET接口是获取用户对应的订阅状态来选择要展示哪些内容,而不是跟着assessment内容走,是解耦的


收尾,设计前端交互#

设计账号登录环节在测评结束以后的付费环节,使用户可以沉浸式体验产品

1. 测评填写过程想要哪种交互节奏? 一屏一个问题(quiz 式)

2. 用户刷新页面或下次打开,怎么知道”还是他”以便恢复进度/识别付费状态? 浏览器本地存(无需登录)

3. 付费环节在页面上怎么展现? 一键模拟付费

4. 整体视觉风格想要哪种方向? 清新现代健康 App 风

5. 用户完成一次测评后,需要看到”我以前测过的记录”吗?还是只需要支持”再测一次”就够了? 不做列表,只加个”再测一次”按钮


1. 关于用户信息#

方案 Aemail/passwordHash 直接加在 User 表上,而不是单独建一张表 方案 B:单独建一张 Credential/Account 表,userId 外键关联,一对一存 email/密码。

一个用户目前最多只有”一套”邮箱+密码,这是真正的 1 对 1 且是可选的关系。 方案B那种单独表的价值在于”以后可能要支持多种登录方式”(比如再加个 Google 登录、手机号登录),那时候一个用户可能对应好几条”凭证记录”。但这个项目现在只有一种登录方式,单独建表只是多一次 join,换不来任何实际好处——这是典型的”为假设中的未来需求设计”的过度工程,先不做


2. 登录状态的存储#

方案 A(采用):cookie 里放一个随机生成的 session id,自己不代表任何意义,每次请求服务器拿它去数据库查”这个 id 对应哪个 userId、过期没” 方案 B:cookie 里放一个自签名的 JWT,里面直接编码 userId + 过期时间,服务器不用查库,验证签名就知道是谁。

为什么选 A(数据库 session):

  • 退出登录要”真退出”。数据库方案里,退出登录 = 删掉那一行,这个 session id 立刻在任何地方都失效。JWT 方案里,令牌一旦签发,在到期之前天然有效,除非额外再维护一张”作废名单”表——但那样其实还是搭了一套数据库状态,只是绕了个更丑的弯路。

  • 和这个项目现有的风格一致。现在项目里所有状态(users/assessments/subscriptions)全部走 Prisma + Postgres,再加一张 Session 表,测试方式和现有的完全一样(用真实 Postgres 测)。JWT 会引入一套全新机制:签名密钥、密钥怎么存/怎么轮换——这是这个项目目前完全没有的复杂度。

  • 不需要新的密钥管理。JWT 需要一个 JWT_SECRET 环境变量,还要考虑它怎么保管、要不要轮换。数据库方案不需要任何新密钥。

JWT 真正的优势是”不用查库就能验证”,当前系统规模较小,而且所有后端实例都能访问同一个 Supabase,因此用数据库统一保存 Session 足够简单可靠,暂时不需要为了无状态认证引入自建 JWT。

🗂️ This is a 📝 note in the knowledge base.

Content may be incomplete or work-in-progress.

← Back

Comment seems to stuck. Try to refresh?✨