ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

前后端分离医院后台管理系统实战:从数据库设计到部署上线全流程解析

2026/10/6 17:25:17 拓冰建站 浏览量
前后端分离医院后台管理系统实战:从数据库设计到部署上线全流程解析 前后端分离医院后台管理系统从数据库设计到部署上线的完整实战这个项目前后调了一个多月断断续续改了三个版本终于把一套完整的医院后台管理系统跑通了。技术栈不复杂SpringBoot Vue MyBatis MySQL典型的前后端分离结构但麻雀虽小五脏俱全从门诊挂号、医生排班、药品库存到收费退费核心业务模块都覆盖了。这套东西我放了完整源码附了从头到尾的部署教程从零开始照着一步步操作就能本地跑起来适合正在做毕业设计、或者刚接触前后端分离项目想搞个完整实战练手的同学。市面上的管理系统demo很多但要么只有前端页面没有后端逻辑要么后端代码堆成一团看不懂前后端怎么对接这套源码的价值就在于它把一条完整的数据流——从Vue页面发起请求到后端Controller接收再到MyBatis操作MySQL返回结果——完整地串起来了。项目整体是按医疗业务场景设计的登录注册走JWT鉴权不同角色管理员、医生、药房、收费处看到不同的菜单和操作权限业务上包含患者管理、排班管理、挂号收费、处方开单、药品出入库、退费处理这些模块。我这篇文章不打算只贴代码重点想讲清楚每一层设计背后的思路和坑比如数据库表为什么要拆成这种粒度、MyBatis的Mapper XML为什么这么写、Vue路由权限控制怎么做动态加载、部署的时候又踩了哪些雷这些都是我实打实验证过的路径你照着走能少走很多弯路。1. 项目概述与整体设计思路1.1 为什么选这套技术栈这套组合其实是目前Java Web后端开发最主流、也最适合入门的搭配。SpringBoot负责把整个后端的骨架搭起来内置Tomcat、自动配置、起步依赖省去了一堆繁琐的XML配置Vue负责前端页面交互配合Vue Router和Vuex或者Pinia单页应用SPA体验比传统JSPJQuery好太多MyBatis作为持久层框架SQL由开发者自己掌控灵活、可控、易排查问题MySQL不用多说开源免费关系型数据存储场景足够支撑医院这种偏传统业务的管理系统。我见过不少项目用MyBatis-Plus它确实省事自动生成CRUD、内置分页插件写起来非常快。但如果你是想学习原理、参加面试或者做毕设我更推荐用原生MyBatis。原因很简单MyBatis-Plus封装的太狠了很多人用它写好几年连Mapper XML里resultMap怎么配、动态SQL怎么写都不清楚面试一问就露馅。这套项目里的SQL和映射关系都是手写的你能真正看清一条数据从前端到后端的完整流转过程。当然项目里也封了通用的BaseMapper和一些公共方法不会让你每个接口都写一遍重复的CRUD算是平衡了学习价值和开发效率。1.2 前后端分离到底在分离什么很多初学者对前后端分离的理解停留在“前端一个文件夹、后端一个文件夹”这个层面其实远不止这么简单。前后端分离的核心在于开发和部署的彻底解耦前端工程和后端工程是两个独立的应用通过HTTP接口进行数据交互。开发阶段前端起一个Vite或Webpack开发服务器端口通常在5173Vite默认或8080后端SpringBoot跑在8080端口。前端通过axios发起跨域请求借助Vite的proxy代理把请求转发到后端这样既解决了开发环境的跨域问题又保持了代码里的请求地址统一。部署阶段就更彻底了前端代码构建成纯静态的HTML、JS、CSS文件扔到Nginx里托管后端打成JAR包单独运行。Nginx既当静态文件服务器又当反向代理把/api前缀的请求转发给后端的SpringBoot服务。这样的架构带来的好处显而易见前端开发和后端开发可以并行前端不用装JDK和Maven就能跑后端不用关心页面交互联调阶段只需要确认接口文档里的参数和返回结构。坏处也很明确——增加了部署的复杂度你得维护Nginx配置处理跨域管理接口文档。这套项目里我把前端的axios封装、后端的统一返回体、跨域配置都做了规范化处理学完这套你出去做任何前后端分离项目基本都能套用这个骨架。1.3 项目目录结构与模块规划后端是标准的Maven多模块单模块结构按功能分包我用的是单模块但包结构非常清晰新手看源码不迷路com.hospital.admin ├── config # 配置类跨域、JWT、拦截器注册 ├── controller # 接口层接收请求返回统一结果 ├── service # 业务逻辑层接口实现 ├── mapper # MyBatis的Mapper接口 ├── entity # 数据库实体类 ├── dto # 数据传输对象前端传参、返回视图对象 ├── common # 公共类统一返回体、异常处理、工具类 └── HospitalApplication.java前端Vue工程用Vite构建目录结构如下src ├── api # 接口请求封装按模块拆分文件 ├── assets # 静态资源 ├── components # 通用组件分页、上传、弹窗等 ├── layout # 主布局侧边栏、头部、面包屑 ├── router # 路由配置 动态路由逻辑 ├── store # Vuex状态管理用户信息、权限、全局状态 ├── utils # 工具函数axios封装、token处理、格式化 └── views # 页面组件按业务模块分文件夹这套目录结构不是乱拍的它对应了实际开发中的协作模式后端开发按controller、service、mapper分层前端开发按路由和API模块拆分前后端通过接口文档对接改一个模块互不干扰。医院管理系统涉及的角色多、权限点多这种清晰的模块划分对后续扩展非常重要。2. 数据库设计与后端核心实现2.1 医院业务表结构设计思路数据库设计是整个系统的地基表设计不好后面写接口和页面都是给自己挖坑。这套系统的表结构我按核心业务流来拆基础数据患者档案、科室、医生、药品→核心业务排班、挂号、处方、收费→辅助业务退费、库存流水、操作日志。先看最核心的几张表。患者表patient——存基础档案脱敏字段设计在这里非常关键。身份证号、手机号这种敏感信息前端列表页默认只显示脱敏格式中间打星号后端接口也默认不返回完整身份证号只有进入详情页才返回完整数据。这个细节看似简单但在医院系统里是合规要求很多毕业设计根本没考虑这一点。科室表department和医生表doctor属于基础数据。医生表不直接存科室名称而是存科室ID关联department表。这是关系型数据库设计的基本功——用主外键关联代替冗余存储。后续查询的时候用JOIN或者MyBatis的嵌套查询把科室名称带出来。排班表schedule是业务核心之一。字段包括医生ID、排班日期、上下午时段上午/下午、号源总数、已约号数、排班状态。注意一个坑排班里的号源总数和已约号数不需要在数据库里实时计算而是在挂号成功时通过事务更新已约号数这样查询排班列表时直接返回两个数字就能算出剩余号源不用COUNT(*)去统计性能好得多。挂号表registration记录每一次挂号。字段有患者ID、排班ID、医生ID、就诊日期、时段、挂号费、状态已挂号/已就诊/已退号、创建时间、操作人。这张表是流水型数据量级增长快在设计的时候就考虑到了查询列表必须按时间范围过滤不允许一次性全表查询。药品表drug和库存表drug_stock是药房模块的基础。药品表存名称、规格、生产厂家、批准文号、零售价、剂型等库存表保存每个药品的当前库存量和预警阈值。库存量不能直接放在drug表里因为库存是会频繁变化的拆开单独一张表更新某条库存时行锁粒度更小性能更好。处方表prescription与处方明细表prescription_item是典型的主子表结构。一张处方对应多个药品明细明细里记录每种药品的开具数量、单价、用法用量。收费的时候主表记录总金额明细表记录每一条费用构成。主子表的关系在MyBatis里用一对多嵌套查询处理这也是面试里经常被问到的一个知识点。收费表charge单独拆出来记录每一笔收费流水关联患者、收费类型挂号费、药费、检查费、金额、收费人、收费时间。退费表refund记录退费信息关联原始的收费记录用状态字段控制退费流程。最后是用户表sys_user和角色权限表sys_role、sys_menu、sys_user_role、sys_role_menu。这是标准的RBAC基于角色的访问控制模型用户→角色→菜单/权限的三层结构。表数量虽然是五张但这是做后台管理系统最标准的做法千万别觉得复杂就砍成一张表存角色的逗号分隔字符串那只是看着省事扩展性极差。2.2 MyBatis映射与动态SQL实战MyBatis在这个项目里承担了所有数据库操作的实现。先看核心配置mybatis-config.xml里最少要配这几个东西mapUnderscoreToCamelCase设为true数据库的下划线字段名自动映射到Java的驼峰属性名比如doctor_name自动映射为doctorName不用写一堆resultMap。配置log-impl为StdOutImpl这样控制台能直接打印SQL开发的时候调错特别重要。我加了这条配置之后开发体验完全不一样了。以前用MyBatis-PlusSQL是自动生成的出了问题根本不知道发给MySQL的是什么语句现在控制台直接打印完整SQL和参数一眼能看出是参数没传进去还是SQL本身写错了。再来看一个分页查询的Mapper写法这里用了动态SQL和PageHelper插件select idselectRegistrationPage resultMapRegistrationResultMap SELECT r.id, r.reg_no, r.visit_date, r.time_slot, r.status, p.id AS patient_id, p.patient_name, p.patient_phone, d.id AS doctor_id, d.doctor_name, dept.id AS dept_id, dept.dept_name FROM registration r LEFT JOIN patient p ON r.patient_id p.id LEFT JOIN doctor d ON r.doctor_id d.id LEFT JOIN department dept ON d.department_id dept.id where if testkeyword ! null and keyword ! AND (p.patient_name LIKE CONCAT(%, #{keyword}, %) OR r.reg_no LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND r.status #{status} /if if teststartDate ! null and startDate ! AND r.visit_date gt; #{startDate} /if if testendDate ! null and endDate ! AND r.visit_date lt; #{endDate} /if /where ORDER BY r.visit_date DESC, r.time_slot ASC /select这里有几个细节值得拿出来讲讲。第一为什么要用LEFT JOIN而不是直接查三张表再在Java里循环组装因为一次性把连表查询的结果返回给前端比查三次然后在内存里组装效率高得多也避免了经典N1查询问题。页面展示时需要一个列表里的医生名称、科室名称、患者名称都有值用JOIN就是最直接的解。第二WHERE后面为什么要加 标签包住动态条件因为如果直接写WHERE 11再拼条件虽然也能用但SQL看着别扭而且MySQL的优化器对这种写法虽然可以优化性能影响不大但代码的整洁度就差很多。 标签会自动去掉第一个AND关键字的写起来干净。第三 是MyBatis最核心的动态SQL用法。每个条件都判断是否为空为空就不拼到SQL里这解决了前端传参不确定的问题。比如列表页只传了status1没传日期那startDate相关的条件就不执行查询结果就是所有状态为1的号。这种过滤条件多的时候动态SQL就是唯一正确的解法。第四PageHelper分页插件。用起来很简单在service层查询之前调用PageHelper.startPage(pageNum, pageSize)然后紧跟着执行查询插件会自动拦截下一条SQL并生成带LIMIT的分页语句同时返回一个PageInfo对象里面包含了总数、总页数这些分页元数据。这个插件的坑在于必须紧挨着查询方法调用中间不能有任何其他SQL操作否则分页会作用在错误的语句上。我项目里特意在service实现里写了注释提醒自己。再讲一下resultMap的写法。JOIN查询的结果通常要做一个嵌套映射resultMap idRegistrationResultMap typecom.hospital.admin.entity.Registration id propertyid columnid/ result propertyregNo columnreg_no/ result propertyvisitDate columnvisit_date/ result propertytimeSlot columntime_slot/ result propertystatus columnstatus/ association propertypatient javaTypecom.hospital.admin.entity.Patient id propertyid columnpatient_id/ result propertypatientName columnpatient_name/ result propertypatientPhone columnpatient_phone/ /association association propertydoctor javaTypecom.hospital.admin.entity.Doctor id propertyid columndoctor_id/ result propertydoctorName columndoctor_name/ association propertydepartment javaTypecom.hospital.admin.entity.Department id propertyid columndept_id/ result propertydeptName columndept_name/ /association /association /resultMap这种嵌套association的映射方式在开发联调阶段帮了大忙。前端一次性拿到结构化嵌套JSON数据可以直接用registration.doctor.doctorName取到值不用自己拼装。MyBatis在处理这种嵌套映射时会自动完成对象的组装只要保证列的别名跟column一致就不会出错。我实际开发中的一个经验刚开始用这个resultMap时因为漏配了一个column导致doctor对象一直是null页面显示空白查了半天最后发现就是resultMap里少了个字段映射。这类问题你debug的时候不会报错只有打印出查询结果对象才能发现是null值排查起来特别费时间。2.3 事务控制与并发防超卖医院挂号系统有一个典型的并发问题——号源超卖。两个患者同时在手机上挂号同一个医生的号如果代码不限制并发数据库操作就会出现两个请求都查到还剩1个号然后都执行了INSERT挂号记录和UPDATE已约号数的操作结果号源变成负数。这在现实生活中会出医疗事故在系统里就是bug。解决这个问题最稳妥的方式是悲观锁事务。所谓悲观锁就是假设并发一定会发生所以在查询的时候就把数据锁住其他事务必须等当前事务提交后才能操作这条数据。MySQL里通过SELECT ... FOR UPDATE语句实现。实现上我先把排班号源扣减和挂号记录插入放在同一个事务方法里然后在扣减号源前先执行加锁查询Override Transactional(rollbackFor Exception.class) public Long register(RegisterRequest request) { // 先查排班信息并加锁 Schedule schedule scheduleMapper.selectByIdForUpdate(request.getScheduleId()); if (schedule null) { throw new BusinessException(排班不存在); } // 检查号源是否足够 if (schedule.getBookedCount() schedule.getTotalCount()) { throw new BusinessException(该时段号源已约满); } // 扣减号源 scheduleMapper.decreaseBookedCount(schedule.getId()); // 插入挂号记录 Registration registration new Registration(); // ...设置字段 registrationMapper.insert(registration); return registration.getId(); }selectByIdForUpdate对应Mapper里的SQL是SELECT * FROM schedule WHERE id #{id} FOR UPDATE。这样写之后第一个请求拿到FOR UPDATE锁执行INSERT和UPDATE后事务提交锁释放第二个请求排队等待等第一个请求提交后才能继续执行重新查询时看到booked_count已经被扣掉1了再判断号源就变成剩余0直接抛异常拒绝挂号。这个方案看着简单但有几个点很重要Transactional注解默认只回滚RuntimeException所以rollbackFor Exception.class必须显式声明否则某些受检异常抛出来事务不会回滚数据就脏了。FOR UPDATE必须在事务里才有意义如果方法没有加TransactionalSELECT执行完锁就自动释放了效果全无。这两个东西是配对的。锁的粒度尽量小。这里锁的是一条排班记录并发访问不同医生的排班记录互不干扰系统的吞吐量不会受到明显影响。比锁整张表或者锁全库的方案要好得多。实际测试时我用JMetery模拟了50个并发线程同时请求同一个排班的挂号接口不加锁的情况下出现了大量已约号数超过总数的数据加了FOR UPDATE锁后只有10个请求成功对应的排班号源正好是10个其余40个全部返回“号源已约满”数据完全正确。压测一跑立刻就能理解数据库锁的必要性。2.4 统一返回体、全局异常与JWT鉴权后端接口如果每个Controller返回格式都不一样前端处理起来会疯掉。这套项目里我定义了一个统一返回体Result 结构固定为code、message、data三个字段。成功返回Result.success(data)业务异常抛出BusinessException由全局异常处理器统一捕获并转换成Result.error(code, message)返回。这样前端封装好的axios拦截器里只需要判断code是否为200就能统一处理成功和失败的情况代码非常干净。全局异常处理用RestControllerAdvice配合ExceptionHandler实现我处理了三类异常BusinessException业务异常比如号源已满、库存不足、权限不足返回对应的错误码和提示信息。MethodArgumentNotValidException参数校验异常把第一个校验失败的错误信息返回给前端。Exception兜底异常防止未捕获异常把堆栈直接暴露给前端日志打印完整堆栈前端收到“系统繁忙请稍后重试”的通用提示。这个设计细节在联调阶段价值巨大。想象一下如果后端哪个接口报了个空指针默认SpringBoot会返回带堆栈的JSON前端控制台看到一坨英文堆栈只能截图找后端效率极低。有了全局异常处理前端只需看message字段后端只需看日志文件问题定位的效率翻倍。JWT鉴权这块我用的是jjwt库。用户登录成功后后端生成一个包含用户ID、用户名、角色编码的Token设置过期时间默认24小时返回给前端。前端存在localStorage里每次axios请求在请求拦截器中自动加上Authorization请求头。后端用一个拦截器拦截所有非白名URL的请求解析Token并校验签名和过期时间校验通过才放行。这里我踩过一个很典型的坑跨域预检请求OPTIONS也会被拦截器拦截导致前端明明在开发环境配了代理还是报跨域错误。解决办法是在拦截器里先放行OPTIONS请求或者使用Spring的HandlerInterceptor判断请求方法为OPTIONS时直接返回true。这个坑几乎每个做前后端分离的人都遇到过排查了一个下午最后发现是预检请求被拦截了。3. 前端Vue实现与核心页面解析3.1 Vue工程搭建与路由权限控制前端用的是Vue 3 Vue Router 4 Element Plus这套组合在当前是主流选择。Vue 3的Composition API让逻辑复用变得非常方便Element Plus的组件库让后台管理页面的开发效率成倍提升。路由权限控制是后台管理系统的核心难点。这个项目的菜单是根据登录用户的角色动态生成的不是把所有路由一次性注册好再通过菜单隐藏来实现“假权限”。我采用的是动态路由方案核心逻辑如下用户登录成功后后端返回该用户的角色编码和菜单列表树形结构父菜单子菜单。前端拿到菜单列表后遍历菜单项根据菜单的component字段比如system/user把对应的页面组件动态注册进路由表然后用router.addRoute()把路由添加到Vue Router实例中。同时把菜单列表存进Vuex侧边栏组件根据Vuex里的菜单数据渲染导航菜单。这个方案的好处是没有权限的用户访问一个URL时这个路由根本不存在直接显示404页面而不是能看到页面才知道没权限。从根源上规避了越权访问。具体实现分成三步第一步定义常量路由baseRoutes包含登录页、404页、首页这些不需要权限的页面。第二步封装动态路由生成函数接收后端返回的菜单列表将每个菜单项映射成一个RouteRecordRaw对象通过import.meta.glob动态导入对应的Vue组件。这里有个小技巧后台管理系统的页面文件都放在views目录下所以我可以这样写const modules import.meta.glob(../views/**/*.vue) // 动态映射 function generateRoutes(menus) { const routes [] menus.forEach(menu { const route { path: menu.path, name: menu.name, component: modules[../views/${menu.component}.vue], meta: { title: menu.title, icon: menu.icon } } if (menu.children menu.children.length 0) { route.children generateRoutes(menu.children) } routes.push(route) }) return routes }import.meta.glob是Vite提供的批量导入方式所有views下的.vue文件都会被打包进构建产物运行时根据字符串路径去匹配。这种写法比重重的require.contextWebpack时代的写法更直观。第三步在路由守卫beforeEach里做权限校验router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) } else if (token to.path /login) { next(/) } else { next() } })这是基础的登录校验。动态路由的注册时机放在登录成功后的处理逻辑里登录后调用store.dispatch(user/login, loginForm)在action里调接口拿菜单和用户信息然后调用addDynamicRoutes()把动态路由注册好最后用router.replace到首页。这里有一个容易犯的错误刷新浏览器后Vuex里的状态全部丢失动态路由也消失了。所以必须把用户信息和菜单数据持久化到localStorage里刷新时重新读取并重新注册路由。这也是我在项目里处理过的核心问题之一解决方案放在项目源码的store/user.js和router/index.js中有完整注释。3.2 axios封装与Vuex状态管理实战axios的封装是所有前端请求的基础。我在utils/request.js里做了统一配置const service axios.create({ baseURL: /api, timeout: 15000 }) // 请求拦截器携带token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) // 响应拦截器统一处理结果和错误 service.interceptors.response.use( response { const res response.data 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) { // token过期清除登录态跳转登录页 localStorage.clear() router.push(/login) } else { ElMessage.error(网络异常请稍后重试) } return Promise.reject(error) } )baseURL设为/api这个设计是和部署方案配套的。前端所有请求都发到/api这个路径开发环境由Vite代理转发到后端生产环境由Nginx把/api开头的请求反向代理到SpringBoot服务。这样前端代码里不需要写死后端地址部署到任何环境只需要修改代理配置即可。响应拦截器里直接返回res.data底层数据页面里的每个请求方法不用再写两层res.data去取代码简洁很多。401状态码的特殊处理也要放在拦截器里因为任何接口都可能返回401不能每个页面单独写一套跳转逻辑。Vuex的state里存了三样东西token、用户信息userInfo、菜单列表menus。mutation里提供setToken、setUserInfo、setMenusaction里封装login和logout。login action先调登录接口拿token再调获取用户信息接口拿用户信息和菜单组合成一个完整流程。logout则清除所有持久化数据并跳回登录页。我自己试过一个比较省事的办法是把用户信息存在localStorage里直接在main.js初始化时读出来放到Vuex里。这样刷新页面状态不丢又避免了在Vuex里单独写一个persistedState逻辑。具体怎么做源码里store/index.js有完整的注释说明。3.3 核心页面实现排班管理、挂号收费、药品管理三个核心业务页面我单独说一下实现要点因为它们的交互逻辑代表了后台管理系统开发的几种典型模式。排班管理页面是最典型的表格表单弹窗组合。页面左侧是科室树形菜单点击某个科室右侧展示该科室下的排班列表。左侧树用Element Plus的el-tree组件数据来自department接口右侧表格用el-table展示排班数据包括医生姓名、职称、排班日期、时段、号源总数、已约号数、剩余号数和操作按钮。点击“新增排班”弹出el-dialog内含一个排班表单表单里选择医生、日期、时段、填写号源总数提交时调create接口刷新列表。这里有一个开销上的小优化左侧科室树每次页面加载时拉一次右侧列表根据选中的科室ID作为查询参数调分页接口。切换科室时重新加载表格数据不会出现一次加载全部数据导致页面卡顿的问题。挂号收费页面是典型的联动表单数据提交。场景是收费员输入患者就诊卡号或身份证号回车自动带出患者信息选择科室、医生点击“查询排班”展示当前日期可挂号的号源列表点击“挂号”按钮弹出确认框确认后调用挂号接口生成挂号记录在页面下方展示已挂号的历史记录。这个页面用到的技术点包括input的防抖处理避免每敲一个字符就去请求后端查患者、级联选择器科室→医生的数据联动、el-table的行点击交互。药品管理页面则更偏数据维护。药品列表分页展示支持按药品名称和批准文号搜索点击编辑可以修改药品的零售价、库存预警阈值等字段修改单价会弹出提示框确认库存变更记录放在一个单独的tab页里展示每一次入库、出库、盘点操作的时间、数量和操作人形成完整的审计链路。这三个页面的代码结构和交互模式基本涵盖了后台管理系统90%的页面开发场景增删改查、树表联动、级联选择、弹窗表单、防抖搜索。你把这三种模式各写一遍后台管理系统基本就能熟门熟路了。4. 完整部署教程与常见问题排查4.1 本地开发环境搭建与启动步骤这套源码在本地跑起来需要JDK 8、Maven 3.6、Node.js 14、MySQL 8.0这些基础环境都很好安装。先导入数据库脚本我提供了db/hospital.sql文件用MySQL客户端执行这个脚本就能创建数据库、表结构和默认数据。默认账号密码在文档里写的很清楚admin/admin123是系统管理员doctor/123456是医生账号pharmacist/123456是药房账号分别演示不同角色登录看到不同菜单的效果。后端启动步骤如下先修改application.yml里的数据库连接配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 你的数据库密码 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.hospital.admin.entity configuration: map-underscore-to-camel-case: true注意连接串里的currentSchema参数有些MySQL版本需要用databaseNamehospital这种写法我用的是url路径里写库名这个最常见的方式。Maven里配置了阿里云镜像的情况下在项目根目录执行mvn spring-boot:run即可启动后端或者用IDEA直接运行HospitalApplication.java主类。启动成功后在控制台能看到Spring Boot的Banner和Tomcat started on port(s): 8080的日志说明后端服务已经就绪。前端启动更简单在vue-admin目录下执行npm install npm run dev首次npm install会拉取大量依赖包如果网络不好可能会失败多试几次或者切换淘宝的npm镜像即可。启动成功后控制台会显示本地开发服务器的地址默认是http://localhost:5173。由于我在vite.config.js里配置了server.proxy前端在开发环境请求/api开头的接口会自动转发到http://localhost:8080所以不用手动配置跨域。这是开发环境能跑通的关键。4.2 前后端联调与接口对接要点开发环境启动后前后端是分开跑的两个服务浏览器访问前端地址前端发请求时通过Vite代理转发到后端地址。代理配置如下// vite.config.js server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这个配置的意思是所有以/api开头的请求都会被转发到http://localhost:8080端口也就是后端的SpringBoot服务地址。因为后端Controller里定义的RequestMapping统一加了/api前缀所以代理配置只需匹配这一个前缀就行。changeOrigin设为true转发时会改写请求头里的Host字段避免后端服务因为Host校验而拒绝请求。联调阶段最容易出问题的地方是参数格式不一致。比如后端接收的是application/json格式的JSON字符串前端用axios.post直接传JS对象axios会自动序列化成JSON这个好解决但如果是文件上传、FormData格式的参数就需要特别注意Content-Type的设置。我在项目里封装了单独的upload函数用FormData对象构造请求体设置headers里的Content-Type为multipart/form-data。联调时还有一个小技巧Chrome开发者工具里Network面板可以实时看到每个请求的请求体、响应体、状态码和耗时。出现接口报错时先看这个请求有没有发出去再看请求头里有没有带上token再看后端返回的异常信息是什么。后端控制台的SQL日志和异常堆栈则是最后的确认手段。按照这个排查顺序80%的联调问题都能在几分钟内定位。4.3 生产环境部署前端Nginx 后端JAR包生产环境的部署架构是前端Nginx托管静态文件后端SpringBoot打成JAR包独立运行。这也是最典型的前后端分离部署架构。前端构建。在vue-admin目录下执行npm run build构建产物会生成在dist目录。这个目录里有index.html和一堆静态资源文件把这些文件丢到Nginx的html目录下即可。Nginx配置如下server { listen 80; server_name localhost; # 前端静态文件 location / { root /usr/share/nginx/html/dist; index index.html; try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1: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; } }这里有一个前端路由专属的大坑Vue Router默认使用history模式路由路径是http://localhost/system/user这种不带#号的风格。如果Nginx不做特殊处理用户刷新当前页面或者直接输入URL访问时Nginx会尝试在磁盘上找system/user这个文件找到就是404找不到就报错。解决办法就是上面配置里的try_files $uri $uri/ /index.html意思是请求的文件不存在时统统回退到index.html由前端的Vue Router接管路由匹配到对应的页面组件。后端打包与启动。在项目根目录执行mvn clean package -DskipTests构建成功后target目录下会生成hospital-admin.jar。然后用如下命令启动nohup java -jar hospital-admin.jar hospital.log 21 nohup和配合使用让进程在后台运行标准输出和错误输出都重定向到hospital.log文件。启动完成后浏览器访问Nginx的地址http://localhost前端页面加载请求会通过Nginx代理转发到8080端口的后端服务整个系统就部署完成了。如果需要修改后端服务端口在application.yml里改server.port但记得同步修改Nginx的proxy_pass地址。四个配置的联动关系一定要理清前端开发环境的代理地址 → 后端开发端口前端生产环境的Nginx代理地址 → 后端生产端口。改错任何一个环节都会导致请求404或502。4.4 十一个常见问题速查与解决实录部署和运行过程中我遇到过不少问题这里挑最有代表性的记录下来基本都能快速复用排查。Q1后端启动报数据库连接失败错误信息是Access denied for user rootlocalhost。原因就是application.yml里的密码或数据库名不对确认下MySQL的账号密码、数据库是否已经导入脚本。另外MySQL 8的默认连接认证方式可能导致驱动报SSL错误连接串加上useSSLfalse就能解决。Q2前端页面能打开但接口全部404控制台看请求URL都是/api开头但后端接口找不到。检查后端Controller的RequestMapping路径我全部用的/api前缀如果前端请求发到了/api后端接口是/api/xxx但代理没生效请求就会直接打到Nginx下找/api这个路径自然就是404。开发环境优先检查Vite代理配置是否正确。Q3登录报跨域错误看浏览器控制台显示CORS error。检查后端CORS配置类是否已生效另外确认登录接口不在登录拦截器的白名单里OPTIONS预检请求是否被拦截。我项目里的CorsConfig配置了allowedOriginPatterns(*)并且拦截器放行了OPTIONS请求这个问题从根源上规避了。Q4表单提交时前端报错400 Bad Request检查提交的数据格式和后端接收参数是否匹配。比如后端用RequestBody接收JSON对象前端用application/x-www-form-urlencoded的格式提交就会报400。看Network面板的请求头里Content-Type字段是什么后端需要的类型是什么改前端请求方式即可。Q5分页数据不生效每页都是全量数据。检查PageHelper是否有被其他查询干扰PageHelper.startPage后必须紧跟着一条查询语句执行。如果startPage和查询之间插入了其他Mapper方法或逻辑PageHelper会把分页应用到错误的SQL上。这是PageHelper的经典坑必须严格遵守写法。Q6药品库存出现负数说明扣减库存没有并发控制。参考排班号源的FOR UPDATE方案给库存扣减加上锁操作。生产环境建议用Redis分布式锁处理更复杂的并发场景但MySQL的悲观锁在这个系统里完全够用且好理解。Q7Vue Router变成404刷新任何页面都显示404。这就是Nginx的try_files配置没写好或者根本就没配。按上面Nginx示例里的try_files $uri $uri/ /index.html;配置解决。Q8前端构建失败提示内存溢出构建产物过大时Node.js默认堆内存不够。在package.json的build脚本里加一句NODE_OPTIONS--max-old-space-size4096或者用cross-env包跨平台设置。这个在部署CI/CD时经常遇到预先配置好可以省很多事。Q9登录后动态路由没有正常加载页面白屏或跳到404。刷新页面后Vuex状态丢失导致动态路由消失需要从localStorage读取菜单重新注册。检查store里是否有持久化用户菜单的逻辑确认store初始化时执行了动态路由生成函数。这个问题我单独写过一个解决版本在源码里有详细注释。Q10MySQL无法导入中文数据乱码。数据库脚本是UTF-8编码导入时确保客户端连接使用utf8mb4字符集。执行source命令前先执行set names utf8mb4如果数据库已经创建但库表字符集不对改成utf8mb4再重新导入。我最初用旧版Navicat导入时遇到过这个坑后来统一改用mysql命令行source导入就好了字符集始终不乱。Q11后端启动时端口被占用提示Port 8080 was already in use。用netstat -ano | findstr 8080命令查找占用进程的PID然后在任务管理器杀掉该进程或者直接改后端端口号并同步修改Nginx代理配置。4.5 性能优化与代码规范经验系统跑起来只是第一步我个人在实际优化过程中重点做了三件事第一件事用懒加载优化前端首屏速度。Vue Router的路由配置里把component换成动态导入即上面提到的import.meta.glob方式这样每个路由对应的页面组件只有在访问时才加载不会一次性把几十个页面全部打包成一个大JS文件。构建产物从首屏6.2秒降到了2.8秒左右效果显著。第二件事给高频查询加上缓存。科室列表、药品分类这些几乎不变化的数据可以在后端加一层简单的内存缓存。项目里我基于Spring的Cacheable注解实现了一级缓存把热门科室列表接口的响应时间从50毫秒左右降到了5毫秒以内。不用引入Redis这个做法对学习绝对友好也足够应对并发量不高的后台管理系统场景。第三件事给列表页加上统一的导出功能。后台管理系统几乎都支持导出Excel我封装了一个基于Apache POI的通用导出工具类前端调一个接口传进查询条件和导出的表头配置后端把查询结果转成Excel并返回文件流。这个功能虽然不算核心业务但在医院管理场景中非常实用比如月底查看门诊统计报表导出。数据库性能优化方面我给经常作为查询条件的字段建了索引registration表的patient_id和visit_date、reg_no加了索引schedule表的doctor_id和visit_date加了联合索引prescription表的patient_id、charge表的charge_time都加了单列索引。场景不同、查询条件不同加索引前可以用EXPLAIN看当前SQL的执行计划Confirm是否走了索引扫描这个习惯非常值得培养。代码规范方面几件事我建议形成肌肉记忆Controller只做参数接收和结果返回不写业务逻辑Service里才写业务逻辑和事务控制Mapper接口和XML文件一一对应XML里的namespace绑定Mapper接口的全限定名实体类的字段和数据库列名通过驼峰映射对应统一返回体、统一异常处理、统一日志记录这些都是团队协作时避免混乱的关键。写在最后这套系统前前后后我改了好几轮踩的坑基本都在文章里写全了。如果要说最重要的几条经验我会说数据库表结构设计一定不能省能拆分就拆分别把所有东西怼在一张表里并发场景一定要用好事务和锁宁可慢一点也不能让数据错乱前后端联调阶段接口参数的格式和返回结构一定要提前约定清楚否则测试的时候你会发现一半的时间都浪费在“我这边传的是xx你那边怎么是xx”的口角上。最后分享一个小技巧。这套项目里有一个“字典管理”模块把性别、挂号状态、处方类型这类固定枚举值都设计成了数据库字典表而不是硬编码在Java代码里。一开始我觉得麻烦后来运营人员直接改数据库就能新增一个下拉选项完全不用发版上线。这个思路在后来的所有项目里我都沿用了。数据字典这一个设计帮我在后续业务迭代中省了太多事。这套源码和部署教程已经完整放出来了照着跑一遍再根据你自己的业务场景改一改前后端分离项目的核心套路你就全部掌握了。