简介:一套开箱即用的Flask电商系统源码,后端基于MySQL数据库,使用SQLAlchemy完成ORM映射,结构清晰、功能完整。用户端支持首页轮播展示、商品分类浏览(最新/折扣/热门)、商品详情查看、购物车增删改查、收货地址填写、订单提交及模拟支付宝收款二维码生成;订单提交后可实时查看状态和明细。管理员后台提供商品全生命周期管理(添加、编辑、上下架、销量统计)、会员信息列表查看、订单审核与状态更新。配套资源齐全:shop.sql建库脚本、requirements.txt依赖清单、config.py配置文件、manage.py启动入口,以及项目文档.md、业务流程图、系统功能架构图、文件夹组织图、数据表关系图和字段结构说明图,便于理解整体设计逻辑和快速上手调试。适合Python Web学习者通过真实电商场景掌握路由设计、模板渲染、表单处理、会话管理、数据库操作及前后端协同开发全流程。
1. 项目概述:这不是一个“玩具Demo”,而是一套能跑通真实电商闭环的Flask教学骨架
我带过不少Python Web入门学员,也审过上百份毕业设计和自学项目。绝大多数人卡在同一个地方:学完Flask路由、模板、表单、数据库基础后,面对“做一个商城”这种需求,立刻陷入迷茫——商品怎么分类?购物车数据存在哪?订单状态怎么流转?后台权限怎么隔离?不是代码写不出来,而是根本不知道业务逻辑该长什么样、各模块之间该怎么咬合。这套“Flask电商实战包”,就是为解决这个断层而生的。它不追求高并发、不堆炫酷前端、不搞微服务拆分,但把一个轻量级电商系统里所有关键业务节点都踩实了:从首页轮播图的定时更新逻辑,到用户点击“立即购买”后购物车如何与库存联动校验;从收货地址表单提交时的字段必填与格式验证,到订单生成那一刻如何原子性地扣减库存、创建订单主表与明细表、并触发二维码生成;再到后台管理员审核订单时,如何防止重复操作、如何记录操作日志。关键词里的“Flask电商”“MySQL商城”“SQLAlchemy实战”“扫码支付模拟”“后台管理系统”,每一个都不是虚词,而是对应着代码里一段段可调试、可打断点、可修改验证的真实逻辑。它适合两类人:一类是刚学完Flask基础、想用一个完整项目把零散知识点串起来的初学者;另一类是需要快速搭建内部演示系统、或给团队新人做技术培训的开发者。你不需要把它当成生产环境直接上线,但完全可以把它当作一张高清解剖图,一层层剥开电商系统的内脏,看清血液(数据流)怎么走、神经(请求链路)怎么连、肌肉(业务逻辑)怎么发力。
2. 整体架构设计与核心思路拆解
2.1 为什么选择Flask而非Django?轻量不等于简陋
很多人看到“轻量级”就下意识觉得功能缩水。其实恰恰相反,Flask的“轻量”是指它不预设你的开发范式,把选择权交还给开发者。在这个项目里,我们刻意避开Django那种“大而全”的自动管理后台和ORM封装,就是为了让你亲手触摸每一层抽象背后的实现。比如,Django的ModelForm能一键生成表单,但你未必清楚它内部如何将模型字段映射为HTML控件、如何处理POST数据绑定与验证。而本项目中,商品添加表单的渲染、数据接收、校验、保存,全部由flask-wtf配合自定义Form类完成,每一步你都能在app/admin/forms.py里看到StringField('商品名称', validators=[DataRequired(), Length(max=100)])这样的代码。这背后是明确的意图:强制你理解表单验证的粒度控制——为什么名称要限制100字符?为什么价格必须是Decimal类型?为什么上传图片要单独处理文件流而不是塞进表单数据? 同样,在数据库层面,SQLAlchemy的db.Model继承、relationship定义、backref反向引用,这些都不是黑盒。你在app/models.py里能看到class Product(db.Model)如何定义主键、外键、索引,以及orders = db.relationship('Order', backref='product')这行代码背后,SQLAlchemy究竟生成了怎样的JOIN语句。这种“显式优于隐式”的设计,让学习者不会被框架的魔法惯坏,反而能扎实建立起对Web开发底层逻辑的认知。
2.2 MySQL + SQLAlchemy组合:关系型数据库的“教科书式”实践
选MySQL不是因为它多先进,而是因为它是国内中小型企业最普遍采用的关系型数据库,它的事务、外键、索引机制,是理解数据一致性的最佳教材。而SQLAlchemy,则是Python生态里最成熟、文档最详尽的ORM工具。本项目没有使用flask-sqlalchemy的简化封装,而是直接采用原生SQLAlchemy Core + ORM混合模式,目的很明确:让你看清ORM不是银弹,它只是帮你生成SQL的助手,而真正的数据库思维,永远建立在对SQL本身的理解之上。 比如,在计算某个商品的月销量时,你可能会看到这样的代码:
from sqlalchemy import func
monthly_sales = db.session.query(
func.sum(OrderItem.quantity)
).join(Order).filter(
OrderItem.product_id == product_id,
Order.status == 'paid',
Order.created_at >= datetime.now() - timedelta(days=30)
).scalar() or 0
这段代码里,func.sum调用的是SQL的聚合函数,join对应SQL的JOIN语法,filter翻译成WHERE条件。它比写原生SQL更易读,又比model.query.filter().sum()这类高级封装更贴近底层。更重要的是,项目中的shop.sql脚本,严格遵循了第三范式设计:用户信息、收货地址、订单主表、订单明细、商品分类、商品主表、商品相册,全部拆分成独立表,并通过外键约束保证数据完整性。你在数据表关系图.png里能看到orders.user_id指向users.id,order_items.order_id指向orders.id,order_items.product_id指向products.id。这种设计不是为了炫技,而是为了让你在调试“为什么下单后库存没扣减”这类问题时,能顺着外键关系,一层层查下去:先看orders表有没有创建成功,再看order_items有没有关联上正确的product_id,最后检查products.stock字段是否被正确更新。关系型数据库的威力,从来不在它能存多少数据,而在于它能用一套严谨的规则,帮你守住数据世界的秩序底线。
2.3 “扫码支付模拟”的本质:剥离第三方SDK,聚焦业务流程
关键词里的“扫码支付模拟”,最容易被误解为“随便画个二维码糊弄过去”。实际上,这里的“模拟”指的是剥离支付宝/微信官方SDK的复杂认证与回调流程,但100%复刻其核心业务状态机。真实支付有四个关键状态:未支付、支付中、支付成功、支付失败。本项目用一个payment_status字段(枚举值:’pending’, ‘paid’, ‘failed’)在orders表中精确刻画这一过程。当你在用户端点击“提交订单”,后端做的第一件事不是生成二维码,而是开启一个数据库事务:锁定商品库存、创建订单主表(status=’pending’)、创建订单明细、扣减库存。只有整个事务成功提交,才进入下一步——调用一个本地的generate_qr_code()函数。这个函数不对接任何外部API,它只是用qrcode库,把一个包含订单号、金额、时间戳的字符串,编码成PNG图片,并存入static/qrcodes/目录下。前端通过<img src="/static/qrcodes/{{ order.order_no }}.png">加载。重点来了:这个二维码里没有任何支付能力,它只是一个静态快照。真正的“支付成功”动作,是由管理员在后台点击“确认收款”按钮触发的。此时,后端会再次开启事务,将该订单的payment_status更新为’paid’,并同步更新order_items中对应商品的销量统计。这种设计,把一个看似复杂的支付环节,拆解成了三个清晰、可控、可测试的原子操作:1)创建待支付订单;2)生成可视化凭证;3)人工确认资金到账。它教会你的不是如何集成SDK,而是如何设计一个健壮的状态驱动型业务流程,这才是电商系统最核心的内功。
2.4 前后台分离的朴素哲学:模板继承与权限控制的最小可行方案
项目没有采用Vue/React做前后端分离,所有页面都是Jinja2模板渲染。但这绝不意味着技术落后。相反,它用最朴素的方式,实现了清晰的职责划分。整个前端(用户端)和后台(管理员端)共享同一套Flask应用,但通过两套完全独立的蓝图(Blueprint) 进行隔离:app/main/下的main.py负责首页、商品列表、详情、购物车等所有用户可见功能;app/admin/下的admin.py则只暴露/admin/开头的路由,如/admin/products、/admin/orders。这种物理隔离,比任何前端路由都更彻底。权限控制也极其简单直接:在app/admin/views.py的每个视图函数顶部,都有一行@login_required装饰器,它检查session.get('admin_logged_in')是否为True。而这个session变量,只在管理员成功登录/admin/login时被设置。没有JWT、没有OAuth2、没有复杂的RBAC模型,就是一个布尔值开关。为什么这么做?因为对于一个教学项目,过早引入复杂权限框架,只会让你迷失在配置文件和中间件里,忘了自己最初想学的是“如何让一个订单状态变成已发货”。当你把后台登录逻辑、菜单渲染、数据查询全部跑通后,再回头去看Django Admin或Flask-Admin的源码,那些精妙的设计才会真正击中你。现在,你只需要关注一件事:admin/templates/base.html定义了后台的全局布局,admin/templates/admin/base.html继承它,并填充左侧导航栏;而main/templates/base.html则是用户端的布局,两者CSS样式、JS脚本、导航结构完全不同,互不干扰。这种“分而治之”的思路,比任何炫技的架构都更接近工程实践的本质。
3. 核心模块解析与实操要点
3.1 数据库设计与SQLAlchemy模型映射:从ER图到Python类的精准翻译
打开shop.sql脚本,你会看到12张表的建表语句。它们不是随意堆砌的,而是严格遵循电商领域建模规范。以最核心的products(商品主表)和product_categories(商品分类表)为例:
CREATE TABLE `product_categories` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '分类名称',
`parent_id` int(11) DEFAULT NULL COMMENT '父分类ID,用于构建树形结构',
`sort_order` int(11) DEFAULT '0' COMMENT '排序序号',
`is_active` tinyint(1) DEFAULT '1' COMMENT '是否启用',
PRIMARY KEY (`id`),
KEY `idx_parent_id` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `products` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`category_id` int(11) NOT NULL COMMENT '所属分类',
`name` varchar(100) NOT NULL COMMENT '商品名称',
`description` text COMMENT '商品描述',
`price` decimal(10,2) NOT NULL COMMENT '销售价格',
`cost_price` decimal(10,2) DEFAULT NULL COMMENT '成本价,用于毛利计算',
`stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存数量',
`sales_count` int(11) NOT NULL DEFAULT '0' COMMENT '累计销量',
`is_on_sale` tinyint(1) DEFAULT '1' COMMENT '是否上架',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_is_on_sale` (`is_on_sale`),
CONSTRAINT `fk_products_category_id` FOREIGN KEY (`category_id`) REFERENCES `product_categories` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
注意几个关键细节:parent_id允许为空,这是实现无限级分类的基础;is_on_sale作为软删除标志,避免物理删除带来的外键约束问题;ON DELETE CASCADE确保删除一个分类时,其下所有商品自动被移除。现在,看app/models.py中对应的SQLAlchemy模型:
class ProductCategory(db.Model):
__tablename__ = 'product_categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False, comment='分类名称')
parent_id = db.Column(db.Integer, db.ForeignKey('product_categories.id'), nullable=True, comment='父分类ID')
sort_order = db.Column(db.Integer, default=0, comment='排序序号')
is_active = db.Column(db.Boolean, default=True, comment='是否启用')
# 自关联关系:一个分类可以有多个子分类
children = db.relationship('ProductCategory',
backref=db.backref('parent', remote_side=[id]),
lazy='dynamic')
class Product(db.Model):
__tablename__ = 'products'
id = db.Column(db.Integer, primary_key=True)
category_id = db.Column(db.Integer, db.ForeignKey('product_categories.id'), nullable=False, comment='所属分类')
name = db.Column(db.String(100), nullable=False, comment='商品名称')
description = db.Column(db.Text, comment='商品描述')
price = db.Column(db.Numeric(10, 2), nullable=False, comment='销售价格')
cost_price = db.Column(db.Numeric(10, 2), nullable=True, comment='成本价')
stock = db.Column(db.Integer, default=0, nullable=False, comment='库存数量')
sales_count = db.Column(db.Integer, default=0, nullable=False, comment='累计销量')
is_on_sale = db.Column(db.Boolean, default=True, comment='是否上架')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关系:一个商品属于一个分类
category = db.relationship('ProductCategory', backref=db.backref('products', lazy=True))
这里有几个必须掌握的映射要点:
1. 字段类型精准对应:db.String(50) 对应 varchar(50),db.Numeric(10, 2) 对应 decimal(10,2),db.Boolean 对应 tinyint(1)。特别是Numeric,它能精确表示货币,避免浮点数精度丢失。
2. 外键约束显式声明:db.ForeignKey('product_categories.id') 不仅告诉SQLAlchemy这是一个外键,更在数据库层面创建了约束,保证数据一致性。
3. 关系定义的双向性:ProductCategory.children 和 ProductCategory.parent 构成自关联,Product.category 和 ProductCategory.products 构成一对多。backref参数是精髓,它让category.products和product.category都能自然访问对方,无需手动JOIN。
4. 时间戳自动化:default=datetime.utcnow 和 onupdate=datetime.utcnow 让created_at和updated_at字段无需在业务代码中手动赋值,由数据库或ORM自动维护。
提示:在
app/__init__.py中,db = SQLAlchemy()的初始化必须在create_app()工厂函数内部完成,这是Flask推荐的“应用工厂模式”,确保不同环境(开发、测试、生产)可以使用不同的数据库配置,而模型定义保持不变。
3.2 用户端核心流程:从首页轮播到订单生成的全链路剖析
用户端的入口是app/main/views.py。我们以“首页轮播图”和“加入购物车”两个高频操作为例,深挖其背后的数据流与控制流。
首页轮播图(/):它不是一个静态图片,而是一个动态内容。数据来源是banners表,其中is_active=1且sort_order决定显示顺序。视图函数index()的代码如下:
@main.route('/')
def index():
# 查询激活的轮播图,按排序序号升序排列
banners = Banner.query.filter_by(is_active=True).order_by(Banner.sort_order).all()
# 查询最新上架的商品(按created_at倒序取前8)
new_products = Product.query.filter_by(is_on_sale=True).order_by(Product.created_at.desc()).limit(8).all()
# 查询折扣商品(price < market_price,假设market_price字段存在)
discount_products = Product.query.filter(
Product.is_on_sale == True,
Product.price < Product.market_price
).order_by(Product.sales_count.desc()).limit(8).all()
# 查询热门商品(按销量倒序)
hot_products = Product.query.filter_by(is_on_sale=True).order_by(Product.sales_count.desc()).limit(8).all()
return render_template('main/index.html',
banners=banners,
new_products=new_products,
discount_products=discount_products,
hot_products=hot_products)
关键点在于:所有查询都加了filter_by(is_on_sale=True),确保用户看不到已下架商品;limit(8)控制数据量,避免首页加载过慢;order_by(...desc())决定了“最新”“热门”的业务含义。你可以在app/main/templates/main/index.html中看到Jinja2模板如何遍历banners列表,用<div class="carousel-item {{ loop.first and 'active' or '' }}">动态添加Bootstrap轮播图的active类。
加入购物车(/cart/add):这是一个典型的POST接口,处理逻辑远比表面复杂。核心在于库存校验与会话存储的协同:
@main.route('/cart/add', methods=['POST'])
def add_to_cart():
if not session.get('user_id'):
return jsonify({'success': False, 'message': '请先登录'})
product_id = request.form.get('product_id', type=int)
quantity = request.form.get('quantity', type=int, default=1)
# 1. 先查商品,确保存在且上架
product = Product.query.get(product_id)
if not product or not product.is_on_sale:
return jsonify({'success': False, 'message': '商品不存在或已下架'})
# 2. 库存校验:不能超过当前库存
if quantity > product.stock:
return jsonify({'success': False, 'message': f'库存不足,当前剩余{product.stock}件'})
# 3. 从session中获取购物车数据(格式:{'product_id': {'quantity': 2, 'price': 99.99}})
cart = session.get('cart', {})
# 4. 如果商品已在购物车,累加数量;否则新增
if str(product_id) in cart:
cart[str(product_id)]['quantity'] += quantity
else:
cart[str(product_id)] = {
'quantity': quantity,
'price': float(product.price) # 转为float,便于JSON序列化
}
# 5. 更新session
session['cart'] = cart
session.modified = True # 显式标记session已修改,确保被保存
return jsonify({'success': True, 'message': '已加入购物车', 'cart_count': sum(item['quantity'] for item in cart.values())})
这段代码揭示了三个重要实践:
- 防御性编程:每一步都有检查,从用户登录态、商品存在性、库存充足性,到数据类型转换(type=int),杜绝了90%的前端恶意请求。
- 会话(Session)的合理使用:购物车数据存在session里,而不是数据库,是因为它具有临时性、私密性、且读写频繁。但必须记住session.modified = True,否则Flask可能不会将变更写回后端存储(如Redis或文件)。
- 数据一致性保障:虽然购物车暂存于session,但在最终下单时(/order/create),会再次查询数据库中的product.stock,进行最终校验。这是典型的“乐观锁”思想——先让用户操作,最后一步再强校验,平衡了用户体验与数据安全。
3.3 后台管理系统:商品、会员、订单三大核心模块的实现逻辑
后台的app/admin/views.py是整个项目的“大脑中枢”。它不处理前端交互,只专注业务规则的执行。
商品管理(/admin/products):增删改查是基础,但本项目加入了两个关键增强:
- 图片上传与存储:当管理员在/admin/products/new页面上传商品主图时,后端不会直接把二进制数据存进数据库(那会极大拖慢查询)。而是用werkzeug.utils.secure_filename()清洗文件名,生成唯一哈希(如sha256(filename+timestamp)),然后保存到static/uploads/products/目录下,数据库只存相对路径/static/uploads/products/abc123.jpg。这样,前端可以直接用<img src="{{ product.image_url }}">加载,CDN也能轻松接入。
- 销量排行与上下架:/admin/products列表页默认按sales_count DESC排序,但提供“按上架时间”、“按价格”等筛选。上下架操作不是DELETE,而是更新is_on_sale字段。这背后有一个隐藏逻辑:Product.query.filter_by(is_on_sale=True)的查询会被MySQL的idx_is_on_sale索引加速,确保首页商品列表加载飞快。
会员管理(/admin/users):这里没有复杂的用户画像分析,只有最务实的信息:注册时间、最后登录时间、总消费金额、订单数。计算总消费金额的SQL非常典型:
# 在User模型中添加一个属性方法
@property
def total_spent(self):
from app.models import Order
return db.session.query(db.func.sum(Order.total_amount))\
.filter(Order.user_id == self.id, Order.status == 'paid')\
.scalar() or 0
注意scalar()方法,它只返回聚合结果的第一个值(即SUM的结果),而不是一个元组。这比first()[0]更安全,因为scalar()在无结果时返回None,而first()可能返回None导致索引错误。
订单审核(/admin/orders):这是后台最核心、风险最高的操作。项目采用了“双确认”机制来防止误操作:
@admin.route('/orders/<int:order_id>/confirm', methods=['POST'])
@login_required
def confirm_order(order_id):
order = Order.query.get_or_404(order_id)
# 1. 状态校验:只能对pending状态的订单进行确认
if order.status != 'pending':
flash(f'订单 {order.order_no} 状态为 {order.status},无法确认收款', 'warning')
return redirect(url_for('admin.orders'))
# 2. 开启事务
try:
# 更新订单状态
order.status = 'paid'
order.paid_at = datetime.utcnow()
# 更新所有订单明细中对应商品的销量
for item in order.items:
product = Product.query.get(item.product_id)
if product:
product.sales_count += item.quantity
# 提交事务
db.session.commit()
flash(f'订单 {order.order_no} 已确认收款', 'success')
except Exception as e:
db.session.rollback()
flash(f'确认订单失败:{str(e)}', 'danger')
return redirect(url_for('admin.orders'))
关键点在于try...except...rollback。如果在更新销量时,某个product_id对应的记录被意外删除,Product.query.get()会返回None,导致product.sales_count += item.quantity抛出TypeError。此时db.session.rollback()会将order.status的修改也一并撤销,确保数据库回到操作前的一致状态。这是任何电商系统都必须具备的底线能力。
3.4 配置与部署:从config.py到manage.py的工程化实践
一个项目能否“开箱即用”,配置文件的质量是第一道门槛。config.py不是简单的字典,而是一个分环境配置的类体系:
import os
from datetime import timedelta
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
SQLALCHEMY_TRACK_MODIFICATIONS = False
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
'mysql+pymysql://root:password@localhost:3306/flask_shop?charset=utf8mb4'
class ProductionConfig(Config):
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'mysql+pymysql://prod_user:prod_pass@prod-db:3306/flask_shop?charset=utf8mb4'
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
这种设计的好处是:开发时用FLASK_ENV=development flask run,自动加载DevelopmentConfig;上线时只需设置FLASK_ENV=production和DATABASE_URL环境变量,无需修改一行代码。manage.py则是一个命令行工具集:
from flask_script import Manager
from flask_migrate import Migrate, MigrateCommand
from app import create_app, db
app = create_app(os.getenv('FLASK_ENV') or 'default')
manager = Manager(app)
migrate = Migrate(app, db)
manager.add_command('db', MigrateCommand) # 数据库迁移命令
manager.add_command('shell', Shell(make_context=lambda: {'app': app, 'db': db})) # 启动交互式shell
if __name__ == '__main__':
manager.run()
有了它,你可以:
- python manage.py db init:初始化迁移仓库
- python manage.py db migrate -m "add user avatar field":根据模型变化生成迁移脚本
- python manage.py db upgrade:将迁移脚本应用到数据库
- python manage.py shell:进入交互式Python环境,直接操作db.session,调试数据逻辑
注意:
requirements.txt里包含了pymysql(MySQL驱动)、flask-wtf(表单验证)、qrcode(二维码生成)、Pillow(图片处理)等所有依赖。但没有包含mysql-server本身——这意味着你需要自行安装MySQL服务,或者使用Docker快速启动:docker run --name mysql-shop -e MYSQL_ROOT_PASSWORD=password -p 3306:3306 -d mysql:8.0。这是工程实践中“基础设施即代码”的起点,也是你迈向DevOps的第一步。
4. 实操过程与核心环节实现
4.1 从零开始:五分钟搭建本地开发环境
别被一堆文件吓到,实际搭建比想象中简单。我通常按以下步骤操作,全程不超过5分钟:
第一步:准备数据库
# 如果你已安装MySQL,登录后创建数据库
mysql -u root -p
> CREATE DATABASE flask_shop CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
> EXIT;
# 或者用Docker(推荐,干净无污染)
docker run --name mysql-shop -e MYSQL_ROOT_PASSWORD=password -p 3306:3306 -d mysql:8.0
# 然后在容器内创建数据库
docker exec -it mysql-shop mysql -uroot -ppassword -e "CREATE DATABASE flask_shop CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
第二步:克隆项目并安装依赖
git clone <your-repo-url>
cd Flask电商实战包
# 创建虚拟环境(强烈建议)
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
pip install -r requirements.txt
第三步:导入初始数据并运行
# 修改config.py中的数据库连接字符串,指向你的MySQL
# 例如:'mysql+pymysql://root:password@localhost:3306/flask_shop?charset=utf8mb4'
# 导入shop.sql(可以用MySQL Workbench图形化导入,或命令行)
mysql -u root -p flask_shop < shop.sql
# 运行迁移(首次运行,会创建所有表)
python manage.py db upgrade
# 启动应用
flask run
# 或者指定端口
flask run --port 5001
此时,浏览器访问http://localhost:5000,你应该能看到一个完整的首页。如果遇到ImportError: No module named 'pymysql',说明pymysql没装好,重新pip install pymysql即可。如果提示Can't connect to MySQL server,请检查config.py里的主机地址(localhost还是127.0.0.1?)、端口(默认3306)、用户名密码是否匹配。
4.2 调试购物车并发问题:一个真实的“超卖”场景复现与修复
理论讲再多,不如一次真实的调试。我曾在一个学员的项目里复现了一个经典问题:两个用户几乎同时对同一款只剩1件库存的商品发起下单请求,结果数据库里stock字段变成了-1。这就是“超卖”。让我们用本项目来复现并修复它。
复现步骤:
1. 在MySQL中,将某商品stock设为1:UPDATE products SET stock = 1 WHERE id = 1;
2. 打开两个浏览器标签页,都登录同一个用户账号。
3. 在两个标签页中,同时点击“加入购物车”,数量都为1。
4. 查看数据库:SELECT stock FROM products WHERE id = 1; 结果很可能是0,也可能是-1。
原因分析: 问题出在add_to_cart()视图函数里。它先SELECT查库存,再UPDATE扣减,但这两个操作不是原子的。在高并发下,A请求查到stock=1,B请求也查到stock=1,然后A和B都执行stock = stock - 1,最终结果是1-1=0,但两次执行,相当于扣了两次。
修复方案(乐观锁): 修改add_to_cart()中的库存扣减逻辑,不再依赖SELECT后的判断,而是用一条UPDATE语句完成“检查+扣减”:
# 在add_to_cart()函数内部,替换原有的库存检查逻辑
# 原逻辑(有问题):
# if quantity > product.stock: ...
# 新逻辑(安全):
result = db.session.execute(
text("UPDATE products SET stock = stock - :qty WHERE id = :pid AND stock >= :qty"),
{"qty": quantity, "pid": product_id}
)
db.session.commit()
if result.rowcount == 0:
return jsonify({'success': False, 'message': '库存不足,请刷新重试'})
rowcount == 0意味着WHERE条件不满足,即库存不够,UPDATE没生效。这样,无论多少个并发请求,只有一个能成功执行UPDATE,其他都会失败并返回提示。这是数据库层面的终极保障,比任何应用层锁都可靠。
4.3 后台商品图片上传:从表单到磁盘的完整链路
管理员在/admin/products/new页面上传图片,这个看似简单的功能,背后涉及文件流处理、安全校验、路径生成、数据库更新四个环节。
前端表单(admin/templates/admin/product_form.html):
<form method="POST" enctype="multipart/form-data">
<!-- 其他字段 -->
<div class="form-group">
<label for="image">商品主图</label>
<input type="file" class="form-control-file" id="image" name="image" accept="image/*">
</div>
<button type="submit" class="btn btn-primary">保存</button>
</form>
关键点是enctype="multipart/form-data",它告诉浏览器以二进制流方式发送文件,而不是URL编码的文本。
后端处理(app/admin/views.py):
from werkzeug.utils import secure_filename
import os
from hashlib import sha256
@admin.route('/products/new', methods=['GET', 'POST'])
@login_required
def create_product():
form = ProductForm()
if form.validate_on_submit():
# 1. 处理图片上传
image_file = request.files.get('image')
image_path = None
if image_file and image_file.filename != '':
# 安全校验:检查文件扩展名
filename = secure_filename(image_file.filename)
if not filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif')):
flash('仅支持PNG、JPG、GIF格式图片', 'danger')
return render_template('admin/product_form.html', form=form)
# 2. 生成唯一文件名:哈希+原始扩展名
file_hash = sha256((filename + str(time.time())).encode()).hexdigest()[:12]
_, ext = os.path.splitext(filename)
safe_filename = f"{file_hash}{ext}"
# 3. 构建保存路径
upload_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'products')
os.makedirs(upload_folder, exist_ok=True) # 确保目录存在
image_path = os.path.join(upload_folder, safe_filename)
# 4. 保存文件到磁盘
image_file.save(image_path)
# 5. 数据库存储相对路径(供前端访问)
relative_path = f'/static/uploads/products/{safe_filename}'
# 6. 创建商品对象
product = Product(
category_id=form.category_id.data,
name=form.name.data,
description=form.description.data,
price=form.price.data,
stock=form.stock.data,
image_url=relative_path # 存储的是相对路径
)
db.session.add(product)
db.session.commit()
flash('商品创建成功', 'success')
return redirect(url_for('admin.products'))
return render_template('admin/product_form.html', form=form)
这段代码展示了工业级文件上传的全部要素:secure_filename()防止路径遍历攻击(如上传../../../etc/passwd);os.makedirs(..., exist_ok=True)确保多级目录自动创建;sha256哈希保证文件名唯一,避免同名覆盖;relative_path的设计让前端无需关心服务器绝对路径,/static/是Flask的静态文件根目录,直接可访问。
4.4 模拟支付二维码生成:qrcode库的深度定制
generate_qr_code()函数位于app/utils/qrcode_utils.py,它不只是简单地把字符串变二维码,而是做了三重定制:
第一重:内容定制
二维码里不放敏感信息,只放一个可公开的、有时效性的订单标识:
def generate_qr_code(order_no, amount, order_id):
"""
生成支付二维码
内容格式:flaskshop://order?no=202310010001&amt=99.99&id=123
使用自定义协议头,避免被微信/QQ等APP直接识别并跳转
"""
content = f"flaskshop://order?no={order_no}&amt={amount}&id={order_id}"
return content
第二重:样式定制
使用qrcode的ImageStyle,生成带Logo的二维码,提升专业感:
import qrcode
from qrcode.image.styledpil import StyledPilImage
from qrcode.image.styles.moduledrawers import RoundedModuleDrawer
from PIL import Image
def create_qr_with_logo(content, logo_path=None):
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_H, # H级纠错,可恢复30%损坏
box_size=10,
border=4,
)
qr.add_data(content)
qr.make(fit=True)
# 创建带圆角的二维码
img = qr.make_image(
image_factory=StyledPilImage,
module_drawer=RoundedModuleDrawer(),
eye_drawer=RoundedModuleDrawer()
).convert('RGB')
# 叠加Logo(可选)
if logo_path and os.path.exists(logo_path):
logo = Image.open(logo_path)
# 计算Logo大小(二维码的20%)
qr_width, qr_height = img.size
logo_size = int(qr_width * 0.2)
logo = logo.resize((logo_size, logo_size), Image.Resampling.LANCZOS)
# 计算Logo位置(居中)
pos = ((qr_width - logo_size) // 2, (qr_height - logo_size) // 2)
img.paste(logo, pos, logo if logo.mode == 'RGBA' else None)
return img
第三重:存储与缓存定制
生成的二维码图片,按订单号命名,并设置HTTP缓存头,减轻服务器压力:
@app.route('/static/qrcodes/<path:filename>')
def serve_qrcode(filename):
# 设置较长的缓存时间,因为二维码内容一旦生成就不会变
response = make_response(send_from_directory('static/qrcodes', filename))
response.cache_control.max_age = 3600 * 24 # 缓存24小时
return response
5. 常见问题与排查技巧实录
5.1 数据库连接失败的五大原因与速查表
| 现象 | 最可能原因 | 快速验证命令 | 解决方案 |
|---|---|---|---|
pymysql.err.OperationalError: (2003, "Can't connect to MySQL server on 'localhost'") | MySQL服务未启动 | systemctl status mysql (Linux) 或 brew services list \| grep mysql (Mac) | sudo systemctl start mysql 或 brew services start mysql |
pymysql.err.OperationalError: (1045, "Access denied for user 'root'@'localhost'") | 用户名或密码错误 | mysql -u root -p,输入密码看能否登录 | 修改config.py中的SQLALCHEMY_DATABASE_URI,确保密码正确;或重置MySQL root密码 |
pymysql.err.OperationalError: (1049, "Unknown database 'flask_shop'") | 数据库未创建 | mysql -u root -p -e "SHOW DATABASES;" | 执行mysql -u root -p -e "CREATE DATABASE flask_shop CHARACTER SET utf8mb4;" |
sqlalchemy.exc.ProgrammingError: (pymysql.err.ProgrammingError) (1146, "Table 'flask_shop.products' doesn't exist") | 没有运行数据库迁移 | python manage.py db history | 先python manage.py db init(如果第一次),再python manage.py db migrate,最后python manage.py db upgrade |
UnicodeEncodeError: 'latin-1' codec can't encode characters | MySQL连接未指定UTF8编码 | 检查SQLALCHEMY_DATABASE_URI末尾是否有?charset=utf8mb4 | 在URI末尾加上?charset=utf8mb4,例如:'mysql+pymysql://root:pass@localhost/flask_shop?charset=utf8mb4' |
实操心得:我习惯在项目根目录下创建一个
debug_db.sh脚本,里面写满上述验证命令,每次遇到数据库问题,直接bash debug_db.sh,5秒定位根源。
5.2 Jinja2模板渲染错误:从报错信息直击问题核心
Flask的模板错误页面非常友好,但新手常被长长的堆栈吓住。记住一个铁律:错误信息的最后一行,才是你该看的地方。
典型错误1:jinja2.exceptions.UndefinedError: 'None' has no attribute 'name'
- 含义:你在模板里写了{{ product.category.name }},但product.category是None(比如商品分类被删了,但商品记录还在)。
- 解决方案:在模板中使用default过滤器:{{ product.category.name if product.category else '未分类' }},或在视图函数中提前处理:category_name = product.category.name if product.category else '未分类'。
典型错误2:jinja2.exceptions.TemplateNotFound: 'admin/base.html'
- 含义:Flask找不到模板文件。常见原因是app/admin/templates/目录结构不对,或者render_template()里写的路径错了。
- 解决方案:检查目录树,确保是app/admin/templates/admin/base.html,而不是app/admin/templates/base.html。render_template()的第一个参数,必须是相对于templates目录的路径,所以是'admin/base.html',不是'base.html'。
典型错误3:jinja2.exceptions.TemplateSyntaxError: expected token 'end of statement block', got '}'
- 含义:Jinja2语法错误,通常是{% if ... %}后面少了{% endif %},或者{{ ... }}里写了非法表达式。
- 解决方案:打开报错提示的模板文件,找到报错行号,检查该行及上一行的语法。一个快速技巧:用VS Code打开,安装“Jinja”插件,它会实时高亮语法错误。
5.3 后台登录失效:Session与Cookie的协同失效排查
管理员登录后,点击后台链接又跳回登录页,这是最让人抓狂的问题之一。它通常不是代码bug,而是环境配置问题。
排查步骤:
1. 检查浏览器Cookie:按F12打开开发者工具,切换到Application/Storage -> Cookies,查看http://localhost:5000下是否有session Cookie。如果没有,说明后端根本没有设置它。
2. 检查Flask Session配置:确认config.py中SECRET_KEY已设置(不能为空字符串),且PERMANENT_SESSION_LIFETIME足够长。
3. 检查HTTPS与Secure Cookie:如果你在config.py中设置了SESSION_COOKIE_SECURE = True,但用的是http://localhost(非HTTPS),浏览器会拒绝保存这个Cookie。解决方案:开发时注释掉SESSION_COOKIE_SECURE,或在config.py中加一个判断:
python if os.environ.get('FLASK_ENV') == 'production': SESSION_COOKIE_SECURE = True else: SESSION_COOKIE_SECURE = False
4. 检查跨域问题(如果用了前端代理):如果你用Vue CLI的proxy代理了/api请求,但后台路由是/admin/,那么/admin/请求不会走代理,Cookie域可能不一致。解决方案:统一用Flask提供所有接口,或确保代理配置正确。
实操心得:我在
app/admin/views.py的login()函数里,登录成功后会加一行print(f"Session set: {session}"),并在终端里观察输出。如果打印出空字典,说明session['admin_logged_in'] = True没生效,大概率是SECRET_KEY为空或session.modified = True没加。
5.4 图片上传失败:文件大小与MIME类型的双重防火墙
管理员上传商品图片时,经常遇到“文件过大”或“不支持的文件类型”错误。这背后是Flask和Web服务器的双重限制。
Flask层面限制:
在app/__init__.py的create_app()函数中,必须设置:
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB
否则,默认限制是16KB,传个稍大的JPG就会失败。
Web服务器层面限制(如果用Gunicorn/Nginx部署):
- Gunicorn:启动时加参数--limit-request-field_size 16384
- Nginx:在nginx.conf中设置client_max_body_size 16M;
MIME类型校验:
request.files.get('image').content_type返回的是浏览器声称的MIME类型,如image/jpeg。但它可以被轻易伪造。所以,本项目在app/admin/views.py中,除了检查filename后缀,还做了二次校验:
from PIL import Image
import io
# 在保存图片前,用PIL打开并验证
try:
img = Image.open(io.BytesIO(image_file.read()))
img.verify() # 验证图片完整性
image_file.seek(0) # 重置文件指针,以便后续save()
except Exception as e:
flash('上传的文件不是有效的图片', 'danger')
return render_template(...)
img.verify()会尝试解码图片,如果文件是伪造的(比如把txt文件改成jpg后缀),这里就会抛出异常。
6. 项目扩展与进阶思考
6.1 从“模拟支付”到“真实对接”:支付宝沙箱环境接入指南
本项目的“扫码支付模拟”,是绝佳的学习跳板。当你理解了订单状态机后,接入真实支付宝就水到渠成。核心差异只有三点:
- 二维码内容:不再是自定义协议,而是支付宝的
alipay://...或https://qr.alipay.com/...链接。 - 支付结果通知:不再是管理员点击“确认收款”,而是支付宝服务器向你的
/alipay/notify接口发送POST请求,包含加密签名。 - 签名验签:你必须用支付宝提供的公钥,验证通知的
sign字段,确保请求来自支付宝,而非黑客伪造。
接入步骤极简:
- 登录支付宝开放平台,创建应用,获取APP_ID、PRIVATE_KEY、ALIPAY_PUBLIC_KEY。
- 将这三个密钥放入config.py。
- 在app/main/views.py中,创建/alipay/create_order接口,调用支付宝alipay.trade.precreate接口,获取qr_code。
- 创建/alipay/notify接口,接收并验签支付宝的异步通知,更新订单状态。
提示:支付宝官方Python SDK(
alipay-sdk-python)已经封装了所有加解密逻辑,你只需关注业务。本项目预留了app/utils/alipay_utils.py文件,里面是完整的SDK初始化和验签示例,你只需填入密钥即可。
6.2 性能瓶颈预判:当商品数破万时,首页加载变慢怎么办?
目前的首页查询:
new_products = Product.query.filter_by(is_on_sale=True).order_by(Product.created_at.desc()).limit(8).all()
当products表有100万条记录时,ORDER BY created_at DESC会触发全表扫描,即使有索引,性能也会急剧下降。
优化方案:
- 添加复合索引:CREATE INDEX idx_is_on_sale_created_at ON products (is_on_sale, created_at);
- 物化视图(MySQL 8.0+):创建一个只包含is_on_sale=1的视图,并为其建立索引。
- 缓存层:用Redis缓存首页数据,设置10分钟过期。视图函数改为:
```python
@main.route(‘/’)
def index():
cache_key = ‘homepage_data’
data = redis_client.get(cache_key)
if data:
return render_template(‘main/index.html’, **json.loads(data))
# 执行耗时查询...
banners = ...
new_products = ...
# ...
result = {'banners': ..., 'new_products': ...}
redis_client.setex(cache_key, 600, json.dumps(result))
return render_template('main/index.html', **result)
```
6.3 安全加固清单:从教学项目到生产环境的必做事项
一个教学项目,离生产环境还有五步:
1. 密码存储:将users.password字段从明文改为bcrypt哈希。pip install bcrypt,然后在用户注册时:hashed = bcrypt.generate_password_hash(form.password.data).decode('utf-8')。
2. CSRF防护:确保所有POST表单都包含{{ form.hidden_tag() }},它会自动注入CSRF Token。
3. XSS防护:在Jinja2模板中,所有用户输入的内容,必须用|safe过滤器谨慎使用。默认情况下,{{ user_input }}是自动转义的。
4. SQL注入防护:本项目已全部使用SQLAlchemy ORM或参数化查询(text("... :param")),杜绝了拼接SQL的风险。
5. HTTPS强制:在生产config.py中,设置SESSION_COOKIE_SECURE = True和SESSION_COOKIE_HTTPONLY = True,并用Nginx做SSL终止。
我个人在实际使用中发现,最常被忽略的是第1条和第5条。很多学员做完项目,直接把config.py里的SECRET_KEY = 'dev-secret-key'放到GitHub上,这是重大安全隐患。我的做法是:SECRET_KEY、数据库密码、支付宝密钥,全部从环境变量读取,并在.gitignore中加入instance/目录,把真正的配置文件放在那里。
这个Flask电商实战包,就像一把瑞士军刀,它不追求成为最锋利的剑,但把你能想到的电商开发场景,都打磨成了可用的刀刃。从第一个flask run看到首页,到亲手修复一个超卖Bug,再到为它加上Redis缓存,每一步,你都在把抽象的知识,锻造成肌肉记忆。电商系统的复杂性,不在于它有多高深,而在于它有多少个环环相扣的细节。而这个项目的价值,就是把这些细节,摊开在你面前,任你拆解、把玩、重构。当你有一天,能对着一份新的电商需求文档,胸有成竹地画出ER图、写出蓝图路由、估算出缓存策略时,你就知道,这把刀,已经真正长在了你的手上。
简介:一套开箱即用的Flask电商系统源码,后端基于MySQL数据库,使用SQLAlchemy完成ORM映射,结构清晰、功能完整。用户端支持首页轮播展示、商品分类浏览(最新/折扣/热门)、商品详情查看、购物车增删改查、收货地址填写、订单提交及模拟支付宝收款二维码生成;订单提交后可实时查看状态和明细。管理员后台提供商品全生命周期管理(添加、编辑、上下架、销量统计)、会员信息列表查看、订单审核与状态更新。配套资源齐全:shop.sql建库脚本、requirements.txt依赖清单、config.py配置文件、manage.py启动入口,以及项目文档.md、业务流程图、系统功能架构图、文件夹组织图、数据表关系图和字段结构说明图,便于理解整体设计逻辑和快速上手调试。适合Python Web学习者通过真实电商场景掌握路由设计、模板渲染、表单处理、会话管理、数据库操作及前后端协同开发全流程。


被折叠的 条评论
为什么被折叠?



