简介:一份面向数据库课程设计与微信小程序开发的完整校园外卖系统项目,围绕学生顾客、商家、配送员三类角色实现闭环业务:学生可浏览在售商品、下单、跟踪订单状态、完成后评分评价,并维护地址头像电话等个人资料;商家可维护商品、接单、派单给兼职配送员,并查看经营统计;配送员可查看派单信息并完成配送。压缩包共123个文件、约2.37MB,包含js、wxml、wxss等小程序前端页面与逻辑代码,json配置文件,sql数据库脚本,py辅助脚本,以及png/jpg界面截图,目录结构清晰,能直接对照学习前后端交互与数据库表设计。通过这份资源,读者可理清小程序端与数据表之间的对应关系,掌握订单状态机、权限角色拆分和数据库设计方法;sql脚本和界面截图尤其适合课程设计答辩前快速梳理功能与数据流。已有280人学习下载,适合需要完成数据库课程设计、小型电商系统开发练习或小程序前后端联调的开发者参考。
1. 微信小程序校园外卖系统:数据库课设也能把三端状态流转做成亮点
微信小程序校园外卖系统,听起来是普通课程设计,但真正让人卡住的不是页面多,而是订单状态怎么在三类角色之间流转。这套基于 JavaScript + Python 的数据库课程设计,把学生客户、商家、学生配送员放在同一套数据模型上:客户下单后能看见“制作中、派单中、接单中”,商家负责接单和派单,配送员抢单后把外卖送到客户手里。拿到手的压缩包里有小程序前端源码、Python 后端接口、SQL 脚本和一堆设计参考图片,适合正在做外卖/交易类课设、或者想快速看懂“小程序+后端+数据库”怎么串起来的人。
2. 数据库设计先于代码:订单状态机与九张核心表
我拿到一个课设资源,一般先不跑代码,先翻 SQL 脚本和.DS_Store旁边的图片素材。为什么?因为前端页面是给人看的,数据库才是系统的骨架。这套校园外卖系统的核心矛盾是:一个订单要经过客户、商家、配送员三个角色,每个角色对同一订单有不同的操作权限,如果表结构没设计好,后面所有接口都在打补丁。下面按我自己的建模顺序来拆。
2.1 角色模型与权限边界
用户表是最顶层的主数据。我一般不在课程设计里把客户表、商家表、配送员表拆成三张,而是用一张users表加role字段:1 学生客户,2 商家,3 学生配送员。理由有三个:第一,三个角色共用的字段(手机号、头像、昵称)不用重复建三份;第二,登录时一次查询就能拿到角色,前端跳转逻辑简单;第三,外卖场景里同一个用户理论上可以既是学生又是配送员,单表加角色天然支持这种身份切换。
权限边界在接口层做,不在表里做。比如POST /api/business/dispatch的处理器里先取当前用户 role,如果role != 2直接返回“无权限”。表结构里不去设计用户-角色映射表,是因为课程设计不需要引入 RBAC 的复杂度,过度设计会让答辩老师追问半天。
2.2 订单状态流转
接下来是订单状态机。摘要里提到客户能看到制作中、派单中、接单中,实际上完整状态还要补上待接单和已完成,不然流程断不开。下面这张状态流转表,建议直接贴在课设文档里。
| 状态值 | 状态名 | 谁触发 | 下一个状态 |
|---|---|---|---|
| -1 | 已取消 | 客户/商家 | 终点 |
| 0 | 待接单 | 客户下单 | 1 |
| 1 | 制作中 | 商家接单 | 2 |
| 2 | 派单中 | 商家制作完成,发布派单 | 3 |
| 3 | 接单中 | 配送员接单 | 4 |
| 4 | 已完成 | 配送员送达/客户确认 | 终点 |
这张表最好在答辩时第一个讲。状态流转必须单向推进,不允许从 1 直接跳到 3,否则统计信息会乱。前端展示的“制作中、派单中、接单中”只是其中三个节点,数据库里一定要留足整个链条,不然配送员抢单前和抢单后的状态没法区分。
2.3 表结构设计与字段说明
这个项目我拆出来的核心表是九张:users、shop、category、goods、cart、orders、order_items、deliveries、reviews。每张表解决一个明确问题:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| users | 用户表 | id, username, password, role, nickname, avatar, phone, address |
| shop | 店铺表 | id, user_id, name, notice, status |
| category | 商品分类表 | id, shop_id, name, sort |
| goods | 商品表 | id, shop_id, category_id, name, price, stock, image_url |
| cart | 购物车表 | id, user_id, goods_id, quantity |
| orders | 订单主表 | id, order_no, user_id, shop_id, total_amount, state |
| order_items | 订单明细表 | id, order_id, goods_id, goods_name, price, quantity |
| deliveries | 配送表 | id, order_id, courier_id, dispatch_time, accept_time, status |
| reviews | 评价表 | id, order_id, user_id, rating, content |
订单明细一定要单独建表,因为商品价格会变。下单时要把商品名称、单价、数量都存快照,不能只关联 goods_id,否则商家改价后历史订单全乱。reviews 和 deliveries 上都要加唯一约束,保证一个订单最多一条评价、一条配送记录。
2.4 ER 关系与建表脚本示例
下面是核心表的简化建表脚本。课程设计阶段不需要把每张表都堆上去,但这几张卡住的是状态流转和统计查询。
CREATE DATABASE IF NOT EXISTS campus_order DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE campus_order; CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT '课程设计常用md5双层加密', role TINYINT NOT NULL COMMENT '1学生客户 2商家 3学生配送员', nickname VARCHAR(32) DEFAULT '', avatar VARCHAR(255) DEFAULT '', phone VARCHAR(20) DEFAULT '', address VARCHAR(255) DEFAULT '', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE goods ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, shop_id INT UNSIGNED NOT NULL, category_id INT UNSIGNED DEFAULT 0, name VARCHAR(64) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT DEFAULT 0, image_url VARCHAR(255) DEFAULT '', status TINYINT DEFAULT 1, sales_count INT DEFAULT 0, KEY idx_shop (shop_id) ) ENGINE=InnoDB; CREATE TABLE orders ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT UNSIGNED NOT NULL COMMENT '下单的客户id', shop_id INT UNSIGNED NOT NULL, total_amount DECIMAL(10,2) NOT NULL, state TINYINT NOT NULL DEFAULT 0 COMMENT '-1取消 0待接单 1制作中 2派单中 3接单中 4已完成', address VARCHAR(255) NOT NULL, phone VARCHAR(20) NOT NULL, remark VARCHAR(255) DEFAULT '', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME DEFAULT NULL, KEY idx_user (user_id), KEY idx_shop_state (shop_id, state) ) ENGINE=InnoDB; CREATE TABLE order_items ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id INT UNSIGNED NOT NULL, goods_id INT UNSIGNED NOT NULL, goods_name VARCHAR(64) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, KEY idx_order (order_id) ) ENGINE=InnoDB; CREATE TABLE deliveries ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id INT UNSIGNED NOT NULL, courier_id INT UNSIGNED NOT NULL, dispatch_time DATETIME DEFAULT NULL, accept_time DATETIME DEFAULT NULL, finish_time DATETIME DEFAULT NULL, status TINYINT DEFAULT 0 COMMENT '0未接 1配送中 2已完成', UNIQUE KEY uk_order (order_id) ) ENGINE=InnoDB; CREATE TABLE reviews ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED NOT NULL, shop_id INT UNSIGNED NOT NULL, rating TINYINT NOT NULL DEFAULT 5, content VARCHAR(255) DEFAULT '', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order (order_id) ) ENGINE=InnoDB;代码里的几个点需要说明:order_no用唯一业务编号,不直接暴露自增 id,避免被遍历下订单;state用 TINYINT 而不是字符串,查询快、占空间少,但前端必须维护状态字典;deliveries和reviews的UNIQUE KEY uk_order是数据完整性底线,保证一个订单不会被两个配送员同时接走,也保证客户只能评一次。字段注释一定要写,因为在 MySQL Workbench 生成 ER 图时,注释会直接变成图里标签,答辩加印象分。
注意:state 状态值一旦在客户端写死,之后不要随意调整顺序,否则已有订单的状态语义会错位。宁可一开始多设计两个状态,也不要后面补。
3. Python 后端:Flask 接口与订单派单的请求封装
数据库设计完,接下来就是后端接口。这套资源里的后端是用 Python 写的,我按自己惯用的 Flask 路线来拆,因为课程设计不需要 Django 那种全家桶,Flask 单文件就能跑,给小程序提供 JSON 接口刚刚好。整个后端可以压缩成三个文件:db.py管连接,utils.py管统一返回,app.py管路由。
3.1 为什么用 Flask 而不是 Django
Flask 的优点是轻量、路由直观、虚拟环境好配。Django 自带 Admin、ORM、认证体系,但对课设来说反而像戴着镣铐跳舞,如果老师问“这个表是你设计的吗”,你得解释 Django 那套迁移机制,容易把问题扯远。Flask 加 pymysql 是课程设计的经典组合,代码量少,逻辑透明。
我一般会把后端目录组织成:
backend/ app.py db.py utils.py requirements.txt static/ goods/ uploads/static/goods放商品图片,static/uploads放用户头像。这样图片和代码分开,部署时不会混淆。
3.2 统一返回格式与数据库连接
小程序的wx.request本身就能接 JSON,但如果不做统一包装,每个接口返回结构不一样,前端要写一堆 if 判断。我习惯所有接口都返回{code, msg, data},前端只看code是否为 0。
# db.py import pymysql def get_conn(): return pymysql.connect( host='127.0.0.1', port=3306, user='root', password='123456', database='campus_order', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor, # 返回字典,前端不用自己映射字段 autocommit=True )这段连接参数里,charset='utf8mb4'必须写,否则中文乱码问题会一直跟着你。DictCursor让查询结果直接是字典列表,接口返回给小程序时少一层转换。autocommit=True省去了手动 commit,但涉及订单插入和库存扣减时,建议手动管理事务。
# utils.py from flask import jsonify def ok(data=None, msg='ok'): return jsonify({'code': 0, 'msg': msg, 'data': data}) def fail(msg='error', code=1): return jsonify({'code': code, 'msg': msg, 'data': None})这样封装以后,业务接口里return ok()或者return fail()就行了,前端拦截器统一处理错误提示。
3.3 关键接口实现
核心接口集中在三个动作:客户下单、商家接单/派单、配送员抢单。先看下单接口:
# app.py from flask import Flask, request from db import get_conn from utils import ok, fail import uuid app = Flask(__name__) @app.route('/api/order/create', methods=['POST']) def order_create(): data = request.get_json() goods_id = data.get('goodsId') quantity = data.get('quantity') address = data.get('address') phone = data.get('phone') # 简化的价格获取,实际要用事务包住订单和明细插入 conn = get_conn() with conn.cursor() as cur: cur.execute('SELECT shop_id, price FROM goods WHERE id=%s', (goods_id,)) goods = cur.fetchone() if not goods: return fail('商品不存在') order_no = uuid.uuid4().hex[:16] total = goods['price'] * quantity cur.execute( 'INSERT INTO orders (order_no, user_id, shop_id, total_amount, state, address, phone) ' 'VALUES (%s, %s, %s, %s, 0, %s, %s)', (order_no, 1, goods['shop_id'], total, address, phone) ) order_id = cur.lastrowid cur.execute( 'INSERT INTO order_items (order_id, goods_id, goods_name, price, quantity) ' 'VALUES (%s, %s, %s, %s, %s)', (order_id, goods_id, goods['name'], goods['price'], quantity) ) return ok({'orderNo': order_no, 'state': 0})这段代码先查商品拿价格和店铺 id,再生成订单号和订单明细。顺序不能反,否则金额不可信。状态初始值固定为 0,也就是“待接单”。
接下来是状态流转中最容易翻车的点——乐观锁:
@app.route('/api/business/accept', methods=['POST']) def business_accept(): data = request.get_json() order_id = data.get('orderId') conn = get_conn() with conn.cursor() as cur: updated = cur.execute( 'UPDATE orders SET state=1 WHERE id=%s AND state=0', (order_id,) ) if updated: return ok() return fail('订单状态已变化,请刷新')关键在WHERE id=%s AND state=0,没有这个条件,两个请求同时进来就会把状态覆盖掉。商家派单接口同理,条件改成state=1,更新到 2;配送员抢单接口条件改成state=2,更新到 3。每一步都走“当前状态 -> 下一个状态”的校验。
3.4 权限校验与参数校验
课程设计里权限没必要做太复杂,写一个装饰器就行:
from functools import wraps def require_role(*roles): def wrapper(fn): @wraps(fn) def inner(*args, **kwargs): # 实际应从请求头解析出用户,这里简化 user = {'id': 1, 'role': 1} if user['role'] not in roles: return fail('无权限') return fn(*args, **kwargs) return inner return wrapper商家接口上标@require_role(2),配送员接口标@require_role(3)。这样权限边界保持在接口层,而不是靠前端隐藏按钮。参数校验至少判断关键字段非空,比如下单接口缺了address要直接拒绝,否则数据库会写入一堆残缺订单。
提示:token 逻辑在课设里可以从简,但别用明文密码连接数据库。密码至少做一层 md5,答辩演示时也显得专业。
4. 微信小程序前端:JavaScript 页面逻辑与状态渲染
后端接口有了,小程序端要做的就是把 REST 数据变成人话。这个微信小程序项目实例是经典原生写法,不依赖 uni-app 或 Taro,好处是答辩时能让老师直接看懂目录结构。JavaScript 页面逻辑就分两块:请求封装和状态渲染。
4.1 小程序目录结构与 app.json 配置
目录结构建议按角色分页面:
miniprogram/ pages/ index/ # 客户首页(商品浏览) order/ # 客户订单列表 user/ # 用户中心 merchant/ # 商家工作台 courier/ # 配送员任务列表 utils/ request.js # wx.request 封装 status.js # 订单状态字典 app.js app.json app.wxssapp.json只需要注册页面和窗口样式:
{ "pages": [ "pages/index/index", "pages/order/order", "pages/user/user", "pages/merchant/merchant", "pages/courier/courier" ], "window": { "navigationBarTitleText": "校园外卖", "navigationBarBackgroundColor": "#FF7043", "navigationBarTextStyle": "white" }, "style": "v2", "sitemapLocation": "sitemap.json" }如果你想把商家端做成自定义导航栏,还需要设置"navigationStyle": "custom"。这时要自己计算顶部导航栏高度:状态栏高度用wx.getWindowInfo().statusBarHeight,加上胶囊按钮高度。不同机型差异很大,iPhone X 以上和普通安卓可能差 20px,我一般直接拿wx.getMenuButtonBoundingClientRect()的返回值去动态撑开,而不是写死 44px。
4.2 wx.request 请求封装与本地缓存
所有页面都不直接调wx.request,先封装成一个 JavaScript 函数,返回 Promise:
// utils/request.js const BASE_URL = 'http://127.0.0.1:5000/api' function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method, data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') ? 'Bearer ' + wx.getStorageSync('token') : '' }, success(res) { const body = res.data if (body.code === 0) { resolve(body.data) } else { wx.showToast({ title: body.msg, icon: 'none' }) reject(body) } }, fail: reject }) }) } module.exports = { request, BASE_URL }封装以后,页面代码只需要await request('/goods/list'),异常和 toast 都集中处理。token 用wx.setStorageSync存本地,如果要控制登录有效期,我会再存一个expires时间戳,读取时和Date.now()比较,这就是微信小程序设置缓存时间的常见做法。注意BASE_URL在真机调试时要改成局域网 IP,后面避坑章节会细说。
4.3 商品列表与下单逻辑
客户首页的控制器代码很短:
// pages/index/index.js const { request } = require('../../utils/request') Page({ data: { goods: [] }, onLoad() { this.loadGoods() }, async loadGoods() { const list = await request('/goods/list?shopId=1', 'GET') this.setData({ goods: list }) }, async createOrder(e) { const goodsId = e.currentTarget.dataset.id const price = e.currentTarget.dataset.price wx.showModal({ title: '下单', content: '确认购买 ' + price + ' 元商品?', success: async (res) => { if (res.confirm) { const result = await request('/order/create', 'POST', { goodsId, quantity: 1, address: wx.getStorageSync('address'), phone: wx.getStorageSync('phone') }) wx.navigateTo({ url: '/pages/order/order?orderNo=' + result.orderNo }) } } }) } })e.currentTarget.dataset.id是从 wxml 的>// utils/status.js const ORDER_STATUS = { '-1': { text: '已取消', color: '#999' }, '0': { text: '待接单', color: '#F56C6C' }, '1': { text: '制作中', color: '#E6A23C' }, '2': { text: '派单中', color: '#409EFF' }, '3': { text: '接单中', color: '#67C23A' }, '4': { text: '已完成', color: '#909399' } } module.exports = { ORDER_STATUS }
在订单页 wxml 里,用wx:for循环渲染,通过state映射文案和颜色:
<view wx:for="{{orderList}}" wx:key="id"> <text style="color: {{ORDER_STATUS[item.state].color}}"> {{ORDER_STATUS[item.state].text}} </text> </view>注意 wxml 不能直接使用 js 文件导入的变量,需要在onLoad里把ORDER_STATUS合并到data中。三端页面都能复用这套字典:商家看到“待接单”就显示“接单”按钮,配送员看到“派单中”就显示“抢单”按钮,客户只读状态。这样整个系统的状态展示不会有二义性。
5. 避坑与排查:三端联调最容易翻车的六个位置
联调阶段才是课设最耗时间的地方,前端跑起来不等于整个链路通。下面六条是我拆这类项目时反复踩过的坑,每一条都按现象、原因、解决讲清楚,直接抄进你的排错笔记就行。
5.1 图片路径写死导致商品图裂掉
现象:首页商品图大面积裂开,控制台报 404。原因:解压后素材里有2.jpg、-1.jpg、33.jpg这类命名杂乱的图片,前端直接写src="/img/2.jpg",后端根本不在这个目录。解决:把素材统一放到backend/static/goods/,数据库存/static/goods/2.jpg这种相对路径,小程序侧用BASE_URL + image_url拼完整地址。不要把-1.jpg这种带横线的文件名硬编码到代码里,转存时顺手重命名一次,后面省很多事。
5.2 订单状态被覆盖:两个角色同时操作同一订单
现象:商家刚点“接单”,配送员也点“抢单”,结果订单状态变成空白或跳过了制作中。原因:前端用wx.navigateTo带订单状态给详情页,后端UPDATE orders SET state=3 WHERE id=?没判断当前状态,后提交的请求把前一个覆盖了。解决:所有状态更新都带当前状态条件,比如UPDATE orders SET state=1 WHERE id=? AND state=0。如果返回影响行数为 0,前端统一提示“订单状态已变化,请刷新”,不要静默失败。
5.3 本地联调 wx.request 永远 fail
现象:开发者工具请求http://127.0.0.1:5000报request:fail。原因:小程序默认要求合法域名,本机 IP 不在白名单里。解决:开发者工具右上角“详情-本地设置”,勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。用真机演示时,把BASE_URL改成局域网 IP,并且后端app.run(host='0.0.0.0', debug=True),不然手机请求不到电脑上的 Flask 服务。
5.4 中文乱码:发布商品变问号
现象:商家输入“黄焖鸡套餐”,小程序端看到“???”。原因:建库时用了默认 latin1,pymysql 连接没指定charset。解决:数据库连接串写charset='utf8mb4',建库语句加DEFAULT CHARACTER SET utf8mb4。如果已经建好的库,执行ALTER TABLE goods CONVERT TO CHARACTER SET utf8mb4,表结构和 connection 必须统一。这个坑最隐蔽,因为有时候命令行查是正常的,但小程序端解析就乱。
5.5 头像上传后刷新就裂
现象:wx.chooseMedia选完头像后能显示,但退出页面再进来就裂。原因:小程序tempFilePaths返回的是临时路径,只在本次会话有效。解决:用wx.uploadFile上传到后端/api/upload/avatar,后端把文件保存到static/uploads/,返回一个永久 URL 存数据库。数据库里不要存wxfile://tmp_xxx这种临时路径,血泪教训,真机测试时特别容易翻车。
5.6 订单时间差 8 小时
现象:前端显示 12:00,数据库存的却是 04:00。原因:MySQL 的DATETIME不带时区,服务器是 UTC,Python 的datetime.now()取的是系统 UTC 时间,小程序端new Date('2024-01-01T04:00:00Z')又会自动转成本地时间,两边对不上。解决:后端统一用本地时区生成时间,或者在 MySQL 连接串里加time_zone='+08:00'。最省事的方案是前端不解析时间字符串,数据库返回什么就显示什么,但课设文档里最好写清楚时间策略,老师追问时能答上来。
6. 验证与答辩演示:用一组测试数据走通全流程
前面把表结构、后端接口、小程序渲染和常见坑都过了一遍,最后说下怎么验证。课程设计答辩最怕的是现场演示时状态卡住,所以我会准备一套固定账号和固定流程,提前跑通,不给老师留提问的空间。
6.1 准备三端测试账密
| 角色 | 账号 | 密码 | 预期入口 |
|---|---|---|---|
| 学生客户 | student | 123456 | 首页商品、下单、订单列表 |
| 商家 | merchant | 123456 | 商品管理、订单接单/派单、统计 |
| 学生配送员 | courier | 123456 | 派单列表、抢单、送达 |
6.2 端到端演示顺序
| 步骤 | 操作 | 预期状态 |
|---|---|---|
| 1 | 客户登录,下单一杯奶茶 | 待接单 |
| 2 | 商家登录,点“接单” | 制作中 |
| 3 | 商家制作完成,点“派单” | 派单中 |
| 4 | 配送员登录,点“抢单” | 接单中 |
| 5 | 配送员送达,点“完成” | 已完成 |
| 6 | 客户进入订单详情,评分 | 评价成功,状态不变 |
这个流程覆盖了所有状态节点,也覆盖了三个角色的操作边界。演示时从客户账号开始,其他两个账号提前登录好,不要在答辩现场频繁切换,切换太慢就会被评委怀疑系统不稳定。
6.3 统计信息验证
商家端能看到统计信息,后台其实就是一条聚合 SQL:
SELECT DATE(create_time) AS day, COUNT(*) AS order_count, SUM(total_amount) AS amount FROM orders WHERE shop_id = 1 AND state = 4 GROUP BY day ORDER BY day DESC;这条语句统计的是“已完成”订单,如果状态机没走完,比如订单卡在派单中,这个数字就永远不对。所以演示前先跑一遍 6.2 的流程,让至少一单状态变成 4,统计数字才好看。
这套流程我拆过好几遍,每次准备预览图或线上版,其实页面切换时经常因为网络慢状态没刷出来,白屏几秒评委就会追问。从那以后我每次演示前都强制走一遍三端全流程,先清空数据库再重新造一轮数据,确保每个状态按钮都能点。希望帮到你。
本文还有配套的精品资源,点击获取