简介:基于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": "张三" } }这里的code=0表示业务成功,非 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 HashMap<Dish, 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 抢单列表与并发控制
骑手端的抢单列表加载所有status=1的订单,列表项显示商家位置、用户位置和配送费。抢单动作就是第 3 章那条带状态条件的 update:
int 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 调起第三方地图完成派送导航
不做应用内地图渲染,直接调起高德或百度地图。以高德为例,构造导航 Intent:
Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse( "androidamap://navi?sourceApplication=dingcan&lat=39.908&lon=116.397&dev=0")); intent.setPackage("com.automavi.minimap"); startActivity(intent);lat和lon是目的地经纬度,dev=0表示导航到普通坐标点而非 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.dir=C\:\\Users\\你的用户名\\AppData\\Local\\Android\\Sdk注意 Windows 路径里反斜杠要转义成\\。如果工程里配置的 compileSdk 版本比本地安装的高,会提示下载或降低版本,这时看 build.gradle 里的compileSdk,改成已安装的版本即可,兼容性影响不大。
6.2 Android 9+ 明文流量与真机联调
服务端地址如果是http://192.168.1.10:8080这种局域网地址,Android 9(API 28)及以上默认禁止明文 HTTP 请求,表现为请求直接报CLEARTEXT communication not permitted。临时解法是在 AndroidManifest.xml 的 application 标签上打开开关:
<application android:usesCleartextTraffic="true" android:label="@string/app_name">正式做法是配置 networkSecurityConfig 只对调试域名放行,但课设项目直接用上面的开关最快。模拟器里访问宿主机服务端用http://10.0.2.2:8080,真机则必须用电脑的局域网 IP。手机和电脑连同一个 WiFi 后,在电脑上跑ipconfig查 IPv4 地址替换即可。
6.3 中文乱码、订单状态不同步的排查顺序
| 故障现象 | 先查哪里 | 常见原因 |
|---|---|---|
| 菜品/订单中文变问号 | 服务端请求编码 Filter | 未设置 UTF-8 统一编码 |
| 数据库表中文乱码 | JDBC 连接 URL | 缺少 characterEncoding=utf8 |
| 用户下单商家看不到 | 服务端控制台日志 | 客户端请求没到达,IP 配错 |
| 商家改单状态用户端不变 | 操作后是否刷新数据源 | 页面切回来没有重新请求接口 |
数据库连接串统一加编码参数:
jdbc:mysql://localhost:3306/dingcan?characterEncoding=utf8&useSSL=false不加characterEncoding=utf8时,Java 默认按平台编码写入,Linux 服务器上经常出现中文乱码。接口地址配置单独放在一个类里,便于换网络环境时统一改:
public class ApiConfig { public static final String BASE_URL = "http://10.0.2.2:8080/dingcan/"; }BASE_URL分为模拟器和真机两套,做一次切换改动最小。定位、购物车图标、订单状态刷新这类功能,验证时不看别人,先看自己这一步的请求有没有发出、响应体打印的是什么。把服务端响应 JSON 打到 Logcat 里,比对数据库实际值,绝大多数问题一眼就能定位。
本文还有配套的精品资源,点击获取