ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FastAPI+Vue3蛋糕店全栈实战:从商品列表到订单闭环

FastAPI+Vue3蛋糕店全栈实战:从商品列表到订单闭环 做开发这些年我反复跟想入全栈的朋友说一个思路不要跟着纯语法教程一行行敲得找一个“看起来不大、但五脏俱全”的真实业务系统从需求到上线完整做一遍。今天要分享的项目就是这样一个适合完整走一遍的实战素材——基于 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 如今已经是非常成熟的主流版本了组合式 APIComposition 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_keyTrue, indexTrue) name: Mapped[str] mapped_column(String(50), uniqueTrue, indexTrue) products: Mapped[list[Product]] relationship(back_populatescategory) class Product(Base): __tablename__ products id: Mapped[int] mapped_column(primary_keyTrue, indexTrue) name: Mapped[str] mapped_column(String(100), indexTrue) description: Mapped[str | None] mapped_column(Text) price: Mapped[float] mapped_column(Float) stock: Mapped[int] mapped_column(Integer, default0) image_url: Mapped[str | None] mapped_column(String(255)) category_id: Mapped[int] mapped_column(ForeignKey(categories.id)) is_active: Mapped[bool] mapped_column(defaultTrue) created_at: Mapped[datetime] mapped_column(DateTime, defaultdatetime.now) category: Mapped[Category] relationship(back_populatesproducts) class Order(Base): __tablename__ orders id: Mapped[int] mapped_column(primary_keyTrue) order_no: Mapped[str] mapped_column(String(32), uniqueTrue, indexTrue) 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), defaultpending) created_at: Mapped[datetime] mapped_column(DateTime, defaultdatetime.now) items: Mapped[list[OrderItem]] relationship(back_populatesorder) class OrderItem(Base): __tablename__ order_items id: Mapped[int] mapped_column(primary_keyTrue) 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_populatesitems)这里有几个建模时容易忽略的点。第一订单明细表里我冗余了product_name和price这不是多余的因为商品名称和价格后续可能会改如果订单明细只存product_id历史订单显示就会错乱。第二订单号和手机号这种高频查询字段都加了索引数据量大了之后查询性能差距非常明显。第三用relationship建立 ORM 关系时前端查询订单接口可以很方便地把明细一起带出来不用手动拼数据。数据库连接和建表逻辑放在database.pyfrom 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(bindengine, autoflushFalse, autocommitFalse) def init_db(): Base.metadata.create_all(bindengine) 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_modelProductList) def list_products( page: int Query(1, ge1), page_size: int Query(12, ge1, le100), 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, ge1)的含义是默认值为 1并且必须大于等于 1如果前端传一个page-1FastAPI 会直接返回 422 校验错误根本不会向下执行。这就是类型标注带来的好处你写一次校验规则接口文档和运行时校验同时生效。对应的 Pydantic schema 长这样from pydantic import BaseModel, ConfigDict class ProductOut(BaseModel): model_config ConfigDict(from_attributesTrue) 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_attributesTrue允许直接从 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(titlesettings.app_name) app.add_middleware( CORSMiddleware, allow_origins[http://localhost:5173], allow_credentialsTrue, 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 vitelatest cake-shop-frontend -- --template vue-ts cd cake-shop-frontend npm install npm install element-plus axios vue-router4 piniaVue3 搭配 TypeScript 是当前的主流选择虽然新手会觉得类型定义有点麻烦但配合编辑器提示开发效率其实比纯 JS 更高。项目装完依赖之后我会把.env.development和.env.production两个环境变量文件建好# .env.development VITE_API_BASE_URLhttp://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 langts import { onMounted, reactive, ref } from vue; import { getProducts } from /api/product; import type { Product } from /types; const loading ref(false); const products refProduct[]([]); 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_credentialsTrue, 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 写清楚整个项目都用得爽。对刚开始学全栈的朋友我的建议是不要贪多先把商品浏览到下单这一条主链路跑通。一个能完整跑起来的“小系统”比一堆零散的知识点有用得多。代码里有任何报错都是正常的按上面速查表的方法一步步排查跑通的那一刻你会对整个前后端协作有非常直观的理解。接下来再逐步加用户、加后台、加部署你会发现原来开发一个线上项目并没有想象中的那么神秘。
返回列表