简介单体架构并非落后而是中小型业务系统里性价比最高的工程解法之一。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都是当前性价比最高的起点。SSHStruts2 Spring Hibernate已经是上一个十年的技术栈新项目再用它招人都是问题。Spring Data JPA虽然开发快但复杂查询比如慢病随访记录按时间区间聚合写起来不如MyBatis直观而且JPA的懒加载和N1查询在不懂的人手里很容易翻车。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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your_password jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 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: 0map-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, deleteduser_account系统账号id, username, password, role, resident_id, statusappointment预约挂号id, patient_id, doctor_id, department_id, appointment_date, time_slot, statusmedical_record门诊病历id, patient_id, doctor_id, visit_date, chief_complaint, diagnosis, treatment_planprescription处方单id, record_id, total_amount, statusprescription_item处方明细id, prescription_id, drug_name, specification, dosage, quantity, unit_pricechronic_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 ResultLong 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 BaseMapperChronicFollowUp { ListMapString, Object countFollowUpByMonth(Param(startDate) String startDate, Param(endDate) String endDate); }对应的XML里写统计SQLselect idcountFollowUpByMonth resultTypemap 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后台管理如何接上SpringBoot4.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 一个HandlerInterceptorpublic 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;charsetUTF-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;charsetUTF-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是否17MyBatis-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默认使用服务器系统时区而国内服务器的时区常常是CSTChina Standard Time。驱动解析CST时可能把它解读成美国中部时间导致时间偏移。拦截器、应用层、数据库三处时区只要有一处不一致你就等着时间错乱。解决在JDBC连接串里显式加serverTimezoneAsia/Shanghai同时在JVM启动参数或SpringBoot配置文件里设spring.jackson.time-zoneGMT8。如果还有问题执行SELECT NOW()对照一下数据库当前时间再对比应用日志时间看偏差出现在哪一段。5.3 避坑之「前端history路由刷新404Nginx和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.activeprod再配一个application-prod.yml区分开发和生产配置。这个细节在毕业答辩演示和实际交付场景里评价会完全不一样。回看这个项目的完整落地链路我最大的教训是社区医疗平台的技术挑战从来不在某个高深算法而在于把居民档案、预约挂号、门诊开方、慢病随访这条长链路的数据流理清楚。SpringBoot在这里扮演的是稳定底座它不花哨但够可靠。项目交付时宁可功能少一个模块也别让权限和数据脱敏留窟窿——前者还能迭代后者一但出事就是信任崩塌。如果你正在啃这套源码按上面的流程把工程跑通、把表关系画出来、把避坑清单过一遍再用我给的验证清单把系统测一轮这个平台的架构和改造点就都在你脑子里了。希望帮到你。本文还有配套的精品资源点击获取