简介:一套面向餐饮场景的微信小程序订餐系统完整源码,后端基于Python Flask框架,前端为微信小程序原生实现,适合Python学习者、中小型餐饮创业者或需要课程设计与毕业设计范式的开发者参考。资源包共含692个文件,涵盖Python后端逻辑、Flask模板与路由、小程序页面脚本、样式表、配置文件及大量界面预览图,压缩包仅7.82MB,便于快速下载与部署。当前已有3253人学习或下载,结合源码目录可直观理解从用户点餐、订单管理到后台数据交互的完整闭环。通过该源码可掌握Flask API设计与小程序wx.request通信方式,参考其中JSON配置和WXML/WXSS结构快速搭建自己的订餐应用,也可在现有基础上扩展支付、菜品分类或商家管理模块,节省从零搭建的重复工作。
1. 微信订餐系统不是只有小程序,Flask 这边的活更重
看到python+flask微信小程序订餐系统源码.zip这个名字,多数人会以为核心是app.js和wxml,真正打开源码才发现,静态资源里躺着bootstrap.min.css、ueditor.css、font-awesome.css、video-js.css,说明这个项目至少有两端:给顾客用的小程序端,和给店家用的 Web 管理后台。Flask 要同时承担小程序 API、后台渲染、文件上传、富文本编辑几件事,很多从零开始写点餐系统的人,第一版往往只做了接口层,等到要接后台就发现代码没法复用。这套源码正好展示了把两套前端压在同一个 Flask 应用下的组织方式,适合正在犹豫「小程序端和后端怎么拆」的 Python 开发,也适合想拿现成后台快速改一版订餐业务的从业者。下面我按自己拆这套代码的顺序,从数据模型到接口验证,把可复现的部分梳一遍。
2. Flask 端的目录结构与数据库建模方案
2.1 项目拆分:小程序 API 和管理后台的边界
先看 Flask 端目录,这个结构决定了后面扩展订单、优惠券时会不会乱:
ordering/ ├── app/ │ ├── __init__.py # create_app 工厂 │ ├── models/ │ │ ├── __init__.py │ │ ├── user.py │ │ ├── dish.py │ │ ├── order.py │ ├── api/ │ │ ├── __init__.py # Blueprint: /api │ │ ├── auth.py │ │ ├── menu.py │ │ └── order.py │ ├── admin/ │ │ ├── __init__.py # Blueprint: /admin │ │ ├── views.py │ │ └── forms.py │ ├── static/ │ │ ├── bootstrap.min.css │ │ ├── ueditor.min.js │ │ └── ... │ ├── templates/ │ │ ├── admin/ │ │ └── ... ├── manage.py └── requirements.txtapp/api下所有路由都挂在/api前缀,返回 JSON;app/admin走服务端渲染,使用 Jinja2 模板。小程序端不需要关心管理后台的页面逻辑,只会请求/api/menu、/api/order这类接口。如果以后要拆成微服务,只需要把api里的视图函数搬到独立服务,models 层几乎可以原样带走。
这里有一个容易犯错的设计:不要把小程序 API 和管理后台写到同一个 views.py 里。这套源码把俩 Blueprint 分开,登录鉴权逻辑也按来源区分。后台用session + CSRF,小程序用token,两套安全模型混在一起会互相污染,后面接支付回调时尤其麻烦。
2.2 订单核心表:用户、菜单、订单、订单项的设计
点餐系统的数据表比普通商城简单,但订单和订单项必须分表,因为一个订单对应多个菜,而且菜品价格可能调整。这套源码的模型定义可以直接抄:
from flask_sqlalchemy import SQLAlchemy from datetime import datetime db = SQLAlchemy() class User(db.Model): __tablename__ = 'user' id = db.Column(db.Integer, primary_key=True) openid = db.Column(db.String(64), unique=True, nullable=False, index=True) nickname = db.Column(db.String(64)) mobile = db.Column(db.String(20)) created_at = db.Column(db.DateTime, default=datetime.now) class Dish(db.Model): __tablename__ = 'dish' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), nullable=False) price = db.Column(db.Numeric(10, 2), nullable=False) image_url = db.Column(db.String(256)) category_id = db.Column(db.Integer, db.ForeignKey('category.id')) status = db.Column(db.SmallInteger, default=1) # 0下架 1上架 class Order(db.Model): __tablename__ = 'order' id = db.Column(db.Integer, primary_key=True) order_no = db.Column(db.String(32), unique=True, nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) total_price = db.Column(db.Numeric(10, 2), nullable=False) status = db.Column(db.SmallInteger, default=0) # 0待支付 1已支付 2已完成 remark = db.Column(db.String(128)) created_at = db.Column(db.DateTime, default=datetime.now) class OrderItem(db.Model): __tablename__ = 'order_item' id = db.Column(db.Integer, primary_key=True) order_id = db.Column(db.Integer, db.ForeignKey('order.id')) dish_id = db.Column(db.Integer, db.ForeignKey('dish.id')) dish_name = db.Column(db.String(64)) price = db.Column(db.Numeric(10, 2)) quantity = db.Column(db.Integer, default=1)字段不复杂,但要注意两个点:OrderItem里冗余了dish_name和price,这是故意做的。菜品改名或调价时,历史订单里的快照不能跟着变,否则对账时金额对不上。User表的openid直接建唯一索引,微信登录返回的openid是用户在小程序维度的唯一标识,后续查询和去重都靠它。如果要做多门店,还得加shop_id字段,这套源码没有,属于单店版本。
建表命令不多说,flask db init那套容易绕晕,我建议直接:
flask shell >>> from app import db >>> db.create_all()开发环境这样跑没问题,生产环境再用 Alembic 迁移。表建完后,用sqlite3 ordering.db '.tables'看一眼实际建出来的表名,确认没有因为__tablename__写错导致映射到奇怪的名字。
2.3 购物车不该建表,append 到缓存才是常态
看到很多半路出家的点餐系统,会把购物车也做成一张表,这个设计我不推荐。购物车是典型的临时态,用户可能加加减减,未登录时根本不知道是谁的购物车。这套源码的思路是把购物车放到 Redis 或者写到小程序本地存储里,后端只在下单时接收一个完整的菜品列表。
如果后端需要临时保存购物车,可以参考这种缓存结构:
import redis import json r = redis.Redis(host='127.0.0.1', port=6379, db=2) def save_cart(openid, cart_items): # cart_items: [{dish_id:1, quantity:2}, {dish_id:3, quantity:1}] key = f'cart:{openid}' r.setex(key, 86400, json.dumps(cart_items)) def get_cart(openid): key = f'cart:{openid}' data = r.get(key) return json.loads(data) if data else []setex设置了 24 小时过期,避免用户丢在餐桌上的一次性购物车占用 Redis 内存。小程序端每次改动购物车,都通过接口同步到后端,下单时以后端这份为准,不能信小程序传过来的total_price。价格必须由后端根据dish_id重新计算一遍。
3. 小程序登录鉴权与点餐主流程接口
3.1 code2session 换 openid 的坑
小程序端点「微信授权登录」,实际拿到的是wx.login产生的code,后端拿这个code去微信接口换session_key和openid。这套源码里auth.py的核心就这一段,但坑不少:
import requests import json from flask import Blueprint, request, jsonify auth_bp = Blueprint('auth', __name__) @auth_bp.route('/login', methods=['POST']) def login(): code = request.json.get('code') appid = 'your_appid' # 小程序 AppID secret = 'your_appsecret' # 小程序 AppSecret resp = requests.get( 'https://api.weixin.qq.com/sns/jscode2session', params={ 'appid': appid, 'secret': secret, 'js_code': code, 'grant_type': 'authorization_code' }, timeout=5 ) data = resp.json() if 'openid' not in data: return jsonify({'code': 400, 'msg': '登录失败'}), 400 user = User.query.filter_by(openid=data['openid']).first() if not user: user = User(openid=data['openid']) db.session.add(user) db.session.commit() token = generate_token(user.id) # 项目里常见做法是用 itsdangerous return jsonify({'code': 0, 'data': {'token': token, 'user_id': user.id}})这里最容易出问题的是requests.get没有设置timeout,微信接口如果抖动,请求会卡住几十秒,前端表现就是永远转圈。另一个坑是appid和secret写在代码里,这套源码如果直接拿去改,记得先从环境变量读取:
import os appid = os.environ.get('WX_APPID', 'your_appid') secret = os.environ.get('WX_APPSECRET', 'your_appsecret')code是一次性的,第二次用同一个code请求会报invalid code。排查时可以打印微信返回的errcode,最常见的40163就是重复使用code。
3.2 菜单列表与购物车下单的 Flask 路由
小程序首页要展示菜品分类和列表,后端接口一般这样写:
from flask import jsonify, request @menu_bp.route('/menu', methods=['GET']) def get_menu(): category_id = request.args.get('category_id', type=int) query = Dish.query.filter_by(status=1) if category_id: query = query.filter_by(category_id=category_id) dishes = query.order_by(Dish.id.desc()).all() data = [ { 'id': d.id, 'name': d.name, 'price': str(d.price), 'image_url': d.image_url, 'category_id': d.category_id } for d in dishes ] return jsonify({'code': 0, 'data': data})注意price转成了str,因为Numeric类型在 JSON 序列化时可能变成 Decimal 对象,直接jsonify会报错。request.args.get('category_id', type=int)是 Flask 自带类型转换,传了非数字会返回 400,比自己在视图里 try/except 干净。
下单接口是这个系统的核心:
@order_bp.route('/create', methods=['POST']) def create_order(): token = request.headers.get('Authorization') user_id = verify_token(token) # 解析 token 得到 user_id if not user_id: return jsonify({'code': 401, 'msg': '未登录'}), 401 items = request.json.get('items') # [{dish_id:1, quantity:2}] if not items or not isinstance(items, list): return jsonify({'code': 400, 'msg': 'items 参数错误'}), 400 total_price = 0 order_items = [] for item in items: dish = Dish.query.filter_by(id=item['dish_id'], status=1).first() if not dish: return jsonify({'code': 400, 'msg': f"菜品 {item['dish_id']} 不存在"}), 400 subtotal = dish.price * int(item['quantity']) total_price += subtotal order_items.append(OrderItem( dish_id=dish.id, dish_name=dish.name, price=dish.price, quantity=item['quantity'] )) order = Order( order_no=generate_order_no(), user_id=user_id, total_price=total_price, status=0 ) db.session.add(order) db.session.flush() # 拿到 order.id for oi in order_items: oi.order_id = order.id db.session.add(oi) db.session.commit() return jsonify({'code': 0, 'data': {'order_id': order.id, 'order_no': order.order_no}})db.session.flush()是个关键动作,它把order的主键立即写回 Python 对象,不需要先 commit 再查询。整套逻辑放在一个事务里,中间任何一个菜品不存在,直接 return 400,之前累积的db.session变更会自动回滚,不会留下残缺订单。
3.3 用 CHARLES 抓包核对小程序请求参数
小程序点餐看起来简单,联调时问题基本出在请求参数对不上。调试方法不是在小程序里打日志,而是用 Charles 抓包看实际发出的 HTTP 请求。电脑端 Charles 设置好 SSL Proxying 后,手机上信任证书,微信开发者工具里把「不校验合法域名」关掉,就能看到https://api.example.com/api/create发出的完整请求体。
这套源码里最容易抓出来的问题有两个:
content-type是application/json还是application/x-www-form-urlencoded,Flask 的request.json只认前者。小程序wx.request的header如果不显式设置,默认是application/json,但如果你用了uni-app的uni.request,有些版本要手动写header: {'content-type': 'application/json'}。- 后端接口返回的
price是字符串"18.00",小程序端直接用parseFloat计算,可能得到18,和total_price比较时要用==而不是===,否则永远不相等。
替换成uni-app开发微信小程序也很常见,uni.request的url只写路径,域名前缀放到manifest.json的h5配置或小程序配置里。抓包时看到请求http://127.0.0.1:5000没问题,但真机预览时127.0.0.1指向手机自己,必须改成电脑局域网的 IP。
4. 管理后台:Bootstrap + UEditor + Video.js 的组合拳
4.1 后台模板复用要改的静态资源路径
这套源码的静态目录里出现了bootstrap.min.css、ueditor.css、video-js.css、font-awesome.css,说明管理后台是一个带富文本编辑、图片轮播、视频展示的完整界面。直接把静态文件复制到 Flask 的static目录后,十个有八个会遇到样式不加载的问题。
Flask 默认静态文件路径是/static,模板里的引用应该写成:
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}"> <script src="{{ url_for('static', filename='ueditor.min.js') }}"></script>不要直接写href="/static/bootstrap.min.css",虽然开发时能跑,但以后如果加了蓝图的子域名或 CDN 前缀,url_for会自动带上SEND_FILE_MAX_AGE_DEFAULT等配置,改起来少动一个文件。
UEditor 的配置它会自己加载一堆image.css、video-js.css,如果你发现编辑器按钮能出但排版乱,检查ueditor.config.js里的window.UEDITOR_HOME_URL是否指向了正确的 Flask 静态路径:
window.UEDITOR_HOME_URL = "/static/ueditor/";4.2 UEditor 富文本与图片上传的安全处理
UEditor 上传图片接口可以直接用 Flask 接收文件,但网上能找到不少「上传 getshell」的案例,问题全出在没限制文件类型和路径。安全做法是,上传目录不能放在应用根目录下,而且文件名要重写:
import os import uuid from flask import request, jsonify from werkzeug.utils import secure_filename @admin_bp.route('/upload', methods=['POST']) def upload_image(): f = request.files.get('upfile') # UEditor 默认文件字段名 if not f: return jsonify({'state': 'FAIL', 'msg': '未获取到文件'}) ext = os.path.splitext(f.filename)[1].lower() allowed = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} if ext not in allowed: return jsonify({'state': 'FAIL', 'msg': '文件类型不允许'}) filename = f"{uuid.uuid4().hex}{ext}" save_path = os.path.join('app/static/uploads', filename) f.save(save_path) return jsonify({'state': 'SUCCESS', 'url': f'/static/uploads/{filename}'})uuid.uuid4().hex生成 32 位随机文件名,避免中文名转码问题和路径穿越。secure_filename只保留 ASCII 字符,对中文文件名最好自己用uuid重写,否则secure_filename可能把中文全删掉,导致文件扩展名丢失。
4.3 使用 Flask-WTF 做表单校验和 CSRF
后台管理页每次新增菜或改价格,都涉及表单提交。直接用request.form.get()不是不行,但字段多的时候校验逻辑会挤占视图代码。Flask-WTF 的FlaskForm可以做一个干净的写法:
from flask_wtf import FlaskForm from wtforms import StringField, DecimalField, IntegerField from wtforms.validators import DataRequired, NumberRange class DishForm(FlaskForm): name = StringField('菜名', validators=[DataRequired()]) price = DecimalField('价格', validators=[DataRequired(), NumberRange(min=0.01)]) category_id = IntegerField('分类', validators=[DataRequired()])视图里调用form.validate_on_submit(),如果返回 False,模板里会把错误渲染出来。这个库另一个价值是默认开启 CSRF 防护,render_template里的表单需要加form.csrf_token。小程序端不需要 CSRF,因为它的token已经放在请求头里,但后台表单如果忘加这行,以后被脚本刷接口连周界都没有。
5. 部署前要过一遍的验收命令与细节优化
订餐系统要真上微信小程序,后端必须跑 HTTPS,而且域名要在小程序后台配置为 request 合法域名。小程序端拿用户openid的接口,微信要求appid和secret必须来自同一个小程序账号,测试的时候用测试号拿到的openid和生产号不通用,这点最容易在换账号后突然无法登录。
生产环境别用flask run跑,那是开发服务器,单进程扛不住多个人同时点单。常见做法是gunicorn + gevent:
pip install gunicorn gevent gunicorn -w 4 -k gevent -b 0.0.0.0:8000 'manage:app' --timeout 30-k gevent让网络请求变成协程并发,点餐这种短请求接口很合适。--timeout 30防止某个微信接口响应慢时 worker 被强制杀掉。启动后用curl -s -X POST https://你的域名/api/login -H 'Content-Type: application/json' -d '{"code":"test"}'验证接口是否能按预期返回400或无openid的错误。不要只在浏览器里打开,curl看不到渲染层,能直接暴露路由和 JSON 问题。
小程序顶部导航栏高度不一样,这是真机适配最常见的坑。拿到用户胶囊按钮位置wx.getMenuButtonBoundingClientRect()后,把返回的top和height存在全局变量里,计算占位视图高度,不要把写死的64px传下去。抓包只是第一次联调用,后续接口改动频繁时,直接在 Python 代码里临时加一行print(request.json)再tail -f日志,定位问题比看半个小时的 Charles 原始请求更直接。最后确认requirements.txt里锁住的版本,Flask==2.2.5、Flask-SQLAlchemy==3.0.5这类,避免下次部署时依赖升级把Numeric的序列化行为改掉。
本文还有配套的精品资源,点击获取