ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot+Vue居民健康档案系统:从数据库设计到权限控制的实战指南

Spring Boot+Vue居民健康档案系统:从数据库设计到权限控制的实战指南

这几年基层医疗信息化是个热门方向,尤其是居民健康档案这一类系统,几乎每个社区卫生服务中心、乡镇卫生院都在做相关建设。我自己也接过几个类似的项目,从最早的纯JSP+Servlet老项目,到后来Spring Boot + Vue前后端分离的架构,踩过的坑确实不少。今天拿一个我正在做的“基于Spring Boot+Vue的居民健康档案管理系统”来聊聊,从需求拆解到数据库设计,再到前后端联调,把关键技术和实操细节一次性讲清楚。不管你是刚入门的学生想做毕业设计,还是在职开发者打算接类似的外包单子,这篇文章应该都能给你一些可复用的思路。

这个系统表面上看起来就是“增删改查”,但真要做得好用、扛得住真实业务场景,涉及的东西远比想象中多:居民基本信息怎么管理、体检记录和随访记录如何关联、多条件检索怎么优化、敏感的健康数据权限怎么控制。我会按照实际开发的顺序,把整个项目的设计思路、核心代码、踩坑记录全部整理出来,你可以直接照着做,也可以作为底稿再扩展。

1. 项目整体设计与需求拆解

1.1 居民健康档案系统到底在管什么

在动手写代码之前,先得搞明白这个系统背后的业务逻辑。居民健康档案,字面上看是“一份居民的电子健康记录”,但实际业务里,它包含的字段和关联数据特别多。我做的这个版本,核心要管四块内容。

第一块是基础档案:姓名、性别、身份证号、出生日期、联系电话、户籍地址、常住地址、血型、过敏史、既往病史、家族病史等。这里最容易忽略的是身份证号的校验,既要做格式校验,还要做18位校验位的合法性校验,服务端一定要验,不能只靠前端提示。

第二块是体检记录:每次居民参加免费体检或者年度体检,会生成一条体检数据,包括身高、体重、血压、血糖、血脂、肝肾功能、B超结果等。体检记录必须支持一次体检关联多个指标项,所以表结构不能设计成一大张宽表,否则以后扩展指标会很痛苦。

第三块是随访记录:高血压、糖尿病、严重精神障碍患者,需要按国家基本公共卫生服务规范定期随访。随访记录要有随访类型、随访日期、目前症状、生活方式指导、用药情况、转诊建议等字段,并且每条随访要能关联到具体的居民。

第四块是慢病管理:高血压和糖尿病档案需要单独维护风险评估、危险因素、并发症筛查等信息,方便基层医生做阶段性的评估和干预。

这四块业务不是孤立的,它们围绕“居民”这个主体形成一棵数据树。所以设计上的核心原则是:主表(居民档案)必须干净、精简,关联表(体检、随访等)按业务域拆分,通过居民ID进行关联。

1.2 模块划分与角色权限思路

居民健康档案的使用者通常分几类角色:系统管理员、基层医生(全科医生)、公共卫生科人员、普通查询人员(可能是上级机构)。不同角色能看到的数据范围不一样。

我在这套系统里把权限模型设计成了 RBAC(基于角色的访问控制),但做了一定的细化:

  • 管理员:拥有全部权限,包括用户管理、角色分配、数据字典维护、系统参数配置。
  • 公卫医生:可对管辖范围内的居民档案进行新增、编辑、删除(逻辑删除),可录入体检和随访记录。
  • 查询人员:只读权限,可统计分析,但看不到居民的敏感联系方式字段。

这里有个在实际业务中很重要的点:数据范围。同一套系统,不同医生登录以后只能看到自己负责片区的居民。所以光有“功能权限”还不够,还要有“数据权限”。我的做法是在居民表里加一个responsible_doctor_id字段,查询的时候,如果当前角色是医生,那么SQL自动拼上responsible_doctor_id = 当前登录用户ID这个条件。超级管理员则绕开这个限制。这是很多初学同学最容易忽略的地方,权限系统做了一半,结果发现普通医生能看到全区的档案,那就是事故了。

1.3 技术栈选型:为什么是 Spring Boot + Vue

选 Spring Boot + Vue 这套组合,主要出于几个实际考虑。

后端用 Spring Boot,因为它的生态成熟、社区资料多、和 MyBatis、MySQL、Redis 等中间件集成非常顺畅。我的项目用的是 Spring Boot 2.7.x 版本,搭配 MyBatis-Plus 做持久层,省去了大量手写 BaseMapper 的重复劳动。选 2.7.x 而不是 3.x,是因为很多生产环境的依赖(包括一些内部封装的组件)对 2.7 兼容性更好。如果你是自己从零写,用最新版也没问题。

前端用 Vue,我采用的是 Vue 2 + Element UI 的组合,因为这套搭配在后台管理系统中非常成熟,组件、模板、踩坑案例满天飞。Vue 3 + Element Plus 也可以,但如果你是做毕业设计或者时间紧,Vue 2 + Element UI 上手成本更低。等以后有精力再迁移到 Vue 3。

数据库用 MySQL 8.0,InnoDB 引擎,字符集统一 utf8mb4。这里要注意,utf8mb4 不只是为了存 emoji,更重要的是它能完整支持中文生僻字和特殊符号,居民姓名、地址里有生僻字的概率其实不低。

2. 数据库设计:核心表结构与关键字段

2.1 居民档案主表设计

主表resident的设计决定了下游所有业务数据的走向。我贴一下核心字段结构(简化版,实际项目里字段更多)。

CREATE TABLE `resident` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `id_card` VARCHAR(18) NOT NULL COMMENT '身份证号', `name` VARCHAR(32) NOT NULL COMMENT '姓名', `gender` TINYINT NOT NULL COMMENT '性别 0男 1女', `birth_date` DATE NOT NULL COMMENT '出生日期', `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `blood_type` VARCHAR(8) DEFAULT NULL COMMENT '血型', `allergy_history` VARCHAR(255) DEFAULT NULL COMMENT '过敏史', `past_history` TEXT COMMENT '既往史', `family_history` TEXT COMMENT '家族史', `province` VARCHAR(64) DEFAULT NULL COMMENT '户籍省份', `city` VARCHAR(64) DEFAULT NULL COMMENT '户籍城市', `district` VARCHAR(64) DEFAULT NULL COMMENT '户籍区县', `address_detail` VARCHAR(255) DEFAULT NULL COMMENT '户籍详细地址', `living_address` VARCHAR(255) DEFAULT NULL COMMENT '常住地址', `responsible_doctor_id` BIGINT DEFAULT NULL COMMENT '责任医生ID', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '档案状态 1正常 0已注销', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_id_card` (`id_card`), KEY `idx_doctor` (`responsible_doctor_id`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='居民健康档案表';

这里有几个设计细节值得说一下。

身份证号作为唯一索引:同一身份证号只能存在一条有效档案。但要注意,身份证号可能因历史数据录入错误需要修正,所以为了操作方便,主键还是用自增ID,身份证号仅做唯一索引,而不是直接当主键。另外,我用deleted字段做逻辑删除,同时把uk_id_card建在(id_card, deleted)上更保险,否则逻辑删除后要重新录入同一身份证号的居民,会触发唯一键冲突。

行政区划字段:我没有用单一字段存完整地址,而是拆成了省、市、区县、详细地址四部分。为什么?因为业务上需要按市、区县做统计报表,单一字段存了之后只能LIKE匹配,统计效率很低。拆开之后,一条简单的GROUP BY就能出区划统计。这是很典型的一个“为了报表而设计表结构”的思路。

状态和逻辑删除:status表示档案当前是否有效(注销、迁出等),deleted表示是否被逻辑删除。这两个要区分开。注销是一个业务动作,档案依然存在于系统里可供追溯;逻辑删除是误操作后的隐藏,要么不走业务接口直接改库,要么走特殊权限的删除接口。

2.2 体检记录与随访记录的关联设计

体检表的核心设计思路是“主从结构”。一次体检是一主表记录,对应多条从表(体检明细)。我简化为两张表:

CREATE TABLE `exam_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `resident_id` BIGINT NOT NULL COMMENT '居民ID', `exam_date` DATE NOT NULL COMMENT '体检日期', `exam_org` VARCHAR(128) DEFAULT NULL COMMENT '体检机构', `doctor_name` VARCHAR(32) DEFAULT NULL COMMENT '体检医生', `height` DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)', `weight` DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)', `blood_pressure_high` INT DEFAULT NULL COMMENT '收缩压(mmHg)', `blood_pressure_low` INT DEFAULT NULL COMMENT '舒张压(mmHg)', `fasting_blood_glucose` DECIMAL(5,1) DEFAULT NULL COMMENT '空腹血糖(mmol/L)', `total_cholesterol` DECIMAL(5,1) DEFAULT NULL COMMENT '总胆固醇(mmol/L)', `conclusion` VARCHAR(255) DEFAULT NULL COMMENT '体检结论', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_resident_date` (`resident_id`, `exam_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体检记录表';

这里我采用了“宽表”方案,把常规体检字段直接作为列放在主表里。有人可能会说,严谨的第三范式应该拆成指标字典表。但实际业务中,体检指标相对固定,拆太细反而让查询变得复杂。如果以后确实需要扩展新指标,可以再加列或加一张扩展表。宽表在数据量不大(几万到几十万条)的情况下完全没有问题,且查询性能比EAV(实体-属性-值)模式好太多。

随访记录表大同小异,核心字段包括随访类型、随访方式、症状集合、血压血糖数值、用药情况、依从性评价、下次随访日期等。随访记录也要按居民ID和时间建联合索引,因为最常见的查询就是“某居民的历史随访列表”。

2.3 数据字典和元数据管理

健康档案系统里充斥着大量枚举值。比如“血型”有O/A/B/AB/不详,“医疗费用支付方式”有城镇职工/城乡居民/自费/其他,“档案来源”有门诊建档/入户调查/健康体检等。

我的做法是建立一张sys_dict数据字典表,统一管理这些枚举。字典表核心字段包括:字典类型编码、字典项名称、字典项值、排序号、是否启用。前端通过接口拉取字典数据,渲染成下拉框;后端在入参校验时,也从数据字典校验传入的值是否合法。这样最大好处是,如果业务那边新增了一个“建档来源”选项,只需要在界面上往数据字典里加一项即可,不需要改代码。

3. 后端实战:Spring Boot 核心模块实现

3.1 工程结构与基础依赖

后端的工程结构我一般习惯按“controller / service / mapper / entity / dto / vo / config / common”分包。有人喜欢按功能模块分包,比如resident、exam、follow,也可以,看团队习惯。我倾向于按技术分层分包,因为功能模块之间多少存在相互调用,统一放到service层之后,依赖关系会更清晰。

pom.xml 里的核心依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>

需要注意mysql-connector-java在 Spring Boot 2.7 里依赖版本要选对,如果用 MySQL 8.x 的驱动,版本号建议明确写成8.0.x,避免某些环境下拉到老版本驱动报通信链路异常。

3.2 统一返回体与全局异常处理

前后端分离项目,后端接口的数据格式必须统一。我定义了一个ApiResponse类:

@Data public class ApiResponse<T> { private Integer code; private String message; private T data; public static <T> ApiResponse<T> success(T data) { ApiResponse<T> response = new ApiResponse<>(); response.code = 200; response.message = "success"; response.data = data; return response; } public static <T> ApiResponse<T> error(Integer code, String message) { ApiResponse<T> response = new ApiResponse<>(); response.code = code; response.message = message; return response; } }

同时定义全局异常处理器,用@RestControllerAdvice捕获所有异常,包括参数校验异常、业务异常、未知异常,统一转成ApiResponse格式返回。这一个环节非常重要,否则前端拿到的错误信息千奇百怪,排查问题的时候会崩溃。

项目里自定义了BusinessException,业务代码里只需要throw new BusinessException("身份证号已存在"),全局处理器会自动转成标准错误响应。不要再在 Controller 里写try-catch到处打印,既丑陋又容易漏。

3.3 居民档案的增删改查与身份证校验

居民的入参校验,除了必填字段之外,最重要的就是身份证号。除了正则校验格式,还要校验最后一位校验码。我封装了一个工具类:

public class IdCardUtil { private static final int[] WEIGHTS = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}; private static final char[] CODES = {'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'}; public static boolean validate(String idCard) { if (idCard == null || idCard.length() != 18) { return false; } String body = idCard.substring(0, 17); char tail = idCard.charAt(17); // 前17位必须是数字 for (int i = 0; i < body.length(); i++) { if (!Character.isDigit(body.charAt(i))) { return false; } } int sum = 0; for (int i = 0; i < 17; i++) { sum += (body.charAt(i) - '0') * WEIGHTS[i]; } int mod = sum % 11; char expect = CODES[mod]; return Character.toUpperCase(tail) == expect; } }

这个工具类看起来简单,但非常实用。前端虽然也做了校验,但后端一定要再做一次,因为接口可以被任何人绕过前端直接调用。

增改操作我用了 MyBatis-Plus 的saveOrUpdate,但在新增前,要单独检查身份证号是否已存在。检查用lambdaQueryWrapper即可:

long count = residentMapper.selectCount( new LambdaQueryWrapper<Resident>() .eq(Resident::getIdCard, dto.getIdCard()) .ne(dto.getId() != null, Resident::getId, dto.getId()) ); if (count > 0) { throw new BusinessException("该身份证号已存在档案"); }

ne(dto.getId()!=null, ...)这个写法是 MyBatis-Plus 条件构造器的典型用法,第一个参数为true时才会拼接该条件,实现“排除自身”的更新校验,非常实用。

3.4 体检数据批量录入的事务设计

体检记录录入时,前端往往一次性提交一条主记录和若干明细。如果明细字段我放在主表里,那就不存在批量问题,但某些体检项目(如超声检查、检验报告)会有多张图片或多项指标,所以我还是设计了“主记录+明细子表”的结构。接口接收的 DTO 如下:

@Data public class ExamRecordDTO { @NotNull(message = "居民ID不能为空") private Long residentId; @NotNull(message = "体检日期不能为空") @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate examDate; private String examOrg; private String doctorName; private BigDecimal height; private BigDecimal weight; private Integer bloodPressureHigh; private Integer bloodPressureLow; private BigDecimal fastingBloodGlucose; // 明细列表 private List<ExamItemDTO> items; }

Service 里保存时用@Transactional保证主表和子表的原子性:

@Override @Transactional(rollbackFor = Exception.class) public Long addExamRecord(ExamRecordDTO dto) { // 校验居民是否存在 Resident resident = residentService.getById(dto.getResidentId()); if (resident == null) { throw new BusinessException("居民不存在"); } // 插入主表 ExamRecord record = new ExamRecord(); BeanUtils.copyProperties(dto, record); examRecordMapper.insert(record); // 批量插入子表 if (dto.getItems() != null && !dto.getItems().isEmpty()) { List<ExamItem> itemList = dto.getItems().stream().map(itemDTO -> { ExamItem item = new ExamItem(); BeanUtils.copyProperties(itemDTO, item); item.setExamId(record.getId()); return item; }).collect(Collectors.toList()); examItemService.saveBatch(itemList); } return record.getId(); }

saveBatch是 MyBatis-Plus 提供的批量插入方法,内部会拼接多值INSERT,性能远好于逐条插入。另外,事务之所以用@Transactional(rollbackFor = Exception.class),是因为默认情况下 RuntimeException 才回滚,而检查异常(比如某些数据访问异常)不会自动回滚,显式指定比较稳妥。

3.5 多条件分页检索的实现

居民列表页的检索条件是典型的多条件场景:姓名(模糊)、身份证号(精确)、性别、管辖医生、建档时间区间、档案状态。

我封装了一个查询 DTO:

@Data public class ResidentQueryDTO { private String keyword; // 姓名或身份证号模糊搜索 private Integer gender; private Long doctorId; private String district; private Integer status; private LocalDate startDate; private LocalDate endDate; private Integer pageNum = 1; private Integer pageSize = 10; }

Mapper 层直接用 MyBatis-Plus 的 LambdaQueryWrapper 拼条件:

LambdaQueryWrapper<Resident> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(query.getKeyword())) { wrapper.and(w -> w.like(Resident::getName, query.getKeyword()) .or().like(Resident::getIdCard, query.getKeyword())); } if (query.getGender() != null) { wrapper.eq(Resident::getGender, query.getGender()); } if (query.getDoctorId() != null) { wrapper.eq(Resident::getResponsibleDoctorId, query.getDoctorId()); } if (StringUtils.hasText(query.getDistrict())) { wrapper.likeRight(Resident::getDistrict, query.getDistrict()); } if (query.getStartDate() != null) { wrapper.ge(Resident::getCreateTime, query.getStartDate()); } if (query.getEndDate() != null) { wrapper.le(Resident::getCreateTime, query.getEndDate()); } Page<Resident> page = residentMapper.selectPage( new Page<>(query.getPageNum(), query.getPageSize()), wrapper );

这里要注意两个点。

第一,name和idCard两个字段的模糊搜索,直接用or会导致查询条件不符合预期,必须用wrapper.and(w -> w.like(...).or().like(...))把两个or条件包成一个整体,否则后面的其他 eq 条件会歧义。这是 MyBatis-Plus 条件构造器比较容易踩的坑。

第二,行政区的likeRight比like效率更高。因为district存的是类似“某市某区”的区划编码或名称,如果值有层级关系,likeRight能走前缀索引,而like '%xx%'会全表扫。

最终返回的 VO,需要把居民ID、责任医生姓名等关联信息填充进去。我推荐在 Service 层做装配,而不是在 Mapper 里写多表联查。为什么呢?因为主表数据量不大时,联查的SQL一旦写复杂,后期维护非常痛苦,尤其是加了逻辑删除、状态过滤、权限过滤之后。Service 层分两步走:先查出当前页居民ID列表,再用IN查询医生姓名等关联字段,代码可读性更高。

4. 前端实战:Vue 页面与接口联调

4.1 项目初始化和路由布局

前端我使用的是 Vue CLI 创建的标准工程结构。第一件事是安装 Element UI 和 axios:

npm install element-ui npm install axios

路由配置采用经典的“整体布局 + 子页面”结构。后台管理系统的布局通常是一个左侧 Sidebar 菜单,顶部是工具栏,中间是内容区。Sidebar 菜单数据可以从后端动态加载(菜单权限里配置),也可以在前端路由表里写死。考虑到健康档案系统的菜单数量不多、变化频率低,我直接在路由表里配置,管理员维护权限时只控制“是否允许访问”,而不是动态渲染菜单数据。

这套方案更简单,安全性也没问题——后端接口上有权限控制,前端隐藏只是体验优化,不是安全边界。

4.2 居民档案列表页开发

居民档案列表页是整个系统最核心的页面,承担了 80% 的日常操作。页面结构上,顶部是筛选区(姓名/身份证号、性别、辖区、建档时间),中间是表格区,下方是分页条。

axiox 封装方面,我写了统一的request.js,在拦截器里统一带上 token:

import axios from 'axios'; import { Message } from 'element-ui'; import router from '@/router'; const request = axios.create({ baseURL: process.env.VUE_APP_BASE_URL, timeout: 10000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; }); request.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { Message.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } return res; }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } Message.error(error.message || '网络错误'); return Promise.reject(error); } ); export default request;

列表页的核心方法就是一个fetchList:

async fetchList() { this.loading = true; try { const res = await request({ url: '/api/resident/page', method: 'get', params: this.query }); this.list = res.data.records; this.total = res.data.total; } finally { this.loading = false; } }

params传对象的优势是 axios 会自动把对象转成查询字符串,不需要手动拼接 URL。这里后端接口的日期参数用yyyy-MM-dd格式,前端在做时间选择器时,组件库默认返回的是一个 Date 对象,需要先用dayjs或者原生方法格式化,否则后端的 LocalDate 解析会报错。

4.3 档案表单的复杂校验与动态增减

居民档案的新增和编辑表单,字段多、校验规则多。我通常将表单拆成几个 Tab 页签:基本信息、户籍地址、病史信息、其他信息。这样既避免了一整页滚不到头的问题,也方便分步校验。

Element UI 的el-form校验规则需要特别注意身份证号和手机号的模式校验:

rules: { name: [{ required: true, message: '请输入姓名', trigger: 'blur' }], idCard: [ { required: true, message: '请输入身份证号', trigger: 'blur' }, { pattern: /^\d{17}[\dXx]$/, message: '身份证号格式不正确', trigger: 'blur' } ], phone: [ { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' } ] }

体检和随访记录的录入,前端会用到动态增减行。比如体检明细里,同一个检查类型可以多行。Element UI 里加一个el-table嵌套el-form-item,点击“添加行”时push一条空对象到明细数组,点击“删除”时splice。这属于非常常规的操作,就不贴完整代码了,但要注意一个细节:动态行的字段校验不能写在全局rules里,而是要写在el-table-column内部的el-form-item的prop属性上,否则校验无法绑定到正确的作用域。

4.4 后端接口返回数据结构的对齐问题

前后端联调阶段最容易出的问题就是接口返回结构和前端预期不一致。比如分页数据结构,MyBatis-Plus 的Page对象序列化后包含records、total、size、current等字段。如果后端直接返回Page对象,前端res.data.records就能拿到列表数据。但如果你包了一层ApiResponse.success(page),那么完整结构就是res.data.records。这个“包一层”和“不包一层”的约定,一定要在项目开始前达成一致,否则写着写着前端就要到处剥壳。

我建议统一让后端返回ApiResponse<Page<T>>,因为这样可以在响应体里附加一些提示信息、警告信息,扩展性更好。前端在request.js拦截器返回res之后,页面拿到的是整个{code, message, data},然后访问res.data.records,这个链路是最清晰的。

4.5 权限控制的前端路由守卫

虽然是 RBAC 权限模型,但前端路由守卫只负责“登录与否”和“按钮显隐”,真正安全的后端接口校验不能省。

路由守卫写在router.beforeEach:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path !== '/login' && !token) { next('/login'); } else if (to.path === '/login' && token) { next('/'); } else { next(); } });

按钮级别的权限,我封装了一个指令v-permission,传入需要的权限编码,从后端获取当前用户的权限列表后比对。如果没有权限,直接移除 DOM 元素。这里说白了就是避免普通医生看见“删除档案”按钮,但即便看见了,后端接口也会拦他。前端做权限更多是为了交互体验,不是安全防线。

5. 认证、授权与数据安全细节

5.1 基于 JWT 的登录认证设计

健康档案系统的登录认证,我用了 JWT(JSON Web Token)。登录成功后,后端签发一个有效期 2 小时的 token,前端存在 localStorage 里,每次请求在拦截器带上。

JWT 本身有三个部分:Header、Payload、Signature。Payload 里可以放用户ID、用户名、角色编码、过期时间。后端解析 token 获得用户身份后,再查询角色权限并缓存到 Redis 里,避免每次请求都查数据库。我之前直接每次都查用户表和角色表,结果接口响应时间明显变长,尤其在高并发刷新页面时数据库压力很大。后来加了 Redis 缓存角色权限,整个系统响应快了一个量级。

签发生成和校验我封装了JwtUtil:

@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire-time}") private Long expireTime; public String createToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expireTime)) .signWith(SignatureAlgorithm.HS256, secret.getBytes(StandardCharsets.UTF_8)) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret.getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token) .getBody(); } }

5.2 密码加密方案

用户密码绝对不能明文存储,这个没有商量的余地。我使用的是 BCrypt 加密,因为它是自适应加密算法,内置随机盐,每次加密结果不同,而且计算强度可调。

Spring Security 虽然可能没引入完整依赖,但可以单独引入spring-security-crypto工具包,直接使用BCryptPasswordEncoder。代码里就几个方法调用的事:

String encoded = encoder.encode(rawPassword); // 校验 boolean matched = encoder.matches(rawPassword, encodedPassword);

我就不在这里贴重复代码了。总之要记住:不要在数据库里存密码原文,不要用 MD5 直接存,MD5 撞库成本太低了。

5.3 接口细粒度鉴权与数据脱敏

后端接口权限我用一个自定义注解@RequirePermission实现。拦截器或在 AOP 切面里,获取当前登录用户的权限集合,判断当前接口需要的权限编码是否匹配。如果匹配失败,直接抛出“无权限访问”异常。

数据脱敏在查询结果返回时做。比如列表页,普通医生能看到居民姓名、性别,但联系方式打了码(比如138****1234);管理员才能看完整号码。脱敏逻辑统一放在一个DesensitizeUtil里,在 Controller 返回前做处理。由于我使用的是 Spring Boot,可以通过@JsonSerialize注解方式对字段进行序列化脱敏,不过这个写法灵活度差一点。我用的是一个简单方案:在 Service 里查询完 VO 后,根据当前用户角色决定是返回原值还是掩码值。逻辑简单,也方便维护。

5.4 操作日志与审计留痕

居民健康档案涉及敏感信息,按监管要求需要保留操作日志。我建了一张oper_log表,记录操作人、操作类型、操作对象ID、操作内容、IP、时间。后端写了一个@Log("新增体检记录")这类注解,配合 AOP 切面统一记录。

切面的实现要点是:从当前登录上下文里拿用户ID,从请求里拿客户端IP,从注解里拿操作描述,然后异步写入日志表。这里有一个实践心得是——日志写入一定不要同步阻塞主业务,否则多写一次表操作会让接口响应慢 10~20ms。我用了@Async注解把日志记录异步化,配合线程池,不拖慢主流程。

6. 性能优化与常见问题排查实录

6.1 分页大深度的优化手段

居民档案数据量上去之后,LIMIT 100000, 10这种分页会越来越慢,因为数据库要扫描前 100010 条数据再丢弃前 100000 条。优化手段有几个:

一是使用游标分页。也就是WHERE id > 上次查询的最大ID ORDER BY id ASC LIMIT 10,它直接走索引,性能极佳。缺点是用户翻到第 3 页之后,无法跳页,只适合“加载更多”场景。

二是保持常规分页,但优化排序字段。MyBatis-Plus 默认排序是按主键,如果用户切换了其他排序字段,要注意给排序字段加索引。对列表页而言,排序字段通常是create_time,需要建好idx_create_time索引。

三是控制 pageSize 上限。我在前端和后端都做了限制,pageSize 最大 50。如果确实需要导出大量数据,走异步导出任务,不走查询接口。

6.2 慢查询定位与优化

如果发现列表加载变慢,我是这样排查的:首先开启 MySQL 慢查询日志:

SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;

然后让前端点一遍页面操作,去慢查询日志里看哪些 SQL 超过了 1 秒。常见的慢 SQL 集中在几个地方:%关键字%的模糊搜索、多表关联带LIKE、缺少索引的状态字段筛选。

举个例子,如果status字段只有 0/1 两个值,它的区分度太低,即使建了索引也可能被优化器放弃使用。这时候不要强行用索引,而是要改查询方式,比如和create_time做联合索引。还有一种做法,是把某些高频查询从数据库挪到 Redis 缓存里,比如统计类数据、数据字典数据。

6.3 前后端联调中的常见报错与处理

我在开发过程中遇到的报错非常多,挑几个最典型的分享给各位。

第一个:跨域请求失败(CORS)。前后端分属不同端口,后端必须开启跨域配置。我在 Spring Boot 里加了一个CorsConfig,允许了指定的 origin,而不是用*通配,这样更安全。

第二个:LocalDate 序列化格式问题。前端传2025-12-01,后端 LocalDate 接收,如果没配置时间格式,会直接报参数解析失败。解决方案是在 Spring Boot 配置文件里加上:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

同时在 DTO 的日期字段上加上@JsonFormat(pattern = "yyyy-MM-dd"),双保险。

第三个:MyBatis-Plus 的逻辑删除和 unique 约束冲突。前面提到过,身份证号做了唯一索引,但逻辑删除后这个记录还在表里,重新录入会触发 duplicate key。解决方案是唯一索引改成联合唯一索引(id_card, deleted),并且deleted字段默认 0,删除后更新为ID本身或者其他业务值,保证联合唯一索引不冲突。实际上,我见过更优雅的做法是删除时设置deleted = id,这样每次删除都是不同的值,完全规避唯一索引冲突。这招是手艺活,很多刚入行的同学想不到。

6.4 体检数据重复提交与幂等性处理

用户快速点击两次“提交体检记录”,可能导致同一条体检数据插入两条。解决思路有两个:

一是在前端提交后立即禁用按钮,防止重复点击。这简单但不可靠,因为一个用户关了页面再提交。

二是在后端做幂等性设计。我的方案是,在体检记录表里对(resident_id, exam_date, exam_org, doctor_name)建联合唯一索引,重复提交时数据库会报 DuplicateKeyException,再被全局异常处理器捕获转成“该居民当日在同一机构已存在体检记录”。这个方案最稳,比任何代码层的判断都靠谱。

这里涉及到一个通用的设计建议:凡是业务上天然具有唯一语义的数据,都要建唯一索引,代码校验只是辅助。唯一索引是最后一道防线,数据库层的强约束比应用层的逻辑判断可靠得多。

7. 项目扩展与落地经验

7.1 从毕业设计到真实项目还差什么

很多同学照着教程做完这个系统,拿去答辩没问题,但如果要真正部署到社区卫生服务中心,还有不少事情要补。

数据迁移是头一个问题。老系统里的历史数据可能是 Excel 或老式单机数据库,字段垃圾多、身份证格式乱,需要写专门的清洗脚本。我在项目里写了一个数据导入模块,支持批量导入 Excel,导入过程中逐行校验身份证号合法性、出生日期与身份证号的一致性,把错误行单独输出成一个错误报告 Excel。这个模块在真实场景中非常受欢迎,省了两天的人工录入时间。

其次是备份策略。MySQL 数据库每天凌晨全量备份,binlog 开启,至少保留 30 天。这个要在部署方案里写清楚,否则数据丢了不是开玩笑的。

7.2 后续功能扩展方向

这个系统目前只是基础版,后续可以扩展的方向包括:对接体检设备,把身高体重等数据自动采集到系统里;做慢性病风险评估,利用已有数据生成健康指导建议;增加统计报表模块,按街道、按病种、按年龄段输出各类公共卫生报表。

技术层面,如果数据量增长很快,可以引入 Redis 缓存热点数据,引入 RabbitMQ 处理异步任务,比如批量通知随访提醒。这玩意儿就是个无底洞,用到哪一步再说哪一步,别一上来就整微服务,对这个小体量系统来说那就是自找麻烦。

8. 一些踩坑后的真心话

聊完技术,再分享一点我个人做这类项目的真实感受。

第一个体会是,健康档案系统这类“经典 CRUD”项目,难点真的不在编码,而在需求收集和细节处理。我在做这个项目时,和基层公卫医生聊了很多次,发现他们真正需要的不是花哨的界面,而是“录入要快、查询要准、报表要全”。比如身份证号自动调取出生日期和性别、地址按区划联动选择、血压血糖异常时自动弹提示,这些细节才是满意度提升的关键。

第二个体会是,数据库设计阶段多花两小时,后面能省二十小时。很多同学一上来就建表写代码,做到中途发现字段不够用、关联关系设计错误,回头改表结构、改代码,痛苦不堪。我强烈建议先拿纸画出业务实体关系图,和需求方确认了再动工。

第三个体会是,一定要重视日志和异常处理。真实环境里,用户报“页面有 bug”,80% 的情况只要看日志就能定位问题。但前提是你把日志打明白了。我在项目里要求所有业务操作都要带操作人ID和关键业务参数打日志,排查问题时效率明显高很多。

最后再提一个小的实践经验:做这类系统,一定要把“列表页查询性能”和“数据导出功能”放在优先级较高的位置。基层用户非常习惯导出 Excel 做离线统计,这个功能不做,他们会天天打电话催你。而列表页查询性能直接决定他们的日常使用体验,慢了一秒就会抱怨“系统卡”。别问我怎么知道的,都是被催出来的经验。

这套 Spring Boot + Vue 的健康档案管理系统,算是我个人比较满意的一个作品,设计上兼顾了业务规范、代码整洁和可扩展性。如果你也打算在自己项目里搞类似的东西,希望这篇能帮你少走点弯路。有任何技术实现上的问题,欢迎留言交流,看到了都会回。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进