做电商系统这两年,我写过通用商城、二手交易,也在不同框架之间来回折腾过。这次想聊的东西比较有代表性:Python + Flask + Vue 的动漫周边商城系统。为什么选这个技术组合?因为它恰好踩在“轻量、好上手、能完整串联一门 Web 技术栈”这三个点上。Flask 结构不像 Django 那么重,完全可以手工搭建 API 层;Vue 的前端生态成熟,页面组件化之后,商品列表、详情、购物车、订单这些页面都能拆得很干净。项目场景选的是动漫周边,手办、徽章、立牌、抱枕、T恤这类商品,用户画像清晰,交易链路也不长,非常适合作为前后端分离应用的完整实践。
这个系统解决的核心问题,简单说就是:让用户能注册、登录、浏览商品、加入购物车并下单,让管理员能维护商品和订单状态,同时把 Python 后端和 Vue 前端从开发到部署这条链路真正跑通。适合谁参考?正在做毕设、求职作品,或者想从零学起前后端分离架构的开发者,都可以拿这套完整流程当模板。
1. 项目整体架构与功能边界拆解
1.1 为什么是 Flask + Vue,而不是全栈一把梭
我见过很多类似的商城项目,有人直接用 Flask 的 Jinja2 模板渲染页面,配一点 jQuery 就完事;也有人一上来就上 Spring Boot + Vue,或者 Go + Next.js。说实话,不同方案各有道理,但就“动漫周边商城”这个场景来说,Flask + Vue 的前后端分离方案是最平衡的选择。
先说 Flask。它非常轻,你可以只用它写 API,不需要像 Django 那样带 Admin、Form、Auth 一大套东西。对于一个小型商城,剩下的模块越多,改起来越费劲。Flask 的另一个好处是“透明度高”:请求进来走哪个函数、中间件怎么挂、JWT 怎么校验,都是你亲手控制的。这对理解 HTTP 协议和 Web 开发的基本链路非常有帮助。等你把 Flask API 写明白,以后再去用 FastAPI、Django REST Framework 都是顺水推舟的事。
再说 Vue。Vue 3 的组合式 API 写起来更适应当前开发习惯,配合 Vue Router 和 Pinia 做页面路由和状态管理,已经是一套很成熟的前端基座。跟 React 相比,Vue 的模板语法更接近 HTML 自然语义,学习曲线要平缓一些,对于以 Python 为主、想顺手补齐前端技能的人来说更友好。
我特意把“前后端分离”列为关键点,而不只是“能用就行”。分离的好处很实际:商品列表页的交互、购物车的角标、订单状态的变化,都交给前端组件处理;后端只返回 JSON 数据。两边可以并行开发,逻辑互不干扰。缺点是部署的时候要比服务端渲染多考虑一点:跨域、代理、静态资源托管。这些坑后面我会专门讲,其实摸清套路之后五分钟就能配好。
1.2 动漫周边商城的业务边界与页面规划
做项目最怕一上来就堆功能。动漫周边商城看起来像“普通电商换了个皮肤”,但它的商品形态有自己的特点:SKU 相对简单,不像服装有尺码颜色组合,下单时主要盯库存;商品图片的展示要求高,手办、徽章这类商品的美观度直接决定转化;用户群体偏年轻,注册登录、收藏、购物车、订单跟踪这些基础体验必须顺滑。
基于这些特点,我把业务边界划成这样:
- 用户端功能:注册登录、商品列表与分类筛选、关键词搜索、商品详情、购物车管理、提交订单、查看订单列表与订单详情。
- 管理员功能:商品新增/编辑/删除、上下架、库存修改;订单列表、订单状态流转(待发货、已发货、已完成、已取消)。
- 明确不做的第一期功能:真实支付通道(只预留支付状态字段)、优惠券满减、秒杀活动、复杂推荐算法。
为什么这么划?核心逻辑是先把交易闭环走通。支付接口如果一开始就去对接,申请商户号、签名、异步回调这些事会分散大量精力;优惠券和秒杀放在商城主流程没验证好的阶段,只会让库存和订单逻辑雪上加霜。把边界划清楚,不是偷懒,是让项目有可交付的版本。
页面规划上,用户端我建议做这些页面:首页(商品卡片流 + 分类 Tab)、商品详情页(图片、价格、库存、购买数量)、登录注册页、购物车页、订单列表页、订单详情页。管理员端做两个核心页面:商品管理页和订单管理页,每个页面配一个独立的 Vue 路由文件,后续再扩展也不乱。
1.3 前后端项目结构如何划分
真实的本地开发环境里,前端和后端最好放在同一个项目根目录下,但保持两个独立子目录。我习惯的结构是这样的:
anime-shop/ ├── backend/ │ ├── app.py # Flask 入口 │ ├── extensions.py # db、jwt、cors 等扩展实例 │ ├── models.py # 数据表模型 │ ├── routes/ │ │ ├── auth.py # 注册登录 │ │ ├── products.py # 商品查询 │ │ ├── cart.py # 购物车 │ │ ├── orders.py # 订单 │ │ └── admin.py # 管理端接口 │ ├── uploads/ # 商品图片 │ ├── requirements.txt │ └── .env └── frontend/ ├── src/ │ ├── api/ # axios 封装 │ ├── router/ # 路由表 │ ├── stores/ # Pinia 状态 │ ├── views/ # 页面组件 │ ├── components/ # 通用组件 │ └── assets/ ├── vite.config.js └── package.json这种分层的收益是长期的。后端按业务模块拆路由,而不是把所有接口堆在 app.py 里,否则项目到 20 个接口以后就变成一坨。前端每个页面一个 view、每个 view 只负责自己这一屏的展示逻辑,公共的请求逻辑全部收敛到 api 目录。别人拿到你的项目,第一眼就知道代码该往哪里加,这种“可维护性”比任何花哨技巧都值钱。
2. 数据库设计与接口规范
2.1 六张核心表怎么设计才抗用
商城系统表不用太多,核心就是六张:用户表、分类表、商品表、购物车表、订单表、订单明细表。我把关键字段和设计意图写一下。
用户表 users
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| username | varchar(50) | 用户名,唯一 |
| password_hash | varchar(255) | 密码哈希,绝不存明文 |
| nickname | varchar(50) | 昵称 |
| avatar | varchar(255) | 头像地址 |
| role | varchar(20) | user / admin |
| created_at | datetime | 注册时间 |
分类表 categories:id、name、sort_order。动漫周边可以分到手办、徽章吧唧、立牌、挂件、毛绒周边、T恤卫衣等,分类数量少,一张表足够。
商品表 products:id、title、description、cover_image、images(JSON 字符串存多图)、price、stock、sales、status(1上架 2下架)、category_id、created_at。注意两个点:images我用 JSON 字符串存多张图,SQLite 和 MySQL 都支持,比单独建一张图片表省事;sales字段用来做“销量排序”。
购物车表 cart_items:id、user_id、product_id、quantity、checked、created_at。加checked字段是为了支持“勾选某几项去结算”,另一种方案是订单接口里再传商品列表,两种都可以,但我实测下来前端维护勾选状态更自然。
订单表 orders:id、order_no、user_id、total_price、status、receiver_name、receiver_phone、receiver_address、created_at。order_no是业务单号,用时间戳加随机数生成,对外展示和后续对账都用它,不用自增主键当单号。
订单明细表 order_items:id、order_id、product_id、product_title、product_image、price、quantity。这里有一个新手容易忽略的设计:下单时把商品标题、图片、单价都冗余一份到明细表。因为商品可能改价、改名甚至被下架删除,如果订单只关联 product_id,历史订单就很可能变成“查得到但显示不出来”的残缺数据。订单是交易快照,不是实时状态,这个原则在电商里很关键。
关于外键,我的实战习惯是不建数据库级外键,只用逻辑字段关联。原因是 SQLite 和 MySQL 之间切换时省事,应用层通过事务保证一致性,很多公司的生产库也是这么处理的。当然,如果你更看重数据库层约束,加上也没有问题,这只是风格差异。
2.2 RESTful 接口清单与统一返回结构
接口设计我全部走 RESTful 风格,路径里带上版本号/api/v1。这样以后接口大改,Vue 端可以先接新版本,而不是改一套崩一套。核心接口清单如下:
| 方法 | 路径 | 功能 | 权限 |
|---|---|---|---|
| POST | /api/v1/auth/register | 注册 | 公开 |
| POST | /api/v1/auth/login | 登录 | 公开 |
| GET | /api/v1/products | 商品列表 | 公开 |
| GET | /api/v1/products/ | 商品详情 | 公开 |
| POST | /api/v1/cart/items | 加入购物车 | 登录用户 |
| GET | /api/v1/cart/items | 获取购物车 | 登录用户 |
| PUT | /api/v1/cart/items/ | 修改数量/勾选 | 登录用户 |
| DELETE | /api/v1/cart/items/ | 删除购物车项 | 登录用户 |
| POST | /api/v1/orders | 提交订单 | 登录用户 |
| GET | /api/v1/orders | 我的订单列表 | 登录用户 |
| GET | /api/v1/orders/ | 订单详情 | 登录用户 |
| POST | /api/v1/admin/products | 新增商品 | 管理员 |
| PUT | /api/v1/admin/products/ | 编辑商品 | 管理员 |
| DELETE | /api/v1/admin/products/ | 删除商品 | 管理员 |
| PUT | /api/v1/admin/orders/ | 订单状态流转 | 管理员 |
统一返回结构我踩过不统一的坑,后来给自己定了一个规矩:所有接口返回{code, message, data}三件套。code=0表示成功,非 0 表示业务错误;HTTP 状态码保持语义正确(200、201、400、401、403、404、500)。分页接口的data统一是:
{ "list": [...], "total": 100, "page": 1, "page_size": 12 }为什么非要统一?因为前端 axios 拦截器里只要判断code === 0就能走成功分支,异常统一弹提示。如果每个接口返回结构都随心所欲,联调时每天都会遇到“这里返回的到底是列表还是对象”的沟通成本,非常磨人。
2.3 JWT 认证、管理员权限与越权防护
前后端分离项目里,我推荐用 JWT 而不是传统 Session。Flask 这边用flask-jwt-extended,登录成功签发 token,前端请求时放在Authorization: Bearer <token>头里。JWT 的好处是天然无状态,后端不需要存会话表,扩容也简单;缺点是 token 泄露后处理起来麻烦,所以SECRET_KEY一定放环境变量,别提交到 git。
权限控制用一个自定义装饰器就能覆盖大部分场景。判定的顺序是:先验 token,再查用户,再校验角色。
from functools import wraps from flask_jwt_extended import jwt_required, get_jwt_identity from flask import jsonify def admin_required(fn): @wraps(fn) @jwt_required() def wrapper(*args, **kwargs): user_id = get_jwt_identity() user = User.query.get(user_id) if not user or user.role != "admin": return jsonify(code=403, message="需要管理员权限"), 403 return fn(*args, **kwargs) return wrapper水平越权是我要单独强调的点。所谓水平越权,就是普通用户访问了自己不该访问的数据。比如订单详情接口,如果你只写了order = Order.query.get(id),那用户 A 只要改 URL 里的订单 id,就能看到用户 B 的订单信息。防护方法很简单,查询时强制加上用户维度:
order = Order.query.filter_by(id=order_id, user_id=current_user_id).first_or_404()这两行代码能挡掉绝大多数越权漏洞,属于写接口时必须养成的手感。
3. 后端核心逻辑与前端页面落地实操
3.1 环境初始化与依赖选择
先说后端。Python 版本我建议 3.10 或更高,创建虚拟环境是第一步:
cd backend python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate依赖文件 requirements.txt 我给一个稳妥版本:
flask==3.0.3 flask-sqlalchemy==3.1.1 flask-cors==4.0.1 flask-jwt-extended==4.6.0 pillow==10.4.0 python-dotenv==1.0.1flask-sqlalchemy负责 ORM,flask-cors解决跨域,flask-jwt-extended处理认证,pillow在商品图片上传时做尺寸校验和缩略图。SQLite 起步时不需要额外装数据库,SQLAlchemy 连接串直接写sqlite:///anime_shop.db就行。
前端用 Vite 创建 Vue 3 项目:
npm create vite@latest frontend -- --template vue cd frontend npm install vue-router@4 pinia axios组件库我建议暂时不引,先自己写基础样式。商城这种页面结构不复杂,手写样式反而能保证视觉统一;如果一味引入 Element Plus,光是覆盖默认样式就可能花掉一晚上。等你觉得管理端表格确实太费劲,再引组件库不迟。
3.2 后端关键接口逐段拆解
挑几个最容易写错的接口展开讲。
注册与登录:密码必须哈希
密码用werkzeug.security的哈希函数处理,这是 Flask 自带的,不需要额外装库。
from werkzeug.security import generate_password_hash, check_password_hash # 注册 user = User(username=username, password_hash=generate_password_hash(password)) db.session.add(user) db.session.commit() # 登录校验 if not check_password_hash(user.password_hash, password): return jsonify(code=400, message="用户名或密码错误"), 400我见过直接把明文密码存进数据库的“快捷实现”,强烈不建议。这不是危言耸听,任何项目只要涉及用户数据,密码哈希就是底线。
商品列表:分页、筛选、排序一次问清楚
商品列表接口是前端首页的主数据源,我写成支持多参数查询:
@app.get("/api/v1/products") def get_products(): page = request.args.get("page", 1, type=int) page_size = request.args.get("page_size", 12, type=int) category_id = request.args.get("category_id", type=int) keyword = request.args.get("keyword", "", type=str).strip() sort = request.args.get("sort", "default", type=str) query = Product.query.filter_by(status=1) if category_id: query = query.filter_by(category_id=category_id) if keyword: query = query.filter(Product.title.contains(keyword)) if sort == "price_asc": query = query.order_by(Product.price.asc()) elif sort == "price_desc": query = query.order_by(Product.price.desc()) elif sort == "sales": query = query.order_by(Product.sales.desc()) else: query = query.order_by(Product.created_at.desc()) total = query.count() products = query.offset((page - 1) * page_size).limit(page_size).all() return jsonify(code=0, message="success", data={"list": [p.to_dict() for p in products], "total": total, "page": page, "page_size": page_size})这里Product.title.contains(keyword)在底层会被翻译成LIKE '%keyword%'。对于个人项目这个方案足够,但产品数据到几万条之后,LIKE 查询会变慢,也没法利用索引。后续优化方向可以考虑给商品表加一个“搜索标签”字段,用jieba分词后存关键词,甚至接 Elasticsearch,那就是另一套工程了。
购物车:存在即加数量,但要校验库存
加入购物车的逻辑很简单,但有个决策点:同一商品重复点击,是新增一条记录还是累加数量?我选累加。实现时先查记录,存在就数量加 1,不存在就新建。
item = CartItem.query.filter_by(user_id=current_user_id, product_id=product_id).first() if item: item.quantity += 1 else: item = CartItem(user_id=current_user_id, product_id=product_id, quantity=1, checked=True) db.session.add(item) db.session.commit()库存校验放在加购环节做一次,下单时再做一次。加购时如果库存已经为 0,直接提示“商品已售罄”;如果数量超过库存,就卡在最大库存。前端虽然也可以判断,但永远不要信任前端传来的数据。
下单:事务保证订单和库存一致性
下单是整个系统最核心的接口,我直接给出关键代码逻辑:
def create_order(user_id, cart_item_ids): # 生成订单号 order_no = datetime.now().strftime("%Y%m%d%H%M%S") + str(random.randint(1000, 9999)) items = CartItem.query.filter( CartItem.id.in_(cart_item_ids), CartItem.user_id == user_id ).all() if not items: raise ValueError("没有选中任何商品") total_price = 0 order_items = [] try: for item in items: product = Product.query.filter_by(id=item.product_id, status=1).first() if not product or product.stock < item.quantity: raise ValueError(f"「{product.title if product else '商品'}」库存不足") # 条件更新,确保原子扣减 result = Product.query.filter( Product.id == product.id, Product.stock >= item.quantity ).update({ Product.stock: Product.stock - item.quantity, Product.sales: Product.sales + item.quantity }) if result == 0: raise ValueError("库存不足") total_price += product.price * item.quantity order_items.append(OrderItem( product_id=product.id, product_title=product.title, product_image=product.cover_image, price=product.price, quantity=item.quantity )) order = Order( order_no=order_no, user_id=user_id, total_price=total_price, status="待发货" ) db.session.add(order) db.session.flush() # 拿到 order.id 后再写明细 for oi in order_items: oi.order_id = order.id db.session.add(oi) # 清掉已下单的购物车项 for item in items: db.session.delete(item) db.session.commit() return order except Exception: db.session.rollback() raise这里有几个细节值得说:第一,库存扣减用update加filter条件,而不是“先查后改”,因为多个请求同时进来时“先查后改”会出现超卖;第二,db.session.flush()的作用是先让订单拿到自增主键,再写明细,不然关联不到 order_id;第三,一旦中途任何一步出错,整体 rollback,不会出现“订单建了库存却扣失败”的中间状态。
3.3 前端路由、axios 封装与页面组件分工
前端路由我用 Vue Router 4,商品详情页通过动态参数传递商品 id:
const routes = [ { path: '/', component: HomeView }, { path: '/product/:id', component: ProductDetailView }, { path: '/cart', component: CartView }, { path: '/orders', component: OrderListView }, { path: '/login', component: LoginView }, { path: '/admin/products', component: AdminProductsView, meta: { admin: true } }, { path: '/admin/orders', component: AdminOrdersView, meta: { admin: true } } ]axios 封装是前端联调的关键。我统一在src/api/request.js里做实例,请求拦截器自动带 token,响应拦截器处理统一业务码:
import axios from 'axios' const api = axios.create({ baseURL: '/api/v1', timeout: 10000 }) api.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) api.interceptors.response.use( res => { if (res.data.code === 0) return res.data.data return Promise.reject(new Error(res.data.message)) }, err => { if (err.response && err.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(err) } )页面组件按“页面视图 + 通用组件”来分。商品卡片、数量选择器、空状态提示这些可以抽成组件;商品列表页的交互重点在分类 Tab 切换、加载更多或分页、加入购物车的即时反馈。状态管理我用 Pinia,购物车角标这种跨页面共享状态放到 store 里,比每个页面单独拉接口更平滑。
3.4 本地联调、前后端代理与生产部署
开发阶段最大的痛点是“前端跑 5173 端口,后端跑 5000 端口,跨域怎么办”。我的首选方案是 Vite 代理,而不是在后端强制开 CORS。在 vite.config.js 里加:
export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': 'http://localhost:5000', '/uploads': 'http://localhost:5000' } } })这样前端代码里所有请求都写相对路径/api/v1/...,由 Vite 开发服务器转发到后端,浏览器看到的请求始终是同源的,CORS 基本可以不管。后端仍然需要注册flask-cors,因为有时候你会直接用 Postman 或手机访问 5000 端口,留一份保险不是坏事。
生产部署我推荐 Nginx 托管前端静态文件,反向代理后端接口。前端先npm run build产出dist目录,Nginx 配置核心就两段:
location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:5000; }第一行的try_files专门解决 Vue history 路由刷新 404 的问题,第二行把 API 请求转发给 Flask。到现在为止,这个系统就具备一个“前端静态资源 + 后端 API 服务”的完整部署形态了。
4. 联调部署与常见坑排查实录
4.1 跨域问题的两种解决路径
现象很经典:前端控制台报Access to XMLHttpRequest at 'http://localhost:5000/api/v1/products' from origin 'http://localhost:5173' has been blocked by CORS policy。原因就是浏览器同源策略,前端 5173 和后端 5000 端口不同源。我推荐的解决方式是上面说的 Vite 代理,开发时根本不让浏览器直连后端;如果你坚持前端直连后端,那就必须开flask-cors并配置允许来源。代理方案能替你省掉至少 80% 的跨域调试时间。
4.2 图片上传成功但页面 404
这个坑我踩过一次,当时上传接口返回了图片路径/uploads/xxx.jpg,但前端页面访问 404。排查下来发现,Vite 代理只配了/api,没配/uploads,图片请求直接打到了前端开发服务器,自然找不到。解决方法是把/uploads也加进代理配置,或者在后端返回完整的图片 URL。如果部署到 Nginx,也要在静态文件 location 里指到 Flask 的 uploads 目录。
4.3 并发下单导致库存超卖
这是商城系统的高频问题。现象是库存只剩 5 件,但同一下单接口被并发调用时卖出去 8 件。原因是很多人的第一版写法是“先查库存,再扣减”,两个请求同时查到 stock=5,都认为可以下单,然后各自减 1 写回,库存变 4 而不是 3。解决办法就是我前面写的条件更新:UPDATE products SET stock = stock - 1 WHERE id = ? AND stock >= 1,数据库原子地判断库存足够才更新,影响行数为 0 就说明库存不足。SQLite 单机写并发不高也能靠这个兜住大部分超卖场景,生产环境再换 MySQL 加行锁,链路就完整了。
4.4 中文乱码与 JSON 转义
SQLite 很少遇到乱码,但切到 MySQL 时常见。建库时要明确字符集:
CREATE DATABASE anime_shop CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;连接串里同样带charset=utf8mb4。还有一个 Python 后端特有的坑:Flask 返回 JSON 时,中文默认被转成\uXXXX,浏览器能正常解析成中文,但数据抓包时看着全是转义字符,很影响排查。Flask 3.x 里在初始化后设置app.json.ensure_ascii = False,返回的就是可读中文。这个属于小细节,但能省不少联调误会。
4.5 前端刷新 404 与路由模式
部署到服务器之后,用户点进商品详情页再按 F5,结果 404。这是因为前端用的是 history 路由模式,路径/product/123在服务器上没有真实文件,Nginx 默认找不到就返回 404。解决办法就是 Nginx 的try_files $uri $uri/ /index.html;,把所有前端路径都回退到入口文件,由 Vue Router 自己接管。如果你不想折腾服务器配置,可以直接用createWebHashHistory,路径变成/#/product/123,刷新不会 404,代价是 URL 不那么好看。个人经验:自己做项目用 hash 路由最省事,要正式上线的项目再上 history + Nginx。
4.6 SQLite 写锁与上线前注意事项
SQLite 在个人项目里很顺手,但有个硬限制:同一时刻只允许一个连接写数据库。一旦出现并发下单,控制台可能频繁报database is locked。开发阶段一般感受不到,压测或者本地多开页面就容易触发。缓解办法是连接串加参数?timeout=5,让写入等待 5 秒而不是立即报错;真正的解决路径是上线前切到 MySQL 或 PostgreSQL。我的经验是:毕设、作品集、几十上百人用的内部小商城,SQLite 完全够用;只要预期有真实用户并发交易,就老实换数据库,别拿 SQLite 顶生产。
4.7 JWT 过期、401 统一处理与安全习惯
JWT 的过期时间我设置成 24 小时,前端 axios 拦截器里已经把 401 统一处理成“清除 token 并跳转登录页”,但现实里还有一个常见问题:token 还没过期,用户刷新页面后 localStorage 里有 token,但用户信息状态没恢复,页面有一瞬间显示“未登录”。解决方式是在路由守卫里做一次“带 token 拉取当前用户信息”的动作,拉取成功就放行,失败再清 token 跳登录。这个小处理能明显提升体验。
安全习惯方面,最后补几句:SECRET_KEY必须随机生成并放在环境变量里;不要把任何私密配置提交到 git;登录接口要做频率限制;不要用render_template_string直接拼接用户输入,免得埋下模板注入风险。这些不是空话,是每一个上线项目都该有的底线。
把整套流程走完一遍之后,我个人最大的体会是,这类商城系统的难点从来不在“某个函数怎么写”,而在设计阶段有没有把边界想清楚:订单明细要不要存商品快照,库存扣减是不是原子的,接口返回结构是不是统一的,越权校验有没有做全。这些问题想明白,写代码其实只是体力活。另一个很实在的建议是,第一版一定控制功能范围,把浏览、购物车、下单、订单管理这条主链路打磨顺,再考虑支付、优惠券和推荐。主流程不飘,后面什么功能都好加。我自己的下一步计划是给这套系统接一个真实支付沙箱,再把商品初始化数据用爬虫从公开渠道整理一部分图片和描述,但抓取的时候一定要注意版权和平台规则,正规渠道的素材才用得安心。希望这套从设计到部署的完整拆解,能让你少走点我走过的弯路。