ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot医院挂号系统核心设计:号源模型与状态机实战

Spring Boot医院挂号系统核心设计:号源模型与状态机实战 简介一套基于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 就诊位置 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 逻辑删除标记 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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); ListScheduleSlot 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但在多表关联复杂后很多人会改成LAZYController 层拿到实体返回时 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 的耗时和数据库连接池的活跃连接数。最后说一个进阶优化预生成号源方案天然支持“号源池隔离”。管理员可以为专家号、普通号分别建排班号源互不共享停诊时把排班状态改为停诊已经挂号的号源批量进入“待退号”状态由后台任务自动原路退款患者端只需查看状态变化。这个方向做进去之后挂号就诊系统就不再是学生项目而是一个能投入小规模门诊试运行的完整信息化系统。做完这套设计我自己的感受是不要把“挂号”当成一个动作去实现要把它当成一条从排班生成到号源消耗的完整链路去管理。链路上每一环都有状态每一处状态变更都有条件系统就不会在并发和异常面前翻车。希望帮到你。本文还有配套的精品资源点击获取
返回列表