1. 高校医疗场景的特殊性:这系统到底该做成什么样
1.1 校医院业务"预防为主、门诊为辅"的特征
每年九月的开学季,校医院门口排队体检的队伍能从挂号窗口一直排到路边。这个场景相信大家都不陌生。校医院和综合医院最大的区别在于,它不需要应付疑难杂症,核心业务是预防保健:入学体检、毕业体检、疫苗接种、传染病防控、慢性病跟踪。而日常门诊虽然也有,但通常集中在感冒发烧、运动损伤、换季过敏这几类常见病上。
这个业务特征直接决定了系统的功能图谱和优先级。如果一上来就照搬综合医院信息系统的模块清单,那项目会越做越大,最后连自己都收不住。我接手这个项目时,甲方(学校信息中心)确实曾经做过一个什么模块都想要的版本,结果用了半年就搁置了,门诊大夫嫌流程太绕,学生嫌预约不便。后来重新梳理时,我们只保留了四个核心闭环:学生健康档案管理、预约挂号、体检管理、健康预警与转诊跟踪。事实证明这样做反而是最被认可的。
1.2 系统边界:不是HIS,也不是电子病历
很多第一次做医疗相关系统的开发者,容易被"医疗系统"四个字吓住,或者反过来,想一口吃成胖子,把药房进销存、财务结算、电子病历全塞进来。我要泼一盆冷水:这些内容在校医院场景下大概率是伪需求。
校医院通常不具备完整的电子病历管理资质,病历数据往往要上报到上级卫生平台,校内系统只需要留存一份可供查询的摘要即可。药房管理如果做进这个系统里,做一个库存台账没问题,但要做到能和财务对账级别的进销存,那就是另一套GSP系统的活了。所以我在一开始就给项目画了一条边界线:
- 做:个人健康档案(含体检数据)、预约挂号、门诊日志、体检批次管理、异常指标预警、转诊审批、统计报表。
- 不做:电子病历全文、药品进销存财务、医保对接、排班考勤。
这个边界需要在论文的"需求分析"章节里写清楚。评审老师最喜欢问的一个问题就是"你这个系统和医院HIS有什么区别",如果你能讲清楚边界和理由,这道题就过了。
1.3 从用户视角拆解核心诉求
再进一步,我把系统涉及的角色和他们的真实诉求列了一个矩阵,这个矩阵也是后面做权限设计和功能开发的依据:
| 角色 | 日常诉求 | 系统功能映射 |
|---|---|---|
| 学生 | 快速预约门诊/体检,查看自己的档案和报告 | 预约挂号、健康档案查询、体检报告查看 |
| 校医 | 处理预约、登记门诊、录入体检结果,减少重复劳动 | 接诊管理、体检结果批量导入、门诊日志 |
| 辅导员 | 知道本学院学生有没有重大健康异常,但不需要看细节 | 健康预警通知(脱敏)、在校生健康统计 |
| 系统管理员 | 基础数据维护、账号管理、报表导出 | 用户管理、字典管理、统计报表 |
在这个矩阵基础上,我建议所有做这个题目的朋友都画一张"核心业务流程图"。我的流程图其实就是一条主线:学生入学 → 建立健康档案 → 参加体检/门诊 → 系统分析指标 → 命中阈值生成预警 → 异常学生进入转诊或跟踪 → 毕业时档案归档。整个系统就是围绕这条主线展开的,后面的数据模型设计和接口设计都跟着它走。
2. 技术选型与工程结构:SpringBoot 2.7为主干的前后端分离方案
2.1 版本选型:为什么锁定SpringBoot 2.7而非3.x
说到技术选型,这里分享一个实际决策过程。当时做评估时,SpringBoot 3.x已经发布了,但我在对比之后还是选了2.7.x,主要原因有三个:
第一,3.x基于Jakarta EE 9,包名从javax改成了jakarta,这意味着很多旧的三方库需要跟着升级。校医院项目并不追求新特性,稳定性压倒一切。第二,2.7是3.0发布前最后一个成熟的2.x版本,社区资料、面试题、毕设参考代码几乎都兼容这个版本,学生做毕设时遇到问题更容易搜到答案。第三,绝大多数高校的Java课程还停留在Java 8/11,SpringBoot 2.7恰好完美兼容Java 8,而SpringBoot 3.x要求Java 17起步,换运行时环境会带来一堆额外麻烦。
所以我的结论很明确:如果不是有迫不得已的理由,面向校园场景的应用就选SpringBoot 2.7.18 + Java 8。你要在论文里写清楚对比过程,这比直接写"用了SpringBoot"要加分得多。
2.2 ORM选型:MyBatis-Plus比JPA更适合这种项目
ORM框架怎么选?我对比过MyBatis-Plus和Spring Data JPA,最终选了前者。这里不是说我否定JPA,而是在这个项目的具体约束下,MyBatis-Plus有三个压倒性优势。
第一,MyBatis-Plus内置了单表CRUD、分页插件、逻辑删除、自动填充这些能力,像健康档案表、预约记录表这类单表操作,基本不需要手写SQL。第二,SQL完全可控,一旦涉及多表关联查询(比如按学院统计体检异常率),你可以直接写原生SQL,出了问题也好排查,这是JPA的复杂关联查询比不了的。第三,国内做Java毕设和中小项目的团队用MyBatis-Plus的比例非常高,对于学生来说,参考资料多,遇到问题在社区一搜就有答案。
数据访问层的具体做法是:实体类上用@TableName、@TableId、@TableField注解做映射,逻辑删除字段加@TableLogic,创建时间和更新时间用MetaObjectHandler自动填充。分页就通过内置的PaginationInnerInterceptor实现,不用自己写PageHelper,省了一道桥接的功夫。
2.3 前端与中间件:Vue3 + Element Plus、MySQL、Redis
前端框架我选了Vue3配合Element Plus,构建工具用Vite。Element Plus的表单组件、表格组件、弹窗组件都直接可用,对于"管理后台+预约页面"这种典型界面来说,开发效率是最高的。Vue3的组合式API在写预约流程这种带状态流转的页面时,逻辑复用比Vue2的选项式API更顺手。这一块没有太多悬念,前端不是这个项目的主战场,够用、稳定、好看就行。
后端配套中间件是MySQL 8.0 + Redis。MySQL存所有业务数据,Redis承担两个职责:一是缓存预约时段的实时余量,二是存放登录验证码和JWT黑名单。Redis的引入要克制,不要什么数据都往里塞,我只在预约高峰和验证码场景使用,核心业务数据一律以MySQL为准。文件存储方面,体检报告PDF、检验单图片这类附件,考虑到服务器资源和部署复杂度,我直接存在本地磁盘,通过Nginx做静态映射。如果将来真要上云,再迁移到MinIO或OSS也方便。
2.4 工程结构:按业务域分包,而不是单纯按技术层分
工程结构上,很多初学者喜欢把controller、service、mapper、entity各建一个包,然后所有业务往里面乱塞。前几个模块还好,到了第十个模块就开始混乱。我这里采用的是"外层按技术层,内层按业务域"的混合结构。简单说:
com.example.univhealth ├── common # 通用返回体、异常、工具类 ├── config # 配置类(安全、跨域、MyBatis、Redis) ├── security # Spring Security、JWT、UserDetails ├── modules │ ├── profile # 健康档案 │ ├── reserve # 预约挂号 │ ├── exam # 体检管理 │ ├── clinic # 门诊日志 │ ├── alert # 健康预警 │ └── system # 用户、角色、菜单、字典 ├── task # 定时任务(体检状态扫描、清理过期时段) └── utils模块之间通过Service接口交互,禁止跨模块直接调用Mapper。比如reserve模块的Service要查询学生基本信息,只能调用profile模块暴露的接口,不能自己去查t_student_health_profile表。这个规矩能保证后续扩展的时候,不会把模块间的耦合搅成一锅粥。在论文的"系统设计"章节里画一张模块依赖图,评审老师一看就知道你不是随便分的包。
3. 核心数据模型设计:看这几张表怎么把业务闭环串起来
3.1 学生健康档案表:入学的第一份数据资产
健康档案是整套系统的基础,所有业务都离不开它。表的设计我建议至少包含这些字段:
CREATE TABLE `t_student_health_profile` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `student_no` varchar(20) NOT NULL COMMENT '学号(唯一索引)', `name` varchar(50) NOT NULL, `gender` tinyint(1) NOT NULL COMMENT '1男 2女', `birthday` date DEFAULT NULL, `college` varchar(100) NOT NULL COMMENT '学院', `major` varchar(100) DEFAULT NULL COMMENT '专业', `class_no` varchar(50) DEFAULT NULL COMMENT '班级', `blood_type` varchar(10) DEFAULT NULL, `allergy_history` varchar(500) DEFAULT NULL COMMENT '过敏史', `chronic_disease` varchar(500) DEFAULT NULL COMMENT '慢性病史', `emergency_contact` varchar(50) DEFAULT NULL, `emergency_phone` varchar(20) DEFAULT NULL, `exam_status` tinyint(1) DEFAULT '0' COMMENT '0未体检 1已体检', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` tinyint(1) DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `uk_student_no` (`student_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生健康档案表';有几个细节值得说。一是student_no必须设唯一索引,因为学号是校园里唯一标识一个人的业务键。二是allergy_history和chronic_disease我建议用字符串存,而不是用JSON字段。原因很简单:这两类信息在体检问诊时只是"有没有、是什么"的级别,不需要结构化到过敏原编码那么细,字符串够用了。三是逻辑删除字段deleted一定要加,学生转学或退学后档案不能物理删除,这是医疗数据的合规要求。四是从这张表延伸出的一个常见面试题:如果学生毕业后再次入校读研,学号变了,健康档案怎么关联?我的方案是在表里加一个pre_student_no字段记录前置学号,保证历史体检数据的可追溯性。
3.2 预约挂号模型:时段槽位设计是重点
预约挂号能不能做好,核心在时段槽位的设计上。我的做法是把"医生某一天的某个时间段"抽象成一个可预约的Slot(时段),而不是简单地记录"某天有号"。
CREATE TABLE `t_reserve_slot` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `doctor_name` varchar(50) NOT NULL COMMENT '坐诊校医', `dept_name` varchar(100) NOT NULL COMMENT '科室', `clinic_date` date NOT NULL COMMENT '门诊日期', `time_slot` varchar(30) NOT NULL COMMENT '时段,如09:00-09:15', `total_num` int(11) NOT NULL COMMENT '总号源', `remaining_num` int(11) NOT NULL COMMENT '剩余号源', `status` tinyint(1) DEFAULT '1' COMMENT '1可预约 0停诊', PRIMARY KEY (`id`), UNIQUE KEY `uk_date_slot_doctor` (`clinic_date`, `time_slot`, `doctor_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约时段表';这个设计的核心价值在于,系统能精确控制每个时段的最大接诊量,比如规定每15分钟最多放3个号,这样既保证了校医有充足的接诊时间,也避免了学生扎堆排队。对应地,学生预约记录表需要一个比较完善的状态机:
CREATE TABLE `t_reserve_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `student_no` varchar(20) NOT NULL, `slot_id` bigint(20) NOT NULL, `reserve_status` tinyint(1) DEFAULT '1' COMMENT '1已预约 2已取消 3已完成 4爽约', `reserve_time` datetime DEFAULT NULL, `cancel_time` datetime DEFAULT NULL, `finish_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_slot_student` (`slot_id`, `student_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约记录表';这里加的uk_slot_student唯一索引,是从数据库层面兜底防重复预约的关键。哪怕业务层逻辑写漏了,数据库也会拒绝同一个人预约同一个时段两次。
3.3 体检批次与结果:为什么用两张表而不是一张大宽表
体检管理我采用了批次-明细两表设计。一张t_exam_batch存批次信息,比如"2024级新生入学体检",字段包括batch_name、exam_type、target_group、start_date、end_date、status。另一张t_exam_item_result存每个学生每个体检项目的具体结果:
CREATE TABLE `t_exam_item_result` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `batch_id` bigint(20) NOT NULL, `student_no` varchar(20) NOT NULL, `item_name` varchar(50) NOT NULL COMMENT '检查项目,如身高/体重/视力/ALT', `exam_value` varchar(100) DEFAULT NULL COMMENT '检查值', `unit` varchar(20) DEFAULT NULL, `reference_range` varchar(100) DEFAULT NULL COMMENT '参考范围', `result_flag` tinyint(1) DEFAULT '0' COMMENT '0正常 1异常', `checked_by` varchar(50) DEFAULT NULL COMMENT '录入医生', PRIMARY KEY (`id`), KEY `idx_batch_student` (`batch_id`, `student_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体检项目结果表';有的同学会问:为什么不用JSON字段直接存一个学生的所有体检结果,一张表多省事?我的回答是:如果是纯粹做"查看报告"功能,JSON确实够用;但一旦你要做"全校学生血压异常的统计"、"按学院对比体检合格率"、"连续两年体重的变化趋势",用JSON就得把每条数据解析出来再聚合,性能和写法都很难受。用明细表后,这些统计无非就是SQL的GROUP BY item_name WHERE result_flag=1的事。我在论文里专门用了一小节解释这个"宽表vs明细表"的取舍,答辩时老师对这个回答比较认可。
3.4 门诊日志与药品台账:轻量但必须存在
门诊模块我用了一张t_clinic_visit表记录每次门诊的主诉、诊断、处理意见、开具药物等关键信息。注意:这里不追求做到电子病历级别,只要记录到"某个学生某天因什么问题来看过病、处理方案是什么"即可。为了后面统计各季节的常见病趋势,visit_date和disease_type两个字段要设索引。
药品台账t_drug_stock我只做基础库存记录:药品名称、规格、库存量、预警阈值。每次门诊开药后,通过Service层事务同时更新门诊记录和药品库存。这里要强调一个很容易犯的错:开药减库存和门诊记录必须放在同一个事务里,不然系统运行一段时间后库存数和实际库存就对不上了。
4. 多角色权限体系:从RBAC到数据权限的一次完整落地
4.1 角色权限矩阵:先梳理清楚谁干什么
权限设计最忌讳一上来就写代码。我先把角色和权限整理成矩阵,后面实现时照着落地就行:
| 角色 | 可访问功能 |
|---|---|
| 学生 | 提交预约、取消预约、查看本人健康档案与体检报告、提交转诊申请 |
| 校医 | 处理预约、登记门诊、录入/导入体检结果、查看接诊记录、审批转诊 |
| 辅导员 | 查看本学院学生的健康预警通知(脱敏)、查看在校生体检完成统计 |
| 系统管理员 | 用户管理、角色管理、时段配置、药品台账、字典维护、全量报表 |
功能权限我用Spring Security的@PreAuthorize("hasRole('DOCTOR')")这种注解控制,界面菜单由后端返回动态路由,根据不同角色渲染不同页面。这里就不展开详细代码了,网上的例子很多,真正容易翻车的是数据权限。
4.2 数据权限隔离:辅导员只能看本学院,校医只能看自己批次
假如你做完功能权限就收工,系统会有一个致命漏洞:辅导员路由/api/alert/list查到的是全校的健康预警列表。因为功能权限只控制了"能不能进这个接口",没控制"能看哪些数据"。这就是数据权限要做的事。
我的实现方案是在Mapper层做数据范围拦截,利用MyBatis-Plus的DataPermissionInterceptor,在SQL执行前自动拼接数据范围条件。具体规则是:
- 辅导员角色访问学生列表或预警列表时,自动在SQL后面拼
AND college = '当前辅导员所属学院'。 - 校医角色访问体检结果时,自动拼
AND checked_by = '当前登录校医姓名'。 - 系统管理员不做任何拼接。
如果不想用拦截器,也可以用自己的AOP切面在Service层注入查询条件。但实名建议用拦截器方案,因为它的作用对业务代码是透明的,不会在几十个Service方法里到处漏条件。这个设计我认为是整套系统里最值得在论文里展开写的部分,它直接体现了你对"权限"这个词的理解深度。
4.3 Spring Security + JWT的授权链路
认证授权我用了Spring Security + JWT的组合。整体流程是:登录接口校验账号密码(密码存的是BCrypt哈希),成功后签发一个JWT,客户端把Token放在请求头的Authorization字段里,后续所有请求通过一个OncePerRequestFilter过滤器解析Token,把用户ID和角色权限塞进SecurityContext。
这里有两个细节必须提醒。第一,JWT的密钥不能硬编码在代码里,要放到application.yml并用环境变量覆盖,防止打包后泄密。第二,为了方便"退出登录"和"修改密码后踢下线",我在Redis里维护了一个JWT黑名单,Token加入黑名单后过滤器就会拒绝。单机部署这套方案够用,完全不需要引入独立的授权服务器。
spring: security: jwt: secret: ${JWT_SECRET} expire-hours: 12@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(STATELESS) .and() .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/captcha").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/doctor/**").hasRole("DOCTOR") .requestMatchers("/api/counselor/**").hasRole("COUNSELOR") .anyRequest().authenticated()) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }4.4 转诊审批:用状态机管理一条业务链路
转诊流程是系统中比较有意思的一个功能。学生提交转诊申请后,记录状态经历:待辅导员初审 → 待校医复审 → 已通过/已驳回 → 已转诊(学生到外院就诊后回填结果)。我在代码里用了一个简单的状态机枚举类,每个状态定义允许的下一步动作,不允许跳转。这样写的好处是流程清晰,不会因为代码里一堆if/else判断导致状态混乱。答辩时如果被问到"转诊过程的边界情况怎么处理",你可以回答:同一次申请只能存在一条流转记录,通过或驳回后自动发站内信通知申请人,学生端还能看到审批意见和材料附件的完整时间线。
5. 关键功能实现细节与避坑记录
5.1 预约防超卖:原子UPDATE比Redis锁更省心
先说出我的结论:在这个项目的并发量级下,数据库行锁+原子更新就是最优雅的防超卖方案。校医院预约高峰是什么概念?流感疫苗接种日,一个上午放出500个号,同一秒内的并发请求最多几十个,远没到需要上消息队列和分布式锁集群的程度。
核心代码很简单:
@Transactional public boolean reserve(Long slotId, String studentNo) { // 原子扣减,剩余号源大于0才允许更新,影响行数为0说明已约满 int updated = reserveSlotMapper.decrRemaining(slotId); if (updated == 0) { throw new BusinessException("该时段已约满"); } ReserveRecord record = new ReserveRecord(); record.setSlotId(slotId); record.setStudentNo(studentNo); record.setReserveStatus(1); reserveRecordMapper.insert(record); return true; }UPDATE t_reserve_slot SET remaining_num = remaining_num - 1 WHERE id = #{slotId} AND remaining_num > 0;这里关键点是:UPDATE如果更新的行数为0,说明remaining_num已经不满足大于0的条件,直接抛异常。加上unique(slot_id, student_no)索引兜底防重复预约,加事务保证扣减和创建记录要么都成功要么都失败。我在测试环境用JMeter模拟过100并发抢同一个时段,没有出现超卖,单次扣减平均耗时在5毫秒以内。有些设计方案喜欢先在Redis里预减库存再异步同步DB,这个思路适用于秒杀级别的高并发,但校医院场景不需要,反而会因为Redis和DB的一致性核对问题增加复杂度。
5.2 体检结果导入:EasyExcel处理校医院样式混乱的Excel
校医院的数据来源非常杂:有些是体检机构导出的Excel,有些是校医用电子表格自己录的,表头格式五花八门。用Apache POI直接读,读一遍下来你要写一大堆遍历Cell的样板代码。我改用阿里的EasyExcel,开箱即用的监听器模式极大简化了操作。
public class ExamResultListener implements AnalysisEventListener<ExamResultRow> { private final List<ExamResultRow> batch = new ArrayList<>(); private static final int BATCH_SIZE = 1000; @Override public void invoke(ExamResultRow row, AnalysisContext context) { batch.add(row); if (batch.size() >= BATCH_SIZE) { saveBatch(batch); batch.clear(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { if (!batch.isEmpty()) { saveBatch(batch); } } }踩坑来了。校医院提供的Excel表头经常带着合并单元格、全角括号、不可见空格,比如"身高(cm)"和"身高(cm)"其实是两列。EasyExcel的@ExcelProperty注解默认按表头名匹配,遇到这种情况就会失效甚至报错。我的解决办法是:优先用index属性指定列序号,而不是用name匹配表头,同时对学号、姓名这类关键列做非空校验,不满足条件的行单独收集起来,导入完成后生成一份"失败数据清单"让校医下载核对。
另一个容易被忽略的点是,批量插入时不要一条一条insert。我用MyBatis-Plus的saveBatch配合自定义SQL实现批量插入,一万条体检数据从解析到入库耗时从几十秒降到了两三秒。体检结果保存后,触发Spring的ApplicationEventPublisher发布一个"体检结果导入完成"事件,健康预警模块异步去处理异常指标,不用在校医录入的前端页面里傻等着算预警。
5.3 健康预警规则:不引入规则引擎,一张表就够了
如果按照企业级做法,健康预警通常上Drools这类规则引擎。但对这个项目来说,那是杀鸡用牛刀。我只需要一张规则表加一个策略处理器即可。
CREATE TABLE `t_alert_rule` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `rule_name` varchar(100) NOT NULL, `item_name` varchar(50) NOT NULL COMMENT '指标名称,如ALT/血压', `condition_type` varchar(20) NOT NULL COMMENT 'GT / LT / BETWEEN', `threshold_min` varchar(20) DEFAULT NULL, `threshold_max` varchar(20) DEFAULT NULL, `severity` tinyint(1) DEFAULT '1' COMMENT '1提示 2警告 3严重', `message_template` varchar(500) DEFAULT NULL, `enabled` tinyint(1) DEFAULT '1' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预警规则表';体检结果入库后,异步逻辑遍历当前学生的异常项目,去规则表里匹配对应的item_name和condition_type,命中就生成一条t_health_alert记录,并根据规则等级决定是否推送通知。比如"ALT高于正常值上限的2倍"会同时给辅导员发一条脱敏预警(只显示学院和学号,不显示具体数值),而"体检结果显示疑似传染病"这类场景则直接标记严重等级,通知校医院管理员人工复核。
这样做的好处是:校医院想要调整某个指标的预警阈值,不用改代码重新部署,直接在管理后台的规则表里改一行就行。这块代码量不大,但功能价值突出,很值得在论文里把它当作系统亮点来写。
5.4 文件上传、跨域与全局异常的三件套
文件上传踩过的坑主要是中文文件名和格式校验。我的处理是:上传的文件一律用UUID重命名后存磁盘,文件类型通过白名单检查,文件大小限制10MB。华为手机上拍的照片文件名经常带一串奇怪的字符,直接存会导致Nginx静态映射时URL乱码,UUID重命名能完美避开这种问题。
跨域配置也踩过坑。用Spring的CorsFilter配置时,allowedOrigins不能设为*,因为一旦allowCredentials(true),浏览器会拒绝带凭证的跨域请求。正确做法是指定具体的来源列表,比如http://localhost:5173(Vite开发服务器地址)。
全局异常处理我统一用@RestControllerAdvice,把所有业务异常、参数校验异常、系统异常包装成统一返回体Result<T>,前端axios拦截器里根据code字段做统一提示。有一个细节:业务异常的code不要用负数,前端判断成功与否统一看code === 200,负数的含义容易带来困惑。这种约定看似不起眼,但多人协作时非常影响效率。
5.5 定时任务与数据统计:别小看这两块
系统里还有两个容易被低估的功能,一是定时任务,二是统计报表。定时任务我用Spring自带的@Scheduled注解,每天凌晨清理已过期的预约时段,每周日凌晨统计一次全校体检完成率,并把未完成体检的学生名单生成提醒任务。这里不需要上XXL-Job这种分布式任务调度平台,单机部署的话Spring自带的调度器完全够用。
统计报表则集中在管理员端:按学院、年级的体检完成率;各科室的门诊量趋势;异常指标Top10排名。我都通过聚合SQL实现,前端用ECharts展示折线图和柱状图。有些讲究的同学会单独建一张汇总表,定时任务跑批更新,这样报表页面的查询性能更好。如果数据量不超过10万条,直接查明细表临时聚合也完全没问题,这个量级不需要引入OLAP引擎。
6. 部署落地与安全加固:从IDEA到一台2C4G服务器
6.1 资源规划:一台轻量服务器跑起来真不难
做这个项目的学生,很多在部署环节出问题。其实校园健康系统的负载量是很明确的,一所两万人的高校,一天的门诊预约量撑死几百条。我用的是一个2C4G的轻量云服务器,跑了后端Jar包、Nginx、MySQL、Redis四样东西,实测高峰时段CPU占用率不超过50%,内存稳定在3G以内。预算有限的同学,部署到腾讯云/阿里云的轻量应用服务器上是完全可以的,甚至学习阶段用本地虚拟机跑通就行。
6.2 Docker Compose编排:一次搞定所有依赖
如果你的服务器比较干净,我建议直接用Docker Compose编排全部依赖,避免手动装MySQL、Redis时踩版本坑。我在项目里提供了一个docker-compose.yml:
version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD} MYSQL_DATABASE: univ_health volumes: - mysql_data:/var/lib/mysql - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - "3306:3306" restart: always redis: image: redis:7.0 ports: - "6379:6379" restart: always app: build: . depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql REDIS_HOST: redis ports: - "8080:8080" restart: always nginx: image: nginx:1.24 volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf - ./dist:/usr/share/nginx/html ports: - "80:80" restart: always volumes: mysql_data:这个编排文件的核心思路是:后端应用和数据库都容器化,前端打包后的dist目录挂载到Nginx容器里,这样学生端只需访问服务器IP就能打开系统,不需要额外配置Node环境。生产环境的数据库密码一律通过环境变量注入,不要写在镜像或代码里。
6.3 备份策略:医疗数据不能丢
医疗数据的备份优先级比普通业务系统高。我的备份方案是每天凌晨用mysqldump全量备份,保留最近7份,备份文件同步一份到另一台机器(或者对象存储)。原因很简单:学生体检数据一旦丢失,让几千人重新体检是不可能的。运维层面再苦再累,备份这关不能省。
0 2 * * * mysqldump -uroot -p${MYSQL_PASSWORD} univ_health | gzip > /backup/univ_health_$(date +\%Y\%m\%d).sql.gz find /backup -name "*.sql.gz" -mtime +7 -delete6.4 安全基线:密码加密、HTTPS、登录锁、脱敏
安全方面有四个必做项。第一,用户密码必须用BCrypt加密存储,不能MD5,理由不用多说,MD5撞库太容易了。第二,生产环境一定要配HTTPS,学校场景下可以用免费的Let's Encrypt证书,学生登录页面如果不加密,抓包就能看到密码和JWT,这是严肃的事故隐患。第三,登录接口需要加验证码和失败次数限制,同一个IP或账号连续失败5次就锁定15分钟,防止暴力破解。第四,涉及个人健康数据时做脱敏展示:辅导员端只显示"学号20240001,学院计算机学院"这种信息,学生的具体体重、血压数值属于个人隐私,辅导员没有权限查看。
7. 复盘时的几句实在话
这个项目从头到尾走下来,我最深的体会是:做管理系统的难点从来不在写代码,而在理清业务边界和权限边界。SpringBoot、MyBatis-Plus这些技术都是工具,三天就能上手,但"校医院到底需要什么、不需要什么、各角色之间如何协作"这些问题,需要你真正去跟业务方聊过、去现场看过才能回答得准确。如果你的项目还在起步阶段,我建议先花一周时间把业务流程图和数据字典设计好,再动手写代码,磨刀不误砍柴工。
最后分享一个小技巧:演示这个系统的时候,不要对着PPT念功能列表,直接从"校医登录 → 创建体检批次 → Excel导入结果 → 系统生成预警 → 辅导员看到脱敏通知"这条链路现场走一遍。评审老师看到数据在系统里流转起来,比你用二十页架构图都更有说服力。这个系统做完以后也不是终点,后续可以扩展的地方还很多,比如对接学校的统一身份认证、把体检报告PDF生成做成定时批量任务、引入更丰富的统计可视化大屏。先把当前这个闭环跑稳,其他的留到下一轮迭代再说。