ARTICLE DETAIL

资讯详情

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

互联网医院系统源码实战:SpringBoot与问诊状态机设计

互联网医院系统源码实战:SpringBoot与问诊状态机设计 简介这是一套基于Java技术栈、SpringBoot框架与MySQL数据库构建的互联网医院系统源码面向具备一定Java Web基础、希望深入在线医疗应用开发的开发者与学习者。项目采用前后端分离架构后端通过RESTful API提供服务前端可对接原生APP涵盖在线挂号、电子病历、在线问诊、处方开具、药品配送等核心业务模块并涉及JPA与Hibernate数据持久化、Spring Security安全认证、日志记录及JUnit单元测试等实践要点。资源以zip压缩包形式提供整体约267.92MB包含项目源码与相关配置文件目录结构清晰便于按模块检索与二次开发。目前已有207人学习关注适合作为课程设计、毕业设计或技术研究的参考方案帮助读者理解微服务拆分、数据库设计与接口权限控制等关键环节快速搭建可运行的互联网医疗应用原型。1. 一套互联网医院系统源码真正难啃的不是 SpringBoot 而是问诊状态机互联网医院系统源码这个关键词搜索的人大致分两类一类是手上有个 Java SpringBoot MySQL 原生 APP 的毕设或外包项目想找一套能跑通的参考实现另一类是真的在评估自研一套线上问诊平台想先摸清楚技术栈的边界在哪。两拨人的诉求其实一样——这套东西能不能在本地跑起来、数据库怎么设计、APP 端和后台怎么对接、哪些地方最容易翻车。我前后参与过两套类似系统的搭建一套是给区域医疗平台做的图文问诊模块一套是帮朋友救火的毕设项目。血泪经验是SpringBoot 的增删改查谁都会写MySQL 建表也不难真正让项目卡住的是问诊流程的状态流转——从患者发起、医生接诊、开具处方、药师审方到订单完成中间任何一个状态没对齐前端就会显示成“玄学 bug”。这篇笔记就按一套可复现的最小系统来拆讲清楚表怎么建、接口怎么分层、原生 APP 怎么对接、坑在哪。2. 互联网医院系统的数据模型从患者、医生到问诊单的六张核心表2.1 为什么先定表结构再写代码很多人拿到 SpringBoot 项目第一反应是打开 IDEA 建 Controller结果写到一半发现字段对不上回头改表、改实体、改 DTO改到怀疑人生。互联网医院系统的业务实体比普通商城复杂因为它有“角色 时间 状态”三重维度同一个用户可能是患者也可能是医生同一条问诊记录在不同时间点属于不同状态同一个医生在不同科室有不同排班。这三重维度如果不先在表结构里表达清楚后面代码里全是 if-else 补丁。我一般会先画一张实体关系草图确认六张核心表用户表、医生表、科室表、问诊单表、处方表、订单表。用户表存账号和角色医生表存执业信息和职称科室表存科室树问诊单表是主流程载体处方表和订单表是衍生业务。下面给出建表语句字段命名统一用下划线避免 MySQL 在 Linux 下大小写敏感导致的坑。-- 用户表患者和医生共用一张账号表用 role 区分 CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(64) NOT NULL COMMENT 登录名, password VARCHAR(128) NOT NULL COMMENT BCrypt 加密后的密码, real_name VARCHAR(32) DEFAULT NULL COMMENT 真实姓名, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, role TINYINT NOT NULL DEFAULT 0 COMMENT 0患者 1医生 2药师 3管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 医生表扩展执业信息与 sys_user 一对一 CREATE TABLE doctor_info ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 关联 sys_user.id, dept_id BIGINT NOT NULL COMMENT 科室ID, title VARCHAR(32) DEFAULT NULL COMMENT 职称, good_at VARCHAR(255) DEFAULT NULL COMMENT 擅长领域, consult_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 图文问诊价格, audit_status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审核 1通过 2驳回, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_dept_id (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT医生信息表; -- 问诊单表整个系统的核心状态字段决定流程走向 CREATE TABLE consult_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务单号, patient_id BIGINT NOT NULL COMMENT 患者 user_id, doctor_id BIGINT NOT NULL COMMENT 医生 user_id, dept_id BIGINT NOT NULL COMMENT 科室ID, type TINYINT NOT NULL DEFAULT 1 COMMENT 1图文 2电话 3视频, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待接诊 1问诊中 2已开方 3已完成 4已取消, symptom TEXT COMMENT 主诉, amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 订单金额, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_patient (patient_id), KEY idx_doctor_status (doctor_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT问诊单表;这三张表是整个系统的骨架。sys_user用role字段做角色区分好处是登录逻辑统一坏处是查询医生列表时要 joindoctor_info所以我在doctor_info上加了user_id索引。consult_order的status字段是后面所有接口的判据idx_doctor_status这个联合索引专门给“医生查自己的待接诊列表”用这是最高频的查询。2.2 处方表和订单表为什么要拆开处方和订单在业务上是两回事。处方是医生开的药可能包含多种药品、用法用量、审方状态订单是患者付的钱包含支付方式、支付流水、退款状态。如果合成一张表字段会膨胀到三四十个而且药师审方和财务对账的查询模式完全不同。拆开之后处方表关联问诊单订单表也关联问诊单通过consult_id做逻辑关联不做外键约束——互联网医院系统里我建议一律不用物理外键原因后面避坑章节细说。-- 处方表一个问诊单可能对应一张处方处方下挂明细 CREATE TABLE prescription ( id BIGINT NOT NULL AUTO_INCREMENT, consult_id BIGINT NOT NULL COMMENT 问诊单ID, doctor_id BIGINT NOT NULL, patient_id BIGINT NOT NULL, audit_status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审 1通过 2驳回, audit_remark VARCHAR(255) DEFAULT NULL COMMENT 审方意见, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_consult (consult_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT处方表; -- 处方明细表 CREATE TABLE prescription_item ( id BIGINT NOT NULL AUTO_INCREMENT, prescription_id BIGINT NOT NULL, drug_name VARCHAR(128) NOT NULL COMMENT 药品名, spec VARCHAR(64) DEFAULT NULL COMMENT 规格, quantity INT NOT NULL DEFAULT 1, usage_desc VARCHAR(255) DEFAULT NULL COMMENT 用法用量, PRIMARY KEY (id), KEY idx_prescription (prescription_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT处方明细表;处方拆成主表和明细表是因为一张处方里的药品数量不固定。如果硬塞进一个 JSON 字段药师审方时没法按药品维度做统计也没法做药品库存联动。明细表用prescription_id关联查询时一次 join 就能拿到完整处方。3. SpringBoot 后端分层Controller、Service、状态机怎么各司其职3.1 项目结构和依赖选型SpringBoot 版本我建议锁在 2.7.x不要盲目追 3.x。原因很实际很多互联网医院系统要对接医院内网的老接口那些接口用的是 JDK 8 编译的 jarSpringBoot 3 要求 JDK 17类加载和反射行为有差异对接时容易出NoSuchMethodError。热词里有人问“springboot 版本太高”怎么办这就是典型场景。pom.xml核心依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies !-- Web 层 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus比原生 MyBatis 少写一半 XML -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- JWT原生 APP 端用 token 鉴权不用 session -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency /dependencies选 MyBatis-Plus 而不是 JPA是因为互联网医院系统的查询条件经常变——今天按科室筛明天按状态筛后天按时间段筛。MyBatis-Plus 的QueryWrapper拼条件比 JPA 的 Specification 直观而且分页插件开箱即用。JWT 是给原生 APP 用的APP 端没有浏览器 cookie 机制必须用 token 放在请求头里。3.2 问诊状态机把 if-else 收进一个枚举问诊单的状态流转是这套系统最容易写乱的地方。我见过一个项目接诊、开方、完成三个操作分散在三个 Service 里每个 Service 都自己判断status结果出现“已完成的单子还能被接诊”这种 bug。正确做法是把状态流转规则收进一个枚举所有变更必须走统一入口。public enum ConsultStatus { WAIT_ACCEPT(0, 待接诊), IN_CONSULT(1, 问诊中), PRESCRIBED(2, 已开方), FINISHED(3, 已完成), CANCELED(4, 已取消); private final int code; private final String desc; ConsultStatus(int code, String desc) { this.code code; this.desc desc; } // 定义合法流转当前状态 - 允许的下一状态 public static boolean canTransfer(int from, int to) { if (from WAIT_ACCEPT.code to IN_CONSULT.code) return true; if (from WAIT_ACCEPT.code to CANCELED.code) return true; if (from IN_CONSULT.code to PRESCRIBED.code) return true; if (from IN_CONSULT.code to CANCELED.code) return true; if (from PRESCRIBED.code to FINISHED.code) return true; return false; } }这个枚举的关键是canTransfer方法它把“哪些状态能跳到哪些状态”写死在一处。Service 层改状态前先调这个方法不合法就抛业务异常。参数说明from是数据库里当前的statusto是本次操作想改成的值。这样即使后面加了“退诊”流程也只需要在这里加一行判断不用去翻三个 Service。3.3 接诊接口的完整实现以“医生接诊”为例走一遍 Controller 到 Service 的完整链路。接口设计上原生 APP 端调用的接口统一加/api/app前缀后台管理端加/api/admin方便后面做权限拦截。RestController RequestMapping(/api/app/consult) public class ConsultController { Autowired private ConsultService consultService; // 医生接诊传入问诊单ID PostMapping(/accept/{orderId}) public ResultVoid accept(PathVariable Long orderId, RequestHeader(Authorization) String token) { Long doctorId JwtUtil.getUserId(token); consultService.accept(orderId, doctorId); return Result.ok(); } }Controller 只做参数提取和结果包装不写业务判断。JwtUtil.getUserId从 token 里解析出当前登录用户避免前端传 doctorId 被篡改。下面是 Service 实现Service public class ConsultServiceImpl implements ConsultService { Autowired private ConsultOrderMapper consultMapper; Override Transactional(rollbackFor Exception.class) public void accept(Long orderId, Long doctorId) { ConsultOrder order consultMapper.selectById(orderId); if (order null) { throw new BizException(问诊单不存在); } // 校验只有该医生本人能接自己的单 if (!order.getDoctorId().equals(doctorId)) { throw new BizException(无权操作该问诊单); } // 校验状态流转合法性 if (!ConsultStatus.canTransfer(order.getStatus(), ConsultStatus.IN_CONSULT.getCode())) { throw new BizException(当前状态不允许接诊); } order.setStatus(ConsultStatus.IN_CONSULT.getCode()); consultMapper.updateById(order); } }这段代码有三个要点。第一Transactional保证状态更新和后续可能的日志写入在同一事务里。第二先查再判再改不要用update ... where status 0这种乐观锁写法因为业务异常需要给前端明确提示而不是静默失败。第三order.getDoctorId().equals(doctorId)用equals不用Long 类型超过 127 之后比较的是引用这是 Java 基础面试题里的经典坑实际项目里真有人栽过。4. 原生 APP 端对接接口约定、Token 刷新和文件上传4.1 接口返回格式统一约定原生 APP 端和后台最大的区别是APP 发版不可控老版本可能长期存在。所以接口返回格式必须从第一版就定死不能中途改字段名。我一般用统一的Result包装public class ResultT { private int code; // 0 成功非 0 失败 private String msg; // 提示信息 private T data; // 业务数据 public static T ResultT ok(T data) { ResultT r new Result(); r.code 0; r.msg success; r.data data; return r; } public static T ResultT fail(int code, String msg) { ResultT r new Result(); r.code code; r.msg msg; return r; } }code用 0 表示成功而不是 200是为了和 HTTP 状态码解耦。HTTP 层永远返回 200业务成败看code。这样 APP 端的网络库拦截器只需要判断code不用处理 401、403 这些 HTTP 状态。参数说明code建议分段1xxx 表示参数错误2xxx 表示权限错误3xxx 表示业务错误方便排查。4.2 Token 过期与刷新机制原生 APP 的 token 不能设太长否则泄露风险大也不能太短否则用户频繁登录。常见做法是双 tokenaccessToken有效期 2 小时refreshToken有效期 7 天。accessToken过期后APP 用refreshToken换新的用户无感知。Component public class JwtUtil { private static final String SECRET your-256-bit-secret; private static final long ACCESS_EXPIRE 2 * 60 * 60 * 1000L; // 2小时 private static final long REFRESH_EXPIRE 7 * 24 * 60 * 60 * 1000L; // 7天 public static String createAccessToken(Long userId) { return Jwts.builder() .setSubject(String.valueOf(userId)) .setExpiration(new Date(System.currentTimeMillis() ACCESS_EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Long getUserId(String token) { String raw token.replace(Bearer , ); Claims claims Jwts.parser().setSigningKey(SECRET) .parseClaimsJws(raw).getBody(); return Long.valueOf(claims.getSubject()); } }SECRET必须放在配置文件里不要硬编码在代码中。getUserId里先去掉Bearer前缀因为 APP 端习惯在请求头写Authorization: Bearer xxx。如果 token 过期parseClaimsJws会抛ExpiredJwtException全局异常处理器捕获后返回特定codeAPP 端拦截到这个code就触发刷新流程。4.3 问诊图片上传与 XSS 防护图文问诊少不了患者上传症状照片。文件上传接口要注意两点一是限制文件类型和大小二是防止文件名注入。热词里有人问“springboot 项目全局过滤器处理上传 pdf 文件时 xss 攻击”思路是一样的——所有上传文件重命名不信任原始文件名。PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.fail(1001, 文件为空); } // 限制 5MB if (file.getSize() 5 * 1024 * 1024) { return Result.fail(1002, 文件超过5MB); } String original file.getOriginalFilename(); String ext original null ? : original.substring(original.lastIndexOf(.)); // 白名单校验扩展名 if (!Arrays.asList(.jpg, .jpeg, .png, .pdf).contains(ext.toLowerCase())) { return Result.fail(1003, 不支持的文件类型); } // 用 UUID 重命名杜绝路径穿越和 XSS String newName UUID.randomUUID().toString().replace(-, ) ext; // 存储逻辑省略返回可访问的相对路径 return Result.ok(/upload/ newName); }关键在UUID重命名这一步。如果直接用原始文件名攻击者可以传../../etc/passwd这种路径或者传test.html然后在浏览器里触发脚本。重命名之后文件名完全可控原始文件名只存数据库做展示用不参与文件系统操作。5. 避坑与排查互联网医院系统落地时最容易翻车的五件事5.1 现象MySQL 连接报error 2002 (hy000)服务起不来原因这个错误几乎都是 MySQL 服务没启动或者 socket 文件路径不对。Linux 下用rpm安装 MySQL 后默认 socket 在/var/lib/mysql/mysql.sock但客户端配置可能指向/tmp/mysql.sock。另外如果 MySQL 是 Docker 跑的容器内外的 socket 路径不一致也会报这个。解决先systemctl status mysqld确认服务状态没启动就systemctl start mysqld。如果是 socket 路径问题在my.cnf里显式指定socket/var/lib/mysql/mysql.sock客户端连接串里也加上对应路径。Docker 场景下SpringBoot 配置的url要用容器名或宿主机 IP不要写localhost。5.2 现象问诊单状态出现“已完成后又被接诊”原因状态判断散落在多个 Service或者用了update ... where id ?没带状态条件并发下两个请求同时通过校验。更隐蔽的是 MyBatis-Plus 的updateById默认更新所有非 null 字段如果实体里status被别处赋了值会覆盖掉。解决所有状态变更走ConsultStatus.canTransfer统一校验数据库更新时用update ... set status ? where id ? and status ?带上原状态做乐观锁。MyBatis-Plus 可以用UpdateWrapper显式指定set和eq不要图省事用updateById。5.3 现象APP 端登录后过一会儿接口全返回 401原因accessToken过期了但 APP 端没有刷新逻辑或者刷新接口本身也需要 token形成死循环。还有一种情况是服务器时间不同步JWT 的exp校验用的是服务器时间时间偏差超过几分钟就会误判过期。解决APP 端网络拦截器里判断业务code遇到 token 过期先调刷新接口刷新接口用refreshToken鉴权不走accessToken。服务器统一装 NTP 服务同步时间timedatectl可以查看当前时间同步状态。5.4 现象医生列表分页查询越翻越慢原因consult_order表数据量上来后limit 100000, 10这种深分页会全表扫描。另外如果doctor_info和sys_userjoin 时没有走索引每页都要回表。解决深分页改用游标方式记录上一页最后一条的id下一页查where id lastId limit 10。join 查询确保doctor_info.user_id和sys_user.id都有索引。如果业务允许医生列表做缓存用 Redis 存热点科室的医生数据设置 5 分钟过期。5.5 现象处方审方后库存没扣减原因处方和药品库存是两个模块审方通过后没有发消息通知库存服务或者用了同步调用但库存服务超时导致事务回滚不一致。解决审方通过后发一条 MQ 消息库存服务消费后扣减。如果项目规模小不想引入 MQ至少用 Spring 的ApplicationEvent做进程内事件把扣库存逻辑从事务里拆出来。注意事件监听要加TransactionalEventListener并指定phase AFTER_COMMIT确保主事务提交后才扣库存。6. 用最小闭环验证一套互联网医院源码值不值得深入拿到一套源码不要急着通读所有代码先跑一个最小闭环患者注册登录 → 选医生 → 发起问诊 → 医生接诊 → 开方 → 审方 → 完成。这个闭环跑通说明核心链路没问题跑不通看卡在哪一步基本能判断代码质量。验证时我习惯用 Postman 或 curl 按顺序打接口把每一步的返回记下来。下面是一个用 curl 验证接诊接口的例子# 1. 登录拿 token curl -X POST http://localhost:8080/api/app/login \ -H Content-Type: application/json \ -d {username:doctor01,password:123456} # 2. 用返回的 token 接诊假设问诊单ID为 1001 curl -X POST http://localhost:8080/api/app/consult/accept/1001 \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx # 3. 查询问诊单状态确认 status 已变为 1 curl -X GET http://localhost:8080/api/app/consult/1001 \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx如果第 2 步返回“当前状态不允许接诊”去数据库看consult_order.status的初始值是不是 0。如果第 3 步查出来还是 0说明事务没提交或者更新条件没匹配上。这种逐步验证的方法比看代码快得多因为代码可能写得漂亮但配置有问题。一个具体技巧在application.yml里把 MyBatis 的 SQL 日志打开logging.level.com.yourpackage.mapperdebug这样每次查询和更新都会打印实际执行的 SQL 和参数。状态类 bug 十有八九是 SQL 的where条件少了字段日志一看就清楚。mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: com.hospital.mapper: debuglog-impl用StdOutImpl直接打到控制台适合本地排查。生产环境换成Slf4jImpl避免日志量过大。logging.level精确到 mapper 包不要开全局 debug否则 Spring 自身的日志会淹没业务 SQL。最后说个我自己的习惯每接一套新源码先看它的consult_order表有几个状态字段、状态流转写在哪里。如果状态判断散落在 Controller 里这套代码的维护成本会很高后面加需求容易出连锁 bug如果状态收在枚举或状态机里说明作者想过流程问题值得花时间深入。希望帮到你。本文还有配套的精品资源点击获取
返回列表