简介:一套基于Spring Boot与Vue的医院挂号就诊系统完整项目源码,面向Java Web开发学习者与毕业设计使用者,覆盖从系统分析、数据库设计到功能模块落地的完整流程。项目采用Java、Spring Boot、Vue、MyBatisPlus、MySQL等技术栈,包含可行性分析、系统流程、性能需求等章节,并实现了用户信息、图片素材、视频素材等核心管理模块,便于对照文档理解业务与代码的对应关系。资源包共770个文件,以101个Java后端文件、60个Vue前端组件、157个JavaScript逻辑脚本及50个CSS样式文件为主,另有数据库脚本、配置文件和辅助说明文档,整体34.42MB,目录结构清晰,适合按模块逐步研读。目前已有162人学习下载,借助这套代码可快速掌握RESTful接口设计、MyBatisPlus持久层操作和Vue页面交互写法,也可作为毕业设计直接参考或二次开发的基础。
1. 医院挂号就诊系统不是“增删改查”:先想清楚号源与状态机
做医院挂号就诊系统,最常见的一种翻车,是把 Spring Boot 项目写成“患者表、医生表、挂号表”三个 CRUD,接口能跑,演示能过,真放到门诊用就崩。挂号就诊系统真正的设计难点不在“挂号”这个动作,而在号源怎么分配、挂号单状态怎么流转、取消和迟到怎么回补,以及并发抢号时怎么保证不多挂也不丢号。围绕基于 Spring Boot 的医院挂号就诊系统源码做设计与实现,核心要解决的是“号源模型 + 状态机 + 事务边界”,而不是把页面堆出来。这篇文章写给两类人:一类是拿它做毕业设计或课程设计的学生,想交一个能演示、能答辩、逻辑站得住的完整工程;另一类是诊所或小规模门诊的信息化负责人,想用 Java 技术栈自建门诊系统,需要知道哪些地方不能省、哪些坑不能踩。我会按源头把领域建模、表结构、核心接口、并发控制和踩坑记录逐一展开,所有代码都以 Spring Boot 为底座,可直接落到自己的工程里改。
2. 领域建模与数据库设计:挂号系统的地基在号源与状态
2.1 核心实体:患者、医生、排班与挂号单,哪个都不能省
很多入门项目上来先建患者表和医生表,然后直接建挂号表,排班信息散落在挂号单的备注字段里。这种建模在演示阶段看不出问题,一旦要做“余号查询”“停诊通知”“医生排班表”就全部卡住。医院挂号就诊系统的领域模型必须包含五类核心实体:患者、医生、科室、排班、挂号单,如果涉及诊疗记录,还要有就诊和处方。排班是连接医生与号的中间层,挂号单则关联患者、排班和号源。
我在设计时会把排班表和号源表拆开。排班记录“哪个医生在哪个时段出诊”,号源则记录“这次排班生成了多少个具体的号”。拆开的好处是排班可以独立维护,停诊时直接禁用排班,已经挂出去的号还能做批量退号。另一个好处是号源可以做成“预生成明细”,每个号有独立状态,而不是在挂号时才去查排班的余数——后一种做法在并发高时极容易超卖,原因后面避坑章节会展开写。
提示:如果项目时间紧,可以暂时不建独立的处方表,把处方当成就诊后的附加记录。但排班和号源这两个实体不能合并,合并之后你迟早要回来重构。
2.2 号源设计:按排班预生成号源,而不是“挂号时即时检查余号”
号源设计是这个系统里最值得花时间的地方。常见做法是:一天上午或下午为一个排班时段,排班表里存总号数,挂号时读排班余号判断是否可挂。这个方案叫“余号即时扣减”,逻辑简单,但有两个致命问题:一是余号字段会被所有挂号线程同时更新,乐观锁一旦写不好就超卖;二是“号”本身没有记录,取消挂号后系统只能把余号加回去,却不知道加回来的是哪个号,运营和财务对账都没法做。
我一般会用“预生成号源”方案。给排班关联一张号源表,每个排班生成 30 条或 50 条号源记录,每条记录有独立的编号和时间区间,挂号操作变成“锁定一条空闲号源”,取消操作变成“释放一条已锁定号源”。这样号源的整个生命周期都透明,也可以支持“选号”“叫号顺序”“过号重排”等后续需求。
下面是一套可直接参考的 MySQL 表结构,包含科室、医生、排班、号源、挂号单五张核心表:
CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '科室ID', name VARCHAR(50) NOT NULL COMMENT '科室名称', location VARCHAR(100) COMMENT '就诊位置' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科室表'; CREATE TABLE doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '医生ID', department_id BIGINT NOT NULL COMMENT '所属科室', name VARCHAR(50) NOT NULL COMMENT '医生姓名', title VARCHAR(30) COMMENT '职称:主任医师/副主任医师/主治医师', is_deleted TINYINT DEFAULT 0 COMMENT '逻辑删除标记' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生表'; CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '排班ID', doctor_id BIGINT NOT NULL COMMENT '医生ID', work_date DATE NOT NULL COMMENT '出诊日期', period TINYINT NOT NULL COMMENT '时段:1上午 2下午', total_number INT NOT NULL DEFAULT 30 COMMENT '该时段总号数', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 0停诊', UNIQUE KEY uk_doctor_date (doctor_id, work_date, period) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='排班表';排班表里的唯一索引非常关键,它保证同一个医生同一天同一个时段只能有一条排班记录。没有这个约束,数据层就可能产生重复排班,进而出现同一时段挂出两倍号源。
CREATE TABLE schedule_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '号源ID', schedule_id BIGINT NOT NULL COMMENT '排班ID', slot_no INT NOT NULL COMMENT '号序,如 1~30', start_time TIME NOT NULL COMMENT '预计就诊开始时间', end_time TIME NOT NULL COMMENT '预计就诊结束时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0空闲 1锁定 2已使用 3已释放', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_schedule_slot (schedule_id, slot_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='号源明细表'; CREATE TABLE registration ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '挂号单ID', registration_no VARCHAR(32) NOT NULL COMMENT '挂号单号', patient_id BIGINT NOT NULL COMMENT '患者ID', slot_id BIGINT NOT NULL COMMENT '号源ID', schedule_id BIGINT NOT NULL COMMENT '排班ID', doctor_id BIGINT NOT NULL COMMENT '医生ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待支付 1已预约 2已完成 3已取消 4已过期', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, cancel_time DATETIME NULL, UNIQUE KEY uk_reg_no (registration_no), UNIQUE KEY uk_slot (slot_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='挂号单表';挂号单与号源之间是一对一关系,并且号源ID上有唯一索引。这个唯一索引就是防超卖的最后一层兜底——即使应用层出了并发 bug,数据库也会拒绝两个挂号单对应同一个号源。
2.3 挂号单状态机:用状态代替删除,让整个流程可追溯
挂号单状态是这个系统里最容易被忽视的设计点。初学者倾向于“挂号就插入一条新记录,取消就删掉这条记录”,这在任何正式系统里都不应该出现。删除会丢失历史,而挂号单的状态流转是医院对账和复诊查询的基础。我在项目中会固定一组状态值,并在服务层用一个状态机来约束流转:
0 待支付,表示号源已经锁定但钱还没付;1 已预约,表示支付完成,准备按时就诊;2 已完成,表示接诊结束;3 已取消,表示患者在就诊前主动取消;4 已过期,表示未就诊且未取消,号源作废。
状态机的好处是让每个操作都只允许特定的方向流转。比如只能从“待支付”进入“已取消”,而不能从“已完成”回到“待支付”;只能从“已预约”进入“已完成”,不能从“已取消”进入“已完成”。这个约束写在 service 层,配合数据库的状态字段更新条件,比如UPDATE registration SET status = 1 WHERE id = ? AND status = 0,在并发场景下就不会出现一个号被重复支付或重复取消。
注意:我的个人习惯是,状态字段一律用 TINYINT 数字存储,Java 里用枚举映射,不在数据库里存中文字符串。数字占空间小、索引快,枚举定义还能作为 Java 代码里的业务字典,前端拿到数字自己翻译成文本即可。
3. 用 Spring Boot 把挂号主流程跑起来:放号、预约、取消与接诊
3.1 工程结构与关键依赖:JPA 还是 MyBatis,先按查询复杂度选
Spring Boot 工程结构我习惯按 DDD 的简化方式分层:controller 只做参数接收和结果封装,service 处理业务逻辑,repository 访问数据库,entity 对应数据表,dto 负责接口出参。挂号就诊系统的查询场景并不算复杂,主要查询是“医生排班列表”“余号查询”“我的挂号记录”,用 Spring Data JPA 可以大幅减少样板代码;如果团队习惯写复杂 SQL,也可以换 MyBatis-Plus,服务层逻辑完全不变,只动 repository 层。
依赖上我会保留这些核心组件:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>这里没有引入 Redis 和 MQ,因为挂号系统的核心诉求是“不能超卖”,单库的事务和锁已经能保证这一点,引入中间件只会增加部署复杂度。如果要在秒杀级别的流量下做抢号,再考虑 Redis 分布式锁;但医院挂号就诊系统的主要矛盾是号源的合法性,不是并发吞吐量,先把数据库层的并发控制做扎实,后边再扩展也不迟。
3.2 排班生成与号源初始化:写一个 Service 一步到位
有了表结构,第一步是把过去手工录入排班的方式改成“生成排班 + 自动铺号源”。下面的代码会在指定日期和时段为医生创建排班,并铺满 30 个号源。号源生成是一个批量插入操作,放在同一个事务里,避免“有排班无号源”的中间状态。
@Service public class ScheduleService { @Transactional public Schedule createSchedule(ScheduleCreateRequest request) { Schedule schedule = new Schedule(); schedule.setDoctorId(request.getDoctorId()); schedule.setWorkDate(request.getWorkDate()); schedule.setPeriod(request.getPeriod()); schedule.setTotalNumber(request.getTotalNumber()); schedule.setStatus(ScheduleStatus.NORMAL.getValue()); schedule = scheduleRepository.save(schedule); List<ScheduleSlot> slots = new ArrayList<>(); LocalTime baseStart = request.getPeriod() == PeriodType.AM.getValue() ? LocalTime.of(8, 0) : LocalTime.of(14, 0); int minutesPerSlot = 10; for (int i = 1; i <= request.getTotalNumber(); i++) { ScheduleSlot slot = new ScheduleSlot(); slot.setScheduleId(schedule.getId()); slot.setSlotNo(i); slot.setStartTime(baseStart.plusMinutes((long) (i - 1) * minutesPerSlot)); slot.setEndTime(baseStart.plusMinutes((long) i * minutesPerSlot)); slot.setStatus(SlotStatus.FREE.getValue()); slots.add(slot); } slotRepository.saveAll(slots); return schedule; } }这段代码的逻辑不复杂,但有两个参数值得解释。minutesPerSlot是每个号源的就诊间隔,我按 10 分钟计算,上午 8 点到 12 点正好 30 个号,这是门诊最常见的节奏;如果科室是儿科或者呼吸科,单号耗时可能更久,间隔要调成 15 分钟甚至 20 分钟。saveAll会在一个批量操作里插入所有号源,小规模下效率没问题,数据量大时再考虑 JDBC batch 或流式插入。
排班生成后,余号查询就变成了“统计空闲号源数量”,不再需要去锁排班的余数字段。查询代码简单到只有一行:
long available = slotRepository.countByScheduleIdAndStatus(scheduleId, SlotStatus.FREE.getValue());3.3 挂号接口:乐观锁 + 唯一索引,双重保险防超卖
挂号是整个系统最核心的写操作,也是并发风险最高的接口。整个逻辑分三步:查出空闲号源,把号源状态从“空闲”改成“锁定”,创建挂号单。这里不能先查出空闲号源再插入挂号单,而要反过来,先让号源锁定成功,再写挂号单。
我用的是数据库乐观锁方式。号源表里有version字段,更新时带上版本条件,更新影响行数为 1 才表示抢号成功。配合上一节的唯一索引,即使两个请求同时读到同一个空闲号源,数据库层面也只会让一个更新成功,另一个影响行数为 0。
@Service public class RegistrationService { @Transactional public Registration register(RegisterRequest request) { // 1. 尝试锁定一个空闲号源 int locked = slotRepository.lockFreeSlot( request.getSlotId(), SlotStatus.FREE.getValue(), SlotStatus.LOCKED.getValue()); if (locked == 0) { throw new BusinessException("该号源已被占用,请重新选择"); } // 2. 生成挂号单 ScheduleSlot slot = slotRepository.findById(request.getSlotId()) .orElseThrow(() -> new BusinessException("号源不存在")); Registration registration = new Registration(); registration.setRegistrationNo(generateRegistrationNo()); registration.setPatientId(request.getPatientId()); registration.setSlotId(slot.getId()); registration.setScheduleId(slot.getScheduleId()); registration.setDoctorId(request.getDoctorId()); registration.setStatus(RegistrationStatus.PENDING_PAYMENT.getValue()); return registrationRepository.save(registration); } }这里的lockFreeSlot是一个自定义 SQL 更新方法,在 Repository 里写更新语句:
@Modifying @Query("UPDATE ScheduleSlot s SET s.status = :lockedStatus, " + "s.version = s.version + 1 WHERE s.id = :slotId " + "AND s.status = :freeStatus") int lockFreeSlot(@Param("slotId") Long slotId, @Param("freeStatus") int freeStatus, @Param("lockedStatus") int lockedStatus);为什么不用SELECT ... FOR UPDATE?行锁在并发不高时也够用,但持锁时间更长,容易扩大锁范围;而乐观锁通过“更新时校验状态”,冲突发生时直接失败退出,让客户端重试,代码更简单清晰。挂号这个动作本身很短,失败重试成本低,乐观锁是更合适的选择,而且能省掉数据库连接长时间被锁占用的问题。
generateRegistrationNo()是挂号单号的生成方法,我会用“日期 + 随机数 + 流水号”组合,落到数据库后用唯一索引兜底:
private String generateRegistrationNo() { return "REG" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + String.format("%04d", ThreadLocalRandom.current().nextInt(10000)); }提示:不要用数据库自增 ID 直接作为对外暴露的挂号单号,原因是自增 ID 会被遍历猜测,且跨天不好判断日期。用
REG + 时间戳 + 随机数就已经够用,不必引入雪花算法。
3.4 取消挂号与接诊闭环:状态回补不是简单“变成空闲”
取消挂号是一个容易被低估的操作。正确的流程是:把挂号单状态从“待支付/已预约”改成“已取消”,同时把对应号源状态从“锁定/已支付”释放回“空闲”。两个操作必须在同一个事务里,否则会出现号源释放了但挂号单状态还挂着,或者号源还锁着但挂号单已经取消的脏数据。
@Transactional public void cancelRegistration(Long registrationId, Long patientId) { Registration registration = registrationRepository.findById(registrationId) .orElseThrow(() -> new BusinessException("挂号单不存在")); if (!registration.getPatientId().equals(patientId)) { throw new BusinessException("不能取消他人的挂号单"); } if (registration.getStatus() != RegistrationStatus.PENDING_PAYMENT.getValue() && registration.getStatus() != RegistrationStatus.BOOKED.getValue()) { throw new BusinessException("当前状态不可取消"); } // 先更新挂号单状态 registration.setStatus(RegistrationStatus.CANCELLED.getValue()); registration.setCancelTime(LocalDateTime.now()); registrationRepository.save(registration); // 再释放号源 int released = slotRepository.releaseSlot( registration.getSlotId(), SlotStatus.LOCKED.getValue(), SlotStatus.FREE.getValue()); if (released == 0) { // 号源已经是已使用状态,说明接诊已经开始,抛出异常回滚 throw new BusinessException("号源已进入就诊流程,无法取消"); } }这里还有一个业务细节:如果挂号单在“待支付”状态超时未支付,号源应该自动释放,否则患者占了号却不付费,会白白浪费可挂号源。这个功能可以交给定时任务,每 15 分钟扫描一次待支付超过 30 分钟的挂号单,自动执行上面的取消逻辑。Spring Boot 里用@Scheduled就能实现,后面放号任务会讲到定时任务的装配方式。
接诊完成时,操作方向是相反的:医生端确认接诊后,挂号单状态从“已预约”改成“已完成”,号源状态从“锁定”改成“已使用”。接诊这个动作不涉及号源释放,所以它的事务主要花在更新挂号单和写入就诊记录上。
4. 这些坑我全踩过:事务、锁、状态回补与序列化
4.1 事务内调用同类方法,@Transactional 直接失效
现象:把cancelRegistration放在RegistrationService里,然后在同一个 Service 的一个公开方法里直接调用它,事务注解完全不生效,取消了一半、后一半失败时数据回滚不了。
原因:Spring 的@Transactional通过 AOP 代理实现,同类内部调用this.cancelRegistration()绕过了代理,事务增强逻辑没有被触发。这属于 Spring 事务最常见的失效场景,不少初学者在这个问题上卡几天。
解决:把事务方法拆到独立的 Service Bean 里,或者通过ApplicationContext获取代理对象再调用。更推荐的写法是:一个 Service 只做一件事,比如RegistrationService负责挂号单状态,SlotService负责号源状态,在RegistrationFacade里编排两个 Service,事务注解放在门面方法上,内部调用绕不开代理问题。
4.2 取消挂号只删记录或只改状态,号源回补之后出现“幽灵号”
现象:取消挂号后,号源状态没有回补到“空闲”,或者回补了但只更新余号数字;过一会儿患者重新挂号时,系统提示无号,后台却显示这个号源没有使用记录。
原因:很多人在做取消功能时只关注挂号单这一张表,把状态改成已取消就收工,没有同步处理号源表。还有人在早期“余号即时扣减”方案里只把排班的余号加回去,但下游已产生的号源明细状态没有联动更新。
解决:挂号单状态和号源状态必须放在同一个事务里更新。我的顺序是先改挂号单,再通过条件更新释放号源;更新号源时要加上状态条件,确保不会把一个已经被接诊的号源释放掉。releaseSlot的 SQL 里AND status = 1这个条件就是最后的拦路虎,防止脏数据扩散。
4.3 JPA 懒加载在接口序列化时抛 LazyInitializationException
现象:接口返回挂号单列表时,前端展示需要带出医生姓名和科室名称,于是实体里加了@ManyToOne关联,直接返回实体,Jackson 序列化时报LazyInitializationException: could not initialize proxy。
原因:JPA 的@ManyToOne默认是FetchType.EAGER,但在多表关联复杂后很多人会改成LAZY,Controller 层拿到实体返回时 Session 已经关闭,懒加载的关联对象无法初始化。这是基于 Spring Boot 做源码项目最容易踩的序列化坑。
解决:接口出参用 DTO,不在 Controller 层直接返回实体。查询时通过 JPQL 的JOIN FETCH把需要的关联一次查出来,或者用@EntityGraph指定要加载的属性。我强烈建议在项目第一天就定下“Controller 只返回 DTO”的规矩,这个代价很小,但能省掉后续大量返工。
4.4 先查余号再插入挂号单,并发请求把号源打穿
现象:测试时单线程挂 30 个号没问题,用 JMeter 或脚本并发 100 个请求抢号,结果挂出了 40 多个号,数据库余号变成负数。
原因:代码写成了“先 select 统计空闲号源,再 insert 挂号单”,两个线程同时读到空闲号源为 true,然后各自插入挂号单,最后数据库层面没有任何限制。这就是典型的并发竞态问题,跟锁无关,是逻辑顺序错了。
解决:把“判断余号是否充足”改成“直接尝试锁定一条号源”。号源锁定成功就继续,失败就返回“号已挂完”。数据库乐观锁和唯一索引是最后的防线,业务上先抢到锁才算成功。这个方案下不存在“查一下再插入”的窗口,也就不存在并发超卖。
4.5 时段比较用 String,跨天之后“上午的号”永远查不出来
现象:排班查询按日期取不到当天上午的号,前端传"上午",数据库存"AM",两边比对上就断掉。
原因:前后端没有统一时段枚举的编码。前端传中文,后端存英文缩写,查询时又转成数字,转来转去容易漏掉一个映射分支。
解决:时段字段用 TINYINT 定义标准枚举:1 上午、2 下午、3 晚上。Java 侧用PeriodType枚举,前端下拉框的 value 也用 1、2、3,数据库只存数字。时间比较统一用LocalTime和LocalDateTime,不要拿字符串比较。日期相关字段全部用java.time包,不用Date,避免时区和格式化问题。
4.6 定时放号任务重复执行,排班被铺了两遍号源
现象:重启应用后,定时任务把同一批排班的号源生成两遍,患者可挂的号翻倍。
原因:定时任务没做幂等。createSchedule方法先查排班是否存在,不存在才生成;但两个线程同时查都返回不存在,就各生成了一次。
解决:在数据库中给排班加唯一索引,这是最可靠的幂等保证。另外在生成号源之前再次查库校验排班状态,如果已经存在号源则直接跳过。代码里用“先查后插 + 唯一索引兜底”的方式来防止重复,不要只靠@Scheduled串行执行这种巧合。
5. 权限与安全:JWT 身份校验、角色分离和 XSS 过滤器的落地
5.1 登录与角色:患者、医生、管理员三种角色不能共用一个接口
医院挂号就诊系统天然有三种角色:患者用手机号注册,登录后挂号、查单、取消;医生登录后看排班、看患者列表、确认接诊;管理员维护科室、医生、排班和号源。三种角色能看的接口完全不同,必须在认证阶段就把角色定下来,而不是每个 Controller 里自己判断。
我用 Spring Boot 做这个项目时,采用 JWT 做无状态认证。登录接口校验用户名密码后,把userId和role放进 token 的 claims 中,然后定义一个@RequireRole注解,在拦截器里统一做校验。下面是 JWT 生成的核心代码:
public String generateToken(UserPrincipal user) { return Jwts.builder() .setSubject(user.getId().toString()) .claim("role", user.getRole().name()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }secretKey在application.yml里用独立的配置项管理,不能写死在代码里。token 有效期我设为 24 小时,对门诊这种不算高并发的场景足够;如果患者频繁使用,可以把有效期缩短到 2 小时,另加刷新 token 的逻辑。
拦截器里只做三件事:解析 token 是否合法、判断角色是否有权限访问当前接口、把userId放回请求上下文。这样一个医生账号就不可能调用管理员的排班配置接口,患者的越权访问也会被统一拦截。
5.2 全局过滤器处理上传 PDF 时的 XSS 攻击
很多开发者只在接口参数上做 XSS 过滤,忽略了文件上传这个入口。医院挂号就诊系统中,患者可能上传病历图片、检验报告 PDF,医生也可能上传诊断文档;如果直接把上传文件的内容原样解析并回显到页面上,恶意 PDF 里嵌入的脚本就可能变成存储型 XSS。
在 Spring Boot 中,我一般会写一个全局过滤器,专门拦截文件上传请求,对文件名称做危险字符过滤,对 PDF 内容做文本提取后再次转义。以 PDF 场景为例,上传时先读取文件内容,检查是否包含<script>、javascript:等危险片段,发现问题直接拒绝。
@Component public class XssUploadFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (request instanceof HttpServletRequest) { HttpServletRequest httpRequest = (HttpServletRequest) request; String contentType = httpRequest.getContentType(); if (contentType != null && contentType.toLowerCase().contains("multipart/form-data")) { // 对文件名和上传内容做基础检测 // 实际文件内容需要在业务层重新解析,这里只做第一道拦截 } } chain.doFilter(request, response); } }上传文件的 XSS 防御从来不是过滤器单独能做完的,过滤器负责拦截明显攻击,真正稳妥的做法是:上传文件单独存到对象存储或独立目录,文件名用 UUID 重命名,回显时强制设置Content-Disposition: attachment和X-Content-Type-Options: nosniff响应头,让浏览器不会把返回内容当 HTML 解析。
提示:如果系统里还涉及富文本编辑器,文本型 XSS 过滤要放在服务端入库前做白名单过滤,只保留 p、strong、img 等安全标签,别的标签一律剥掉。前端过滤只是体验,服务端过滤才是底线。
5.3 参数校验与越权防护:字段校验是第一步,数据归属校验才是关键
Spring Boot 的@Valid注解能很快完成“挂号单号不能为空”“手机号格式不对”这类基础校验,真正容易漏掉的是数据归属校验。比如患者 A 拿患者 B 的挂号单号去取消挂号,如果接口只校验“挂号单存在”就放行,就会发生越权操作。
我的做法是:在 Controller 层通过@AuthenticationPrincipal或请求上下文拿到当前登录用户 ID,然后在 Service 层做归属校验。取消挂号、查看挂号详情、支付挂号费这三个接口,都必须校验registration.getPatientId().equals(currentUserId),不相等直接抛 403 异常。医生端接口则要校验当前医生是否属于该号源对应的医生,防止医生点开不属于自己的患者单。
这种归属校验看起来重复,但它是权限系统最后一道防线。JWT 只能证明“你是谁”,不能证明“你能操作谁的数据”,数据归属校验必须落在业务代码里,一步都不能省。
6. 验证与进阶:用并发测试证明挂号逻辑没有翻车,再加一条优化
工程写完不代表逻辑是对的。我最常用的验证方式不是打开浏览器点几次,而是写一个并发脚本,模拟 100 个线程同时抢 30 个号源,看最终挂号单数量和号源状态是否一致。下面这个脚本用 Java 的CountDownLatch模拟并发请求,直接把挂号接口跑一遍:
int threadCount = 100; CountDownLatch ready = new CountDownLatch(threadCount); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { new Thread(() -> { ready.countDown(); try { start.await(); registrationService.register(request); } catch (Exception e) { System.out.println("挂号失败: " + e.getMessage()); } finally { done.countDown(); } }).start(); } ready.await(); start.countDown(); done.await();压测通过的标准有三条:最终挂号单数量不多于号源总数;空闲号源 + 锁定号源 + 已使用号源之和等于初始号源总数;没有任何一条号源被两个挂号单引用。三条都满足,才能说这个挂号逻辑在并发下站得住。如果还想更接近真实环境,可以用 JMeter 跑 HTTP 接口压测,重点关注lockFreeSlot这条 SQL 的耗时和数据库连接池的活跃连接数。
最后说一个进阶优化:预生成号源方案天然支持“号源池隔离”。管理员可以为专家号、普通号分别建排班,号源互不共享;停诊时把排班状态改为停诊,已经挂号的号源批量进入“待退号”状态,由后台任务自动原路退款,患者端只需查看状态变化。这个方向做进去之后,挂号就诊系统就不再是学生项目,而是一个能投入小规模门诊试运行的完整信息化系统。做完这套设计,我自己的感受是:不要把“挂号”当成一个动作去实现,要把它当成一条从排班生成到号源消耗的完整链路去管理。链路上每一环都有状态,每一处状态变更都有条件,系统就不会在并发和异常面前翻车。希望帮到你。
本文还有配套的精品资源,点击获取