通用后台框架开发心法 —— 从 likeadmin 学架构

取其精华,去其糟粕,用 FastAPI & Fastify 打造新一代通用后台。不是"换个写法",而是"升级思维方式"。

副标题:取其精华,去其糟粕,用 FastAPI & Fastify 打造新一代通用后台

核心理念:不是"换个写法",而是"升级思维方式"


前言:为什么要从 likeadmin 学起?

likeadmin 是目前国内最流行的 PHP 通用后台框架之一,累计 Star 数万。

它就像一个开了十年的连锁奶茶品牌——配方成熟、流程规范、出餐快。但十年老店也有老店的问题。

本教程的做法:

  1. 盘点 likeadmin 的 10 大缺点 + 8 大优点
  2. 对每条缺点,不只给"改法",更要讲清"为什么这样改"——背后的架构原则
  3. 对每条优点,说明如何保留,并在 FastAPI/Fastify 中发扬光大
  4. 最终形成一套与时俱进的架构思维

likeadmin 优缺点全面盘点

❌ 10 大缺点(我们要升级的)

#缺点现象根因升级方向
1按层分目录改一个功能跳 4 个目录违反高内聚低耦合→ 按模块分,高内聚
2PHP 强绑定换语言全得重写业务与框架耦合→ 洋葱架构,核心无关框架
3Logic 层太薄Logic 只是转发 Model没有真正的业务层→ Service 承载领域逻辑
4验证器耦合 Controller校验写控制器里关注点未分离→ Schema 独立,输入防腐
5没有依赖注入类内部 new,mock 不了控制反转缺失→ IoC 容器管理依赖
6全局函数满天飞common.php 塞几百函数命名空间污染→ 按职责封装工具类
7Model 层臃肿查询+关联+校验上千行持久化与领域混杂→ Repository 分离持久化
8异常处理粗糙错误码混乱,return json缺少领域异常体系→ 异常码+全局捕获
9多租户硬编码业务代码里 if/else 判断租户横切关注点污染业务→ 基础设施层透明处理
10代码生成器质量低生成后需大改约定不够,模板太糙→ 约定优于配置

✅ 8 大优点(我们要保留发扬的)

#优点为什么好如何在 FastAPI/Fastify 中发扬
1统一响应格式前端对接零成本Pydantic 泛型响应
2中间件管道登录→权限→日志流水线Depends 链式依赖
3Base 基类模式公共能力继承Python Mixin / 继承
4配置分离.env + config 物理隔离pydantic-settings + env-schema
5多端入口分离adminapi / api 安全隔离Router 分包
6代码生成器思路减少 CRUD 重复生成即能跑的模块
7前后端分离独立开发部署纯 API + Swagger
8RBAC 权限模型用户→角色→权限保留 + 补数据权限

升级一:目录结构——从"按层散落"到"按模块内聚"

对标缺点 #1:按层分目录,改一个功能跳 4 个目录

架构原则:高内聚低耦合

🏪 奶茶店类比

按层分 = 按工种分区坐(likeadmin 的做法)

收银台区:  珍珠奶茶收银员、柠檬茶收银员、奶盖茶收银员
制茶区:    珍珠奶茶制茶师、柠檬茶制茶师、奶盖茶制茶师
原料区:    珍珠奶茶原料员、柠檬茶原料员、奶盖茶原料员

来一杯珍珠奶茶 → 收银台接单 → 喊给制茶区 → 制茶师跑去原料区。三个区来回跑,做个奶茶像跑接力赛。

按模块分 = 按品类分组坐(改进方案)

珍珠奶茶组: 收银、制茶、管料三人背靠背
柠檬茶组:   收银、制茶、管料三人背靠背

来一杯珍珠奶茶 → 丢给珍珠奶茶组 → 组内秒级协作,零跑动。

🔍 深层原因:为什么按层分是架构缺陷?

按层分的本质问题是违反高内聚低耦合

  • 高内聚:相关的东西放一起。珍珠奶茶的收银、制茶、管料高度相关,应该放一起。
  • 低耦合:不相关的东西分开。珍珠奶茶和柠檬茶互不相关,不要混一起。

按层分恰恰反过来了——把相关的拆散了(珍珠奶茶的收银和制茶分开放),把不相关的聚在一起(所有品类收银员坐一排)。

这导致的后果不只是"找文件麻烦":

  1. 改一个功能要改多个文件 → 改用户注册要跳 controller/ + logic/ + model/ + validate/,容易漏改
  2. 删一个模块要删多个文件 → 删用户模块要在 4 个目录各删一个,漏删就报错
  3. 无法独立部署 → 用户模块和订单模块代码混在一起,没法把用户模块拆成微服务
  4. 新人理解困难 → "用户注册涉及哪些文件?"没人能一口答上来

❌ likeadmin 的目录

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 有天生的不匹配:

  1. 没有独立的"数据访问层" → Model 既定义表结构,又写查询,又管关联,职责过重
  2. 没有独立的"输入输出定义层" → 校验逻辑散落在 Controller 或 Validate 里,和业务模型耦合
  3. Logic 的定位模糊 → 有人说它是"业务逻辑",有人说它是"Controller 和 Model 之间的桥梁",实际上经常只是转发

升级为五层,每层职责清晰、不可替代:

┌──────────────────────────────────────────────────┐
│                  Controller 层                     │
│         接收请求、调度服务、返回响应                  │
│         (不写任何业务逻辑和数据访问)                 │
├──────────────────────────────────────────────────┤
│                   Schema 层                        │
│         定义输入输出的形状、验证规则                   │
│         (脏数据在这里被拦截,进不了业务层)            │
├──────────────────────────────────────────────────┤
│                   Service 层                       │
│         业务规则、流程编排、事务管理                   │
│         (整个框架的核心大脑)                        │
├──────────────────────────────────────────────────┤
│                 Repository 层                      │
│         数据存取、查询封装、分页                       │
│         (只跟数据库打交道,不碰业务逻辑)              │
├──────────────────────────────────────────────────┤
│                   Model 层                         │
│         表结构定义、字段约束                          │
│         (纯粹的数据结构,零逻辑)                     │
└──────────────────────────────────────────────────┘

五层代码实现

第1层:Controller —— 前台接待

只做三件事:① 接参数 ② 调 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 判断业务规则。

第2层:Schema —— 菜单模板 & 安检门

定义输入输出的形状。声明即验证,脏数据止于此。

# 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 的一部分?

  1. 复用:同一个 Schema 可以给 REST API 和 GraphQL 共用
  2. 自动文档:FastAPI 根据 Schema 自动生成 Swagger 文档
  3. 类型安全:IDE 知道 data.phonestr,自动补全
  4. 关注点分离:Controller 只管"接什么参数",Schema 管"参数长什么样"

第3层:Service —— 掌勺大厨(核心大脑)

业务逻辑的真正归属地。这是整个框架最重要的层。

# 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,业务逻辑无处安放
测试价值低(逻辑太少)高(核心业务都在这)

第4层:Repository —— 仓库管理员

只做数据存取。封装所有数据库操作,返回 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 → 只做查询和存取,纯操作,换数据库只改这里

第5层:Model —— 食材规格书

纯粹的数据结构定义。零逻辑,零查询,零校验。

# 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 = 规格书,只管定义

升级三:依赖注入——从"硬编码 new"到"声明我需要什么"

对标缺点 #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(); // 硬编码依赖
    }
}

这违反了两个核心原则:

  1. 控制反转(IoC):依赖的创建应该由外部控制,而不是类自己创建。类应该"声明我需要什么",而不是"我自己造"。
  2. 依赖倒置(DIP):高层模块不应该依赖低层模块,两者都应该依赖抽象。

🧠 逐个搞懂: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,必须看方法内部

✅ 升级方案

🐍 FastAPI:Depends(Python 生态最优雅的 DI)

# 步骤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:decorate + plugin

// 插件系统——注册依赖到 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))
}))

💡 顿悟时刻

依赖注入 = "别自己造零件,告诉工厂你需要什么,工厂给你装好。"

三个关键词:

  1. 声明 → 我不 new,我声明"我需要什么"
  2. 自动 → 框架帮我创建并注入,依赖链全自动解析
  3. 可换 → 测试时换假的,生产时换真的,只改配置不改代码

这就是控制反转——控制权从"我自己造"反转为"别人给我"。


升级四:验证层——从"手写规则字符串"到"类型即验证"

对标缺点 #4:验证器耦合在 Controller,校验逻辑散落

架构原则:关注点分离 + 防腐层

🏪 奶茶店类比

likeadmin 的做法: 收银员一边接单一边校验

收银员:您好,点什么?
顾客:我要一杯 -3°C 的奶茶
收银员:等等,温度不能是负数...让我翻手册查规则...
       手册第 37 页写着"温度范围 0-100"...好的,不行。

校验逻辑和接待逻辑混在一起,收银员脑子要记两套东西。

改进做法: 门口放个安检门

顾客进门 → 安检门自动扫描 → 温度-3°C?嘟!禁止入内!
收银员:你好,点什么?(只管接单,不管校验)

❌ likeadmin 的验证方式

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'      => '手机号格式不正确',
    ];
}

问题:

  1. 规则是字符串regex:^1[3-9]\d{9}$ 藏在字符串里,IDE 没有提示,容易写错
  2. 和 Controller 耦合 → 校验逻辑写在 Controller 里 $validate->check(),换一个入口(如 CLI 命令)就得重写
  3. 和 Model 脱节 → 改了 Model 字段容易忘记同步改验证

✅ 升级方案:Schema 声明式验证

核心理念:类型定义本身就是验证规则。不需要额外写校验代码。

# 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"}
  ]
}

⚡ Fastify:JSON Schema

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 ValidatePydantic 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 的问题

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,不管怎么返回给前端。全局异常处理器统一兜底。

第1步:统一响应(保留 likeadmin 的好设计)

# 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

第2步:异常码体系(likeadmin 缺失的)

# 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)

第3步:全局异常处理器(一个兜底,万事大吉)

# 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)

第4步:业务代码中使用

# 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)
  }
}

⚡ Fastify 版本

// 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 让它更灵活:可以精确到每个接口指定需要哪些检查。

✅ 保留 RBAC + 补充数据权限

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

✅ 升级:FastAPI Depends 链式权限

# 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 的做法:

对比likeadminFastAPI Depends
粒度全局中间件或控制器内 if每个接口声明式指定
可读性要看代码逻辑才知道要什么权限PermissionChecker("user:list") 一看就懂
依赖链手动调用自动解析
自动文档Swagger 自动标注

⚡ Fastify 版本

// 认证中间件 —— 为什么要写成独立函数而不是写在路由里?
// 原因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, '删除成功')
})

🔍 数据权限如何在 Repository 中自动过滤?

上面的操作权限解决了"能不能进这扇门"的问题。但还有另一个问题:用户进了门之后,能看到多少数据?

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

为什么这样设计?三重保障:

  1. Repository 层过滤 → 从根源保证安全,即使 Service 忘了检查也不怕
  2. PermissionService 独立 → 数据权限逻辑不散落在各处,改规则只改一个地方
  3. DataScope 枚举 → 新增权限级别只需加枚举值,不改业务代码

💡 顿悟时刻

中间件 = 安检传送带上的检查站。

操作权限管"能不能进这扇门",数据权限管"进门后能看到多少东西"。 likeadmin 只有前者,我们补上后者——这才是一个完整的权限体系。

数据权限放在 Repository 而不是 Service,原因和门锁装在门上而不是挂在脖子上一样—— 安全措施必须放在最靠近数据的地方,而不是靠人记住规则。


升级七:配置管理——从"能跑就行"到"类型安全"

保留发扬:优点 #4(配置分离)

对标缺点 #2(PHP 强绑定)→ 配置方案与框架解耦

🏪 奶茶店类比

配方(代码):珍珠奶茶 = 红茶底 + 奶精 + 珍珠
  → 全国统一,写死在流程里

运营参数(配置):糖度 70%、珍珠 50g/份、营业时间 9-22 点
  → 每家店不同,放在店长手册里随时调

把糖度写死在代码里,老板想调成 60% 就得改代码重新部署——太蠢了。

✅ 保留 .env 思路 + pydantic-settings 增强

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 configpydantic-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)}天前"

⚡ Fastify 版本

// 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 的问题不只是"乱"——它违反了两个原则:

  1. 单一职责:一个文件管了字符串、日期、加密、文件、短信……改加密逻辑可能不小心改坏了字符串函数
  2. 接口隔离:只用字符串工具的项目也被迫加载加密依赖(bcrypt 库很大)

拆分后:

文件职责依赖谁会用
string_utils.py字符串操作无额外依赖几乎所有模块
crypto_utils.py加密/Tokenbcrypt, 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) { /* 特殊逻辑 */ }

这就像每个制茶师都要先问"你是哪家店的"再决定用什么配方——本该是管理层面的事,却让一线员工操心。

改进做法: 门口的安检门自动给你贴标签,后面所有人看标签就行

进门 → 安检门贴上"北京朝阳店"标签 → 所有人自动只处理朝阳店的事

✅ 升级方案:中间件自动注入,业务代码零感知

完整的四步实现:

第1步:中间件——从请求头提取租户ID

# 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

第2步:Model 基类——需要多租户的表继承它

# 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 自动继承

第3步:Repository——自动加租户过滤(核心!)

# 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

第4步:Service——完全不需要关心租户

# 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 的做法

// 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 遍?累死。聪明的做法:模板 + 参数 = 自动生成。

❌ likeadmin 生成器的问题

  1. 按层分目录,生成的文件散落各处
  2. 千篇一律,没有业务语义
  3. 生成完还是要改很多

✅ 升级方案

按模块分目录后,生成器输出一个完整模块,生成即能跑

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 的生成器是"半成品"?

  1. 按层分目录 → 生成的文件散落 4 个目录,新人不知道哪些文件是一组
  2. 没有约定 → 每次生成都要填一堆配置,配置项之间可能冲突
  3. 模板太糙 → 生成的代码只做了最简单的 CRUD,连分页搜索都没有

我们的生成器为什么"生成即能用"?

  1. 按模块分目录 → 生成的文件全部在一个文件夹,一眼看清
  2. 约定优于配置 → 只需提供模块名和字段,其余按约定自动生成
  3. 模板完整 → 生成的代码包含完整 CRUD + 分页 + 搜索 + 权限标注

💡 顿悟时刻

代码生成器 = 复印机 + 填空题。

约定优于配置的本质:减少决策。 不需要问"Controller 放哪个目录?"——约定了放 modules/ 下。 不需要问"权限码怎么命名?"——约定了 {module}:{action}。 不需要问"分页参数叫什么?"——约定了 page + size。

决策越少,出错越少,速度越快。


完整项目搭建——从零到一

🐍 FastAPI 最小可用框架

安装依赖

pip install fastapi uvicorn[standard] sqlalchemy[asyncio] aiomysql \
            pydantic pydantic-settings python-jose[cryptography] \
            passlib[bcrypt] redis python-multipart

入口文件:main.py

# 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

# 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

# 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

# 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

# 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

# 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

⚡ Fastify 最小可用框架

安装依赖

npm install fastify @fastify/cors @fastify/swagger @fastify/swagger-ui \
            @fastify/jwt @fastify/env mysql2 knex bcrypt

入口文件:app.js

// 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

// 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 得失录 & 升级全貌

一张表看清 10 项升级

#likeadmin 的问题架构根因升级方案升级后收益
1按层分目录违反高内聚→ 按模块分改功能只进一个文件夹
2PHP 强绑定业务耦合框架→ 洋葱架构FastAPI/Fastify 通用
3Logic 太薄缺少业务层→ Service 承载领域逻辑业务有真正的家
4验证器耦合关注点未分离→ Schema 独立声明式类型即验证
5没有依赖注入控制反转缺失→ Depends/decorate可测试、可替换
6全局函数散乱命名空间污染→ 按职责拆工具类看类名知用途
7Model 臃肿持久化领域混杂→ Repository 分离Model 纯定义
8异常处理粗糙缺领域异常体系→ 异常码+全局捕获前端精准处理
9多租户硬编码横切关注点污染→ 基础设施透明处理业务代码零污染
10生成器质量低约定不足→ 约定优于配置生成即能跑

框架选择建议

场景推荐原因
快速开发、AI 友好FastAPIPydantic 类型 + 自动文档 + Depends
高并发、低延迟FastifyNode.js 异步 + JSON Schema
Python 团队FastAPI学习成本低,生态成熟
Node.js 团队Fastify前后端统一语言

五条终极心法

  1. 目录按模块分,不按层分 —— 高内聚低耦合,让人一眼找到想改的代码
  2. 每层只做自己该做的事 —— 单一职责,Controller 不写业务,Service 不写 SQL
  3. 依赖注入替代 new —— 控制反转,像换零件一样换功能
  4. 声明式验证替代手写规则 —— 关注点分离,类型即文档,类型即验证
  5. 横切关注点下沉到基础设施 —— 租户、日志、权限在中间件层处理,业务代码干净

框架是骨架,设计模式是灵魂。 语言会过时,框架会换代,但好的设计原则是通用的—— 高内聚低耦合、单一职责、控制反转、关注点分离、约定优于配置。 掌握这五条,你就从"会用框架"变成了"会造框架"。


📚 配套教程:

📅 最后更新:2026-06-07