做点餐系统这个需求我前前后后接触过不少,从最开始商家只求“能扫码看菜单”,到后来要求小程序端、商家后台、打印小票、营销活动一条龙,项目边界不断在变。今天写这个“vue+uniapp小程序+Python美食菜品点餐订单系统”,是把一套适合中小餐饮门店快速落地的点餐方案完整拆给大家看:小程序端负责点餐、购物车、下单支付,后端用Python提供接口服务,中间用订单状态机把整个业务流程串起来。内容不多不少,恰好覆盖从需求分析、数据库设计、接口开发到联调上线的全流程,适合正在做毕业设计、个人作品集,或者给餐饮客户做数字化升级的朋友参考。
我尽量不说废话,直接讲这套系统里真正有价值的东西:表结构怎么设计才不容易返工,购物车到底该放本地还是后端,订单提交事务和扣库存为什么容易出事,uniapp写小程序时那些官方文档不会明说的坑。这些事看起来零碎,但都是实际项目里躲不开的坎。
1. 整体设计思路与技术选型
1.1 需求拆解:一个最小可用点餐系统要包含什么
先别急着写代码,把需求捋清楚。一套完整的点餐订单系统,拆到最小可用闭环,至少要包含下面几条链路:
- 用户端:浏览菜品分类和列表、加入购物车、确认订单、提交支付、查看订单状态和历史订单。
- 商家端:维护菜品分类与菜品信息、管理订单状态(待支付、待接单、制作中、已完成等)。
- 底层支撑:用户身份、菜品数据、购物车数据、订单数据、订单明细数据,以及前后端分离的接口服务。
大多数做这套系统的同学,容易上来就怼着页面写,写到一半发现购物车数据不知道放哪,订单状态不知道谁来改。所以我做项目的第一件事永远是先画业务闭环:用户从“进店-点菜-下单-支付-等餐”,商家从“接单-制作-出餐”,这一步走通了,再往里面加会员、优惠券、打印机这些外围功能。
用我之前给一家小吃店做的调研来说,商家最痛的不是点菜,而是“接单后不知道谁点了啥、在哪桌、要不要辣”。所以订单明细里必须保存菜品快照(名称、价格、图片),不能只存菜品ID,否则以后菜品改价或删除,历史订单就全花了。这些看起来是小细节,但设计阶段没想清楚,后期全是返工。
1.2 选型理由:为什么是vue+uniapp,为什么是Python
前端选uniapp而不是原生小程序,核心就一个原因:多端复用。一套Vue语法的代码,同时编译到微信小程序、H5、甚至App,对没有原生开发团队的餐饮商家来说太省事了。你在微信里跑通了点餐,后端接口不变,改改配置就能出支付宝小程序或H5商城,边际成本几乎为零。Vue3的组合式API写起来也顺手,生态成熟,社区里例子多,遇到问题搜一圈基本都能找到答案。
后端选Python而不是Java,不是Java不行,而是项目体量决定的。中小餐饮门店的并发量撑死几百人同时点餐,这个压力下Python的开发效率优势比性能劣势重要得多。框架我推荐Flask而不是Django,原因是Flask轻、灵活、没有强制约束,做个接口服务非常合适;Django自带Admin和ORM固然香,但对这种规模的项目来说有点重,而且魔法太多,新手反而容易踩坑。
数据库生产环境我建议MySQL,开发和本地调试用SQLite也行,反正对接了SQLAlchemy ORM之后切换成本很低。整套系统的数据量不大,单表几千几万条记录而已,关键是表关系设计清楚,不要动不动就搞出四五张关联表把简单问题复杂化。
1.3 总体架构与目录结构
架构上就是标准的前后端分离:uniapp小程序作为前端,通过HTTPS调用Flask提供的RESTful API,数据落在MySQL里。前端项目里按pages管理页面,后端按业务模块划分蓝图(Blueprint),不把接口全堆在一个文件里。
frontend/ # uniapp 项目根目录 ├── pages/ │ ├── index/index.vue # 首页:分类+菜品列表 │ ├── cart/cart.vue # 购物车 │ ├── order/confirm.vue # 确认订单 │ ├── order/list.vue # 订单列表 │ └── user/user.vue # 我的 ├── utils/request.js # 请求封装 └── stores/cart.js # 购物车状态管理 backend/ # Flask 项目根目录 ├── app.py # 应用入口 ├── models.py # ORM模型 ├── auth.py # JWT鉴权 ├── blueprints/ │ ├── user.py # 登录、用户信息 │ ├── dish.py # 菜品和分类 │ ├── cart.py # 购物车 │ └── order.py # 订单 └── requirements.txt这套结构的好处是边界清晰:改首页不影响购物车逻辑,改订单接口不碰菜品代码。多人协作时每个人负责一个蓝图就行,自己单干时也好维护,半年后回来看代码不用从头猜。
2. 数据库设计与接口契约设计
2.1 核心表结构:先定表,再写代码
数据库是整个系统的地基,字段少一个后面全要补。我这套项目沉淀下来的核心表有六张:用户表、菜品分类表、菜品表、购物车表、订单表、订单明细表。下面直接把建表SQL放出来,你可以直接抄作业。
-- 用户表 CREATE TABLE `user` ( `id` int NOT NULL AUTO_INCREMENT, `openid` varchar(64) NOT NULL COMMENT '微信openid,唯一标识', `nickname` varchar(64) DEFAULT '' COMMENT '昵称', `avatar` varchar(255) DEFAULT '' COMMENT '头像', `phone` varchar(20) DEFAULT '' COMMENT '手机号', `role` tinyint NOT NULL DEFAULT 0 COMMENT '0普通用户 1管理员', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 菜品分类表 CREATE TABLE `category` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(32) NOT NULL COMMENT '分类名,如热菜、凉菜', `sort` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 菜品表 CREATE TABLE `dish` ( `id` int NOT NULL AUTO_INCREMENT, `category_id` int NOT NULL COMMENT '所属分类', `name` varchar(64) NOT NULL COMMENT '菜品名', `description` varchar(255) DEFAULT '' COMMENT '描述/口味说明', `image` varchar(255) DEFAULT '' COMMENT '图片URL', `price` decimal(10,2) NOT NULL COMMENT '单价,单位元', `stock` int NOT NULL DEFAULT 0 COMMENT '库存,0表示售罄', `sales` int NOT NULL DEFAULT 0 COMMENT '销量,用于排序展示', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 购物车表 CREATE TABLE `cart` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, `dish_id` int NOT NULL, `quantity` int NOT NULL DEFAULT 1 COMMENT '数量', `selected` tinyint NOT NULL DEFAULT 1 COMMENT '是否勾选结算', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_dish` (`user_id`, `dish_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 CREATE TABLE `orders` ( `id` int NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号,业务唯一', `user_id` int NOT NULL, `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付/待接单 2制作中 3待取餐 4已完成 5已取消', `remark` varchar(255) DEFAULT '' COMMENT '用户备注', `address` varchar(255) DEFAULT '' COMMENT '取餐方式或配送地址', `pay_time` datetime DEFAULT NULL COMMENT '支付时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表 CREATE TABLE `order_item` ( `id` int NOT NULL AUTO_INCREMENT, `order_id` int NOT NULL, `dish_id` int NOT NULL, `dish_name` varchar(64) NOT NULL COMMENT '菜品快照名称', `dish_image` varchar(255) DEFAULT '' COMMENT '菜品快照图片', `price` decimal(10,2) NOT NULL COMMENT '下单时的单价', `quantity` int NOT NULL COMMENT '数量', PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有几个设计细节值得说。订单表和订单明细表我故意拆成两张,因为一张订单有多个菜品,明细必须单独存,否则字段没法设计。订文明细里存dish_name和dish_image而不是关联查询菜品表,这叫“历史快照”,因为菜品以后可能改价、改名甚至删除,但用户的订单记录必须保持原样。购物车表用(user_id, dish_id)做唯一键,同一个用户同一道菜只存一条记录,数量累加,避免购物车出现两行一样的菜。
2.2 订单状态机的设计:别让状态满天飞
订单状态是整个系统里最容易出bug的地方。我见过不少项目没定义清楚状态,前端传个数字、后端再改一个数字,两边对不上,订单就卡死了。我的方案是把状态模型定义成五档:
- 0:待支付,下单后创建,给用户15分钟支付时间。
- 1:已支付/待接单,支付成功,等商家确认。
- 2:制作中,商家接单开始做。
- 3:待取餐/待配送,餐做好了。
- 4:已完成,用户取餐或确认收货。
- 5:已取消,用户主动取消或超时取消。
状态流转只允许相邻推进,禁止跳状态,比如“待支付”不能直接变成“制作中”,必须先支付成功。这个约束在后端接口里做判断,不要指望前端自觉。前端每次刷新订单列表时拉最新状态,商家端操作后同步状态,用户端才能看到实时进展。
2.3 接口响应格式与鉴权方案
前后端联调最怕什么?最怕接口返回格式不统一。有的接口返回数组,有的返回对象,有的报错只给个英文文本,前端解析代码写八遍。我统一约定一个响应格式,所有接口都必须遵守:
{ "code": 0, "message": "ok", "data": {} }code为0表示成功,非0表示业务错误,message给出用户可读的提示,data放真正的数据。前端请求封装里统一拦截,code非0就直接toast,前端代码不用每个接口都写错误处理。
接口清单给一个汇总表,开发时照着表逐项实现就行:
| 模块 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 用户 | POST | /api/user/login | code换openid,返回token |
| 分类 | GET | /api/category/list | 获取分类列表 |
| 菜品 | GET | /api/dish/list | 支持分类筛选、分页 |
| 购物车 | GET | /api/cart/list | 查询我的购物车 |
| 购物车 | POST | /api/cart/add | 加入购物车 |
| 购物车 | POST | /api/cart/update | 修改数量/勾选状态 |
| 购物车 | POST | /api/cart/delete | 删除购物车项 |
| 订单 | POST | /api/order/submit | 提交订单 |
| 订单 | GET | /api/order/detail | 订单详情 |
| 订单 | GET | /api/order/list | 订单列表,按状态筛选 |
| 订单 | POST | /api/order/pay | 模拟支付 |
鉴权我用JWT,登录成功后后端返回一个token,小程序端存到storage里,之后每个请求在header里带Authorization: Bearer <token>。后端用一个装饰器校验身份,用户相关接口必须登录,菜品分类这类公开接口则放行。后面我专门开一节讲登录和鉴权,这里是先把契约定清楚,前端后端照着同一份文档开发,效率高很多。
3. 小程序端核心功能落地
3.1 初始化:manifest配置与请求封装
uniapp项目的第一步不是写页面,是确认manifest.json的配置。微信小程序的appid、权限配置、基础库版本都要在这里面搞定。很多新手没配appid就急着跑调试,结果真机预览黑屏,类型的报错跑到怀疑人生。
请求封装是前端的基建,统一处理baseURL、token注入、错误处理和401跳转。baseURL我习惯按环境区分,开发环境用http://127.0.0.1:5000/api,生产环境换成HTTPS域名。uniapp里可以通过process.env.NODE_ENV判断环境,也可以用uni.getSystemInfoSync()的platform区分平台,总之别把URL写死在每个页面里。
// utils/request.js const BASE_URL = 'https://your-domain.com/api'; export function request(options) { return new Promise((resolve, reject) => { const token = uni.getStorageSync('token'); uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, success: (res) => { if (res.statusCode === 401) { uni.removeStorageSync('token'); uni.navigateTo({ url: '/pages/login/login' }); reject(new Error('未登录')); return; } if (res.data.code !== 0) { uni.showToast({ title: res.data.message, icon: 'none' }); reject(new Error(res.data.message)); return; } resolve(res.data.data); }, fail: (err) => { uni.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); }这里有几个经验点。401跳转登录后,最好加一个开关防止并发请求时连续跳转多次,后面踩坑实录里我会细说。第二个是uni.request的fail回调,很多同学不处理网络错误,用户没网时界面就卡住了,必须统一给个toast提示。
3.2 首页菜品展示与分类筛选
首页是点餐系统的门面,做得好的标准是:用户2秒内能找到想吃的菜。我采用“左侧分类tab + 右侧菜品列表”的布局,左边显示分类名,右边滚动显示该分类下的菜品。这个布局在原生小程序里很常见,uniapp里用scroll-view实现也很方便。
菜品列表接口我设计成按分类分页加载,每页10条。这样首屏只请求第一页,触底再加载下一页,避免一次拉几百条数据把用户流量烧光。页面逻辑用Vue3组合式API组织,状态管理清晰,代码可读性高:
<!-- pages/index/index.vue --> <script setup> import { ref, onMounted } from 'vue'; import { request } from '@/utils/request'; const categories = ref([]); const currentCategory = ref(null); const dishList = ref([]); const page = ref(1); const loading = ref(false); const finished = ref(false); async function loadCategories() { const data = await request({ url: '/category/list' }); categories.value = data; if (data.length > 0) { currentCategory.value = data[0].id; loadDishes(true); } } async function loadDishes(reset = false) { if (loading.value) return; if (reset) { page.value = 1; finished.value = false; dishList.value = []; } loading.value = true; const data = await request({ url: '/dish/list', data: { category_id: currentCategory.value, page: page.value, size: 10 } }); dishList.value.push(...data.list); page.value += 1; if (data.list.length < 10) { finished.value = true; } loading.value = false; } function onSwitchCategory(id) { currentCategory.value = id; loadDishes(true); } onMounted(loadCategories); </script>菜品卡片上展示图片、名称、月售量和价格。价格必须用分或者保留两位小数的字符串处理,避免浮点计算精度问题,比如0.1+0.2不等于0.3这种经典事故。加购按钮放在卡片右下角,点击后触发全局购物车状态更新。
3.3 购物车状态管理:本地还是后端,这是个问题
购物车有两种实现思路。一种是把购物车数据存在小程序本地storage里,好处是响应快、不占后端资源,坏处是换设备就丢;另一种是存后端数据库,好处是跨端同步,坏处是每次加购都要请求接口。
我的建议是:做作品集或练习项目用后端存,因为订单流程需要服务端参与,购物车也放后端整个数据链路最一致;做演示Demo可以用本地存,上线简单。本项目我按后端存储来设计,因为后面订单提交时要校验菜品状态和库存,前端购物车数据并不一定可信,统一走后端更安全。
购物车页面用状态管理工具(uniapp内置vuex或pinia)存放数据结构,同时和后端同步。核心操作包括加购、减少数量、勾选结算、删除。每次操作调用后端接口,成功后更新本地状态。加购时如果购物车里已有这道菜,数量加1,否则新增一行。
3.4 订单确认与支付:从购物车到订单的最后一公里
订单确认页要做的事情很明确:展示用户勾选的菜品清单、计算总价、让用户填备注和取餐方式、提交订单。提交时前端把购物车里勾选项的信息传给后端,后端在事务里创建订单主表和订单明细。这里必须强调一遍:金额不能信任前端传值,后端要根据菜品表的实际价格重新计算总价,否则懂点接口知识的人就能改价格下单了。
支付环节我使用了模拟支付和真实微信支付两套方案,通过配置开关切换。模拟支付就是点击“确认支付”后,前端调一次/api/order/pay接口,后端直接把订单状态从0改成1,并记录支付时间,适合开发调试和演示。真实支付则要对接微信支付的下单和支付回调,需申请商户号,个人开发阶段一般用不上。
// 提交换单核心代码 async function submitOrder() { const cartItems = cartStore.selectedItems; if (cartItems.length === 0) { uni.showToast({ title: '请选择菜品', icon: 'none' }); return; } const res = await request({ url: '/order/submit', method: 'POST', data: { items: cartItems.map(item => ({ dish_id: item.dish_id, quantity: item.quantity })), remark: remark.value, address: address.value } }); // 下单成功,跳转支付或订单详情 uni.redirectTo({ url: `/pages/order/detail?id=${res.order_id}` }); }提交订单和支付是两个步骤,而不是一步搞定。先创建待支付订单,再调支付,这样用户中途退出也能回来继续付,不会出现“钱没付但订单没建”的尴尬。这个设计在银行、电商系统里叫“分离式订单”,点餐系统也必须这样。
4. Python后端API开发与联调
4.1 Flask项目结构与启动
后端我用Flask + SQLAlchemy + PyJWT的组合,requirements.txt如下:
flask==2.3.3 flask-sqlalchemy==3.1.1 flask-cors==4.0.0 pyjwt==2.8.0 requests==2.31.0入口文件app.py负责初始化应用、注册蓝图、开启跨域:
from flask import Flask from flask_cors import CORS from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() def create_app(): app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:password@localhost/order_db' app.config['JWT_SECRET_KEY'] = 'your-secret-key' CORS(app) db.init_app(app) from blueprints.user import bp as user_bp from blueprints.dish import bp as dish_bp from blueprints.cart import bp as cart_bp from blueprints.order import bp as order_bp app.register_blueprint(user_bp, url_prefix='/api/user') app.register_blueprint(dish_bp, url_prefix='/api') app.register_blueprint(cart_bp, url_prefix='/api/cart') app.register_blueprint(order_bp, url_prefix='/api/order') with app.app_context(): db.create_all() return app if __name__ == '__main__': app = create_app() app.run(host='0.0.0.0', port=5000, debug=True)数据库连接串我这里写的MySQL,端口和密码你自己改。db.create_all()在开发和练习阶段够用,生产环境建议加个迁移工具管理表结构变更,否则后期加字段会非常痛苦。
4.2 用户登录与JWT鉴权
微信小程序登录的标准流程分两步:小程序端调uni.login()拿到临时code,把code传给后端;后端用code调用微信接口,换取openid。换取openid要求你有小程序的appid和secret,这两个配置千万别写在前端代码里,必须放后端环境变量。
# blueprints/user.py import requests import jwt import time from flask import Blueprint, request, jsonify from models import User, db bp = Blueprint('user', __name__) @bp.route('/login', methods=['POST']) def login(): data = request.get_json() code = data.get('code') if not code: return jsonify({'code': 1, 'message': '缺少code'}) appid = 'your-appid' secret = 'your-secret' resp = requests.get( 'https://api.weixin.qq.com/sns/jscode2session', params={'appid': appid, 'secret': secret, 'js_code': code, 'grant_type': 'authorization_code'} ) wx_data = resp.json() openid = wx_data.get('openid') if not openid: return jsonify({'code': 1, 'message': '登录失败'}) user = User.query.filter_by(openid=openid).first() if not user: user = User(openid=openid, nickname='微信用户') db.session.add(user) db.session.commit() token = jwt.encode( {'user_id': user.id, 'exp': int(time.time()) + 7 * 24 * 3600}, app.config['JWT_SECRET_KEY'], algorithm='HS256' ) return jsonify({'code': 0, 'message': 'ok', 'data': {'token': token, 'user_info': {'nickname': user.nickname}}})为了开发调试方便,我额外加了一个测试登录接口,传个userId直接返回token,这样前端跑通业务流程时不用每次都依赖微信登录,联调效率翻倍。这个接口上线前一定要关掉,否则等于后端裸奔。
鉴权装饰器是所有需要登录接口的守卫:
# auth.py from functools import wraps import jwt from flask import request, jsonify, current_app from models import User def login_required(f): @wraps(f) def wrapper(*args, **kwargs): auth_header = request.headers.get('Authorization', '') if not auth_header.startswith('Bearer '): return jsonify({'code': 401, 'message': '未登录'}) token = auth_header[7:] try: payload = jwt.decode(token, current_app.config['JWT_SECRET_KEY'], algorithms=['HS256']) user = User.query.get(payload['user_id']) if not user: return jsonify({'code': 401, 'message': '用户不存在'}) request.current_user = user except jwt.ExpiredSignatureError: return jsonify({'code': 401, 'message': '登录已过期'}) except jwt.InvalidTokenError: return jsonify({'code': 401, 'message': '无效token'}) return f(*args, **kwargs) return wrapper把用户对象挂到request.current_user上,后面每个接口只要在视图函数里直接用它就能拿到当前用户,省去重复解析token。
4.3 菜品、购物车、订单核心接口实现
菜品列表接口要支持分类筛选和分页,一个常规的list查询:
# blueprints/dish.py @bp.route('/dish/list', methods=['GET']) def dish_list(): category_id = request.args.get('category_id', type=int) page = request.args.get('page', default=1, type=int) size = request.args.get('size', default=10, type=int) query = Dish.query.filter_by(status=1) if category_id: query = query.filter_by(category_id=category_id) total = query.count() dishes = query.order_by(Dish.sales.desc()).offset((page - 1) * size).limit(size).all() data = { 'total': total, 'list': [{ 'id': d.id, 'name': d.name, 'price': str(d.price), # 转字符串避免浮点问题 'image': d.image, 'sales': d.sales, 'stock': d.stock, 'description': d.description } for d in dishes] } return jsonify({'code': 0, 'message': 'ok', 'data': data})购物车接口相对直接,增删改查都要带上login_required装饰器,先拿到当前用户,再操作。
订单提交接口是整个后端的核心,必须用事务处理。一个订单包含创建主表、创建明细、扣减库存、清空购物车四个操作,任何一个失败都要全部回滚,不能出现“订单建了但库存没扣”的脏数据。库存扣减我用了带条件的UPDATE,防止超卖:
# blueprints/order.py from sqlalchemy import update from datetime import datetime import random, string @bp.route('/submit', methods=['POST']) @login_required def submit_order(): user = request.current_user data = request.get_json() items = data.get('items', []) if not items: return jsonify({'code': 1, 'message': '菜品不能为空'}) try: db.session.begin() # 1. 计算总价并校验库存 total_amount = 0 order_items = [] for item in items: dish = Dish.query.filter_by(id=item['dish_id'], status=1).with_for_update().first() if not dish or dish.stock < item['quantity']: db.session.rollback() return jsonify({'code': 1, 'message': f'菜品{dish.name if dish else ""}库存不足'}) # 2. 扣减库存 dish.stock -= item['quantity'] dish.sales += item['quantity'] subtotal = dish.price * item['quantity'] total_amount += subtotal order_items.append({ 'dish_id': dish.id, 'dish_name': dish.name, 'dish_image': dish.image, 'price': dish.price, 'quantity': item['quantity'] }) # 3. 生成订单号 order_no = datetime.now().strftime('%Y%m%d%H%M%S') + ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) # 4. 创建订单 order = Order( order_no=order_no, user_id=user.id, total_amount=total_amount, remark=data.get('remark', ''), address=data.get('address', ''), status=0 ) db.session.add(order) db.session.flush() # 获取 order.id # 5. 创建订单明细 for oi in order_items: db.session.add(OrderItem(order_id=order.id, **oi)) # 6. 清空购物车 Cart.query.filter_by(user_id=user.id).delete() db.session.commit() return jsonify({'code': 0, 'message': 'ok', 'data': {'order_id': order.id, 'order_no': order.order_no}}) except Exception as e: db.session.rollback() return jsonify({'code': 1, 'message': '下单失败'})这里的with_for_update()是行级锁,同一道菜同时被多人抢购时串行化,防止库存扣成负数。事务必须用db.session.begin()显式开启,让提交和回滚的边界清晰。订单号用时间戳加6位随机字符串,在单机环境基本不会重复,还能保证长度统一。
4.4 商家端接口的思路
完整点餐系统肯定需要商家端。这里不展开全部代码,讲两个思路。商家端接口和用户端接口分离,但共用同一套数据库。用role字段区分身份,用户表里role=1是管理员,只有管理员的token能访问商家接口。菜品管理、订单状态流转这些接口都加一层admin_required装饰器,在login_required基础上再判断user.role != 1就拒绝。
订单状态流转接口的设计也很简单,比如商家接单:
@bp.route('/status', methods=['POST']) @login_required @admin_required def update_status(): data = request.get_json() order_id = data.get('order_id') new_status = data.get('status') order = Order.query.get(order_id) if not order: return jsonify({'code': 1, 'message': '订单不存在'}) # 状态流转校验:只允许从pengding到making这类合法迁移 if not is_valid_transition(order.status, new_status): return jsonify({'code': 1, 'message': '非法的状态流转'}) order.status = new_status if new_status == 1: order.pay_time = datetime.now() db.session.commit() return jsonify({'code': 0, 'message': 'ok'})is_valid_transition的实现就是把2.2节定义的状态机写成硬编码校验,这是系统稳定性的真正保障。
5. 常见问题与避坑实录
5.1 uniapp多端兼容:一套代码不是万能药
uniapp打的口号是“一套代码,多端运行”,但实际跑起来你会发现,不同平台还是有细节差异。第一个坑是条件编译:有些API只存在于微信小程序,比如uni.getUserProfile,在H5端用不了,需要页面启动时判断平台,或者用条件编译注释掉不兼容的代码。第二个坑是单位:uniapp里推荐用rpx做响应式单位,但在小程序端和H5端的渲染效果偶尔会有1px的偏差,需要真机调试微调。第三个坑是图片:小程序端的图片必须用HTTPS地址,本地调试也要把图片放到服务器或用base64,否则无法显示。
我最近帮A同学调他的点餐项目,首页菜品图在开发者工具里显示正常,真机上一片空白,排查了一圈发现是图片域名没加入小程序后台的“downloadFile合法域名”。这种事文档里写得明白,但很多新手不看,踩一次坑就记住了。
5.2 token过期与401竞态处理
前面提过401并发跳转的问题,这里展开细讲。用户A进入首页,首页同时发起三个请求,三个请求都返回401,前端拦截器就执行三次navigateTo。结果不是跳三次登录页,而是页面栈里压了三个登录页,用户返回时直接卡死。
我的解法是设置一个isRedirecting标志位:
let isRedirecting = false; // 在401处理逻辑中 if (!isRedirecting) { isRedirecting = true; uni.removeStorageSync('token'); uni.navigateTo({ url: '/pages/login/login' }); setTimeout(() => { isRedirecting = false; }, 3000); }用时间窗口(比如3秒)把重复跳转拦掉,等第一个登录页跳转完,后面的401请求自然不会再触发重复跳转。真正生产环境还要上refresh token机制,但小系统用这个标志位方案已经够稳。
5.3 订单与库存的并发:超卖是怎么发生的
超卖是库存系统最经典的事故。我见过一个项目,扣库存的代码是“先select剩余库存,判断大于0,再update库存减1”,这代码在低并发下没问题,但在秒杀场景下,两个请求同时读到库存1,都判断可以卖,都执行update,库存就变成-1了。这就是典型的“竞态条件”。
解决超卖有三个层次。最基础的是在扣库存的SQL里加上条件:UPDATE dish SET stock = stock - 1 WHERE id = ? AND stock > 0,让数据库自己保证原子性;进阶一点像我上面写的,用with_for_update()对菜品行加锁;再高级用Redis分布式锁或乐观锁版本号,但点餐系统并发没到这个量级。我推荐的方案是“条件更新+事务”,简单可靠,也不用引入额外组件。
5.4 前端性能与体验优化
点餐系统的前端性能优化,核心是首屏加载速度。图片是这个系统的性能大头,一张没压缩的菜品图可能2MB,首页拉10个菜就是20MB流量,用户早跑了。我一般用小程序端的image组件自带懒加载模式,再加上后端做图片压缩,一张菜品图压到100KB以内。列表滚动性能方面,长列表用scroll-view配合分页加载,不要一上来就把全部分类的菜全拉下来。
菜品分类和列表的加载交互也要设计好:切换分类时清掉旧列表、展示骨架屏或loading动画,避免用户以为卡了。这些优化一次做完,后面用户体验报告好看很多。
最后再分享一个实际小技巧
这套系统做到后面,我最大的体会是:先把最核心的“点菜-下单-支付-查单”闭环跑通,再考虑加花活。很多开发者一上来就想着做优惠券、会员积分、营销活动,结果核心功能还没稳定,需求越滚越大,项目永远交付不了。我自己做项目是先列表页点餐——购物车——提交订单——模拟支付,整条链路通了,再逐步加上商家端、打印小票、数据分析这些模块。
如果你打算在这个基础上扩展,我有两个很实用的方向。一个是给商家端加个简单的Web管理页面,用Vue3全家桶搭一个菜品管理后台,前端代码和小程序端复用很大一部分接口,成本很低;另一个是接入实际的微信支付,把模拟支付的开关换成真实下单,对接下支付回调接口,这算是商业化落地前的临门一脚。把这两步做完,这套系统就能从演示Demo变成真正能上线的商用产品。