简介这是一份基于JavaScriptPython的微信小程序校园外卖系统课程设计项目面向计算机相关专业学生及需要快速搭建完整业务闭环的开发者。系统覆盖学生客户、商家、配送员三类角色包含商品浏览与下单、订单状态跟踪、评分评价、商家商品管理、派单接单及用户信息维护等核心功能贴合数据库课程设计对数据表设计、关联查询与业务逻辑的要求。资源内含sql数据库脚本及说明文档便于搭建数据库环境与理解表结构前端页面截图丰富可直观对照小程序各功能模块的交互效果。压缩包共123个文件以png界面图、js逻辑脚本、json配置、wxss样式、wxml页面结构为主辅以sql脚本、md说明及python辅助脚本包体仅2.37MB目录结构清晰便于直接导入运行与二次开发。已有280人学习下载适合作为毕业设计、课程答辩或小程序开发入门的完整参考。1. 微信小程序 Python 的校园外卖系统一道数据库课程设计题的三层考验基于 JavaScriptPython 的微信小程序校园外卖系统是数据库课程设计里出现频率很高的全栈题目。它把小程序前端、Python 后端和关系型数据库串成一条完整链路评分重点往往不在页面好不好看而在表结构是否规范、事务与约束有没有用对、答辩时能不能把设计取舍讲清楚。很多同学把时间花在美化界面上结果被评委三个问题问住金额为什么用 DECIMAL外键为什么没加并发下单会不会超卖这篇笔记按一线开发者的做法拆解这套题先定架构选型再设计表结构最后联调与答辩演练。全程用常见且可复现的技术方案给出关键代码、参数和踩坑记录。适合正在做课程设计的学生也适合想用「校园外卖」这类题目练手全栈的小程序初学者。读完之后你能照着搭出一套可运行的系统也知道哪些地方是评分点、哪些坑必须提前绕开。2. 系统架构与选型小程序端 Python 后端 MySQL 为什么是稳妥组合2.1 小程序端的职责边界JavaScript 只做 UI 和请求不碰数据库先讲清楚小程序端到底要管什么。校园外卖的用户核心流程是五个动作浏览商家和菜品、加入购物车、确认下单、模拟支付、查看订单状态。这些动作在小程序里都属于「视图与交互层」用 JavaScript 绑定数据、监听点击事件再通过 wx.request 调后端接口。小程序运行在微信提供的沙箱环境里没有数据库驱动也没有 TCP 直连权限所以任何「在小程序里直接写 SQL」的想法都不成立这正是分层架构存在的理由。把这条边界划清楚代码结构就不会乱。我见过有同学在小程序端写了一大堆价格计算逻辑满减、折扣、配送费全在前端算结果后端不认账订单金额和前端展示对不上。正确做法是前端只展示、只收集用户操作所有金额和业务规则的计算都放后端。前端传过来的 total 金额只当参考后端必须按数据库里的单价重新算一遍。这个原则不仅适用于课程设计也是真实接口开发的底线。小程序的工程结构一般长这样pages 目录放页面每个页面由 wxml结构、wxss样式、js逻辑、json页面配置四个文件组成utils 目录放公共工具典型的是 request 封装app.js 是全局逻辑入口app.json 注册所有页面路由。后端接口设计成 RESTful 风格小程序端只关心返回的 JSON 结构是不是固定——字段名和结构一旦变化前端可能静默出错所以联调时先定接口约定比写代码更重要。2.2 Python 后端选型Flask 够用别再硬上 Django后端框架决定你后半段的推进速度。常见选项就两个Flask 和 Django。Flask 是微框架路由和视图都是函数级一个 app.py 就能写完所有接口每个接口的逻辑一目了然答辩时很容易讲清楚「这个接口做什么、输入是什么、输出是什么」。Django 自带 ORM、Admin 后台、用户认证功能全面但学习曲线明显更陡而且框架替你做掉太多事很多同学最后分不清哪些是 Django 内置行为、哪些是自己的设计被评委追问时容易露怯。我一般建议课程设计用 Flask 搭配 PyMySQL 手写 SQL。用 PyMySQL 时表名、字段、JOIN、事务都是显式写出来的答辩时可以直接指着代码讲这是最扎实的展示方式。如果用 SQLAlchemy 这类 ORM代码会更精简但 ORM 会掩盖你对表结构的理解评委追问 «这行 ORM 对应数据库里的什么操作» 时容易卡壳。折中方案就是 Flask PyMySQL本文后面的代码都用这个组合。后端代码的组织方式我习惯拆成几个文件app.py 放应用入口和路由db.py 放数据库连接函数auth.py 放登录态校验sqls.py 放常用的 SQL 语句常量。拆分的目的是答辩时能讲出「工程结构」而不是堆一个两千行的单文件。最小可用入口和连接配置如下# app.py from flask import Flask, jsonify, request import pymysql app Flask(__name__) # 数据库连接参数按你的 MySQL 实际配置修改 DB_CONFIG { host: 127.0.0.1, port: 3306, user: root, password: your_password, database: campus_food, charset: utf8mb4, # 关键不用 utf8用 utf8mb4否则 emoji 写入直接报错 cursorclass: pymysql.cursors.DictCursor, # 查询结果返回字典方便转 JSON } def get_conn(): # 每次请求新建连接课程设计的数据量下足够可靠 return pymysql.connect(**DB_CONFIG) app.route(/api/health) def health(): return jsonify({status: ok, db: connected}) if __name__ __main__: # 答辩真机演示时把 host 改成 0.0.0.0并关闭 debug app.run(host127.0.0.1, port5000, debugTrue)几个参数值得记一下。charset 必须写 utf8mb4MySQL 里叫 utf8 的字符集是 3 字节旧编码存不了 4 字节的 emoji关于这一点第 5 章会展开。cursorclass 用 DictCursor每一行结果以字典返回省去手动拼 JSON 的麻烦。host 为 127.0.0.1 表示只允许本机访问课程设计阶段够用如果要做真机演示得改成 0.0.0.0 并处理好局域网访问。port 5000 是 Flask 默认端口注意别和本机其他服务冲突冲突时会报地址占用错误。2.3 数据库选型与初始参数一个建库 SQL 把字符集和排序规则定死数据库选型上SQLite 虽然零配置、单文件即可运行适合快速验证逻辑但数据库课程设计的评分点——表结构规范化、约束使用、事务一致性、复杂查询——在 SQLite 里做出来的说服力远不如 MySQL。评委默认你用的是 MySQL很多经典问题也都是围绕 MySQL 展开的。所以结论很简单课程设计用 MySQL理由不是它最流行而是它最符合这门课的期待。安装完 MySQL 后第一步是建库。这一步把所有问题都堵在最前面CREATE DATABASE IF NOT EXISTS campus_food DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;character set 和 collation 是一对。utf8mb4 是 4 字节 UTF-8 编码能存中文、emoji、生僻字utf8mb4_unicode_ci 是配套的排序规则表示按 Unicode 规则比较、大小写不敏感。如果建库时漏了 collationMySQL 会用默认值在某些系统上默认是历史遗留的 latin1后面建表时容易继承错误字符集。建库这一步定死字符集后面每张表都少一个隐患。建完库先想表再动手。校园外卖的最小业务闭环至少要六张表user用户、merchant商家、dish菜品、orders订单主表、order_item订单明细表cart购物车可以做成可选项。其中 orders 和 order_item 是必须拆开的因为一个订单包含多个菜品订单与菜品是多对多关系必须通过明细表表达。很多同学会把订单和菜品塞在同一张表里一行存「订单-菜A-菜B-菜C」这是典型的反范式设计答辩必被指出来。表不是越多越好能讲清楚每个表为什么存在、和谁关联比堆二十张表有用得多。到这里架构立住了小程序端管展示和交互Flask 管接口和业务规则MySQL 管存储和一致性。三个角色各司其职后面联调才有章法。记住一点答辩开场不用画多复杂的架构图能用一句话说清「谁请求谁、谁依赖谁」就足够拿到底分。3. 从零跑通最小联调小程序项目 Flask 接口 MySQL 的三层对接3.1 创建小程序项目页面路由与首页菜品列表的最小代码打开微信开发者工具新建项目AppID 选测试号即可——课程设计不需要上线测试号支持全部开发功能。新建后先不要急着写页面先在 app.json 里注册页面路由这是小程序启动时读取的第一份配置。{ pages: [ pages/index/index, pages/cart/cart, pages/orders/orders, pages/profile/profile ], window: { navigationBarTitleText: 校园外卖, navigationBarBackgroundColor: #2B6CB0, navigationBarTextStyle: white }, style: v2 }pages 数组第一项是启动页课程设计把首页放第一位。每个页面是一个目录目录下必须有四个同名文件.js、.wxml、.wxss、.json。navigationBar 三个字段控制顶部导航栏navigationBarTextStyle 只接受 white 或 black 两个值写别的不会生效。style: v2 是启用新版组件样式不加也能跑但加了整体风格更统一。然后是首页的菜品列表加载。用 wx.request 拉数据下面这个版本包含 loading 状态和错误处理比裸写请求更接近真实开发习惯// pages/index/index.js Page({ data: { dishes: [], loading: false, errorMsg: }, onLoad() { this.fetchDishes(); }, fetchDishes() { this.setData({ loading: true, errorMsg: }); wx.request({ url: http://127.0.0.1:5000/api/dishes, method: GET, timeout: 5000, // 5 秒超时避免后端没启动时一直转圈 success: (res) { if (res.statusCode 200 res.data.code 0) { this.setData({ dishes: res.data.data }); } else { this.setData({ errorMsg: 服务端返回异常 }); } }, fail: (err) { console.error(请求失败, err); this.setData({ errorMsg: 网络请求失败检查后端是否启动 }); }, complete: () { this.setData({ loading: false }); } }); } })注意几个细节。url 写 http://127.0.0.1:5000 在开发者工具里能跑但前提是勾选「详情 → 本地设置 → 不校验合法域名」否则请求会直接 fail。success 回调里先看 statusCode 再看业务 code这是前后端约定后端返回 {code: 0, data: ...} 表示成功code 非 0 表示业务失败。timeout 设 5 秒请求挂死有兜底。fail 分支必须给用户一个可理解的提示而不是静默失败——答辩演示时评委看到「网络请求失败检查后端是否启动」比看到一片空白要体面得多。对应的 wxml 渲染核心是 wx:for 列表循环view wx:for{{dishes}} wx:keyid classdish-card view classdish-name{{item.name}}/view view classdish-price¥{{item.price}}/view /viewwx:key 必须绑定每行数据的唯一字段这里用 id。如果写成 wx:key*this 或干脆省略 key数据更新时小程序会全量重渲染列表一长就会出现卡顿或者状态错乱。这个细节不复杂但是代码评审时一眼能看出你有没有经验。3.2 Flask 后端第一组接口菜品列表与下单事务后端第一个接口是菜品列表。菜品属于商家前端需要同时拿到商家名所以用 JOIN 一次查出避免前端再发第二次请求app.route(/api/dishes) def list_dishes(): conn get_conn() try: with conn.cursor() as cursor: cursor.execute( SELECT d.id, d.name, d.price, d.merchant_id, m.name AS merchant_name FROM dish d JOIN merchant m ON d.merchant_id m.id WHERE d.is_on_sale 1 ORDER BY d.merchant_id, d.id ) rows cursor.fetchall() return jsonify({code: 0, data: rows}) except Exception as e: return jsonify({code: 1, msg: str(e)}), 500 finally: conn.close()这段代码有四个要点。第一JOIN 比子查询更适合这种「主表带出关联字段」的场景语义清晰执行计划通常也更好。第二WHERE d.is_on_sale 1 是软删除的配套逻辑下架的菜直接不出现在列表里不用物理删除。第三ORDER BY d.merchant_id, d.id 保证同一商家的菜连续排列前端按 merchant_id 分组渲染时无需重新排序。第四finally 里 conn.close() 确保连接一定释放这个习惯能避开第 5 章要讲的连接耗尽问题。下单接口是整套系统里最该认真写的地方。它要往 orders 和 order_item 两张表写数据必须用事务保证原子性app.route(/api/orders, methods[POST]) def create_order(): data request.get_json() user_id data.get(user_id) merchant_id data.get(merchant_id) items data.get(items) # 格式: [{dish_id: 1, quantity: 2}] if not items or not isinstance(items, list): return jsonify({code: 1, msg: items 不能为空}), 400 conn get_conn() try: conn.begin() with conn.cursor() as cursor: # 1. 后端重新计算总金额前端传的 total 一律不信任 placeholders ,.join([%s] * len(items)) dish_ids [str(i[dish_id]) for i in items] cursor.execute( fSELECT id, price FROM dish WHERE id IN ({placeholders}), dish_ids ) price_map {row[id]: row[price] for row in cursor.fetchall()} # 2. 菜品不存在直接抛错触发回滚 total 0 for i in items: price price_map.get(i[dish_id]) if price is None: raise ValueError(f菜品 {i[dish_id]} 不存在) total price * i[quantity] # 3. 插入订单主表status 默认 0 表示待支付 cursor.execute( INSERT INTO orders (user_id, merchant_id, status, total_amount, address) VALUES (%s, %s, 0, %s, %s), (user_id, merchant_id, total, data.get(address, )) ) order_id cursor.lastrowid # 4. 插入订单明细表price 存下单时刻的单价快照 for i in items: cursor.execute( INSERT INTO order_item (order_id, dish_id, quantity, price) VALUES (%s, %s, %s, %s), (order_id, i[dish_id], i[quantity], price_map[i[dish_id]]) ) conn.commit() return jsonify({code: 0, data: {order_id: order_id, total_amount: total}}) except Exception as e: conn.rollback() return jsonify({code: 1, msg: 下单失败: str(e)}), 500 finally: conn.close()这段代码是答辩的核心素材要能逐行讲。第一f-string 只用于生成 IN 子句的占位符dish_ids 本身走参数化传递没有 SQL 注入风险如果直接把 dish_ids 拼进 SQL 字符串就有注入了这个区别要能说出来。第二金额在服务端重算前端传的 total 被忽略这是接口可信度的关键设计。第三order_item 里的 price 存的是下单那一刻的单价快照——以后菜品涨价历史订单金额不变这是订单系统的基本要求。第四conn.begin() 到 conn.commit() 之间任何异常都会触发 rollback保证不会出现有订单头没明细的脏数据。3.3 登录态与请求封装wx.login 换 openid 的完整链路小程序没有 cookie 机制登录态要靠 wx.login 生成的临时 code 去后端换。完整链路是小程序端 wx.login 拿到 code把 code 发给后端后端拿 code课程设计可 mock生成用户标识 openid查表或建用户再签发一个 token 返回小程序把 token 存本地后续所有请求 header 带上。先看小程序端的请求封装这是联调的地基// 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: method, data: data, header: { Content-Type: application/json, X-Token: wx.getStorageSync(token) }, success: (res) { if (res.data.code 0) { resolve(res.data.data); } else { reject(res.data.msg || 业务错误); } }, fail: (err) reject(err) }); }); } module.exports { request };Promise 封装的意义是让页面代码避免回调嵌套。header 里的 X-Token 从本地存储读取未登录时是空字符串。后端需要在校验逻辑里区分「未登录」和「token 失效」前者返回 401 让前端跳登录页后者提示重新登录。这里注意token 放在自定义 header 字段里字段名可以自己定但前后端必须一致我习惯用 X-Token。后端登录接口按课程设计标准可以简化处理import hashlib, uuid # 简化实现内存会话表key 是 tokenvalue 是 user_id TOKENS {} app.route(/api/login, methods[POST]) def login(): data request.get_json() code data.get(code) # 课程设计简化真实项目要拿 code 调微信接口换 openid # 这里用 code 的 md5 模拟保证同一个 code 生成同一个用户 openid mock_ hashlib.md5(code.encode(utf-8)).hexdigest() conn get_conn() try: with conn.cursor() as cursor: cursor.execute( INSERT INTO user (openid, nickname) VALUES (%s, 微信用户) ON DUPLICATE KEY UPDATE id LAST_INSERT_ID(id), (openid,) ) conn.commit() user_id cursor.lastrowid finally: conn.close() token uuid.uuid4().hex TOKENS[token] user_id return jsonify({code: 0, data: {token: token, user_id: user_id}})INSERT ... ON DUPLICATE KEY UPDATE 依赖 user 表的 openid 唯一索引一条 SQL 完成「不存在就插入存在就取回 id」。LAST_INSERT_ID(id) 这个写法保证 upsert 后能拿到已有行的 id而不是 0。TOKENS 用内存 dict 在课程设计里够用但要有意识重启丢会话、多实例不共享、token 不过期。答辩被问「生产环境怎么存」时回答「用一张 token 表存带 expires_at 字段定时清理过期记录」就够了。第 6 章会把这个改进落成代码。联调完成的标志不是「页面能打开」而是「走通一次完整下单」。如果这一步还没做到先别急着写购物车和其他页面。4. 数据库设计核心四张表、外键约束与订单状态机的实现4.1 建表 SQL 与字段类型金额用 DECIMAL、状态用 TINYINT 的理由数据库课程设计的评分重心就在这一章。表结构是否规范、约束是否合理、类型选得对不对答辩时老师一眼就能看出来。下面这套建表 SQL 覆盖了校园外卖的核心闭环CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, openid VARCHAR(64) NOT NULL UNIQUE, nickname VARCHAR(64) DEFAULT 微信用户, phone VARCHAR(20), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE merchant ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, address VARCHAR(255) NOT NULL, phone VARCHAR(20), is_open TINYINT(1) DEFAULT 1 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE dish ( id INT AUTO_INCREMENT PRIMARY KEY, merchant_id INT NOT NULL, name VARCHAR(100) NOT NULL, description VARCHAR(255), price DECIMAL(10, 2) NOT NULL, is_on_sale TINYINT(1) DEFAULT 1, FOREIGN KEY (merchant_id) REFERENCES merchant(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, merchant_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, total_amount DECIMAL(10, 2) NOT NULL, address VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME NULL, accepted_at DATETIME NULL, delivered_at DATETIME NULL, FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (merchant_id) REFERENCES merchant(id), INDEX idx_user (user_id), INDEX idx_merchant (merchant_id), INDEX idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, dish_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, price DECIMAL(10, 2) NOT NULL, FOREIGN KEY (order_id) REFERENCES orders(id), FOREIGN KEY (dish_id) REFERENCES dish(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;先把最容易被问到的三个选型理由讲透。第一金额用 DECIMAL(10,2) 而不用 FLOAT 或 DOUBLE。IEEE 754 浮点数在二进制里表达不了 0.19.9 加 19.9 算出来是 29.799999999999997。DECIMAL 是定点十进制精度精确到分是所有金额字段的唯一正确选择。第二状态用 TINYINT 而不用字符串。字符串可读性好但空间浪费、拼写易错、排序不直观TINYINT 存 0、1、2、3配合后端常量映射是主流实践。第三orders 表加了三个普通索引。idx_user 支撑「我的订单」查询idx_merchant 支撑商家对账idx_status 支撑状态筛选。不加索引数据量一上去就是全表扫描课程设计数据量小索引不会带来肉眼可见的加速但索引意识要体现在建表 SQL 里。orders 表里 paid_at、accepted_at、delivered_at 三个可空时间戳值得单独说明。它们记录订单状态流转的关键时间点配合 status 字段可以完整回溯一个订单的生命周期。答辩时主动说「每个状态变更都记录时间方便对账和排障」是很自然的加分句。order_item 里冗余了一个 price 字段存的是下单那一刻的单价快照而不是实时去查 dish 表——这是订单系统防止「历史订单金额被后续改价影响」的经典设计。表清单和职责可以用一句话总结user 管人merchant 管店dish 管菜orders 管单order_item 管单里的每一行菜。4.2 订单状态流转从待支付到已完成的事务代码订单状态机是整个系统里业务逻辑最密集的部分。用 0 到 3 四个状态0 待支付、1 待接单、2 配送中、3 已完成另外用 -1 表示已取消。合法迁移路径必须约束待支付可以取消或支付待接单可以被商家接单配送中只能变已完成已完成和已取消是终态不能再动。状态的更新要放在后端接口里做而不是前端随便改。每个状态变更接口都要做两件事校验当前状态是否允许迁移然后更新状态和时间戳。下面是接单接口的写法app.route(/api/orders/int:order_id/accept, methods[POST]) def accept_order(order_id): conn get_conn() try: with conn.cursor() as cursor: # 状态校验与更新合并到一条 SQL用 AND 条件做乐观锁 affected cursor.execute( UPDATE orders SET status 1, accepted_at NOW() WHERE id %s AND status 0, (order_id,) ) if affected 0: conn.rollback() return jsonify({code: 1, msg: 订单状态不允许接单}), 400 conn.commit() return jsonify({code: 0, msg: 接单成功}) except Exception as e: conn.rollback() return jsonify({code: 1, msg: str(e)}), 500 finally: conn.close()这段代码的精髓在 WHERE 条件里的 status 0。它把「检查状态」和「更新状态」合并成一条原子 SQL两个并发请求同时来的时候只有一个能影响 1 行另一个影响 0 行直接返回失败。这比「先 SELECT 再 UPDATE」安全得多后者在并发场景下会让两个请求都通过校验把状态更新两次。这种写法就是乐观锁一行条件解决的问题答辩时价值很高。支付、配送、完成三个接口结构一致先校验合法迁移再更新状态和时间戳最后返回最新状态。把这套逻辑想明白业务代码本身反而很薄。4.3 答辩高频 SQL分组聚合、连表查询与月销售趋势数据库课程设计答辩必问数据查询下面三条 SQL 覆盖了最常见的考察点-- 1. 按商家统计订单量和销售额 SELECT m.name, COUNT(o.id) AS order_count, SUM(o.total_amount) AS revenue FROM orders o JOIN merchant m ON o.merchant_id m.id WHERE o.status 1 AND o.status 3 GROUP BY m.id, m.name ORDER BY revenue DESC; -- 2. 某商家销量前五的菜品 SELECT d.name, SUM(oi.quantity) AS sold FROM order_item oi JOIN dish d ON oi.dish_id d.id JOIN orders o ON oi.order_id o.id WHERE d.merchant_id 1 AND o.status 3 GROUP BY d.id, d.name ORDER BY sold DESC LIMIT 5; -- 3. 月销售趋势 SELECT DATE_FORMAT(o.created_at, %Y-%m) AS month, SUM(o.total_amount) AS revenue, COUNT(o.id) AS order_count FROM orders o WHERE o.status 3 GROUP BY month ORDER BY month;第一条 SQL 的 WHERE 排除了待支付和已取消的订单避免把未完成订单计入营收。GROUP BY m.id, m.name 而不是只 GROUP BY m.name因为重名商家不是零概率按主键聚合才不会把两家店的数据合到一起。第二条涉及三表 JOIN是答辩时最能展示 SQL 功底的题销量必须经过订单主表过滤掉未完成订单因为 order_item 表本身没有订单状态字段。第三条用 DATE_FORMAT 把时间归到月份GROUP BY month 可以直接引用 SELECT 里的别名这是 MySQL 允许而部分数据库不允许的写法可以顺带一提。每条 SQL 都要能说出「为什么这样写、换一种写法有什么问题」。把这些推导过程讲出来比背十条 SQL 有用得多。如果你还想体现「查询优化意识」可以在第一条的 EXPLAIN 结果里指出它用了 index merge 或 ref 访问类型这在课程设计答辩里属于超纲加分。5. 课程设计避坑清单联调阶段最容易翻车的 5 个现场5.1 请求全部超时或报 url not in domain list现象小程序里点任何按钮请求都失败控制台报「url not in domain list」或「request: fail」后端明明已经启动浏览器访问接口也正常。原因微信小程序对请求地址有域名白名单校验。开发者工具默认开启校验http://127.0.0.1 不在白名单里。真机预览时校验更严格真机不认 localhost。解决如果只在电脑上调试打开「详情 → 本地设置 → 不校验合法域名」即可。如果答辩要用手机真机演示需要在电脑上把 Flask 监听地址改成 0.0.0.0手机和电脑连同一 Wi-Fi请求地址写「http://电脑局域网IP:5000」。注意手机访问不了 127.0.0.1那个地址在手机上是手机自己。另外Windows 防火墙可能拦截 5000 端口要在入站规则里放行否则手机依然连不上。这个坑几乎人人会踩提前把三件事做掉能省出半天时间。5.2 中文乱码与 emoji 报错 Incorrect string value现象接口返回的 JSON 里中文正常存进数据库变成 ??。更典型的是微信昵称带 emoji一写入数据库就报「Incorrect string value: \xF0\x9F...」。原因字符集不一致。代码、数据库连接、表三处字符集只要有一处不是 utf8mb4就可能出问题。MySQL 早期版本建库默认字符集可能是 latin1PyMySQL 连接如果不指定 charset用的也是服务端默认值。很多同学只改了表字符集漏了连接参数。解决三处统一。建库时 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ciPyMySQL 连接参数加 charsetutf8mb4已存在的表执行 ALTER TABLE user CONVERT TO CHARACTER SET utf8mb4。注意 CONVERT 不会修复已经存坏的 ?所以新项目最好从建库起就统一别等到答辩前夜再来改。判断根因的排查命令也顺手记一下SHOW CREATE TABLE 看表定义SELECT character_set_server 看服务端默认值两处都是 utf8mb4 才放心。5.3 订单金额多出 0.00000001现象两个菜价格 9.9 和 19.9前端算出 29.8后端算出来 29.799999999999997订单列表里显示一串小数。原因字段类型用了 FLOAT 或 DOUBLE。浮点数的二进制表示不精确任何编程语言都一样不是 Python 的锅。金额这种对精度敏感的数据用浮点类型就是埋雷。解决数据库层面用 DECIMAL(10,2)Python 计算层用 decimal.Decimal或者把金额转成「分」做整数运算最后再除以 100 输出。课程设计用 DECIMAL 就足够。答辩被问「精度问题怎么解决」时把「DECIMAL 整数分」两种方案说出来就是加分项尤其要主动提到前端 toFixed(2) 只是显示上的补救不是计算精度方案。这个坑的隐蔽性在于测试数据只有整数价格时根本看不出来一旦出现 9.9、19.9 这种一位小数问题立刻暴露。5.4 外键约束挡住删除操作现象想删除一个已经产生订单的商家执行 DELETE FROM merchant WHERE id 1 报错Cannot delete or update a parent row: a foreign key constraint fails。原因这不是 bug是外键在正常工作。dish 和 orders 都通过外键引用 merchant数据库检测到有子记录引用它拒绝破坏完整性。解决不要用 SET FOREIGN_KEY_CHECKS0 绕过那是掩耳盗铃答辩时会被问出破绽。正确做法是软删除给 merchant 表加 is_open 字段停业就 UPDATE merchant SET is_open 0菜品列表的查询条件带上这个标记。这样历史订单还能正常关联商家商家下架不会破坏引用。如果一定要物理删除按依赖顺序先删 order_item、orders、dish最后删 merchant但课程设计完全用不到物理删除。答辩时主动提一句「我没有物理删而是做了软删除保证历史订单可追溯」这是很加分的工程意识。5.5 并发下单把库存扣成负数现象库存只剩 1 份的菜品两个人几乎同时下单数据库里库存变成 -1。原因典型的并发竞争。传统写法先 SELECT stock 看够不够再 UPDATE stock stock - 1两个请求都读到库存 1都执行了扣减最后写成了负数。课程设计虽然数据量小不会真压测但评委很爱问这个概念。解决把检查与更新合并成一条带条件的 UPDATEUPDATE dish SET stock stock - 1 WHERE id ? AND stock 1然后检查 cursor.rowcount。如果受影响行数为 0说明库存不足事务回滚。这是乐观锁思路一行条件就解决了问题比 SELECT ... FOR UPDATE 写起来简单答辩时也更容易讲清楚为什么条件要写在 WHERE 里而不是在 Python 里先判断。这个写法同时适用于扣库存和状态流转和 4.2 节接单接口是同一个套路可以打包讲。6. 从能跑到能答辩验证清单与两个加分改进先给一份答辩前的验证清单照着走一遍能过滤掉九成低级问题。第一层是数据库层执行 SHOW CREATE TABLE orders确认外键、DECIMAL、utf8mb4 全部到位把第 4 章的三条统计 SQL 逐一跑通结果不能是空集合。第二层是接口层用 curl 或 Postman 按顺序调通登录、菜品列表、下单、查订单、接单五个接口每次请求都看返回的 code 字段。第三层是端到端在小程序里完整走「浏览菜品 → 加购物车 → 提交订单 → 商家接单 → 完成」这条链路中途不手动改数据库确认每个状态变更后的页面展示都正确。两个加分改进代码量都不大但答辩效果很明显。第一个是订单状态机自检 SQL-- 自检已完成订单必须存在 delivered_at待接单订单必须存在 accepted_at SELECT COUNT(*) AS dirty_rows FROM orders WHERE (status 3 AND delivered_at IS NULL) OR (status 1 AND accepted_at IS NULL);正常结果必须是 0 行。答辩时主动跑出这条 SQL 并解释「这是我自己设计的完整性自检」评委能直接感受到你理解数据一致性而不是只会写增删改查。第二个改进是把登录 token 从内存 dict 改成数据库表。建一张 token 表CREATE TABLE token ( id INT AUTO_INCREMENT PRIMARY KEY, token VARCHAR(64) NOT NULL UNIQUE, user_id INT NOT NULL, expires_at DATETIME NOT NULL, FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;登录时写入 token 和过期时间接口校验时查表并判断 NOW() expires_at。答辩时这么说内存方案重启即丢、多实例无法共享、token 没有过期机制表方案把三个问题全解决。这一句话就能把登录模块从「能跑」拉到「懂设计」。我自己的习惯是课程设计这类全栈项目动手前先花一个晚上把「状态机、金额类型、删除策略、字符集」这四个点定下来后面写代码基本不返工。第一次做类似项目时我就是先把页面全做完才回头补数据库设计结果表结构改了三轮代码扔了一半。后来学乖了先库后码先想清楚再动手。课程设计最后真正拉开差距的不是页面多炫而是那些「你多想了半步」的地方——状态能不能乱跳、金额会不会漂、删了商家订单还在不在、并发扣库存会不会负数。希望这些经验帮到你把你的课程设计做成答辩时敢挺直腰杆讲的作品。本文还有配套的精品资源点击获取