
做电商系统这两年我写过通用商城、二手交易也在不同框架之间来回折腾过。这次想聊的东西比较有代表性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字段类型说明idint主键usernamevarchar(50)用户名唯一password_hashvarchar(255)密码哈希绝不存明文nicknamevarchar(50)昵称avatarvarchar(255)头像地址rolevarchar(20)user / admincreated_atdatetime注册时间分类表 categoriesid、name、sort_order。动漫周边可以分到手办、徽章吧唧、立牌、挂件、毛绒周边、T恤卫衣等分类数量少一张表足够。商品表 productsid、title、description、cover_image、imagesJSON 字符串存多图、price、stock、sales、status1上架 2下架、category_id、created_at。注意两个点images我用 JSON 字符串存多张图SQLite 和 MySQL 都支持比单独建一张图片表省事sales字段用来做“销量排序”。购物车表 cart_itemsid、user_id、product_id、quantity、checked、created_at。加checked字段是为了支持“勾选某几项去结算”另一种方案是订单接口里再传商品列表两种都可以但我实测下来前端维护勾选状态更自然。订单表 ordersid、order_no、user_id、total_price、status、receiver_name、receiver_phone、receiver_address、created_at。order_no是业务单号用时间戳加随机数生成对外展示和后续对账都用它不用自增主键当单号。订单明细表 order_itemsid、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}三件套。code0表示成功非 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(code403, message需要管理员权限), 403 return fn(*args, **kwargs) return wrapper水平越权是我要单独强调的点。所谓水平越权就是普通用户访问了自己不该访问的数据。比如订单详情接口如果你只写了order Order.query.get(id)那用户 A 只要改 URL 里的订单 id就能看到用户 B 的订单信息。防护方法很简单查询时强制加上用户维度order Order.query.filter_by(idorder_id, user_idcurrent_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 我给一个稳妥版本flask3.0.3 flask-sqlalchemy3.1.1 flask-cors4.0.1 flask-jwt-extended4.6.0 pillow10.4.0 python-dotenv1.0.1flask-sqlalchemy负责 ORMflask-cors解决跨域flask-jwt-extended处理认证pillow在商品图片上传时做尺寸校验和缩略图。SQLite 起步时不需要额外装数据库SQLAlchemy 连接串直接写sqlite:///anime_shop.db就行。前端用 Vite 创建 Vue 3 项目npm create vitelatest frontend -- --template vue cd frontend npm install vue-router4 pinia axios组件库我建议暂时不引先自己写基础样式。商城这种页面结构不复杂手写样式反而能保证视觉统一如果一味引入 Element Plus光是覆盖默认样式就可能花掉一晚上。等你觉得管理端表格确实太费劲再引组件库不迟。3.2 后端关键接口逐段拆解挑几个最容易写错的接口展开讲。注册与登录密码必须哈希密码用werkzeug.security的哈希函数处理这是 Flask 自带的不需要额外装库。from werkzeug.security import generate_password_hash, check_password_hash # 注册 user User(usernameusername, password_hashgenerate_password_hash(password)) db.session.add(user) db.session.commit() # 登录校验 if not check_password_hash(user.password_hash, password): return jsonify(code400, message用户名或密码错误), 400我见过直接把明文密码存进数据库的“快捷实现”强烈不建议。这不是危言耸听任何项目只要涉及用户数据密码哈希就是底线。商品列表分页、筛选、排序一次问清楚商品列表接口是前端首页的主数据源我写成支持多参数查询app.get(/api/v1/products) def get_products(): page request.args.get(page, 1, typeint) page_size request.args.get(page_size, 12, typeint) category_id request.args.get(category_id, typeint) keyword request.args.get(keyword, , typestr).strip() sort request.args.get(sort, default, typestr) query Product.query.filter_by(status1) if category_id: query query.filter_by(category_idcategory_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(code0, messagesuccess, 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_idcurrent_user_id, product_idproduct_id).first() if item: item.quantity 1 else: item CartItem(user_idcurrent_user_id, product_idproduct_id, quantity1, checkedTrue) 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(iditem.product_id, status1).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_idproduct.id, product_titleproduct.title, product_imageproduct.cover_image, priceproduct.price, quantityitem.quantity )) order Order( order_noorder_no, user_iduser_id, total_pricetotal_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商品详情页通过动态参数传递商品 idconst 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 件。原因是很多人的第一版写法是“先查库存再扣减”两个请求同时查到 stock5都认为可以下单然后各自减 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;连接串里同样带charsetutf8mb4。还有一个 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。开发阶段一般感受不到压测或者本地多开页面就容易触发。缓解办法是连接串加参数?timeout5让写入等待 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直接拼接用户输入免得埋下模板注入风险。这些不是空话是每一个上线项目都该有的底线。把整套流程走完一遍之后我个人最大的体会是这类商城系统的难点从来不在“某个函数怎么写”而在设计阶段有没有把边界想清楚订单明细要不要存商品快照库存扣减是不是原子的接口返回结构是不是统一的越权校验有没有做全。这些问题想明白写代码其实只是体力活。另一个很实在的建议是第一版一定控制功能范围把浏览、购物车、下单、订单管理这条主链路打磨顺再考虑支付、优惠券和推荐。主流程不飘后面什么功能都好加。我自己的下一步计划是给这套系统接一个真实支付沙箱再把商品初始化数据用爬虫从公开渠道整理一部分图片和描述但抓取的时候一定要注意版权和平台规则正规渠道的素材才用得安心。希望这套从设计到部署的完整拆解能让你少走点我走过的弯路。