做了几年 Flask 项目,也带过不少刚入门的同学做课程设计,看到“flask+python的每日鲜牛奶订购系统的设计与实现 商家”这个题目,第一反应是:这确实是个很适合练手、又能落地的小型业务系统。它的业务链路很清晰——商家维护奶品、管理客户、处理每日订购单、跟踪配送状态,既覆盖了常见 Web 系统的增删改查,又带了一点“周期性任务”的设计难度,比单纯的博客系统有嚼头,又比电商系统轻量得多。
这篇文章我会从需求和数据库设计讲起,把商家端的几个核心模块(登录、奶品管理、客户管理、订购下单、当日配送单、统计看板)逐个拆开讲,附上关键代码和踩坑记录。如果你正在做类似题目,或者想用 Flask 做一个真实可用的小型订单管理系统,这篇文章可以直接照着改。
1. 项目概述与需求拆解
1.1 鲜奶订购业务到底在解决什么问题
鲜奶订购和普通外卖下单最大的不同在于:它不是“一单一送”,而是“一次订购、每天配送”。客户订的是“未来 30 天每天一瓶鲜奶”,商家要负责在这 30 天里按计划出单、送奶、记账。如果只做一张订单表存“开始日期、结束日期、总量”,那每天的配送状态、漏送补送、客户临时停奶退奶,都会变成一团乱麻。
所以这个系统的核心矛盾是:订购行为是周期性的,而配送行为是每日实时的。数据库设计必须把这两层拆开——上面存“订购计划”,下面存“每日配送明细”。这是整个系统最关键的决策,后面会细说。
商家端需要处理的实际场景包括:
- 维护自家在售的奶品,比如鲜牛奶、酸奶、高钙奶,每种的规格、价格、状态(上架/下架)。
- 建立客户档案,记录姓名、电话、配送地址、配送路线,方便按路线批量安排送奶。
- 给老客户开订购单:选客户、选奶品、填起止日期和每日数量,系统自动生成每天的配送明细。
- 每天打开系统看“今日应送”列表,逐单标记“已送达”,处理客户临时停送、补送。
- 月底或随时查看这个月的营收、各奶品的订购量、客户排行,用来做经营决策。
从标题看,这是一个以“商家”为中心的独立系统,就算不做用户端小程序或 H5,单靠商家后台也能把整个门店的配送业务跑起来。我下面讲的方案就以商家后台为完整闭环。
1.2 技术选型上为什么是 Flask 而不是 Django
Flask 在这个场景下是合理选择,甚至比 Django 更合适。理由有三点:
第一,项目规模小,表结构不超过 6 张,业务逻辑集中在订单生成和统计,用 Flask 的轻量路由和视图函数足够清晰,不需要 Django 自带的后台管理、ORM 方言、中间件体系,反而是负累。
第二,Flask 的 ORM(Flask-SQLAlchemy)和模板引擎(Jinja2)上手快,代码可读性强。对于课程设计或小型落地项目,快速出原型比功能堆砌更重要。
第三,Flask 的生态足够支撑扩展需求,后面想加用户端小程序,只需要把 API 改造为 RESTful 接口;想加图表统计,可以直接在模板里引入 ECharts 或者用 Chart.js 渲染后端返回的 JSON。
如果你是第一次做这类系统,技术栈建议这样配:Python 3.10+、Flask 2.3+、Flask-SQLAlchemy、Flask-WTF(表单与 CSRF 防护)、Werkzeug 的密码散列函数。数据库先用 SQLite,跑通以后再切 MySQL 成本也很低。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与核心模型实现
2.1 五大核心表的建模思路与关联关系
先说结论,这个系统最少需要六张表:admin(商家账号)、customer(客户)、milk_product(奶品)、order_main(订购主表)、delivery_record(每日配送明细)、route(配送路线,可以并入 customer 字段)。
表与表之间的关联是这样的:
- 一个客户可以有多张订购单,order_main 通过 customer_id 关联 customer。
- 一张订购单对应一种奶品,所以 order_main 直接存 product_id,不需要再拆订单商品明细表。
- 一张订购单会生成多条配送记录,delivery_record 通过 order_id 关联 order_main。
- 配送记录里冗余客户名和奶品名(或通过关系属性动态获取),目的是让“当日配送单”查询时少一次 join,性能更好也更直观。
这里的冗余是有意为之,不是为了炫技。每天查询配送记录时,如果每次都去 join 客户表和奶品表,数据量大了以后模板渲染会很啰嗦。直接在配送记录里冗余 customer_name 和 product_name,能大幅简化视图和模板代码。代价是更新客户姓名或奶品名时要同步更新历史配送记录,但鲜奶订购场景里改名极其少见,这个代价可以接受。
除了基础字段,customer 表建议加 route_no(路线编号)字段,比如“A线”“B线”。送奶师傅早上按路线跑,系统里“按路线筛选今日配送单”就能直接派上用场。这是很多类似项目没做、实际使用中却很刚需的功能。
2.2 每日订购逻辑表结构设计:为什么单独建配送记录表
这是整篇文章最想讲清楚的一点。
建模的时候,新手最容易犯的错误是:在一张 order_main 表里放 start_date、end_date、total_quantity,然后通过日期计算来判断“今天该不该送”。表面上省了一张表,但真正用起来会发现几个问题:
一是没法记录“今天这单到底送了没有”。如果客户今天出差要求停送一天,你只能在订单上额外加一堆标记字段,比如 pause_dates(停送日期列表),查询时要拆字符串,恶心到爆炸。
二是没法处理补送。前天漏送了一瓶,今天补上,这种“单日意外”需要一个独立于订购计划的载体来记录。
三是统计口径混乱。想查“本周实际送了多少瓶奶”,如果只看订单总量,根本没有“实际”的概念。
所以正确做法是:订购计划在主表,每日执行情况在明细表。
python复制class OrderMain(db.Model):
__tablename__ = 'order_main'
id = db.Column(db.Integer, primary_key=True)
order_no = db.Column(db.String(32), unique=True, nullable=False)
customer_id = db.Column(db.Integer, db.ForeignKey('customer.id'))
product_id = db.Column(db.Integer, db.ForeignKey('milk_product.id'))
daily_quantity = db.Column(db.Integer, default=1)
start_date = db.Column(db.Date, nullable=False)
end_date = db.Column(db.Date, nullable=False)
unit_price = db.Column(db.Numeric(10, 2), nullable=False)
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
status = db.Column(db.String(16), default='active') # active / finished / cancelled
remark = db.Column(db.String(200))
created_at = db.Column(db.DateTime, default=datetime.now)
customer = db.relationship('Customer', backref=db.backref('orders', lazy='dynamic'))
product = db.relationship('MilkProduct', backref=db.backref('orders', lazy='dynamic'))
daily_quantity 是“每天送几瓶”,total_amount 在下单时就算好(天数 × 每日数量 × 单价),日常查询营收时直接 sum 即可。
配送记录表:
python复制class DeliveryRecord(db.Model):
__tablename__ = 'delivery_record'
id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey('order_main.id'))
customer_id = db.Column(db.Integer, default=0)
customer_name = db.Column(db.String(50))
product_id = db.Column(db.Integer, default=0)
product_name = db.Column(db.String(50))
delivery_date = db.Column(db.Date, nullable=False)
quantity = db.Column(db.Integer, default=1)
status = db.Column(db.String(16), default='pending')
# pending 待送 / delivered 已送 / paused 停送 / cancelled 取消
remark = db.Column(db.String(200)) # 记录补送、停送原因等
order = db.relationship('OrderMain', backref=db.backref('deliveries', lazy='dynamic'))
有了这张表,所有跟“某一天”有关的查询都变成了简单的日期过滤,痛快得很。
2.3 模型代码实现与初始化注意事项
模型写好后,还要注意几个初始化细节。
第一个是数据库迁移。课程设计阶段可以只在启动时 db.create_all(),但开发过程中改字段很频繁,建议提前引入 Flask-Migrate:
bash复制pip install flask-migrate
初始化迁移:
bash复制flask db init
flask db migrate -m "init tables"
flask db upgrade
Flask-Migrate 会把每次表结构变更记录在 migrations 目录,后面改字段不用手动删表重建,省很多事。
第二个是初始化管理员账号。数据库建好后没有账号,登录功能没法测。合理做法是在项目启动函数里做一个幂等初始化:
python复制@app.cli.command("init-admin")
def init_admin_command():
admin = Admin(username="admin")
admin.set_password("admin123")
db.session.add(admin)
db.session.commit()
print("管理员账号创建成功,用户名 admin / 密码 admin123")
注意密码绝不能用明文,要用 Werkzeug 的 generate_password_hash 生成散列值。
第三个是日期处理。系统是“每日配送”,日期计算涉及 datetime.date 和 dateutil.rrule。用 dateutil 的好处是遍历起止日期时自动处理大小月:
python复制from dateutil.rrule import rrule, DAILY
for dt in rrule(DAILY, dtstart=start_date, until=end_date):
record = DeliveryRecord(
order_id=order.id,
customer_id=customer.id,
customer_name=customer.name,
product_id=product.id,
product_name=product.name,
delivery_date=dt.date(),
quantity=daily_quantity,
status='pending'
)
db.session.add(record)
这一小段就是整个业务系统最核心的代码逻辑。
3. 商家端核心功能实操:登录、奶品与客户管理
3.1 登录认证与权限控制的最小实现
商家端第一步是登录。这里不需要上 Flask-Login 这种重量级库,靠 session 和装饰器就够了。理由是这个系统只有一个角色(商家),没有多角色权限树,引入 Flask-Login 反而多了一层概念。
登录视图核心逻辑:
python复制from functools import wraps
from flask import session, redirect, url_for, flash, request
from werkzeug.security import check_password_hash
def login_required(func):
@wraps(func)
def wrapper(*args, **kwargs):
if not session.get('admin_id'):
flash('请先登录', 'warning')
return redirect(url_for('auth.login', next=request.url))
return func(*args, **kwargs)
return wrapper
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
admin = Admin.query.filter_by(username=username).first()
if admin and check_password_hash(admin.password_hash, password):
session['admin_id'] = admin.id
session['admin_name'] = admin.username
session.permanent = True
return redirect(url_for('dashboard.index'))
flash('用户名或密码错误', 'danger')
return render_template('login.html')
样式上建议用 Bootstrap 5 做登录页,栅格布局居中卡片即可。这里有一个实操细节:登录成功后把 session.permanent 设为 True,再配合 app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=8),可以控制登录态过期时间,避免浏览器关闭后 session 立刻失效。
3.2 奶品管理模块:CRUD 与状态控制
奶品管理的核心是一个列表页加一个表单页。列表页显示所有奶品,支持“上架/下架”切换;表单页处理新增和编辑。
这里有一个容易被忽略的点:奶品删除操作不能是物理删除。因为历史订单和配送记录都关联着奶品 id,一旦把奶品删了,历史数据的 name 字段虽然冗余了,但下单模块的外键就悬空了。正确做法是逻辑删除——用 status 字段标记,status = 1 表示上架,status = 0 表示下架,列表查询默认只展示上架商品,但历史数据不受影响。
视图函数示意:
python复制@app.route('/product/add', methods=['GET', 'POST'])
@login_required
def product_add():
form = ProductForm()
if form.validate_on_submit():
product = MilkProduct(
name=form.name.data,
spec=form.spec.data,
unit=form.unit.data,
price=form.price.data,
description=form.description.data,
status=1
)
db.session.add(product)
db.session.commit()
flash('奶品添加成功', 'success')
return redirect(url_for('product.list'))
return render_template('product_form.html', form=form, title='新增奶品')
表单类建议用 Flask-WTF,一是它可以做 CSRF 防护,二是校验逻辑集中在表单类里,视图体更干净:
python复制class ProductForm(FlaskForm):
name = StringField('奶品名称', validators=[DataRequired()])
spec = StringField('规格', validators=[DataRequired()], default='250ml')
unit = StringField('单位', validators=[DataRequired()], default='瓶')
price = DecimalField('单价', validators=[DataRequired()], places=2)
description = TextAreaField('描述')
submit = SubmitField('保存')
列表页的“上下架切换”可以做成一个 POST 连接,提交到 /product/toggle/<int:product_id>,视图里只翻转 status,然后重定向回列表页。这里特别注意:切换状态这种有副作用操作,不要用 GET 请求,否则容易被浏览器预加载或搜索引擎爬虫触发,造成状态误改。
3.3 客户与配送地址管理:为按路线派单打基础
客户管理页面的字段建议包含:姓名、电话、地址、配送路线(route_no)、备注。其中配送路线直接影响每天送奶师傅的工作效率。
实操中我会在客户列表页加一个“按路线筛选”的下拉框,默认“全部路线”,选某个路线后只显示该路线的客户。这个功能对真实商家非常有用,尤其是客户到了几十个之后,没有路线分组,每天出配送单时就是一个灾难。
新建客户时的地址输入,建议统一用户填写格式:“小区名 + 楼栋单元 + 门牌号”,并在模板端做实时提示。虽然加了校验也不能保证数据 100% 规范,但至少能减少九成的手工整理成本。如果客户量大,还可以考虑在客户列表页放一个“导出 CSV”按钮,用 Python 的 csv 模块几行代码就能实现,方便商家做线下备份。
4. 订单流程与配送管理实现
4.1 下单逻辑:一键生成一个月配送计划
下单页是商家最常用的页面,点击“新增订购单”后需要选择客户、奶品、开始日期、结束日期、每日数量,并自动回填当前奶品单价,允许商家微调单价。
表单提交后的服务端逻辑要分三步走:
第一步,校验日期。end_date 必须大于等于 start_date,否则直接拒绝。
第二步,创建订购主表记录。计算总金额时,用 (end_date - start_date).days + 1 得到总天数,再乘以每日数量和单价,结果存到 total_amount。
第三步,生成每日配送明细。回到前面说的 rrule(DAILY, ...) 遍历,为每一天生成一条 delivery_record,状态默认 pending。
这三步必须放在同一个事务里执行,要么全部成功,要么全部回滚,否则会出现主表存在但配送明细缺失的脏数据。在 SQLAlchemy 里,只需要把创建和循环写在同一个 db.session.commit() 之前,一旦中途异常,整个事务会自动回滚。
完整视图函数:
python复制@app.route('/order/add', methods=['GET', 'POST'])
@login_required
def order_add():
form = OrderForm()
form.customer_id.choices = [(c.id, c.name) for c in Customer.query.order_by(Customer.route_no).all()]
form.product_id.choices = [(p.id, f"{p.name}({p.spec})") for p in MilkProduct.query.filter_by(status=1).all()]
if form.validate_on_submit():
customer = Customer.query.get(form.customer_id.data)
product = MilkProduct.query.get(form.product_id.data)
start_date = form.start_date.data
end_date = form.end_date.data
daily_qty = form.daily_quantity.data
days = (end_date - start_date).days + 1
total = Decimal(days) * daily_qty * form.unit_price.data
order = OrderMain(
order_no=generate_order_no(),
customer_id=customer.id,
product_id=product.id,
daily_quantity=daily_qty,
start_date=start_date,
end_date=end_date,
unit_price=form.unit_price.data,
total_amount=total,
status='active'
)
db.session.add(order)
db.session.flush() # 先拿到 order.id
for dt in rrule(DAILY, dtstart=start_date, until=end_date):
db.session.add(DeliveryRecord(
order_id=order.id,
customer_id=customer.id,
customer_name=customer.name,
product_id=product.id,
product_name=product.name,
delivery_date=dt.date(),
quantity=daily_qty,
status='pending'
))
db.session.commit()
flash(f'订购单创建成功,共生成 {days} 条配送记录', 'success')
return redirect(url_for('order.detail', order_id=order.id))
return render_template('order_form.html', form=form, title='新增订购单')
db.session.flush() 在这里很关键,它的作用是把当前变更发送到数据库但先不提交,从而获得自增主键 order.id,给配送记录使用。不调用 flush 直接用 order.id,在部分数据库方言下取到的是 None,会直接报错。
4.2 当日配送单与状态流转:让商家每天有单可送
每天打开系统,商家第一眼想看的就是“今天要送什么”。这里我设计了两个入口:
一是“今日配送”页面,直接按 delivery_date = today 查询所有配送记录,默认按路线分组展示,每条记录后面有“标记已送”按钮。
二是“订购单详情”页面,展示某张订单下所有日期的配送记录日历,可以快速看到哪些天漏送、哪些天停送,方便月底对账。
今日配送查询代码:
python复制today = date.today()
records = (DeliveryRecord.query
.join(Customer, DeliveryRecord.customer_id == Customer.id)
.filter(DeliveryRecord.delivery_date == today)
.order_by(Customer.route_no, Customer.id)
.all())
状态流转这块,主要有三个操作场景:
- 正常送达:把
delivery_record.status从pending改为delivered。 - 客户停送某天:在订购单详情页找到对应日期的记录,把状态改成
paused,并填备注原因。 - 漏送补送:把漏送那天的记录保留为
delivered(或单独标记missed),再在当前日期补一条 remark 写明“补送 7 月 3 日 1 瓶”的记录。
补送的完整实现要写一个单独的小表单:选择客户和奶品、填写补送日期和数量,直接生成一条新的配送记录,状态默认为 pending,备注注明“补送”。这样月末统计实际配送量时,补送的记录也能被算进去,账目清清楚楚。
这里有一个经验:配送记录的状态不要用一堆字段组合去表达,比如 is_delivered + is_paused + is_cancelled。用一个 status 字符串字段,配合统一的取值枚举,查询时只做字符串匹配,代码更简单,也避免出现“既暂停又被标记送达”这种逻辑矛盾。
4.3 统计面板与经营看板:SQL 聚合查询实战
系统做到这一步,增删改查已经完整了,但按下单和送奶只是日常工作,要想让商家真正愿意每天打开系统,还得有统计面板。
按日期范围统计营收:
python复制from sqlalchemy import func, extract
@app.route('/dashboard')
@login_required
def index():
today = date.today()
month_start = today.replace(day=1)
# 今日应送数量
today_pending = db.session.query(
func.coalesce(func.sum(DeliveryRecord.quantity), 0)
).filter(DeliveryRecord.delivery_date == today,
DeliveryRecord.status == 'pending').scalar()
# 本月营收
month_revenue = db.session.query(
func.coalesce(func.sum(OrderMain.total_amount), 0)
).filter(OrderMain.created_at >= month_start,
OrderMain.status == 'active').scalar()
# 奶品订购量排行
product_stats = db.session.query(
MilkProduct.name,
func.sum(OrderMain.daily_quantity * func.datediff(OrderMain.end_date, OrderMain.start_date) + 1)
).join(OrderMain, OrderMain.product_id == MilkProduct.id) \
.filter(OrderMain.status == 'active') \
.group_by(MilkProduct.name) \
.order_by(func.sum(OrderMain.daily_quantity).desc()) \
.all()
return render_template('dashboard.html', today_pending=today_pending,
month_revenue=month_revenue, product_stats=product_stats)
注意 SQLite 里的 func.datediff 不可用,不同数据库的日期函数差异很大。为保证跨库兼容,建议在 Python 层算好天数再传给 SQLAlchemy,比如遍历 active 订单,用 (end_date - start_date).days + 1 乘数量后累加。这个数据量级不大,性能不会成为问题,换来的是可移植性。
统计页面的图表我建议用 ECharts 的 CDN 版本,后端返回 JSON 数据,前端用 fetch 拉取后渲染。不要嫌麻烦,图表比表格直观太多,招生和答辩的时候效果也更好。模板里这样引入:
html复制<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
然后在页面底部写一小段 JS,把后端传过来的 product_stats 直接塞进 setOption。这个方案简单可靠,不需要 npm 和 webpack。
5. 常见问题与排查技巧实录
5.1 登录和表单相关:CSRF 与密码散列
第一类高频坑是模板里表单忘记加 csrf_token。Flask-WTF 生成表单后,模板里必须渲染隐藏字段,否则 form.validate_on_submit() 永远返回 False:
html复制<form method="post">
{{ form.hidden_tag() }}
<!-- 其他字段 -->
</form>
第一类高频坑里还有一个是密码散列。有同学直接 admin.password = password 存明文,这样一旦数据库泄露,所有账号直接暴露。务必用 generate_password_hash 和 check_password_hash,这个好习惯要养成。
5.2 表结构变更与数据迁移问题
开发过程中最常见的报错是 sqlalchemy.exc.OperationalError: no such column: customer.route_no。原因很简单:你改了模型的字段,但数据库表结构没同步。
如果没引入 Flask-Migrate,我就只能建议手动备份数据后 db.drop_all() 再 db.create_all(),但对已上线数据是致命的。正确做法是一开始就引入迁移工具,每次改模型后运行:
bash复制flask db migrate -m "add route_no to customer"
flask db upgrade
5.3 时间处理与中文乱码问题
配送系统对日期敏感,“今天”的计算必须在服务器本地时区进行。建议在配置中显式设置 app.config['TIMEZONE'] = 'Asia/Shanghai',并在日期相关函数里统一使用 datetime.now() 而不是 datetime.utcnow(),否则部署到海外服务器时,“今日配送”会出现半天偏差。
中文乱码通常发生在两个地方。一是模板文件保存编码不是 UTF-8,编辑器右下角检查一下;二是 Flask 的 JSON 返回默认 ensure_ascii=True,中文会被转成 \uXXXX,前端 fetch 后仍然能正常显示,但如果你直接在浏览器访问 API 看到乱码,可以在 jsonify 后设置 app.config['JSON_AS_ASCII'] = False(新版本 Flask 是 app.json.ensure_ascii = False)。页面渲染时,确保 <meta charset="utf-8"> 在 head 最前面。
5.4 部署与运行的几个心得
开发阶段 app.run(debug=True) 没问题,但部署到服务器上绝对不要开 debug,不仅性能差,还有代码执行漏洞风险。小项目用 waitress(Windows 和 Linux 都能跑)或者 gunicorn(Linux 推荐)就能满足日常访问量,没必要上 nginx+uwsgi 的组合拳。
如果用的是 SQLite,要注意并发写入问题。Flask 默认每个请求一个线程,SQLite 同一时刻只允许一个写锁,商家后台这种低并发场景基本够用,但如果以后加了用户端小程序,日均请求量上来,建议切换 MySQL。切换时把 SQLALCHEMY_DATABASE_URI 换成 MySQL 连接串即可,模型代码基本不用改。
我在实际测试中还发现一个小坑:SQLite 连接串后面不加 check_same_thread=False 的话,多线程环境下可能报 SQLite objects created in a thread can only be used in that same thread。Flask-SQLAlchemy 2.4 版本开始默认处理了这个问题,老版本项目要留意。
6. 还能怎样扩展这个系统
如果你想在答辩或实际使用中更进一步,这几点扩展方向在现有架构上都很好实现。
第一是批量导入客户。商家现在可能已经有几十上百个老客户,一个个手工录入太不现实。加一个“导入 Excel”按钮,用 openpyxl 读表,逐行校验后写入 customer 表。这是个非常实用的功能,但课程设计中很少人做,做了就是亮点。
第二是配送单导出。每天生成 PDF 或 Excel 配送单,打印给师傅带着跑。Excel 导出用 openpyxl,PDF 可以用模板转 HTML 再调 weasyprint,考虑到依赖较重,建议导出 Excel 优先。
第三是按月结算提醒。部分客户是月结或季结,可以在订购单详情页显示“已生成金额”和“已收款金额”,再加一个简单的收银记录表。这样商家月底对账不用再翻纸质账本。
第四是续订功能。老客户订购到期前三天,系统在首页提示“以下订单即将到期”,点一下就能跳转到复制订购单页面,保留客户和奶品信息,只需改新的起止日期。这个小功能能大幅提升商家日常操作效率。
我个人在实际操作中的体会是:这类系统最难的部分不是 CRUD,而是把“周期订购”这个业务规则吃透。只要数据模型把订购计划和每日配送分开设计,后续所有功能都顺理成章;如果一开始图省事只做一张订单表,后面三天两头改表结构,才是最痛苦的。最后再分享一个小技巧:开发这种带日历概念的系统,每天手动造测试数据非常累,可以直接写一个循环脚本生成未来 10 天的模拟配送记录,所有页面都能在真实数据下测试,开发体验会好很多。
