做开发这些年,我反复跟想入全栈的朋友说一个思路:不要跟着纯语法教程一行行敲,得找一个“看起来不大、但五脏俱全”的真实业务系统,从需求到上线完整做一遍。今天要分享的项目,就是这样一个适合完整走一遍的实战素材——基于 FastAPI 和 Vue3 的蛋糕零售店系统。
后端用 Python 生态里这几年热度一直很高的 FastAPI,配合 SQLAlchemy 做数据持久化;前端用 Vue3 + Vite + Element Plus 搭建界面,覆盖商品展示、购物车、下单、订单状态跟踪这些零售门店最常见的流程。整套代码是免费公开的,没有付费门槛,照着敲就能跑。如果你有 Python 基础但还没写过 FastAPI,或者以前用的是 Vue2、想趁这个机会把 Vue3 组合式 API 练熟,这套系统正好能当你的第一份全栈实践项目。我会把核心模块拆开讲,重点说清楚每一步为什么这么做,哪里容易踩坑。
1. 项目定位:为什么拿蛋糕零售店做全栈练手项目
1.1 选型背后的逻辑:FastAPI + Vue3 组合为什么值得学
先聊后端。FastAPI 在 Python Web 框架里算是个“后来居上”的选手,它基于 ASGI,天然支持异步,性能和并发能力比传统的 Flask、Django 同步模式要好不少。更重要的一点是它对新手特别友好:你写 Python 函数的时候只要把参数类型标注好,FastAPI 会自动帮你完成数据校验、参数解析,甚至自动生成 OpenAPI 接口文档。也就是说,你不需要单独维护一份接口文档,写出的代码本身就带文档,前端拿去就能看。
再聊前端。Vue3 如今已经是非常成熟的主流版本了,组合式 API(Composition API)把逻辑组织能力提升了一大截,配合<script setup>语法,代码写起来比 Vue2 的 Options API 直观很多。而 Element Plus 作为 Vue3 生态里最常用的组件库,表格、表单、弹窗、消息提示这些后台和商城页面高频组件开箱即用,能省掉大量造轮子的时间。
选这俩还有一个现实原因:近几年招聘市场上前后端分离的项目,FastAPI + Vue3 的组合出现频率越来越高,尤其是中小型团队和内部系统。拿这套技术栈做项目,学完就能直接迁移到真实工作中,不会学了没地方用。
1.2 业务闭环决定你的学习路径
为什么要选“蛋糕零售店”而不是一个单纯的图书管理或 Todo List?因为零售业务天然是一个完整的业务闭环:门店需要陈列商品(蛋糕列表和分类)、用户需要挑选商品(购物车)、最终要生成订单并跟踪状态(下单和订单管理)。
这个闭环能带动你学习一系列关键技术点:
- 商品模块需要你掌握数据库建模、分页查询、关键词搜索、分类筛选;
- 购物车模块需要你理解状态管理(前端放在 Pinia 里,后端也可以落库)、增减数量、计算总价;
- 订单模块需要你学会事务处理、批量写入(订单主表和订单明细表)、生成唯一的订单号;
- 前后端联调需要你处理跨域、接口约定、字段类型转换;
- 部署上线时还可以顺带学一下 Uvicorn、Nginx 反向代理、环境变量配置。
也就是说,你做完这套系统,不是只学会了某个框架的语法,而是把“从需求到上线”的整条链路都走了一遍。后面无论是做商城、点餐系统、预约系统,业务逻辑都能很快迁移过去。
2. 后端搭建:FastAPI 把蛋糕店的“数据底座”立起来
2.1 初始化项目,配置文件一次到位
我习惯从目录结构开始规划。后端的建议结构长这样:
cake-shop-backend/ ├── app/ │ ├── __init__.py │ ├── main.py │ ├── config.py │ ├── database.py │ ├── models.py │ ├── schemas.py │ ├── crud.py │ └── routers/ │ ├── products.py │ ├── categories.py │ └── orders.py ├── .env └── requirements.txt用虚拟环境隔离依赖,然后安装下面的包:
pip install fastapi uvicorn sqlalchemy pydantic-settingspydantic-settings是我特别想提的一个库。很多新手教程里配置信息直接硬编码在代码里,连接数据库的地址、调试开关全写在main.py里,换个环境就得改代码,非常难受。用pydantic-settings可以把配置统一放到.env文件,代码里声明一个 Settings 类就行:
from pydantic_settings import BaseSettings class Settings(BaseSettings): app_name: str = "蛋糕店 API" database_url: str = "sqlite:///./cake_shop.db" debug: bool = True model_config = {"env_file": ".env"} settings = Settings()这样初始化读取配置文件的问题就解决了。开发环境连 SQLite,生产环境把.env里的database_url改成 MySQL 或 PostgreSQL 的连接串,代码一行都不用动。注意.env一定要加入.gitignore,不然数据库密码、密钥这种敏感信息很容易被提交到仓库里。
2.2 数据建模:商品、分类、购物车、订单
蛋糕店的业务如果收敛到核心表,其实就四张:分类表、商品表、订单表、订单明细表。购物车可以纯前端实现,也可以后端落库,为了控制学习曲线,我在这个项目里把购物车放在了前端,商品和订单相关的数据表这样设计:
from datetime import datetime from sqlalchemy import String, Float, Integer, Text, DateTime, ForeignKey from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class Category(Base): __tablename__ = "categories" id: Mapped[int] = mapped_column(primary_key=True, index=True) name: Mapped[str] = mapped_column(String(50), unique=True, index=True) products: Mapped[list["Product"]] = relationship(back_populates="category") class Product(Base): __tablename__ = "products" id: Mapped[int] = mapped_column(primary_key=True, index=True) name: Mapped[str] = mapped_column(String(100), index=True) description: Mapped[str | None] = mapped_column(Text) price: Mapped[float] = mapped_column(Float) stock: Mapped[int] = mapped_column(Integer, default=0) image_url: Mapped[str | None] = mapped_column(String(255)) category_id: Mapped[int] = mapped_column(ForeignKey("categories.id")) is_active: Mapped[bool] = mapped_column(default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) category: Mapped[Category] = relationship(back_populates="products") class Order(Base): __tablename__ = "orders" id: Mapped[int] = mapped_column(primary_key=True) order_no: Mapped[str] = mapped_column(String(32), unique=True, index=True) customer_name: Mapped[str] = mapped_column(String(50)) customer_phone: Mapped[str] = mapped_column(String(20)) total_amount: Mapped[float] = mapped_column(Float) status: Mapped[str] = mapped_column(String(20), default="pending") created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) items: Mapped[list["OrderItem"]] = relationship(back_populates="order") class OrderItem(Base): __tablename__ = "order_items" id: Mapped[int] = mapped_column(primary_key=True) order_id: Mapped[int] = mapped_column(ForeignKey("orders.id")) product_id: Mapped[int] = mapped_column(ForeignKey("products.id")) product_name: Mapped[str] = mapped_column(String(100)) price: Mapped[float] = mapped_column(Float) quantity: Mapped[int] = mapped_column(Integer) order: Mapped[Order] = relationship(back_populates="items")这里有几个建模时容易忽略的点。第一,订单明细表里我冗余了product_name和price,这不是多余的,因为商品名称和价格后续可能会改,如果订单明细只存product_id,历史订单显示就会错乱。第二,订单号和手机号这种高频查询字段都加了索引,数据量大了之后查询性能差距非常明显。第三,用relationship建立 ORM 关系时,前端查询订单接口可以很方便地把明细一起带出来,不用手动拼数据。
数据库连接和建表逻辑放在database.py:
from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from .config import settings from .models import Base engine = create_engine(settings.database_url, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) def init_db(): Base.metadata.create_all(bind=engine) def get_db(): db = SessionLocal() try: yield db finally: db.close()get_db是给 FastAPI 依赖注入用的,每个请求独立开一个 session,请求结束自动关闭,避免连接泄漏。新手很容易忽略这一步,直接把同一个 session 用在所有接口里,并发一上来就会出现各种奇怪的报错。
2.3 路由、校验与自动文档
路由层我按业务模块拆成多个文件,避免把逻辑全塞进main.py。以商品接口为例:
from fastapi import APIRouter, Depends, Query from sqlalchemy import select, func from sqlalchemy.orm import Session from ..database import get_db from ..models import Product from ..schemas import ProductList router = APIRouter(prefix="/api/products", tags=["products"]) @router.get("", response_model=ProductList) def list_products( page: int = Query(1, ge=1), page_size: int = Query(12, ge=1, le=100), keyword: str | None = Query(None, description="按名称搜索"), category_id: int | None = Query(None, description="按分类筛选"), db: Session = Depends(get_db), ): query = select(Product).where(Product.is_active == True) if keyword: query = query.where(Product.name.contains(keyword)) if category_id: query = query.where(Product.category_id == category_id) total = db.scalar(select(func.count()).select_from(query.subquery())) products = db.scalars( query.order_by(Product.id.desc()) .offset((page - 1) * page_size) .limit(page_size) ).all() return {"total": total, "items": products}分页和搜索这些逻辑看起来简单,但参数校验不能省。Query(1, ge=1)的含义是默认值为 1,并且必须大于等于 1,如果前端传一个page=-1,FastAPI 会直接返回 422 校验错误,根本不会向下执行。这就是类型标注带来的好处:你写一次校验规则,接口文档和运行时校验同时生效。
对应的 Pydantic schema 长这样:
from pydantic import BaseModel, ConfigDict class ProductOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str description: str | None price: float stock: int image_url: str | None category_id: int class ProductList(BaseModel): total: int items: list[ProductOut]from_attributes=True允许直接从 SQLAlchemy 模型实例转成 Pydantic 模型,不用手动逐个字段赋值。响应模型的作用是给接口输出加一层“过滤网”,避免把 ORM 模型里不该暴露的字段泄露给前端。
最后在main.py里把路由挂载进去:
from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from .config import settings from .database import init_db from .routers import products, categories, orders app = FastAPI(title=settings.app_name) app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.on_event("startup") def on_startup(): init_db() app.include_router(products.router) app.include_router(categories.router) app.include_router(orders.router)启动命令:
uvicorn app.main:app --reload --port 8000启动后浏览器打开http://localhost:8000/docs,你会发现所有接口的请求参数、响应格式、示例都自动生成了文档,还能直接在页面上测试。这一条前期开发时能省不少沟通成本。
3. 前端搭建:Vue3 + Element Plus 把“蛋糕店门面”做出来
3.1 用 Vite 创建项目并配置环境
前端我用 Vite 创建项目,先确保本机装了 Node.js 18 以上版本,然后执行:
npm create vite@latest cake-shop-frontend -- --template vue-ts cd cake-shop-frontend npm install npm install element-plus axios vue-router@4 piniaVue3 搭配 TypeScript 是当前的主流选择,虽然新手会觉得类型定义有点麻烦,但配合编辑器提示,开发效率其实比纯 JS 更高。项目装完依赖之后,我会把.env.development和.env.production两个环境变量文件建好:
# .env.development VITE_API_BASE_URL=http://localhost:8000/api# .env.production VITE_API_BASE_URL=/api这样做的好处是,本地开发时请求后端 8000 端口,部署时把前端打包产物交给 Nginx,再通过反向代理把/api转发到后端服务,前端代码不用改动。Vite 会自动根据当前的 mode 加载对应的环境变量文件。
前端目录结构我同样做了拆分:
cake-shop-frontend/ ├── src/ │ ├── api/ # 接口请求函数 │ ├── assets/ │ ├── components/ # 通用组件 │ ├── router/ # 路由配置 │ ├── stores/ # Pinia 状态 │ ├── views/ # 页面视图 │ ├── utils/request.ts # axios 实例封装 │ ├── App.vue │ └── main.ts3.2 请求层与路由骨架
Axios 实例封装是前端工程化的第一课。我通常会做两件事:设置基础 URL 和统一处理错误提示。
// src/utils/request.ts import axios from "axios"; import { ElMessage } from "element-plus"; const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, }); request.interceptors.response.use( (response) => response.data, (error) => { const status = error.response?.status; if (status === 422) { ElMessage.error("参数校验失败,请检查输入"); } else if (status >= 500) { ElMessage.error("服务器开小差了,请稍后再试"); } else { ElMessage.error(error.response?.data?.detail || "请求失败"); } return Promise.reject(error); } ); export default request;这样页面里请求接口就可以直接拿到后端返回的数据,不需要每次都在then里再剥一层response.data。错误统一提示也比每个页面各写一遍要清爽得多。
路由用 Vue Router 4,这里我列出核心路由配置:
import { createRouter, createWebHistory } from "vue-router"; const router = createRouter({ history: createWebHistory(), routes: [ { path: "/", name: "home", component: () => import("@/views/HomeView.vue") }, { path: "/product/:id", name: "product-detail", component: () => import("@/views/ProductDetail.vue") }, { path: "/cart", name: "cart", component: () => import("@/views/CartView.vue") }, { path: "/orders", name: "orders", component: () => import("@/views/OrderListView.vue") }, ], }); export default router;这里用了动态导入() => import(),Vite 构建时会自动做代码分割,首屏只加载首页对应的 JS 包,而不是把整个应用打包成一个巨大的文件。
3.3 页面落地:从商品列表到提交订单
商品列表页是用户进店看到的第一屏,我建议用卡片布局配合分类筛选和关键词搜索。核心逻辑是:用一个reactive对象保存查询参数,监听搜索和分页变化后重新请求接口。
<script setup lang="ts"> import { onMounted, reactive, ref } from "vue"; import { getProducts } from "@/api/product"; import type { Product } from "@/types"; const loading = ref(false); const products = ref<Product[]>([]); const total = ref(0); const query = reactive({ page: 1, pageSize: 12, keyword: "", categoryId: undefined as number | undefined, }); async function loadProducts() { loading.value = true; try { const data = await getProducts(query); products.value = data.items; total.value = data.total; } finally { loading.value = false; } } onMounted(loadProducts); </script>购物车状态用 Pinia 管理,这里是最能体现 Vue3 组合式 API 优势的地方。把购物车项、加入购物车、修改数量、清空购物车这些逻辑收进一个 store 里:
// src/stores/cart.ts import { defineStore } from "pinia"; import { ref, computed } from "vue"; import type { Product } from "@/types"; export const useCartStore = defineStore("cart", () => { const items = ref<{ product: Product; quantity: number }[]>([]); const totalAmount = computed(() => items.value.reduce((sum, item) => sum + item.product.price * item.quantity, 0) ); function addToCart(product: Product, quantity = 1) { const existing = items.value.find((item) => item.product.id === product.id); if (existing) { existing.quantity += quantity; } else { items.value.push({ product, quantity }); } } function removeFromCart(productId: number) { items.value = items.value.filter((item) => item.product.id !== productId); } return { items, totalAmount, addToCart, removeFromCart }; });这个设计思路的好处是,商品列表页、商品详情页、购物车页都能通过同一个 store 读写购物车,避免了组件间多层传参。而且因为是响应式数据,任意页面点击“加入购物车”,购物车页面里的角标数量会自动更新。
提交订单时,前端只需要把购物车里的商品快照和收货人信息发给后端:
const payload = { customer_name: form.name, customer_phone: form.phone, items: cart.items.map((item) => ({ product_id: item.product.id, quantity: item.quantity, })), }; await axios.post("/orders", payload);订单号、总价、明细这些后端会算好并落库,下单成功之后清空购物车、跳转到订单列表页,整个闭环就走通了。
4. 前后端联调与常见问题排查
4.1 CORS 跨域:前后端第一次握手就踩的坑
前后端分离项目第一次联调,百分之八十会遇到跨域问题。浏览器控制台报错里出现blocked by CORS policy或者Access-Control-Allow-Origin相关提示,就是跨域被拦截了。
原因很简单:前端跑在http://localhost:5173,后端跑在http://localhost:8000,端口不同,浏览器认为这是两个不同的源,出于安全策略会阻止前端读取后端的响应。解决办法也不是什么黑魔法,就是让后端明说“我允许 5173 这个来源访问我”。在 FastAPI 里加 CORS 中间件:
app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], )allow_origins里写的就是前端地址,如果部署后域名变了要同步修改。生产环境更稳妥的方式是用 Vite 的 proxy 方案,在vite.config.ts里做一层转发,让浏览器只看到同源的请求:
server: { proxy: { "/api": { target: "http://localhost:8000", changeOrigin: true, }, }, }这样前端请求/api/products时,Vite 开发服务器会帮忙转发给 8000 端口,前端代码里不需要写死后端地址,也基本不会触发 CORS。两种方案我都试过,开发阶段用 proxy 更省心,但要记得生产和开发的环境变量策略要分开。
4.2 数据格式不一致:字段命名与类型
联调时另一个高频问题来自命名风格。后端 Python 和数据库习惯用snake_case(比如customer_name、created_at),前端 JavaScript 和 TypeScript 社区习惯用camelCase(比如customerName、createdAt)。
FastAPI 的响应模型会自动把 ORM 字段名序列化为 snake_case,前端拿到后如果直接console.log(data)看字段名,一不留神就会写错。最简单粗暴的处理方式是全链路统一用 snake_case,前端定义接口类型时也按后端字段名写,虽然不符合 TS 社区习惯,但胜在直观、不用做转换。如果团队对命名要求严格,也可以在 Pydantic 模型里配置alias_generator统一输出 camelCase,但那样就要多套一层配置,学习阶段我不推荐。
字段类型也值得注意。SQLite 里price我用Float,前端计算总价时如果直接做浮点运算,可能出现0.1 + 0.2 = 0.30000000000000004这种精度问题。金额相关字段,更稳妥的做法是后端用整数存分,或者前端展示时用toFixed(2)格式化,计算总价时先乘 100 取整再除 100。这个细节做过一次电商项目的人基本都会强调。
4.3 常见问题速查表
我把这个项目踩过的一些典型问题整理成了一张速查表,方便对照排查:
| 问题现象 | 可能原因 | 排查思路与解决办法 |
|---|---|---|
| 页面请求接口报 404 | 前端 baseURL 和后端路由前缀不一致 | 检查.env里的VITE_API_BASE_URL是否包含/api,后端路由 prefix 是否也是/api |
| 所有接口都报 CORS 错误 | 后端没有配置跨域中间件,或来源不匹配 | 在 FastAPI 添加 CORSMiddleware,确认allow_origins里包含前端完整地址 |
| 接口报 422 Unprocessable Entity | 请求参数缺失、类型不对或超出校验范围 | 看 FastAPI 文档页的请求体说明,对照字段名和类型修改前端传参 |
| 能启动项目但查询列表为空 | 数据库里没有种子数据,或is_active字段为 False | 先写一段种子数据脚本,把分类和商品插入数据库再调试接口 |
| 创建订单时报外键约束错误 | 商品 ID 不存在,或事务中没有正确写入明细 | 确认购物车里的商品没有被后台下架,锁定库存后重新提交 |
| 修改后端代码后不生效 | 没有加--reload参数重启 | 开发时用uvicorn app.main:app --reload启动,代码变更自动重载 |
| 前端页面刷新后路由 404 | 使用了 history 模式但服务器没有做 fallback | 本地开发改用 Vite proxy 时配置historyApiFallback;生产环境让 Nginx 把非静态文件请求都指回index.html |
这些问题的共同点在于,报错信息其实都已经告诉了你方向,只是新手容易被一大段英文吓住。排查任何问题时,先看浏览器 Network 面板里请求的 URL、状态码和响应体,再配合后端控制台的异常堆栈,基本能定位九成问题。
5. 项目还能怎么扩展 + 我的体会
5.1 从教学项目到生产系统的路径
这套蛋糕店系统做完,并不是终点,而是一个起点。我列几个比较推荐的扩展方向,你可以按自己的兴趣选择:
第一个是加用户体系和权限控制。现在下单不需要登录,真实场景肯定要有用户注册、登录、JWT 鉴权。FastAPI 生态里有python-jose和passlib可以做 token 签发和密码哈希,前端登录后把 token 存起来,在 axios 拦截器里统一加上Authorization头,就能把我的项目升级成带用户中心的系统。
第二个是加后台管理界面。目前商品数据是数据库直接插入的,后端可以加一组/admin开头的管理接口,前端做一套简单的后台页面,支持商品的新增、编辑、上下架,订单状态从“待付款”改成“已完成”这类操作。做完这一块,你对 RBAC、表单校验、表格操作的理解会再上一个台阶。
第三个是加部署。把后端用 Docker 打包,前端构建后交给 Nginx,再用 Docker Compose 一键启动整套服务。部署过程中你会碰到静态文件路径、环境变量注入、数据库迁移这些只能在真实环境里学到的问题。
第四个是接入支付或第三方通知,比如对接模拟支付、发送下单成功短信通知。这些属于锦上添花,但能让你体会到真实业务系统的复杂性。
5.2 我做完这套项目后的真实体会
最后说点我个人的想法。写这套系统的过程中,我最大的感受是:FastAPI 和 Vue3 这两个技术栈,天生适合做“边学边做”的组合。FastAPI 的自动文档功能让前端拿接口时不用反复问后端“这个参数是什么意思”;Vue3 的组合式 API 让逻辑复用变得很简单,一个购物车 store 写清楚,整个项目都用得爽。
对刚开始学全栈的朋友,我的建议是不要贪多,先把商品浏览到下单这一条主链路跑通。一个能完整跑起来的“小系统”,比一堆零散的知识点有用得多。代码里有任何报错都是正常的,按上面速查表的方法一步步排查,跑通的那一刻你会对整个前后端协作有非常直观的理解。接下来再逐步加用户、加后台、加部署,你会发现原来开发一个线上项目并没有想象中的那么神秘。