1. 一个挂号系统的业务边界别把毕设做成“伪需求堆砌”1.1 患者、医生、管理员三类角色各管什么我先说一个很常见的现象很多人在做课设的时候习惯性地把 SpringBoot 后端拆成“用户管理、科室管理、医生管理、预约管理”四个模块然后每个模块就是一套增删改查页面。这个做法不是错但它最大的问题是看不出系统的行业属性老师拿到之后第一眼就会觉得“这就是个普通管理后台”。线上医院挂号系统不太一样它的核心业务是围绕“挂号”这一件事展开的。患者端不需要看到太多后台表他关心的是这个科有哪些医生、哪个医生哪天还有号、能不能预约、约完之后去哪查自己的挂号单。医生端关心的是自己哪天出诊、放了多少号、被约了多少、剩下多少。管理员则负责维护科室、医生信息以及在某些异常情况下帮患者取消订单。这三类角色串起来之后业务闭环就形成了。课程设计里常说“角色权限”很多人做的是给不同角色显示不同菜单这其实只是表面。更好的做法是让权限体现在业务流程上患者能约号但看不到后台菜单医生能查看自己的排期但不能改别人的排期管理员能操作停诊但需要确认停诊后已有预约怎么处理。做到这一步项目的含金量就上来了。1.2 三条核心业务流程串起来之后项目复杂度就够了我习惯把整个系统拆成三条流程线基础数据维护流程、预约挂号核心流程、就医完成与订单流转流程。基础数据维护流程解决的是“医院有什么”科室从哪来、医生挂在哪个科室、医生一周排班怎么录入。预约挂号流程解决的是“号怎么被约走”患者选科室、选医生、选日期时段、提交预约、扣号源。订单流转流程解决的是“约完之后怎么办”待支付、已预约、取消回补、完成就诊、超时未支付自动释放。这三条流程不是彼此孤立的。排期表连接医生表和预约记录预约记录在取消时又要反向去回补排期的号源数据。如果你能把这个数据流转关系理清楚后面写代码会非常顺而且答辩的时候也有话讲。我见过不少同学喜欢把功能做得特别多什么公告轮播图、数据大屏、在线问诊都塞进去结果每个功能只做了一半。倒不如先把这 20 张以内的表收拢成上面三块每一块都走通、走顺这比堆十个半成品页面有价值得多。所以如果你想拿这个选题我的建议是先别急着写代码花半天把业务边界画清楚哪些功能是必做的、哪些是纯加分项心里要有数。2. 技术选型背后的取舍为什么这一套组合最稳妥2.1 后端框架的“隐形工作量”对比先讨论一个比较老生常谈但绕不开的问题为什么后端选 SpringBoot而不是 SSM 或者 SpringCloudSSMSpring SpringMVC MyBatis不是不能做但对课程设计来说配置成本偏高。你要自己处理大量的 XML 配置、web.xml 配置还要协调各种 jar 包版本。SpringBoot 把这些约定都做掉了内嵌 Tomcat一个 main 方法就能启动这对第一次做完整项目的同学来说能省下非常多“跟业务无关”的时间。换句话说你不是在偷懒而是把精力留给真正该学的东西——业务逻辑和数据库设计。至于 SpringCloud 微服务我的态度是明确的课设阶段不建议碰。注册中心、网关、配置中心、服务调用链这些概念一套下来光搭环境就能把你耗空。而且微服务是为了解决高并发、独立部署、团队协作问题一个模拟挂号的单体项目用不上这一套。答辩的时候如果老师问“你这项目有没有考虑分布式事务”诚实回答“单体应用场景下用数据库事务已经够了”反而更稳妥。2.2 Vue2还是Vue3、MySQL版本怎么定前端的版本选择这两年也经常让人纠结。如果你以前学过 Vue2现在用 Vue2 顺手倒也不必强行上 Vue3。但如果是刚开始学或者想给自己的设计加一点“技术新鲜感”我建议用Vue3 Vite Element Plus。理由很简单Vue3 的 Composition API 写起来更灵活Element Plus 组件库足够齐全表格、表单、日期选择器、弹窗都现成不需要你自己造轮子。不过要注意一个坑Vue3 对应的是 Element Plus不是 Element UI。Element UI 是给 Vue2 用的硬塞进去跑不起来。这一点在答辩的时候经常被老师逮住问答不上来会很尴尬。MySQL 版本我用的是 8.0因为它在 SQL 函数、JSON 支持、默认字符集方面都比 5.7 友好。如果你的电脑上装的是 5.7 也没关系这个项目的 SQL 基本兼容唯一需要注意的是两个版本的驱动连接参数不一样后面第 6 章我会专门讲。2.3 推荐的项目方案目录以及哪里容易写乱前后端分离项目目录规划直接决定你后面维护的体验。我推荐的方案是两套代码并行hospital-server // 后端 SpringBoot 工程 ├─ src/main/java/com/example/hospital │ ├─ controller // 控制层负责参数接收和返回 │ ├─ service // 业务层挂号核心逻辑都在这 │ ├─ mapper // 数据访问层MyBatis-Plus 的 Mapper 接口 │ ├─ entity // 对应数据库表的实体类 │ ├─ config // 跨域配置、拦截器配置、MyBatis-Plus 配置 │ └─ common // 统一返回结果、全局异常处理、常量枚举 └─ src/main/resources ├─ mapper // XML 文件复杂 SQL 放这里 └─ application.yml hospital-web // 前端 Vue 工程 ├─ src │ ├─ api // 按模块拆分的接口请求文件 │ ├─ router // 路由配置 │ ├─ stores // Pinia 状态管理 │ ├─ views // 页面组件 │ ├─ components // 公共组件 │ └─ utils // axios 封装、工具函数最容易写乱的地方在哪后端把业务逻辑堆在 controller 里前端把请求代码散落在各个组件里。我见过一些同学整个项目只有 controller 和 mapperservice 层形同虚设。这个问题在期末验收的时候会很致命因为老师随便打开一个 service 接口如果发现里面全是空的他会怀疑你到底有没有认真写。我现在带学生做这个系统通常会强制要求controller 只做参数校验和结果封装所有业务判断都放 service前端每个模块对应的请求单独放一个文件比如src/api/appointment.js避免路由跳转之后代码没法复用。3. 数据库建模才是挂号系统的灵魂3.1 五张核心表的设计与字段含义挂号系统的数据模型并不复杂核心就是五张表用户表、科室表、医生表、排期表、预约记录表。下面是我整理出来的一套可直接落地的核心字段设计表名核心字段说明sys_userid, username, password, real_name, phone, role_type, status角色可以简化为 patient/doctor/admindepartmentid, dep_name, dep_desc, status科室表如内科、外科、儿科doctorid, department_id, name, title, introduce, status职称如主任医师、副主任医师scheduleid, doctor_id, work_date, shift, total_quota, remain_quota, status排期表shift 表示上午下午appointmentid, order_no, patient_id, schedule_id, doctor_id, department_id, visit_date, shift, status, del_flag, create_time, pay_time预约记录状态区分待支付/已预约/已取消其中sys_user和doctor的关系可以有两种设计一种是医生直接挂到用户表上通过角色区分另一种是单独建一张 doctor 表用字段和用户表做关联。我倾向于单独建表因为医生有职称、简介、所属科室这些属性硬塞到用户表里会让用户表变得臃肿。这里的字段看起来简单但有几个细节值得注意。第一金额字段不要用 float/double如果涉及挂号费使用 decimal(10,2)避免浮点精度损耗。第二order_no要有唯一索引生成规则可以是“日期 随机数”直接使用数据库自增 id 做订单号是不专业的。第三work_date严格用date类型不要用datetime排期只需要精确到天。第四shift字段用 tinyint 比用 varchar 更省空间也更好写判断逻辑1 代表上午2 代表下午。3.2 防重复挂号唯一索引和状态字段怎么配合防重复挂号是挂号系统里很关键的一个设计点。你细想一下同一个患者对同一个医生同一天的同一个时段理论上不应该产生两条订单。很多同学的直觉做法是在预约之前先查一次数据库看看有没有已存在的记录没有就插入。这个做法在单用户测试下没问题但一旦两个人同时操作或者同一个人快速重复点击就可能因为查询和插入之间有时间差而出现重复数据。正确思路是用数据库约束兜底。我给appointment表加一个联合唯一索引UNIQUE KEY uk_schedule_patient (schedule_id, patient_id, del_flag)这个设计灵感来自软删除方案。正常预约时del_flag 0这条记录占据唯一的“有效预约”坑位。患者取消预约后我们不是真的把记录删掉而是把del_flag改成 1再回补号源。这样一来数据库层面就已经保证同一患者对同一时间段无法生成两条有效订单。不过要提醒一个细节如果患者取消后又想重新预约del_flag已经是 1它和del_flag 0的记录不冲突可以正常插入新记录这就巧妙地绕开了“取消后无法重约”的问题。但如果你把del_flag放在唯一索引里每次取消都要记得更新它这个逻辑要写在 service 层不能漏。3.3 排期、号源、预约之间的约束关系排期和号源的关系可以用“库存”这个词来理解。schedule表里有两个字段total_quota是初始放号数remain_quota是剩余号数。每一次预约操作对应的排期remain_quota减 1每一次取消remain_quota加 1。这里有一个很多人会写错的点不要在事务里先查询再判断再更新。后面我会展开讲并发控制但先记住一个原则扣减号源用的是更新语句自带的条件判断而不是在 Java 代码里if (remain 0)之后再执行 update。因为两条并发请求可能同时读到remain 0然后一起进入更新最后导致超卖。另外停诊的处理也很有意思。如果医生某天临时停诊管理员需要把对应排期状态改成“已停诊”。这时候已经存在的预约记录怎么办我建议的做法是利用定时任务或者管理端手动触发批量取消把这些预约订单改成“已取消”同时把号源回补的逻辑走一遍。很多课设只做了“停诊状态修改”却没有处理已预约订单这在业务上是闭环缺失的。答辩的时候如果老师追问这一点你说“我已经考虑了停诊自动取消”会非常加分。4. 后端核心逻辑号源扣减、预约与取消回补4.1 排期接口先把“医生哪天出诊”生成好排期功能是整个挂号系统的输入源头。患者能约的号都是医生先排好的班。最简单的实现是做一个批量生成接口选择医生、选择时间段、输入每个时段放号数量然后批量生成 schedule 记录。核心代码如下Transactional public void createSchedule(ScheduleCreateRequest request) { LocalDate startDate request.getStartDate(); LocalDate endDate request.getEndDate(); Long doctorId request.getDoctorId(); // 按天循环每天生成上午、下午两个时段 for (LocalDate date startDate; !date.isAfter(endDate); date date.plusDays(1)) { // 周末是否放号可以做成参数一般课设里简单处理为全部放号 genOneDay(doctorId, date, 1, request.getMorningQuota()); genOneDay(doctorId, date, 2, request.getAfternoonQuota()); } }注意给(doctor_id, work_date, shift)加上唯一索引否则同一个医生同一天同一时段可能会出现两条排期记录。批量生成时如果遇到已存在的排期可以直接跳过或者抛业务异常让管理员决定是覆盖还是不做处理。这个接口直接放在后台管理模块前端做一个排期录入页配合 Element Plus 的日期范围选择器体验非常舒服。4.2 预约接口的并发控制不要先select再update现在到了整个项目最值得说道的地方预约接口的并发控制。我先演示一个错误版本Transactional public BookingResult bookWithBug(BookingRequest request) { Schedule schedule scheduleMapper.selectById(request.getScheduleId()); if (schedule.getRemainQuota() 0) { throw new BizException(号源已约满); } // 这里有问题并发情况下两个请求都可能通过上面的判断 scheduleMapper.updateRemainQuota(schedule.getId()); insertAppointment(request); return ok; }问题出在哪两个请求同时读到remain_quota 1都认为还有号然后都去执行扣减。虽然最终更新语句会串行执行但业务判断用的是“过期快照”仍然可能把余额扣到负数或者产生两条有效订单。更稳的写法是把“判断 扣减”合并成一条 SQLUpdate(update schedule set remain_quota remain_quota - 1 where id #{id} and remain_quota 0) int deductQuota(Param(id) Long id);然后 service 这样写Transactional public BookingResult book(BookingRequest request) { // 1. 排期基本校验 Schedule schedule scheduleMapper.selectById(request.getScheduleId()); if (schedule null || schedule.getStatus() ! ScheduleStatus.OPEN.getCode()) { throw new BizException(该排期不存在或已停诊); } // 2. 条件更新用 update 影响行数判断是否还有号源 int rows scheduleMapper.deductQuota(request.getScheduleId()); if (rows 0) { throw new BizException(号源已被约完请更换时段); } // 3. 插入预约记录 Appointment appointment buildAppointment(request); appointmentMapper.insert(appointment); return new BookingResult(appointment.getOrderNo()); }这里的核心思路是update ... where remain_quota 0MySQL 在更新时会给对应行加行级锁两个并发请求执行这条 SQL 时第二个会被阻塞等第一个提交后再执行然后发现不满足条件影响行数是 0直接返回“号源已约满”。这个方案的学名是“乐观锁思路 条件原子更新”但代码实现一点都不复杂。还有一个细节是事务范围。Transactional加在方法上没问题但事务里不要做太耗时的事情更不要在事务内调用外部接口否则长事务会持有数据库连接和锁影响整体吞吐。虽然课设不像线上系统那样有压力但这个习惯值得从一开始就养成。4.3 取消预约的号源回补与状态一致性取消预约的代码比预约简单但牵扯到状态一致性。我的建议是先改订单状态再回补号源或者反过来总之不要只做一半。Transactional public void cancel(Long appointmentId, Long patientId) { // 1. 校验订单归属和状态 Appointment appointment appointmentMapper.selectById(appointmentId); if (appointment null || !appointment.getPatientId().equals(patientId)) { throw new BizException(订单不存在); } if (!appointment.canCancel()) { throw new BizException(当前状态不可取消); } // 2. 更新订单状态为已取消同时把 del_flag 置为 1 appointmentMapper.updateStatusAndDelFlag(appointmentId, AppointmentStatus.CANCELED.getCode(), 1); // 3. 回补排期号源 scheduleMapper.addQuota(appointment.getScheduleId()); }canCancel的判断很重要。什么情况下允许取消待支付和已预约状态一般都可以取消已完成、已取消、已爽约就不应该再走取消逻辑。这个状态机如果你用 if-else 写很容易漏掉边界情况我的建议是在实体里加一个canCancel()方法统一判断后续再加新状态时只需要改这一处。同时还要考虑取消预约之后del_flag变成 1但唯一索引(schedule_id, patient_id, del_flag)仍然成立因为新插入的正常预约记录是del_flag0不冲突。这一点在测试的时候可以重点验证预约-取消-再预约应该能成功跑通。5. 前端Vue实现从科室列表到预约成功的完整链路5.1 页面路由设计和状态管理的最小方案前端页面我不建议铺太多核心几个就够了首页展示科室、科室详情页展示医生列表、预约页选择日期和时段、我的挂号单页展示登录用户的预约记录、后台管理相关页面科室管理、医生管理、排期管理。路由用 Vue Router 就能解决const routes [ { path: /, name: Home, component: Home }, { path: /department/:id, name: DepartmentDetail, component: DepartmentDetail }, { path: /appoint, name: Appoint, component: Appoint }, { path: /orders, name: MyOrders, component: MyOrders, meta: { requiresAuth: true } }, ]状态管理要不要用 Pinia我的观点是如果项目里只是存 userInfo 和 token只用 localStorage 也能跑但用 Pinia 会让代码更整齐特别是多个页面都要读取用户信息的时候。不需要把全局状态堆满能少用就少用。登录态判断用路由守卫统一处理router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath }}) } else { next() } })5.2 核心页面交互排期日历、余号展示、提交预约预约页是整个前端最核心的页面。我通常把它拆成三块医生信息卡片、日期选择区域、号源展示列表。日期选择直接用 Element Plus 的日期选择器限制今天之前的日期不可选el-date-picker v-modelselectedDate typedate :disabled-datedisabledDate placeholder选择就诊日期 /选中日期后调后端接口查这个医生在这一天的排期信息// src/api/schedule.js export function getDoctorSchedules(doctorId, date) { return request.get(/schedule/list, { params: { doctorId, date } }) }返回的列表里包含上午、下午两个时段的排期和余号。展示的时候我会对余号做不同样式处理余号大于 5 显示正常色少于等于 5 显示橙色等于 0 则按钮置灰不可点击。这些细节不需要复杂的图表库几行计算就能搞定但用起来会非常直观。提交预约的逻辑要注意重复点击问题。前端在按钮点击后立即禁用按钮等到后端响应后再恢复。虽然数据库已经有了唯一索引和条件扣减做兜底但提前在前端挡掉重复请求体验会好很多。5.3 axios封装与token鉴权axios 封装是前后端联调的基础建议独立放在utils/request.js里。核心逻辑就两件事请求头带上 token响应里统一判断业务状态码。import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000, }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data // 假设后端统一返回 { code: 200, data: ..., message: ... } if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) location.href /login } ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } ) export default request后端拦截器校验 token 时注意两个细节一是放行登录注册接口二是对未登录请求返回统一的 401 状态码不要返回 200 但塞一个“未登录”的业务码否则前端拦截器很容易和业务层混淆。我见过不少同学前后端各写各的最后联调时 jwt 校验不过页面跳转逻辑乱成一锅粥其实问题就出在状态码约定不清晰。6. 跑通项目时最容易踩的五个坑6.1 MySQL时区和Java日期类型的错位这是一个非常经典的坑。如果你使用 MySQL 8.0连接串里没指定serverTimezone可能会看到类似“The server time zone value CST is unrecognized”的报错。解决方式就是在application.yml里把连接参数写全spring: datasource: url: jdbc:mysql://localhost:3306/hospital_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver另一个容易忽略的点是 Java 层日期类型的选择。排期表里的work_date对应LocalDate创建时间对应LocalDateTime不要用java.util.Date。使用LocalDate/LocalDateTime配合 MyBatis-Plus 处理起来更顺手也不容易出现时区偏移。6.2 跨域与代理配置前端联调的第一道坎前后端分离项目前端跑在 5173Vite或者 8081后端跑在 8080浏览器直接请求后端接口会触发跨域。最简单的解决方式是利用 Vite 的代理配置把/api开头的请求转发到后端// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ), }, }, }, })这个方案比后端开启 CORS 更干净也贴近真实项目的前后端分离部署思路。如果你决定在后端配 CORS记住前端如果用了代理两者同时生效也不会冲突但配置不当反而会出现“本地能访问打包部署后接口失效”的问题。我的建议是前端走代理后端统一接口前缀即可。6.3 Node 17/18与低版本Vite的OpenSSL冲突如果你本机装的是 Node 17 以上版本跑 Vite 4 或者更早版本时可能会看到这个错误Error: error:0308010C:digital envelope routines::unsupported这个原因是 Node 新版本默认 OpenSSL 调整了算法老版本 Vite 不兼容。解决办法有两个一是升级 Vite 到较新版本二是临时设置环境变量export NODE_OPTIONS--openssl-legacy-providerWindows PowerShell 下对应$env:NODE_OPTIONS--openssl-legacy-provider但更推荐的办法是安装 nvm 管理 Node 版本整个项目统一使用 LTS 版本后续再开新项目也不容易踩这一类环境问题。6.4 数据库初始化顺序带来的外键报错很多同学的数据库脚本是一口气全部执行如果表之间有外键关系就容易出现“表不存在”或者“外键缺失”的报错。我的习惯是不建外键约束而是在应用层维护关联关系。比如appointment表里的doctor_id、schedule_id我用普通索引加业务校验不用foreign key。这样做的好处是导入数据时不用考虑建表顺序而且后续做软删除和分库分表时不会被外键束缚。如果你就是想用外键把脚本执行顺序整理成sys_user→department→doctor→schedule→appointment先建主表再建从表。6.5 Maven依赖下载慢和端口被占用国内网络环境下Maven 中央仓库下载慢是常态。解决方式是在~/.m2/settings.xml里配置阿里云镜像mirror idaliyunmaven/id mirrorOfcentral/mirrorOf urlhttps://maven.aliyun.com/repository/public/url /mirror端口被占用也是高频问题。启动后端时如果 8080 被占用直接使用netstat -ano | findstr 8080找到占用进程后杀掉即可。前端 Vite 默认端口 5173 被占用时可以在配置里改成port: 5174。这些小事虽然不复杂但在答辩前突然卡住心态很容易崩提前看一眼能省很多麻烦。7. 课设升级为毕设亮点三个值得做的增强和答辩要点7.1 用定时任务做“超时未支付自动取消”挂号场景一般会要求订单在一定时间内完成支付比如 30 分钟内未支付就自动释放号源。实现方式可以依赖 SpringBoot 自带的ScheduledComponent public class AppointmentTimeoutTask { Scheduled(fixedDelay 60000) // 每分钟执行一次 public void cancelExpiredOrders() { ListAppointment expiredList appointmentMapper.selectExpiredPending(LocalDateTime.now().minusMinutes(30)); for (Appointment item : expiredList) { // 修改状态 回补号源 appointmentMapper.updateStatus(item.getId(), AppointmentStatus.CANCELED.getCode()); scheduleMapper.addQuota(item.getScheduleId()); } } }这个功能不大但很能体现工程思维。相比单纯的 CRUD定时任务、状态流转、号源回补是一条完整的闭环逻辑答辩时讲得清楚老师会觉得你有思考深度。7.2 使用Excel导出挂号清单尽量不图省事按医生导出某天预约患者清单是很多课设里容易被忽略的加分项。实现上可以使用 Apache POI 或者 Alibaba EasyExcel推荐后者API 更简洁内存占用也低public void exportAppointmentList(Long scheduleId, HttpServletResponse response) throws IOException { ListAppointmentExportVO list appointmentMapper.selectExportList(scheduleId); EasyExcel.write(response.getOutputStream(), AppointmentExportVO.class) .sheet(挂号清单) .doWrite(list); }前端点击一个按钮调用接口下载 Excel 文件。需要注意的是下载接口不能用 axios 的正常拦截器处理因为返回的是二进制文件流得单独处理 responseType。这个“坑”本身就是很好的经验展示的时候还可以讲一讲。7.3 答辩时最常被问到的三个技术问题第一个问题号源超卖你是怎么解决的回答思路数据库更新语句带条件update schedule set remain_quota remain_quota - 1 where id ? and remain_quota 0利用行锁保证并发安全业务层再配合事务把判断和扣减合并成一步。第二个问题为什么订单表里不用外键回答思路应用层维护关联关系减少数据库锁竞争提高扩展性同时方便软删除。这个答案能体现你对数据库设计的理解层次。第三个问题如果上线部署你会考虑哪些改进回答思路引入 Redis 缓存排期数据、使用消息队列削峰、增加接口幂等性设计、前端增加分布式部署。答出两到三个方向就足够不用说得太满避免被追问实现细节时露怯。最后给所有准备拿这套系统做课设或者毕设的同学一句实话源码可以复制思路很难复制。你可以飞快地把项目跑起来但如果你能在这三个文件里多花点时间——schedule表的结构设计、预约 service 的事务方法、前端 axios 封装逻辑——这个项目你就真正吃透了。答辩时只要你敢说自己亲手改过其中的某一块代码说话的感觉是完全不一样的。祝你们都能顺利过关。
