简介基于Java Web技术开发的EMR电子病历管理系统完整项目资源面向医疗信息化初学者、Java Web开发者及高校相关专业学生用于理解电子病历系统的数据管理与业务流程实现。资源共255个文件压缩包约2.21MB内含21个Java源码、21个class文件、17个JSP页面以及XML配置、HTML页面、CSS样式、JavaScript脚本等覆盖Servlet/JSP、MVC、Spring、JDBC、ORM等核心技术模块。同时包含SQL数据库文件mdf/ldf与Eclipse工程配置可直接导入开发环境进行二次开发学习。已有1230人学习下载。通过该项目可掌握电子病历系统的权限管理、数据一致性、报表统计等设计思路为后续构建互操作性更强的医疗信息系统打下基础。1. 电子病历系统不是表单系统它是一台医院里的“文档主发动机”很多初次接触电子病历的人会走一个弯路——以为它就是把纸病历搬到网页上做成几个表单填一填。真拆过 EMR 电子病历管理系统之后你会发现它的核心难点根本不在表单而在病历文档的全生命周期模板、结构化存储、状态流转、审签、归档、借阅、打印还原。电子病历系统本质上是一台以“病历文档”为对象的流水线机器它既要让医生录入得快又要让质控查得到、让打印还原得像纸版。这台机器服务三拨人一线医生要的是录入效率和不被打断的工作流病案室和质控要的是完整性和可追溯性信息科要的是可维护、能排障、扛得住全院并发。下面按我拆这套系统时的实际路径把设计思路、核心代码片段和踩过的坑逐层展开。2. 把病历文档做成结构化数据模型设计、存储选型与三类字段陷阱2.1 先分清“人”与“一次就诊”患者主索引与就诊模型的边界EMR 里最容易混淆的是“患者”和“就诊”。患者是长期存在的人就诊是患者在某一个时间窗口内的医疗活动总称。一个患者可以有多次住院、多次门诊每一次就诊都独立携带病历、医嘱、检验报告。设计数据模型时如果只建一张 patient 表所有病历都挂在患者 ID 下后续查“某一次住院的病历”就得靠时间筛选既慢又容易串数据。这套资源里的做法是把模型拆成三层patient患者主索引、visit就诊记录、document病历文档。visit 表记录每次就诊的入院时间、科室、床位、主治医生等公共信息document 表则挂 visit_id。这样任何一个病历文档都能精确回溯到“哪个患者的哪一次就诊”而不是含糊地挂在人头上。CREATE TABLE patient ( id BIGSERIAL PRIMARY KEY, patient_no VARCHAR(32) NOT NULL UNIQUE, name VARCHAR(64) NOT NULL, gender SMALLINT NOT NULL DEFAULT 0, birth_date DATE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE TABLE visit ( id BIGSERIAL PRIMARY KEY, patient_id BIGINT NOT NULL REFERENCES patient(id), visit_no VARCHAR(32) NOT NULL UNIQUE, visit_type SMALLINT NOT NULL, -- 1:门诊 2:住院 admission_time TIMESTAMP WITH TIME ZONE, discharge_time TIMESTAMP WITH TIME ZONE, department_id INT NOT NULL, bed_no VARCHAR(16), attending_doctor_id INT NOT NULL ); CREATE TABLE document ( id BIGSERIAL PRIMARY KEY, visit_id BIGINT NOT NULL REFERENCES visit(id), doc_type VARCHAR(32) NOT NULL, -- 门诊病历/入院记录/病程记录 doc_no VARCHAR(64) NOT NULL UNIQUE, content JSONB NOT NULL DEFAULT {}, status SMALLINT NOT NULL DEFAULT 0, -- 0:草稿 1:已提交 2:已审签 3:已归档 created_by INT NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() );visit 表承担“就诊上下文”的职责document 表只关心“这个 visit 下有哪些文档”。上面 SQL 里 visit_no 和 doc_no 都加了唯一约束这是为了防止手工录入或接口重试时产生重复数据——病历文档一旦重复后续打印和归档就会出双份这是很实际的坑。doc_type 字段建议用统一编码别直接存中文不然统计和接口对接的时候还得做映射。2.2 文档内容为什么用 JSONB结构化与灵活性的平衡点病历文档和普通业务单据最大的区别在于“结构不统一”。一份入院记录包含主诉、现病史、既往史、体格检查等段落而一份手术记录包含术前诊断、术中发现、手术步骤等不同段落段落里还嵌套子项。如果为每种文档建一张表公式化但维护成本极高模板一改就要动表结构。这套资源把文档内容统一存成 JSONB用模板 ID 来标识结构既保留了灵活性又能在查询时利用 JSONB 的 GIN 索引做内容检索。-- 在 document 表上建 GIN 索引支持 content 内部的 key 查询 CREATE INDEX idx_document_content ON document USING GIN (content); -- 查询所有“主诉”里包含“胸痛”的门诊病历 SELECT id, doc_no, content-chief_complaint AS chief_complaint FROM document WHERE content {chief_complaint: } AND content-chief_complaint ILIKE %胸痛% AND doc_type 门诊病历;content 这个操作符是 PostgreSQL JSONB 的原生能力适合做“存在性”过滤文本模糊匹配用 content-chief_complaint ILIKE 实现。我一般把高频查询字段患者姓名、诊断编码、主治医生拆成 document 表的独立列并建 B-tree 索引低频字段留在 JSONB 里这样查询性能最稳。这套设计在文档量到百万级时依然可用但要注意 JSONB 的更新是整体替换频繁改局部字段会带来写放大所以“文档编辑时频繁存草稿”的场景要配合应用层的防抖机制来做。2.3 三类字段陷阱时间、签名、数字编码JSONB 里存字段有三个高频翻车点。第一个是时间格式。Java 端默认序列化的 LocalDateTime 可能是 “2025-06-01T10:30:00”而前端期望的是 “2025-06-01 10:30:00”两者混着存进 JSONB 后排序和区间查询会变得混乱。我在这套资源里统一约定所有时间字段在 JSONB 内一律存 “yyyy-MM-dd HH:mm:ss” 字符串缺失值存 NULL 而不是空串这样排序行为可预期。第二个是签名。手写签名图片如果直接 Base64 字符串塞进 JSONB一条病历几 MB 是常事批量查询时内存直接被打爆。正确做法是把签名图片上传到独立存储JSONB 里只存图片地址和签名时间。第三个是数字编码。诊断编码、手术编码、科室编码全部用标准编码字符串不要在前端显示值和存储值之间来回转。3. 模板引擎与动态表单渲染从 XML 配置到 Web 表单的落地路径3.1 为什么不用现成的富文本编辑器直接存 HTML很多初版 EMR 的做法是在页面上放一个富文本编辑器医生输入什么就存什么 HTML。这套方案初期开发快后面维护成本会很高——不同浏览器生成的 HTML 标签不一致打印样式被内联 style 干扰质控抽查时难以统一提取字段。模板引擎要解决的是“结构约束”问题一段病历的段落顺序、必填项、字段类型都应该由模板定义而不是由医生自由发挥。这套资源用 XML 定义模板结构后端解析后向前端输出动态表单配置。XML 的好处是结构直观、可版本化管理也方便非开发人员维护。template codeoutpatient_note name门诊病历 version1.3 section codechief_complaint name主诉 requiredtrue field typetextarea maxlength200 placeholder症状持续时间 / /section section codepresent_history name现病史 requiredtrue field typetextarea maxlength2000 / /section section codephysical_exam name体格检查 field typetextarea maxlength1000 / /section section codediagnosis name诊断 requiredtrue field typetag-input dicticd10 maxcount10 / /section /templateXML 模板里的 section 是段落field 是字段。required 标记必填段落控制医生提交时做校验。dict 属性挂接字典编码比如诊断字段挂 icd10 字典前端渲染时就变成带搜索的标签输入框。模板版本号很重要——病历文档存的是“模板 ID 模板版本号”不是只存模板 ID否则模板改版后旧病历的渲染结果会悄悄变化。3.2 从 XML 到前端表单解析、渲染与数据回填后端把 XML 模板解析成 JSON 配置输出给前端前端根据配置动态渲染表单。这里的关键是数据回填编辑已有病历时要把 JSONB 里存的字段值按模板结构回填到表单。如果模板改了字段名或结构回填时就可能出现字段对不上的情况所以需要按模板版本做字段映射。// 前端渲染逻辑Vue 3 风格核心逻辑通用 function renderForm(templateConfig, savedData) { return templateConfig.sections.map(section { const sectionData savedData?.[section.code] || {}; return { sectionCode: section.code, sectionName: section.name, required: section.required, fields: section.fields.map(field { return { ...field, value: sectionData[field.code] || , touched: false }; }) }; }); } // 提交时提取数据为纯 JSON结构干净不包含 UI 状态 function collectFormData(renderedForm) { const result {}; renderedForm.forEach(section { result[section.sectionCode] {}; section.fields.forEach(field { result[section.sectionCode][field.code] field.value; }); }); return result; }renderForm 负责把模板配置和已存数据合并成渲染表单collectFormData 在提交时把 UI 状态剥离、只保留字段值。两段代码一拆数据的存储结构与 UI 结构解耦了——存的时候很干净渲染的时候按模板恢复。这样做的额外好处是同一份数据可以用不同模板版本渲染方便历史病历按新版样式展示。3.3 模板版本管理与旧数据兼容一份 XSD 校验与升级注入模板改版是必然的关键是怎么让旧病历不受影响。这套资源里的约定是模板一经发布不允许修改只能新建版本病历录入时记录模板版本号展示时优先用保存的版本号对应模板渲染如果该版本模板已停用则用最近一个兼容版本渲染并标记“已升级显示”。# 升级校验逻辑示意 def resolve_template_for_document(doc_version, available_versions): # available_versions 按版本号倒序排列 for ver in available_versions: if ver[version] doc_version: return ver[config] # 老版本归档寻找兼容版本 for ver in available_versions: if ver[compatible_with] and doc_version in ver[compatible_with]: return ver[config] raise TemplateNotFoundException(fno compatible template for version {doc_version})resolve_template_for_document 的逻辑是先精确匹配保存的版本找不到就找兼容版本两个都没有就报错而不是静默用最新版本渲染——静默降级会让旧病历的段落顺序变化质控检查时发现就会追责到系统头上。模板发布流程里建议加一道 XSD 校验模板上线前先验证结构合法性和必填项配置完整再走发布审批避免线上模板解析报错。4. 工作流与状态流转会诊、审签、归档的并发控制实践4.1 病历状态机的设计草稿、提交、审签、归档的边界定义EMR 的病历文档不能像普通笔记一样随便改每个状态都有严格的权限边界。这套资源把 document 表的状态设计成五个草稿、已提交、审签中、已归档、已作废。草稿阶段只有创建人可编辑已提交后内容锁死质控或上级医生可以审签审签中发现缺陷可以退回草稿归档后所有编辑入口关闭只能借阅。状态机的核心是“哪些状态允许哪些动作”的矩阵配置。比如“已提交”状态下只有角色为“主任医师”或“质控员”的用户才能触发“审签”动作普通医生只能触发“撤回”。这套矩阵在代码里用一张表配置不用硬编码因为各医院的流程差异很大。# 状态流转校验伪代码 def transition_document(doc, target_status, operator, role): allowed STATE_MACHINE[doc.status] if target_status not in allowed: raise InvalidTransitionError(f{doc.status} - {target_status} not allowed) if role not in allowed[target_status][roles]: raise PermissionDeniedError(frole {role} cannot perform {target_status}) # 乐观锁更新防止两人同时审签 updated db.execute( UPDATE document SET status%s, updated_atNOW() WHERE id%s AND status%s, target_status, doc.id, doc.status ) if updated.rowcount 0: raise ConcurrentModificationError(document status changed by another user) return updatedtransition_document 里先校验状态动作矩阵再校验角色权限最后用乐观锁更新。UPDATE 语句的 WHERE 条件带上原状态 status%s一旦两个医生同时操作同一份病历先执行的人成功后者 rowcount 为 0 就抛出并发冲突应用层捕获后提示用户“文档已被他人处理”。这里不能用悲观锁否则一个医生打开病历编辑时其他人都要等待科室协作体验会很差。4.2 会诊流程的高频并发问题多人同时更新同一份病历会诊是 EMR 里并发冲突最高的场景一份疑难病例被多位医生同时查阅、填写意见。如果设计成“大家编辑同一段文本”会产生严重的写冲突和内容覆盖。这套资源的做法是把会诊意见拆成独立子文档——主病历只保留一个只读快照每个会诊医生在自己的 child_document 上填写意见父文档的 status 改为“会诊中”等所有会诊意见提交后由主持人汇总。CREATE TABLE consult_record ( id BIGSERIAL PRIMARY KEY, parent_doc_id BIGINT NOT NULL REFERENCES document(id), doctor_id INT NOT NULL, opinion_content TEXT, opinion_status SMALLINT NOT NULL DEFAULT 0, -- 0:待填写 1:已提交 created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE (parent_doc_id, doctor_id) );UNIQUE(parent_doc_id, doctor_id) 确保每个医生对同一份病历只能有一条会诊记录重复提交走 UPDATE 而不是 INSERT。这样设计后并发冲突从“多人争抢一个字段”降级为“每人写自己的字段”写冲突的概率大幅降低。唯一剩下的并发点在于所有意见都提交后的“汇总归档”动作这时用一个 UPDATE 语句判断所有 consult_record 均已提交后再改主文档状态即可。4.3 归档的不可逆性只读副本与留痕机制病历归档后就不能改但现实里经常有“归档后发现错别字需要更正”的需求。这套资源的做法不是把归档状态打开而是建立“更正记录”子文档在归档病历上追加一个更正说明由更正人、质控员签字原内容保持不动。实现上就是把 document 表加一个 parent_id 字段更正记录作为子文档挂在原文档下。ALTER TABLE document ADD COLUMN parent_id BIGINT REFERENCES document(id); -- 归档时生成只读副本复制内容与元数据 INSERT INTO document (visit_id, doc_type, doc_no, content, status, parent_id, created_by) SELECT visit_id, doc_type, doc_no || -ARCHIVE- || id, content, 3, id, created_by FROM document WHERE id %(doc_id)s;归档时把原文档内容复制成一份 status3 的只读副本原文档继续保留编辑痕迹。查询时默认展示副本需要查看修改历史时关联 parent_id 链条。这套设计既满足病案管理的“不改原文”要求又给了临床一个合法的更正通道比直接放开归档状态要安全得多。5. 避坑清单EMR 项目里最常见的五个翻车现场5.1 打印与屏幕显示总差一截现象病历在屏幕上排版正常打印出来却出现段落错位、字体变大、表格断裂。原因浏览器打印时使用的是打印样式表前端把屏幕用的 CSS 直接复用到了打印场景。常见是 width 用了百分比或 vw 单位打印页面宽度是按纸张计算的百分比会被重新换算。解决为打印单独写 print.css页面宽度设为固定值 190mmA4 纸减边距字体用 pt 不用 px表格加上 page-break-inside: avoid。调试时用浏览器的打印预览不要用 Chrome 的响应式模式模拟两者渲染机制不同。这套资源里就内置了一份通用的 print.css段落间距和页边距都调过了直接引到入口页就能用。5.2 模板改版后旧病历渲染错乱现象医院把“既往史”段落从现病史之后移到体格检查之后结果打开半年前的病历段落顺序全乱部分字段丢失。原因病历数据是 JSON 键值对顺序本来就不保留模板改版后按新模板结构渲染旧数据字段对不上就丢失了。解决按 3.3 节的版本规则存储时带模板版本号渲染用原版本模板老数据升级到新模板要经过字段映射转换过程而不是直接套新模板。我在这个坑上花了两天做数据迁移脚本从那以后每个模板版本发布前都强制跑一遍“旧数据渲染测试”——用上一个版本的真实病历来测试新版本的兼容性。5.3 凌晨定时归档任务静默失败现象病案室发现一批住院病历过了 3 天还没归档查日志没有任何报错。原因定时任务里调用了外部服务接口接口超时后任务框架默认重试 3 次重试也失败后就把异常吞掉了。没有监控告警和数据对账失败就静默过去了。解决定时任务必须有失败告警和次日对账。这套资源提供的处理方式是在任务末尾加一个统计 SQL比对“应归档数”和“已归档数”不相等就发企业微信/短信消息到病案室管理员。从那以后我所有定时任务都会加一个“数量对账”步骤——不指望不出错只希望出错时能第一时间知道。5.4 会诊超时无人处理病历卡在“会诊中”现象病历进入会诊状态后某位医生出差没登录系统病历卡在“会诊中”无法归档患者出院手续都办不了。原因会诊流程没有超时机制系统不强制催办也没人主动处理滞留状态。解决加一个超时扫描任务会诊创建 24 小时后未提交意见的系统自动给医生和科室主任发提醒48 小时后仍未处理自动转交科室其他人处理。实现上就是轮询 consult_record 表查 created_at 超过阈值且 opinion_status0 的记录。机制虽简单但能避免病历被“人走事停”卡住。5.5 手写签名图片存进数据库导致表膨胀现象数据库磁盘占用快速增长病历查询变慢备份时间越来越长。原因签名图片以 Base64 字符串直接存进 JSONB 字段每天几百个患者住院签名几个月就是几个 GB 的无效数据膨胀。解决图片统一走文件存储服务数据库只存对象地址和签名时间。迁移老数据时用脚本读 JSONB 里的 Base64 字段解码后上传文件服务再回写地址。这个坑属于早期设计没想清楚的典型案例越晚发现代价越大。6. 上线前的验证方法把系统当“文档流”而不是“表单流”来测6.1 用统计脚本跑一遍“录入-审签-归档-打印”全链路EMR 系统最容易出错的地方不是某个表单而是文档生命周期跨多个节点时数据是否保持一致。我在验收系统时习惯用 SQL 做全链路对账-- 验证归档状态下的文档全部有对应一份只读副本 SELECT d.id AS doc_id FROM document d LEFT JOIN document copy ON copy.parent_id d.id AND copy.status 3 WHERE d.status 3 AND copy.id IS NULL; -- 验证所有已归档病历的必填段落都有值 SELECT d.id, d.content-chief_complaint AS chief_complaint FROM document d WHERE d.status 3 AND d.doc_type 门诊病历 AND (d.content-chief_complaint IS NULL OR d.content-chief_complaint );第一段验证“已归档的文档是否有对应只读副本”第二段验证“归档病历的核心字段是否空值”。两个查询在每次版本发布后跑一遍能快速发现数据写入流程的回归问题。我一般会再加一个脚本统计当日新增病历数、已提交数、已归档数三者之间的差值应该等于“草稿审签中”的数量对不上就说明某个状态流转出现了遗漏。6.2 打印对比测试一份病历打印三次逐行对比打印还原是 EMR 里最琐碎也最容易出问题的环节。我的经验是每次改完模板或打印样式都选 3 份真实病历一份门诊、一份入院记录、一份手术记录各打印两次逐行对比段落顺序、字段值、分页位置。这份工作看起来笨拙但比任何自动化测试都可靠因为它直接验证了用户最终拿到手的那张纸是正确的。自动化用例只能保证代码改动不报错打印效果必须靠人工看。6.3 让医生参与验收而不是只看 IT 测试系统开发到可运行时我不急着让信息科写测试报告而是找三个科室的医生各用一天真录入几份病历。医生对录入效率的敏感度远超预期的测试人员他们能告诉你“这个字段默认值应该取上次的值”“这里多了一步点击”“这个下拉选项顺序和门诊经常用到的顺序不一致”。这些反馈是任何技术测试都替代不了的——系统是给人用的让真实用户在最真实的场景下用一遍比跑一百个自动化用例都管用。有一次上线前A同学坚持要把所有历史病历一次性导入新系统我劝他先导一个科室的数据试运行两周再全量导。他不听结果导入后才发现旧系统导出的数据里诊断编码混用新旧版本全量导入后一个月都在洗数据。从那以后我每次做 EMR 迁移都强制走一遍“小范围试运行-数据对账-再全量”的流程宁可慢三天绝不连夜全量切换。希望这些拆解和排坑经验能帮你少走几个弯。本文还有配套的精品资源点击获取