ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SpringBoot+MyBatis-Plus社区医疗平台:从ER图到单体架构落地实践

SpringBoot+MyBatis-Plus社区医疗平台:从ER图到单体架构落地实践

简介:单体架构并非落后,而是中小型业务系统里性价比最高的工程解法之一。SpringBoot作为Java后端的主流框架,天然适合快速搭建可维护的Web管理系统,配合MyBatis-Plus提供的内置CRUD、分页与逻辑删除能力,能显著降低数据持久层的开发成本。在社区医疗这类日访问量有限、但业务链路长的场景中,单体架构既保证了开发效率,也简化了部署与排错流程。围绕居民健康档案、预约挂号、门诊开方和慢病随访等核心环节,清晰的数据库表设计加上事务控制与权限校验,就能让一个医疗管理平台从能跑到真正可用。本文基于SpringBoot + MyBatis-Plus + Vue3的技术组合,梳理社区医疗平台的工程结构、后端接口规范、前端联调方式以及常见的版本与时区陷阱,为同类管理系统的开发与源码改造提供可复用的落地思路。

1. 一张ER图看懂中山社区医疗综合服务平台:SpringBoot单体架构为什么够用

「中山社区医疗综合服务平台」这个名字听起来像某个城市的政务项目,但落到技术层面,它就是一个典型的Java SpringBoot管理系统:围绕社区居民的建档、门诊、体检、慢病随访等日常医疗场景,把线下纸质流程搬到一个前后端分离(或半分离)的Web系统里。这类项目在毕业设计、课程设计、以及中小型社区卫生服务中心的信息化改造里出现频率极高,核心价值不在于算法多复杂,而在于业务闭环是否完整——从居民建档到医生开方,数据能不能在同一个系统里流转。

我第一次拿到类似需求时,第一反应也是要不要上微服务、要不要搞分布式事务,后来被自己否了。社区卫生服务中心的真实场景是:日门诊量几百人次,并发峰值极低,数据量以十万级计,一台4核8G的服务器就能扛住全部压力。SpringBoot + MyBatis-Plus + Vue3(或Thymeleaf)的单体架构,在这个体量下是最稳的选择:开发效率高、排错链路短、部署只需要一个jar包加一个MySQL。

这篇笔记不替你做完整代码搬运,而是把「从零搭这样一个平台」的路径拆清楚:技术选型为什么这么定、核心表怎么设计、后端接口怎么写不绕弯、前端打包后怎么塞进SpringBoot。你拿到的如果是一份现成源码,这篇能帮你快速读懂它;如果你打算自己动手写,这篇能帮你少走三个月弯路。

2. 技术选型与工程骨架:先定边界,再写代码

2.1 为什么是SpringBoot + MyBatis-Plus而不是SSH或JPA

社区医疗平台的需求方通常是两类人:一类是带项目的老师,关心的是学生能不能把CRUD写清楚、业务流程有没有漏洞;另一类是社区卫生服务中心的信息科,关心的是系统能不能跑三年不出大问题。这两个场景下,SpringBoot都是当前性价比最高的起点。

SSH(Struts2 + Spring + Hibernate)已经是上一个十年的技术栈,新项目再用它,招人都是问题。Spring Data JPA虽然开发快,但复杂查询(比如慢病随访记录按时间区间聚合)写起来不如MyBatis直观,而且JPA的懒加载和N+1查询在不懂的人手里很容易翻车。MyBatis-Plus的优势在社区医疗这类以单表操作为主、少量多表关联查询的业务里体现得最明显:内置的BaseMapper让你不用写XML就能完成单表CRUD,分页插件一键启用,逻辑删除和自动填充都是开箱即用的。

提示:如果你的源码里看到的是Spring Boot 2.x而不是3.x,别急着升级。Spring Boot 3要求JDK 17起步,且javax.servlet被替换成了jakarta.servlet,很多老代码直接跑不起来。先确认你本机的JDK版本再决定要不要动版本。

2.2 标准工程结构与包名规范

拿到源码后第一件事不是点运行,而是看目录结构。一个规范的单体工程应该是「按业务模块分包」而不是「按技术类型分包」。我见过太多失败案例是controller包下堆了几十个类,每个类名带Controller后缀但谁也说不清哪个接口管哪个业务。

常见做法是分层分包加模块化命名,像下面这样:

com.zscommunity.medical ├── common // 通用返回体、异常处理、工具类 │ ├── result │ ├── exception │ └── utils ├── config // 配置类:跨域、拦截器、MyBatis-Plus分页 ├── module │ ├── patient // 居民健康档案模块 │ │ ├── controller │ │ ├── service │ │ ├── mapper │ │ └── entity │ ├── appointment // 预约挂号模块 │ ├── outpatient // 门诊诊疗模块 │ └── chronic // 慢病管理模块 └── MedicalApplication.java

这种结构的核心逻辑是:一个业务模块的Controller、Service、Mapper、Entity放在同一个包路径下,改需求时只需要打开一个目录,所有相关文件都在眼前。相比先建controller包再建service包的平铺式结构,这种方式在项目中期维护时能省大量找文件的时间。

2.3 最小可运行配置

如果源码带了SQL脚本,先把数据库建好;如果没带,你就要根据实体类反推建表语句。无论哪种情况,application.yml里的这几项配置是必须确认的:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/community_medical?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 servlet: multipart: max-file-size: 10MB max-request-size: 50MB 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: 0

map-underscore-to-camel-case和logic-delete-field这两个配置是社区医疗这类有历史数据追溯需求的系统里最容易忽略的。前者保证数据库字段patient_name能自动映射到实体类的patientName,后者让你删档案时不真的DELETE,而是置一个deleted标记——这在医疗场景里是底线要求,居民健康档案不能物理删除,出了问题要能追溯。

3. 核心业务表设计与后端实现:一张表一个故事

3.1 社区医疗的七张核心表

社区医疗平台和普通企业管理系统的最大区别在于:它有非常明确的主数据链路——居民从首次建档到每次就诊,所有数据必须能串起来。我最开始设计时只画了五张表,结果做到门诊开药环节就发现缺了处方明细表,不得不回头重构。如果从零开始,建议至少规划这七张核心表:

表名用途关键字段
resident_profile居民健康档案id, id_card, name, gender, birth_date, phone, address, blood_type, allergy_history, deleted
user_account系统账号id, username, password, role, resident_id, status
appointment预约挂号id, patient_id, doctor_id, department_id, appointment_date, time_slot, status
medical_record门诊病历id, patient_id, doctor_id, visit_date, chief_complaint, diagnosis, treatment_plan
prescription处方单id, record_id, total_amount, status
prescription_item处方明细id, prescription_id, drug_name, specification, dosage, quantity, unit_price
chronic_follow_up慢病随访id, patient_id, disease_type, follow_up_date, blood_pressure, blood_sugar, medication, next_visit_date

这里有一条设计经验:处方单和处方明细必须拆成两张表。如果图省事把药品直接塞进处方表,遇到一张处方开五种药的情况,数据冗余会把你逼疯。拆开后,统计某位医生一个月开了多少盒阿莫西林,一条SQL就能搞定。

3.2 居民档案接口:写CRUD也要讲规范

先看一个最基础的居民档案新增接口。很多人写CRUD喜欢直接用@RequestBody Map接收参数,图省事,但这样写项目里到处是魔法字符串,改字段名编译期根本报不出来。更稳妥的是用DTO接受前端参数,再转成实体类落库:

@RestController @RequestMapping("/api/resident") public class ResidentController { @Autowired private ResidentService residentService; @PostMapping("/add") public Result<Long> addResident(@RequestBody @Valid ResidentAddDTO dto) { // 校验身份证号格式 if (!IdCardUtil.isValid(dto.getIdCard())) { return Result.error("400", "身份证号格式不正确"); } // 检查是否已存在相同身份证号的档案 Long existingId = residentService.findByPlanIdCard(dto.getIdCard()); if (existingId != null) { return Result.error("409", "该居民已存在档案,请勿重复建档"); } Long residentId = residentService.addResident(dto); return Result.success(residentId); } }

@Valid注解配合DTO里的@NotBlank、@Pattern做参数校验,能把身份证格式、手机号格式这类脏数据挡在接口之外。重复建档检查是社区医疗平台必须做的——同一个身份证号建两条档案,后面所有就诊记录都会串掉,这个坑我踩过一次,后期合并数据花了整整一周。

3.3 门诊开方流程:Transaction注解应该放在哪一层

门诊诊疗流程是平台的核心链路:医生选择患者 → 写病历 → 开处方 → 保存。这个流程涉及medical_record和prescription、prescription_item三张表的写入,必须保证原子性——病历保存成功但处方失败,这数据就废了。

@Service public class OutpatientService { @Autowired private MedicalRecordMapper recordMapper; @Autowired private PrescriptionMapper prescriptionMapper; @Autowired private PrescriptionItemMapper itemMapper; @Transactional(rollbackFor = Exception.class) public Long createVisit(VisitCreateDTO dto) { // 1. 创建病历主记录 MedicalRecord record = new MedicalRecord(); record.setPatientId(dto.getPatientId()); record.setDoctorId(dto.getDoctorId()); record.setChiefComplaint(dto.getChiefComplaint()); record.setDiagnosis(dto.getDiagnosis()); recordMapper.insert(record); // 2. 创建处方单 Prescription prescription = new Prescription(); prescription.setRecordId(record.getId()); prescription.setStatus("PENDING_PAYMENT"); prescription.setTotalAmount(calculateTotalAmount(dto.getItems())); prescriptionMapper.insert(prescription); // 3. 批量插入处方明细 for (PrescriptionItemDTO item : dto.getItems()) { PrescriptionItem detail = new PrescriptionItem(); detail.setPrescriptionId(prescription.getId()); detail.setDrugName(item.getDrugName()); detail.setQuantity(item.getQuantity()); detail.setUnitPrice(item.getUnitPrice()); itemMapper.insert(detail); } return record.getId(); } }

@Transactional(rollbackFor = Exception.class)的意思是:只有抛出Exception及其子类才回滚。如果只写@Transactional不指定rollbackFor,遇到自定义运行时异常以外的Checked Exception是不会回滚的,这也是很多初学者「明明加了事务但还是写了一半数据」的原因。这段逻辑里有一个技巧点:先通过recordMapper.insert(record)拿到自增主键,再用它去关联prescription和明细,如果先插入明细再回头补主键,事务的时序就乱了。

3.4 慢病随访的统计接口:分组查询让报表不再靠人工Excel

慢病管理是社区医疗和公立医院最大的区别所在。公立医院看一次病就结束了,社区医疗要求对高血压、糖尿病患者做周期性随访,平台必须有「某月随访了多少人、达标率多少」这类统计能力。

@Mapper public interface FollowUpMapper extends BaseMapper<ChronicFollowUp> { List<Map<String, Object>> countFollowUpByMonth(@Param("startDate") String startDate, @Param("endDate") String endDate); }

对应的XML里写统计SQL:

<select id="countFollowUpByMonth" resultType="map"> SELECT DATE_FORMAT(follow_up_date, '%Y-%m') AS month, COUNT(DISTINCT patient_id) AS patient_count, COUNT(*) AS total_visits FROM chronic_follow_up WHERE follow_up_date BETWEEN #{startDate} AND #{endDate} GROUP BY DATE_FORMAT(follow_up_date, '%Y-%m') ORDER BY month DESC </select>

这里用COUNT(DISTINCT patient_id)而不是COUNT(*),是因为同一个患者在一个月内可能被随访两次,统计人数时不能重复计数,但统计工作量时可以算两次。两个指标的口径不同,这在社区上报数据时会被特别审核,写错了整个月的报表都要重做。不要把这类统计逻辑写在Java里用for循环数,数据库分组聚合比你写的任何循环都快,而且写出来就是一张可以直接塞进报表的数据结构。

4. 前端落地与联调:Vue3后台管理如何接上SpringBoot

4.1 选Vue3还是Thymeleaf:取决于你拿到的源码

「中山社区医疗综合服务平台」这类代码,前端常见两种形态:一种是用Vue3 + Element Plus做的独立后台管理系统,另一种是SpringBoot直接套Thymeleaf模板的经典单体页面。如果你拿到的源码是前者,那通常是前后端分离架构,前端工程独立部署或用Nginx反代;如果是后者,那打包后只有一个jar包,页面都在templates目录下。

从近年市场看,Vue3后台管理系统已经成为主流——这跟组件生态成熟度和招人标准有关。现在毕业设计或企业新项目的技术选型里,Vue3 + Vite + Element Plus的组合几乎成了标配。它的好处是组件化开发,每个页面模块独立,改一个表格组件不会影响其他页面。坏处是打包部署链路比Thymeleaf长一点,需要先npm run build再处理静态资源。

4.2vite.config.js里那个绕不开的代理配置

前后端分离联调时,前端开发服务器和后端接口不在同一个端口,直接请求必然跨域。很多新手在这个环节被卡住,然后在后端写个CorsFilter全局放行,这治标不治本。更规范的做法是在vite.config.js里配代理,开发时让前端请求路径转发到后端:

// vite.config.js export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, server: { host: '0.0.0.0', port: 3000, proxy: { // 所有以 /api 开头的请求,转发到后端 8080 端口 '/api': { target: 'http://localhost:8080', changeOrigin: true, // 如果后端接口前缀就是 /api,不需要重写路径 // rewrite: path => path.replace(/^\/api/, '') } } } })

changeOrigin: true保证后端收到的请求头里Host跟目标地址一致,有些后端拦截器会校验Host来源,不写这个可能莫名其妙地拿不到数据。rewritePath这个注释不是摆设——如果你的后端Controller实际路径是/resident/add,而前端请求写的是/api/resident/add,就必须打开rewrite把/api前缀去掉,否则404。前后端谁改路径,先确认这个代理配置,省得排查半小时最后发现是路径前缀没对齐。

4.3 把Vue打包产物塞进SpringBoot jar包

生产环境不一定要单独部署Nginx。如果你的系统面向的是一个社区卫生服务中心,服务器资源紧张到可能只有一台Windows主机,那「Vue打包后放进SpringBoot」的做法更务实。

执行npm run build后,Vue默认输出到dist目录。把这个目录里的所有文件复制到SpringBoot项目的src/main/resources/static目录下,重启后端服务,访问http://localhost:8080就能看到页面。SpringBoot会自动把/static目录映射成静态资源根路径。

# 打包命令(在vue项目根目录执行) npm run build # 复制产物到SpringBoot静态资源目录(Linux/Mac) cp -r dist/* ../src/main/resources/static/ # 重新打包后端 mvn clean package -DskipTests

这里有个隐藏配置:如果你的后端接口路径也是/开头的,静态资源和接口可能出现冲突。规避方法是所有后端接口统一加/api前缀,静态资源不带这个前缀,SpringBoot的请求映射自然会把两类请求分开。如果Vue项目里用了Vue Router的history模式,刷新页面时报404,原因是没有做路由回退——这个后面在避坑章节细说。

4.4 登录鉴权的Axios拦截器写法

管理系统必然有登录页,完整的鉴权流程是:前端登录拿到token后存到localStorage,请求时在Axios拦截器里带上Authorization头,后端用一个拦截器校验token。社区医疗平台通常不会用那种重型的Spring Security + OAuth2方案,够用的做法是JWT + 一个HandlerInterceptor:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains("/login")) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); return false; } try { // 解析token,将用户信息放入request Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); return true; } catch (Exception e) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"token过期或无效\"}"); return false; } } }

JWT的过期时间设置是个讲究:社区医疗场景下医生可能上午登录后一整天都不再操作,token有效期设4小时会导致下午继续开方时突然被踢下线;设24小时又增加token泄露风险。折中方案是token有效期4小时,但前端在Axios响应拦截器里捕获401后,用refreshToken去换新token。如果你图省事不想做刷新机制,直接把有效期设到24小时+,能用但不优雅。

5. 源码排查与改造:拿到一套代码后先做这五件事

5.1 避坑之「SpringBoot版本太高导致JPA或MyBatis-Plus不兼容」

现象:项目能编译成功,一启动就报ClassNotFoundException: javax.servlet.Filter或NoSuchMethodError。

原因:这是最典型的SpringBoot 3.x兼容性问题。SpringBoot 3从Java EE迁移到了Jakarta EE,所有javax.*包变成了jakarta.*。如果你的字节点上用的是Spring Boot 3.2,但MyBatis-Plus用的是旧版,或者Tomcat版本和部分第三方依赖冲突,就会出现上面的报错。

解决:优先检查pom.xml里的SpringBoot版本。如果它是3.x,确认下面三件事:JDK是否17+,MyBatis-Plus是否为3.5.3+,依赖里是否混用了官方Spring Initializr生成的模板。最省事的做法是把<parent>里SpringBoot版本降到2.7.x,同时Java版本降到1.8或11,这是社区医疗这类管理系统最成熟稳定的组合。

5.2 避坑之「MySQL连接串里没有serverTimezone导致的8小时时差」

现象:测试时新增一条记录,数据库里的create_time比当前时间晚了8小时或直接报The server time zone value'�й���ʱ��'is unrecognized。

原因:MySQL驱动8.0以后,连接串不指定serverTimezone,默认使用服务器系统时区,而国内服务器的时区常常是CST(China Standard Time)。驱动解析CST时可能把它解读成美国中部时间,导致时间偏移。拦截器、应用层、数据库三处时区只要有一处不一致,你就等着时间错乱。

解决:在JDBC连接串里显式加serverTimezone=Asia/Shanghai,同时在JVM启动参数或SpringBoot配置文件里设spring.jackson.time-zone=GMT+8。如果还有问题,执行SELECT NOW()对照一下数据库当前时间,再对比应用日志时间,看偏差出现在哪一段。

5.3 避坑之「前端history路由刷新404,Nginx和jar包两种场景解法不同」

现象:登录后进入首页,点击浏览器刷新,页面白屏或显示Whitelabel Error Page。

原因:Vue Router的history模式用的是HTML5 History API,刷新时浏览器向服务器请求当前路径,但SpringBoot静态资源映射里根本没有这个路径对应的文件,于是返回404。hash模式不会出这个问题,但URL里会多一个#,看起来没那么正式。

解决:如果你的Vue打包产物是放进SpringBoot的static目录,可以通过添加一个ViewController把非接口路径全转发到index.html。更干净的方案是用Nginx部署前端,在nginx.conf里配try_files $uri $uri/ /index.html;。注意:这个转发规则对后端接口路径要放行,否则接口请求也会被转发到首页。

5.4 避坑之「@RequestBody参数里缺少必填字段,400报错信息不够明确」

现象:前端调用新增接口,返回HTTP 400,后台日志只看到Required request body is missing,前端根本不知道哪个字段没传。

原因:Controller的参数没有做Bean Validation,或者DTO上没加@NotNull、@NotBlank注解。SpringBoot默认的400信息只提示请求体缺失或解析失败,不会精确到具体字段。

解决:给DTO的必填字段加上javax.validation.constraints注解,比如@NotBlank(message = "患者姓名不能为空"),同时在Controller用@Valid @RequestBody触发校验,再配合全局异常处理器捕获MethodArgumentNotValidException,把message返回给前端。这样前端能直接弹出「患者姓名不能为空」,而不是自己对着控制台猜。

5.5 避坑之「CORS跨域配置写在网关层,拦截器却不生效」

现象:前端开发环境通过proxy转发请求一切正常,但部署后前端域名与后端不同,请求直接被浏览器拦截,即使后端已经加了@CrossOrigin注解或用CorsFilter处理过。

原因:跨域配置的位置不对,或者拦截器的执行顺序在CORS处理之前,导致预检请求OPTIONS被拦截器拦下来返回401,后续真正的请求根本没发出去。

解决:如果你用拦截器做JWT校验,一定要在WebMvcConfigurer中把CORS配置和拦截器注册分开写,并确认InterceptorRegistry里注册的路径不拦截OPTIONS方法。常见做法是定义一个CorsFilter的Bean,并确保它的order值小于拦截器的order值,让预检请求先通过CORS放行,再进入拦截器。

6. 从能跑到能用:三项验证清单与两个实战技巧

代码在本地跑通只是开始,社区医疗平台真正交付前要做的验证远不止CRUD通不通。我第一次做类似系统时,把全部接口测完就以为完工了,结果给社区医生演示当天就暴露了三个问题。这里整理一份验证清单,照着走一遍能少丢一次人。

第一个必做项是权限边界验证。用护士账号登录后,尝试调用只有管理员才能用的接口,比如删除居民档案、修改系统配置。很多系统的前端按钮是根据角色隐藏的,但后端Controller上根本没加权限注解,用Postman直接发请求就能绕过前端进入接口。SpringBoot里用@PreAuthorize("hasRole('ADMIN')")做方法级权限控制,或者简单点在拦截器里判断当前用户角色,总之不能让前端隐藏按钮成为唯一防线。

第二个必做项是敏感字段脱敏验证。打开居民列表接口,检查响应里有没有把身份证号、手机号、详细地址完整返回。社区医疗的数据比普通管理系统敏感得多,这些字段泄露一次就是事故。做法是后端在接口返回时对敏感字段做脱敏处理,只保留前三位和后四位,或者按角色控制字段可见性。如果你拿到的源码里没有做这个,把它列为第一优先级改造项。

第三个必做项是极端数据量的分页压力测试。往medical_record表里插入十万条测试数据,再调用分页接口看响应时间。MyBatis-Plus的分页插件默认limit是写死的,但如果没有给Mapper传分页参数,它会一次性查全表,十万条数据足够把接口拖到几秒级。用SELECT COUNT(*)语句估算全表扫描时间,如果超过500毫秒,就考虑在visit_date或patient_id上建索引。

最后一个实战技巧是给系统做一份「一键启动」脚本。社区的服务器往往是Windows或Linux各占一半,而且操作人可能不是专业运维。在项目根目录放一个start.bat和start.sh,里面写好java -jar target/community-medical.jar --spring.profiles.active=prod,再配一个application-prod.yml区分开发和生产配置。这个细节在毕业答辩演示和实际交付场景里,评价会完全不一样。

回看这个项目的完整落地链路,我最大的教训是:社区医疗平台的技术挑战从来不在某个高深算法,而在于把居民档案、预约挂号、门诊开方、慢病随访这条长链路的数据流理清楚。SpringBoot在这里扮演的是稳定底座,它不花哨但够可靠。项目交付时,宁可功能少一个模块,也别让权限和数据脱敏留窟窿——前者还能迭代,后者一但出事就是信任崩塌。如果你正在啃这套源码,按上面的流程把工程跑通、把表关系画出来、把避坑清单过一遍,再用我给的验证清单把系统测一轮,这个平台的架构和改造点就都在你脑子里了。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表