副标题:取其精华,去其糟粕,用 FastAPI & Fastify 打造新一代通用后台
核心理念:不是"换个写法",而是"升级思维方式"
likeadmin 是目前国内最流行的 PHP 通用后台框架之一,累计 Star 数万。
它就像一个开了十年的连锁奶茶品牌 ——配方成熟、流程规范、出餐快。但十年老店也有老店的问题。
本教程的做法:
盘点 likeadmin 的 10 大缺点 + 8 大优点 对每条缺点,不只给"改法",更要讲清"为什么这样改 "——背后的架构原则 对每条优点,说明如何保留,并在 FastAPI/Fastify 中发扬光大 最终形成一套与时俱进的架构思维 # 缺点 现象 根因 升级方向 1 按层分目录 改一个功能跳 4 个目录 违反高内聚低耦合 → 按模块分,高内聚 2 PHP 强绑定 换语言全得重写 业务与框架耦合 → 洋葱架构,核心无关框架 3 Logic 层太薄 Logic 只是转发 Model 没有真正的业务层 → Service 承载领域逻辑 4 验证器耦合 Controller 校验写控制器里 关注点未分离 → Schema 独立,输入防腐 5 没有依赖注入 类内部 new,mock 不了 控制反转缺失 → IoC 容器管理依赖 6 全局函数满天飞 common.php 塞几百函数 命名空间污染 → 按职责封装工具类 7 Model 层臃肿 查询+关联+校验上千行 持久化与领域混杂 → Repository 分离持久化 8 异常处理粗糙 错误码混乱,return json 缺少领域异常体系 → 异常码+全局捕获 9 多租户硬编码 业务代码里 if/else 判断租户 横切关注点污染业务 → 基础设施层透明处理 10 代码生成器质量低 生成后需大改 约定不够,模板太糙 → 约定优于配置
# 优点 为什么好 如何在 FastAPI/Fastify 中发扬 1 统一响应格式 前端对接零成本 Pydantic 泛型响应 2 中间件管道 登录→权限→日志流水线 Depends 链式依赖 3 Base 基类模式 公共能力继承 Python Mixin / 继承 4 配置分离 .env + config 物理隔离 pydantic-settings + env-schema 5 多端入口分离 adminapi / api 安全隔离 Router 分包 6 代码生成器思路 减少 CRUD 重复 生成即能跑的模块 7 前后端分离 独立开发部署 纯 API + Swagger 8 RBAC 权限模型 用户→角色→权限 保留 + 补数据权限
对标缺点 #1:按层分目录,改一个功能跳 4 个目录
架构原则:高内聚低耦合
按层分 = 按工种分区坐(likeadmin 的做法)
收银台区: 珍珠奶茶收银员、柠檬茶收银员、奶盖茶收银员
制茶区: 珍珠奶茶制茶师、柠檬茶制茶师、奶盖茶制茶师
原料区: 珍珠奶茶原料员、柠檬茶原料员、奶盖茶原料员
来一杯珍珠奶茶 → 收银台接单 → 喊给制茶区 → 制茶师跑去原料区。三个区来回跑,做个奶茶像跑接力赛。
按模块分 = 按品类分组坐(改进方案)
珍珠奶茶组: 收银、制茶、管料三人背靠背
柠檬茶组: 收银、制茶、管料三人背靠背
来一杯珍珠奶茶 → 丢给珍珠奶茶组 → 组内秒级协作,零跑动。
按层分的本质问题是违反高内聚低耦合 :
高内聚 :相关的东西放一起。珍珠奶茶的收银、制茶、管料高度相关,应该放一起。低耦合 :不相关的东西分开。珍珠奶茶和柠檬茶互不相关,不要混一起。按层分恰恰反过来了——把相关的拆散了(珍珠奶茶的收银和制茶分开放),把不相关的聚在一起(所有品类收银员坐一排)。
这导致的后果不只是"找文件麻烦":
改一个功能要改多个文件 → 改用户注册要跳 controller/ + logic/ + model/ + validate/,容易漏改删一个模块要删多个文件 → 删用户模块要在 4 个目录各删一个,漏删就报错无法独立部署 → 用户模块和订单模块代码混在一起,没法把用户模块拆成微服务新人理解困难 → "用户注册涉及哪些文件?"没人能一口答上来
server/
├── controller/ # 所有 Controller 堆一起
│ ├── UserController.php
│ ├── OrderController.php
│ └── GoodsController.php
├── logic/ # 所有 Logic 堆一起
│ ├── UserLogic.php
│ ├── OrderLogic.php
│ └── GoodsLogic.php
├── model/ # 所有 Model 堆一起
│ └── ...
└── validate/ # 所有 Validate 堆一起
└── ...
server/
├── modules/ # 业务模块——每个模块高内聚
│ ├── user/ # 用户模块
│ │ ├── controller.py # 接收请求
│ │ ├── service.py # 业务逻辑
│ │ ├── repository.py # 数据访问
│ │ ├── model.py # 数据模型
│ │ ├── schema.py # 请求/响应结构
│ │ └── router.py # 路由注册
│ ├── order/ # 订单模块
│ │ └── ...(同上结构)
│ └── goods/ # 商品模块
│ └── ...
├── common/ # 跨模块共享——低耦合的桥梁
│ ├── response.py # 统一响应
│ ├── exceptions.py # 统一异常
│ ├── middleware/ # 公共中间件
│ └── utils/ # 按职责分组的工具类
│ ├── string_utils.py
│ ├── crypto_utils.py
│ └── date_utils.py
├── core/ # 框架核心——与业务无关的基础设施
│ ├── config.py # 配置管理
│ ├── database.py # 数据库连接
│ ├── security.py # 安全相关
│ └── dependencies.py # 公共依赖
└── main.py # 应用入口
为什么加 common/ 和 core/?
模块间确实有共享的东西(统一响应、异常码、数据库连接),这些放 common/ 和 core/。原则是:
模块内高内聚 → modules/ 里每个模块自包含模块间低耦合 → 模块之间只通过 common/ 的接口交互,不直接引用彼此的内部代码基础设施与业务分离 → core/ 不含任何业务逻辑操作 按层分(旧) 按模块分(新) 改用户注册 跳 4 个目录 进 modules/user/ 删用户模块 4 个目录各删 1 个文件 删 modules/user/ 文件夹 新人找代码 "用户注册在哪?"答不上来 modules/user/service.py拆微服务 拆不动,代码耦合 拎出 modules/user/ 即可 加新模块 在 4 个目录各加文件 复制一个模块目录改改
目录结构不是给机器看的,是给人看的。
按层分 = 图书馆按"精装/平装/线装"分类 → 找一本书跑三个区
按模块分 = 图书馆按"文学/科学/历史"分类 → 找一本书直奔一个区
本质区别:按层分是"以技术视角组织",按模块分是"以业务视角组织"。
代码是给业务服务的,当然应该按业务组织。
对标缺点 #3(Logic 太薄)+ 缺点 #7(Model 臃肿)
架构原则:单一职责 + 关注点分离
likeadmin 的三层架构像一家管理混乱的奶茶店 :
收银员(Controller): 接单 + 帮忙校验订单 + 偶尔帮制茶师调配方
制茶师(Logic): 大多数时候只喊"仓库拿红茶",偶尔调配方
仓库+配方+标准(Model):又管仓库、又定配方、又管标准——一人干三份活
Logic 太薄 :很多 Logic 只做 $this->model->find($id) 然后返回,形同虚设。收银员都能干这活。
Model 太臃肿 :一个 Model 类塞了查询方法、关联定义、数据校验、修改器……上千行,改查询逻辑可能不小心改坏了关联定义。
likeadmin 的 Controller → Logic → Model 三层架构来自 MVC 思想,但 MVC 是 1970 年代为图形界面设计的,搬到后端 API 有天生的不匹配:
没有独立的"数据访问层" → Model 既定义表结构,又写查询,又管关联,职责过重没有独立的"输入输出定义层" → 校验逻辑散落在 Controller 或 Validate 里,和业务模型耦合Logic 的定位模糊 → 有人说它是"业务逻辑",有人说它是"Controller 和 Model 之间的桥梁",实际上经常只是转发升级为五层,每层职责清晰、不可替代:
┌──────────────────────────────────────────────────┐
│ Controller 层 │
│ 接收请求、调度服务、返回响应 │
│ (不写任何业务逻辑和数据访问) │
├──────────────────────────────────────────────────┤
│ Schema 层 │
│ 定义输入输出的形状、验证规则 │
│ (脏数据在这里被拦截,进不了业务层) │
├──────────────────────────────────────────────────┤
│ Service 层 │
│ 业务规则、流程编排、事务管理 │
│ (整个框架的核心大脑) │
├──────────────────────────────────────────────────┤
│ Repository 层 │
│ 数据存取、查询封装、分页 │
│ (只跟数据库打交道,不碰业务逻辑) │
├──────────────────────────────────────────────────┤
│ Model 层 │
│ 表结构定义、字段约束 │
│ (纯粹的数据结构,零逻辑) │
└──────────────────────────────────────────────────┘
只做三件事:① 接参数 ② 调 Service ③ 返回响应。薄得像纸。
# modules/user/controller.py
from fastapi import APIRouter , Depends , Query
from . schema import CreateUserRequest , UpdateUserRequest , UserResponse
from . service import UserService
from common . response import ApiResponse , PageData
from core . dependencies import PermissionChecker
router = APIRouter ( prefix = " /users " , tags =[ " 用户管理 " ])
@ router . get ( " / " , response_model = ApiResponse [ PageData [ UserResponse ]],
dependencies =[ Depends ( PermissionChecker ( " user:list " ))])
async def list_users (
page : int = Query ( 1 , ge = 1 ),
size : int = Query ( 10 , ge = 1 , le = 100 ),
keyword : str = Query (None),
service : UserService = Depends ()
):
items , total = await service . list_users ( page , size , keyword )
return ApiResponse . success ( PageData ( items = items , total = total , page = page , size = size ))
@ router . post ( " / " , response_model = ApiResponse [ UserResponse ],
dependencies =[ Depends ( PermissionChecker ( " user:create " ))])
async def create_user ( data : CreateUserRequest , service : UserService = Depends ()):
user = await service . create_user ( data )
return ApiResponse . success ( user , " 创建成功 " )
@ router . get ( " / {user_id} " , response_model = ApiResponse [ UserResponse ])
async def get_user ( user_id : int , service : UserService = Depends ()):
return ApiResponse . success ( await service . get_user ( user_id ))
@ router . put ( " / {user_id} " , response_model = ApiResponse [ UserResponse ],
dependencies =[ Depends ( PermissionChecker ( " user:update " ))])
async def update_user ( user_id : int , data : UpdateUserRequest , service : UserService = Depends ()):
return ApiResponse . success ( await service . update_user ( user_id , data ), " 更新成功 " )
@ router . delete ( " / {user_id} " , response_model = ApiResponse ,
dependencies =[ Depends ( PermissionChecker ( " user:delete " ))])
async def delete_user ( user_id : int , service : UserService = Depends ()):
await service . delete_user ( user_id )
return ApiResponse . success ( msg = " 删除成功 " )
// modules/user/controller.js —— Fastify 版本
module.exports = async function ( fastify , opts ) {
fastify . get ( ' /users ' , {
preHandler : [ fastify . auth , fastify . requirePermission ( ' user:list ' )] ,
schema : { response : { 200 : pagedUserResponseSchema } }
}, async ( request , reply ) => {
const { page = 1 , size = 10 , keyword } = request . query
const { items , total } = await fastify . userService . listUsers ( page , size , keyword )
return ApiResponse . success ( ApiResponse . pageData ( items , total , page , size ))
} )
fastify . post ( ' /users ' , {
preHandler : [ fastify . auth , fastify . requirePermission ( ' user:create ' )] ,
schema : { body : createUserSchema , response : { 200 : userResponseSchema } }
}, async ( request , reply ) => {
const user = await fastify . userService . createUser ( request . body )
return ApiResponse . success ( user , ' 创建成功 ' )
} )
}
注意 Controller 里没有一行业务逻辑,没有一行 SQL,没有一个 if/else 判断业务规则。
定义输入输出的形状。声明即验证,脏数据止于此。
# modules/user/schema.py
from pydantic import BaseModel , Field , EmailStr , field_validator
from datetime import datetime
import re
class CreateUserRequest ( BaseModel ):
""" 创建用户请求 —— 声明即验证 """
username : str = Field ( ... , min_length = 2 , max_length = 50 , description = " 用户名 " )
phone : str = Field ( ... , pattern = r ' ^1 [ 3-9 ] \d {9} $ ' , description = " 手机号 " )
password : str = Field ( ... , min_length = 6 , max_length = 20 , description = " 密码 " )
email : EmailStr | None = Field (None, description = " 邮箱 " )
@ field_validator ( ' password ' )
@ classmethod
def password_strength ( cls , v : str ) -> str :
if not re . search ( r '[ A-Za-z ]' , v ) or not re . search ( r '[ 0-9 ]' , v ):
raise ValueError ( ' 密码必须包含字母和数字 ' )
return v
class UpdateUserRequest ( BaseModel ):
""" 更新用户请求 —— 所有字段可选 """
username : str | None = Field (None, min_length = 2 , max_length = 50 )
email : EmailStr | None = None
avatar : str | None = None
class UserResponse ( BaseModel ):
""" 用户信息响应 —— 返回给前端的数据结构 """
id : int
username : str
phone : str
email : str | None
avatar : str | None
is_active : bool
created_at : datetime
model_config = { " from_attributes " : True} # 自动从 ORM 对象转换
class UserListResponse ( BaseModel ):
""" 用户列表响应 """
items : list [ UserResponse ]
total : int
page : int
size : int
为什么 Schema 层是独立的一层,而不是 Controller 的一部分?
复用 :同一个 Schema 可以给 REST API 和 GraphQL 共用自动文档 :FastAPI 根据 Schema 自动生成 Swagger 文档类型安全 :IDE 知道 data.phone 是 str,自动补全关注点分离 :Controller 只管"接什么参数",Schema 管"参数长什么样"业务逻辑的真正归属地。这是整个框架最重要的层。
# modules/user/service.py
from . repository import UserRepository
from common . exceptions import AppException , ErrorCode
from common . utils . crypto_utils import CryptoUtils
class UserService :
""" 用户服务 —— 所有用户相关的业务规则在这里 """
def __init__ ( self , repo : UserRepository = Depends ()):
self . repo = repo
async def create_user ( self , data ) -> " User " :
"""
创建用户的完整业务流程:
1. 检查手机号唯一性(业务规则)
2. 密码加密(业务规则)
3. 保存到数据库(调用仓库层)
4. 发送欢迎短信(业务编排)
"""
# 业务规则1:手机号唯一
if await self . repo . find_by_phone ( data . phone ):
raise AppException ( ErrorCode . USER_PHONE_EXISTS , " 该手机号已注册 " )
# 业务规则2:用户名唯一
if await self . repo . find_by_username ( data . username ):
raise AppException ( ErrorCode . USERNAME_EXISTS , " 用户名已被占用 " )
# 业务规则3:密码加密存储
hashed_pwd = CryptoUtils . hash_password ( data . password )
# 调用仓库层保存(Service 不写 SQL)
user = await self . repo . create (
username = data . username ,
phone = data . phone ,
password = hashed_pwd ,
email = data . email ,
)
# 业务编排:创建成功后发欢迎短信(异步,不阻塞响应)
# await sms_service.send_welcome(user.phone)
return user
async def get_user ( self , user_id : int ) -> " User " :
user = await self . repo . find_by_id ( user_id )
if not user :
raise AppException ( ErrorCode . USER_NOT_FOUND , " 用户不存在 " , 404 )
return user
async def update_user ( self , user_id : int , data ) -> " User " :
user = await self . get_user ( user_id ) # 复用上面的逻辑
return await self . repo . update ( user , data )
async def delete_user ( self , user_id : int ):
user = await self . get_user ( user_id )
# 业务规则:不能删除自己
# 业务规则:管理员不能被删除
await self . repo . delete ( user )
async def list_users ( self , page : int , size : int , keyword : str = None):
return await self . repo . find_all ( page , size , keyword )
Service 层 vs likeadmin 的 Logic 层:
对比 likeadmin Logic 升级后 Service 定位 "Controller 和 Model 之间的桥梁" "业务逻辑的唯一归属地" 代码量 大多只是转发 Model 真正承载业务规则 可替代性 去掉 Logic,Controller 直接调 Model 也行 去掉 Service,业务逻辑无处安放 测试价值 低(逻辑太少) 高(核心业务都在这)
只做数据存取。封装所有数据库操作,返回 Model 对象。不写业务逻辑。
# modules/user/repository.py
from sqlalchemy import select , func , or_
from . model import User
class UserRepository :
""" 用户仓库 —— 所有用户相关的数据库操作在这里 """
def __init__ ( self , db : AsyncSession = Depends ( get_db )):
self . db = db
async def find_by_id ( self , user_id : int ) -> User | None:
result = await self . db . execute ( select ( User ). where ( User . id == user_id ))
return result . scalar_one_or_none ()
async def find_by_phone ( self , phone : str ) -> User | None:
result = await self . db . execute ( select ( User ). where ( User . phone == phone ))
return result . scalar_one_or_none ()
async def find_by_username ( self , username : str ) -> User | None:
result = await self . db . execute ( select ( User ). where ( User . username == username ))
return result . scalar_one_or_none ()
async def create ( self , ** kwargs ) -> User :
user = User (** kwargs )
self . db . add ( user )
await self . db . commit ()
await self . db . refresh ( user )
return user
async def update ( self , user : User , data ) -> User :
for key , value in data . model_dump ( exclude_unset =True). items ():
setattr ( user , key , value )
await self . db . commit ()
await self . db . refresh ( user )
return user
async def delete ( self , user : User ):
await self . db . delete ( user )
await self . db . commit ()
async def find_all ( self , page : int , size : int , keyword : str = None) -> tuple [ list [ User ], int ]:
query = select ( User )
count_query = select ( func . count ()). select_from ( User )
if keyword :
filter_cond = or_ (
User . username . contains ( keyword ),
User . phone . contains ( keyword ),
)
query = query . where ( filter_cond )
count_query = count_query . where ( filter_cond )
query = query . offset (( page - 1 ) * size ). limit ( size )
items = ( await self . db . execute ( query )). scalars (). all ()
total = ( await self . db . execute ( count_query )). scalar ()
return items , total
为什么要把 Repository 从 Model 中拆出来?
likeadmin 的 Model 又当爹又当妈:
// 一个 Model 上千行
class User extends Model {
protected $ table = ' users ' ; // 表结构定义
public function orders () { ... } // 关联关系
public function scopeActive ($ q ) { .. } // 查询作用域
public function setPasswordAttr ($ v ) { } // 修改器
public function getAvatarAttr ($ v ) { } // 获取器
public static function getByPhone ($ p ) {} // 自定义查询
// ... 还有 30 个方法
问题: 改查询逻辑可能不小心改坏了关联定义,改字段约束可能影响了查询方法。职责混杂,牵一发动全身。
拆出 Repository 后:
Model → 只定义表结构和字段,纯数据,10 行代码Repository → 只做查询和存取,纯操作,换数据库只改这里纯粹的数据结构定义。零逻辑,零查询,零校验。
# modules/user/model.py
from sqlalchemy import Column , Integer , String , DateTime , Boolean
from core . database import Base
class User ( Base ):
__tablename__ = " users "
id = Column ( Integer , primary_key =True, autoincrement =True)
username = Column ( String ( 50 ), unique =True, nullable =False, comment = " 用户名 " )
phone = Column ( String ( 20 ), unique =True, nullable =False, comment = " 手机号 " )
password = Column ( String ( 255 ), nullable =False, comment = " 密码 " )
email = Column ( String ( 100 ), nullable =True, comment = " 邮箱 " )
avatar = Column ( String ( 255 ), nullable =True, comment = " 头像 " )
is_active = Column ( Boolean , default =True, comment = " 是否启用 " )
created_at = Column ( DateTime , server_default = func . now ())
updated_at = Column ( DateTime , onupdate = func . now ())
和 likeadmin 的 Model 对比:
对比 likeadmin Model 升级后 Model 行数 上千行 10-20 行 包含查询 ✅ scopeActive 等 ❌ 查询在 Repository 包含校验 ✅ setXxxAttr 等 ❌ 校验在 Schema 包含关联 ✅ function orders() ❌ 关联在 Repository 职责 表结构 + 查询 + 校验 + 关联 只有表结构
三层 vs 五层,不是"多加两层",而是"每层归位"。
likeadmin 的三层里,Model 承担了太多不该承担的职责——
它既定义数据结构,又做查询,又管关联,又搞校验。
这就像让仓库管理员同时负责:搬货、记账、质检、采购——一人干四份活,哪样都干不好。
五层架构的本质:单一职责原则——每个角色只做一件事,做透它。
Controller = 接待员,只接客 Schema = 安检门,只验票 Service = 大厨,只管配方 Repository = 仓管,只管存取 Model = 规格书,只管定义 对标缺点 #5:没有依赖注入,类内部 new 对象,单测 mock 不了
架构原则:控制反转(IoC)+ 依赖倒置(DIP)
没有依赖注入的店:
class 制茶师 :
def __init__ ( self ):
self . 茶叶 = 福建茶园 () # 写死了!
self . 奶源 = 蒙牛牧场 () # 写死了!
self . 杯子 = 塑料杯厂 () # 写死了!
想换供应商?改代码。想测试"如果供应商断货怎么办"?没法模拟,因为供应商是内部创建的。
有依赖注入的店:
class 制茶师 :
def __init__ ( self , 茶叶 , 奶源 , 杯子 ): # 从外部传进来
self . 茶叶 = 茶叶
self . 奶源 = 奶源
self . 杯子 = 杯子
# 生产时:用真实的供应商
制茶师 ( 福建茶园 (), 蒙牛牧场 (), 塑料杯厂 ())
# 测试时:用假的供应商(不真的进货,不真的花钱)
制茶师 ( 假茶园 (), 假牧场 (), 假杯厂 ())
likeadmin 的代码里到处是这样:
class UserController {
public function create () {
$ logic = new UserLogic (); // 硬编码依赖
$ validate = new UserValidate (); // 硬编码依赖
}
}
这违反了两个核心原则:
控制反转(IoC) :依赖的创建应该由外部控制,而不是类自己创建。类应该"声明我需要什么",而不是"我自己造"。依赖倒置(DIP) :高层模块不应该依赖低层模块,两者都应该依赖抽象。先搞清一个问题:谁控制谁? IoC 回答的核心问题:谁负责创建对象?
正常情况(没有IoC): 我要用车 → 我自己造一辆车 → 我开车
IoC之后: 我要用车 → 我告诉工厂"我要车" → 工厂给我一辆车 → 我开车
"控制"指的就是创建对象的控制权 。"反转"就是这个权从"类自己"转到了"外部" 。
回到奶茶店:
# ❌ 没有 IoC —— 制茶师自己控制供应商的创建
class 制茶师 :
def __init__ ( self ):
self . 茶叶 = 福建茶园 () # "我自己找的!"
# 控制权在制茶师手里
# ✅ 有 IoC —— 制茶师不控制,外部传进来
class 制茶师 :
def __init__ ( self , 茶叶 ): # "你给我什么,我用什么"
self . 茶叶 = 茶叶
# 控制权在外部调用者手里
就这一步变化,就叫"控制反转"。 记住这一句就够了:
IoC = 不要自己在构造函数里 new,让别人传进来。
DIP 比 IoC 多走一步:不仅"谁创建"要改,"依赖什么类型"也要改 DIP 回答的核心问题:我依赖的是具体实现,还是抽象接口?
再拿奶茶店举例:
# ❌ 没有 DIP —— 依赖具体品牌
class 制茶师 :
def __init__ ( self , 茶叶 : 福建茶园 ): # 只能用福建茶园!
self . 茶叶 = 茶叶
# 现在想换云南茶园?改类型声明,改代码。
# ✅ 有 DIP —— 依赖抽象(接口)
class 制茶师 :
def __init__ ( self , 茶叶 : 茶叶供应商 ): # 只要是"茶叶供应商"就行!
self . 茶叶 = 茶叶
# 福建茶园、云南茶园、假茶园,只要实现了"茶叶供应商"接口,都能传进来
DIP 的核心:我不管你是谁,只要你满足我需要的"能力"(接口/协议),我就能用你。
一张图看清区别
没有 IoC,没有 DIP:
制茶师 ──new──→ 福建茶园(具体类)
硬编码,改不了,测不了
有 IoC,没有 DIP:
制茶师 ←──传─── 福建茶园(具体类)
能从外部传了,但类型写死了,只能传福建茶园
有 IoC + DIP:
制茶师 ←──传─── 任何实现了"茶叶供应商"接口的类
能从外部传,类型也灵活了,想传谁传谁
为什么非要这么搞?三个真实场景 场景1:写单测
# ❌ 没有IoC:UserController 内部 new UserService,UserService 内部 new UserRepository
# 你想测 Controller 的逻辑,但它会真的连数据库——测试又慢又不稳定
# ✅ 有IoC+DIP:传入 FakeUserRepository(假仓库,不连数据库)
service = UserService ( repo = FakeUserRepository ()) # 秒完,不碰数据库
场景2:换数据库
# ❌ 没有IoC:到处 new MySQLRepository(),换 PostgreSQL 要改50个文件
# ✅ 有IoC+DIP:只改一处注册
# 原来:app.dependency_overrides[UserRepository] = MySQLUserRepository
# 现在:app.dependency_overrides[UserRepository] = PGUserRepository
# 业务代码一行不改
场景3:看代码就知道依赖了什么
# ❌ 没有IoC:看函数签名猜不到依赖
def create_user ( data ):
logic = new UserLogic () # 藏在方法内部,读代码才能发现
...
# ✅ 有IoC:依赖一目了然
def create_user ( data , service : UserService = Depends ()): # 签名就告诉你:我需要 UserService
...
一句话总结 IoC = "我不自己造,别人给我" —— 解决创建权的问题
DIP = "我不认具体牌子,我只认规格" —— 解决依赖类型的问题
两个加起来 = 你的代码能测、能换、能看清。
IoC 是手段,DIP 是目标,依赖注入(DI)是实现它们的具体方式。
没有依赖注入的三大痛点:
痛点 场景 不可测试 想测试 UserService,但它内部 new 了 UserRepository,测试时会真的连数据库 不可替换 想把 MySQL 换成 PostgreSQL,要改所有 new UserRepository() 的地方 依赖隐藏 看 UserController 的代码,不知道它依赖了 UserLogic,必须看方法内部
# 步骤1:定义依赖(我需要什么)
async def get_db ():
""" 数据库会话——请求进来时创建,请求结束时关闭 """
async with AsyncSessionLocal () as session :
yield session
class UserRepository :
""" 声明:我需要一个数据库会话 """
def __init__ ( self , db : AsyncSession = Depends ( get_db )):
self . db = db
class UserService :
""" 声明:我需要一个仓库 """
def __init__ ( self , repo : UserRepository = Depends ()):
self . repo = repo
# 步骤2:在路由中使用(框架自动注入整条依赖链)
@ router . post ( " /users " )
async def create_user (
data : CreateUserRequest ,
service : UserService = Depends (), # ← 自动创建并注入
):
return ApiResponse . success ( await service . create_user ( data ))
FastAPI 自动做了什么?
1. 看到 Depends(UserService)
2. 发现 UserService 需要 UserRepository
3. 发现 UserRepository 需要 AsyncSession
4. 自动按顺序创建:
AsyncSession → UserRepository → UserService
5. 注入到路由函数
6. 请求结束后自动关闭 AsyncSession
你一行 new 都不用写。整个依赖链全自动。
测试时:
# 单元测试——不需要数据库
async def test_create_user ():
fake_repo = FakeUserRepository () # 假仓库,不连数据库
service = UserService ( repo = fake_repo ) # 手动注入假仓库
user = await service . create_user ( ... )
assert user . username == " 张三 "
// 插件系统——注册依赖到 fastify 实例
const fp = require ( ' fastify-plugin ' )
async function dbPlugin ( fastify , opts ) {
const db = await createDB ( opts . database )
fastify . decorate ( ' db ' , db )
}
async function userPlugin ( fastify , opts ) {
const repo = new UserRepository ( fastify . db )
const service = new UserService ( repo )
fastify . decorate ( ' userRepository ' , repo )
fastify . decorate ( ' userService ' , service )
}
// 注册
fastify . register ( fp (dbPlugin) , { database : config . db } )
fastify . register ( fp (userPlugin))
// 路由中使用
fastify . get ( ' /users/me ' , async ( request , reply ) => {
return await fastify . userService . getUser ( request . currentUser . id )
} )
// 测试时:注册假的插件
fastify . register ( fp ( async ( f ) => {
f . decorate ( ' userRepository ' , new FakeUserRepo ())
f . decorate ( ' userService ' , new UserService ( f . userRepository ))
} ))
依赖注入 = "别自己造零件,告诉工厂你需要什么,工厂给你装好。"
三个关键词:
声明 → 我不 new,我声明"我需要什么"自动 → 框架帮我创建并注入,依赖链全自动解析可换 → 测试时换假的,生产时换真的,只改配置不改代码这就是控制反转——控制权从"我自己造"反转为"别人给我"。
对标缺点 #4:验证器耦合在 Controller,校验逻辑散落
架构原则:关注点分离 + 防腐层
likeadmin 的做法: 收银员一边接单一边校验
收银员:您好,点什么?
顾客:我要一杯 -3°C 的奶茶
收银员:等等,温度不能是负数...让我翻手册查规则...
手册第 37 页写着"温度范围 0-100"...好的,不行。
校验逻辑和接待逻辑混在一起,收银员脑子要记两套东西。
改进做法: 门口放个安检门
顾客进门 → 安检门自动扫描 → 温度-3°C?嘟!禁止入内!
收银员:你好,点什么?(只管接单,不管校验)
class UserValidate extends Validate {
protected $ rule = [
' username ' => ' require|max:50 ' ,
' phone ' => ' require|regex:^1[3-9]\d{9}$|unique:user ' ,
' password ' => ' require|min:6|max:20 ' ,
];
protected $ message = [
' username.require ' => ' 用户名必填 ' ,
' phone.regex ' => ' 手机号格式不正确 ' ,
];
}
问题:
规则是字符串 → regex:^1[3-9]\d{9}$ 藏在字符串里,IDE 没有提示,容易写错和 Controller 耦合 → 校验逻辑写在 Controller 里 $validate->check(),换一个入口(如 CLI 命令)就得重写和 Model 脱节 → 改了 Model 字段容易忘记同步改验证核心理念:类型定义本身就是验证规则。不需要额外写校验代码。
# modules/user/schema.py
from pydantic import BaseModel , Field , EmailStr , field_validator
import re
class CreateUserRequest ( BaseModel ):
""" 声明即验证——每个字段的类型就是它的验证规则 """
username : str = Field (
... , # ... = 必填
min_length = 2 , # 最短 2 字符
max_length = 50 , # 最长 50 字符
description = " 用户名 "
)
phone : str = Field (
... ,
pattern = r ' ^1 [ 3-9 ] \d {9} $ ' , # 正则校验
description = " 手机号 "
)
password : str = Field (
... ,
min_length = 6 ,
max_length = 20 ,
description = " 密码 "
)
email : EmailStr | None = Field (None, description = " 邮箱 " ) # EmailStr 自动验证邮箱格式
age : int = Field ( ge = 0 , le = 150 , description = " 年龄 " ) # ge=大于等于, le=小于等于
# 复杂业务校验用 field_validator
@ field_validator ( ' password ' )
@ classmethod
def password_must_have_letter_and_digit ( cls , v : str ) -> str :
if not re . search ( r '[ A-Za-z ]' , v ) or not re . search ( r '[ 0-9 ]' , v ):
raise ValueError ( ' 密码必须同时包含字母和数字 ' )
return v
到达 Controller 时,数据已经是验证过的了:
@ router . post ( " /users " )
async def create_user ( data : CreateUserRequest ):
# data.phone 一定是合法手机号
# data.age 一定在 0-150 之间
# data.email 一定是合法邮箱(如果有的话)
# 不需要写一行校验代码!
return await user_service . create_user ( data )
数据不合法时,FastAPI 自动返回:
{
" detail " : [
{ " loc " : [ " body " , " phone " ], " msg " : " String should match pattern '^1[3-9] \\ d{9}$' " },
{ " loc " : [ " body " , " age " ], " msg " : " Input should be less than or equal to 150 " }
]
}
const createUserSchema = {
type : ' object ' ,
required : [ ' username ' , ' phone ' , ' password ' ] ,
properties : {
username : { type : ' string ' , minLength : 2 , maxLength : 50 },
phone : { type : ' string ' , pattern : ' ^1[3-9] \\ d{9}$ ' },
password : { type : ' string ' , minLength : 6 , maxLength : 20 },
email : { type : ' string ' , format : ' email ' },
age : { type : ' integer ' , minimum : 0 , maximum : 150 }
}
}
fastify . post ( ' /users ' , { schema : { body : createUserSchema } }, async ( req ) => {
// req.body 已验证
return await fastify . userService . createUser ( req . body )
} )
维度 likeadmin Validate Pydantic Schema / JSON Schema 写法 字符串规则 类型定义 IDE 支持 无(字符串无提示) 完整(类型自动补全) 自动文档 无 Swagger 自动生成 复用性 只能在 Controller 用 任何地方都能用 和 Model 的关系 容易忘记同步 可以共用字段定义
验证层 = 门口的安检门。脏数据在门口被拦截,根本进不了业务层。
Pydantic/JSON Schema 的本质是"声明式"——你只说"我想要什么",不用说"怎么检查"。
声明 phone: str = Field(pattern=r'^1[3-9]\d{9}$') 的那一刻:
验证规则生效了 Swagger 文档生成了 IDE 类型提示有了 三件事,一行代码 对标缺点 #8:异常处理粗糙,错误码不统一
保留发扬:优点 #1(统一响应格式 {code, msg, data})
不管成功还是失败,店员回复格式必须统一:
✅ "好的,珍珠奶茶已下单,订单号 #042,等 5 分钟"
❌ "抱歉,芒果今天卖完了(错误码:3001)"
而不是:
"042,奶茶,5分钟" ← 信息不全
"没了" ← 什么没了?
likeadmin 保留了统一响应格式 {code, msg, data}(这是好的),但异常处理粗糙:
// 到处直接 return,没有异常体系
return json ([ ' code ' => 0 , ' msg ' => ' 用户不存在 ' ]);
return json ([ ' code ' => - 1 , ' msg ' => ' 参数错误 ' ]);
return json ([ ' code ' => 400 , ' msg ' => ' 没有权限 ' ]);
问题: 错误码不统一(0、-1、400 混用),前端只能靠 msg 字符串匹配,无法精准处理不同错误。
核心思想:业务代码只管 raise,不管怎么返回给前端。全局异常处理器统一兜底。
# common/response.py
from typing import Generic , TypeVar
from pydantic import BaseModel
T = TypeVar ( " T " )
class ApiResponse ( BaseModel , Generic [ T ]):
""" 统一响应格式——前端只认这一种 """
code : int = 1
msg : str = " 操作成功 "
data : T | None = None
@ classmethod
def success ( cls , data : T = None, msg : str = " 操作成功 " ):
return cls ( code = 1 , msg = msg , data = data )
@ classmethod
def fail ( cls , msg : str = " 操作失败 " , code : int = 0 ):
return cls ( code = code , msg = msg , data =None)
class PageData ( BaseModel , Generic [ T ]):
""" 分页数据 """
items : list [ T ]
total : int
page : int
size : int
# common/exceptions.py
from enum import IntEnum
class ErrorCode ( IntEnum ):
"""
统一错误码——每个错误有唯一编号,按模块分段
前端拿到 code 就知道具体什么错误,不用匹配字符串
"""
# 通用错误 1000-1999
SUCCESS = 0
UNKNOWN = 1000
PARAM_ERROR = 1001
NOT_FOUND = 1002
# 用户模块 2000-2999
USER_NOT_FOUND = 2000
USER_PHONE_EXISTS = 2001
USER_PASSWORD_ERROR = 2002
USER_DISABLED = 2003
USERNAME_EXISTS = 2004
# 订单模块 3000-3999
ORDER_NOT_FOUND = 3000
ORDER_STOCK_INSUFFICIENT = 3001
ORDER_STATUS_ERROR = 3002
# 权限模块 4000-4999
UNAUTHORIZED = 4000
FORBIDDEN = 4001
TOKEN_EXPIRED = 4002
class AppException ( Exception ):
""" 应用异常——业务代码只管 raise,不管怎么返回 """
def __init__ ( self , code : ErrorCode , msg : str = None, http_status : int = 400 ):
self . code = code
self . msg = msg or code . name
self . http_status = http_status
# 常用异常快捷方式
class NotFoundException ( AppException ):
def __init__ ( self , msg : str = " 资源不存在 " ):
super (). __init__ ( ErrorCode . NOT_FOUND , msg , 404 )
class UnauthorizedException ( AppException ):
def __init__ ( self , msg : str = " 未登录 " ):
super (). __init__ ( ErrorCode . UNAUTHORIZED , msg , 401 )
class ForbiddenException ( AppException ):
def __init__ ( self , msg : str = " 无权限 " ):
super (). __init__ ( ErrorCode . FORBIDDEN , msg , 403 )
# common/exception_handler.py
from fastapi import Request
from fastapi . responses import JSONResponse
async def app_exception_handler ( request : Request , exc : AppException ):
""" 统一处理业务异常 """
return JSONResponse (
status_code = exc . http_status ,
content ={ " code " : exc . code , " msg " : exc . msg , " data " : None}
)
async def validation_exception_handler ( request : Request , exc : ValidationError ):
""" 统一处理 Pydantic 验证异常 """
errors = exc . errors ()
first_error = errors [ 0 ][ " msg " ] if errors else " 参数错误 "
return JSONResponse (
status_code = 422 ,
content ={ " code " : 1001 , " msg " : f "参数校验失败: { first_error } " , " data " : None}
)
async def global_exception_handler ( request : Request , exc : Exception ):
""" 兜底——处理所有未预期的异常 """
logger . error ( f "未预期错误: { exc } " , exc_info =True)
return JSONResponse (
status_code = 500 ,
content ={ " code " : 1000 , " msg " : " 服务器内部错误 " , " data " : None}
)
# main.py 注册
app . add_exception_handler ( AppException , app_exception_handler )
app . add_exception_handler ( ValidationError , validation_exception_handler )
app . add_exception_handler ( Exception , global_exception_handler )
# Service 中只管 raise——干净利落
async def login ( self , phone : str , password : str ):
user = await self . repo . find_by_phone ( phone )
if not user :
raise AppException ( ErrorCode . USER_NOT_FOUND , " 用户不存在 " )
if not CryptoUtils . verify_password ( password , user . password ):
raise AppException ( ErrorCode . USER_PASSWORD_ERROR , " 密码错误 " )
if not user . is_active :
raise AppException ( ErrorCode . USER_DISABLED , " 账号已停用 " )
# ... 生成 token
前端拿到错误码后可以精准处理:
// 前端全局拦截器
if (res . code !== 1 ) {
switch ( res . code ) {
case 2001 : showToast ( ' 该手机号已注册,请直接登录 ' ) ; break
case 2002 : showToast ( ' 密码错误,还剩 3 次机会 ' ) ; break
case 4002 : redirectTo ( ' /login ' ) ; break // 登录过期,跳转登录页
default : showToast ( res . msg )
}
}
// common/exceptions.js
class AppException extends Error {
constructor ( code , msg , httpStatus = 400 ) {
super ( msg )
this. code = code
this. msg = msg
this. httpStatus = httpStatus
}
}
// app.js 全局错误处理
fastify . setErrorHandler ( async ( error , request , reply ) => {
if ( error instanceof AppException ) {
return reply . status ( error . httpStatus ) . send ( {
code : error . code , msg : error . msg , data : null
} )
}
if ( error . validation ) {
return reply . status ( 422 ) . send ( {
code : 1001 , msg : ` 参数校验失败: ${ error . message }` , data : null
} )
}
request . log . error ( error )
return reply . status ( 500 ) . send ( {
code : 1000 , msg : ' 服务器内部错误 ' , data : null
} )
} )
统一响应 + 异常码 = 前端拿到万能钥匙。
前端只写一次 if (res.code === 1) 后端只管 raise,不管怎么返回 每个错误有唯一 code,前端精准处理不同场景 likeadmin 有统一响应但没有异常码体系,改进后两者结合,前后端都轻松。
保留发扬:优点 #2(中间件管道)+ 优点 #8(RBAC 权限模型)
补充:likeadmin 缺失的数据权限
高端奶茶店的进门流程:
门口 → 测温 → 扫码登记 → 洗手消毒 → 点单台
↓ ↓ ↓
发烧劝退 记录轨迹 卫生保障
每一关只做一件事,不通过就劝退。这就是中间件管道。
但 likeadmin 的中间件是"全局一刀切"——所有请求都过同一套中间件。FastAPI 的 Depends 让它更灵活:可以精确到每个接口 指定需要哪些检查。
likeadmin 的 RBAC 五表设计很经典,保留。但它只有操作权限 (能不能点这个按钮),没有数据权限 (能看到多少数据)。
操作权限: 店员能不能看"订单列表" → 能/不能
数据权限: 店员看"订单列表"时,能看到哪些订单 → 全部/本部门/仅自己
# modules/permission/service.py
from enum import Enum
class DataScope ( Enum ):
ALL = " all " # 超级管理员:看全部
DEPT = " dept " # 部门经理:看本部门
SELF = " self " # 普通员工:仅自己
class PermissionService :
async def check_permission ( self , user_id : int , perm_code : str ) -> bool :
""" 操作权限检查 """
perms = await self . _get_cached_permissions ( user_id )
return perm_code in perms
async def get_data_scope ( self , user_id : int ) -> DataScope :
""" 数据权限范围——likeadmin 缺失的能力 """
roles = await self . repo . get_user_roles ( user_id )
if " admin " in roles : return DataScope . ALL
if " manager " in roles : return DataScope . DEPT
return DataScope . SELF
# core/dependencies.py
from fastapi import Depends
async def get_current_user (
authorization : str = Header ( ... ),
auth_service : AuthService = Depends ()
):
""" 认证依赖——验证登录态 """
user = await auth_service . verify_token ( authorization )
if not user :
raise UnauthorizedException ( " 登录已过期 " )
return user
class PermissionChecker :
""" 权限依赖——声明式,精确到每个接口 """
def __init__ ( self , perm_code : str ):
self . perm_code = perm_code
async def __call__ (
self ,
current_user = Depends ( get_current_user ), # 先认证
perm_service : PermissionService = Depends ()
):
if not await perm_service . check_permission ( current_user . id , self . perm_code ):
raise ForbiddenException ( f "缺少权限: { self . perm_code } " )
return current_user
# 路由中使用——每个接口精确指定需要什么权限
@ router . get ( " /users " , dependencies =[ Depends ( PermissionChecker ( " user:list " ))])
@ router . post ( " /users " , dependencies =[ Depends ( PermissionChecker ( " user:create " ))])
@ router . delete ( " /users/ {id} " , dependencies =[ Depends ( PermissionChecker ( " user:delete " ))])
对比 likeadmin 的做法:
对比 likeadmin FastAPI Depends 粒度 全局中间件或控制器内 if 每个接口声明式指定 可读性 要看代码逻辑才知道要什么权限 PermissionChecker("user:list") 一看就懂依赖链 手动调用 自动解析 自动文档 无 Swagger 自动标注
// 认证中间件 —— 为什么要写成独立函数而不是写在路由里?
// 原因1:复用——几十个路由都需要认证,不用每个都写一遍
// 原因2:可测试——可以单独测试认证逻辑
// 原因3:可替换——换成 OAuth 只需改这一个函数
async function authMiddleware ( request , reply ) {
const token = request . headers . authorization ?. replace ( ' Bearer ' , '' )
if ( ! token ) {
throw new AppException ( ErrorCode . UNAUTHORIZED , ' 未登录 ' , 401 )
}
try {
const decoded = await fastify . jwt . verify ( token ) // 用 @fastify/jwt 验证
request . currentUser = await fastify . userService . getUser ( decoded . userId )
} catch ( err ) {
throw new AppException ( ErrorCode . TOKEN_EXPIRED , ' 登录已过期 ' , 401 )
}
}
// 权限中间件工厂 —— 为什么是"工厂"?
// 因为每个接口需要的权限不同,requirePermission('user:list') 返回一个
// 专门检查 user:list 权限的中间件函数。这比 if/else 灵活得多。
function requirePermission ( code ) {
return async function checkPermission ( request , reply ) {
const userId = request . currentUser . id
const permissions = await fastify . permService . getPermissions ( userId )
if ( ! permissions . includes ( code )) {
throw new AppException ( ErrorCode . FORBIDDEN , ` 缺少权限: ${ code }` , 403 )
}
}
}
// 路由中使用 —— 注意 preHandler 数组的顺序:
// 先认证(拿到用户信息),再检查权限(根据用户信息判断)
// 顺序不能反,否则权限中间件拿不到 currentUser
fastify . get ( ' /users ' , {
preHandler : [authMiddleware , requirePermission ( ' user:list ' )]
}, async ( request , reply ) => {
const { page = 1 , size = 10 , keyword } = request . query
const { items , total } = await fastify . userService . listUsers ( page , size , keyword )
return ApiResponse . success ( ApiResponse . pageData ( items , total , page , size ))
} )
fastify . post ( ' /users ' , {
preHandler : [authMiddleware , requirePermission ( ' user:create ' )] ,
schema : { body : createUserSchema }
}, async ( request , reply ) => {
const user = await fastify . userService . createUser ( request . body )
return ApiResponse . success ( user , ' 创建成功 ' )
} )
fastify . delete ( ' /users/:userId ' , {
preHandler : [authMiddleware , requirePermission ( ' user:delete ' )]
}, async ( request , reply ) => {
await fastify . userService . deleteUser ( request . params . userId )
return ApiResponse . success ( null, ' 删除成功 ' )
} )
上面的操作权限解决了"能不能进这扇门"的问题。但还有另一个问题:用户进了门之后,能看到多少数据?
likeadmin 完全没有数据权限——一个店长能看到所有店的订单。下面是完整的解决方案:
# modules/permission/repository.py
class PermissionRepository :
async def get_user_roles ( self , user_id : int ) -> list [ str ]:
""" 获取用户的角色列表 """
result = await self . db . execute (
select ( Role . code )
. join ( user_roles , Role . id == user_roles . c . role_id )
. where ( user_roles . c . user_id == user_id )
)
return [ row [ 0 ] for row in result . all ()]
# modules/permission/service.py
class PermissionService :
async def get_data_scope ( self , user_id : int ) -> DataScope :
"""
获取数据权限范围。
为什么不直接在 Service/Repository 里写 if/else?
因为数据权限是"横切关注点"——它不是某个业务的逻辑,
而是所有涉及数据查询的地方都需要的能力。
把它抽成独立服务,任何 Repository 都能调用,不用重复写。
"""
roles = await self . repo . get_user_roles ( user_id )
if " admin " in roles :
return DataScope . ALL # 管理员:看全部数据
if " manager " in roles :
return DataScope . DEPT # 部门经理:看本部门数据
return DataScope . SELF # 普通员工:只看自己的数据
# modules/order/repository.py —— 数据权限在这里自动过滤
class OrderRepository :
async def find_all ( self , user_id : int , page : int , size : int ):
"""
为什么把数据权限过滤放在 Repository 而不是 Service?
因为 Repository 是"唯一直接操作数据库的地方"。
如果在 Service 里过滤,万一有人绕过 Service 直接调 Repository,
数据权限就形同虚设。放在 Repository 里,从根源保证安全。
"""
query = select ( Order )
# 根据数据权限自动添加过滤条件
scope = await self . perm_service . get_data_scope ( user_id )
if scope == DataScope . SELF :
# 只看自己创建的订单
query = query . where ( Order . creator_id == user_id )
elif scope == DataScope . DEPT :
# 只看本部门的订单
dept_id = await self . perm_service . get_user_dept_id ( user_id )
query = query . where ( Order . dept_id == dept_id )
# DataScope.ALL → 不加过滤,看全部
# 无论如何,查询的后续逻辑(分页、排序)不受影响
count_query = select ( func . count ()). select_from ( query . subquery ())
total = ( await self . db . execute ( count_query )). scalar ()
items = ( await self . db . execute (
query . offset (( page - 1 ) * size ). limit ( size )
)). scalars (). all ()
return items , total
为什么这样设计?三重保障:
Repository 层过滤 → 从根源保证安全,即使 Service 忘了检查也不怕PermissionService 独立 → 数据权限逻辑不散落在各处,改规则只改一个地方DataScope 枚举 → 新增权限级别只需加枚举值,不改业务代码中间件 = 安检传送带上的检查站。
操作权限管"能不能进这扇门",数据权限管"进门后能看到多少东西"。
likeadmin 只有前者,我们补上后者——这才是一个完整的权限体系。
数据权限放在 Repository 而不是 Service,原因和门锁装在门上而不是挂在脖子上一样——
安全措施必须放在最靠近数据的地方,而不是靠人记住规则。
保留发扬:优点 #4(配置分离)
对标缺点 #2(PHP 强绑定)→ 配置方案与框架解耦
配方(代码):珍珠奶茶 = 红茶底 + 奶精 + 珍珠
→ 全国统一,写死在流程里
运营参数(配置):糖度 70%、珍珠 50g/份、营业时间 9-22 点
→ 每家店不同,放在店长手册里随时调
把糖度写死在代码里,老板想调成 60% 就得改代码重新部署——太蠢了。
likeadmin 的 .env + config/ 分离思路很好。我们保留并用 pydantic-settings 增强类型安全 :
# core/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings ( BaseSettings ):
"""
应用配置——自动从 .env 读取,有类型校验。
为什么用 pydantic-settings 而不是直接 os.getenv()?
1. 类型安全:DB_PORT 一定是 int,不会出现 "3306" 字符串当数字用
2. 启动校验:必填项缺失时,应用启动就报错,而不是运行到一半才崩
3. IDE 友好:settings.DB_PORT 有自动补全,os.getenv("DB_PORT") 没有
4. 默认值集中:所有默认值在 Settings 类里一目了然
"""
# 应用
APP_NAME : str = " 通用后台 "
APP_VERSION : str = " 1.0.0 "
DEBUG : bool = False
# 数据库
DB_HOST : str = " localhost "
DB_PORT : int = 3306 # pydantic 自动把 "3306" 转成 int
DB_USER : str = " root "
DB_PASSWORD : str = "" # 敏感信息从 .env 读取,不写死在代码里
DB_NAME : str = " admin "
# Redis
REDIS_URL : str = " redis://localhost:6379/0 "
# JWT
JWT_SECRET : str = " change-me-in-production " # 生产环境必须通过 .env 覆盖!
JWT_ALGORITHM : str = " HS256 "
JWT_EXPIRE_MINUTES : int = 1440 # 24 小时
# 上传
UPLOAD_DIR : str = " ./uploads "
MAX_UPLOAD_SIZE : int = 10 * 1024 * 1024 # 10MB
# 为什么用 @property 而不是直接写 DB_URL 字段?
# 因为 database_url 是从其他字段"拼出来"的,不是独立的配置项。
# 如果写成字段,用户需要在 .env 里同时配置 DB_HOST 和 DB_URL,
# 容易出现不一致。用 @property 保证永远一致。
@ property
def database_url ( self ) -> str :
return f "mysql+aiomysql:// { self . DB_USER } : { self . DB_PASSWORD } @ { self . DB_HOST } : { self . DB_PORT } / { self . DB_NAME } "
model_config = { " env_file " : " .env " , " case_sensitive " : True}
# 为什么用 @lru_cache()?
# 因为 Settings() 每次调用都会读 .env 文件、解析类型、校验数据。
# 整个应用只需要加载一次,后续调用直接返回缓存的结果。
# 这比"全局变量"优雅,因为不会在模块导入时就执行(避免循环依赖)。
@ lru_cache ()
def get_settings () -> Settings :
return Settings ()
配套的 .env 文件(每个环境一份,不提交 Git):
# .env —— 开发环境配置(不提交 Git!)
APP_NAME = 通用后台-开发环境
DEBUG = true
DB_HOST = localhost
DB_PORT = 3306
DB_USER = root
DB_PASSWORD = dev_password_123
DB_NAME = admin_dev
REDIS_URL = redis://localhost:6379/0
JWT_SECRET = dev-secret-key-do-not-use-in-production
JWT_EXPIRE_MINUTES = 10080 # 开发环境7天,方便调试
UPLOAD_DIR = ./uploads
MAX_UPLOAD_SIZE = 10485760
# .env.example —— 配置模板(提交 Git,告诉别人需要哪些配置)
# 复制此文件为 .env,然后填写实际值
APP_NAME =
DEBUG = false
DB_HOST =
DB_PORT = 3306
DB_USER =
DB_PASSWORD =
DB_NAME =
REDIS_URL =
JWT_SECRET = # 必填!生产环境请使用强密钥
JWT_EXPIRE_MINUTES = 1440
UPLOAD_DIR = ./uploads
MAX_UPLOAD_SIZE = 10485760
为什么 .env 不提交 Git,.env.example 提交?
.env → 包含真实密码,绝不能提交(类似店长的保险箱密码)
.env.example → 只有配置项名称和默认值,可以提交(类似"需要填哪些表"的清单)
新人入职流程:
1. git clone 项目
2. cp .env.example .env → 复制模板
3. 填写真实密码 → 生成自己的 .env
4. 启动项目 → pydantic-settings 自动读取 .env
比 likeadmin 的改进:
对比 likeadmin config pydantic-settings 类型校验 无(都是字符串) 有(DB_PORT 一定是 int) 缺失校验 启动时不报错,运行时才崩 启动时就报错,必填项不能少 IDE 支持 无 完整自动补全 默认值 分散在各文件 集中在 Settings 类 派生值 手动拼字符串 @property 自动计算
配置分离 = 换店不换配方。代码全国一样,配置每家店不同。 类型安全的配置 = 启动时就发现配置错误,而不是运行时才崩。
三个关键设计决策及原因:
@property database_url → 派生值用属性计算,不让用户手动拼,避免不一致@lru_cache() → 配置只加载一次,避免重复解析,比全局变量更安全.env.example → 提交模板而非真实值,新人不用猜需要哪些配置对标缺点 #6:全局函数满天飞,命名冲突风险高
likeadmin 的做法: 所有工具扔一个大抽屉
大抽屉 common.php:
剪刀、计算器、温度计、封口机、量杯、计时器、订书机...
找一个"计算器"?翻遍整个抽屉。两个函数都叫 format_time?命名冲突。
改进做法: 分类放工具箱
string_utils.py: 字符串相关(截断、脱敏、随机串)
date_utils.py: 日期相关(格式化、天数差)
crypto_utils.py: 加密相关(哈希、JWT)
file_utils.py: 文件相关(上传、压缩)
# common/utils/string_utils.py
class StringUtils :
"""
字符串工具类。
为什么用类而不是直接写函数?
1. 命名空间隔离:StringUtils.mask_phone() 不会和其他模块的 mask_phone 冲突
2. 语义清晰:看到 StringUtils 就知道里面是字符串相关的
3. 可扩展:以后加实例方法(如需要状态的工具)不用改调用方式
"""
@ staticmethod
def mask_phone ( phone : str ) -> str :
""" 手机号脱敏:138****1234 """
return phone [: 3 ] + " **** " + phone [- 4 :]
@ staticmethod
def random_code ( length : int = 6 ) -> str :
""" 生成随机数字验证码 """
return '' . join ( random . choices ( ' 0123456789 ' , k = length ))
@ staticmethod
def truncate ( text : str , max_length : int = 50 , suffix : str = " ... " ) -> str :
""" 截断长文本,保留前 max_length 个字符 """
if len ( text ) <= max_length :
return text
return text [: max_length - len ( suffix )] + suffix
# common/utils/crypto_utils.py
class CryptoUtils :
"""
加密工具类。
为什么单独放一个文件而不是和 string_utils 混在一起?
因为加密工具的依赖(bcrypt、jose)和其他工具完全不同。
如果只用到字符串工具的项目,不需要安装加密依赖。
这就是"按职责拆分"的好处——各取所需,不连带安装没用的包。
"""
_pwd_context = CryptContext ( schemes =[ " bcrypt " ], deprecated = " auto " )
@ staticmethod
def hash_password ( password : str ) -> str :
""" 密码加密——为什么用 bcrypt 而不是 MD5/SHA256?
因为 MD5/SHA256 是"哈希"不是"加密",可以被彩虹表破解。
bcrypt 自带盐值(salt),同一密码每次加密结果不同,更安全。 """
return CryptoUtils . _pwd_context . hash ( password )
@ staticmethod
def verify_password ( plain : str , hashed : str ) -> bool :
""" 密码校验 """
return CryptoUtils . _pwd_context . verify ( plain , hashed )
@ staticmethod
def create_token ( data : dict , secret : str , expires_minutes : int = 1440 ) -> str :
""" 生成 JWT Token——为什么用 JWT 而不是 Session?
因为 JWT 是无状态的,服务端不需要存储会话信息。
多台服务器之间不用共享 Session,天然支持水平扩展。 """
to_encode = data . copy ()
expire = datetime . utcnow () + timedelta ( minutes = expires_minutes )
to_encode . update ({ " exp " : expire })
return jwt . encode ( to_encode , secret , algorithm = " HS256 " )
@ staticmethod
def verify_token ( token : str , secret : str ) -> dict :
""" 验证 JWT Token """
return jwt . decode ( token , secret , algorithms =[ " HS256 " ])
# common/utils/date_utils.py
class DateUtils :
""" 日期工具类 """
@ staticmethod
def now_beijing () -> datetime :
""" 获取北京时间——为什么单独写?
因为服务器可能部署在不同时区,业务上统一用北京时间。 """
return datetime . now ( timezone ( timedelta ( hours = 8 )))
@ staticmethod
def format_ago ( dt : datetime ) -> str :
""" 人性化时间:刚刚、5分钟前、3小时前、昨天 """
delta = datetime . now () - dt
seconds = delta . total_seconds ()
if seconds < 60 : return " 刚刚 "
if seconds < 3600 : return f " { int ( seconds // 60 ) } 分钟前"
if seconds < 86400 : return f " { int ( seconds // 3600 ) } 小时前"
if seconds < 172800 : return " 昨天 "
return f " { int ( seconds // 86400 ) } 天前"
// common/utils/stringUtils.js
class StringUtils {
static maskPhone ( phone ) {
// 手机号脱敏:138****1234
return phone . slice ( 0 , 3 ) + ' **** ' + phone . slice ( - 4 )
}
static randomCode ( length = 6 ) {
// 生成随机数字验证码
return Array . from ( { length }, () => Math . floor ( Math . random () * 10 )) . join ( '' )
}
static truncate ( text , maxLength = 50 , suffix = ' ... ' ) {
return text . length <= maxLength ? text : text . slice ( 0 , maxLength - suffix . length ) + suffix
}
}
// common/utils/cryptoUtils.js
const bcrypt = require ( ' bcrypt ' )
const jwt = require ( ' jsonwebtoken ' )
class CryptoUtils {
static async hashPassword ( password ) {
// bcrypt 自动加盐,同一个密码每次 hash 结果不同
return await bcrypt . hash ( password , 10 )
}
static async verifyPassword ( plain , hashed ) {
return await bcrypt . compare ( plain , hashed )
}
static createToken ( data , secret , expiresMinutes = 1440 ) {
return jwt . sign ( data , secret , { expiresIn : expiresMinutes + ' m ' } )
}
static verifyToken ( token , secret ) {
return jwt . verify ( token , secret )
}
}
// 使用时按需引入,不用全部加载
const { StringUtils } = require ( ' ./common/utils/stringUtils ' )
const { CryptoUtils } = require ( ' ./common/utils/cryptoUtils ' )
// 在 Service 中使用
class UserService {
async createUser ( data ) {
const hashedPwd = await CryptoUtils . hashPassword ( data . password )
const user = await this. repo . create ( { ... data , password : hashedPwd } )
return user
}
}
likeadmin 的 common.php 的问题不只是"乱"——它违反了两个原则:
单一职责 :一个文件管了字符串、日期、加密、文件、短信……改加密逻辑可能不小心改坏了字符串函数接口隔离 :只用字符串工具的项目也被迫加载加密依赖(bcrypt 库很大)拆分后:
文件 职责 依赖 谁会用 string_utils.py 字符串操作 无额外依赖 几乎所有模块 crypto_utils.py 加密/Token bcrypt, jose 用户模块、认证模块 date_utils.py 日期格式化 无额外依赖 订单模块、报表模块 file_utils.py 文件上传/校验 aiofiles 上传模块
只用到字符串工具的模块,不需要安装 bcrypt。这就是接口隔离原则(ISP)。
工具函数不应该是"垃圾堆",应该是"工具箱"——分门别类,即取即用。
好工具类的标准:看类名就知道里面有什么,不用打开文件。 更好的标准:只引入你需要的工具,不用连带加载你不需要的依赖。
这就是 likeadmin 的 common.php 和我们的 utils/ 的本质区别:
前者是"瑞士军刀"(啥都有但笨重),后者是"专业工具箱"(各司其职)。
对标缺点 #9:SaaS 版硬编码多租户,业务代码被 if/else 污染
架构原则:横切关注点应由基础设施层处理,不污染业务逻辑
全国 100 家连锁店用同一套系统:
北京朝阳店 → 只看自己的订单、库存
上海静安店 → 只看自己的订单、库存
likeadmin 的做法: 每个业务方法里都判断租户
// 业务代码里到处都是 tenant_id
$ orders = Order :: where ( ' tenant_id ' , $ tenant_id )-> get ();
if ($ tenant_id == 1 ) { /* 特殊逻辑 */ }
这就像每个制茶师都要先问"你是哪家店的"再决定用什么配方 ——本该是管理层面的事,却让一线员工操心。
改进做法: 门口的安检门自动给你贴标签,后面所有人看标签就行
进门 → 安检门贴上"北京朝阳店"标签 → 所有人自动只处理朝阳店的事
完整的四步实现:
# common/middleware/tenant.py
from fastapi import Request , Header
async def get_current_tenant (
request : Request ,
x_tenant_id : str = Header ( ... , alias = " X-Tenant-Id " )
) -> str :
"""
从请求头获取租户ID。
为什么用请求头而不是子域名?
- 子域名方案(beijing.example.com)需要 DNS 配置,部署复杂
- 请求头方案(X-Tenant-Id: beijing)简单直接,开发/测试方便
- 生产环境可以在网关层(Nginx)根据子域名自动注入请求头
为什么存在 request.state 里?
- 因为后续的 Repository 层需要拿到 tenant_id
- request.state 是 FastAPI 的请求上下文,整个请求生命周期内可访问
- 不用一层层传参,避免污染业务方法的签名
"""
# 这里可以加验证逻辑,比如检查租户是否有效/是否过期
request . state . tenant_id = x_tenant_id
return x_tenant_id
# core/database.py(多租户基类部分)
class TenantBase ( Base ):
"""
多租户模型基类。
为什么用基类而不是每个 Model 都写 tenant_id?
1. 不重复:几十张表都要加 tenant_id,不写重复代码
2. 不会忘:继承 TenantBase 就有 tenant_id,忘不了
3. 统一管理:字段名、类型、索引一处定义,改一处全局生效
"""
__abstract__ = True
tenant_id = Column ( String ( 32 ), nullable =False, index =True, comment = " 租户ID " )
# modules/order/model.py
class Order ( TenantBase ): # 继承 TenantBase,自动有 tenant_id 字段
__tablename__ = " orders "
id = Column ( Integer , primary_key =True)
order_no = Column ( String ( 50 ), unique =True)
amount = Column ( Numeric ( 10 , 2 ))
status = Column ( Integer , default = 0 )
# tenant_id 不需要写,从 TenantBase 自动继承
# modules/goods/model.py
class Goods ( TenantBase ): # 商品表也要租户隔离
__tablename__ = " goods "
id = Column ( Integer , primary_key =True)
name = Column ( String ( 100 ))
price = Column ( Numeric ( 10 , 2 ))
stock = Column ( Integer , default = 0 )
# tenant_id 不需要写,从 TenantBase 自动继承
# common/base_repository.py
class TenantAwareRepository :
"""
多租户感知的仓库基类。
为什么 Repository 要有基类?而不是每个 Repository 手动加过滤?
因为手动加过滤有一个致命问题:忘了加就数据泄露!
北京店能看到上海店的订单,这在 SaaS 里是严重事故。
基类自动加过滤,开发者不需要记住"每个查询都要加 tenant_id",
从根源杜绝数据泄露。
"""
def __init__ ( self , db : AsyncSession , request : Request ):
self . db = db
self . tenant_id = getattr ( request . state , ' tenant_id ' , None)
def _add_tenant_filter ( self , query , model_class ):
"""
自动给查询添加 WHERE tenant_id = ? 条件。
这是整个多租户方案的核心:
- 所有继承 TenantAwareRepository 的仓库,查询自动带租户过滤
- 开发者写 find_all() 时不需要手动加 .where(tenant_id=xxx)
- 忘了加?不可能,因为基类帮你加了
"""
if self . tenant_id and hasattr ( model_class , ' tenant_id ' ):
query = query . where ( model_class . tenant_id == self . tenant_id )
return query
# modules/order/repository.py
class OrderRepository ( TenantAwareRepository ):
""" 订单仓库——继承多租户基类,自动过滤 """
async def find_all ( self , page : int , size : int ):
query = select ( Order )
# 核心一行:自动加 WHERE tenant_id = ?
query = self . _add_tenant_filter ( query , Order )
# 后续的分页、排序逻辑完全不用关心租户
count_query = select ( func . count ()). select_from ( query . subquery ())
total = ( await self . db . execute ( count_query )). scalar ()
items = ( await self . db . execute (
query . offset (( page - 1 ) * size ). limit ( size )
)). scalars (). all ()
return items , total
async def find_by_id ( self , order_id : int ):
query = select ( Order ). where ( Order . id == order_id )
# 自动加租户过滤——即使有人猜到了其他租户的订单ID也查不到
query = self . _add_tenant_filter ( query , Order )
return ( await self . db . execute ( query )). scalar_one_or_none ()
async def create ( self , ** kwargs ):
# 创建时自动填入当前租户ID——开发者不需要手动传
if self . tenant_id :
kwargs [ ' tenant_id ' ] = self . tenant_id
order = Order (** kwargs )
self . db . add ( order )
await self . db . commit ()
await self . db . refresh ( order )
return order
# modules/order/service.py
class OrderService :
""" 订单服务——注意,这里没有一行业务代码提到 tenant_id! """
async def list_orders ( self , page , size ):
# Repository 自动只返回当前租户的订单
# Service 完全不关心租户过滤——这就是"业务无感知"
return await self . repo . find_all ( page , size )
async def get_order ( self , order_id : int ):
# 即使有人传了其他租户的订单ID,Repository 也会过滤掉
order = await self . repo . find_by_id ( order_id )
if not order :
raise NotFoundException ( " 订单不存在 " )
return order
async def create_order ( self , data ):
# 创建时不需要手动传 tenant_id,Repository 自动填入
return await self . repo . create (
order_no = generate_order_no (),
amount = data . amount ,
status = 0 ,
)
// likeadmin SaaS 版——每个业务方法都要手动传 tenant_id
class OrderService {
public function list ($ tenantId ) { // 手动传
return Order :: where ( ' tenant_id ' , $ tenantId )-> get (); // 手动过滤
}
public function create ($ tenantId , $ data ) { // 手动传
$ data [ ' tenant_id ' ] = $ tenantId ; // 手动填
return Order :: create ($ data );
}
}
// 问题1:忘了传 tenant_id?数据泄露。
// 问题2:特殊租户 if/else?业务代码被污染。
// 问题3:每个方法都多一个参数?接口变丑。
# 我们的做法——业务代码完全无感知
class OrderService :
async def list_orders ( self , page , size ): # 没有 tenant_id 参数
return await self . repo . find_all ( page , size ) # 没有手动过滤
async def create_order ( self , data ): # 没有 tenant_id 参数
return await self . repo . create (** data ) # 没有手动填入
# 1. 忘了过滤?不可能,基类自动加。
# 2. 特殊租户?在中间件层处理,不碰业务代码。
# 3. 接口干净?和单租户版本一模一样。
多租户 = 给每行数据打上"属于谁"的标签。
最好的实现是业务代码无感知的:
你在 Service 写 list_orders(),不用写 list_orders(tenant_id=xxx)。
框架在底层自动加租户过滤,业务代码干净如初。
为什么 Repository 基类自动过滤比手动过滤更安全?
因为安全措施必须"默认生效",不能"靠人记住"。
就像门锁装在门上自动锁门,而不是靠人每次出门记得锁。
架构原则:横切关注点(日志、权限、租户、限流)应在基础设施层处理,不污染业务逻辑。
对标缺点 #10:代码生成器质量一般,生成后需大改
保留发扬:优点 #6(代码生成器思路)
架构原则:约定优于配置
50 种饮品,每种制作流程一样:接单→拿杯→加茶底→加配料→封口→出餐
手写 50 遍?累死。聪明的做法:模板 + 参数 = 自动生成。
按层分目录,生成的文件散落各处 千篇一律,没有业务语义 生成完还是要改很多 按模块分目录后,生成器输出一个完整模块,生成即能跑 :
python generate.py module:goods --table=goods --fields=name,price,stock
# 自动生成——每个文件都有完整的 CRUD 代码
modules/goods/
├── controller.py # 完整 CRUD 接口(含权限标注)
├── service.py # 基础业务逻辑
├── repository.py # 数据访问(含分页、搜索)
├── model.py # 数据模型
├── schema.py # 请求/响应结构
└── router.py # 路由注册
为什么"约定优于配置"能让生成器生成即能用?
likeadmin 的生成器需要用户填很多配置(表名、字段名、中文名、验证规则、关联关系……),配置项越多,生成结果越不可控。
我们的做法是靠约定减少配置 :
约定 具体规则 好处 文件命名 controller.py / service.py / repository.py / model.py / schema.py / router.py 看到文件名就知道干什么 类命名 {Module}Controller / {Module}Service / {Module}Repository / {Module} 全项目风格统一 Schema 命名 Create{Module}Request / Update{Module}Request / {Module}Response 前端一看就懂 路由前缀 /api/{module_name} URL 风格统一 权限编码 {module}:list / {module}:create / {module}:update / {module}:delete 权限码有规律
生成器的模板代码示例:
# generate.py(核心逻辑简化版)
import os
from string import Template
# 模板——约定了每个文件的骨架
CONTROLLER_TEMPLATE = Template ( ''' """
$ {module_comment} 管理 - Controller
自动生成于: $ {date}
"""
from fastapi import APIRouter, Depends, Query
from .schema import Create$ {Module} Request, Update$ {Module} Request, $ {Module} Response
from .service import $ {Module} Service
from common.response import ApiResponse, PageData
from core.dependencies import PermissionChecker
router = APIRouter(prefix="/$ {module_name} ", tags=["$ {module_comment} 管理"])
@router.get("/", response_model=ApiResponse[PageData[$ {Module} Response]],
dependencies=[Depends(PermissionChecker("$ {module_name} :list"))])
async def list_$ {module_name} (
page: int = Query(1, ge=1),
size: int = Query(10, ge=1, le=100),
keyword: str = Query(None),
service: $ {Module} Service = Depends()
):
items, total = await service.list_$ {module_name} (page, size, keyword)
return ApiResponse.success(PageData(items=items, total=total, page=page, size=size))
@router.post("/", response_model=ApiResponse[$ {Module} Response],
dependencies=[Depends(PermissionChecker("$ {module_name} :create"))])
async def create_$ {module_name} (
data: Create$ {Module} Request,
service: $ {Module} Service = Depends()
):
result = await service.create_$ {module_name} (data)
return ApiResponse.success(result, "创建成功")
@router.get("/{${module_name}_id}", response_model=ApiResponse[${Module}Response])
async def get_${module_name}(
${module_name}_id: int,
service: ${Module}Service = Depends()
):
return ApiResponse.success(await service.get_${module_name}(${module_name}_id))
@router.put("/{${module_name}_id}", response_model=ApiResponse[${Module}Response],
dependencies=[Depends(PermissionChecker("${module_name}:update"))])
async def update_${module_name}(
${module_name}_id: int,
data: Update${Module}Request,
service: ${Module}Service = Depends()
):
return ApiResponse.success(await service.update_${module_name}(${module_name}_id, data), "更新成功")
@router.delete("/{${module_name}_id}", response_model=ApiResponse,
dependencies=[Depends(PermissionChecker("${module_name}:delete"))])
async def delete_${module_name}(
${module_name}_id: int,
service: ${Module}Service = Depends()
):
await service.delete_${module_name}(${module_name}_id)
return ApiResponse.success(msg="删除成功")
''' )
def generate_module ( module_name : str , module_comment : str , fields : list [ dict ]):
"""
生成一个完整模块。
为什么用模板而不是 AST 代码生成?
1. 模板直观——看模板就知道生成什么代码
2. 模板可定制——团队可以修改模板风格
3. 模板简单——不需要学 AST,改文本就行
为什么生成即能用而不是生成半成品?
因为半成品需要二次修改,每次修改都可能引入不一致。
生成即能用 → 复制模块目录 → 改 Service 里的业务逻辑 → 完事。
"""
module_dir = f "modules/ { module_name } "
os . makedirs ( module_dir , exist_ok =True)
context = {
" module_name " : module_name ,
" Module " : module_name . capitalize (),
" module_comment " : module_comment ,
" date " : " 2026-06-07 " ,
" fields " : fields ,
}
# 生成每个文件
templates = {
" controller.py " : CONTROLLER_TEMPLATE ,
" service.py " : SERVICE_TEMPLATE ,
" repository.py " : REPOSITORY_TEMPLATE ,
" model.py " : MODEL_TEMPLATE ,
" schema.py " : SCHEMA_TEMPLATE ,
" router.py " : ROUTER_TEMPLATE ,
}
for filename , template in templates . items ():
code = template . substitute ( context )
with open ( f " { module_dir } / { filename } " , " w " , encoding = " utf-8 " ) as f :
f . write ( code )
print ( f "✅ 模块 { module_name } 生成完成!目录: { module_dir } /" )
print ( f " 下一步:在 main.py 中注册路由" )
print ( f " from modules. { module_name } .router import router as { module_name } _router" )
print ( f " app.include_router( { module_name } _router, prefix='/api')" )
# 使用
generate_module ( " goods " , " 商品 " , [
{ " name " : " name " , " type " : " str " , " comment " : " 商品名称 " , " max_length " : 100 },
{ " name " : " price " , " type " : " Decimal " , " comment " : " 价格 " },
{ " name " : " stock " , " type " : " int " , " comment " : " 库存 " , " default " : 0 },
])
为什么 likeadmin 的生成器是"半成品"?
按层分目录 → 生成的文件散落 4 个目录,新人不知道哪些文件是一组没有约定 → 每次生成都要填一堆配置,配置项之间可能冲突模板太糙 → 生成的代码只做了最简单的 CRUD,连分页搜索都没有我们的生成器为什么"生成即能用"?
按模块分目录 → 生成的文件全部在一个文件夹,一眼看清约定优于配置 → 只需提供模块名和字段,其余按约定自动生成模板完整 → 生成的代码包含完整 CRUD + 分页 + 搜索 + 权限标注代码生成器 = 复印机 + 填空题。
约定优于配置的本质:减少决策。
不需要问"Controller 放哪个目录?"——约定了放 modules/ 下。
不需要问"权限码怎么命名?"——约定了 {module}:{action}。
不需要问"分页参数叫什么?"——约定了 page + size。
决策越少,出错越少,速度越快。
pip install fastapi uvicorn[standard] sqlalchemy[asyncio] aiomysql \
pydantic pydantic-settings python-jose[cryptography] \
passlib[bcrypt] redis python-multipart
# main.py —— 应用入口,把所有零件组装起来
from fastapi import FastAPI
from contextlib import asynccontextmanager
from core . config import get_settings
from core . database import init_db
from common . exception_handler import register_exception_handlers
from common . middleware import register_middlewares
settings = get_settings ()
@ asynccontextmanager
async def lifespan ( app : FastAPI ):
"""
应用生命周期管理。
为什么用 lifespan 而不是 on_event("startup")?
因为 on_event 已被 FastAPI 标记为废弃,lifespan 是官方推荐的新方式。
更重要的是,lifespan 用 context manager 语法,启动和关闭逻辑紧挨着写,
不会出现"启动逻辑在第 10 行,关闭逻辑在第 200 行"的问题。
"""
# 启动时
await init_db ()
print ( f "🚀 { settings . APP_NAME } v { settings . APP_VERSION } 启动成功" )
yield
# 关闭时(yield 之后的代码在应用关闭时执行)
print ( " 👋 应用已关闭 " )
def create_app () -> FastAPI :
app = FastAPI (
title = settings . APP_NAME ,
version = settings . APP_VERSION ,
lifespan = lifespan ,
docs_url = " /api/docs " if settings . DEBUG else None, # 生产环境关闭 Swagger
)
register_middlewares ( app )
register_exception_handlers ( app )
# 按模块注册路由——每加一个模块只需加一行
from modules . user . router import router as user_router
from modules . order . router import router as order_router
from modules . goods . router import router as goods_router
app . include_router ( user_router , prefix = " /api " )
app . include_router ( order_router , prefix = " /api " )
app . include_router ( goods_router , prefix = " /api " )
return app
app = create_app ()
# 启动:uvicorn main:app --reload --host 0.0.0.0 --port 8000
# core/database.py —— 数据库连接和会话管理
from sqlalchemy . ext . asyncio import create_async_engine , AsyncSession , async_sessionmaker
from sqlalchemy . orm import DeclarativeBase
from core . config import get_settings
settings = get_settings ()
# 为什么用 async_engine?因为 FastAPI 是异步框架,
# 同步数据库操作会阻塞整个事件循环,拖慢所有请求。
engine = create_async_engine (
settings . database_url ,
echo = settings . DEBUG , # 开发环境打印 SQL,生产环境关闭
pool_size = 20 , # 连接池大小
max_overflow = 10 , # 超出连接池后最多再创建 10 个连接
pool_recycle = 3600 , # 连接 1 小时后回收,防止 MySQL 8 小时断连问题
)
# 为什么用 async_sessionmaker 而不是直接创建 Session?
# 因为 sessionmaker 是工厂模式,保证每个请求拿到的是独立的 Session,
# 请求之间不会互相干扰(不会出现请求 A 的未提交数据被请求 B 读到)。
AsyncSessionLocal = async_sessionmaker (
engine ,
class_ = AsyncSession ,
expire_on_commit =False, # commit 后对象不过期,避免延迟加载报错
)
# ORM 基类——所有 Model 继承这个
class Base ( DeclarativeBase ):
pass
# 数据库初始化(建表)
async def init_db ():
async with engine . begin () as conn :
await conn . run_sync ( Base . metadata . create_all )
# 依赖注入用的数据库会话
async def get_db ():
"""
为什么用 yield 而不是 return?
因为 yield 允许在请求结束后执行清理逻辑(关闭 Session)。
return 只能返回值,无法在请求结束后做清理。
整个流程:
1. 请求进来 → yield 之前的代码执行 → 创建 Session
2. 请求处理中 → 业务代码使用 Session
3. 请求结束 → yield 之后的代码执行 → 关闭 Session
"""
async with AsyncSessionLocal () as session :
try :
yield session
finally :
await session . close ()
# core/security.py —— 安全相关功能
from jose import jwt , JWTError
from passlib . context import CryptContext
from core . config import get_settings
settings = get_settings ()
# 为什么 CryptContext 配置 schemes=["bcrypt"]?
# bcrypt 是目前最安全的密码哈希算法,自带盐值,计算慢(防暴力破解)。
# deprecated="auto" 表示如果旧数据用了其他算法,自动迁移到 bcrypt。
pwd_context = CryptContext ( schemes =[ " bcrypt " ], deprecated = " auto " )
def hash_password ( password : str ) -> str :
return pwd_context . hash ( password )
def verify_password ( plain : str , hashed : str ) -> bool :
return pwd_context . verify ( plain , hashed )
def create_access_token ( data : dict ) -> str :
"""
生成 JWT Token。
为什么把过期时间写进 Token 而不是服务端存储?
因为 JWT 是无状态的——服务端不需要查数据库就能验证 Token 是否有效。
多台服务器之间不用共享 Session,天然支持水平扩展。
"""
from datetime import datetime , timedelta
to_encode = data . copy ()
expire = datetime . utcnow () + timedelta ( minutes = settings . JWT_EXPIRE_MINUTES )
to_encode . update ({ " exp " : expire })
return jwt . encode ( to_encode , settings . JWT_SECRET , algorithm = settings . JWT_ALGORITHM )
def verify_access_token ( token : str ) -> dict | None:
"""
验证 JWT Token。
返回 None 而不是抛异常,是因为"Token 无效"不是系统错误,是正常的业务场景。
"""
try :
return jwt . decode ( token , settings . JWT_SECRET , algorithms =[ settings . JWT_ALGORITHM ])
except JWTError :
return None
# core/dependencies.py —— 全局通用的 FastAPI 依赖
from fastapi import Depends , Header
from sqlalchemy . ext . asyncio import AsyncSession
from core . database import get_db
from core . security import verify_access_token
from common . exceptions import UnauthorizedException , ForbiddenException , ErrorCode
async def get_current_user (
authorization : str = Header ( ... , alias = " Authorization " ),
db : AsyncSession = Depends ( get_db ),
):
"""
获取当前登录用户。
为什么写成依赖而不是中间件?
1. 中间件是"全局的"——所有请求都会经过,包括不需要登录的接口
2. 依赖是"按需的"——只有声明了 Depends(get_current_user) 的接口才会执行
3. 依赖可以链式组合——PermissionChecker 依赖 get_current_user,自动先认证再授权
为什么从 Header 而不是 Cookie 取 Token?
因为 API 通常是跨域的(前端和后端不同域),Cookie 跨域配置复杂。
Header 方案更简单,前端 axios 拦截器统一加 Authorization 即可。
"""
if not authorization or not authorization . startswith ( " Bearer " ):
raise UnauthorizedException ( " 未登录 " )
token = authorization . replace ( " Bearer " , "" )
payload = verify_access_token ( token )
if not payload :
raise UnauthorizedException ( " 登录已过期 " )
# 从数据库加载最新用户信息(防止用户已被禁用但 Token 还没过期)
from modules . user . repository import UserRepository
repo = UserRepository ( db )
user = await repo . find_by_id ( payload . get ( " sub " ))
if not user or not user . is_active :
raise UnauthorizedException ( " 账号已停用 " )
return user
class PermissionChecker :
"""
权限检查器——为什么用类而不是函数?
因为需要传参数(perm_code),函数做不到 `Depends(require_permission("user:list"))`,
但类的 __call__ 可以:`Depends(PermissionChecker("user:list"))`。
"""
def __init__ ( self , perm_code : str ):
self . perm_code = perm_code
async def __call__ (
self ,
current_user = Depends ( get_current_user ), # 先认证
db : AsyncSession = Depends ( get_db ),
):
from modules . permission . service import PermissionService
from modules . permission . repository import PermissionRepository
service = PermissionService ( PermissionRepository ( db ))
has_perm = await service . check_permission ( current_user . id , self . perm_code )
if not has_perm :
raise ForbiddenException ( f "缺少权限: { self . perm_code } " )
return current_user
# modules/user/router.py —— 为什么需要单独的 router.py?
# 因为 controller.py 定义了路由函数,但路由注册(prefix、tags)应该集中管理。
# 这样 main.py 只需要 include_router,不用关心路由的详细配置。
from fastapi import APIRouter
from . controller import router
# 可以在这里添加模块级别的中间件、依赖等
# 比如所有用户接口都需要登录:dependencies=[Depends(get_current_user)]
# common/middleware.py —— 统一注册所有中间件
from fastapi import FastAPI
from fastapi . middleware . cors import CORSMiddleware
from starlette . middleware . base import BaseHTTPMiddleware
import time
import logging
logger = logging . getLogger ( __name__ )
class LogMiddleware ( BaseHTTPMiddleware ):
"""
日志中间件——记录每个请求的耗时。
为什么用 BaseHTTPMiddleware 而不是 @app.middleware("http")?
两种写法效果一样,但类写法更易测试和复用。
"""
async def dispatch ( self , request , call_next ):
start = time . time ()
response = await call_next ( request )
duration = time . time () - start
logger . info (
f " { request . method } { request . url . path } - "
f " { response . status_code } - { duration :.3f } s"
)
return response
def register_middlewares ( app : FastAPI ):
""" 注册所有中间件——为什么集中注册?方便一览全部中间件 """
# CORS 中间件——为什么需要?
# 前端(localhost:5173)和后端(localhost:8000)端口不同,属于跨域。
# 没有 CORS 中间件,前端请求会被浏览器拦截。
app . add_middleware (
CORSMiddleware ,
allow_origins =[ " * " ], # 生产环境应改为具体域名
allow_credentials =True,
allow_methods =[ " * " ],
allow_headers =[ " * " ],
)
# 日志中间件
app . add_middleware ( LogMiddleware )
# 开发环境(自动重载)
uvicorn main:app --reload --host 0.0.0.0 --port 8000
# 生产环境(多 worker)
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
# 访问 Swagger 文档
# http://localhost:8000/api/docs
npm install fastify @fastify/cors @fastify/swagger @fastify/swagger-ui \
@fastify/jwt @fastify/env mysql2 knex bcrypt
// app.js —— Fastify 应用入口
const Fastify = require ( ' fastify ' )
const config = require ( ' ./core/config ' )
async function buildApp () {
const fastify = Fastify ( {
logger : config . DEBUG
? { transport : { target : ' pino-pretty ' } } // 开发环境:美化日志
: true // 生产环境:JSON 日志
} )
// 注册全局插件
// 为什么用 @fastify/plugin (fp) 包装?
// 因为 Fastify 的插件系统有作用域隔离——不用 fp 包装的插件,
// 注册的 decorate 只在当前作用域可用,子路由访问不到。
// fp 打破作用域,让 decorate 的东西全局可用。
await fastify . register ( require ( ' @fastify/cors ' ) , {
origin : true , // 开发环境允许所有来源
credentials : true
} )
await fastify . register ( require ( ' @fastify/swagger ' ) , {
openapi : {
info : { title : config . APP_NAME , version : config . APP_VERSION }
}
} )
await fastify . register ( require ( ' @fastify/swagger-ui ' ) , {
routePrefix : ' /api/docs '
} )
await fastify . register ( require ( ' @fastify/jwt ' ) , {
secret : config . JWT_SECRET
} )
// 注册数据库插件
await fastify . register ( require ( ' ./plugins/database ' ))
// 注册业务插件(Service 层)
await fastify . register ( require ( ' ./plugins/user ' ))
await fastify . register ( require ( ' ./plugins/order ' ))
await fastify . register ( require ( ' ./plugins/goods ' ))
// 注册路由模块
await fastify . register ( require ( ' ./modules/user/router ' ))
await fastify . register ( require ( ' ./modules/order/router ' ))
await fastify . register ( require ( ' ./modules/goods/router ' ))
// 全局错误处理
fastify . setErrorHandler ( require ( ' ./common/errorHandler ' ))
return fastify
}
async function start () {
const app = await buildApp ()
try {
await app . listen ( { port : 3000 , host : ' 0.0.0.0 ' } )
console . log ( ` 🚀 ${ config . APP_NAME } 启动成功: http://localhost:3000 ` )
} catch ( err ) {
app . log . error ( err )
process . exit ( 1 )
}
}
start ()
// plugins/database.js —— 为什么数据库是插件而不是工具文件?
// 因为 Fastify 插件有生命周期管理——应用关闭时自动调用 onClose,
// 确保数据库连接被正确关闭,不会出现连接泄露。
const fp = require ( ' fastify-plugin ' )
const knex = require ( ' knex ' )
async function databasePlugin ( fastify , opts ) {
const db = knex ( {
client : ' mysql2 ' ,
connection : {
host : config . DB_HOST ,
port : config . DB_PORT ,
user : config . DB_USER ,
password : config . DB_PASSWORD ,
database : config . DB_NAME ,
},
pool : { min : 5 , max : 20 }, // 连接池
debug : config . DEBUG , // 开发环境打印 SQL
} )
// 挂到 fastify 实例——所有路由都能用 fastify.db 访问
fastify . decorate ( ' db ' , db )
// 应用关闭时自动断开连接
fastify . addHook ( ' onClose ' , async () => {
await db . destroy ()
} )
}
module.exports = fp (databasePlugin)
# 开发环境(自动重载)
node --watch app.js
# 生产环境
node app.js
# 访问 Swagger 文档
# http://localhost:3000/api/docs
# likeadmin 的问题 架构根因 升级方案 升级后收益 1 按层分目录 违反高内聚 → 按模块分 改功能只进一个文件夹 2 PHP 强绑定 业务耦合框架 → 洋葱架构 FastAPI/Fastify 通用 3 Logic 太薄 缺少业务层 → Service 承载领域逻辑 业务有真正的家 4 验证器耦合 关注点未分离 → Schema 独立声明式 类型即验证 5 没有依赖注入 控制反转缺失 → Depends/decorate 可测试、可替换 6 全局函数散乱 命名空间污染 → 按职责拆工具类 看类名知用途 7 Model 臃肿 持久化领域混杂 → Repository 分离 Model 纯定义 8 异常处理粗糙 缺领域异常体系 → 异常码+全局捕获 前端精准处理 9 多租户硬编码 横切关注点污染 → 基础设施透明处理 业务代码零污染 10 生成器质量低 约定不足 → 约定优于配置 生成即能跑
场景 推荐 原因 快速开发、AI 友好 FastAPI Pydantic 类型 + 自动文档 + Depends 高并发、低延迟 Fastify Node.js 异步 + JSON Schema Python 团队 FastAPI 学习成本低,生态成熟 Node.js 团队 Fastify 前后端统一语言
目录按模块分,不按层分 —— 高内聚低耦合,让人一眼找到想改的代码每层只做自己该做的事 —— 单一职责,Controller 不写业务,Service 不写 SQL依赖注入替代 new —— 控制反转,像换零件一样换功能声明式验证替代手写规则 —— 关注点分离,类型即文档,类型即验证横切关注点下沉到基础设施 —— 租户、日志、权限在中间件层处理,业务代码干净框架是骨架,设计模式是灵魂。
语言会过时,框架会换代,但好的设计原则是通用的——
高内聚低耦合、单一职责、控制反转、关注点分离、约定优于配置。
掌握这五条,你就从"会用框架"变成了"会造框架"。
📚 配套教程:
📅 最后更新:2026-06-07