ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

校园跑腿代取系统开发实战:Spring Boot + Vue3 + Android 全栈解析

校园跑腿代取系统开发实战:Spring Boot + Vue3 + Android 全栈解析

校园跑腿代取系统这类题目,我在不少课程设计和毕业设计的选题列表里都见过。表面看它就是“用户发单、跑腿员接单、完事给钱”的小平台,可一旦把订单生命周期、实时定位、抢单并发、多端状态同步全部串起来,项目立刻就不是一个简单 CRUD 能撑住的东西了。下面我就用 Spring Boot + Vue3 管理后台 + Android 客户端这个组合,复盘一套能落地实现的校园跑腿代取系统,包括我实际开发时踩过的坑、反复改过的设计,以及一些可以直接抄走的代码思路。适合正在做类似课题的学生,也适合想从零搭一个校园服务小产品的开发者参考。

1. 明确定位:这个系统不是“发单接单”四个字这么简单

1.1 角色与核心用例

校园跑腿代取的业务核心,是让没有时间或不想出宿舍的同学,把“代取快递”“食堂带饭”“代买小零食”“代送文件”这类任务外包给有空闲时间的同学。系统里至少有三类角色:下单用户、跑腿员、管理员。

下单用户的核心诉求是“快速发单,知道谁接单,东西什么时候到”。所以前端必须支持选择任务类型、填写取件地址和送达地址、设置跑腿费、查看订单状态变化。跑腿员的核心诉求是“附近有没有单、单值不值钱、怎么联系取件人”。所以客户端需要定位、抢单、查看订单详情、打电话联系对方。管理员的核心诉求则是“每天多少订单、有没有纠纷、价格设置是否合理”,这部分我建议放到独立的管理后台而不是做成移动端页面,否则手机屏幕上密密麻麻的表格根本没法用。

很多第一次做这个项目的人,上来就列用户表、订单表、任务表,把“下单”和“接单”做出来就以为结束了。实际上还差一个关键角色链条:谁创建订单、订单如何流转到跑腿员手里、跑腿员取件后状态如何通知到用户,以及订单异常时管理员怎么介入。这几个环节只要有一个没想清楚,后面做到 Android 端状态刷新时就会非常痛苦。

1.2 被很多人忽略的“非功能需求”

除了功能列表,这类系统还需要提前考虑几件事。

第一是定位。校园范围看起来不大,但快递点、食堂、教学楼之间的步行距离通常有三五百米。跑腿费要按距离算,不能是固定值。系统要拿到下单人的位置、取件点的位置和送达点的位置,用坐标算距离,再套一个计费规则。

第二是订单超时。没人接的单不能一直挂着,状态机里必须有“超时自动取消”或者“超时提醒”机制。实现方式可以用定时任务扫描,也可以用延迟消息处理,但至少先得有这个规则。

第三是取消策略。用户下单后想取消,跑腿员接单后能不能取消?接单后用户单方面取消对跑腿员公不公平?这些不是写代码能自己定义的,需要提前定业务规则。我见过不少项目把取消按钮放在任何状态都可用,结果状态流直接乱掉。

第四是数据统计。管理员会想知道每天下单数量、完单率、平均跑腿费。假如数据库表结构一开始没有设计好冗余字段,后面想统计还得回填数据,很麻烦。

把这些非功能需求先记下来,再去设计模块,后面的技术选型才有依据。

2. 从三端选型说起:框架组合为什么这么搭配

2.1 后端选 Spring Boot 的原因

Spring Boot 在这个项目里扮演的是唯一服务端,既要给 Android 提供 HTTP API,也要给 Vue3 管理后台提供管理接口。选它首先是因为生态成熟,Starter 包几乎覆盖了校园项目会用到的所有基础能力:鉴权、参数校验、数据库访问、定时任务、消息推送的辅助依赖。遇到问题时网上能查到的案例密度远超其他框架,对项目周期短的同学来说这是实打实的优势。

第二个原因是 Spring Boot 的工程结构对“单体应用”非常友好。校园跑腿系统初期用户量不会很大,没必要一上来就拆微服务。把 Controller、Service、Mapper 分清楚,按模块划包,足够支撑几百上千人同时使用。如果直接上微服务,反而会陷入服务发现、配置中心、分布式事务的泥潭。不是技术越复杂越厉害,而是要在合适规模下解决问题。

第三个原因是 Java 生态里做安全控制比较顺手。使用 Spring Security 或者轻量拦截器加 JWT,都可以把登录态和角色权限管起来。移动端没有 Cookie 概念,接口要采用 Token 方式认证,Spring Boot 做这个事情非常自然。

选型其实是最不需要纠结的地方。纠结的是项目里的包结构怎么划分、接口响应格式怎么统一、状态码怎么定义。我推荐把统一返回结构放在最基础的 commons 包里,例如 Result ,里面包含 code、message、data 三个字段,所有接口不管成功失败都返回这个结构。这样 Android 端的 Retrofit 和 Vue3 管理后台的 Axios 都可以做同一套拦截逻辑。

2.2 管理后台选 Vue3 的原因

管理后台选择 Vue3,很大程度上是因为它和 Spring Boot 的组合是目前资料最多、上手成本最低的方案之一。Vue3 的 Composition API 让页面状态管理比 Vue2 时代清晰很多,一个订单列表页需要维护查询参数、分页信息、表格数据、加载状态,如果用 Options API 写,逻辑会散落在 data、methods、watch 里;用 setup 写,所有东西都在一个作用域里,思路更接近“一个页面就是一个状态机”。

另外 Vue3 配合 Element Plus,后台表格、表单、弹窗、分页一套组件就能出来,不需要自己手写样式。管理后台的核心是“效率”,不是“炫酷”,组件库成熟能省下大量时间。

也要说明一下,Vue3 只是前端框架,它本身不能替你做权限控制。路由守卫、按钮权限这些都要自己写。管理员账号和普通用户账号不能访问同一个菜单,这个逻辑必须前后端同时做,前端控制入口,后端控制接口,两层都压住才算安全。

2.3 Android 侧选型与后端接口契约

Android 端我建议使用 Kotlin 加 Retrofit,配合 ViewModel 和 LiveData 或 Flow 做状态管理。Kotlin 的协程可以用同步代码风格写异步请求,对项目维护非常友好。Retrofit 负责把后端接口声明成函数,配合 Gson 或 Moshi 做 JSON 序列化,几乎是当前最稳的组合。

三端项目的关键问题之一,是前后端接口契约必须尽早固定。移动端发布后不能像 Web 页面那样即时热更新,如果后端接口字段名字写错了、类型变了,版本迭代就是事故。我习惯在项目初始化阶段先写一份接口文档或 YAML,把订单创建、订单详情、接单、状态变更这些核心接口的请求响应定下来。能减少很多后面联调时的无线沟通成本。

下表是我在这个项目里常用的技术栈组合,供参考:

端技术组合承担职责
服务端Spring Boot、MySQL、Redis(可选)、JWT业务接口、订单状态管理、鉴权、数据存储
管理端Vue3、Element Plus、Axios、Vite订单监控、用户管理、数据统计
Android 端Kotlin、Retrofit、Coroutines、ViewModel发单、抢单、订单状态查看、位置上报

3. 后端核心实现:订单状态机、抢单与超时处理

3.1 订单表设计:先把字段粒度定好

订单表是整个系统的核心表,设计时要把字段粒度一次定好,后面改动成本很高。我这里给出一份简化过的表结构:

CREATE TABLE order_info ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '订单编号', user_id BIGINT NOT NULL COMMENT '下单用户', taker_id BIGINT DEFAULT NULL COMMENT '接单跑腿员', type TINYINT NOT NULL COMMENT '1-代取快递 2-代买 3-代送', pickup_address VARCHAR(255) NOT NULL COMMENT '取件地址', delivery_address VARCHAR(255) NOT NULL COMMENT '送达地址', pickup_lat DECIMAL(10,6) DEFAULT NULL, pickup_lng DECIMAL(10,6) DEFAULT NULL, delivery_lat DECIMAL(10,6) DEFAULT NULL, delivery_lng DECIMAL(10,6) DEFAULT NULL, reward_fee DECIMAL(8,2) DEFAULT 0 COMMENT '跑腿费', status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态', remark VARCHAR(500) DEFAULT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, KEY idx_status_create (status, create_time), KEY idx_user (user_id), KEY idx_taker (taker_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跑腿订单表';

注意几个容易被忽略的细节。订单号不要用自增 id 直接展示给用户,因为那样会暴露订单量。可以用时间戳加随机数生成。经纬度字段要保留坐标,不能只存文字地址,否则跑腿员无法根据距离排序。type 字段用 TINYINT 存数字,含义在代码枚举里定义,不直接存中文,避免改字面量时数据库迁移。status 字段同理。

除了订单表,还需要用户表、跑腿员认证资料表、订单评价表、系统配置表。系统配置表用来存跑腿费计算规则,例如基础价、每公里加价、不同类型的加权系数,不要把这些值硬编码在代码里。

3.2 状态机:状态变更的合法路径

订单状态是这类系统最需要维护的东西。我整理了一套简化状态:

待接单(PENDING) -> 已接单(ACCEPTED) -> 已取件(PICKED) -> 已完成(COMPLETED) 待接单(PENDING) -> 已取消(CANCELLED) 已接单(ACCEPTED) -> 已取消(CANCELLED)

在代码里用枚举表达会清晰很多:

public enum OrderStatus { PENDING(0, "待接单"), ACCEPTED(1, "已接单"), PICKED(2, "已取件"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }

状态机最重要的不是枚举定义,而是状态迁移的校验。我自己的做法是写一个状态流转校验工具类,里面放一张“合法迁移表”,每次更新状态前对比当前状态和目标状态。比如已取件状态不能直接跳回已接单,已完成状态不能再取消。这样能防止并发请求把状态改乱,也方便以后扩展新状态。

这里也直接决定了 API 设计:接口层面不应该提供一个“万能的状态更新接口”,而应该拆成“用户取消订单”“跑腿员接单”“跑腿员标记取件”“跑腿员标记送达”这种语义明确的接口。每个接口内部只完成一个特定迁移,可读性和安全性都会好很多。

3.3 并发抢单与超时自动取消的实现细节

校园跑腿系统里最容易“翻车”的场景是多人同时抢同一个单。如果把接单实现成先查询订单状态再看是否可接,最后更新订单状态,并发情况下很容易出现“多个人都看到待接单,然后都被更新成功”的问题。这不是线程安全问题,而是典型的并发更新问题。

我采用的方案是用一条带条件的 UPDATE 语句直接把状态改掉:

UPDATE order_info SET status = 1, taker_id = #{takerId}, accept_time = NOW() WHERE id = #{orderId} AND status = 0

这条语句会返回受影响行数。如果返回值是 1,说明当前用户抢单成功;如果是 0,说明订单已经被别人抢走或者状态已经变化。配合事务和唯一约束,可以保证同一个订单只能被一个跑腿员接单。

@Transactional public boolean grabOrder(Long orderId, Long takerId) { int updated = orderMapper.grabOrder(orderId, takerId); return updated == 1; }

超时取消则是另一个容易被忽略的点。比较简单的方案是定时任务每分钟扫描一次待接单且超过 15 分钟的订单,把状态置为已取消。这个方案在订单量不大时完全够用。如果量大,可以用延迟队列,但校园项目通常不需要做到这个程度。

需要注意的是,超时取消不能影响正在操作的用户。比如用户已经在点击取消,同时定时任务也在取消,要用 update 条件限制状态,避免重复取消导致的数据不一致。

4. Android 客户端的定位获取与订单状态同步

4.1 定位模块:精度、省电、权限缺一不可

Android 端第一个绕不开的问题是定位。校园场景下,用户下单要获取当前定位作为送达地址附近的坐标,跑腿员抢单列表要按距离排序,所以定位精度和获取频率必须提前想好。

定位方式上,Android 系统一般建议采用 Google Play 服务里的 FusedLocationProvider,但国内环境很多设备没有相关服务,所以实际项目里我更常使用系统 LocationManager 或者接入高德、百度这类第三方定位 SDK。如果用第三方 SDK,需要申请 Key,并且要处理隐私政策说明,这部分工作要留出时间。

权限申请要一次性把定位权限和通知权限说清楚。定位权限分前台和后台,校园跑腿场景建议只申请前台定位权限,因为应用退到后台后继续定位既耗电又容易触碰隐私合规问题。通知权限用于订单状态变化提醒,在 Android 8.0 以后必须显式创建通知渠道,否则通知弹不出来。我第一次做的时候忘记创建 NotificationChannel,测试机上怎么调都收不到推送消息,后来查资料才想起来是这个原因。

定位更新不用一直高频刷新。下单时取一次当前位置即可,跑腿员在地图页看附近订单时可以 30 秒拉一次。全时高频率定位会让手机发烫,续航明显下降,用户会对应用印象很差。

4.2 状态同步方案:轮询、长连接与消息推送怎么选

订单状态同步是移动端体验的核心。用户下单后想知道有没有被接单,跑腿员接单后想看到用户坐标更新。一般来说有三种方案。

第一种是轮询。Android 端定期调用订单详情接口,比如每 5 秒或 10 秒请求一次,然后更新界面。实现简单,后端只需要提供普通查询接口。缺点是会有延迟,而且订单量大了以后后端查询压力偏高,但校园项目用户量不大,完全可接受。

第二种是长连接。用 WebSocket 保持服务端和客户端之间的双向通信,服务端订单状态变化时主动推送。这个方案实时性好,但客户端要自己处理断线重连、心跳保活,后端还要维护连接会话和消息丢失补偿,复杂度高不少。

第三种是接入第三方推送。集成厂商推送服务,服务端在状态变化时发送通知,点击通知再进入应用拉取详情。实时性和成本均衡,但会多一个 SDK 依赖。

我的建议是:项目初期用轮询,把状态刷新做得稳一点,把体验优化放在请求频率和界面刷新上。如果后面确实需要实时性,再引入长连接。先把核心业务流程跑通,比一开始就追求实时推送更实际。

4.3 一个最小可跑的 Kotlin 请求链路

Android 端和 Spring Boot 对接的核心,是用 Retrofit 定义接口。假设后端有订单详情和抢单两个接口:

interface OrderApi { @GET("order/{id}") suspend fun getOrder(@Path("id") id: Long): Result<OrderDTO> @POST("order/{id}/grab") suspend fun grabOrder(@Path("id") id: Long): Result<OrderDTO> }

这里 Result 是后端统一返回结构的映射。网络请求通过 Retrofit 调用,ViewModel 里用一个 loadOrder 方法触发请求并更新 State:

class OrderDetailViewModel(private val api: OrderApi) : ViewModel() { private val _orderState = MutableStateFlow<OrderUiState>(OrderUiState.Loading) val orderState: StateFlow<OrderUiState> = _orderState fun refresh(orderId: Long) { viewModelScope.launch { try { val response = api.getOrder(orderId) _orderState.value = OrderUiState.Success(response.data) } catch (e: HttpException) { _orderState.value = OrderUiState.Error(e.code()) } catch (e: IOException) { _orderState.value = OrderUiState.Error(-1) } } } }

轮询可以用一个简单的 while 循环加 delay 来做,在界面销毁时通过 viewModelScope 的取消机制停止。注意轮询请求要加异常捕获,否则网络抖动一次就退出循环了。

5. Vue3 管理后台:从订单列表到权限路由的落地要点

5.1 管理端页面结构与状态设计

管理后台我划分为四个页面:订单列表、用户管理、跑腿员审核、数据统计。订单列表是核心页面,运营同学一天大部分时间都在盯着它看,所以要把搜索状态和分页状态设计得足够顺手。

Vue3 里我倾向于用 Composition API 把列表页的状态集中起来。一个典型的查询状态可能包含这些:关键词、订单状态、时间范围、当前页码、每页大小。这些字段绑到搜索表单和分页组件上,任何一个条件变化都触发重新加载。

页面结构不要做太复杂。左侧是菜单,顶部是管理员信息和退出登录,中间内容区放表格和筛选条件。后台的布局最好保持稳定,不要让用户到处找操作按钮。

5.2 列表页的搜索、分页与防抖实现

搜索功能不能只做前端过滤,因为订单量大了以后前端拿全量数据不现实。正确做法是后端接口支持条件查询,前端把筛选参数通过请求传给服务器,服务器返回分页结果。前端只需要维护好参数对象。

一个简化版的 Vue3 列表实现大概是这样的:

const query = reactive({ keyword: '', status: undefined as number | undefined, page: 1, size: 10 }) const tableData = ref<OrderInfo[]>([]) const total = ref(0) const loading = ref(false) async function loadOrders() { loading.value = true try { const res = await getOrders(query) tableData.value = res.data.records total.value = res.data.total } finally { loading.value = false } } function handleSearch() { query.page = 1 loadOrders() }

关键词搜索最好加防抖,避免用户每敲一个字符就触发一次接口请求。比较简单的方式是用 watch 加定时器:输入停止 500 毫秒以后再触发搜索。如果你用 Element Plus,也可以直接给搜索框加 clearable 属性,方便用户一键清空条件。

5.3 权限路由与按钮级控制的简化方案

管理后台权限分成两个级别:菜单路由级和操作按钮级。路由级权限用 Vue Router 的 beforeEach 守卫实现,登录后根据角色过滤可访问的路由表。按钮级权限通常会封装一个自定义指令,比如 v-permission,元素不满足权限条件时直接移除。

严格来说,前端权限只是用户体验层面。后端接口必须做同样的角色校验,否则用户绕过前端直接调接口一样能访问数据。这两个都要做,但后端才是底线。登录后 Token 里带上用户角色,后端拦截器在每次请求时解析出来,再判断接口权限。

权限设计不需要一开始就做得很重。用一个 role 字段区分普通用户和管理员,骑手权限合并到普通用户里,先保证“管理员才能看到后台、用户不能调用管理接口”就够。等系统跑起来了,再细化角色权限也不迟。

6. 上线前后绕不开的部署与安全问题

6.1 用 Nginx 把前后端接到同一个域名的部署方式

部署时最常见的问题,是 Android 端和管理后台分别访问不同端口,导致跨域和配置混乱。我的建议是用 Nginx 做反向代理,把三个端统一到同一个域名下:API 路径转发到 Spring Boot,前端静态文件直接由 Nginx 托管。

一个简化版的 Nginx 配置:

server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } location /admin/ { alias /var/www/admin/dist/; try_files $uri $uri/ /admin/index.html; } }

这样 Android 端只需要配置一个 baseUrl,实际请求地址就是 https://your-domain.com/api/...,不存在跨域问题。管理后台访问 https://your-domain.com/admin/ 加载静态资源,API 也走同一个域名。部署简单,后期维护也方便。

打包流程中,要注意前端项目的 API 环境变量。开发环境指向本地后端,生产环境指向线上域名。很多同学项目本地能跑,部署到服务器上白屏,绝大多数情况就是 API 地址没有切换。Android 端同样,不要把本地调试地址写死在代码里,应该通过 BuildConfig 区分 Debug 和 Release 环境。

6.2 安全自查清单

这种校园系统最容易暴雷的其实是安全问题,因为学生信息和位置数据都有隐私属性。我的自查清单固定包含以下几条:

后端参数校验必须做。所有接收前端参数的接口都要做非空、长度、类型校验,防止恶意绕过前端直接构造请求。Spring Boot 里可以用 validation 注解简化,例如 @NotBlank、@Size,一行的注解比在 Service 里写一堆 if 更可靠。

密码存储一律用 BCrypt,不能存明文也不能自己发明加密算法。手机号在数据库可以正常保存,但接口返回给前端时要做脱敏处理,比如只显示前三位和后四位。用户定位坐标不能随便展示给所有客户端,普通用户查订单详情时,只能看到跑腿员的脱敏联系方式,不能拿到完整手机号。

管理接口需要严格的权限校验。如果后台没有任何鉴权保护,等于把所有订单数据和用户手机号裸奔在公网上。我见过有人做课程设计,后台接口没有任何拦截器,任何人知道 URL 就能看全部数据。这样即便功能演示满分,评审一看安全问题就打了低分。

定时任务和大批量导出操作要注意执行频率和日志记录。管理员操作后台时保留操作日志,能追溯谁在什么时候改了订单状态。这个日志表非常简单,但关键时刻能救命。

6.3 常见坑与我的处理经验

最后分享几个我实际开发中遇到的坑,未必在教科书里能看到。

时区问题。后端 MySQL 连接串里如果不明确指定 serverTimezone,并且系统里的时间字段直接存当前时间,不同时区的服务器和本地环境显示会出现偏差。建议统一使用 Asia/Shanghai 时区,并且在代码里所有时间字段都用 LocalDateTime。

坐标距离计算不能直接用“经纬度相减再乘一个系数”,那样误差会很大,尤其在南北方言距离感比较明显的地方。要用 Haversine 公式把球面距离算出来。代码不复杂,但结果是可信的:

public static double distance(double lat1, double lng1, double lat2, double lng2) { double rad = Math.PI / 180.0; double dLat = (lat2 - lat1) * rad; double dLng = (lng2 - lng1) * rad; double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLng / 2) * Math.sin(dLng / 2); return 6371000.0 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }

通知渠道问题。Android 8.0 以上必须为通知创建渠道,否则 NotificationManager 会静默丢弃通知。测试时如果发现通知不弹,先检查有没有创建 NotificationChannel,不要一上来就怀疑代码逻辑。

接口字段规范。前后端协同开发时,后端返回的字段名如果是驼峰,Android 端通过 Gson 默认能直接映射同名属性;但如果后端返回 json 里多了下划线命名,就必须再写映射注解。最稳妥的方式是一切以接口文档为准,团队内部在项目启动时就约定统一命名风格。

我在实际开发中的最后一个建议是:不要急着写代码,先把订单状态机画对,再把角色权限边界理清,这两点决定了整个项目的骨架。骨架对了,Spring Boot + Vue3 + Android 三端项目的后半段推进会顺利得多。后续如果要扩展支付、接入更完整的推送体系,或者把抢单改成派单模式,底层设计也都是可以平滑接上的。做校园跑腿代取系统,真正的价值不是把单一接口跑通,而是把一条完整的业务闭环想透、做稳。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进