
简介基于Android的校园外卖跑腿系统毕业设计项目覆盖用户端、商家端、骑手端、管理员端四个角色模块实现从用户点餐、订单支付、商家接单、骑手派送到后台管理的完整业务闭环面向计算机相关专业毕业生及Android/Java课程设计学习者也可作为企业级外卖App的简化参考。资源包共4个文件主要包含两套RAR源码工程Android客户端与Java服务端、一份SQL数据库初始化脚本和一份TEXT运行说明文档整体大小约109.05MB使用Android Studio与Java环境即可导入运行。目前已有147人下载学习适合用于毕业设计答辩演示或课程项目冲刺。通过该资源读者能掌握系统分层架构、数据库表设计、订单状态流转、多角色权限控制等关键实现尤其是用户下单评价退款、商家菜品管理和退款处理、骑手接单导航、管理员信息维护等典型场景的代码写法对理解校园外卖业务落地非常有帮助。1. 校园外卖跑腿系统的骨架和适用场景校园外卖和普通外卖最大的区别在于配送距离短、单量峰值集中在饭点而大多数毕业设计外卖项目只做了“下单-接单”这一条线骑手端基本是个摆设。这个项目则是完整的四端结构学生端负责点餐、评价、退款和与商家聊天商家端管理菜品和订单状态骑手端完成接单与派送导航管理员做全局管控。客户端基于 Android Studio 开发服务端配 MySQL 数据库数据交互走 HTTP 接口。适合正在做 Android 毕业设计、课程设计或者想搞明白一个多角色业务系统如何从数据库设计一路落到客户端实现的开发者。我按拆项目的角度把权限模型、表结构、点餐链路、骑手派送和常见故障逐一展开。2. 系统架构与四角色权限模型拆解2.1 C/S 分层、接口协议与技术选型理由这个项目采用客户端/服务器C/S结构Android 客户端只负责界面展示和用户操作业务逻辑全部收在服务端。选这种结构而不是把数据放在本地 SQLite核心原因是订单状态会被用户、商家、骑手三个角色同时修改用户下单后商家要接单商家出餐后骑手要取货骑手送达后用户确认状态机必须由一个统一的后端来维护否则客户端各存一份数据根本对不上。服务端按 Controller/Service/DAO 三层来组织接口返回统一的 JSON 结构客户端拿到后根据 code 字段判断业务是否成功。以登录接口为例正常响应长这样{ code: 0, msg: ok, data: { userId: 1001, roleId: 1, nickname: 张三 } }这里的code0表示业务成功非 0 表示失败msg是给客户端弹 Toast 用的提示文本data里放具体业务数据。roleId是权限模型的核心字段1 代表用户、2 代表商家、3 代表骑手、4 代表管理员。接口返回 roleId 后客户端据此跳转到不同主界面同时这个值会随后续请求一起发往服务端用来做接口级权限校验。2.2 四端功能矩阵与模块边界各角色的功能边界和对应页面、接口前缀整理如下角色端上核心页面主要功能接口前缀用户商家列表、菜品列表、购物车、订单列表、聊天点餐、生成订单、评价、申请退款、与商家聊天/user/**商家菜品管理、订单管理、聊天菜品增删改、查看订单、同意退款、更改订单状态/business/**骑手抢单列表、我的配送注册登录、接单、派送导航/rider/**管理员人员管理、商家管理、菜品管理、订单查看管理用户/商家/菜品信息、查看订单/admin/**模块边界的关键在于用户端不能直接调/business/**的接口商家端也不能调/rider/**的接口。服务端收到请求后先取 header 或参数里的roleId做一次角色判断不是对应角色直接返回 403。这个校验虽然简单但能避免答辩时被问到“如果用户手动调商家接口怎么办”这类问题。2.3 会话保持与权限校验的简化方案课设阶段不建议直接上 Spring Security 或者完整的 JWT 方案原因很简单代码量大、概念多答辩时容易把自己绕进去。常见做法是登录成功后服务端生成一个 userId roleId 的会话标记客户端存进 SharedPreferences后续每个请求都带上userId和roleId服务端用过滤器统一校验。public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { String roleId req.getParameter(roleId); String uri ((HttpServletRequest) req).getRequestURI(); if (uri.startsWith(/admin/) !4.equals(roleId)) { resp.setContentType(application/json); resp.getWriter().write({\code\:403,\msg\:\无权限\,\data\:null}); return; } chain.doFilter(req, resp); }这里按 URI 前缀区分接口归属再拿 roleId 比对不匹配直接拦截。优点是把权限判断收敛到一个 Filter 里新增接口不用四处写权限代码缺点是没有做签名和防重放安全性不足以支撑生产环境但对于毕业设计这个体量已经是性价比最高的方案。3. MySQL 表结构设计与订单状态机3.1 用户、商家、菜品、订单的分表设计登录注册部分用一个 user 表撑起四个角色字段里加role区分身份而不是给商家、骑手各建一张单独的用户表。原因在于四类账号的认证信息完全一样——用户名、密码、手机号、注册时间拆分只会让登录逻辑多写好几处分支。订单部分拆成订单主表和订单明细表这是整库设计的核心。一张订单包含多个菜品订单主表存一次配送信息和总价订单明细表每一行是一个菜品CREATE TABLE orders ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, business_id BIGINT NOT NULL, rider_id BIGINT DEFAULT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, refund_status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL ); CREATE TABLE order_item ( item_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, dish_name VARCHAR(64) NOT NULL, dish_price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL );订单明细里冗余了dish_name和dish_price这是有意为之。菜品可以被商家改价或删除但订单一旦生成当时的成交价和菜品名必须冻结。如果下单时去关联查 dish 表商家后续改价会导致历史订单金额错误。这一点在答辩时主动讲出来是加分项。3.2 订单状态机谁在什么时机改状态订单状态是整个系统里最容易被问倒的地方。状态机拆成两条线主状态线处理配送流程退款状态单独用一个字段管理两条线互不干扰。状态码状态名触发角色触发动作0待支付用户下单1待派送商家接单确认2配送中骑手接单3已送达骑手确认送达4已完成用户确认收货/自动完成退款状态字段简单一点0 无退款申请1 申请中2 已同意3 已拒绝。每个状态迁移都限制操作角色比如状态 1 只能由骑手改成 2状态 3 只能由商家或用户操作。迁移操作的 SQL 必须加状态条件用乐观锁避免并发问题UPDATE orders SET status 2, rider_id ?, accept_time NOW() WHERE order_id ? AND status 1核心是WHERE status 1这个条件。骑手 A 和骑手 B 同时抢同一单两个人执行的 update 里 status 都是 1但数据库行锁保证只有一个 update 能命中。判断 Java 代码里executeUpdate()的返回值返回 1 表示抢单成功返回 0 表示订单已经被别人接走这时候客户端要弹出“手慢了”的提示。3.3 聊天消息表与未读消息统计用户和商家聊天是整个项目里比较容易被忽略的部分。消息表按订单维度隔离会话而不是全局单聊这样一笔订单一个会话窗口逻辑清晰CREATE TABLE chat_message ( msg_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, from_id BIGINT NOT NULL, to_id BIGINT NOT NULL, content VARCHAR(500) NOT NULL, create_time DATETIME NOT NULL, is_read TINYINT DEFAULT 0 );未读数统计也简单查一条聚合语句SELECT COUNT(*) FROM chat_message WHERE order_id ? AND to_id ? AND is_read 0is_read在客户端打开会话时统一置 1比逐条更新节省请求次数。聊天的拉取方式见第 4 章这里不展开。4. Android 客户端点餐下单链路与关键实现4.1 登录注册与角色路由跳转客户端登录请求用 OkHttp 发起这是目前 Android 网络请求的主流选择比原生 HttpURLConnection 省掉大量样板代码。登录拿到 roleId 后决定跳转到哪个主界面路由逻辑集中在一个方法里private void routeByRole(int roleId) { Class? targetActivity; switch (roleId) { case 1: targetActivity UserMainActivity.class; break; case 2: targetActivity BusinessMainActivity.class; break; case 3: targetActivity RiderMainActivity.class; break; case 4: targetActivity AdminMainActivity.class; break; default: Toast.makeText(this, 未知角色, Toast.LENGTH_SHORT).show(); return; } startActivity(new Intent(this, targetActivity)); finish(); }每次启动 App 时先从 SharedPreferences 里读上次登录的 userId 和 roleId存在就直接走 routeByRole避免重复登录。退出登录时同时清掉 SharedPreferences 里的会话信息否则会出现“点退出再进还是登录态”的怪问题。4.2 菜品列表与购物车的内存态设计购物车用 Activity 级别的 HashMap 维护key 是菜品对象value 是数量private final HashMapDish, Integer cart new HashMap(); public void addDish(Dish dish) { cart.put(dish, cart.getOrDefault(dish, 0) 1); } public void removeDish(Dish dish) { int count cart.getOrDefault(dish, 0); if (count 1) { cart.put(dish, count - 1); } else { cart.remove(dish); } }getOrDefault的作用是当菜品第一次加入购物车时默认数量取 0 再加 1省掉一次 containsKey 判断。购物车不落库是刻意为之的简化课设演示场景下用户不会关掉 App 再回来继续点单内存态购物车的实现和维护成本都最低。生产环境必须换成购物车表因为要支持跨设备同步和营销活动。4.3 订单提交的服务端事务边界购物车提交订单时服务端要同时写订单主表和订单明细表明细表的外键是主表的 order_id。两步插入必须在同一个事务里否则会出现“订单主表存在、明细为空”的数据脏状态。JDBC 事务的标准写法Connection conn getConnection(); try { conn.setAutoCommit(false); PreparedStatement ps1 conn.prepareStatement( INSERT INTO orders(order_no, user_id, business_id, total_price, status) VALUES(?,?,?,?,?), Statement.RETURN_GENERATED_KEYS); // 填充参数后执行从 ResultSet 中取出自增 orderId PreparedStatement ps2 conn.prepareStatement( INSERT INTO order_item(order_id, dish_id, dish_name, dish_price, quantity) VALUES(?,?,?,?,?)); for (CartItem item : cartList) { ps2.setLong(1, orderId); // 逐条填充菜品参数 ps2.addBatch(); } ps2.executeBatch(); conn.commit(); } catch (SQLException e) { conn.rollback(); throw new RuntimeException(下单失败); }setAutoCommit(false)之后所有的 SQL 都不会立即生效必须显式 commit 才落盘。RETURN_GENERATED_KEYS用来在插入主表后立刻拿到自增 order_id这是明细表的外键来源。循环里用addBatch批量提交明细避免一条条 executeUpdate 造成无谓的网络往返。任何一步抛异常就整体 rollback购物车里的数据重新请求菜品列表恢复。4.4 用户与商家聊天的轻量轮询方案聊天功能不引入 WebSocket 或第三方即时通讯 SDK用轮询实现。客户端开一个 Handler每 3 秒拉一次该订单下的新消息private final Handler handler new Handler(Looper.getMainLooper()); private final Runnable pollTask new Runnable() { Override public void run() { fetchNewMessages(); handler.postDelayed(this, 3000); } }; Override protected void onResume() { super.onResume(); handler.postDelayed(pollTask, 0); } Override protected void onPause() { super.onPause(); handler.removeCallbacks(pollTask); }onResume里启动轮询、onPause里移除回调保证页面退到后台后不再发无效请求省电也省流量。3 秒间隔在校园网环境下体感接近实时而且服务端实现只需要一个查询接口成本极低。真实商用的聊天系统必须换 WebSocket 或 MQTT原因不是轮询功能不行而是高并发下轮询对服务端压力太大但课设场景里这个取舍是合理的。5. 骑手接单、定位与派送导航的落地5.1 抢单列表与并发控制骑手端的抢单列表加载所有status1的订单列表项显示商家位置、用户位置和配送费。抢单动作就是第 3 章那条带状态条件的 updateint rows ps.executeUpdate(); if (rows 1) { // 抢单成功跳转导航页面 } else { Toast.makeText(this, 订单已被抢走, Toast.LENGTH_SHORT).show(); refreshList(); }executeUpdate返回 1 时说明当前骑手拿到了这单返回 0 说明订单状态已经被其他骑手改成 2此时必须刷新列表。这里有个细节抢单成功后客户端本地要立即把该订单从列表中移除或置灰而不是等服务端返回再刷新界面上反馈更快也不会出现两个人同时点同一单才暴露冲突的情况。5.2 经纬度采集与定位参数选择骑手确认接单后需要导航到商家再到用户地址。定位用系统 LocationManager 即可满足演示要求LocationManager lm (LocationManager) getSystemService(LOCATION_SERVICE); if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED) { return; } lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 2000, 10, locationListener);requestLocationUpdates三个参数的含义第一个是定位提供者GPS_PROVIDER 精度高但室内容易断可以考虑用 NETWORK_PROVIDER 兜底第二个2000是最小时间间隔单位毫秒即每 2 秒回调一次第三个10是最小距离间隔单位米即位移超过 10 米才触发回调。演示时如果所在环境 GPS 信号差可以在 Android Studio 的 Device Explorer 里用 Extended Controls 的 Location 面板模拟一个坐标或者用 adb 命令直接注入adb emu geo fix 116.397 39.908geo fix后面跟的是经度和纬度执行后模拟器会立刻回调一次定位。这个命令在联调阶段非常有用不需要跑到室外就能完整演示“骑手在途中”的效果。5.3 调起第三方地图完成派送导航不做应用内地图渲染直接调起高德或百度地图。以高德为例构造导航 IntentIntent intent new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse( androidamap://navi?sourceApplicationdingcanlat39.908lon116.397dev0)); intent.setPackage(com.automavi.minimap); startActivity(intent);lat和lon是目的地经纬度dev0表示导航到普通坐标点而非 POI 点sourceApplication只是标识来源。setPackage(com.automavi.minimap)指定高德包名百度地图对应的是com.baidu.BaiduMap协议换成baidumap://map/direction。调起前要判断目标地图是否安装PackageManager pm getPackageManager(); if (intent.resolveActivity(pm) null) { Uri uri Uri.parse(https://uri.amap.com/navigation); startActivity(new Intent(Intent.ACTION_VIEW, uri)); }resolveActivity返回 null 说明该包名不存在此时用网页版地图。骑手送达后点击“确认送达”客户端把订单号和骑手 ID 发给服务端执行UPDATE orders SET status 3 WHERE order_id ?收尾。6. 导入运行配置与三个高频故障排查6.1 Android Studio 工程导入与 SDK 匹配代码包解压后用 Android Studio 打开 DingcanClient 目录等待 Gradle 同步完成。最多发的报错是 SDK 路径对不上症状是SDK location not found。解决办法在工程根目录确认local.properties文件存在并写入本机 SDK 路径sdk.dirC\:\\Users\\你的用户名\\AppData\\Local\\Android\\Sdk注意 Windows 路径里反斜杠要转义成\\。如果工程里配置的 compileSdk 版本比本地安装的高会提示下载或降低版本这时看 build.gradle 里的compileSdk改成已安装的版本即可兼容性影响不大。6.2 Android 9 明文流量与真机联调服务端地址如果是http://192.168.1.10:8080这种局域网地址Android 9API 28及以上默认禁止明文 HTTP 请求表现为请求直接报CLEARTEXT communication not permitted。临时解法是在 AndroidManifest.xml 的 application 标签上打开开关application android:usesCleartextTraffictrue android:labelstring/app_name正式做法是配置 networkSecurityConfig 只对调试域名放行但课设项目直接用上面的开关最快。模拟器里访问宿主机服务端用http://10.0.2.2:8080真机则必须用电脑的局域网 IP。手机和电脑连同一个 WiFi 后在电脑上跑ipconfig查 IPv4 地址替换即可。6.3 中文乱码、订单状态不同步的排查顺序故障现象先查哪里常见原因菜品/订单中文变问号服务端请求编码 Filter未设置 UTF-8 统一编码数据库表中文乱码JDBC 连接 URL缺少 characterEncodingutf8用户下单商家看不到服务端控制台日志客户端请求没到达IP 配错商家改单状态用户端不变操作后是否刷新数据源页面切回来没有重新请求接口数据库连接串统一加编码参数jdbc:mysql://localhost:3306/dingcan?characterEncodingutf8useSSLfalse不加characterEncodingutf8时Java 默认按平台编码写入Linux 服务器上经常出现中文乱码。接口地址配置单独放在一个类里便于换网络环境时统一改public class ApiConfig { public static final String BASE_URL http://10.0.2.2:8080/dingcan/; }BASE_URL分为模拟器和真机两套做一次切换改动最小。定位、购物车图标、订单状态刷新这类功能验证时不看别人先看自己这一步的请求有没有发出、响应体打印的是什么。把服务端响应 JSON 打到 Logcat 里比对数据库实际值绝大多数问题一眼就能定位。本文还有配套的精品资源点击获取