1. 项目定位:为什么是"中医诊所"而非"医院"
很多人第一次看到这类题目,第一反应是"不就是个挂号系统吗,换个皮肤而已"。如果你也这么想,那这个项目基本白做了。我之所以强调这是"中医诊所"而非"三甲医院",是因为两者的业务模型差异极大,直接决定了系统的表结构、流程设计和代码实现方向。
综合医院的预约挂号,核心是"科室—医生—号源"三层关系,医生属于科室,患者选择科室再选择医生,号源按时段批量放出去,爽约率、加号、退号基本靠独立子系统处理,患者身份靠就诊卡或医保卡绑定。而中医诊所的典型场景是什么?一个诊所往往只有几位固定大夫,每位大夫一周出诊时间是相对固定的,患者复诊率高,且很大一部分老患者是"认人不认店"——他们挂的不是科室,是某个明确的大夫。更关键的是,中医讲究"首诊留档",初诊时大夫要写详细的四诊信息(望闻问切)、舌苔脉象记录,这些信息是后续复诊开方的重要参考,必须随患者档案长期保存,而不是像综合医院那样每次挂号都重新建一个"就诊事件"。
这就决定了你的数据模型不能直接套用网上那些"医院预约挂号系统"的表结构。网上能找到的开源项目,十个里有八个是抄的同一套教学设计模板:patient表、doctor表、appointment表、time_interval表,完事了。但放到中医诊所场景,至少要额外考虑几个问题:
- 患者和中医师之间是否存在长期绑定关系?复诊时如何快速识别?
- 不同大夫的出诊时段是否支持个性化配置?比如张大夫每周一三五上午,李大夫只有周二下午?
- 预约之后,医生能否在系统中看到这个患者的历史诊疗摘要?
我见过太多做这类课题的同学,花了大把时间在"抢票"逻辑上——锁号、并发扣减、Redis队列——却完全没有处理中医诊所最核心的需求。其实你的用户量根本到不了需要Redis锁的程度,一个MySQL事务加唯一索引就已经绰绰有余。把精力放在数据建模和业务流程的合理性上,才是这个题目真正的得分点。
所以这篇文章我会按照我实际做这个项目的思路来拆解:从业务建模讲起,再到核心预约流程的技术实现,然后是权限和病历模块的处理方式,最后是部署阶段那些踩过的坑。
2. 技术选型:SpringBoot为主体的架构考量
技术选型这件事,答案本身不复杂,但背后的理由值得掰扯清楚。这个题目用的是SpringBoot,那就从SpringBoot说起。
2.1 为什么SpringBoot是这类项目的"标准答案"
先别急着说"因为学校要求"或者"因为网上教程多"。SpringBoot能成为中小型管理系统的绝对主流,是有技术上的实际原因的。
第一,自动配置大幅降低了基础设施接入成本。你需要数据库连接池、ORM框架、JSON序列化、Web容器,在SpringBoot里就是一个starter依赖的事。对比传统Spring项目动辄几百行XML配置,SpringBoot把"约定优于配置"贯彻到了极致。开发效率层面的差距,对于三到五个月周期的毕设项目来说,是决定性的。
第二,生态成熟度无人能比。安全认证有Spring Security,接口文档有Swagger/knife4j,持久层有MyBatis-Plus,权限控制有Sa-Token,这些都是被无数生产项目验证过的组件。你不需要自己造轮子,组合起来就是一套稳定可靠的系统。做项目最怕的不是功能做不完,而是在某个冷门框架的bug上卡两周。
第三,内置Tomcat,打包即运行。这一点将直接关系到后面的远程部署。SpringBoot的spring-boot-maven-plugin可以把项目打成一个可执行的fat jar,服务器上只需要装一个JDK,java -jar一条命令就能起服务。相比传统war包需要配置外部Tomcat、调整server.xml,部署复杂度下降了一个量级。
至于前端,这个题目提到"源码+lw+远程部署",没限定前后端分离还是服务端渲染。我做的版本采用的是SpringBoot + Vue 3 + Element Plus的前后端分离架构。原因很简单:
- 预约挂号这类系统交互逻辑比较清晰,前后端分离可以让后端接口保持RESTful风格,同时前端组件化开发,页面的复用和维护都方便。
- 毕业设计和实际项目的评判重点在教学演示和功能完整性,前端用CDN引入Vue和Element Plus就能跑,不需要复杂的Node构建,也降低了答辩演示时的环境风险。
如果你的基础相对薄弱,直接用SpringBoot的Thymeleaf模板引擎做服务端渲染也不是不行,功能上一样能完成,但Vue版本会让你的项目在答辩时的技术呈现更丰富一档。建议有条件的都上Vue。
2.2 关键依赖清单与版本选择
版本选择上我有一个血泪教训:不要无脑追求最新版本。SpringBoot 3.x要求JDK 17+,并且javax.servlet换成jakarta.servlet,一部分老教程的代码会直接报错。对于做课程设计和毕业设计的同学,我的建议是用SpringBoot 2.7.x + JDK 8或者JDK 11,这是兼容性最稳的组合,网上的资料、博客、Stack Overflow的答案基本都覆盖到这个版本,遇到问题一搜就有答案。
我本次项目的依赖组合如下:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 1.8 / 11 | 不建议更高,避免兼容性问题 |
| SpringBoot | 2.7.18 | 2.x系列的最后一个维护版本,稳定性极佳 |
| MyBatis-Plus | 3.5.3 | 极大简化单表CRUD操作 |
| MySQL | 5.7 / 8.0 | 数据存储核心 |
| Redis | 可选 | 本期项目未使用,用数据库锁替代 |
| Sa-Token | 1.37.0 | 轻量级权限认证框架 |
| knife4j | 4.x(适配boot2.7) | 接口文档自动生成 |
| Lombok | 最新即可 | 减少样板代码 |
提示:如果服务器内存只有1G或2G,就别装Redis了,剩那点资源给MySQL和Java进程更实际。预约系统每天几百单的并发量,数据库完全扛得住。
2.3 分层架构与扩展性考量
项目采用经典的四层架构:
- Controller层:接收HTTP请求,参数校验,调用Service层,返回统一结果对象
Result<T>。 - Service层:业务逻辑的核心载体,比如"创建预约"的完整事务就在这里编排:检查号源、锁定号源、创建预约记录、发送通知(预留扩展点)。
- Mapper层(DAO层):基于MyBatis-Plus,单表CRUD全继承,复杂查询用
@Select注解写SQL,不单独建XML文件。 - 实体层(Model层):数据库表对应的实体类,加上DTO(数据传输对象)和VO(视图对象)做数据隔离。
这套架构的好处是职责清晰,答辩时被问到"如果要做微服务改造怎么切入"这类问题时,你至少能说清楚哪些逻辑属于领域核心、哪些属于接口适配,而不至于哑口无言。
3. 核心设计与数据建模:从中医诊所业务到表结构
这节是全文的重头戏。我见过太多人一上来就撸代码,结果做到一半发现表结构不合理,业务根本绕不过去,只好推倒重来。把表设计好,整个项目就完成一半了。
3.1 核心角色与业务边界梳理
先对系统涉及的角色做一个清晰的梳理:
| 角色 | 核心操作 | 注意点 |
|---|---|---|
| 患者(普通用户) | 注册登录、浏览医生排班、预约挂号、查询/取消预约 | 核心诉求是"快速找到要挂的大夫" |
| 诊所管理员 | 维护医生信息、配置排班、审核号源、查看统计数据 | 核心诉求是"可配置,不用改代码" |
| 中医师 | 查看预约列表、维护患者病历、填写四诊信息 | 核心诉求是"看得到患者的历史情况" |
| 系统管理员 | 用户管理、角色分配、系统日志 | 一般在课程设计中并入诊所管理员 |
动作就那几个:注册 → 选医生 → 看排班 → 预约 → 确认 →(医生侧)问诊 → 写病历。但业务边界要注意一个关键问题——"患者是否必须注册才能挂号"。
有的系统做了访客挂号,不登录也能约,但中医诊所要求首诊留档,基本属性决定了患者必须至少留下姓名、性别、联系方式这些基础信息,否则医生没法做复诊跟踪。所以我的设计是:必须注册登录才能预约,但注册只需要手机号+验证码(测试环境可以做成固定验证码),目的是降低使用门槛,同时保留患者身份的唯一性。
3.2 数据表设计详解
下面是本项目的核心数据表结构设计,我直接给出关键字段和设计理由。这不是网上随处抄的那套,而是我结合实际业务考虑后的最终版本。
表1:用户表(sys_user)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,雪花算法生成 |
| username | varchar(32) | 登录账号(手机号) |
| password | varchar(64) | BCrypt加密存储 |
| real_name | varchar(32) | 真实姓名 |
| phone | varchar(11) | 手机号 |
| gender | tinyint | 性别 |
| user_type | tinyint | 1-患者 2-中医师 3-管理员 |
| create_time | datetime | 创建时间 |
表2:中医师信息表(doctor_info)
注意,这里没有和sys_user合并,原因在于"登录用户"和"职业身份"本质上是两种维度的数据,一个用户未来可能既是患者又是医生(虽然业务上不常见),合并会导致字段冗余,而且医生的职称、擅长领域、简介这类属性放用户表里非常不合适。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 关联sys_user表 |
| title | varchar(32) | 职称:主任医师、副主任医师等 |
| specialty | varchar(255) | 擅长领域 |
| introduction | text | 个人简介 |
| years_of_practice | int | 从业年限 |
| status | tinyint | 1-出诊 0-停诊 |
表3:排班表(schedule)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| doctor_id | bigint | 关联医生 |
| work_date | date | 出诊日期 |
| start_time | time | 上午/下午时段开始时间 |
| end_time | time | 时段结束时间 |
| total_slots | int | 总号源数 |
| booked_slots | int | 已预约数 |
| status | tinyint | 1-正常 0-已停诊 |
这里的核心设计点是排班不是按时间点,而是按时间段+号源数。中医诊所的实际情况是,大夫上午出诊半天,患者来了按顺序看,大约能看20到30个人,没办法精确到"9:30-9:45这个号"。所以排班按半天做时间段,用total_slots和booked_slots控制剩余量,逻辑简单且符合诊所业务。
表4:预约记录表(appointment)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| appointment_no | varchar(32) | 预约编号(业务编号,如YY+日期+序号) |
| patient_id | bigint | 患者用户ID |
| doctor_id | bigint | 医生ID |
| schedule_id | bigint | 排班ID |
| appointment_date | date | 预约日期 |
| time_slot | varchar(32) | MORNING/AFTERNOON |
| status | tinyint | 0-待就诊 1-已完成 2-已取消 3-爽约 |
| queue_no | int | 排队序号(同一天同一时段内递增) |
| create_time | datetime | 创建时间 |
其中appointment_no为什么不直接用自增ID?因为给患者看的预约凭证如果是纯数字自增ID,容易暴露系统的预约总量,也不够正规。用"YY"前缀+日期+序号生成业务单号,观感专业,而且可以通过单号快速定位预约记录,实际开发中这是标配。
表5:病历表(medical_record)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| patient_id | bigint | 患者ID |
| doctor_id | bigint | 接诊医生ID |
| appointment_id | bigint | 关联预约记录 |
| sympton_desc | text | 主诉(症状描述) |
| tongue | varchar(64) | 舌象 |
| pulse | varchar(64) | 脉象 |
| diagnosis | text | 诊断(辨证分型) |
| prescription | text | 处方内容 |
| create_time | datetime | 创建时间 |
这张表是中医诊所和综合医院挂号系统的最大区别所在。综合医院的门诊病历是电子病历系统的职责,和挂号系统是两个系统;但在中医诊所的信息化方案里,病历和预约必须打通。医生接诊时打开一个患者,应该同时看到他以前在这家诊所的所有就诊记录和处方,这样复诊的时候才能做前后对比。
注意:病历数据属于医疗敏感信息,在你的课程设计文档和答辩PPT中,一定要体现"隐私保护"和"访问权限控制"的意识,这是加分项。后面我会专门讲权限控制怎么做。
3.3 为什么不在表里直接存"医生姓名"
做表设计的时候我坚持了一个原则:所有关联字段全部存ID,需要冗余展示字段时采用"查询时组装"而不是"写入时冗余"。比如appointment表里不存doctor_name和patient_name,而是在查询预约列表时通过JOIN关联查询。这样做的理由有三个:
- 数据一致性。如果医生改了姓名(换了个系统中的显示名),历史预约记录里冗余的旧名字就成了脏数据。
- 表结构更清晰,业务边界明确,各表各司其职。
- MyBatis-Plus支持
@TableField(exist = false)标注非表字段,配合关联查询组装数据并不算复杂。
用一条LEFT JOIN就解决的事,不值得用一堆冗余字段换。
4. 预约流程的并发控制:从乐观锁到唯一索引
预约系统的核心难点,在于"同一时间很多人抢同一个号"时,如何保证数据不出错。这个场景要在课程设计里做出亮点,不必上分布式锁,但要能讲清楚你用的是什么方案,以及为什么这个方案够用。
4.1 问题的本质
假设某个医生某天上午放出30个号,现在只剩最后1个,同时有两个患者点击"预约"。如果程序不做并发控制,可能两个请求同时读到booked_slots=29,然后同时执行UPDATE schedule SET booked_slots=30,最后两个人都显示预约成功,但实际数据变成了31甚至32。这就是典型的超卖问题,和电商抢购是同一个逻辑。
解决方案有很多种,按实施复杂度从小到大排:
- 悲观锁(
SELECT ... FOR UPDATE):写操作前就把这一行锁住,其他事务只能等待。 - 乐观锁(版本号或条件更新):更新时校验版本号,不一致则更新失败。
- 唯一索引:在预约表上对
(schedule_id, patient_id)建唯一索引,同一患者对同一排班只能有一条有效预约。 - Redis分布式锁:利用Redis的原子性实现互斥,适合高并发场景。
对于诊所预约系统这种每天几十到几百次预约请求的系统,用方案2+3的组合已经完全够用,而且逻辑简单,易于解释。用Redis锁其实是杀鸡用牛刀,还引入一个必须安装Redis的部署依赖,不划算。
4.2 乐观锁的具体实现
MyBatis-Plus对乐观锁的支持非常友好。先在Schedule实体类的version字段上加上@Version注解:
@Version @TableField(fill = FieldFill.INSERT) private Integer version;然后在配置类中注册乐观锁插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }之后执行scheduleMapper.updateById(schedule)时,MyBatis-Plus会自动在SQL里拼接WHERE version = ?,如果更新影响行数为0,说明数据已经被别人修改过了,操作失败。
预约核心逻辑的Service层代码大致是这样的:
@Transactional(rollbackFor = Exception.class) public Result createAppointment(AppointmentCreateRequest request) { // 1. 获取排班信息 Schedule schedule = scheduleMapper.selectById(request.getScheduleId()); if (schedule == null) { return Result.error("排班不存在"); } if (schedule.getStatus() != 1) { return Result.error("该排班已停诊"); } // 2. 检查是否已预约过(唯一索引兜底,这里做业务判断) LambdaQueryWrapper<Appointment> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(Appointment::getScheduleId, request.getScheduleId()) .eq(Appointment::getPatientId, request.getPatientId()) .in(Appointment::getStatus, Arrays.asList(0, 1)); Long count = appointmentMapper.selectCount(queryWrapper); if (count > 0) { return Result.error("您已预约过该时段,请勿重复预约"); } // 3. 剩余号源判断 if (schedule.getBookedSlots() >= schedule.getTotalSlots()) { return Result.error("号源已约满"); } // 4. 乐观锁扣减号源 schedule.setBookedSlots(schedule.getBookedSlots() + 1); int updateRows = scheduleMapper.updateById(schedule); if (updateRows == 0) { return Result.error("号源已被抢完,请刷新后重试"); } // 5. 生成预约记录 Appointment appointment = new Appointment(); appointment.setAppointmentNo(generateAppointmentNo(schedule.getWorkDate())); appointment.setPatientId(request.getPatientId()); appointment.setDoctorId(schedule.getDoctorId()); appointment.setScheduleId(schedule.getId()); appointment.setAppointmentDate(schedule.getWorkDate()); appointment.setTimeSlot(schedule.getTimeSlot()); appointment.setQueueNo(schedule.getBookedSlots()); // 排队号就是当前已预约数 appointment.setStatus(0); appointmentMapper.insert(appointment); return Result.success(appointment); }这里有几个设计细节需要特别说明:
@Transactional保证了整个方法在同一事务中执行。如果第5步插入失败,第4步的号源更新也会回滚,不会出现"号扣了但预约没建"的数据不一致。- 乐观锁的判断归根到底靠的是
updateById的返回值,如果返回0就要主动抛出异常或返回失败,不能继续往下走。 - 排队序号
queueNo直接取当前bookedSlots的值,逻辑上是合理的——第几个预约成功就是第几号。这样患者看到的排队号是连续的,观感也好。
4.3 唯一索引兜底
光靠乐观锁还有一个漏洞:同一个患者的两个请求并发进来,两者都通过了第2步的重复校验,然后都走到第4步。这时候乐观锁会保证只有一个请求更新成功吗?未必。因为两条更新语句对应的是同一行排班记录,MyBatis-Plus的乐观锁是基于版本号的,两个线程读到同样的版本号,第一个更新成功,第二个更新影响行数为0,会被拦截。但这里前提是bookedSlots和version是同一行记录,所以乐观锁本身是可以拦住超发的。
不过为了做到"系统级的兜底",我还在appointment表上建了联合唯一索引:
ALTER TABLE appointment ADD UNIQUE KEY uk_schedule_patient (schedule_id, patient_id, status);等等,这个索引有个问题:status在运行中会从0变到1、变到2,如果索引包含了status,同一个患者取消预约后想再约同一个时段,由于原记录status=2已经存在,新插入status=0的记录时,唯一索引会冲突。所以这个索引不能简单加。
正确的做法是:在业务代码里用"状态为0或1的记录只能存在一条"来保证,而唯一索引只建在schedule_id和patient_id上也不合适,因为取消后还想重约。所以在最终设计里,我选择不建联合唯一索引,而是依赖业务校验 + 乐观锁。理由很简单:这个系统的并发量根本到不了需要用数据库索引兜底的量级,而业务校验配合事务已经足够可靠。
注意:做课程设计的时候,答辩老师很可能问到"为什么这里不加唯一索引"。你如果回答"防止并发下重复预约",那就要想清楚上面的问题。更好的答案是带着思辨地讲清楚——"我评估了并发量级和业务重约场景,最终用事务+乐观锁来保证一致性"。这比背一个标准答案更能体现工程思维。
4.4 取消预约与号源释放
有预约必然有取消。取消操作的逻辑是:更新预约状态为"已取消",同时把排班的booked_slots减1。这里同样要知道一个并发细节:取消操作和新增预约操作如果同时对同一个排班执行,理论上也可能产生数据不一致。不过在事务层面,两行UPDATE同一行记录,数据库的行锁会保证它们的串行执行。也就是说,先执行取消再执行新增,或者反过来,最终booked_slots都会维持在一个正确的值上。所以这里不需要额外加锁。
取消预约代码:
@Transactional(rollbackFor = Exception.class) public Result cancelAppointment(Long appointmentId, Long userId) { Appointment appointment = appointmentMapper.selectById(appointmentId); if (appointment == null || !appointment.getPatientId().equals(userId)) { return Result.error("预约记录不存在"); } if (appointment.getStatus() != 0) { return Result.error("当前状态不可取消"); } // 更新预约状态 appointment.setStatus(2); appointmentMapper.updateById(appointment); // 释放号源 Schedule schedule = scheduleMapper.selectById(appointment.getScheduleId()); if (schedule != null && schedule.getBookedSlots() > 0) { schedule.setBookedSlots(schedule.getBookedSlots() - 1); scheduleMapper.updateById(schedule); } return Result.success(); }这里有个小坑:scheduleMapper.updateById(schedule)如果启用了乐观锁,schedule对象是从数据库查到的最新的,版本号是最新的,所以更新不会失败。但如果查询之后隔了很久才做更新,期间有其他人改了这行数据,乐观锁就会导致取消失败。考虑到取消操作流程很短,这个概率极低,而且即使失败,提示用户重试即可,不影响整体体验。
5. 权限控制与数据隔离:从登录到三角色访问
权限控制是管理系统的地基,几乎是所有课程设计答辩的必问题。"你这个系统,患者能看到医生的界面吗?"答案必须是"不能"。Spring Security + JWT或者Sa-Token都是可行方案。我选了Sa-Token,理由后面会说。
5.1 轻量级方案:为什么弃用Spring Security
Spring Security功能强大,但学习曲线是真的陡。它的过滤器链、认证管理器、ProviderManager机制,新手理解起来非常吃力。而Sa-Token的设计理念就是"简单,再简单一点"——登录就是StpUtil.login(userId),获取当前登录用户就是StpUtil.getLoginId(),权限校验就是@SaCheckRole("admin")一个注解。对于中小型系统,尤其是课程设计项目,这种极简API带来的效率提升是立竿见影的。
项目引入Sa-Token后:
sa-token: token-name: satoken timeout: 2592000 is-concurrent: true is-share: true token-style: uuid登录接口核心代码:
@PostMapping("/login") public Result login(@RequestBody LoginRequest request) { LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SysUser::getUsername, request.getUsername()); SysUser user = userMapper.selectOne(wrapper); if (user == null || !BCrypt.passwordEncoder().matches(request.getPassword(), user.getPassword())) { return Result.error("用户名或密码错误"); } if (user.getStatus() != 1) { return Result.error("账号已被禁用"); } StpUtil.login(user.getId()); // 将用户角色信息写入会话 StpUtil.getSession().set("userType", user.getUserType()); // 构造登录返回数据 LoginResponse response = new LoginResponse(); response.setToken(StpUtil.getTokenInfo().tokenValue); response.setRealName(user.getRealName()); response.setUserType(user.getUserType()); return Result.success(response); }5.2 基于角色的访问控制(RBAC)实现
系统的角色分成三类:患者、中医师、管理员。我采用了RBAC模型,但在这个规模下不需要做"角色-菜单-权限"三级表,直接在路由和接口层面做拦截:
- 后端拦截:在
WebMvcConfig中注册Sa-Token拦截器,对/api/appointment/**、/api/medical-record/**等路径要求登录,再用@SaCheckRole注解控制角色范围。 - 前端控制:Vue Router中配置路由meta信息,例如
meta: { roles: ['patient'] },通过后端返回的用户角色动态生成可访问路由。
后端接口的权限控制案例:
@SaCheckRole("admin") @PostMapping("/schedule") public Result createSchedule(@RequestBody ScheduleRequest request) { // 管理员新建排班 } @SaCheckRole("doctor") @GetMapping("/appointment/list") public Result listAppointments(@RequestParam Long scheduleId) { // 医生只能看自己的排班预约列表 }这里需要注意一个细节:医生的数据无论如何都要做归属校验。比如医生查看预约列表时,Service层要判断当前登录的医生ID和排班的doctorId是否一致。仅仅依赖前端隐藏按钮是不够的——用户完全可以绕过前端直接调API,后端不校验就是越权漏洞。这是答辩时老师最喜欢深挖的一个点。
5.3 病历数据的隔离与隐私保护
病历表medical_record包含了患者的症状描述、舌苔脉象和处方信息,属于高度敏感的医疗数据。在权限控制上,我做了以下设计:
- 只有接诊医生本人和系统管理员能够查看患者的病历详情。
- 患者本人可以查看自己的病历摘要,但不能查看其他任何人的病历。
- 在SQL查询层面强制拼接当前登录用户的ID作为过滤条件,从根源上杜绝通过遍历ID查看他人病历的可能。
病历读取逻辑:
@SaCheckLogin @GetMapping("/detail/{appointmentId}") public Result getRecordDetail(@PathVariable Long appointmentId) { // 获取当前登录用户 long loginId = StpUtil.getLoginIdAsLong(); MedicalRecord record = medicalRecordMapper.getByAppointmentId(appointmentId); if (record == null) { return Result.error("病历不存在"); } // 数据归属校验 boolean isDoctor = record.getDoctorId() == loginId; boolean isPatient = record.getPatientId() == loginId; boolean isAdmin = (Integer) StpUtil.getSession().get("userType") == 3; if (!isDoctor && !isPatient && !isAdmin) { return Result.error("无权访问该病历"); } return Result.success(record); }这套逻辑虽然朴实,但涵盖了"认证、授权、数据级权限"三个层次,答辩时按这个思路讲,老师一般不会再追问太多。
6. 管理后台的设计:排班配置与数据统计
管理后台往往是课程设计中最容易被忽视、但实际工作量最大的模块。患者端的预约流程做完,管理端的排班管理、医生管理、统计报表还是得一个一个补。这一节说几个我认为值得花时间的点。
6.1 排班配置页面
管理端排班的核心痛点在于批量操作。比如要给张医生配置未来一个月每周一三五上午的出诊计划,如果让管理员一天一条记录慢慢地填,体验非常差,也不符合实际诊所的使用场景。
我的做法是:在管理端提供一个**"按周模板批量生成"**的功能。配置内容包含:
- 选择医生
- 选择星期(周一、周三、周五)
- 选择时段(上午8:00-12:00)
- 填入每个时段号源数(比如上午30个)
- 选择起始日期和持续周数
后端通过循环遍历日期,批量生成schedule记录。同时做好两件事:判断日期是否已经存在该医生的排班(存在则跳过或者提示),以及排班日期不能早于当前日期。
6.2 预约统计的SQL实现
管理后台需要展示基础的数据统计,包括:
- 每天预约数量(按日柱状图)
- 各医生的预约量排名(用于了解哪些医生最受欢迎)
- 每个时段的上座率(已预约数/总号源数)
这些都是经典的分组聚合查询,用一条SQL就能解决。例如查询某时间段内各医生的预约人数排行:
SELECT d.real_name AS doctorName, COUNT(a.id) AS appointmentCount FROM appointment a LEFT JOIN doctor_info di ON a.doctor_id = di.user_id LEFT JOIN sys_user d ON di.user_id = d.id WHERE a.appointment_date BETWEEN #{startDate} AND #{endDate} AND a.status IN (0, 1) GROUP BY a.doctor_id ORDER BY appointmentCount DESC实现这些统计时我踩过一个坑:由于appointment表的数据量不大,直接用SQL统计完全没性能问题,但我一开始因为想展现自己的后端能力,引入了Spring Data Redis和缓存来存储统计数据,结果不仅代码复杂度高,数据还不实时(缓存过期了要刷新),反而把简单问题复杂化了。后来我直接把缓存层去掉,导出报表或者前端图表的数据直接查库,一次查询毫秒级返回,根本不需要缓存。这个教训分享给大家:先考虑直接查库,性能真正有瓶颈了再上缓存,不要为了用技术而用技术。
6.3 前端图表的选型
统计页面的图表,目前主流方案是ECharts和AntV G2。ECharts生态更成熟,用的人多,文档和Demo也全面,推荐直接用它。如果是Vue 3项目,用vue-echarts封装或者直接在组件里引入都可以,不复杂。
7. 部署实录:从jar包到远程服务器
远程部署是整个项目交付的最后一个环节,也是失误率最高的环节。很多人在本地跑得欢,一到部署就连环踩坑。我把我实际部署过程踩过的问题列出来,这些坑几乎每个做毕设的同学都会遇到。
7.1 环境准备:服务器上要装什么
远程服务器我选的是Linux(CentOS 7或Ubuntu 20.04皆可),1核2G内存的配置就够跑这个项目。服务器上需要安装的环境如下:
| 软件 | 版本 | 安装方式 |
|---|---|---|
| JDK | 1.8 | yum/apt 或上传tar包解压 |
| MySQL | 5.7/8.0 | 在线安装或Docker |
| Nginx | 最新稳定版 | 用于反向代理前端静态文件 |
部署的整体流程是:
- 本地
mvn clean package打包SpringBoot项目,生成target/clinic-appointment.jar。 - 将jar包通过
scp命令或宝塔面板上传到服务器。 - 在服务器上创建MySQL数据库,导入初始化SQL脚本。
- 修改配置文件的数据库连接地址为服务器的IP,重新打包或通过外部配置文件覆盖。
nohup java -jar clinic-appointment.jar --spring.profiles.active=prod &启动服务。- 前端项目
npm run build后,将dist目录上传到Nginx的html目录,配置Nginx反代到后端8080端口。
7.2 部署踩坑记录
这里记录几个典型问题,建议挨个对照。
踩坑一:打包后配置文件里的数据库连接还是本地地址
这是新手最高频的问题。application.yml里写死了localhost:3306,部署到服务器后连不上数据库。解决方案有两种:一是打包前手动改成服务器的IP地址,二是更优雅的方式——使用SpringBoot的Profile机制。application.yml里放公共配置,application-dev.yml和application-prod.yml分别放不同环境的差异化配置,启动时通过--spring.profiles.active=prod指定使用哪个Profile。这样本地开发和远程部署只需要启动命令不同,代码不用改。
踩坑二:MySQL 8.0的驱动和连接配置变了
MySQL 8.0的驱动类从com.mysql.jdbc.Driver改成了com.mysql.cj.jdbc.Driver,同时需要在JDBC URL里追加时区参数:
url: jdbc:mysql://localhost:3306/clinic_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai不配serverTimezone=Asia/Shanghai,启动时会报The server time zone value is unrecognized的异常。这个坑基本人人都会踩。
踩坑三:防火墙和云安全组没放行端口
服务在服务器本机用curl localhost:8080测试是通的,但外部访问却超时。排查思路:先看服务是否启动成功(ps -ef | grep java),再看监听端口(netstat -lnpt | grep 8080),确认没问题后检查防火墙(firewall-cmd --list-ports)——如果用的是阿里云、腾讯云、华为云的服务器,还要去控制台的安全组检查是否放行了8080端口。这个问题一般能卡住新手一整天。
踩坑四:Nginx配置反向代理时丢失了请求头
前后端分离部署时,前端请求/api路径要反代到后端http://localhost:8080,但如果Nginx配置里少了proxy_set_header Host $host;这行,后端获取的Host会是localhost:8080,可能导致一些依赖Host的接口调用失败(比如Sa-Token的Cookie写入、Swagger的接口地址生成)。
推荐的后端接口反代配置:
server { listen 80; server_name your-domain-or-ip; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }7.3 进程守护与日志排查
线上服务不能一个java -jar裸跑,终端一关服务就没了。推荐用nohup命令配合&:
nohup java -jar clinic-appointment.jar --spring.profiles.active=prod > logs/app.log 2>&1 &如果要进阶一点,可以用systemd写一个service文件,实现开机自启和崩溃自动重启。网上有很多模板,这里就不展开了。
遇到服务启动失败,查看日志是最快的定位手段:
tail -200f logs/app.log看日志的典型思路是往回翻堆栈信息,找到Caused by或APPLICATION FAILED TO START的部分,对症处理。常见的原因无非就是:数据库连接失败、端口被占用、配置的Redis连不上(如果你用了)、依赖的Bean不存在。
8. 测试与交付:模拟数据、演示要点与说明文档
项目的代码写完不代表结束。对于毕业设计类的项目,你还需要准备好演示用的数据、测试用例和一套"能讲出来"的完整叙述,这在答辩中非常加分。
8.1 造数据的艺术
一个空荡荡的系统演示起来完全没有说服力。你需要提前准备一套覆盖常见业务场景的模拟数据:
- 4-5位中医师,其中至少1位"热门医师"(号源经常约满),一位"普通医师"(有空余号),一位"停诊医师"。
- 10-20个患者账号,部分患者有历史预约记录和病历记录。
- 未来两周的排班数据,覆盖上午、下午不同时段。
- 已有的预约记录中包含各种状态:待就诊、已完成、已取消。
这些数据直接通过Navicat手动插入也行,写一个>