先说明一下,这套系统的技术含量不在于功能多复杂,而在于它把前后端分离、权限控制、数据一致性这些常见需求拧成了一股绳。你拿它做毕设、做课设、甚至改造成企业内部预约平台,都能直接落地。
1. 整体设计与技术选型
1.1 核心需求拆解
医院挂号预约管理系统,拆开看就是两条业务线:一条是 C 端患者的预约流程,另一条是 B 端管理员的维护流程。患者侧核心动作是“搜医院、选科室、看排班、约号、查记录”,管理员侧核心动作是“维护医院与科室、维护医生与排班、处理号源与停诊”。中间还夹着一个医生角色,医生可以看到自己的排班和患者预约列表,但不能改号源。
所以系统必须有三种角色:管理员、医生、患者。权限控制是这类的核心难点。很多新手做这类系统时,习惯把角色判断写在每个接口里,比如if(role == 1)这种写法,后期改一处需求就要翻一堆代码。正确做法是在拦截器或过滤器里统一鉴权,接口上只声明所需角色,业务代码里完全不关心角色判断,只关心“这个用户能不能操作这行数据”。
一个标准的前后端分离架构,SpringBoot 只负责输出 JSON,Vue 负责页面渲染和路由控制。核心配置我放在下面:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0MyBatis Plus 的逻辑删除必须从第一天就加上,不然后面做用户停用、医生离职这种功能会非常痛苦。map-underscore-to-camel-case必须打开,否则数据库doctor_name映射不到doctorName字段,前端拿到一堆 null。
1.2 技术栈选型背后的取舍
后端用 SpringBoot 2.7.x 而不是 3.x。原因很简单:3.x 基于 Jakarta EE,很多老教程里的写法不通用,你自己用没问题,但网上能查到的资料八成都是 2.x。数据库选 MySQL 8.x,ORM 层直接用 MyBatis Plus,它把分页、条件构造、逻辑删除全封装好了,比 JPA 更适合这种 CRUD 密集型业务。
权限这块,我没用 Shiro 也没用 Spring Security,而是自己基于 JWT 写了一套轻量拦截器。不是说不该用框架,而是这个项目的权限模型太简单了——三种角色,每个接口固定角色,JWT 里塞一个role字段就够。引入 Spring Security 意味着你要处理它那张庞大的过滤器链,还要配UserDetailsService、PasswordEncoder,对项目本身来说属于过度设计。
前端用 Vue2 + Element UI。为什么不是 Vue3?不是 Vue3 不好,而是 Vue2 的中文资料和组件库生态太成熟了,毕设场景下你遇到的每个报错都能在网上搜到答案。Element UI 的表格、表单、弹窗、日期选择器正好覆盖管理后台的全部需求。
前端目录结构我是按模块拆的,不是按文件类型拆的:
src/ api/ // 按业务模块封装axios请求 user.js hospital.js schedule.js appointment.js assets/ components/ router/ index.js permission.js // 全局前置守卫 store/ views/ login/ patient/ // C端页面:首页、排班、预约、我的记录 admin/ // B端页面:医院管理、医生管理、排班管理 doctor/ // 医生端页面:我的排班、我的患者 utils/ request.js // axios封装 auth.js // token存取这个结构最直观的好处是:C 端和 B 端页面物理隔离,改一端不会误伤另外一端。后端接口也按这个思路分模块:controller层里同样分patient、admin、doctor三个包,业务边界清晰,联调时也好定位问题。
2. 数据库设计与核心表结构
2.1 设计思路
数据库是这类系统的地基。表设计错了,后面写接口、写页面全是歪的。我当时设计了九张表:user、hospital、department、doctor、schedule、appointment、time_slot、category,外加一张系统日志表。
核心关联关系是:一个医院有多个科室,一个科室有多个医生,一个医生在多个时段有排班,一个排班被多个患者预约。这个逻辑链条必须先从脑子里过一遍,再动手建表。
说个常见的错误设计:有人把医生直接挂在科室下,科室挂在医院下,看起来没问题,但实际业务里一个医生可能同时在多个医院坐诊(多点执业),所以doctor表里应该存hospital_id,而不是通过科室间接关联。这个细节直接决定了你以后扩展功能时要不要重构表。
2.2 核心表结构说明
用户表这个没什么悬念,主键、用户名、密码(BCrypt 哈希)、角色、手机号、状态。医生表是重点,它除了关联医院和科室,还要存title(职称)、intro(简介)、avatar,这些字段直接决定 C 端展示页面的丰富程度。排班表schedule是核心中的核心:
CREATE TABLE `schedule` ( `id` bigint NOT NULL AUTO_INCREMENT, `doctor_id` bigint NOT NULL COMMENT '医生ID', `work_date` date NOT NULL COMMENT '出诊日期', `time_slot_id` bigint NOT NULL COMMENT '时段ID', `total_count` int NOT NULL DEFAULT 0 COMMENT '总号源', `remain_count` int NOT NULL DEFAULT 0 COMMENT '剩余号源', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1正常 0停诊', `deleted` tinyint NOT NULL DEFAULT 0, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_doctor_date_slot` (`doctor_id`,`work_date`,`time_slot_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表';uk_doctor_date_slot这个唯一索引非常关键。它保证同一个医生在同一天、同一个时段只能有一条排班记录。如果没用唯一索引,程序里就得先查再插,并发一高就会产生重复排班,数据全乱。数据库层面的约束是你最后一道防线,程序里的判断都可能有漏洞,但唯一索引不会。
号源用total_count和remain_count两个字段,不要在预约表里去count(*)计算余号。医疗系统的号源必须要快照,因为预约表的数据量会越来越大,每次实时 count 会越来越慢。排班的remain_count就是当前状态的快照,预约成功就减一,取消预约就加一,简单直接。
2.3 时段表与日期生成策略
时段表time_slot是预先定义好的固定时间段,比如上午 08:00-08:30、08:30-09:00……下午 14:00-14:30,具体粒度看需求。排班不是按天生成的,而是按“日期 + 时段”生成。管理员操作时,选定一个医生、一个日期范围、勾选时段、填号源数量,后端循环生成多条排班记录。
生成排班之后会遇到一个问题:如果医生一周前就排了下周一的班,但下周一临时有事,需要停诊。这时候不是删掉排班数据,而是把status改成0(停诊)。已经预约该时段的人,要么在 C 端看到停诊提示并收到通知,要么在系统里做自动改签。真正的生产环境还有短信通知,但毕设场景里做一个站内消息提醒就足够了。
3. 后端核心接口与关键实现
3.1 JWT 登录与权限拦截
登录接口的逻辑不复杂:根据用户名查用户,用 BCrypt 验证密码,通过后生成 JWT。JWT 的 payload 里我放了三个字段:userId、role、name。不放太多信息,JWT 本身是 Base64 编码的,可以被解码出来,放敏感信息就是裸奔。
生成 JWT 的工具类用的是io.jsonwebtoken:jjwt,这个库从 0.9.1 版本开始 API 就有变化,网上教程经常混着讲。我用的是 0.11.5 版本,构建和解析方式如下:
// 生成token private String createToken(Map<String, Object> claims, String subject) { return Jwts.builder() .setClaims(claims) .setSubject(subject) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }注意如果你的 jjwt 版本是 0.11.x,signWith传的是SecretKey对象而不是字符串。字符串密钥长度不能小于 256 位(32 字节),不然启动会报Key length must be at least 256 bits。这个坑我踩过,当时密钥写了个"my-secret",启动直接崩。
拦截器里要做两件事:一是校验 token 有效性并解析出用户信息放入ThreadLocal,二是根据接口上的@RequireRole注解做角色校验。业务代码里只需要从ThreadLocal里拿当前用户,不需要再解析 token。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行OPTIONS预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); // 解析token,失败则抛出未登录异常 // 解析成功后将用户信息存入UserContext } }这里的OPTIONS预检请求必须放行,否则前后端分离部署时浏览器跨域请求会直接 403。我在联调阶段因为漏了这行代码,前端所有非简单请求全部失败,排查了半天才看到控制台明明有请求但后端日志里根本没进来。
3.2 排班管理与号源生成
排班接口是管理端的核心。设计上要支持两种录入方式:单日排班和批量排班。批量排班的场景是医生固定每周一、三、五上午出诊,管理员选好重复规则,后端一次生成多条。
批量的实现逻辑不复杂,核心是“先查重再插入”。我用了双重校验:插入前用countByDoctorIdAndDate判断当天该时段是否已有排班,插入时再靠数据库的唯一索引兜底。如果出现重复插入,捕获DuplicateKeyException并返回友好提示。直接让异常抛给前端显示 500 页面,用户根本看不懂。
号源数量建议细化到住院号、复诊号、初诊号,或者直接就是普通号、专家号。最简单的做法是给department表加一个default_count字段,管理员创建排班时自动带出默认号源数,不用每次手敲。
C 端查询排班时,只查status = 1 且 remain_count > 0 且 work_date >= 今天的记录。用户看到的界面是一个星期几的排班表格,可以按日期翻页。对应 SQL 用 MyBatis Plus 的QueryWrapper就能搞定,不需要手写 XML:
LambdaQueryWrapper<Schedule> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Schedule::getDoctorId, doctorId) .eq(Schedule::getStatus, 1) .gt(Schedule::getRemainCount, 0) .ge(Schedule::getWorkDate, LocalDate.now()) .orderByAsc(Schedule::getWorkDate);3.3 在线预约的事务与并发控制
预约接口是整个系统中并发压力最大、最容易出错的地方。核心流程是:校验患者是否登录,校验排班是否存在且未停诊,校验剩余号源,如果是复诊还要校验是否已在该医生这里约过,然后创建预约记录,最后扣减号源。
这四步如果按顺序请求再逐条执行,并发一高就会超卖。两个用户同时看到号源还剩 1 个,同时点预约,都通过了校验,结果都成功了,号源变成 -1。解决办法有两个方向:乐观锁或唯一索引兜底。
我是两个都用了。第一步用乐观锁扣减号源:
int updated = scheduleMapper.reduceRemainCount(scheduleId); if (updated == 0) { throw new ServiceException("号源已被约满,请选择其他时段"); }对应的 SQL 长这样:
UPDATE schedule SET remain_count = remain_count - 1, version = version + 1 WHERE id = #{id} AND remain_count > 0这里的关键是remain_count > 0这个条件。如果更新的影响行数为 0,说明这条排班的号源已经被抢光了,直接抛异常。该操作必须在开启事务的方法里执行,且在插入预约记录之前完成。这样的顺序能保证预约占用了号源,INSERT 失败时再回滚把号源加回来。
第二步是重复预约拦截。用户重复提交、刷新页面导致重复 POST,单靠前端按钮禁用挡不住恶意请求。我在appointment表上加了一个唯一索引:
UNIQUE KEY `uk_patient_schedule` (`user_id`, `schedule_id`)两个并发请求同时插入同一条记录时,只有一条能成功,另一条抛DuplicateKeyException,捕获后提示“您已预约过该时段”。这个方案简单粗暴,但极其有效。
预约成功后的返回值不仅要包含预约记录 ID,还要返回号源剩余数,前端拿到后立即刷新排班页面的余号显示。不然用户成功预约后,页面还停留在原来的余号数字上,刷新太慢会造成二次误操作。
3.4 Redis 缓存与验证码
Redis 在这个项目里承担三块职责:图形验证码存储、短信验证码存储(如果有)、热门医生排班数据的缓存。
图形验证码我用的Kaptcha生成图片,验证码文本存 Redis,key 是captcha:{uuid},过期时间 5 分钟。登录时前端把uuid和验证码一起提交,后端从 Redis 取出比对,比对完立即删除,防止同一验证码被重放。不要用 Session 存验证码,前后端分离后 Session 的跨域问题会让你怀疑人生。
热门科室的医生排班列表是可以缓存的。比如某个三甲医院的热门科室,患者搜索量大,每次查排班都打到 MySQL 不划算。我用 Redis 存一份“科室 + 日期 → 排班摘要”的缓存,过期时间 5 分钟,排班变化后手动删缓存。实际测试下来,首页接口响应时间从 300ms 降到了 80ms 左右,感知很明显。
4. 前端 Vue 实现的关键细节
4.1 路由与权限守卫
前端路由按角色拆成三个模块:patientRoutes、adminRoutes、doctorRoutes。不是一开始全部注册,而是在登录后根据角色动态添加路由。用 Vue Router 4(Vue2 对应 Router 3)的addRoutes或者addRoute方法逐个注册。
全局前置守卫是这套系统的安全闸门:
router.beforeEach((to, from, next) => { const token = getToken() if (!token) { if (to.path === '/login') return next() return next('/login') } if (to.path === '/login') return next('/') // 动态路由已注册,直接放行 if (hasRoutes) return next() // 首次进入,根据角色动态注册路由 generateRoutes().then(accessRoutes => { router.addRoutes(accessRoutes) next({ ...to, replace: true }) }) })核心逻辑是先判断有没有 token,有 token 再判断路由是否加载过。第一次进入系统时,后端返回该用户的角色和菜单权限,前端根据角色动态生成可访问路由表,然后用router.addRoutes注册。配合后端的接口鉴权,双保险。
一个很容易被忽略的坑是:动态路由注册后,直接next()可能会导致页面空白,因为当前要跳转的路由在addRoutes之前还不在路由表里。正确的做法是next({...to, replace: true}),让 router 重新匹配一次。
4.2 axios 封装与请求拦截
Axios 封装是前端所有接口请求的入口。我在utils/request.js里做了几件事:创建实例设置baseURL和超时时间、请求拦截器里从 Cookie 或 LocalStorage 取 token 塞到请求头、响应拦截器里统一处理错误码。
const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) service.interceptors.request.use(config => { if (getToken()) { config.headers['Authorization'] = getToken() } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message({ message: res.message, type: 'error' }) if (res.code === 401) { // token失效,清除登录状态并跳转登录页 } return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { // 处理未授权 } return Promise.reject(error) } )后端返回格式我是统一定义的:{ code: 200, message: "success", data: {...} }。前端只认 code 字段,HTTP 状态码基本只用 200 和 401 两种。这个约定要从前端到后端贯穿一致,否则联调时每个接口都在对字段,效率极低。
有个实战教训:响应拦截器里直接Message.error每次都弹窗,如果接口报错时前端又跳转路由,会出现一连串重复弹窗。我后面改成只对用户直接操作触发的请求弹窗,比如预约、取消这类 POST 请求;页面初始化时拉的列表数据报错则只 console 不打扰用户。
4.3 核心业务页面实现
C 端首页是搜索框 + 医院列表 + 科室入口。搜索支持医院名称模糊查询和科室名称查询。这里一定要防抖,用户在搜索框里连续输入时,节流到 300ms 后才发一次请求。不用做很复杂,直接一个debounce函数包一层就行。
排班日历页是整个系统前端最难写的部分。难点不在逻辑,而在 Element UI 的日期组件限制。排班日期只能选今天和未来 7 天的日期,所以要用picker-options禁用过去的日期:
pickerOptions: { disabledDate(time) { return time.getTime() < Date.now() - 8.64e7 } }选完日期后,同一日期下展示该医生的上午/下午各时段排班卡片,每张卡片显示时段、余号和“立即预约”按钮。余号为 0 或排班停诊时按钮置灰并显示对应文案。这里要用v-loading控制列表加载状态,不然切换日期时旧数据还在页面上闪过。
预约确认页要显示完整的预约信息:医院名称、科室、医生、职称、出诊日期、时段。用户确认后点击“提交预约”,成功后展示预约号,这个预约号我直接用预约表主键格式化生成。不要用自增 ID 裸奔展示,至少要拼个业务前缀,比如YY20250101001。
个人中心分为两块:我的预约列表和取消预约。取消预约逻辑在前端再加一次二次确认弹窗,防止误触。取消后后端把号源加回来,列表状态变成“已取消”。这块必须同步刷新列表的剩余号源数字,前端可以在取消成功的回调里重新拉一次排班详情。
5. 联调部署与问题排查实录
5.1 跨域配置与前后端联调
前后端分离开发时,最痛的问题是跨域。后端端口 8080,前端开发环境端口 8081,端口不同就是跨域。用 Vue CLI 的话,在vue.config.js里配代理,不要把浏览器访问的所有接口都直接打到后端:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这样前端请求/api/user/login会被代理到后端的/user/login,浏览器里看不到跨域身影。但后端 CORS 配置依然要加,因为生产环境前端静态文件可能通过 Nginx 代理转发。我后端加了一个全局 CORS 配置类,允许所有来源,生产环境限定域名。
联调阶段最容易遇到的坑是日期格式。后端返回LocalDateTime,默认序列化格式是2025-01-08T10:30:00,前端 Element UI 的el-date-picker显示会出问题。我在后端做了全局 Jackson 配置:
@Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; }还有一个前端时间显示问题:后端返回的2025-01-08字符串,在 JS 里 new Date 解析时,如果传的是连字符格式,Safari 会解析失败返回 Invalid Date。这是因为 Safari 不支持这种格式的严格解析。解决办法是前端统一用dayjs或自己写一个格式化函数,不要依赖原生 Date。
5.2 生产部署方案
生产环境我用的是经典三件套:Nginx 托管前端静态文件,反向代理后端接口,后端连 MySQL 和 Redis。Nginx 配置是核心:
server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location / { 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; } }try_files那行必须加。Vue 打包后是纯静态文件,直接访问根路径没问题,但刷新/patient/schedule这种深层路径时,Nginx 找不到对应的静态文件,会返回 404。try_files的作用是:找不到文件就回退到index.html,让 Vue Router 接管。
proxy_pass后面的斜杠也很关键。http://localhost:8080/末尾带斜杠,表示把/api/前缀替换为空;如果不带斜杠,后端接收到的路径会变成/api/user/login而不是/user/login。我在 Nginx 配置上吃过这个亏,前端登录页反复报 404。
后端启动时用nohup java -jar hospital-0.0.1-SNAPSHOT.jar > app.log 2>&1 &挂后台,注意加上-Dfile.encoding=UTF-8参数,防止日志中文乱码。用 SpringBoot Maven 插件打包前,先把前端 build 出来的dist目录拷到后端项目的static目录下,这样也可以做成单包部署。
5.3 常见问题速查表
| 现象 | 排查方向 | 解决办法 |
|---|---|---|
| 前端登录成功后刷新页面报 404 | Nginx 没有 fallback 到 index.html | 配置try_files $uri $uri/ /index.html |
| 跨域请求被拦截 | 后端未放行 OPTIONS 预检请求 | 拦截器里判断 OPTIONS 直接返回 true |
| 同一个患者重复下单成功 | 预约表缺唯一索引 | 加uk_user_schedule唯一索引并捕获重复键异常 |
| 排班接口查询慢 | 数据量大且缺少索引 | work_date和doctor_id分别建普通索引 |
日期显示为2025-01-08T10:30:00 | 后端 LocalDateTime 序列化格式问题 | 配置全局 Jackson 日期格式化 |
| 打包后 static 资源访问 404 | Maven 构建未包含前端静态资源 | 用 maven-resources-plugin 将 dist 拷入 resources |
| 端口被占用 | 本地已启动其他服务 | lsof -i:8080找到进程并杀掉 |
| Redis 连接失败 | Redis 服务未启动或密码错误 | 检查 Redis 状态,配置 spring.redis.password |
| Vue 页面白屏无报错 | 路由模式配置错误 | history 模式下需要 Nginx 配合;改用 hash 模式最保险 |
这里重点说说分布式下的数据一致性问题。我在写预约接口时遇到过“事务内调用外部服务导致事务失效”的情况。原因是 Spring 事务代理默认只在方法内部通过代理对象调用时才生效。如果同一个类里的方法 A 调用方法 B,B 上的@Transactional注解不会生效。正确的做法是把扣号源和创建预约记录的逻辑放在不同的 Service 类里,或者将实现拆到不同的方法中通过代理调用。我后来把预约流程拆成了AppointmentService.create()和ScheduleService.reduceCount(),用编程式事务TransactionTemplate兜底。
5.4 部署后必须验证的边界场景
部署完成后不能只测一遍主流程就完事,需要把边界场景过一遍。我整理了一份冒烟测试清单:
- 未登录时直接访问预约接口,后端必须返回 401,前端跳转登录页。
- 已预约的患者再次点击同一个排班的预约按钮,后端必须返回友好提示,不能报 500。
- 管理员停诊后,患者端排班日历不能再显示该时段,已预约记录必须展示停诊标记。
- 号源数量为 1 时,两个账号同时预约同一排班,最终只能成功一条,失败方提示号源不足。
- 取消预约后再重新预约同一排班,号源数量要正确恢复,预约记录不能被唯一索引误拦截。
这些场景覆盖了大部分会导致线上事故的逻辑漏洞。尤其是第二条和第三条,很多系统在正常流程里测不出来,但一旦发生就是用户直接投诉。
实际使用下来的体会
说句实在话,这套系统我自己独立开发到上线,前后花了大概三周时间。最花时间的不是写代码,而是处理各种“看似没问题但实际就是不对”的边界情况。比如号源超卖、重复下单、停诊后的用户通知,这些才是系统的灵魂。很多教程demo只教你写好 CRUD,但真正评价一个管理系统合不合格的,是数据倒灌、并发请求、异常降级这些极端场景下的表现。
给后面做类似项目的朋友一个建议:先花两天时间把数据库表结构和接口文档定死,再开始写前后端代码。我最早就是上来直接建表写代码,写了一半发现预约和排班的关联逻辑有歧义,被迫改了 4 张表的结构,浪费了整整一天。契约先行的成本最低,返工的代价最高。
如果后续想扩展这个项目,可以做三个方向:一是接入支付实现线上缴费,二是用 WebSocket 做排队叫号实时通知,三是把排班算法升级成基于医生工作量的智能排班。每一步都不难,但都会让项目从“课设水平”变成“能用的产品”。