Flask电商网站开发实战:从架构到部署完整指南
2026/9/16 21:32:50 网站建设 项目流程

做电商网站,技术栈怎么选?我个人的答案一直很明确:中小型项目,直接用 Flask。不用纠结,不用犹豫,Flask 轻量、灵活,既可以快速搭出一个能跑通全流程的网站,又不会像重型框架那样给你套上太多条条框框。这篇实战笔记,我会完整走一遍基于 Flask 的电子商务网站开发流程:从需求拆解、数据库建模,到用户注册登录、商品展示、购物车、下单模拟支付,再到后台管理和本地部署。项目带完整代码,适合有一定 Python 基础、想系统接触 Web 开发的朋友,看完照着敲一遍,你就能拥有一个属于自己的电商站点,而不是只停留在语法层面的“Python 学习者”。

整个项目我会坚持“最小可用”原则,能用一个文件说清的,绝不分三个文件;但该分离的,比如模型层、视图层,还是得分清楚。这样做的原因后面会讲,但它直接决定了你后续扩展功能的难度。

1. 项目整体设计与架构拆解

1.1 选型逻辑:为什么是 Flask 而不是 Django

很多朋友一上来就问我:做电商网站不是应该用 Django 吗?Django 自带 Admin 后台、ORM、用户认证,听起来确实省事。但我的看法是,Django 的“全家桶”模式对新手并不友好——它把一切都给你准备好了,你自己反而不知道底层发生了什么。而 Flask 只给你一个核心:路由和视图,其他的自由组合。这种“自己做主”的感觉,恰恰是理解 Web 开发本质的最好途径。

具体到电商场景,Flask 的优势主要体现在三块:

  • 自由度高:数据库层、表单层、用户认证都可以按需选型,项目结构由你掌控,不会被迫接受框架的某些约定。
  • 轻量高效:一个 Flask 应用启动只需要很少的内存和依赖,本地开发体验非常流畅,调试也方便。
  • 学习曲线平缓:路由怎么写、视图怎么返回 HTTP 响应,几分钟就能上手。把精力留在业务逻辑上,而不是折腾框架配置。

当然,如果你要做的是一个百万级用户的大型电商平台,Flask 确实需要额外做很多工程化工作。但对我们这个项目来说,Flask 是性价比最高的选择。

1.2 功能模块与数据流设计

动手写代码之前,把功能模块先拆清楚。我把它分成五个核心模块:

模块核心功能涉及的数据表
用户模块注册、登录、退出登录、会话保持users
商品模块商品列表、商品详情、库存展示products
购物车模块加购、修改数量、删除、清空无独立表(用 session)
订单模块下单、模拟支付、订单列表orders, order_items
后台管理模块商品管理、订单状态更新复用以上数据表

数据流是典型的电商链路:用户注册登录 → 浏览商品列表 → 查看商品详情 → 加入购物车 → 在购物车调整数量 → 点击结算 → 生成订单 → 模拟支付 → 在“我的订单”中查看订单状态。

这个流程覆盖了一个电商网站最核心的主干。支付网关、物流追踪、营销活动这类东西,属于枝干功能,第一版 MVP(最小可行产品)阶段先不做,等主干跑通了再往上加,你会发现容易得多。

1.3 项目目录结构规划

我实际使用的项目目录结构如下:

flask_ecommerce/ ├── app.py # 应用工厂与入口 ├── models.py # 数据库模型 ├── views.py # 视图函数(路由) ├── extensions.py # 扩展对象初始化(db等) ├── config.py # 配置项 ├── requirements.txt # 依赖列表 ├── templates/ # Jinja2 模板 │ ├── base.html │ ├── index.html │ ├── product_detail.html │ ├── cart.html │ ├── login.html │ ├── register.html │ ├── checkout.html │ ├── order_list.html │ └── admin/ │ ├── products.html │ └── orders.html └── static/ ├── style.css └── logo.png

这个结构是“半分离”的:模型、视图、模板分层清晰,但又不至于为了追求“企业级规范”把简单问题复杂化。你以后如果要把这个项目改造成蓝本(Blueprint)模式,直接在 views.py 里拆分即可,迁移成本几乎为零。

2. 环境准备与项目初始化

2.1 Python 版本与虚拟环境

项目基于 Python 3.8+,推荐使用 3.10 或 3.11,稳定性和兼容性都比较好。如果你还没装 Python,去官网下载安装包,安装时记得勾选“Add Python to PATH”,否则后续在命令行里执行python会找不到命令。

虚拟环境是这个项目必不可少的工具。它能让每个 Python 项目拥有独立的依赖空间,不会出现“A 项目要用 Flask 2.x,B 项目要用 Flask 3.x,结果两边打架”的尴尬局面。

创建并激活虚拟环境:

# 在项目根目录执行 python -m venv venv # Windows 激活 venv\Scripts\activate # macOS / Linux 激活 source venv/bin/activate

激活后,命令行提示符前面会出现(venv)标记,这代表你已经在虚拟环境内部了。

2.2 安装 Flask 与扩展库

直接 pip 安装下面几个库,一条命令搞定:

pip install flask flask-sqlalchemy

如果后续想加表单校验,可以再装flask-wtf;想加登录扩展就装flask-login。但第一版我建议大家先不装这些“捷径库”,原因有两个:一是自己写表单校验和登录装饰器,能帮你看清 Web 开发中的关键机制;二是减少黑盒依赖,出问题时排查范围更小。

装完后,用 pip freeze 生成依赖清单:

pip freeze > requirements.txt

2.3 应用工厂与配置

为什么用应用工厂模式?简单说,就是用一个函数来创建应用实例,而不是在模块顶层直接app = Flask(__name__)。这样做的优势是:每次调用函数可以得到全新的应用实例,方便测试环境、开发环境用不同配置。

extensions.py先初始化扩展对象:

from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy()

config.py定义配置:

import os BASE_DIR = os.path.abspath(os.path.dirname(__file__)) class Config: SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-me' SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(BASE_DIR, 'ecommerce.db') SQLALCHEMY_TRACK_MODIFICATIONS = False

app.py实现应用工厂:

from flask import Flask from config import Config from extensions import db def create_app(config_class=Config): app = Flask(__name__) app.config.from_object(config_class) db.init_app(app) from views import bp app.register_blueprint(bp) with app.app_context(): db.create_all() return app if __name__ == '__main__': app = create_app() app.run(debug=True)

SECRET_KEY有两个注意点:开发环境随便写没关系,但生产环境必须从环境变量读取,绝不能把真实密钥硬编码进代码仓库。SQLALCHEMY_TRACK_MODIFICATIONS = False是为了关闭不必要的对象追踪警告,省点内存。

3. 数据库建模:用户、商品、订单

3.1 为什么用 SQLite 和 SQLAlchemy

第一版项目,我用 SQLite 做数据库,没有额外装 MySQL、PostgreSQL。原因很简单:零配置,文件即数据库,开发机上直接跑,不需要单独起数据库服务。等到真正部署上线时,再把连接串从sqlite:///...改成mysql+pymysql://用户:密码@主机/库名,SQLAlchemy 能帮我们屏蔽掉大部分底层差异。

SQLAlchemy 是目前 Python 生态里最成熟的 ORM 框架,它让你用 Python 类来定义表结构,不再手写原生 SQL。购物车查询、多表关联查询这些逻辑,在 ORM 里就是一次普通的方法调用,可读性和可维护性都高很多。

3.2 用户模型与密码安全

从安全角度讲,密码绝不能明文保存。一旦数据库泄露,用户的明文密码就会直接暴露,而大多数人喜欢在不同平台复用密码,后果不堪设想。所以注册时,我们用werkzeug.securitygenerate_password_hash做哈希处理,登录时用check_password_hash校验。

用户模型:

from datetime import datetime from extensions import db from werkzeug.security import generate_password_hash, check_password_hash class User(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False, index=True) password_hash = db.Column(db.String(256), nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) orders = db.relationship('Order', backref='user', lazy='dynamic') def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) def __repr__(self): return f'<User {self.username}>'

请留意username字段加了unique=Trueindex=True。唯一约束保证注册时不能重名,索引提高按用户名查询时的效率。电商网站的用户量上来后,这两个字段的索引能显著加快登录查询。

3.3 商品模型与订单模型

商品表包含电商最基本的字段:名称、价格、库存、图片地址。注意价格这里我采用整数(单位:分)存储,而不是使用浮点数。为什么?因为浮点数有精度问题,0.1 + 0.2 并不等于 0.3,价格计算会出现“差一分钱”的 bug。用整分来存储,展示时再除以 100 转换,是电商系统里非常常见的做法。

class Product(db.Model): __tablename__ = 'products' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(120), nullable=False, index=True) description = db.Column(db.Text, nullable=True) price = db.Column(db.Integer, nullable=False) # 单位:分 stock = db.Column(db.Integer, nullable=False, default=0) image_url = db.Column(db.String(256), nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) def to_dict(self): return { 'id': self.id, 'name': self.name, 'description': self.description, 'price': self.price, 'stock': self.stock, 'image_url': self.image_url, }

订单模型分两张表:订单主表orders和订单明细表order_items。这样做是电商标准的“头-明细”结构。一个订单包含多个商品项,主表只存订单总额、用户、状态等公共信息,明细表存每一项的购买数量、快照价格。

class Order(db.Model): __tablename__ = 'orders' id = db.Column(db.Integer, primary_key=True) order_no = db.Column(db.String(32), unique=True, nullable=False, index=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) total_amount = db.Column(db.Integer, nullable=False, default=0) # 单位:分 status = db.Column(db.String(16), nullable=False, default='pending') # pending/paid/shipped/completed/cancelled created_at = db.Column(db.DateTime, default=datetime.utcnow) items = db.relationship('OrderItem', backref='order', lazy='dynamic', cascade='all, delete-orphan') class OrderItem(db.Model): __tablename__ = 'order_items' id = db.Column(db.Integer, primary_key=True) order_id = db.Column(db.Integer, db.ForeignKey('orders.id'), nullable=False) product_id = db.Column(db.Integer, db.ForeignKey('products.id'), nullable=False) product_name = db.Column(db.String(120), nullable=False) # 商品名称快照 product_price = db.Column(db.Integer, nullable=False) # 下单时单价快照 quantity = db.Column(db.Integer, nullable=False, default=1) product = db.relationship('Product', backref='order_items')

你在order_items里会看到product_nameproduct_price这两个“冗余字段”。这在电商里叫“商品快照”。为什么要存快照?比如用户下单时商品价格是 100 元,三个月后商品涨价到 120 元,难道订单记录要跟着改吗?当然不能。订单必须记录下单那一刻的商品信息,所以即使是冗余,也必须存。

关于cascade='all, delete-orphan',意思是删除订单时,自动删除其下所有订单明细。这样防止产生“有明细无主单”的脏数据。

初始化测试数据的时候,可以写一段一次性脚本,往商品表里塞几条演示商品,方便后续功能调试。

4. 用户注册登录与会话管理

4.1 注册页面的实现

注册页的核心处理流程是:接收前端 POST 过来的用户名、邮箱、密码,先检查用户名是否已存在,再检查两次密码是否一致,最后创建用户并跳转到登录页。

@bp.route('/register', methods=['GET', 'POST']) def register(): if request.method == 'POST': username = request.form.get('username', '').strip() email = request.form.get('email', '').strip() password = request.form.get('password', '') password2 = request.form.get('password2', '') if not username or not email or not password: flash('请完整填写表单') return redirect(url_for('register')) if password != password2: flash('两次输入的密码不一致') return redirect(url_for('register')) existing = User.query.filter((User.username == username) | (User.email == email)).first() if existing: flash('用户名或邮箱已被注册') return redirect(url_for('register')) user = User(username=username, email=email) user.set_password(password) db.session.add(user) db.session.commit() flash('注册成功,请登录') return redirect(url_for('login')) return render_template('register.html')

这里有几个实操经验点需要说明:

  • 前端表单里必须做required标记和后端双重校验。前端校验决定用户体验(不提交就知道没填完),后端校验决定数据安全(绕过前端攻击的用户可以被拦截)。
  • flash是 Flask 内置的“闪现消息”机制,非常适合做一次性的错误提示。配合模板里的get_flashed_messages()使用,页面刷新后消息自动消失,不会堆积。
  • 数据库查询filter((User.username == username) | (User.email == email))用的是 SQLAlchemy 的|逻辑或运算符,最终会生成WHERE username = ? OR email = ?的 SQL,效率不错。

用户名已存在时的报错提示,有人会建议直接不区分“用户名已存在”和“邮箱已存在”,只提示“用户名或邮箱已被注册”。这样可以避免攻击者通过错误提示判断某个用户名是否存在于系统中,属于基础的安全意识。

4.2 登录与会话保存

登录逻辑:

@bp.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': username = request.form.get('username', '').strip() password = request.form.get('password', '') user = User.query.filter_by(username=username).first() if user and user.check_password(password): session['user_id'] = user.id session.permanent = True flash('登录成功') return redirect(url_for('index')) flash('用户名或密码错误') return redirect(url_for('login')) return render_template('login.html')

Flask 的session和你在浏览器上强会话中的 Cookie 是相关的,不同的是 Flask 把 session 数据做了签名存在客户端的 Cookie 里。这意味着用户无法篡改 session 内容——因为他没有服务端的SECRET_KEY,改了签名就会失效。这是 Flask session 的安全基础。

这里有个细节:session['user_id'] = user.id,我在 session 里只存用户 ID,不存用户名、密码等敏感字段。需要查询用户信息时,用这个 ID 去数据库加载最新数据。这样可以最大程度避免“用户改了昵称,session 里的旧昵称不更新”之类的脏数据问题。

登出就是清掉 session:

@bp.route('/logout') def logout(): session.pop('user_id', None) flash('已退出登录') return redirect(url_for('index'))

4.3 登录状态控制与访问控制

很多页面(比如购物车结算、个人中心)要求用户必须登录。如果每个视图都写一遍“判断 session 里有没有 user_id”,代码会很啰嗦。我写了一个装饰器:

from functools import wraps from flask import session, redirect, url_for, flash def login_required(view_func): @wraps(view_func) def wrapped_view(*args, **kwargs): if not session.get('user_id'): flash('请先登录') return redirect(url_for('login')) return view_func(*args, **kwargs) return wrapped_view

使用的时候,在视图函数上方加@login_required即可。这个装饰器想表达的逻辑很直观:检查 session 中是否有user_id,没有就踢到登录页。@wraps(view_func)这行很多人会忽略,它的作用是保留被装饰函数的名字和文档字符串,否则 Flask 的 URL 反向解析会出现“视图函数名重复”的报错。

如果你后续有条件,可以用同样的思路做admin_required,只要再加一个判断:当前登录用户是否有管理员权限。

5. 商品展示与购物车实现

5.1 商品列表与详情页

商品列表页直接查询所有商品:

@bp.route('/') def index(): products = Product.query.order_by(Product.created_at.desc()).all() return render_template('index.html', products=products)

商品数据量小的时候,.all()没问题。但当商品达到几千、几万条时,必须分页。Flask-SQLAlchemy 提供了paginate方法:

page = request.args.get('page', 1, type=int) paginator = Product.query.order_by(Product.created_at.desc()).paginate(page=page, per_page=12, error_out=False)

per_page=12是电商首页常见的每页展示数量,error_out=False让页面不存在时返回空列表而不是抛出 404,体验更友好。模板里渲染时,用paginator.items取当前页商品,用paginator.has_prev/paginator.has_next控制上一页、下一页按钮。

商品详情页按主键查:

@bp.route('/product/<int:product_id>') def product_detail(product_id): product = Product.query.get_or_404(product_id) return render_template('product_detail.html', product=product)

get_or_404是一个很神的小方法:查不到数据时直接返回 404 页面,不需要你手动判断。注意路由参数类型写了<int:product_id>,这样product_id会被强制转换成整数,如果用户访问/product/abc,Flask 会自动返回 404,不会把abc传给视图再让数据库查错。

5.2 购物车的数据结构设计

购物车我选择放在 session 里,而不是建一张carts表。为什么?

从功能角度讲,第一版电商站的购物车数据是“临时性”的——绝大多数用户登录后买东西,买完就不管了。如果为此建表、关联用户、处理过期逻辑,工作量会翻一倍,收益却很小。从扩展角度讲,session 存购物车也方便未来迁移到 Redis 缓存系统,接口结构可以保持一致。

购物车的结构非常清晰,就是一个字典:

cart = { 商品ID: 数量, 商品ID: 数量, ... }

Python 字典天然适合这种“键值对”结构。我用 session 存的购物车就是从 Cart 单词首字母命名的cart键,存的是一个 JSON 序列化后的字典。

5.3 购物车的增删改查

先写一个从 session 里取购物车的辅助函数:

def get_cart(): cart = session.get('cart', {}) # 确保购物车里的所有值都是整数 return {int(k): int(v) for k, v in cart.items()} def save_cart(cart): session['cart'] = {str(k): v for k, v in cart.items()}

注意这里做了一层键值类型转换,因为 session 在序列化到 Cookie 时,所有键会自动变成字符串。如果没有这层转换,后续cart[1]cart['1']会出现两个不同的键,逻辑就全乱了。

加购接口:

@bp.route('/add-to-cart/<int:product_id>', methods=['POST']) def add_to_cart(product_id): product = Product.query.get_or_404(product_id) quantity = max(int(request.form.get('quantity', 1)), 1) cart = get_cart() cart[product_id] = cart.get(product_id, 0) + quantity # 库存校验:不能超过库存 if cart[product_id] > product.stock: flash('库存不足') return redirect(url_for('product_detail', product_id=product_id)) save_cart(cart) flash('已加入购物车') return redirect(url_for('cart'))

这里用max(int(...), 1)是为了防止用户提交负数或者 0 的数量。注意库存校验放在加购时做一次,这只是第一道防线。真正扣库存的时机在下单时,地道且必须的是,下单时在高并发场景下做二次校验(比如带上条件WHERE stock >= quantity再 UPDATE),这点我会在订单模块继续讲。

购物车页面的路由:

@bp.route('/cart') def cart(): cart = get_cart() items = [] total = 0 for product_id, quantity in cart.items(): product = Product.query.get(product_id) if product: subtotal = product.price * quantity total += subtotal items.append({ 'product': product, 'quantity': quantity, 'subtotal': subtotal, }) return render_template('cart.html', items=items, total=total)

购物车修改数量、删除条目,本质都是更新 session 字典里的值。我这里给出一个“更新数量”的接口:

@bp.route('/update-cart/<int:product_id>', methods=['POST']) def update_cart(product_id): cart = get_cart() quantity = int(request.form.get('quantity', 1)) if quantity <= 0: cart.pop(product_id, None) else: cart[product_id] = quantity save_cart(cart) return redirect(url_for('cart'))

这里的细节是:当数量小于等于 0 时,直接把商品从购物车移除,而不是把它留成负数。删除按钮完全可以通过一个quantity=0的表单提交来触发,这样前端少一个接口,逻辑又统一。

6. 订单流程与模拟支付

6.1 下单逻辑与事务控制

用户点击“去结算”后,进入订单创建流程。这段代码是整个项目里最该谨慎的地方,因为它涉及库存扣减和订单生成,一旦出错,就会出现“库存扣了但订单没生成”或者“订单生成但库存没扣”的严重事故。

解决这个问题的关键就是数据库事务。把“创建订单”“扣减库存”“清空购物车”放到同一个事务里,要么全部成功,要么全部失败回滚。

@bp.route('/checkout', methods=['GET', 'POST']) @login_required def checkout(): if request.method == 'POST': user_id = session['user_id'] cart = get_cart() if not cart: flash('购物车为空') return redirect(url_for('cart')) user = User.query.get(user_id) order = Order( order_no=generate_order_no(), user_id=user_id, status='pending', total_amount=0 ) db.session.add(order) db.session.flush() # 提前拿到 order.id total_amount = 0 for product_id, quantity in cart.items(): product = Product.query.get(product_id) if not product or product.stock < quantity: db.session.rollback() flash(f'商品 {product.name if product else "未知"} 库存不足') return redirect(url_for('cart')) # 扣减库存,带上条件防止超卖 affected_rows = Product.query.filter_by(id=product_id, stock >= quantity) \ .update({'stock': Product.stock - quantity}) if affected_rows == 0: db.session.rollback() flash(f'商品 {product.name} 库存不足') return redirect(url_for('cart')) item = OrderItem( order_id=order.id, product_id=product.id, product_name=product.name, product_price=product.price, quantity=quantity ) db.session.add(item) total_amount += product.price * quantity order.total_amount = total_amount db.session.commit() # 清空购物车 session.pop('cart', None) flash('下单成功,请进行支付') return redirect(url_for('order_detail', order_id=order.id)) return render_template('checkout.html')

请你注意几个关键点:

  1. db.session.flush()的作用是立即生成 SQL 发送到数据库,让order.id不再为空。flush并不提交事务,只是把当前 session 中的操作同步到数据库,这样才能拿到自增主键。
  2. 扣减库存时用了Product.query.filter_by(id=product_id, stock >= quantity).update(...)。这一步就是刚才说的“二次校验”,通过 SQL 的条件语句保证只有当库存足够时才更新。affected_rows为 0 说明并发情况下别人已经把库存抢光了,此时必须回滚,绝不硬扣。
  3. session.pop('cart', None)放在commit之后。如果事务回滚了,购物车里的数据还在,用户可以重新尝试购买,而不是莫名其妙丢了购物车。

6.2 订单号生成规则

订单号不能直接暴露自增 ID 给用户,因为那样用户可以推断出“今天注册了多少订单”。我写了一个简单的订单号生成函数:

import time import random def generate_order_no(): # 格式:时间戳 + 6位随机数 ts = time.strftime('%Y%m%d%H%M%S', time.localtime()) rand = str(random.randint(100000, 999999)) return f'{ts}{rand}'

时间戳精确到秒,再加 6 位随机数,基本能保证同一秒内产生的订单号不重复。如果你要严格保证唯一性,可以把order_no字段设置成唯一索引(我们已经在模型中做了unique=True),万一生成重复,捕获IntegrityError异常后重新生成即可。

6.3 模拟支付与订单状态流转

第一版项目不做真实支付网关对接,但我会把“模拟支付”这个动作做得很真实:

@bp.route('/order/<int:order_id>/pay', methods=['POST']) @login_required def pay_order(order_id): order = Order.query.filter_by(id=order_id, user_id=session['user_id']).first_or_404() if order.status != 'pending': flash('订单状态不允许支付') return redirect(url_for('order_detail', order_id=order.id)) order.status = 'paid' db.session.commit() flash('支付成功,商家将尽快发货') return redirect(url_for('order_detail', order_id=order.id))

这里做了一个关键的状态校验:只有pending状态的订单才能支付。如果已经是paid或者cancelled,直接拒绝操作,防止重复支付。真实项目对接支付宝、微信支付时,思路是一样的:支付回调里先去查订单当前状态,只有待支付状态才允许更新为已支付,其他状态直接忽略。

订单状态流转我用一个常量列表维护:

ORDER_STATUS = { 'pending': '待支付', 'paid': '已支付', 'shipped': '已发货', 'completed': '已完成', 'cancelled': '已取消', }

后台管理里可以通过表单把订单从paid改成shipped,从shipped改成completed。这套状态机逻辑非常简单清晰,真实电商里还会有“退款中”“退款成功”“部分发货”等状态,原理都是在状态迁移前做合法性校验。

7. 后台管理、常见报错与部署上线

7.1 一个轻量后台管理页面

Django 有现成的 Admin 后台,Flask 没有官方后台,但这对我们来说不是坏事——自己写一个,正好把前面学的 CRUD 操作用起来。

后台的路由我放在/admin前缀之下:

@bp.route('/admin') @login_required @admin_required def admin_index(): return render_template('admin/index.html') @bp.route('/admin/products') @login_required @admin_required def admin_products(): products = Product.query.order_by(Product.id.desc()).all() return render_template('admin/products.html', products=products) @bp.route('/admin/products/create', methods=['GET', 'POST']) @login_required @admin_required def admin_product_create(): if request.method == 'POST': name = request.form.get('name', '').strip() price = int(float(request.form.get('price', '0')) * 100) # 元转分 stock = int(request.form.get('stock', '0')) description = request.form.get('description', '') image_url = request.form.get('image_url', '') if not name: flash('商品名称不能为空') return redirect(url_for('admin_product_create')) product = Product(name=name, price=price, stock=stock, description=description, image_url=image_url) db.session.add(product) db.session.commit() flash('商品创建成功') return redirect(url_for('admin_products')) return render_template('admin/product_form.html')

商品创建时,前端输入的是“元”单位的金额,后端用int(float(...) * 100)转换成“分”存储。这里要注意不要直接int(price * 100),否则如果是像 19.99 这种数值,浮点运算后得多余的精度可能会导致结果变成 1998.999999999,转 int 后就变成 1998 分,凭空少了 1 分钱。

admin_required装饰器和login_required类似,区别在于多了一个“当前用户是不是管理员”的判断。第一版我直接用一个环境变量配置管理员用户名列表:

ADMIN_USERNAMES = ['admin']

生产环境里可以用settings.py读取环境变量,或者建一个roles字段保存在数据表,方式很多,但核心思路不变。

7.2 本地开发遇到的常见报错与排查技巧

这个项目我自己在开发过程中踩过几个坑,整理成一张速查表,大家遇到问题时直接对照:

报错信息原因解决方案
sqlalchemy.exc.OperationalError: database is lockedSQLite 在并发写入时会锁库本地调试不严重时重启即可;生产环境换 MySQL/PostgreSQL
jinja2.exceptions.TemplateNotFound模板文件路径写错了检查templates/目录下是否有对应文件,注意大小写和目录层级
UnboundLocalError: local variable 'product' referenced before assignment循环里某次查询没拿到数据,后面却引用了变量循环中查不到数据时continue或做空判断
TypeError: 'NoneType' object is not subscriptablesession 中某个键不存在却直接取值使用.get(key, default)并做 None 校验
密码登录永远失败注册时未走user.set_password(),而是直接把明文存进字段删除该用户,重新走一次注册流程
The session is unavailable because no secret key was set没有配置SECRET_KEYconfig.py中配置SECRET_KEY

我最想说的是第一个:SQLite 的database is locked。很多人第一次遇到都懵,其实这个错误在并发场景下是完全正常的——SQLite 只支持一个写者,多个进程同时写时会锁库。本地调试时,我通常先检查是不是有其他 Python 进程把数据库文件占了,用lsof或任务管理器找出来杀掉。真正部署到服务器时,绝对不能再用 SQLite,换成 PostgreSQL 或者 MySQL 才是正解。

7.3 部署上线:gunicorn + nginx 的基本思路

项目在本地跑通后,部署到 Linux 服务器的思路并不复杂。最朴素的方案是这样的:

# 在服务器上,项目根目录 pip install -r requirements.txt pip install gunicorn # 启动 gunicorn gunicorn -w 4 -b 127.0.0.1:8000 "app:create_app()"

简单解释一下:-w 4表示启动 4 个 worker 进程,-b 127.0.0.1:8000表示监听本地 8000 端口。之所以监听 127.0.0.1 而不是 0.0.0.0,是因为前面通常还挡着一层 Nginx,由 Nginx 接收外部请求并转发给 gunicorn。

Nginx 的站点配置可以像这样:

server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /static/ { alias /path/to/flask_ecommerce/static/; } }

这里location /static/把静态文件直接交给 Nginx 托管,不再经过 gunicorn,能显著减轻 Python 进程的压力。生产环境的SECRET_KEY一定要通过环境变量传入,而不是写死在代码里,这点前面提过,再多强调一次——它决定了你的 session 是否会被人恶意伪造。

部署这个环节,第一版你能做到“gunicorn 起服务、Nginx 反向代理、静态文件分离、日志落盘”,就已经远超很多半路出家的开发者了。再往后就是 Docker 化、CI/CD、日志监控那些体系化的东西,慢慢积累即可。

写在最后,一些大实话

做完这个项目,你会在脑子里形成一条清晰的 Web 开发主线:路由处理 HTTP 请求,ORM 操作数据库,模板渲染页面给用户,session 维持用户状态。这套认知是所有 Web 框架通用的,学完 Flask 再去碰 Django、FastAPI,你会觉得非常亲切。

我个人最想分享的一点是:这个项目里“购物车存在 session 里”这个设计,被很多人吐槽不够“正统”,但我觉得在 MVP 阶段这是效率最高的取舍。做项目的关键不是一开始就设计一个完美的架构,而是在跑通主流程后,有能力识别出哪些技术债该还、哪些可以继续留着。等你真的遇到“用户在手机上加了购物车,电脑上却看不到”这种跨端问题,自然就有动力把 session 购物车升级成数据库版或 Redis 版了。

另外一个非常推荐的操作是:把项目代码 push 到 GitHub 或 Gitee 上。放着吃灰的代码,和放在代码托管平台上的代码,完全不是一个性质。代码推上去之后,不光方便你随时回溯调试,也方便你向别人展示自己的工作成果,面试时直接甩个链接,比打印几十页代码有效多了。

到这里,整套基于 Flask 的电子商务网站开发流程就完整走完了。记住,先跑起来,再谈优化;先能用,再谈完美。把这段项目的每一步亲手敲出来、改出来、踩一遍坑再爬起来,你就会发现,Web 开发这事儿的门槛并没有想象中那么高。

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

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

立即咨询