简介:一份面向临床研究数据管理场景的EDC系统完整源代码包,适合希望深入理解临床试验数据采集流程的开发者、研究者及计算机相关专业学习者,用于搭建可定制化的数据采集管理平台,解决试验数据标准化录入、质量核查与报表输出等问题。压缩包为zip格式,共6个文件,以4个Java源码为主,另含2个VSS版本控制文件,整体约17KB,体量轻便,便于快速浏览核心实现。资源围绕试验项目管理、实验设计、CRF表单设计、疑问管理、数据录入、逻辑自动校验、CRF与DCF表单导出PDF、统计报表等环节展开,并包含输入与手工查询两个功能模块,可帮助读者梳理EDC系统的数据流与校验思路。已有1731人学习,适合作为了解临床试验数据管理流程、进行二次开发或课程设计参考的源码样本。
1. 从一张 CRF 表说起:EDC 系统到底管什么
做过临床试验数据采集的人大概都见过这种场面:一个多中心项目铺开,三十几家中心同时往回交表格,数据管理员每天的工作就是比对哪一版才是最终版,改错一个单元格,整条数据链就说不清了。EDC 系统要解决的就是这件事——把 CRF 表单、受试者访视、逻辑核查、数据质疑和稽查轨迹装进一套临床试验管理系统,让每次修改都留痕、每条异常都有据可查。这份源码给的是一个完整可运行的 EDC:建库、录入、核查、质疑、锁库、导出整条链路打通,前后端加数据库脚本齐全。适合两类人:刚接手临床数据系统的开发者,想找一套能跑起来的参照;医药研发团队的技术负责人,需要一套能自己改、能过内部评审的底座。
2. 技术选型与工程结构:Spring Boot 单体 + Vue 前后端分离的骨架
2.1 为什么这类系统更适合单体而不是微服务
先泼一盆冷水。如果拿互联网高并发的思路来看 EDC,很容易得出「拆微服务」的结论,但真做过临床项目就知道,这类系统的并发模型和电商完全不是一回事。一家中型研究中心同时在线的用户可能就几十个,峰值出现在早上录数据的时段,QPS 撑死几百。真正压着架构的是另外三件事:事务一致性、审计完整性和发版验证成本。
事务一致性好理解。一次 CRF 保存要同时落数据行、生成质疑、写稽查轨迹、刷新核查结果,这四件事必须在一个事务边界里,任何一步失败都得整体回滚。拆成微服务,就得引入分布式事务或者最终一致性补偿,本来一个@Transactional能解决的事,变成一套消息表加重试队列,复杂度翻几倍,收益接近于零。
审计完整性更麻烦。合规要求是「任何一次数据变更都能还原出谁、什么时候、改了什么、为什么改」,轨迹必须和数据写入同源同事务。数据服务和审计服务分开部署,中间网络抖一下,数据进去了轨迹没进去,这在核查时是直接判缺陷的。
发版验证成本是最容易被低估的。临床系统每次上线新版本,甲方往往要走一轮验证流程,确认功能没退化、审计没断链。单体一个包,验证范围清晰;二十个微服务一起发,验证清单能写成一本书。所以这类项目的常见做法是 Spring Boot 单体 + 模块化包结构,用包边界约束依赖方向,等真到了必须拆的那天再拆。
2.2 分层与包结构:从 controller 到 audit 的五层
源码里的包组织基本是标准五层,但多了 rule 和 audit 两个横向模块,这两个是 EDC 区别于普通 CRUD 系统的地方,值得单独拎出来说。
| 包路径 | 职责 | 关键约束 |
|---|---|---|
controller | 接收前端请求、参数校验、权限注解 | 不写业务逻辑,不直接调 mapper |
service | 业务编排、事务边界 | 所有写操作必须在此层开事务 |
mapper | MyBatis 接口与 XML | 禁止在 controller 里直接注入 |
rule | 逻辑核查规则加载与执行 | 只读数据,不产生副作用 |
audit | 稽查轨迹记录与校验 | 只追加,禁止 update/delete |
metadata | 研究、访视、表单、字段的元数据管理 | 建库与改库的唯一入口 |
依赖方向是单向的:controller → service → mapper,rule 和 audit 被 service 调用,不反向依赖 service。这条线看着简单,实际项目里最容易破的地方是 audit——有人图省事在 controller 里直接调审计接口记录「用户点了导出」,结果事务边界不一致,导出失败了轨迹却写进去了。我的建议是审计入口只暴露给 service,controller 层要记什么,通过 AOP 切面统一处理。
2.3 目录结构与关键配置项
后端是标准的 Maven 多模块,前端 Vue 单独一个目录,两边通过/api前缀对接。下面是裁剪过的核心结构,拿到源码后对着看能快速定位。
edc/ ├── edc-admin/ # 启动模块,只放 Application 和配置 │ └── src/main/resources/ │ ├── application.yml # 公共配置 │ └── application-dev.yml ├── edc-common/ # 通用工具、统一返回、异常 ├── edc-metadata/ # 研究/访视/表单/字段元数据 ├── edc-data/ # 受试者、CRF 数据录入 ├── edc-rule/ # 逻辑核查引擎 ├── edc-audit/ # 稽查轨迹 ├── edc-report/ # 导出与统计 ├── sql/ │ ├── schema.sql # 建表脚本 │ └── init-data.sql # 演示研究、演示账号 └── edc-web/ # Vue 前端 ├── src/api/ ├── src/views/crf/ └── vite.config.js配置项里最容易出问题的是数据库连接和时间处理,application-dev.yml这一段建议原样保留,别自作聪明改。
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/edc?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: edc password: edc_dev_2024 driver-class-name: com.mysql.cj.jdbc.Driver jackson: time-zone: Asia/Shanghai date-format: yyyy-MM-dd HH:mm:ss mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true edc: audit: hash-chain: true # 稽查轨迹哈希链开关,生产必须开 rule: async: false # 核查默认同步执行,见第 5 章说明serverTimezone=Asia/Shanghai这个参数不是可选项。MySQL 8 的驱动默认会按 JVM 时区解析DATETIME,服务器和数据库时区不一致时,访视日期会整体偏移一天,而临床试验里「给药日期填错一天」是能直接触发方案违背的。哈希链开关先记着,第 4 章会讲它怎么防篡改。
2.4 从源码到能登录:启动顺序与最小验证路径
拿到包之后别急着mvn spring-boot:run,顺序错了会浪费半小时。正确的顺序是先建库、再导数据、然后改配置、最后起服务。
# 1. 建库,字符集必须是 utf8mb4,否则中文表单名会截断 mysql -uroot -p -e "CREATE DATABASE edc DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导表结构和演示数据(顺序不能反,init-data 依赖 schema) mysql -uroot -p edc < sql/schema.sql mysql -uroot -p edc < sql/init-data.sql # 3. 改 application-dev.yml 里的账号密码,然后启动后端,默认 8080 mvn -pl edc-admin -am spring-boot:run # 4. 前端另开一个终端,默认 5173,代理到 8080 cd edc-web && npm install && npm run dev起来之后的最小验证路径是:用演示账号登录 → 进入「演示研究」→ 打开某个受试者的访视 → 在 CRF 里改一个超出范围的数值 → 保存 → 看是否弹出核查提示并生成质疑。这一条链路走通,说明元数据、数据录入、规则引擎、质疑模块四个核心都活着,剩下的功能基本不会有系统性问题。
提示:如果登录后菜单是空的,八成是
init-data.sql里的角色权限关联没导进去,别去翻前端路由,先查sys_role_menu表有没有数据。
3. eCRF 表单引擎:元数据驱动的建库与数据落表
3.1 四层元数据模型:研究、访视、表单、字段
EDC 和普通业务系统最大的差别在于「表单是配置出来的,不是写死的」。一个项目上线前,数据管理员会先拿到方案和 CRF,然后在系统里建库:建研究、建访视计划、建表单、建字段。这套元数据决定了后面所有数据的形状。源码里的模型是标准四层。
| 层级 | 表 | 说明 | 典型数量级 |
|---|---|---|---|
| 研究 | study | 项目主体,含方案编号、适应症、状态 | 个位数 |
| 访视 | study_visit | 访视计划,如筛选、基线、第 4 周 | 10~30 |
| 表单 | crf_form | 一张 eCRF,如生命体征、合并用药 | 20~80 |
| 字段 | crf_field | 表单里的每个数据点,含类型、单位、参考范围 | 每表单 10~100 |
四层之外还有两个关联维度:表单挂在哪些访视上(form_visit_rel),以及字段之间谁依赖谁(field_dependency)。后者是动态显隐的基础,比如「是否吸烟」选否,「吸烟量」字段直接隐藏并置空,这类逻辑在呼吸科项目里几乎必有。
这套模型的难点不在表结构,在于「改库」。项目跑起来之后,甲方加一个字段是家常便饭,问题是怎么改才不破坏已有数据。我的经验是元数据变更必须走版本:crf_form上有version字段,改字段定义时生成新版本,旧版本冻结但数据保留,导出时按受试者实际填写的版本还原。不做版本控制,三个月后你根本说不清某条数据当时是按哪版 CRF 填的。
3.2 动态表单渲染与数据存储的两种方案取舍
元数据定好了,数据往哪存是个分叉口。行业里常见两种做法,各有各的坑。
第一种是竖表(EAV):一张crf_data_item存所有数据,字段是subject_id / visit_id / field_id / value。好处是加字段零成本,改元数据完全不动表结构;坏处是查询极难写,一个表单 30 个字段要 30 次 join 或者一次 group by 行转列,导出 SAS 数据集时性能会很难看,几万条记录就开始卡。
第二种是动态建表:每张 eCRF 对应一张物理表,字段定义直接映射成列。查询性能好,导出方便,SQL 可读;代价是建库时要执行 DDL,字段类型变更要 ALTER TABLE,而且表数量会随项目增长。
源码采用的是混合策略:主数据走动态建表,不常查的备注、说明类字段丢进extra_json列。这个取舍在临床试验场景下是合理的,因为最终交付物是符合 CDISC SDTM 标准的数据集,需要按列快速导出,EAV 在这方面太吃亏。代价是要把 DDL 生成这段写稳,下面这段代码就是核心。
/** * 根据表单元数据生成物理表 DDL * 表名规则:crf_{studyCode}_{formCode},全小写,避免大小写敏感问题 */ public String buildCreateTableDdl(CrfForm form, List<CrfField> fields) { String tableName = physicalName(form); StringBuilder ddl = new StringBuilder("CREATE TABLE `").append(tableName).append("` ("); // 固定列:主键、受试者、访视、行号(支持多行记录,如合并用药) ddl.append("`id` BIGINT NOT NULL AUTO_INCREMENT,") .append("`subject_id` BIGINT NOT NULL COMMENT '受试者ID',") .append("`visit_id` BIGINT NOT NULL COMMENT '访视ID',") .append("`row_no` INT NOT NULL DEFAULT 1 COMMENT '行号',"); for (CrfField f : fields) { ddl.append("`").append(columnName(f)).append("` ") .append(sqlType(f)).append(" NULL COMMENT '") .append(escape(f.getFieldName())).append("',"); } // 扩展列:放不常查询的备注类字段 ddl.append("`extra_json` JSON NULL COMMENT '扩展字段',") .append("`created_by` VARCHAR(64),`created_at` DATETIME,") .append("`updated_by` VARCHAR(64),`updated_at` DATETIME,") .append("PRIMARY KEY (`id`),") .append("UNIQUE KEY `uk_subject_visit_row` (`subject_id`,`visit_id`,`row_no`),") .append("KEY `idx_visit` (`visit_id`)") .append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='") .append(escape(form.getFormName())).append("'"); return ddl.toString(); } /** 字段类型映射,注意日期统一用 DATE,不要用 DATETIME,避免时区问题 */ private String sqlType(CrfField f) { switch (f.getFieldType()) { case "NUMBER": return f.getDecimals() > 0 ? "DECIMAL(" + (f.getLength()) + "," + f.getDecimals() + ")" : "BIGINT"; case "DATE": return "DATE"; case "DATETIME": return "DATETIME"; case "TEXT": return f.getLength() > 500 ? "TEXT" : "VARCHAR(" + f.getLength() + ")"; default: return "VARCHAR(255)"; // CODED / RADIO / CHECKBOX 存编码值 } }这段代码有两个地方值得盯。一是表名和列名做了规范化处理,统一小写并过滤掉特殊字符,因为 MySQL 在 Linux 下默认表名大小写敏感,配置在 Windows 上开发、Linux 上部署时,不改这里必翻车。二是必填校验没有放到数据库层,required只在应用层校验,原因是临床试验里有「部分字段允许后补」的场景,数据库层强约束会让补录流程走不通。
3.3 数据录入的保存逻辑:一次保存要干五件事
表单渲染和建表都是准备动作,真正高频的是保存。一次 CRF 保存看着只是几个字段更新,实际上要串起校验、写入、审计、核查四件事,任何一步漏掉都是后患。下面是源码里保存方法的主干,裁剪掉了日志和异常包装。
@Transactional(rollbackFor = Exception.class) public SaveResult saveCrfData(SaveCrfRequest req, UserContext ctx) { // 1. 锁库校验:已锁定或已冻结的数据禁止任何修改 lockService.assertEditable(req.getStudyId(), req.getSubjectId(), req.getVisitId()); // 2. 元数据校验:字段是否存在、类型是否匹配、必填是否为空 metadataValidator.validate(req.getFormId(), req.getValues()); // 3. 差异比对:和库里旧值比较,只记录真正变化的字段 Map<String, FieldChange> changes = diffService.diff( req.getFormId(), req.getSubjectId(), req.getVisitId(), req.getValues()); if (changes.isEmpty()) { return SaveResult.noChange(); } // 4. 写数据(动态表按表单路由) dynamicDataWriter.upsert(req.getFormId(), req.getSubjectId(), req.getVisitId(), req.getRowNo(), req.getValues()); // 5. 写稽查轨迹,与数据同事务,失败一起回滚 auditService.logChanges("CRF_DATA", req.getFormId(), req.getSubjectId(), req.getVisitId(), changes, ctx); // 6. 同步跑一遍核查规则,命中的生成质疑 List<RuleHit> hits = ruleEngine.execute(req.getSubjectId(), req.getVisitId(), req.getFormId()); queryService.raiseIfAbsent(hits, ctx); return SaveResult.ok(hits); }第 3 步的差异比对是整个方法里最不能省的地方。有人图快,直接把整行数据序列化后当作「变更内容」写进审计,结果审计表比业务数据还大,而且完全没法还原「到底改了哪个字段」。正确做法是逐字段比对,只记录fieldCode / oldValue / newValue三元组,这样后面生成数据核查报告时能直接读出来。
第 6 步采用了同步执行,这一条有争议。同步的好处是用户点保存就能看到核查结果,不会出现「以为提交成功了,其实后台还在跑规则」的误解;坏处是规则多的时候保存会变慢。源码默认同步,配置里留了edc.rule.async开关。我的建议是规则总数在 200 条以内一律走同步,超过之后再拆成「保存时跑必填和范围规则、异步跑跨表单规则」两级,别一上来就全异步。
4. 逻辑核查与质疑闭环:把脏数据挡在锁库之前
4.1 Edit Check 规则的三类形态
临床试验里的逻辑核查(Edit Check)听着玄乎,落到实现就是布尔表达式。规则写得对不对,直接决定数据质量。行业里常见的规则分三类,源码的规则表用rule_type区分。
第一类是范围校验,最基础也最多:SYSBP >= 40 && SYSBP <= 300。收缩压填 30 或者 400,要么是录入错误要么是设备问题,必须质疑。
第二类是表单内逻辑:如果 SEX == 'F' 且 PREGNANT == 'Y' 且 AGE < 12,这组合在临床上不合理,需要质疑。这类规则的坑在于空值语义,字段没填时表达式求值结果是什么,必须有明确定义,否则空值会被当成 false 静默放行。
第三类是跨表单、跨访视校验,最难写:比如「基线访视的肿瘤长径必须大于后续访视的测量值」,要跨两次访视关联查询。这类规则通常不能靠单条表达式搞定,需要先在上下文里预加载关联数据。
我把规则表结构列一下,后面写扩展规则时对着看。
CREATE TABLE edit_check_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, rule_code VARCHAR(64) NOT NULL COMMENT '规则唯一编码,如 VS_SYSBP_RANGE', study_id BIGINT NOT NULL COMMENT '所属研究', form_id BIGINT NOT NULL COMMENT '目标表单', rule_type VARCHAR(16) NOT NULL COMMENT 'RANGE/LOGIC/CROSS_FORM', expression VARCHAR(1024) NOT NULL COMMENT '布尔表达式,true 为通过', error_msg VARCHAR(255) NOT NULL COMMENT '质疑内容模板', level VARCHAR(8) NOT NULL DEFAULT 'ERROR' COMMENT 'ERROR/WARNING', enabled TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_code (study_id, rule_code) ) COMMENT '逻辑核查规则';level这个字段容易被忽略,实际用起来很关键。ERROR级会阻断保存,用户必须改到合法才能继续;WARNING级只提示不阻断,允许用户确认后保留原值。临床试验里大量规则属于「异常但可能真实」,比如高龄受试者的某项指标,硬拦着不让保存是给自己找麻烦。
4.2 规则引擎的表达式解析与执行
规则存成字符串,执行时就得有个求值器。源码用的是 SpEL,理由是 Spring 自带、不用引额外依赖,而且能直接访问上下文里的变量。求值代码不长,但安全边界要卡死。
@Component public class RuleEvaluator { private static final ExpressionParser PARSER = new SpelExpressionParser(); /** * 求值单条规则 * @param expression 规则表达式,true 表示数据通过 * @param vars 当前表单/访视的数据上下文,key 为字段编码 * @return true 通过 / false 命中规则(需要生成质疑) */ public boolean evaluate(String expression, Map<String, Object> vars) { StandardEvaluationContext ctx = new StandardEvaluationContext(); // 只放开变量读取,禁用类型引用、构造器、bean 引用,防止表达式注入 ctx.setVariable("root", vars); vars.forEach(ctx::setVariable); try { Expression exp = PARSER.parseExpression(expression); Boolean pass = exp.getValue(ctx, Boolean.class); // null 一律视为不通过,逼迫用户补填,而不是静默放过 return Boolean.TRUE.equals(pass); } catch (EvaluationException e) { // 表达式本身有问题时抛业务异常,不能当通过处理 throw new RuleEvalException("规则表达式求值失败: " + expression, e); } } }null 一律视为不通过这一行是血泪经验。早期版本里,表达式SYSBP >= 40 && SYSBP <= 300遇到SYSBP为 null 时,整个表达式结果是 null,被Boolean.TRUE.equals判成不通过,逻辑上看着对。但反过来,规则写成SYSBP < 40 || SYSBP > 300表示「不通过」时,null 同样返回 null,被当成通过,空值就漏过去了。统一成「null 视为不通过」之后,所有规则的空值行为才一致,代价是必填校验要单独做,不能指望规则引擎兜底。
StandardEvaluationContext默认允许T(java.lang.Runtime)这类类型引用,这是表达式注入的入口。上面代码里没有显式禁用,是因为 SpEL 需要额外配置SimpleEvaluationContext或者自定义TypeLocator才能封死。这一条我在避坑章里会再提一次,因为它是规则引擎最容易被忽略的安全洞。
规则执行的调度层做了一件小事:按表单和访视预加载数据,避免每条规则都查一次库。
public List<RuleHit> execute(Long subjectId, Long visitId, Long formId) { List<EditCheckRule> rules = ruleCache.getByForm(formId); if (rules.isEmpty()) { return Collections.emptyList(); } // 一次性把本访视所有表单数据load进内存,跨表单规则也能用 Map<String, Object> context = dataLoader.loadVisitContext(subjectId, visitId); List<RuleHit> hits = new ArrayList<>(); for (EditCheckRule rule : rules) { if (!evaluator.evaluate(rule.getExpression(), context)) { hits.add(RuleHit.of(rule, context)); } } return hits; }loadVisitContext是性能关键点。一个访视下有十几张表单、几百个字段,全量加载进内存是几百 KB 的量级,完全可以接受;如果改成每条规则单独查库,200 条规则就是 200 次查询,保存一张表单要等好几秒。这里用空间换时间是对的。
4.3 质疑状态机与数据锁定
规则命中了只是发现问题,真正闭环靠质疑(Query)。质疑有一套明确的状态流转,源码里用枚举加迁移表实现,比一堆 if-else 好维护得多。
public enum QueryStatus { OPEN, // 已发起,等待中心回复 ANSWERED, // 中心已回复,等待数据管理员审核 CLOSED, // 已关闭,数据确认无误或已修正 CANCELLED // 误报,作废 } /** 允许的状态迁移,不在表里的迁移一律拒绝 */ private static final Map<QueryStatus, Set<QueryStatus>> TRANSITIONS = Map.of( QueryStatus.OPEN, EnumSet.of(QueryStatus.ANSWERED, QueryStatus.CANCELLED), QueryStatus.ANSWERED, EnumSet.of(QueryStatus.CLOSED, QueryStatus.OPEN), QueryStatus.CLOSED, EnumSet.noneOf(QueryStatus.class), QueryStatus.CANCELLED, EnumSet.noneOf(QueryStatus.class) ); public void transit(Long queryId, QueryStatus target, String remark, UserContext ctx) { Query q = queryMapper.selectById(queryId); if (!TRANSITIONS.get(q.getStatus()).contains(target)) { throw new BizException("非法的质疑状态迁移: " + q.getStatus() + " -> " + target); } // 状态变更必须留痕,这是核查时必看的证据 auditService.logStatusChange("QUERY", queryId, q.getStatus().name(), target.name(), remark, ctx); queryMapper.updateStatus(queryId, target, ctx.getUserId()); }OPEN → CLOSED这条迁移被明确禁止,看着反直觉,其实是合规要求:质疑必须经过「中心回复」这一环,数据管理员不能自己把自己的质疑关掉,否则等于既当运动员又当裁判。同理,CLOSED是终态,要重新打开必须新建一条质疑并关联原质疑,历史不可改写。
数据锁定(Data Lock)是质疑闭环之后的动作。一个访视的质疑全关、核查全过,数据管理员可以执行锁定,锁定后该访视下所有 CRF 数据变成只读。源码里锁定的实现是给subject_visit表打状态标记,同时在lockService.assertEditable里做拦截。这个拦截点必须放在 service 层入口,不能只在前端灰掉按钮,原因在避坑章细说。
4.4 稽查轨迹:不可篡改的实现要点
稽查轨迹(Audit Trail)是整个系统里最不能出问题的模块。合规检查时,检查员第一件事就是打开某条数据看变更历史,轨迹断了或者能改,前面所有功能都白做。源码用了三个手段保证完整性。
第一,只追加。audit_log表在应用层没有任何 update/delete 方法,mapper 只暴露insert和select。数据库账号层面也做了限制,业务账号只有 INSERT 权限,没有 UPDATE/DELETE。
第二,哈希链。每条轨迹记录存前一条的哈希,形成链式结构,中间任何一条被篡改,后面全部对不上。
public void append(AuditEntry entry) { // 取同一受试者上一条轨迹的hash作为链接点 String prevHash = auditMapper.selectLastHash(entry.getSubjectId()); String payload = String.join("|", entry.getEntityType(), String.valueOf(entry.getEntityId()), entry.getFieldCode(), String.valueOf(entry.getOldValue()), String.valueOf(entry.getNewValue()), entry.getOperator(), entry.getOperateTime().toString(), String.valueOf(prevHash)); entry.setPrevHash(prevHash); entry.setHash(DigestUtils.sha256Hex(payload)); auditMapper.insert(entry); }prevHash为空时补空字符串,不要补"null",否则第一次写入和后续校验的逻辑会对不上,这种细节只有真跑过校验脚本才会发现。
第三,操作时间用数据库时间而不是应用服务器时间。operateTime在插入时由NOW()生成,理由是应用服务器可能被运维改系统时间,数据库时间相对可信。校验脚本定期跑一遍链式校验,发现断裂就报警。
5. 部署运行与避坑排查:五个上线前必须走一遍的自检点
5.1 部署链路:数据库、后端、前端三件事
部署本身不复杂,复杂的是环境差异。源码给的是开发环境配置,上测试环境时要改的只有三处:数据库连接、文件存储路径、前端 API 基地址。后端打成 jar 直接跑,前端npm run build之后把dist丢给 Nginx,反向代理/api到后端端口,这部分照抄标准配置就行。
真正容易出事的是初始化数据。schema.sql建表,init-data.sql灌演示数据,两步顺序不能反,而且init-data.sql里包含角色权限和字典表,少导一张表的表现是「能登录但什么都点不了」。上线前建议单独跑一次全新建库 + 全量导入,确认脚本本身没有依赖历史数据。
下面五条是我在类似系统上真实踩过或者见别人踩过的,按出现频率排序。
5.2 坑一:日期永远差一天
现象:受试者的访视日期在页面上显示正确,导出 Excel 之后整体往前错一天,跨月的时候尤其明显。
原因:MySQL 驱动默认按 JVM 时区解析DATETIME,而容器里的 JVM 时区常常是 UTC。写入时是2024-03-01 00:30,读出来按 UTC 减 8 小时变成2024-02-29 16:30,再截断成日期就成了 2 月 29 日。
解决:连接串加serverTimezone=Asia/Shanghai,同时在启动参数里显式指定-Duser.timezone=Asia/Shanghai,两处都要改。另外临床上的纯日期字段(访视日期、给药日期)建议直接用DATE类型而不是DATETIME,从类型上规避时区问题。
注意:只改连接串不改 JVM 参数,在部分容器镜像里仍然会偏,因为 Jackson 序列化时会用自己的时区。
spring.jackson.time-zone也得一起配。
5.3 坑二:动态建表撞上 SQL 保留字
现象:建库时提示You have an error in your SQL syntax,报错位置在一个叫desc或者order的字段上。
原因:CRF 字段名是数据管理员按方案填的,出现desc、order、group、key这类 SQL 保留字完全正常。DDL 拼接时如果没加反引号,直接语法错误。
解决:所有动态表名、列名统一用反引号包裹,并且在columnName()里做一次保留字检查,命中就加后缀_val。更稳的做法是维护一张保留字表,建库前先校验一遍,把问题暴露在元数据录入阶段,而不是等到执行 DDL 才报错。顺带一提,字段名去重也要做,同一张表单里两个字段映射到同一个列名,建表时会直接失败。
5.4 坑三:规则表达式静默通过
现象:某条核查规则明显该命中,但保存数据后一条质疑都没生成,日志里也没有报错。
原因:表达式里写错了一个变量名,比如字段编码是VS_SYSBP,规则里写成了SYSBP。SpEL 遇到未定义变量返回 null,null 被判定为不通过——但如果规则方向写反了(表达式表示「不通过」条件),null 反而会被当成通过,规则彻底失效。更隐蔽的一种是表达式里用了T(java.lang.Math)之类的类型引用,被安全策略拦掉之后异常被吞了。
解决:规则保存时做一次静态校验,解析表达式并提取所有变量,和表单字段编码做比对,对不上的直接拒绝保存。运行时求值异常必须抛出并记录,不能 catch 之后返回 true。规则上线后跑一遍「故意造脏数据」的回归,每条规则至少命中一次,才算真的生效。
5.5 坑四:批量导入绕过稽查轨迹
现象:单个录入的数据有完整的变更历史,通过 Excel 批量导入的数据在审计里查不到任何记录。
原因:批量导入为了性能,直接调 mapper 批量插入,跳过了 service 层的审计逻辑。开发时觉得「导入的数据不需要审计」,但合规上任何数据进入系统都必须留痕,导入和手工录入没有区别。
解决:把审计写入抽成独立组件,批量导入在同一个事务里循环调用,用批量插入的 mapper 写数据、批量写审计,两边都走批处理,性能损失在可接受范围内。如果数据量特别大,改成「先落临时表 → 校验 → 正式入库并写审计」三段式,审计记录标注来源为IMPORT,并记录导入批次号和原始文件名。
5.6 坑五:锁库只锁了按钮
现象:数据锁定之后,页面上的输入框确实变灰了,但用 Postman 直接调保存接口,数据照样能改。
原因:锁定校验写在了前端,后端只在查询接口返回了locked标记,保存接口没做拦截。这类问题在内部测试时几乎发现不了,因为测试人员都是通过页面操作。
解决:lockService.assertEditable必须放在所有写接口的 service 入口,包括保存、删除、批量导入、质疑回复。前端置灰只是体验优化,不是权限控制。上线前用接口工具把每个写接口在锁定状态下各调一遍,确认全部返回业务异常,这一步花不了十分钟,但能挡掉最严重的合规缺陷。
6. 二次开发:给这套 EDC 加一条自定义核查规则并验证
扩展点其实就三个:元数据、规则、质疑。加一条新核查规则是最典型也最安全的改造,不用动表结构,风险可控。我拿「数值型字段超出临床参考范围」举例,走一遍完整流程。
第一步,在crf_field里确认字段带了ref_low和ref_high,这两个字段源码里预留了但演示数据没填全。补一条更新语句把参考范围灌进去,注意单位要和研究方案一致,血常规和生化项目的单位经常不统一。
第二步,写规则。规则表达式直接引用字段编码,不要引物理列名,元数据层和存储层要解耦:
INSERT INTO edit_check_rule (rule_code, study_id, form_id, rule_type, expression, error_msg, level, enabled) VALUES ('LB_HGB_REF_RANGE', 1, 12, 'RANGE', 'LB_HGB == null || (LB_HGB >= 110 && LB_HGB <= 160)', '血红蛋白值 ${LB_HGB} 超出参考范围 110-160 g/L,请核实', 'WARNING', 1);注意level用了WARNING而不是ERROR。参考范围是统计意义上的,个体差异真实存在,硬拦着不让保存会把数据管理员逼疯,提示后允许确认保留才是合理设计。
第三步,验证。别只看页面弹没弹提示,要按三层验:正常值不产生质疑;边界值 110 和 160 不产生质疑;越界值 109 和 161 各产生一条质疑,且质疑内容里的变量被正确替换。再跑一次修改流程,把越界值改回正常,确认原质疑自动变成「待关闭」而不是消失。最后查audit_log,确认规则命中和质疑状态变化都有记录。
这套流程跑熟之后,加规则就是十分钟的事,但有一条纪律必须守住:规则上线前先在测试研究里跑一遍全量历史数据,看命中率。如果一条规则把 80% 的历史数据都标红了,问题多半出在表达式写错或者单位没统一,而不是数据真的那么脏。
从那以后我每次加核查规则,都强制自己先造三条脏数据跑一遍再提交,宁可多花十分钟,也不想半夜被叫起来查「为什么这条规则从来没过」。希望帮到你。
本文还有配套的精品资源,点击获取