一、技术栈总览
后端与数据
- FastAPI:原生异步,自动生成 OpenAPI 文档。
- SQLModel:结合 Pydantic 校验与 SQLAlchemy 映射。
- Pydantic v2:用类型注解做运行时校验,并驱动文档生成。
- PostgreSQL + Alembic:成熟的数据库能力,表结构变更可版本化、回滚。
- JWT(HS256)+ Argon2 / Bcrypt:前后端分离认证,支持密码哈希的渐进式迁移。
前端
- React 19 + TypeScript:组件化开发和类型安全。
- Vite 8:快速冷启动与热更新。
- Tailwind CSS 4 + shadcn/ui:原子化样式与可复制的组件。
- TanStack Query / Router / Table:数据请求、类型化路由和表格。
- React Hook Form + Zod:表单管理与校验。
工具与部署
- uv + bun:Python 与 JS 依赖管理,都支持锁文件。
- Docker Compose + Traefik:服务编排、路由和 HTTPS。
二、为什么用 SQLModel,而不是直接 SQLAlchemy
用了什么:ORM 层选 SQLModel,一个由 FastAPI 作者(Sebastián Ramírez)设计的库。
为什么这么用:
SQLModel 的本质是「Pydantic + SQLAlchemy」的融合。传统做法里,这两件事是割裂的:
- Pydantic 负责请求/响应的数据校验(FastAPI 的接口层)
- SQLAlchemy 负责数据库表映射(持久层)
同一个 User 实体,往往要写两套几乎一样的类:一套 Pydantic 模型、一套 SQLAlchemy 模型。字段重复、改一处要改两处、容易不一致。
SQLModel 用一个类同时承载两种能力,消除了这种重复。
好处:
- 一个模型同时用于「校验请求」和「映射数据库表」,减少一半的模型代码
- 类型提示贯通全程,IDE 补全和静态检查都能吃到
- 和 FastAPI、Pydantic 天然同源,配合无摩擦
代价 / 边界:SQLModel 比纯 SQLAlchemy 年轻,极复杂的关系映射和高级特性覆盖不如后者全。所以它适合中小型项目;超大、超复杂的数据模型场景,仍可能回退到原生 SQLAlchemy。
三、为什么一个实体要拆成多个模型类
用了什么:models.py 里,User 和 Item 每个实体都拆成了多个类——XxxBase、XxxCreate、XxxUpdate、Xxx(表)、XxxPublic。
为什么这么用:
不同场景对「字段可见性」的要求是冲突的,用一个类无法同时满足:
| 场景 | 需要的字段 | 不能出现的字段 |
|---|---|---|
| 用户创建(Create) | 必填字段 | id、owner_id、created_at(后端生成) |
| 数据库表(table) | 全部字段 | 无 |
| 返回前端(Public) | 可公开字段 | hashed_password 等敏感字段 |
| 更新(Update) | 全部可选 | 无(PATCH 语义) |
好处:
- 安全:
UserPublic没有hashed_password,密码哈希从结构上就无法泄漏到响应;UserRegister(自注册)没有is_superuser,用户从结构上就无法给自己提权。 - 契约清晰:哪个字段进、哪个字段出,由类定义决定,不靠函数里的 if 判断。
- 变更可控:加一个字段,能精确控制它出现在「入参」「出参」还是「数据库」哪一层。
核心思想:好的模型设计不只是为了复用,更为了限制——明确每个方向允许流动哪些数据,本身就是防御。
四、为什么用依赖注入做鉴权
用了什么:FastAPI 的 Depends + Annotated 类型别名,把鉴权、数据库会话封装成 SessionDep、CurrentUser 这类可复用依赖。
SessionDep = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[User, Depends(get_current_user)]
为什么这么用:
鉴权、数据库连接这些逻辑,和具体业务无关,但几乎每个接口都要。如果写进每个接口函数里,会导致:
- 大量重复代码(每个接口都 copy 一段验 token、开连接)
- 修改困难(改鉴权逻辑要改几十处)
- 业务代码被淹没在样板代码里
依赖注入把这类「横切关注点」抽出来,通过参数类型声明的方式注入。
好处:
- 业务函数只剩业务:接口函数里看不到任何鉴权、连接管理的样板代码。
- 声明式:函数参数写上
current_user: CurrentUser,就自动完成「验 token → 查用户 → 查权限」整套流程。 - 可组合:
get_current_active_superuser依赖get_current_user,链式叠加出「管理员专属」这种更高权限。 - 改一处全局生效:改
get_current_user的实现,所有用到它的接口同步更新。
五、为什么权限写在查询条件里,而不是 if 里
用了什么:列表接口通过 where 子句做数据隔离,而不是查全表后过滤。
# 普通用户只能查自己的
select(Item).where(Item.owner_id == current_user.id)
为什么这么用:
两种做法结果一样,但代价天差地别:
- 数据库层过滤:数据库只返回属于当前用户的数据,网络传输和内存占用都是 O(自己的数据量)。
- 应用层过滤:先把全表读进内存,再丢弃别人的数据,是 O(全表数据量)。
好处:
- 性能:数据量大时,两者的差距是数量级级别的。
- 安全:数据从源头就不离开数据库,不存在「读出来再漏」的风险。
- 可扩展:未来加索引、加分库分表,都能在 SQL 层继续优化。
原则:权限控制必须下沉到数据层,不能只在应用层做判断。
六、为什么密码哈希用两个算法
用了什么:pwdlib 库,同时配置 Argon2Hasher 和 BcryptHasher 两个哈希器。
为什么这么用:
- Argon2id 是 2015 年密码哈希竞赛的冠军,设计上需要大内存,能有效抵抗 GPU 暴力破解,是当前推荐的首选。
- Bcrypt 是上一代主流算法,大量历史系统的密码用它存储。
如果只用 Argon2,历史 Bcrypt 密码就无法校验;如果只用 Bcrypt,就无法享受新算法的安全性提升。所以两个都要。
好处(关键在 verify_and_update 这个设计):
校验密码的函数返回元组 (是否通过, 需要更新的新哈希)。当用户密码还是旧的 Bcrypt 哈希时,校验通过的同时会返回一个新的 Argon2 哈希,系统静默写回数据库。
效果:用户无需任何操作,只需正常登录一次,密码算法就完成了从 Bcrypt 到 Argon2 的迁移。这是生产系统升级密码算法的标准做法——渐进式、无感知,而不是停服强制改密码。
七、为什么用 JWT 做认证
用了什么:JWT(HS256 算法),登录后签发 access_token,前端存储后每次请求带上。
为什么这么用:
前后端分离架构下,后端是无状态的——不保存 session,服务器可以水平扩展,任意一台机器都能处理请求。
JWT 的特点正好契合:token 自带用户身份信息(sub 字段存用户 ID),服务端用密钥验签即可,无需查询 session 存储。
好处:
- 无状态:服务端不存 session,天然支持横向扩容。
- 自包含:token 里带身份标识,验证不依赖额外存储。
- 跨域友好:token 放在请求头,天然适配前后端分离。
边界(同样要清楚):
- JWT 是签名不是加密,payload 可被 base64 解码,所以绝不能放敏感信息,只能放用户 ID 这种标识符。
- 因为无状态,token 在过期前无法强制失效。所以
get_current_user解完 token 后必须再查一次数据库,确认用户当前状态(是否被禁用、是否还存在),否则禁用用户后他的旧 token 还能用到过期。
八、为什么本地开发用 Docker,而不用原生装数据库
用了什么:PostgreSQL、Mailpit(本地邮件捕获)、Adminer(数据库管理界面)都跑在 Docker Compose 里。
为什么这么用:
数据库、邮件服务这类「基础设施」,是开发环境依赖但又不想污染本机的东西。如果原生安装:
- 每台机器都要手动装一遍,版本还可能不一致
- 卸载、升级、清理都麻烦
- 团队成员环境难以对齐
好处:
- 一条命令起环境:
docker compose up -d db mailpit,几秒内数据库和邮件服务就绪。 - 环境一致:所有人用同一个镜像和版本,消除「我机器上能跑」的问题。
- 隔离干净:基础设施都在容器里,不污染本机,随时可删。
- Mailpit 的价值:本地开发时,找回密码等邮件被 Mailpit 拦截,在网页上查看,而不会真的发出去。
九、为什么用 Traefik 而不是 Nginx 做反向代理
用了什么:Traefik v3 作为反向代理,负责 HTTPS 和路由转发。
为什么这么用:
Traefik 是「云原生」时代的反向代理,专为容器环境设计。传统 Nginx 需要手写配置文件指定「哪个域名转发到哪个端口」;Traefik 则通过读取 Docker 容器的 label 自动发现服务。
labels:
- traefik.enable=true
- traefik.http.routers.backend.rule=Host(`your-domain.com`)
好处:
- 自动服务发现:新服务加上 label 就自动接入,不用改代理配置。
- HTTPS 自动化:内置 Let’s Encrypt 集成,一行配置完成证书申请和续期。
- 安全默认值:
exposedbydefault=false,没显式标记的服务默认不暴露到公网。
边界:Traefik 强在容器和微服务场景;如果是有大量定制化 rewrite 规则的复杂静态站点,Nginx 的配置文件反而更直观。选型看场景。
十、为什么前后端契约要自动生成
用了什么:前端 API 客户端不是手写的,而是用 openapi-ts 从后端的 OpenAPI 文档自动生成。
为什么这么用:
传统前后端协作,接口契约靠文档或口头约定,极易出现「后端改了字段、前端不知道」的脱节。这个项目用「代码生成」把契约固化:
Pydantic 模型 → OpenAPI Schema → TypeScript 类型 + 请求函数
好处:
- 类型安全:后端改一个字段,重新生成后,前端调用处会直接类型报错,编译期就暴露问题。
- 零手写成本:前端不用手写任何请求函数和类型定义。
- 单一事实来源:后端模型是唯一真相,前端类型是它的投影,不会出现两套定义打架。
这背后的设计原则:FastAPI 从 Pydantic 模型自动导出 OpenAPI,是这个链路的起点——数据模型只定义一次,文档、校验、前端类型全部派生。
十一、为什么前端要同时用 Tailwind 和 shadcn/ui
用了什么:样式用 Tailwind CSS 4(原子化 CSS),组件用 shadcn/ui。
为什么这么用:
两者解决不同层面的问题:
- Tailwind:解决「样式怎么写」。原子化类名直接写在元素上,不用维护庞大的 CSS 文件,也不用想类名。
- shadcn/ui:解决「组件哪来」。它不是 npm 包,而是把组件源码直接复制进你的项目,可以随意修改。
好处:
- Tailwind:样式内聚、无全局污染、构建时自动剔除未用样式。
- shadcn/ui:组件代码在你手里,可自由定制,不被第三方库锁死;基于 Radix UI,无障碍和交互质量有保证。
- 组合效应:Tailwind 让 shadcn/ui 的组件样式易于微调,两者天然契合。
十二、为什么用 uv 和 bun 做包管理
用了什么:Python 用 uv,JS 用 bun,替代传统的 pip 和 npm。
为什么这么用:
两者都是「下一代」包管理器,核心卖点一致——快。
uv用 Rust 实现,安装依赖比 pip 快 10-100 倍bun用 Zig 实现,安装依赖比 npm 快数倍,且原生支持 monorepo workspace
好处:
- 速度快:依赖安装、环境搭建的耗时大幅缩短。
- 可复现:都有锁文件(
uv.lock、bun.lock),精确锁定版本,保证团队环境一致。 - 原生 workspace:一个项目同时管理前端和后端依赖,monorepo 结构清晰。
总结:这套选型的底层逻辑
把上面的选型串起来,能看到一条清晰的选择主线:
| 选型 | 背后的原则 |
|---|---|
| SQLModel、自动生成客户端 | 单一事实来源——数据模型只定义一次,其他全部派生 |
| 多模型类、权限下沉 SQL | 防御优先——用结构而非逻辑来保证安全 |
| 依赖注入 | 关注点分离——横切逻辑从业务中剥离 |
| Argon2+Bcrypt、JWT | 渐进式与无状态——迁移无感知,服务可扩展 |
| Docker、Traefik | 环境一致 + 自动化——减少人工配置和人为错误 |
| uv、bun、Tailwind、shadcn/ui | 开发效率——把时间花在业务上,而非环境搭建和样板代码 |