☰
Flask+Vue搭建绿植商城:从商品建模到库存扣减的避坑指南
2026/9/30 5:01:06 网站建设 项目流程

做植物绿植盆景销售商城这个项目的念头,最初是身边一个开花店的朋友提的。他线下的生意不错,但线上除了朋友圈发图,几乎没有像样的销售渠道。我想着正好把这几年用 Python 和 Vue 做 Web 项目的经验落到实际,就用 Python + Flask + Vue 这套技术组合,给他做一个能挂在服务器上的绿植盆景商城管理系统。

做完回头看,这个项目属于典型的"看起来简单,做起来琐碎"。商品规格、购物车、库存扣减、订单状态流转、支付回调,每个环节单拎出来都不难,但串在一起就需要仔细设计。而且绿植这类商品和标准化的服饰数码差别不小,盆径大小、带不带土、运输损耗这些问题普通商城系统根本不会帮你考虑。这篇把我从需求盘点、数据库设计、后端接口、Vue 前端到部署排错的完整过程写出来,准备用这套技术栈做商城类项目的朋友可以参考着少走弯路。

1. 做商城前的需求盘点:绿植商品有什么不一样

1.1 商品模型比想象中更复杂

先说商品模型。绿植盆景不是标品,同一种绿萝,用户会纠结要买大盆还是小盆,带盆带土还是脱土发货,买回去放室内还是阳台。如果照搬普通商城的"一个商品一个价格"设计,后期运营会非常痛苦。所以我在设计表结构时专门引入了 SKU 的概念,用 JSON 字段保存规格信息,比如:

[ {"spec": "小盆(口径12cm)", "price": 16.8, "stock": 120}, {"spec": "大盆(口径20cm)", "price": 39.9, "stock": 40} ]

这个字段直接存在商品表里,查询详情时读出来渲染成规格选项,不同规格对应不同价格和实时库存。一开始我也想过单独建 SKU 表,但对这个体量的项目来说反而增加了 JOIN 复杂度,JSON 字段配合应用层解析完全够用。

第二个特点是库存有季节性和损耗。绿植不是囤货越多越好,养护成本、运输损耗跟季节强相关。夏天多肉和仙人掌类好卖,冬天又要规避运输冻伤风险。所以我在后台商品管理里加了一个"上下架理由"备注字段,管理员可以写明"冬季不适合露天运输"这类提示,前端商品详情页直接展示,能减少大量客服重复解释。

第三个痛点是运费。花盆、带土苗在快递运输中很容易破损,如果系统不能根据商品重量和体积估算运费,最后一定是商家默默亏运费。这个我放在了订单模块处理,下单时后端根据购物车内商品的重量字段计算运费模板,前端只展示最终金额,不做运费计算。

1.2 三类账号的权限边界

"管理系统"这四个字决定了它不只是给普通用户一个下单页面。权限设计上我分了三类账号。

普通用户通过前端注册产生,能浏览商品、搜索、加购物车、下单、查看自己的订单列表和详情、维护收货地址、申请退款。管理员负责商品上下架、库存调整、订单发货、退款审核、轮播图管理、公告发布。运营者本质上也是管理员,但我不想让他接触用户隐私和数据库操作,所以后台页面只开放商品管理和订单管理两块,权限粒度做到了按钮级别。

技术实现上我没有上 Flask-Security 这种重框架,而是手写了一个基于角色判断的权限装饰器。一个@permission_required('product:edit')就能控制接口访问,逻辑清晰,也不会给项目引入太多用不到的约定。

1.3 为什么选 Flask 而不是 Spring Boot 或 Node

不少人建议我用 Spring Boot,毕竟国内课程项目和招聘 JD 里出现的频率很高。但我最终选了 Flask,理由有三个。

第一,项目体量完全不需要 Spring Boot 那么多工程化约束。Flask 的微框架定位让一个十几张表的商城系统代码结构非常清晰,路由、蓝图、SQLAlchemy ORM 基本够用,不需要 Maven 依赖管理、统一返回体、拦截器那一整套。Spring Boot 不是不好,对个人项目来说学习成本确实高了一些。

第二,业务逻辑集中在商品查询、订单状态流转、库存扣减,属于典型的偏业务操作,不需要复杂中间件。Redis 我只用来缓存验证码和 JWT 黑名单,Lightweight 方案足够。

第三,Python 生态处理运营需求太方便了。绿植商城经常要从供应商的 Excel 表格里批量导入商品,pandas 读进来直接写数据库,比在 Java 或 Node 里解析 Excel 省事太多。技术选型最终是选自己最顺手的那套,而不是听起来更"企业级"的那套。

2. Flask 后端:数据表设计、接口拆分与登录鉴权

2.1 九张核心表的结构与冗余设计

最终落地的数据库表一共九张:user、category、product、product_image、cart、address、orders、order_item、banner。核心结构简化后如下:

表名关键字段业务说明
userid, username, password_hash, role, phone, create_time用户与管理员共用一张表,role 区分角色
categoryid, name, parent_id, sort分类表,支持 "观叶植物 / 绿萝" 这类二级结构
productid, category_id, name, description, price, stock, spec_json, status商品主表,status 控制上下架
product_imageid, product_id, url, is_cover一个商品多张图片,is_cover 标记封面
cartid, user_id, product_id, quantity, checked购物车项,checked 表示是否勾选结算
addressid, user_id, receiver, phone, province, city, detail, is_default收货地址,默认地址单独标记
ordersid, user_id, order_no, total_amount, status, pay_time, ship_time, finish_time主订单表,order_no 全局唯一
order_itemid, order_id, product_id, product_name, price, quantity订单明细,冗余商品名和价格快照
bannerid, image, link, sort首页轮播图

这里有两个容易被新手忽略的点。

第一,订单明细表必须做快照。同一个商品以后改价、改名、下架,都不影响用户已经下单的记录。所以我在 order_item 里用 product_name 和 price 把下单那一刻的信息冗余存储,查订单历史时不需要再 JOIN 商品表,也不怕商品被删。

第二,商品和图片是一对多查询,要警惕 N+1 问题。如果用 SQLAlchemy 默认的 relationship 懒加载,循环 20 个商品就会查 20 次图片表。我是用一次in_批量查出所有相关图片,然后在内存里按 product_id 分组,性能问题直接消失。

2.2 蓝图拆分与商品列表接口

Flask 项目最忌讳把所有路由写在一个 app.py 里。我按业务模块拆了六个蓝图:

  • auth.py:注册、登录、JWT 刷新
  • product.py:商品列表、详情、分类
  • cart.py:购物车增删改查、勾选状态
  • order.py:下单、订单列表、订单详情、取消、确认收货
  • admin.py:后台商品管理、订单管理、轮播图、数据统计
  • upload.py:图片上传

注册方式是在工厂函数里统一处理,所有接口自动带/api/v1前缀。后续部署到服务器配合 Nginx 做反向代理时,路径规则非常清晰。

商品列表接口返回的数据结构直接决定前端渲染效率。我设计的返回体长这样:

{ "code": 0, "msg": "success", "data": { "total": 56, "page": 1, "size": 10, "list": [ { "id": 101, "name": "水培绿萝", "cover": "http://.../cover.jpg", "price": 16.8, "sales": 102, "tags": ["水培", "好养"] } ] } }

前端拿 list 渲染卡片,total 做分页,不需要再处理额外字段。搜索功能我用 SQLAlchemy 的Product.name.contains(keyword)加上 tags 的模糊匹配。现阶段完全够用,等数据量大了再换 MySQL 全文索引或 Elasticsearch。

2.3 JWT 登录鉴权的落地方式

前后端分离之后,Session 加 Cookie 的跨域处理很麻烦,所以我直接用 JWT。Flask 里用 PyJWT 库,注册时用 werkzeug.security 的generate_password_hash生成密码哈希,登录验证通过后签发一个有效期两小时的 token,payload 里放 user_id 和 role。

自定义装饰器是所有受保护接口的关键。核心代码如下:

from functools import wraps from flask import request, g import jwt SECRET_KEY = 'your-secret-key' def login_required(f): @wraps(f) def wrapper(*args, **kwargs): token = request.headers.get('Authorization', '').replace('Bearer ', '') try: payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256']) g.user_id = payload['user_id'] g.user_role = payload['role'] except jwt.PyJWTError: return {'code': 401, 'msg': '登录已过期'}, 401 return f(*args, **kwargs) return wrapper

管理员接口额外写一个admin_required,内部判断g.user_role == 'admin'。前端拿到 401 后统一跳转登录页,这套逻辑非常直接。

2.4 图片上传的本地方案

考虑到绿植商城图片量不会特别大,我没上云存储,直接存在应用目录下的 uploads 文件夹,配合 Nginx 作为静态资源访问。上传接口用request.files接收文件,类型做白名单校验:

ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'webp', 'gif'} def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

文件名用 uuid 加扩展名重命名,避免中文名和重复名引发问题。这里踩过一个坑:如果前端自己拼接图片 URL,Nginx 配置稍微不对就会 404。后来我改成后端在响应里直接返回完整的可访问 URL,前端不做任何拼接,省了大量排查时间。

3. Vue 前端:页面组件、路由与接口联调

3.1 目录结构与路由设计

前端我用 Vue CLI 创建项目,没有上 Vue 3 的 Composition API 全家桶,保持 Options API 的写法,原因很简单:这个项目页面复杂度适中,Options API 的语义对团队协作更友好,维护时不烧脑。目录结构大概是:

src/ api/ products.js cart.js order.js user.js assets/ components/ ProductCard.vue NavBar.vue Pagination.vue router/ index.js views/ Home.vue ProductDetail.vue Cart.vue Checkout.vue OrderList.vue Login.vue Register.vue Admin/ ProductManage.vue OrderManage.vue

views 下按页面拆分,components 放复用组件。管理后台路由单独用/admin前缀,并用路由守卫做权限拦截:非管理员访问后台直接重定向。不过要强调一句,前端权限只是体验优化,真正的权限校验一定在后端接口上,前端路由守卫可以被绕过。

3.2 商品列表、详情与规格选择

商品卡片组件 ProductCard 接收一个 product 对象,渲染图片、名称、价格和"加入购物车"按钮。首页图片懒加载用了自定义的 v-lazy 指令,避免一次请求几十张原图带来的卡顿。

商品详情页最需要花心思的是规格选择逻辑。我用 radio 组件绑定当前选中的规格对象,选中的规格决定显示的价格和库存量,库存为 0 时按钮禁用并提示"暂时缺货"。这里有个交互细节:切换规格时必须同步更新右侧价格展示和底部按钮状态,否则用户选了大盆却还是显示小盆的价格,下单后才发现金额不对,体验很差,还要走退款流程。

加入购物车时前端只传 product_id 和 quantity,规格信息由后端根据传入的 spec_key 解析。购物车列表展示的也是后端快照数据,前端不做价格计算。

3.3 购物车结算页与数值安全

购物车页面的核心是勾选商品后计算总价,然后进入结算页填写地址、选择支付方式。

这里有必要提醒一句:所有价格相关的计算必须在后端做。用户勾选商品后,前端可以展示一个"预计金额",但真正提交订单时,后端重新遍历购物车内勾选商品计算总金额、运费和优惠。如果前端把总价放到请求体里,用户拿开发者工具改成 0.01 元,系统就会被薅羊毛。我的做法是下单接口只接收 address_id 和购物车项 id 列表,后端自己算钱,返回订单数据让前端确认。

3.4 Axios 封装和跨域方案

Axios 实例统一做了三件事:设置 baseURL、在请求拦截器里加 Authorization 头、在响应拦截器里统一处理 401 和业务码。响应拦截器代码:

service.interceptors.response.use( response => { const res = response.data if (res.code === 401) { router.push('/login') } return res }, error => { ElMessage.error(error.response?.data?.msg || '请求失败') return Promise.reject(error) } )

跨域是本地开发最容易卡住的地方。我的方案是开发环境用 Vue CLI 的 devServer.proxy,把所有/api请求代理到http://localhost:5000,完美绕开 CORS。生产环境前后端共用同一个域名,Nginx 直接接管,也不存在跨域。这样后端根本不需要配置 Flask-CORS 插件,少一个依赖少一堆可选的坑。

4. 最绕不开的业务细节:库存扣减与订单状态流转

4.1 超卖是怎么发生的,怎么用原子操作解决

商城最经典的坑就是超卖。库存剩 1 件,两个用户同时下单,如果代码写成"先查出库存,判断大于 0,再执行 update 减 1",在高并发环境下几乎必然超卖。原因是"查库存"和"扣库存"两个操作之间可能有任意间隙,另一个请求插进来读到同样的库存值。

我用的方案是条件更新:

updated = Product.query.filter( Product.id == product_id, Product.stock >= quantity ).update( {'stock': Product.stock - quantity}, synchronize_session=False ) if updated == 0: raise BizException('库存不足')

这条 UPDATE 把"判断库存够不够"和"扣库存"合并成一个原子操作,数据库行锁保证同一时刻只有一个请求能成功扣减。配合事务提交,第二个用户即使几乎同时到达,也会因为stock < quantity导致影响行数 0,从而拿到"库存不足"的提示。对绿植商城这种并发量级,这个方案撑得住,不需要引入 Redis 分布式锁。

4.2 订单状态机与操作合法性校验

订单状态我设计成有限状态集合,不允许任意跳转。

状态码含义用户可操作管理员可操作
0待付款付款、取消订单无
1已付款待发货无发货
2已发货确认收货无
3已完成申请退款处理退款
4已取消无无
5退款中无审核退款
6已退款无无

状态迁移的合法性校验写在 OrderService 里。比如"待付款"只能迁移到"已付款"或"已取消",管理员不能把"待付款"改成"已完成"。这种显式的状态机设计让代码里不会出现到处乱改状态的写法,出问题排查时非常省心。

4.3 支付环节的简化实现和正式对接注意事项

真实对接微信支付需要商户号、证书、回调验签,开发初期很麻烦。我的做法是先抽象一个 PayService 接口,测试阶段实现"模拟支付":用户点确认支付后,系统把订单状态从待付款改成待发货,支付单号记录为 system_mock。这样整个订单主链路能跑通,后面要接真实支付,替换 PayService 实现就行,订单模块其他代码不用动。

如果正式对接,支付回调必须由后端接收支付平台异步通知,不能由用户浏览器直接通知后端"我付完了",因为那可能被伪造。回调逻辑做三件事:验签、校验订单金额一致、幂等处理。同一笔支付通知可能来多次,需要按支付单号去重,避免重复更新订单状态。

5. 部署到服务器的完整记录

5.1 本地方案与版本锁定

本地开发环境是 Python 3.10、Node 18、MySQL 8.0。后端用虚拟环境管理依赖,requirements.txt 锁版本。前几年经常遇到项目在别人机器上死活跑不起来,后来发现是 Flask 或 SQLAlchemy 版本差一个小 minor 版本导致兼容问题。锁版本这件事不是可有可无,是必需品。

前端依赖我用 package-lock.json 锁定,避免同事或服务器上 npm install 拉出不同版本。这一步成本很低,收益却很大。

5.2 用 Gunicorn 和 Nginx 部署

服务器部署方案是 Gunicorn 作为 WSGI 服务器,Nginx 做反向代理和静态文件服务。Gunicorn 启动命令:

gunicorn -w 2 -b 127.0.0.1:5000 wsgi:app

两个 worker 对这个项目完全够用。Nginx 关键配置片段:

server { listen 80; server_name plantshop.example.com; location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /uploads/ { alias /var/www/plant-shop/uploads/; } location / { root /var/www/plant-shop/dist; try_files $uri $uri/ /index.html; } }

前端 build 之后把 dist 目录放到服务器对应位置。这里最关键的就是try_files $uri $uri/ /index.html。Vue 是单页应用,前端路由在 Nginx 里没有对应的物理文件,不配置这一句话,刷新页面直接 404。

5.3 五个让我花最多时间的坑

整理一下整个开发部署过程中真正让我头疼的问题。

第一个是时区问题。Flask 默认存储的 datetime 是 UTC,前端展示需要北京时间,直接返回就会差 8 个小时。我最后的处理是数据库统一存 UTC,接口返回时转成YYYY-MM-DD HH:mm:ss的本地时间字符串,前端不做时区换算,彻底避免各处显示不一致。

第二个是前端 history 路由刷新 404,这个上面说过,Nginx 的 try_files 是标准解法。

第三个是 MySQL 连接断开的坑。开发时连着连着数据库,隔一段时间再请求就出现MySQL server has gone away。原因是连接空闲过久被服务端断开,而 SQLAlchemy 连接池还拿着旧连接。解决方案是在 SQLAlchemy 配置里加pool_pre_ping=True,每次取连接前先探测有效性,立竿见影。

第四个是图片 404。问题根源往往不在 Flask 而在 Nginx 的 root 和 alias 配置搞混。alias 后面要带完整路径,location /uploads/ 必须有对应的文件夹。排查时直接用 curl 请求图片 URL 看响应码,能快速定位是哪一层的问题。

第五个是前端 build 后白屏。通常是 Vue Router 的 base 配置不对,或者静态资源引用了绝对路径。我在 vue.config.js 里设置publicPath: '/',并把项目部署在域名根路径下,之后就没再遇到白屏。

这个项目目前还在稳定运行。如果后期订单量大,可以再把验证码、购物车缓存到 Redis,引入消息队列异步处理支付回调。商城系统的核心不在于技术栈多新,而在于状态流转、数据一致性和权限边界这些细节是否经得起推敲。希望这篇记录能帮你少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询