
做计算机毕业设计的时候只要题目里同时出现“Java”“Android”和“医疗预约”很多人第一反应就是三个难点叠在一起。去年帮A同学把一个基于Android的医疗预约系统从零搭到能正常答辩演示我才真正发现这个题目最卡人的地方并不是某个技术点而是需求梳理和业务细节的串接。科室、医生、排班、号源、预约订单、取消退号这些关系一旦理不清App界面做得再漂亮后端也是乱成一团。这篇文章会以移动端医疗挂号预约平台的完整设计过程为主线把业务模块、技术选型、数据库设计、号源并发处理、真机调试和答辩演示准备全部拆开讲。所涉及方案都按“拿过去就能改”的标准写适合本科毕业设计、课程设计也适合第一次做Android管理类项目的开发者。写出来的角度不是教科书式的流程说明而是一个项目从0到1的真实踩坑记录。1. 先把需求盘明白这个系统到底要解决什么问题1.1 系统角色与核心用例很多同学拿到这个题目就直接开写代码结果做到一半发现医院科室、医生排班和用户预约这三块数据互相纠缠改来改去。我的建议是动手前先把自己当成产品经理把角色和用例列清楚。医疗预约系统在典型场景里有两类使用者。第一类是患者也就是使用安卓App的普通用户核心诉求是查询医院有哪些科室、某个科室有哪些医生、医生什么时间出诊然后选择合适的时间段完成预约挂号。第二类是医院管理方包括系统管理员和医生管理方需要维护科室信息、录入医生档案、设置排班规则、查看每天预约了多少人、处理退号。从毕业设计的展示角度来说就算不做一个完整的管理后台也必须有一个能够维护基础数据的入口否则演示的时候数据全靠手写SQL会显得非常单薄。基于这个分析我建议把系统拆成三个端安卓患者端、后端REST接口服务、轻量级Web管理端。患者端负责挂号预约体验Web管理端负责基础数据维护后端负责所有业务逻辑和数据库存取。1.2 功能模块边界划分功能模块不要拆得太细也不建议一个功能一个Activity而是按业务域归类。我在这个项目里最终划分成五块每一块都对应清晰的数据流。用户模块注册、登录、个人信息查询、密码修改。医院资源模块科室列表、医生信息、科室下的医生筛选。排班模块按日期查看医生出诊计划、剩余号源数、号源状态。预约模块提交预约、取消预约、查看预约记录、判断预约状态。管理模块科室管理、医生管理、排班录入、预约总览。这五个模块里最容易做乱的是排班和预约。初期我把排班字段直接挂在医生表上比如某个医生的work_date字段存一串日期后来发现完全没法处理“上午号”“下午号”这种细分。最终的设计是把排班提升为独立实体一个医生在某一天对应一条排班记录排班记录再关联多个号源时段预约单最终落在具体时段上。这样整个数据链路才是闭环的。提示毕业设计里功能不在多而在于每个功能都能讲清楚“为什么这样设计”。排班独立成表这个决策后来成为整个系统演示时最重要的一个讲解点。2. 技术选型为什么是Java Android原生而不是跨平台方案2.1 移动端选型逻辑类似“医院挂号平台”这种题目很多同学会纠结要不要用Flutter或React Native毕竟写一套代码两端跑。但从毕业设计的实际诉求看我强烈建议坚持Android原生Java。原因很直接。首先题目本身就限定了“基于Android”评审老师期望看到的是你掌握Android原生开发的基础能力包括Activity生命周期、RecyclerView列表、权限管理、网络请求等。其次原生开发的排查路径更短出错时你知道去哪里找问题。跨平台框架虽然开发快但如果出现页面渲染异常或插件兼容问题定位成本更高。最后原生Java代码配合Gradle依赖管理在Android Studio里跑起来几乎零门槛。在架构上患者端我采用标准的MVP模式Model管理数据请求View负责界面更新Presenter做中间协调。这个项目里我用了MVP而不是MVVM是因为RxJava和DataBinding的引入会让代码复杂度线性上升而MVP只需要一个接口加一个Presenter类对小项目非常直观。2.2 后端与数据库选型后端框架我选了Spring Boot。它在毕业设计里几乎是标准答案主要因为它集成了Web服务、事务管理、数据校验等能力一个RestController就能对外提供JSON接口开发速度比传统SSH框架快太多。考虑到项目复杂度不高我建议用Spring Boot 2.7.x版本搭配MyBatis Plus作为ORM工具。数据库选MySQL理由就一条毕业设计场景下MySQL的文档最多、语法亲民、可视化工具丰富。如果你在答辩现场被问到“为什么选MySQL”可以从数据安全、事务支持、生态成熟三个角度回答。小提示尽量不要用H2或SQLite代替MySQL因为在展示时评审老师很可能会问“你的数据存在哪里”如果你回答“在内存里”解释起来会很被动。用MySQL至少能导出真实的数据文件演示范围更灵活。2.3 工程结构和依赖清单后端工程我按功能分包而不是按技术层分包这样项目看起来更清晰。举个例子包结构是com.xxx.appointment.controller、com.xxx.appointment.service、com.xxx.appointment.mapper、com.xxx.appointment.entity、com.xxx.appointment.common。按技术分包虽然是传统习惯但功能分包在多人协作和后期维护时更友好。Android端则需要引入几个基础库。我的build.gradle核心依赖如下implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.9.0 implementation androidx.recyclerview:recyclerview:1.3.2 implementation com.squareup.retrofit2:retrofit:2.9.0 implementation com.squareup.retrofit2:converter-gson:2.9.0 implementation com.squareup.okhttp3:logging-interceptor:4.11.0网络库我选了Retrofit而不是原生的HttpURLConnection原因是Retrofit的注解式接口定义优雅配合Gson能省掉一堆JSON解析代码。图片加载用Glide列表刷新用SwipeRefreshLayout这些库技术成熟查询资料也方便。3. 核心模块开发实录从登录到预约完成3.1 登录认证与Token处理医疗预约系统涉及用户隐私所以登录态的设计不能太随意。我这里采用的方案是后端登录成功后生成一个UUID作为Token存到Redis里返回给App。App端把Token保存在SharedPreferences中每次请求都通过拦截器自动携带。Android端用OkHttp拦截器统一加Header这是非常关键的一步。如果你在每个网络请求里手动加Token会很繁琐而且一旦忘记加某个请求的Token就会出现“有时候能查数据有时候报401”这种怪问题。拦截器代码如下public class AuthInterceptor implements Interceptor { Override public Response intercept(Chain chain) throws IOException { Request original chain.request(); String token MySharedPreferences.getInstance().getToken(); Request request; if (token ! null) { request original.newBuilder() .header(Authorization, Bearer token) .method(original.method(), original.body()) .build(); } else { request original; } return chain.proceed(request); } }后端这边我用一个拦截器统一校验Token只放行登录和注册接口。校验失败时返回401状态码App端在收到401后跳转回登录页。这条链路要提前测好否则演示时Token过期一点“我的预约”就闪退非常尴尬。3.2 科室列表与医生排班展示患者打开App后最常用的功能就是找科室、找医生。科室列表我使用RecyclerView展示每个item包含科室图标、科室名称、科室简介。科室数据从后端接口获取结构是departmentId, departmentName, description, iconUrl。点击某个科室后进入该科室下的医生列表。医生信息的字段包括医生姓名、职称、擅长方向、出诊状态。这里有一个细节必须注意医生列表的展示内容要和排班状态联动。如果某位医生本周没有任何排班前端就应该显示“暂无排班”而不是让用户进入预约页面后发现约不了。医生排班页是核心中的核心。我的交互设计是顶部显示日期选择器横向滑动选择近七天的日期中间区域展示当前选中日期下该医生的排班时段和剩余号源。每个时段包含开始时间、结束时间、号源总数、已约人数、剩余号数。如果剩余号数为0对应的“预约”按钮置灰显示“已约满”。这个页面我会强调一个实现要点日期选择器不要自己写日历控件直接用Android自带的MaterialCalendarView或DatePickerDialog然后用SimpleDateFormat格式化日期作为后端查询参数。自己写日历控件的人90%的时间都浪费在计算周几和闰年上没有意义。3.3 预约核心流程号源扣减与状态流转预约流程是整个项目的业务核心。患者选定医生和时间段点击“立即预约”App把doctorId、departmentId、scheduleId、timeSlotId、patientUserId发送到后端。后端需要做三件事校验排班是否存在、校验该时段是否还有号源、防止同一个用户重复预约。这段业务逻辑在Service层实现关键代码体现了事务控制Transactional(rollbackFor Exception.class) public Result createAppointment(AppointmentRequest request) { TimeSlot slot timeSlotMapper.selectById(request.getTimeSlotId()); if (slot null) { return Result.error(就诊时段不存在); } if (slot.getRemainingCount() 0) { return Result.error(该时段号源已约满); } int duplicateCount appointmentMapper.countByUserAndSlot( request.getPatientId(), request.getTimeSlotId()); if (duplicateCount 0) { return Result.error(您已预约该时段请勿重复操作); } int updated timeSlotMapper.decreaseRemainingCount(request.getTimeSlotId()); if (updated 0) { return Result.error(号源更新失败请刷新后重试); } Appointment appointment new Appointment(); appointment.setPatientId(request.getPatientId()); appointment.setDoctorId(request.getDoctorId()); appointment.setDepartmentId(request.getDepartmentId()); appointment.setScheduleId(request.getScheduleId()); appointment.setTimeSlotId(request.getTimeSlotId()); appointment.setStatus(0); // 0 表示已预约 appointment.setCreateTime(LocalDateTime.now()); appointmentMapper.insert(appointment); return Result.success(预约成功, appointment.getId()); }这里必须加Transactional注解因为“扣减号源”和“生成预约订单”是两个操作如果只扣号源但订单插入失败就会出现数据不一致。所有涉及多表更新的操作都要放到同一个事务里。前端在收到“预约成功”后我会弹出一个确认对话框显示就诊时间和就诊地点让用户选择“确定”或“取消”。这一步看似简单但在答辩演示时特别加分因为你展示了预约成功的完整反馈链路而不是只甩一条Toast。3.4 取消预约与号源回补有预约就有取消。取消预约的业务逻辑相对反着做先把订单状态改为“已取消”再把对应时段的剩余号数加回去同时更新已约人数。这里有个很容易被忽略的边界如果用户已经过了就诊时间就不应该再支持线上取消。我采用前端和后端双重判断。前端在列表页根据时间段判断如果endTime now就隐藏取消按钮后端在取消接口里再做一次时间校验。注意刚做完这个功能的时候有一个bug取消预约时只更新了订单状态没有回补号源导致用户取消后再去约同一个时段会提示“重复预约”。原因就是用timeSlotId判断重复预约时把已取消的订单也算进去了。修复方案很简单查询用户是否重复预约时必须加上status ! 2已取消这个条件同时要在取消操作里正确回补号源。3.5 我的预约列表状态与过滤在用户点击“我的预约”后App展示该用户全部预约记录每条记录包含医生姓名、职称、科室、预约日期、时间段、预约状态。状态分三种待就诊、已取消、已完成。列表顶部我用了一个简单的Tab切换方便只看某一类状态。这个页面在数据呈现上要注意分页问题。如果用户预约记录很多不加上分页会导致一次性加载大量数据接口响应变慢。我建议使用最简单的分页方式pageNum和pageSize两个参数后端用MyBatis Plus的Page对象自动分页前端在RecyclerView滚动到底部时自动加载下一页。4. 数据库设计从实体关系到并发控制4.1 实体关系与关键字段数据库设计是答辩提问的重灾区。很多同学设计表的时候只想着把字段凑出来却没有考虑表之间的关系和数据的约束。我把核心表设计为用户表、科室表、医生表、排班表、号源时段表、预约表共六张表。实体关系可以这样理解一个科室下挂多个医生一个医生对应多条排班记录一条排班记录下配备多个号源时段一个用户在多条预约记录中关联到具体的号源时段。这是一个典型的“一对多 最终落到订单”的模型。用户身份的区分我建议用role字段取值可以是PATIENT、ADMIN、DOCTOR。统一用户表的好处是登录逻辑简单不需要做三套用户体系。医生信息表再通过userId外键关联用户表形成用户和医生详情的一对一关系。以下是核心表的DDL片段字段注释直接在SQL里写清楚答辩时可以展示CREATE TABLE department ( id bigint NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 科室名称, description varchar(255) DEFAULT NULL COMMENT 科室简介, location varchar(50) DEFAULT NULL COMMENT 门诊位置, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE doctor ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 关联登录用户表, department_id bigint NOT NULL COMMENT 所属科室, name varchar(50) NOT NULL COMMENT 医生姓名, title varchar(50) DEFAULT NULL COMMENT 职称, specialty varchar(255) DEFAULT NULL COMMENT 擅长方向, avatar varchar(255) DEFAULT NULL COMMENT 头像地址, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;排班表和号源时段表是承上启下的重要部分。排班表记录医生在某一天有没有门诊是半天还是全天号源时段表则把排班拆成可预约的“上午号”“下午号”或具体时间段字段里保留total_count和remaining_count。CREATE TABLE schedule ( id bigint NOT NULL AUTO_INCREMENT, doctor_id bigint NOT NULL, work_date date NOT NULL COMMENT 出诊日期, period tinyint DEFAULT NULL COMMENT 0:全天 1:上午 2:下午, status tinyint DEFAULT 1 COMMENT 0:停诊 1:出诊, PRIMARY KEY (id), UNIQUE KEY uk_doctor_date (doctor_id, work_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE time_slot ( id bigint NOT NULL AUTO_INCREMENT, schedule_id bigint NOT NULL, start_time varchar(20) NOT NULL, end_time varchar(20) NOT NULL, total_count int NOT NULL COMMENT 总号源, remaining_count int NOT NULL COMMENT 剩余号源, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;4.2 并发预约场景下的防超卖设计医疗预约本质上是库存系统。多个用户对同一个时段的最后几个号源同时发出请求如果控制不当就会出现“超卖”——号源只剩1个却有3个用户预约成功。我最开始没有加锁测试时用两个模拟器同时抢最后一个号结果两个都提示成功这就是典型的并发问题。解决方案我采用的是数据库层面的乐观锁。给time_slot表增加一个version字段每次扣减号源时先查出version更新时带上version条件UPDATE time_slot SET remaining_count remaining_count - 1, version version 1 WHERE id ? AND version ?。如果更新的影响行数为0说明数据已经被别人改过此时就提示用户“手慢了号源已被抢完”。这里不建议用悲观锁也就是SELECT ... FOR UPDATE。原因在于医院预约系统的读多写少悲观锁会导致大量请求阻塞影响查询性能。毕业设计虽然不需要承受太高的并发量但回答“为什么选乐观锁”时能够从锁粒度和性能角度解释会让评委觉得你有理论支撑。4.3 数据一致性的其他细节除了并发控制还有两个一致性问题容易被忽略。第一用户取消预约后号源回补操作必须放在同一个事务里。第二删除科室或医生时需要检查是否已有排班和预约记录如果有就不能直接物理删除而是通过status字段做逻辑删除。我把删除操作设计为软删除通常用一个deleted字段标记而不是直接执行DELETE语句。这个设计不仅安全而且在答辩时是一个很好的“业务思考点”——你可以说“我考虑到历史数据的重要性所以采用逻辑删除来保留医疗记录的可追溯性”。5. 开发测试阶段遇到的典型问题与排查过程5.1 登录态失效所有接口忽然全部401项目做到中期出现过一次连环报错。App端明明登录成功了SharedPreferences里也存了Token但查询科室列表仍然返回401。排查了半天最后发现是后端拦截器把Authorization头解析错了。我最初在OkHttp拦截器里用的Header名称是token后端读取的是Token大小写不一致导致每次都解析为null。统一改成Authorization: Bearer xxx后问题解决。这个坑能写出来是因为太典型了前后端约定的请求头名称不一致往往是联调期最浪费时间的问题之一。建议开发前就把接口文档写清楚尤其是Headers、Params、Body三块能省下大量联调时间。5.2 号源超卖与重复预约的复现在号源并发测试的时候我搭了两个模拟器同时点击最后一个号源的预约按钮。发现不只是超卖还出现了一个更隐蔽的问题用户已经约成功了但第二次点击同一个时段会被后端判定为“重复预约”哪怕这个用户已经取消过记录里仍然存在。这个问题的根源在于数据库里没有唯一约束。我后来在appointment表上给patient_id和time_slot_id加了一个唯一索引。但取消后再次预约又被拦住所以查询时必须在status ! 2的基础上去重。同时Unique索引里不能包含status字段否则同一个用户无法对同一时段建立两条记录。实现“同一用户可以取消后再次预约”的逻辑最终靠的是Service层的条件判断而不是简单依赖数据库索引。ALTER TABLE appointment ADD UNIQUE KEY uk_patient_slot (patient_id, time_slot_id, status);注意如果直接对status加索引还是无法实现“重复预约同一天同一时段”的校验。这里本质是业务规则不是单纯的数据库约束Service层要写清楚。5.3 Android端图片加载与OOM问题在医生列表页头像加载一多低配置模拟器直接崩溃。排查日志发现是大量图片同时加载导致的内存溢出。换用Glide之后配合override(100, 100)和centerCrop()缩放了图片尺寸OOM问题基本消失。给新手一个建议不要直接使用ImageView.setImageResource()或BitmapFactory.decodeStream()加载网络图片。Glide可以自动处理图片缓存、内存复用和生命周期绑定代码量还少。以下是我在适配器中加载头像的标准写法Glide.with(context) .load(doctor.getAvatar()) .placeholder(R.drawable.ic_default_avatar) .error(R.drawable.ic_default_avatar) .override(120, 120) .circleCrop() .into(holder.doctorAvatar);加circleCrop()是为了兼容圆角头像不加的话有些高版本的Android设备上会显示正方形观感差很多。5.4 真机与模拟器联调时的地址问题开发时在模拟器上用10.0.2.2访问本机后端接口很正常但换到真机就怎么也连不上。原因是电脑和手机不在同一个网段或者后端接口没有监听0.0.0.0。我的处理方法是后端在启动命令中加--server.address0.0.0.0手机端把BaseUrl改成电脑的局域网IP。每次换网络都要改IP非常麻烦。这里建议大家把BaseUrl单独放在一个Config类里并把调试开关做成可视化在App的“设置”页加一个“服务器地址”输入框保存后即时生效。虽然只多了一个简单页面但联调效率直接翻倍。5.5 日期格式化导致的隐藏Bug另一个让我加班到凌晨的问题是后端接口返回的日期是2024-06-01 08:00:00前端解析却一直报错。排查半天是Gson对LocalDateTime类型的序列化支持不完全。我被迫在时间字段的getter上加了JsonFormat注解。如果你是Java 8的时间类型一定记得在后端字段上加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)否则Bean转JSON时会直接抛异常。6. 部署演示与答辩准备把完成的项目讲出亮点6.1 演示环境怎么搭最稳答辩演示最怕的就是现场环境出问题。我的建议是提前准备一套离线可用的演示环境而不是现场依赖外网或数据库服务器。我这边最终的做法是把MySQL数据导出成SQL脚本在答辩用的电脑上装好MySQL和Redis预先导入数据。后端打包成jar文件用一行命令启动。整个演示环境只依赖一台笔记本电脑网络不通也不会影响核心展示。为了防止现场乱码所有接口返回都统一用UTF-8编码。本地启动命令可以写成java -jar appointment-server.jar --server.port8080Android端我在模拟器上提前装好App并配置好BaseUrl。如果答辩现场网络环境复杂也可以直接用真机的热点保证手机和电脑在同一局域网。6.2 演示流程的剧本化设计演示过程不要一上来就点“查看科室”而是先讲业务痛点然后按“注册登录—查看科室—选择医生—查看排班—预约挂号—取消预约—管理员维护排班—查看预约统计”这个顺序走一遍。每走一步都用一分钟左右的时间解释背后的关键设计。我在演示时最成功的一个环节是现场演示“号源约满”在管理员后台把某个时段的号源总数改成1然后用两个模拟器同时抢号其中一个提示成功另一个提示“号源已被抢完”。这一个动作直接证明了并发控制的真实性比任何口头描述都有说服力。6.3 答辩高频问题怎么答评审老师大概率会围绕三个方向提问为什么用这个技术栈、数据如何保证一致性、系统有哪些不足。这三类问题不要现场随便发挥提前准备好回答思路。技术栈的选择可以回到第2章那套逻辑Java跨平台、Android生态成熟、Spring Boot便于快速构建接口、MySQL支持事务。数据一致性方面把乐观锁和事务控制的关键代码截图打印出来或者放在PPT里讲到“防止超卖”时直接展示代码片段。系统不足方面主动承认没有接入第三方支付、没有做消息推送、没有医生排班的智能冲突检测同时强调这些是后续优化方向。这里有一个小技巧答辩时不要只讲自己做了什么也要讲“我如何验证它是对的”。我把并发测试、边界测试的过程写成了一段测试记录包括输入参数、预期结果、实际结果。哪怕只有三次测试也要写清楚。这是一个非常容易拿分的细节。写在最后我个人做完这个项目最大的体会是医疗预约系统的难点并不在于Android界面做得多么炫而在于把“医院资源—排班—号源—预约订单”这条完整业务链打通。排班、号源、预约、取消每一步都牵动着数据的一致性任何一个环节没想清楚演示时就会翻车。最后再分享一个小技巧这类“预约类”系统建议你在完成基础功能后加一个极其简单的后台统计页比如按日期展示总共预约了多少人、每个科室预约了多少人。这只是一个聚合查询但对评委来说它能看到项目的完整闭环比只说“我能挂号”要强得多。希望我的踩坑记录能让你少走几个弯路。