简介:这是一套基于Android的预约挂号APP毕业设计项目,采用前后端分离架构,后端可选用SpringBoot/SSM框架,前端使用Android原生开发,数据库为MySQL 5.7,适合计算机相关专业学生用于毕业设计、课程设计或期末大作业。资源包共6个文件、24.63MB,包含项目源码、数据库SQL脚本、部署说明TXT文档及两份PPT答辩演示文稿,覆盖从环境搭建到项目运行的完整流程。项目经过严格调试,确保代码可运行,且注释详细,新手也能快速理解。除Android端源码外,还附带微信小程序版本的预约挂号系统演示文稿,便于横向对比或作为答辩展示素材。部署说明中列出了JDK、IDEA、AndroidStudio等环境的配置要点,并建议将Gradle下载源改为国内镜像以提升构建速度。目前已有406人学习,适合需要快速落地或参考完整项目结构的同学直接使用。
1. 一个预约挂号APP的毕设,到底在考察什么
预约挂号APP在学生毕业设计里出现频率很高,但它并不是“做几个列表页然后跳转”的产品题。用户能感知的动作是“选医院、选科室、看医生排班、选时段、填信息、提交成功”,而真正决定项目能不能在答辩现场讲清楚的关键,是医院、科室、医生、排班、号源、订单这几类数据之间的关系,以及“两个用户同时抢最后一个号”时系统如何保证不超卖。本文以一个可落地的预约挂号APP为例,从服务端表结构、接口规范到 Android 网络层实现一步步拆解。适合正在做 Android 毕业设计、手里只有部分源码和数据库脚本、需要把项目串成完整方案的同学。
2. 预约挂号APP的技术选型与工程骨架:Android端 + 服务端接口
2.1 不要把预约挂号做成单机版,数据库一定要放在服务端
预约挂号这个业务天然是“多端共享一套数据”的系统。手机A预约了号,手机B在同一时刻应该看到号源减少,而不是各存一份本地数据。这一点也是毕业答辩里最容易翻车的地方:只给 Android 工程嵌一个 SQLite 或 Room 数据库,所有预约记录都存在手机本地,老师打开模拟器换一个账号登录,之前的数据就“丢”了。所以常规做法是三层结构:Android 客户端做展示和交互,服务端提供 REST 接口,MySQL 负责持久化,数据库脚本独立存放在项目里的 sql 目录。
如果你手头拿到的 Zip 里只有 Android 源码和一个 .sql 文件,那说明数据库脚本是给服务端用 MySQL 还原的。Android 端不要直接连 MySQL,也不要在客户端写jdbc:mysql://这种连接串。客户端通过 Retrofit 或 OkHttp 访问服务端接口,拿到 JSON 数据再渲染。这样做的好处是表结构变更时,只需要改服务端接口返回字段,不用重新发版本;坏处是你得多写一个小的服务端工程,但毕业设计阅卷时这反而是加分项。
真机调试时需要注意,Android 模拟器访问宿主机服务端不要用localhost,要用http://10.0.2.2:8080/,这个地址被模拟器固定映射到宿主机。真机调试则需要把地址换成电脑在局域网里的 IP,并且保证手机和电脑连同一个 WiFi。这个坑我在帮人调项目时几乎每次都遇到:页面一直 loading,抓包一看全是ConnectException,把 BaseUrl 改对就通。
2.2 Android 端选型:Retrofit + Gson + RecyclerView + Glide
做预约挂号APP,Android 端不需要引入特别复杂的架构。MVVM、Hilt 这类框架当然可以写,但对毕业设计来说,把功能跑通、把代码结构讲清楚更重要。最稳妥的组合是:Retrofit 做网络请求,Gson 做 JSON 解析,RecyclerView 做列表,Glide 加载医生头像,SharedPreferences 或本地数据库存登录态。这个组合在 Android Studio 里创建新项目后,加几个 Gradle 依赖就能用,不需要配置乱七八糟的编译器参数。
定义一个医院列表接口,常见写法如下:
public interface HospitalApi { @GET("hospital/list") Call<ApiResult<List<Hospital>>> listHospital(); @GET("schedule/list") Call<ApiResult<List<ScheduleVO>>> listSchedule( @Query("deptId") long deptId, @Query("date") String date); }接口定义中,@GET后面写的是相对路径,完整的请求地址由 BaseUrl 拼接而成。ApiResult<T>是一个统一的响应包装类,正常情况下包含code、msg、data三个字段,所有接口都返回这个结构,Android 端解析时就只需判一次code == 0即可。@Query注解用来拼接查询参数,listSchedule对应的请求就是/schedule/list?deptId=1&date=2025-04-10这种形式,服务端按这两个参数去查排班表。
访问服务端时,Android 默认禁止在主线程做网络操作,所以调用 Retrofit 的call.enqueue()是标准姿势,回调会切到主线程执行,可以直接更新 UI。如果项目中看到的还是new Thread()加runOnUiThread()的写法,那是老项目;同样的逻辑用 Retrofit 写会省掉一半代码。
2.3 Zip 包里“源码 + 数据库”的常见工程结构
一个完整的预约挂号毕业设计 Zip,解压后通常是这样的布局:Android 工程目录占主体,里面有app/src/main/java放 Java 或 Kotlin 源码,res放布局和图片资源,build.gradle管理依赖版本;服务端源码可能是独立的 Spring Boot 工程或一个server文件夹;数据库脚本一般放在db/hospital.sql或doc/hospital.sql。有的压缩包只给出 Android 端源码和一份创建表的 SQL,这时候服务端需要自己补。
拿到项目后不要急着打开 Android Studio,先把 SQL 脚本过一遍。看表结构里有没有t_hospital、t_department、t_doctor、t_schedule、t_timeslot、t_order这类核心表。如果只有用户表和预约表,那说明数据模型是简化过的,页面上的医院和科室很可能是写死在 Android 代码里的静态数组。能用是能用,但答辩时经不起追问“如果换一家医院怎么办”。我的建议是:哪怕服务端代码不写,也先把六张核心表建全,用最简单的 Servlet 或 Spring Boot 把它们查出来返回 JSON,整个项目的完成度会明显不一样。
3. 预约挂号核心表结构设计与数据库防超卖SQL
3.1 基础实体:用户、医院、科室、医生
预约挂号这个业务先有“医院—科室—医生”这个层级,前置表不要一上来就把字段堆在订单表里。用户表保存账号和就诊人基本信息,医院表、科室表、医生表逐级外键关联。建表脚本如下,可以直接作为数据库课程设计的起点:
CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(16) NOT NULL, password_hash CHAR(64) NOT NULL, name VARCHAR(32) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ); CREATE TABLE t_hospital ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, level VARCHAR(16) COMMENT '三级甲等/二级甲等', address VARCHAR(128) ); CREATE TABLE t_department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, hospital_id BIGINT NOT NULL, name VARCHAR(64) NOT NULL, intro TEXT, KEY idx_hospital (hospital_id) ); CREATE TABLE t_doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_id BIGINT NOT NULL, name VARCHAR(32) NOT NULL, title VARCHAR(32) COMMENT '主任医师/副主任医师', avatar_url VARCHAR(255), intro TEXT, fee DECIMAL(10,2) DEFAULT 0 );设计时注意几个点。password_hash用固定长度CHAR(64),对应 SHA-256 的十六进制输出长度,密码不要明文存储,这是评审老师一定会扫一眼的地方。t_department挂在t_hospital下面,同一科室可能存在于多家医院,不要在科室表里写死一个医院名。t_doctor挂在科室下,fee在医生层设置,因为同一科室下不同职称的医生挂号费不同,这一点在计算订单金额时要用到。COMMENT注释写清楚字段含义,导出 SQL 的时候老师会看表结构,注释比字段名更能说明你理解业务。
3.2 排班表与号源表:医生出诊和号源数量分开建模
排班和号源是这个业务里最容易设计混乱的部分。很多初学会把“某医生某天上午出诊”和“上午还剩几个号”混在一张表里,字段一会儿加total_count一会儿加remain_count,最后排班和号源变成同一份数据。更好的做法是把排班与号源拆成两张表:排班表记录医生某天是否出诊、是上午还是下午;号源表记录该排班下具体的时间段,以及每个时间段的总号和剩余号。
CREATE TABLE t_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, dept_id BIGINT NOT NULL, work_date DATE NOT NULL, period TINYINT NOT NULL COMMENT '1上午 2下午', status TINYINT NOT NULL DEFAULT 1 COMMENT '1出诊 0停诊', UNIQUE KEY uk_doctor_date_period (doctor_id, work_date, period) ); CREATE TABLE t_timeslot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, fee DECIMAL(10,2) NOT NULL, total_count INT NOT NULL, remain_count INT NOT NULL, KEY idx_schedule (schedule_id) );t_schedule上的唯一键(doctor_id, work_date, period)保证同一个医生在同一天同一个午别只能有一条出诊记录,重复插入时数据库会直接报错,这比在代码里写 if 判断可靠得多。t_timeslot按schedule_id关联排班,例如上午排班下面可以生成 08:00–08:30、08:30–09:00、09:00–09:30 三个时段,每个时段默认total_count = 5,remain_count初始等于total_count。页面展示时,用户看到的是“医生头像 + 出诊日期 + 剩余号数”,数据来源就是这两张表的联查。
这里有一个值得在代码注释里写清楚的设计意图:为什么不直接在t_timeslot里写doctor_id。因为同一个医生换排班后,号源是重新生成的,保留排班维度的好处是:医生停诊时,只需要把t_schedule.status置 0,该排班下的所有时段自动不可约,不需要逐条改号源表。
3.3 订单表与状态机:待支付、已确认、已取消、已过期
订单表承载预约结果,也承载状态流转。状态字段用TINYINT,不要用字符串VARCHAR存“已预约”这种中文值,一是浪费空间,二是写代码时容易打错字,三是排序和统计都不方便。用数字表示状态,在 Java 代码里定义一个枚举或常量类统一对应:
CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, timeslot_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已确认 2已取消 3已完成 4已过期', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id), KEY idx_timeslot (timeslot_id) );状态机建议只保留四条迁移路径:待支付转已确认(用户支付),待支付转已取消(用户取消),待支付转已过期(超时未支付,由定时任务触发),已确认转已完成(就诊后标记)。不要在代码里允许任意状态互转,比如已取消的订单不能再变成已确认。把状态迁移的逻辑收敛到一个 Service 方法里,每个方法只做一件事:payOrder()、cancelOrder()、expireOrder()、finishOrder()。面试和答辩时这类“状态收敛”的设计比写十个 Activity 更值得讲,因为老师关心你是否想清楚了业务边界。
订单号不要用数据库自增 id 直接暴露给用户,因为自增 id 能看出系统一天的订单量,这属于信息泄露。常见做法是生成一个 20 位左右的业务单号:日期时间(14位) + 用户ID后四位 + 随机数补足,或者直接用UUID去掉横杠再截断。注意order_no必须建唯一索引,这是后面做幂等判断的基础。
3.4 防超卖的关键:UPDATE 里带 remain_count > 0
预约挂号的并发问题是毕业设计里含金量最高的知识点。场景是:一个时段只剩最后一个号,两个用户同时点击提交,如果代码逻辑是“先查询剩余号数,大于0就扣减,再插入订单”,在并发下两个请求都可能查到remain_count = 1,然后都执行插入,最后变成超卖。解决的办法是把“检查”和“扣减”合并成一条 SQL:
UPDATE t_timeslot SET remain_count = remain_count - 1 WHERE id = #{timeslotId} AND remain_count > 0;执行这条 UPDATE 后,根据受影响的行数判断是否抢号成功。影响了 1 行,说明remain_count在扣减前大于 0,扣减成功;影响了 0 行,说明剩余号数已经是 0,用户约满。这个操作依赖 MySQL 行锁,两个事务同时执行时会串行化,直接避免超卖。配合订单插入放在同一个事务里,就形成了“扣号源 + 写订单”的原子操作。
完整下单逻辑在 Service 层看起来是这样:
@Transactional public BookResult tryBookTimeslot(Long timeslotId, Long userId) { // 同一个用户不允许重复约同一个时段 if (orderMapper.countByUserAndTimeslot(userId, timeslotId) > 0) { return BookResult.fail("您已预约过该时段,请勿重复提交"); } // 核心:条件写在 SQL 里,数据库行锁保证不超卖 int rows = timeslotMapper.decreaseRemain(timeslotId); if (rows == 0) { return BookResult.fail("该时段号源已满"); } Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTimeslotId(timeslotId); order.setScheduleId(queryScheduleIdByTimeslot(timeslotId)); order.setStatus(0); orderMapper.insert(order); return BookResult.ok(order); }这里有两层防护。第一层查重,t_order表上可以再加一个(user_id, timeslot_id)唯一索引,数据库层面挡住同一用户重复预约;第二层就是这条带条件的 UPDATE,防止不同用户超卖。这种“数据库条件更新”的技巧同样适用于秒杀、选课、活动报名等所有余量扣减场景,掌握之后在简历上写项目经验时,可以理直气壮写上“基于数据库行锁解决并发超卖”。注意 MyBatis XML 里写大于号要转义:
<update id="decreaseRemain"> UPDATE t_timeslot SET remain_count = remain_count - 1 WHERE id = #{timeslotId} AND remain_count > 0 </update>>是 XML 中对>的转义写法。如果忘了转义直接写>,XML 解析会报错;如果顺手写成了>=,还要转义成>=,否则同样报错。这类细节说出来,能给对方“这个人真的调过接口”的感觉,而不是照着教程抄了一遍。
4. 从科室列表到预约成功:Android 与接口对接的完整链路
4.1 排班与时段列表的页面数据拼装
用户的操作路径是:首页选择医院,进入医院后展示科室列表,点击科室展示该科室下医生的出诊排班,选择排班后弹出当天的号源时段列表。服务端在设计接口时,不要要求 Android 端自己算“明天是周几、上午下午怎么分”,这些判断放在服务端完成,Android 端只负责展示。
排班列表接口返回的数据结构大致是这样:
{ "code": 0, "data": [ { "scheduleId": 101, "doctorId": 5, "doctorName": "王建国", "title": "主任医师", "avatarUrl": "http://xx/avatar/5.png", "workDate": "2025-06-10", "period": 1, "periodText": "上午", "timeslots": [ { "timeslotId": 501, "timeText": "08:00-08:30", "remainCount": 5, "fee": 20.00 }, { "timeslotId": 502, "timeText": "08:30-09:00", "remainCount": 0, "fee": 20.00 } ] } ] }timeslots是内嵌在排班对象里的数组,Gson 在解析时不需要额外配置也能处理这种嵌套结构。Android 页面用一个外层RecyclerView展示排班卡片,卡片内再嵌套一个RecyclerView横向滚动展示时段,这是预约挂号APP最常用的布局。时段剩余为 0 的项要把按钮置灰,文案显示“已约满”,不要等用户点进去再提示失败,这个细节虽然小但很影响体验,也属于“边界状态处理”的加分答辩点。
列表加载时的空态也要处理。比如“该科室暂无排班”或“该医生今日停诊”,不要白屏。用ProgressBar展示 loading,数据返回后隐藏;请求失败在页面上显示重试按钮而不是直接Toast一下就消失。这个完整链路写清楚,约等于把 Android 列表加载的骨架代码全部打通。
4.2 提交预约的 Android 实现:防重复点击与进度条反馈
提交预约是整个 App 最核心的操作。用户选好时段,确认订单页展示医生、时间、挂号费,点击“提交预约”按钮后:按钮置灰,显示一个加载进度条,同时发起网络请求。请求失败后按钮恢复可点击,并给出失败原因;请求成功后跳转“预约成功”页,显示订单号。重点在于防止用户因为网络慢而疯狂点按钮,导致发出多个请求。
private void submitBooking(long userId, TimeslotVO timeslot) { btnSubmit.setEnabled(false); progressBar.setVisibility(View.VISIBLE); BookingApi api = RetrofitClient.get().create(BookingApi.class); BookRequest request = new BookRequest(userId, timeslot.getId()); Call<ApiResult<OrderInfo>> call = api.book(request); call.enqueue(new Callback<ApiResult<OrderInfo>>() { @Override public void onResponse(Call<ApiResult<OrderInfo>> call, Response<ApiResult<OrderInfo>> response) { progressBar.setVisibility(View.GONE); btnSubmit.setEnabled(true); ApiResult<OrderInfo> body = response.body(); if (body != null && body.getCode() == 0) { startActivity(SuccessActivity.newIntent(context, body.getData())); finish(); } else { Toast.makeText(context, body.getMsg(), Toast.LENGTH_SHORT).show(); } } @Override public void onFailure(Call<ApiResult<OrderInfo>> call, Throwable t) { progressBar.setVisibility(View.GONE); btnSubmit.setEnabled(true); Toast.makeText(context, "网络异常,请检查网络后重试", Toast.LENGTH_SHORT).show(); } }); }这段代码里,btnSubmit.setEnabled(false)在请求发出前立即执行,避免双击。progressBar的可见性切换保障用户能感知请求状态。body.getCode() == 0判断的是业务成功,HTTP 层面的 200 只代表请求到达了服务端,不代表预约成功。这里有一个容易踩的坑:服务端可能返回code = 500,但 HTTP 状态码仍然是 200,如果代码里直接拿response.isSuccessful()判断成功,就会把失败当成功处理。统一用ApiResult的code字段判断才是正确姿势。
OkHttp 的网络超时配置也要检查。默认连接超时是 10 秒,在校园网这种弱网环境下,预约提交经常出现前端迟迟不回调的问题。通常手动在 Retrofit 构建时设置超时时间,推荐配置为connectTimeout(10, TimeUnit.SECONDS)、readTimeout(15, TimeUnit.SECONDS)、writeTimeout(15, TimeUnit.SECONDS)。一个普通提交请求在服务端只需几十毫秒,如果 15 秒还没返回,大概率是网络断了或者服务端卡死,直接提示失败比让用户干等更合理。
4.3 取消预约的号源回滚与事务处理
取消预约在业务上是下单的逆操作,处理不当会出现号源凭空消失。用户取消订单后,t_order.status要改成 2(已取消),同时t_timeslot.remain_count要加 1。这两个操作必须在同一个数据库事务中提交,否则可能出现订单已取消但号源没加回去,或者号源加回去了订单还是待支付状态。
@Transactional public void cancelOrder(Long orderId) { Order order = orderMapper.selectById(orderId); if (order == null) { throw new BizException("订单不存在"); } if (order.getStatus() != 0 && order.getStatus() != 1) { throw new BizException("当前状态不可取消"); } orderMapper.updateStatus(orderId, 2); timeslotMapper.increaseRemain(order.getTimeslotId()); }@Transactional注解可以放在这个 Service 方法上,异常时自动回滚,remain_count不会和订单状态不一致。注意increaseRemain的 SQL 是UPDATE t_timeslot SET remain_count = remain_count + 1 WHERE id = #{id},不要受前面防超卖 SQL 的影响也加上remain_count > 0的条件,取消失败时大概率是某处代码把号源数量改错了,排查时先去看订单状态是不是已经被改成已取消。
如果项目里还需要支持“医生停诊”场景,回滚逻辑会更复杂:停诊后,已预约该医生号源的用户要收到通知,订单批量置为已取消,号源不需要恢复,因为停诊后号源也不再对外开放。这部分逻辑在毕设里属于锦上添花,但可以在论文的“系统功能扩展”章节写一笔。
5. 答辩前用自查清单验证预约挂号APP的边界行为
5.1 十条必测场景
把下面这十种情况逐项在模拟器里跑一遍,能显著减少现场演示时的意外:
| 场景 | 预期行为 | 自查方法 |
|---|---|---|
| 同一用户快速双击提交预约 | 只生成一条订单 | 连点“提交预约”按钮,观察订单列表 |
| 两个账号同时抢最后一个号 | 一个成功一个失败 | 服务端日志看两条请求耗时接近时结果 |
| 取消后再约同一时段 | 可以成功预约 | 取消后刷新页面,号源数量加 1 |
| 号源为 0 的时段 | 按钮置灰,不可点击 | 找一个约满的时段查看 UI 状态 |
| 订单超过支付时限 | 自动变已过期 | 把定时任务间隔改小,等待几分钟观察 |
| 弱网环境提交预约 | 提示网络异常,可重试 | 开启飞行模式后再点提交 |
| 医院列表为空 | 显示空态和刷新按钮 | 连一个没有医院的测试服务端 |
| 排班日期过了一天 | 日期列表不出现过去日期 | 切换设备时间测试 |
| 医生停诊 | 排班从列表消失,已约订单取消 | 后台把 schedule status 改成 0 |
| 未登录直接点预约 | 跳转登录页 | 清除登录态后操作 |
这十条覆盖了并发、状态流转、空态和 UI 反馈四个维度,每一类都能对应到上面讲过的设计点。过程中遇到的问题,基本都是表结构缺失或状态没有收敛导致的,不会牵涉特别高深的技术。
5.2 回答“你的系统有什么亮点”的三个角度
答辩被问到项目亮点时,不要回答“我做了登录注册和预约挂号”。可以从三个具体设计点展开。第一,号源扣减使用带条件的 UPDATE 语句,利用数据库行锁解决并发超卖问题,并且已经在代码里验证过两个并发请求只有一个成功。第二,订单状态机显式定义四条迁移路径,所有状态变更收敛到 Service 层,核心操作全部放在事务中,取消了数据和状态不一致的可能。第三,客户端做到接口统一返回ApiResult结构,业务失败和 HTTP 失败分开判断,配合按钮置灰和进度条反馈,避免了重复提交订单。这三条每一句都可验证,比背十页架构图更容易让老师相信这是你自己写出来的代码。
答辩前建议把六张核心表的 ER 图画在一页 PPT 上,然后在图上标注事务边界:下单时需要同时更新t_timeslot和t_order,取消时需要同时更新t_order和t_timeslot。再把状态机的四条迁移路径画在旁边。这份图就是整个预约挂号APP的业务闭环,代码、数据库与论文都围绕它铺开,比临时翻源码能讲得清楚得多。
本文还有配套的精品资源,点击获取