
医疗问诊拿药系统这类项目算是Java后端和Vue前端技术栈里非常典型的一类全栈实战案例。它的业务链条相对完整从患者发起问诊、医生开立处方再到药房配药发药、患者缴费取药整条链路都涉及到了所以特别适合用来练习前后端分离开发、权限模型设计、核心业务状态流转这些东西。加上它自带源码、数据库脚本和文档拿来做课程设计、毕业设计或者想入门全栈开发的练手项目都是比较合适的切入点。这篇内容我就从项目落地角度把系统设计思路、核心模块拆解、数据库设计、关键代码实现和部署过程都过一遍顺便把我在实际搭建这类系统时踩过的坑和总结的经验一并写出来给大家一个可以直接参考的实操路径。1. 系统整体设计与技术选型思路着手开始搭建之前先把整体设计思路理清楚后面才能少走弯路。这类医疗问诊系统表面上看是“问诊”和“拿药”两个环节实际拆分下来里面的角色、状态和流程要比预想的复杂不少。1.1 角色权限划分是第一步系统里至少有三种角色患者、医生、管理员。有些更完整的版本还会加上药师。患者端Vue页面登录注册、浏览医生列表、发起问诊、查看问诊记录、在线支付/模拟支付、查看处方、确认取药。医生端Vue页面查看待接诊列表、接诊、填写诊断意见、开立处方药品明细用法用量、查看历史接诊记录。管理后台Vue页面医生信息管理、药品信息管理、分类管理、问诊订单管理、数据统计。权限这块建议直接用JWT做登录凭证配合前端路由守卫和后端拦截器做双重校验。角色的区分在JWT的claims里带上一个role字段就行后端接口用自定义注解拦截器做权限控制。第1步用户输入账号密码后端校验通过后生成JWT把用户ID和角色封装进token里返回前端。第2步前端拿到token存到localStorageVue Router的beforeEach钩子里判断没有token就跳登录页有token但访问了无权限路由就跳403页面。第3步后端除了登录接口其余接口都走拦截器验证token有效性再根据注解校验角色权限比如只有“DOCTOR”角色才能访问接诊相关接口。实际项目中权限这块其实容易出问题。JWT的密钥一定要放到配置文件里不要硬编码在代码里。token过期时间建议设成2小时前端在axios响应拦截器里检测到401就自动跳登录页同时清掉本地缓存。1.2 前后端分离架构带来的便利采用前后端分离的架构核心目的就是把页面渲染和数据接口分开。前端工程用Vue全家桶来管理页面交互后端只用Spring Boot提供纯JSON接口。这种模式下前后端各自可以独立开发、独立部署。前端跑在Nginx上后端是打包好的Jar包互不干扰。后端开发的几个核心依赖放在pom.xml里Spring Boot 2.7.x基础框架快速搭建RESTful API。MyBatis Plus 3.5.x操作数据库非常便捷自带分页插件和代码生成器做课程设计能省很多时间。MySQL 8.x存储业务数据的核心库。JJWT 0.9.x生成和验证JWT Token。Hutool 5.x挺实用的Java工具类库很多重复工作不用自己写了。Lombok简化实体类的Getter/Setter。前端工程的核心依赖package.json里Vue 2.6.x Vue Router 3.x Vuex 3.x稳定的一套组合课程设计的主流选择。Element UI现成的组件库表格、表单、弹窗这些直接拿来用开发效率很高。Axios统一处理HTTP请求和响应。ECharts管理端数据统计图表的展示。1.3 为什么用MyBatis Plus而不是纯MyBatis我看过不少课程设计代码很多还在用原生MyBatis写大量XML一个简单的单表查询都要写一大段resultMap工作量都耗在重复劳动上了。MyBatis Plus的价值在于内置了通用Mapper和通用Service单表CRUD基本不用写SQL只需要定义实体类和Mapper接口继承BaseMapper就行。像分页查询只需要配置一个分页插件然后直接调用Page对象的方法。多表关联查询再单独写XML这样既省事又保留了灵活性。一个实际的对比我用原生MyBatis写用户表的分页查询需要写SQL、配置resultMap和处理参数差不多要20多行配置。换MyBatis Plus之后继承BaseMapper分页查询一行代码搞定。1.4 项目目录结构规划好了再动手不管是从零开始还是拿到种子项目二次开发先把目录结构理清楚是很有必要的。后端工程我建议按以下方式组织src/main/java/com/medical/ ├── MedicalApplication.java // 启动类 ├── config/ // 配置类CORS、MyBatis Plus分页插件、拦截器注册 ├── controller/ // 接口控制层 │ ├── PatientController.java // 患者端接口 │ ├── DoctorController.java // 医生端接口 │ └── AdminController.java // 管理端接口 ├── service/ // 业务逻辑层 │ ├── UserService.java // 用户相关服务 │ ├── ConsultationService.java // 问诊相关服务 │ ├── PrescriptionService.java // 处方相关服务 │ └── MedicineService.java // 药品相关服务 ├── mapper/ // 数据访问层 ├── entity/ // 数据库实体类 ├── dto/ // 数据传输对象 ├── vo/ // 视图返回对象 ├── common/ // 通用工具Result类、异常处理、常量 └── utils/ // JWT工具类等前端工程建议这样组织src/ ├── api/ // 所有接口请求统一放这里 │ ├── user.js // 用户相关接口 │ ├── consultation.js // 问诊相关接口 │ └── medicine.js // 药品相关接口 ├── assets/ // 静态资源 ├── components/ // 公共组件 ├── router/ // 路由配置 ├── store/ // Vuex状态管理 ├── views/ │ ├── login/ // 登录页 │ ├── patient/ // 患者端页面 │ ├── doctor/ // 医生端页面 │ └── admin/ // 管理端页面 ├── utils/ // 封装的axios工具本地存储工具 └── App.vue目录结构清晰之后后面开发不易产生代码到处乱放、想改找不到的情况。2. 核心功能模块的实现细节明确了整体结构之后就需要分解业务模块逐一实现。我会按照实际开发顺序来拆解先从基础的用户认证开始再逐步推进到问诊和处方最后补充管理端功能。2.1 用户认证与登录注册用户表是系统的基石至少要包含这些字段用户ID、用户名、密码、真实姓名、角色、手机号、性别、年龄、创建时间。密码存储建议用MD5加盐或者BCrypt加密不要明文存储。登录接口的处理逻辑重点注意一下前端把用户名和密码通过POST请求传到后端。后端根据用户名查询用户如果不存在提示“用户不存在”。密码校验通过后生成JWT把用户基本信息连同token一并返回前端。前端拿到token后存起来跳转到对应角色首页。JWT工具类比较核心生成token和解析token这两个方法要写好public class JwtUtil { // 密钥实际项目中应该放到配置文件里 private static final String SECRET_KEY your-secret-key; // 过期时间 2小时 private static final long EXPIRE_TIME 2 * 60 * 60 * 1000; public static String generateToken(Integer userId, String role) { Date now new Date(); Date expireDate new Date(now.getTime() EXPIRE_TIME); return Jwts.builder() .setHeaderParam(typ, JWT) .claim(userId, userId) .claim(role, role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); } }后端对密码的处理逻辑我建议用BCrypt加密。虽然MD5实现更简单但安全性太差尤其这类会涉及敏感健康数据的系统在校验时能体现对安全的重视度。Spring Security的BCryptPasswordEncoder是个好工具加解密逻辑经过长期生产验证直接引依赖用就可以。2.2 问诊业务流程的实现问诊模块是整个系统的业务核心它的状态流转需要仔细设计。问诊单状态建议分成几个阶段0 - 待接诊患者提交问诊请求后的初始状态。1 - 已接诊医生接诊开始查看病情描述。2 - 已完成医生填写了诊断结果并开出药方。3 - 已取消患者主动取消或超时未接诊。患者发起问诊时需要填写的主要内容病情描述、发病时间、是否有既往病史、是否有药物过敏史。这些信息直接关系到后面医生开方时对用药安全的判断。医生的接诊流程稍微复杂一点前端医生页面会有一个“待接诊”列表点击“接诊”按钮之后进入问诊详情页。在这个页面里医生可以看到患者填写的病情描述然后填写诊断结论和一个或多个药品条目。每个药品条目包含药品ID、药品名称、用法用量、用量说明、天数、数量。提交处方后系统需要同时完成几件事更新问诊单状态为“已完成”、生成一条处方记录、在处方明细表里插入多条药品明细数据。这几步必须放在一个事务里执行防止出现问诊单状态更新了但处方明细没写进去的数据不一致情况。这里有一个容易忽略的设计细节处方要存一个“药品快照”。也就是说处方的药品明细不能只是引用药品ID而是要把开方时的药品名称、单价都冗余存到处方明细表里。因为药品信息后续可能被管理员调整价格或改名如果处方只存ID历史处方单显示出来时会因为药品信息变更而对不上原价。2.3 药品管理模块和库存扣减药品管理这块管理端的操作相对标准药品的增删改查、上下架、分类管理。需要考虑药品的关键字段药品ID、药品名称、规格、单位、生产厂家、进价、售价、库存数量、药品图片、分类、说明书。比较考验细节的是问诊开方时的库存扣减。在设计上不应该在医生提交处方的瞬间就冻结库存更合理的方案是医生提交处方时仅仅生成处方记录不影响库存。患者完成支付或确认取药时系统才真正扣减库存。这样设计的考虑在于现实中一个处方开出去患者如果没有缴费拿药这段时间的库存其实不应该被占用否则会造成系统显示的库存量偏低影响后续开方用药。如果还想更进一步可以在生成处方时把相应库存“锁定”患者支付后正式扣减超时未支付自动释放锁定库存。这个逻辑在并发场景下比较靠谱不过对于课程设计来说做到支付时扣减库存已经可以了。扣减库存的代码我建议这么写Transactional public boolean deductStock(Integer medicineId, Integer quantity) { // 使用乐观锁或行锁防止超卖 Medicine medicine medicineMapper.selectById(medicineId); if (medicine.getStock() quantity) { return false; } medicine.setStock(medicine.getStock() - quantity); medicineMapper.updateById(medicine); return true; }但这样直接updateById在并发场景下有一定的安全问题。更规范的写法是用SQL层面的原子更新UPDATE medicine SET stock stock - #{quantity} WHERE id #{medicineId} AND stock #{quantity}如果影响行数为1说明扣减成功返回0则说明库存不够。这种写法在执行层面避免了并发超卖问题比“先查询再更新”的方式安全得多。2.4 基于Vue的前端页面开发前端页面拆成几个核心视图来实现效果会比较好。登录注册页包含角色选择患者/医生/管理员登录表单验证。Element UI的Form组件自带验证规则设置好rules属性就可以完成必填校验和格式校验。患者端首页展示医生列表卡片式布局患者选择一个医生点击“图文问诊”弹出对话框填写病情描述。我的建议是表单里加上一个“历史用药”的字段这样医生问诊时更有参考依据。问诊记录列表用表格展示问诊时间、医生姓名、状态、操作按钮。状态用el-tag展示不同颜色的标签让患者一目了然。点击“详情”可以展开看完整的处方信息。医生端工作台这是医生每天登录后看到的页面需要展示待接诊数量、今日已完成问诊数以及待接诊列表。接诊操作设计成两步先看患者详情再填写诊断和开药。管理端药品管理表格、用户管理表格、数据统计仪表盘。统计仪表盘可以用ECharts画一个“近7日问诊量趋势图”和“药品销售排行TOP10”柱状图看起来比较完整。有一个前端细节值得注意问诊详情的状态流转展示。患者和医生看到的按钮是不一样的。患者视角问诊状态是“待接诊”时展示“取消问诊”按钮是“已完成”时展示“查看处方”按钮。医生视角状态是“待接诊”时展示“接诊”按钮是“已接诊”时展示“填写诊断”按钮。这种根据状态动态切换操作按钮的逻辑在开发时要处理好不能把所有操作按钮都渲染出来。建议在Vue的computed属性里根据问诊状态计算当前用户可用的操作列表模板里只渲染这个计算出来的结果。3. 数据库设计与关键实现数据库表结构的合理性直接决定项目能否顺利跑通这部分我会针对核心表和关键SQL展开细说。3.1 核心表结构说明我用文字描述一下几张核心表的建表逻辑直接照着建库就能用。用户表sys_user字段名类型说明idbigint主键自增usernamevarchar(50)登录用户名唯一passwordvarchar(100)加密后的密码real_namevarchar(50)真实姓名rolevarchar(20)角色PATIENT/DOCTOR/ADMINphonevarchar(20)手机号gendertinyint0未知1男2女ageint年龄department_idbigint所属科室仅医生有create_timedatetime创建时间医生与科室如果系统里有科室概念可以加一张科室表医生表通过department_id关联。一些简化版的系统没有科室表把科室做成一个字符串存医生表里也能用但这样做后面统计各科室业务量的时候会比较吃力。我建议还是把科室抽成独立的表。药品表medicine字段名类型说明idbigint主键namevarchar(100)药品名称categoryvarchar(50)药品分类specificationvarchar(50)规格如0.25g*24片unitvarchar(20)单位盒/瓶/袋manufacturervarchar(100)生产厂家purchase_pricedecimal(10,2)进价selling_pricedecimal(10,2)售价stockint库存数量imagevarchar(255)药品图片URLdescriptiontext药品说明书statustinyint1上架0下架问诊单表consultation字段名类型说明idbigint主键consult_novarchar(32)问诊单号唯一patient_idbigint患者IDdoctor_idbigint医生IDdescriptiontext病情描述diagnosistext医生诊断结论statustinyint0待接诊1已接诊2已完成3已取消create_timedatetime发起时间accept_timedatetime接诊时间finish_timedatetime完成时间处方表prescription字段名类型说明idbigint主键consult_idbigint关联问诊单patient_idbigint患者IDdoctor_idbigint医生IDtotal_amountdecimal(10,2)总金额remarkvarchar(255)备注create_timedatetime开方时间处方明细表prescription_item字段名类型说明idbigint主键prescription_idbigint关联处方medicine_idbigint药品IDmedicine_namevarchar(100)药品名称快照pricedecimal(10,2)单价快照quantityint数量dosagevarchar(100)用法用量说明daysint用药天数3.2 事务控制在开方环节的使用开方这个操作涉及多张表的数据写入必须放在一个事务里。在Spring Boot中直接加Transactional注解就行。我来写一个参考实现Transactional(rollbackFor Exception.class) public Result createPrescription(PrescriptionRequestDTO dto) { // 1. 校验问诊单状态只有“已接诊”的问诊单才能开方 Consultation consultation consultationMapper.selectById(dto.getConsultId()); if (consultation null || consultation.getStatus() ! 1) { return Result.error(问诊单状态不允许开方); } // 2. 创建处方主记录 Prescription prescription new Prescription(); prescription.setConsultId(consultation.getId()); prescription.setPatientId(consultation.getPatientId()); prescription.setDoctorId(consultation.getDoctorId()); // 计算总金额 BigDecimal total dto.getItems().stream() .map(item - item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); prescription.setTotalAmount(total); prescriptionMapper.insert(prescription); // 3. 批量插入处方明细 for (PrescriptionItemDTO item : dto.getItems()) { PrescriptionItemDetail detail new PrescriptionItemDetail(); detail.setPrescriptionId(prescription.getId()); detail.setMedicineId(item.getMedicineId()); detail.setMedicineName(item.getMedicineName()); detail.setPrice(item.getPrice()); detail.setQuantity(item.getQuantity()); detail.setDosage(item.getDosage()); detail.setDays(item.getDays()); prescriptionItemMapper.insert(detail); } // 4. 更新问诊单状态为“已完成” consultation.setStatus(2); consultation.setDiagnosis(dto.getDiagnosis()); consultation.setFinishTime(new Date()); consultationMapper.updateById(consultation); return Result.success(开方成功); }这段代码有几个要点第1步做状态校验防止对同一个问诊单重复开方这是业务正确性的基础。第2步算总金额。这里有个常见错误前端可能计算好总金额直接传过来但后端必须要重新计算不能信任前端传过来的金额。实际开发时前端传过来的items里应该只包含药品ID和数量后端根据数据库中真实单价计算金额。第4步更新问诊单状态整个多表写入在任何一步抛异常时都会整体回滚。3.3 用视图对象VO组合数据返回前端数据库表设计遵循的是第三范式存数据是高效的但前端展示时往往需要关联查询多张表的数据。比如前端“问诊记录列表”需要展示患者姓名、医生姓名、状态、总金额、时间。这些信息分散在用户表、问诊单表、处方表里。一种做法是前端拿到问诊单列表后逐个调接口查询患者信息和医生信息但这样做会产生大量请求性能差代码也乱。更合理的做法是在后端定义一个视图对象直接在SQL里关联查询组装public class ConsultationVO { private Long id; private String consultNo; private String patientName; private String doctorName; private String departmentName; private String description; private String diagnosis; private Integer status; private BigDecimal totalAmount; private Date createTime; }然后写一条关联查询的SQLSELECT c.*, p.real_name AS patientName, d.real_name AS doctorName, dept.name AS departmentName, pr.total_amount AS totalAmount FROM consultation c LEFT JOIN sys_user p ON c.patient_id p.id LEFT JOIN sys_user d ON c.doctor_id d.id LEFT JOIN sys_department dept ON d.department_id dept.id LEFT JOIN prescription pr ON c.id pr.consult_id ORDER BY c.create_time DESC这样前端只需要调一个接口拿到的数据就是列表页完整展示所需的对象。MyBatis Plus的分页插件可以直接对这个查询做分页返回IPage 接口统一封装成含total和records的响应体。3.4 数据库测试数据准备跑通项目需要一些测试数据特别是医生和药品没有这些数据系统跑起来之后界面是空的没法演示。我建议准备1个管理员账号admin/admin1232-3个医生账号doctor1/doctor1doctor2/doctor2分属不同科室2-3个患者账号patient1/patient1patient2/patient210-15条药品数据覆盖感冒、消炎、止痛等常见分类带价格和库存部分演示数据的SQL-- 插入科室 INSERT INTO sys_department (name, description) VALUES (内科, 主要处理内科常见疾病), (外科, 主要处理外科相关疾病), (儿科, 主要处理儿童常见疾病); -- 插入医生账户密码需要按实际加密方式处理 INSERT INTO sys_user (username, password, real_name, role, phone, gender, age, department_id, create_time) VALUES (doctor1, 加密后的密码, 张医生, DOCTOR, 13800000001, 1, 35, 1, NOW()), (doctor2, 加密后的密码, 李医生, DOCTOR, 13800000002, 2, 42, 2, NOW()), (doctor3, 加密后的密码, 王医生, DOCTOR, 13800000003, 1, 29, 3, NOW()); -- 插入药品 INSERT INTO medicine (name, category, specification, unit, manufacturer, purchase_price, selling_price, stock, status, create_time) VALUES (感冒灵颗粒, 感冒用药, 10g*9袋, 盒, 华润三九, 8.50, 15.00, 100, 1, NOW()), (阿莫西林胶囊, 抗生素, 0.25g*24粒, 盒, 联邦制药, 12.00, 25.00, 80, 1, NOW()), (布洛芬缓释胶囊, 解热镇痛, 0.3g*20粒, 盒, 中美史克, 10.00, 18.50, 120, 1, NOW());数据准备阶段有一个经常踩的坑很多种子项目用的是固定前缀的密码比如admin123、123456但在Java里不同加密方式生成的密文不同如果直接把项目自带数据库脚本里的INSERT语句复制过来而项目代码里加密方式不匹配登录时就会一直报密码错误。建议插入数据时用项目代码里注册接口跑几遍把生成的密文复制回来用或者在SQL脚本里明文写密码登录时先用MD5校验一下是否匹配。4. 前端页面与接口联调要点前端是用户直接接触的部分体验好不好影响很大。但前端项目真正容易出问题的往往不是页面样式而是和后台的数据联调。4.1 Axios请求封装的合理性页面里如果每个API请求都写一长串axios配置代码会显得很冗余也不便维护。比较推荐集中封装import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器 service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token // 统一在请求头里附加token } return config }, error { return Promise.reject(error) }) // 响应拦截器 service.interceptors.response.use(response { const res response.data if (res.code ! 200) { Message.error(res.message || 请求失败) if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, error { Message.error(网络异常请稍后重试) return Promise.reject(error) }) export default service其中请求头里token的携带方式需要前后端约定好。常见两种做法一是带Authorization头值直接放token字符串二是遵循http规范写成Bearer token格式。只要前后端保持一致就好但要在一个系统里统一。后端拦截器里取token的方式相应是这样String token request.getHeader(Authorization); if (token null || token.isEmpty()) { throw new BusinessException(未登录或登录已过期); }4.2 路由守卫实现页面访问控制前端路由需要根据角色动态控制可访问的页面。最简单有效的方案是在路由meta里配置角色const router new VueRouter({ routes: [ { path: /login, component: Login }, { path: /patient, component: PatientLayout, meta: { roles: [PATIENT] }, children: [ { path: doctors, component: DoctorList }, { path: records, component: ConsultationRecords } ] }, { path: /doctor, component: DoctorLayout, meta: { roles: [DOCTOR] }, children: [ { path: workbench, component: DoctorWorkbench }, { path: history, component: DoctorHistory } ] }, { path: /admin, component: AdminLayout, meta: { roles: [ADMIN] }, children: [ { path: medicine, component: MedicineManage }, { path: statistics, component: Statistics } ] } ] }) router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.path /login) { next() return } if (!token) { next(/login) return } if (to.meta.roles !to.meta.roles.includes(role)) { next(/403) return } next() })路由守卫逻辑相对简单清晰未登录的跳登录页登录了但角色权限不符合的跳403页面。实际操作中还可以在菜单栏上做一层判断没有权限的菜单项直接不渲染出来这样的体验会更好。4.3 联调阶段的后端配置前端开发时经常遇到跨域问题。开发环境下Vue CLI启动的开发服务器默认跑在8080端口后端是8081端口前后端直接请求就会产生跨域的情况。两种解决办法后端开启CORS使用CrossOrigin注解或者配置全局CorsFilter。前端配置vue.config.js的devServer代理把所有请求转发给后端绕开浏览器跨域限制。我建议用第二种因为这样前端代码里可以直接写相对路径到了生产环境配合Nginx反向代理代码不需要改动// vue.config.js module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } } }后端接口统一前缀写成/api这样开发环境的代理和生产环境的Nginx配置可以保持同样的路径规则前端代码不用区分环境。4.4 状态管理里存什么Vuex用来存用户信息比较合适。但要注意一点刷新页面之后Vuex中的数据会丢失所以首选的持久化方案还是localStorage。登录成功之后执行关键的三步操作this.$store.commit(SET_USER, res.data.userInfo) localStorage.setItem(token, res.data.token) localStorage.setItem(role, res.data.userInfo.role)建议在store里定义一个getters方法从localStorage里读取用户信息这样即使刷新页面后Vuex被重置getters里也能通过localStorage取回数据。可以维护一份用户基本信息与必要信息在localStorage里而不需要存太多敏感字段。5. 常见问题与排查实录项目的运行过程必然伴随着各种报错和异常我总结了几个典型问题和排查思路供大家参考。5.1 跨域问题导致前端请求失败现象浏览器控制台报错No Access-Control-Allow-Origin header is present on the requested resource。排查思路后端是否配置了CORS。如果用了SpringBoot可以在配置类里加一个CorsFilter的Bean统一放行。也可以用上面提到的方式配置前端代理把请求转发到后端绕开浏览器跨域策略。实际项目建议后端跨域配置加一个前端代理也加上双保险。生产环境会给Nginx加一层反向代理把前端的/api请求直接转发到后端服务跨域问题就彻底不存在了。5.2 MyBatis Plus分页失效现象调用了selectPage方法之后返回的记录是全部数据分页没有起作用。原因MyBatis Plus的分页功能需要额外配置分页插件如果没有注册这个插件分页查询就是查询全部数据然后在内存中简单处理数据多了会拖慢速度。解决在config包下添加配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(); paginationInnerInterceptor.setDbType(DbType.MYSQL); paginationInnerInterceptor.setOverflow(false); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }5.3 JWT过期后前端页面无响应现象用户操作一段时间后点击按钮没有反应浏览器控制台显示401请求。原因token过期了后端拒绝请求但前端没有统一的处理逻辑。解决在axios响应拦截器里统一处理401状态码清空本地缓存并跳转登录页。这个方法在前面的封装的代码中已经体现了。5.4 医生接诊状态与数据库不一致现象医生点了接诊按钮页面显示已接诊但患者端看到的还是待接诊。原因前端操作的只是本地状态数据库里的状态没有被正确更新。后端更新接口可能报错了或者前端跳过了请求直接修改了页面数据。排查方法查看浏览器sources里的network请求点接诊按钮时应该有一个PUT/POST请求看请求是否返回了成功状态。如果返回500就去看后端日志里具体的报错信息。如果后端更新成功前端页面用新的数据去渲染状态问题就不大。5.5 药品图片显示不出来现象药品列表里的图片裂了。排查思路这类全栈项目的图片通常放在后端的静态资源目录或者使用服务器上的磁盘路径。常见原因是前端请求的图片URL指向了一个后端没有映射的路径。一个简易可靠方案在后端配置静态资源映射让上传的图片可以通过URL访问Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: System.getProperty(user.dir) /upload/); } }这样上传的文件放在项目的upload目录里前端直接通过http://localhost:8081/upload/xxx.jpg访问即可。5.6 数据库编码导致中文乱码现象插入的中文数据显示成问号或乱码。排查思路大部分情况是数据库连接串缺少编码参数、或者数据库本身和表的字符集不合适。解决连接串加上characterEncodingutf8和useSSLfalse参数jdbc:mysql://localhost:3306/medical?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai建库时指定字符集CREATE DATABASE medical DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;已经出现乱码的表可以调整字符集后再重新插入数据。MySQL中utf8和utf8mb4是两个概念建议直接用utf8mb4避免输入特殊字符或表情时出现报错。5.7 前端npm install速度慢或失败现象执行npm install时卡了很久或直接报错。解决更换国内镜像源npm config set registry https://registry.npmmirror.com如果是node-sass安装失败比较常见的问题可以尝试npm install node-sass --sass_binary_sitehttps://npm.taobao.org/mirrors/node-sass不过更推荐的方式是直接用dart-sasssass替代node-sass新版Vue CLI项目对sass的兼容性一直保持更新。6. 部署与运行环境准备项目开发完成之后最终需要部署到服务器上运行。这里我把完整流程梳理一遍包括本机运行和生产部署两个场景。6.1 本机运行项目的前置条件JDK 1.8配置JAVA_HOME环境变量。Maven 3.6配置Maven阿里云镜像加速依赖下载。MySQL 5.7或8.x创建好数据库并导入初始SQL脚本。Node.js 12npm 6。IDE推荐IDEA前端也可以直接用VS Code。启动顺序建议第1步启动MySQL服务确认数据库能够正常连接。第2步导入初始化脚本。用Navicat或命令行执行项目的medical.sql文件初始化表结构和测试数据。第3步修改后端配置文件application.yml把数据库连接串、账号密码改成自己本地的。第4步启动后端Spring Boot应用。如果项目依赖Redis还要保证Redis服务已启动。这个项目如果纯用MySQL存储登录态和缓存数据可以不依赖Redis减少部署负担。第5步进入前端项目目录执行npm install安装依赖然后npm run serve启动开发服务器。第6步浏览器访问前端开发服务器地址默认http://localhost:8080用测试账号登录验证。6.2 生产部署的简化流程后端打jar包使用Maven的package命令生成可执行jar文件通过java -jar运行。mvn clean package -DskipTests nohup java -jar medical-server.jar server.log 21 前端构建执行npm run build生成dist目录。把dist目录里的文件部署到Nginx的html目录下。Nginx配置示例server { listen 80; server_name your-domain.com; # 前端页面 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }需要注意的一点是try_files和proxy_pass的区别前者让前端路由在刷新时不会404后者把接口请求转发给后端服务。6.3 部署时的常见问题端口被占用后端启动报端口占用可以先找占用端口的进程或者直接改配置文件中的端口号。jar包启动但接口不通排查防火墙和云服务器安全组是否放行了对应端口。数据库连接失败确认服务器数据库远程访问权限已开放且账号允许从对应IP连接。前端刷新404Nginx缺少try_files配置SPA单页应用需要把不存在的路径都指向index.html。7. 从课程设计到面试项目的进阶建议这个系统做完除了交差完全可以作为面试时的项目经验来讲。有几个点建议你在做的时候重点打磨面试时能讲出东西来。并发扣库存药品库存扣减用SQL原子操作可以展开讲讲为什么不用“先查后更”可以引出乐观锁、悲观锁、分布式锁的一些概念这是面试官比较感兴趣的方向。权限模型JWT权限控制的实现可以聊一聊token续期方案、会话管理等方向。这块比较通用属于大多数业务系统都会遇到的问题。事务边界开方这个操作的多表事务一致性可以聊聊什么情况下事务会失效比如this调用后方法、异常被catch没有抛出等这些都是经典问题。表结构设计药品快照的设计思路可以解释为什么处方明细冗余了药品名称和价格引出一个经典设计问题订单快照为什么要冗余商品信息。前端路由权限动态路由控制、按角色渲染菜单这些概念可以说明你在前端工程化上不是只会写页面。面试时讲项目核心是讲清楚这几个点我遇到了什么问题我为什么这么设计中间踩了什么坑。这个系统只要真正动手写过一遍很多细节就能讲得比较扎实。医疗问诊拿药系统的开发过程是个比较完整的全栈实战从数据库设计到后端服务再到前端页面整个流程走通一遍对Java和Vue的理解都能更深入一些。项目自带的源码、数据库脚本和文档是比较完整的学习资料但建议拿到后不要只停留在“能跑起来”的阶段而是把每个模块的代码逐行读一遍理解接口为什么要这么设计事务为什么要放在这一层前端路由守卫是怎么生效的。真正动手改过、补过功能跑通从需求到交付的每个环节这才是这个项目能带来的最大价值。