简介:这份临床研究数据采集管理(EDC)系统源代码面向临床信息化开发者、数据管理人员及希望了解试验数据流程的学习者,覆盖试验项目管理、实验设计、CRF表单设计、疑问管理、数据录入、逻辑自动校验、CRF与DCF表单导出PDF及统计报表等环节。压缩包共6个文件,以4个Java源码为主,另有2个scc版本控制标记文件,整体约17KB,体量精简,适合快速通读与二次开发。目录中的input与handquery两个模块,分别对应InputModel、InputAction和HandQueryModel、HandQueryAction,呈现模型与动作分层,便于理解数据录入和手动查询的实现思路。已有1731人学习,可作为搭建EDC原型、梳理临床试验数据管理链路的参考样本,也方便在此基础上扩展字段校验规则与导出模板。
1. 一个 200 例的多中心研究,数据清理拖了三个月
一个 200 例的多中心临床研究,如果 CRF 表还靠 Excel 下发、CRC 填完邮件回传、数据管理员再一份份对数,数据清理拖上两三个月是常事,中间还夹着一堆「这个值是谁改的、什么时候改的」扯皮。EDC 临床试验管理系统干的就是把这套流程搬到线上——受试者入组、访视表单填写、逻辑核查、数据质疑、稽查轨迹、锁库导出,全链路留痕。这份源码是一套能直接跑起来的临床试验数据采集管理系统,Spring Boot + Vue 前后端分离,做临床研究信息化的团队、CRO 的技术岗、要给科室搭科研数据库的开发者都能拿来改。前端会点 Vue、后端看得懂 Spring Boot,就能接手;只想找个问卷工具填两张表的,用不上它。
2. 技术选型与工程结构:一套 EDC 从登录到锁库分了几层
2.1 技术栈与选型理由
这套源码的后端是 Spring Boot 2.7 + MyBatis-Plus + MySQL 8,前端 Vue 3 + Element Plus + Vite。选型上没什么花活,但每一层都有 EDC 这个场景的特殊理由,不是随手堆的。
| 层次 | 选型 | 在 EDC 里为什么这么选 |
|---|---|---|
| 后端框架 | Spring Boot 2.7 | 单体部署,医院内网环境常见,运维成本低 |
| 持久层 | MyBatis-Plus | 表单字段值是 JSON,需要自定义 TypeHandler;还要手写复杂导出 SQL |
| 数据库 | MySQL 8.0 | JSON 字段、窗口函数都能用,8.0 的 JSON 函数做字段检索很方便 |
| 缓存 | Redis 6 | 字典、会话、表单模板 schema 缓存,还有锁库时的分布式锁 |
| 权限 | Spring Security + JWT | 无状态,多中心跨院区部署时不用管 session 共享 |
| 对象存储 | MinIO | 知情同意书扫描件、检验单照片,不能塞数据库 |
| 前端 | Vue 3 + Element Plus | 表单设计器要拖拽,Element Plus 的组件覆盖度够 |
| 导出 | EasyExcel + 自研 ODM XML | Excel 给数据管理员看,ODM 给统计方用 |
MySQL 选 8.0 不是为了追新。CRF 表单的字段值最终落成一张宽表或者 JSON 列,如果用 5.7,JSON 的路径查询和函数支持会让你在数据核查阶段写一堆字符串拼接 SQL,容易出错。8.0 里JSON_EXTRACT配合生成列加索引,查询某个字段有没有值的效率能接受。
2.2 后端目录结构与模块职责
后端是标准的 Maven 多模块,按业务域切分,不是按技术层切分。这点挺关键——EDC 的业务边界很清晰,按域切分后加一个「随机化」模块或者「药物管理」模块,不用动别人的代码。
edc/ ├── edc-admin/ # 启动模块:Controller、定时任务、启动类 │ └── src/main/java/.../web/ # 只放 Web 层,不放业务逻辑 ├── edc-common/ # 统一返回 R<T>、业务异常、常量、工具类 ├── edc-framework/ # 框架层:安全过滤链、审计切面、数据权限 │ ├── security/ # JWT 解析、@PreAuthorize 扩展 │ ├── audit/ # @AuditLog 切面,写稽查轨迹 │ └── datascope/ # 项目级/中心级数据隔离 ├── edc-system/ # 用户、角色、机构(中心)、字典、消息通知 ├── edc-study/ # 项目 Study、中心 Site、受试者 Subject、访视 Visit ├── edc-form/ # 表单模板、字段定义、表单实例、字段值 ├── edc-query/ # 质疑 Query、SDV 源数据核查、核查任务 ├── edc-export/ # Excel / CSV / ODM-XML / SDTM 导出 └── edc-ui/ # 前端工程 ├── src/views/designer/ # CRF 表单设计器 ├── src/views/subject/ # 受试者列表与访视导航 └── src/views/query/ # 质疑管理台每个模块内部的包结构统一是controller / service / mapper / domain / dto。edc-form是最厚的模块,表单引擎的渲染 schema 生成、字段值校验、版本管理全在这里。edc-query反而很薄,因为质疑本质上是挂在字段上的一条记录加状态流转,逻辑不复杂,但和edc-form的耦合要小心处理——质疑里存的是form_data_id + item_code,不能直接存字段值,否则字段改值后质疑对不上。
2.3 三层数据隔离:项目级、中心级、角色级
EDC 的权限模型跟普通后台管理系统完全不是一回事。普通系统是「这个用户有没有这个菜单」,EDC 是「这个用户能不能看到这个中心这个受试者的这条数据」。三层叠加:
- 项目级:一个账号可能同时参与三个临床试验,登录后要先选项目,或者按项目切换。
- 中心级:某研究中心的研究者只能看到本中心入组的受试者,跨中心看数据在合规上是红线。
- 角色级:研究者能填表、CRC 能代填、CRA 能发质疑但不能改原始数据、数据管理员能锁库但不能录入。
常见做法是自定义一个@DataScope注解,配 AOP 在 SQL 执行前动态拼条件。源码里就是这么干的:
// edc-framework/src/main/java/.../datascope/DataScope.java @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { /** 业务表里表示中心的列名,默认 site_id */ String siteColumn() default "site_id"; /** 表示项目的列名 */ String studyColumn() default "study_id"; /** 是否同时按项目过滤,默认 true */ boolean byStudy() default true; }// edc-framework/src/main/java/.../datascope/DataScopeAspect.java @Aspect @Component public class DataScopeAspect { @Before("@annotation(dataScope)") public void doBefore(JoinPoint jp, DataScope dataScope) { LoginUser user = SecurityUtils.getLoginUser(); // 申办方数据管理员、统计方:不限制中心,只按项目过滤 if (user.hasAnyRole("SPONSOR_DM", "STATISTICIAN")) { DataScopeContext.set(buildStudyOnly(user, dataScope)); return; } // 研究者 / CRC / CRA:只放行自己绑定的中心 Set<Long> siteIds = user.getSiteIds(); if (CollectionUtils.isEmpty(siteIds)) { // 没绑中心就拼 1=0,宁可查空也不能查全量,这是越权事故的高发点 DataScopeContext.set(" AND 1 = 0 "); return; } String in = siteIds.stream().map(String::valueOf).collect(Collectors.joining(",")); StringBuilder sb = new StringBuilder(" AND " + dataScope.siteColumn() + " IN (" + in + ") "); if (dataScope.byStudy()) { sb.append(" AND ").append(dataScope.studyColumn()) .append(" = ").append(user.getCurrentStudyId()); } DataScopeContext.set(sb.toString()); } @After("@annotation(dataScope)") public void doAfter() { // ThreadLocal 必须清,线程池复用时会串数据 DataScopeContext.clear(); } }切面把条件写进ThreadLocal,Mapper XML 里用${params.dataScope}拼到 where 后面:
<select id="selectSubjectPage" resultType="SubjectVO"> SELECT s.*, st.study_name, si.site_name FROM edc_subject s LEFT JOIN edc_study st ON st.id = s.study_id LEFT JOIN edc_site si ON si.id = s.site_id WHERE s.del_flag = 0 ${params.dataScope} ORDER BY s.create_time DESC </select>${}是字符串直接拼接,天然有注入风险。这里的条件全部来自Long类型的 ID 集合和登录态里的项目 ID,没有外部输入,所以安全;但不要在别的地方照抄这个写法去拼用户传的字符串。另外DataScopeContext.clear()那句不能省,我见过不止一次因为线程池复用导致 A 用户看到 B 中心数据的翻车现场,排查起来极其痛苦,因为它不是必现的。
3. 核心模块拆解:表单引擎、访视状态机与逻辑核查
3.1 CRF 表单引擎:模板、字段、实例三层分离
EDC 的核心不是增删改查,是「表单可变」。同一个系统要能承载不同项目的不同 CRF,字段从十几个到几百个不等,硬编码字段表是死路。源码用的是模板 + 字段定义 + 实例 + 值四张表:
-- 表单模板:一个项目下可以有多个表单,比如「筛选期访视」「基线检查」「不良事件」 CREATE TABLE edc_form_template ( id BIGINT PRIMARY KEY AUTO_INCREMENT, study_id BIGINT NOT NULL COMMENT '所属项目', template_code VARCHAR(64) NOT NULL COMMENT '模板编码,如 VS', template_name VARCHAR(128) NOT NULL COMMENT '模板名称,如 生命体征', version INT NOT NULL DEFAULT 1 COMMENT '版本号,改结构就 +1', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1启用 2停用', UNIQUE KEY uk_study_code_ver (study_id, template_code, version) ) COMMENT 'CRF 表单模板'; -- 字段定义 CREATE TABLE edc_form_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, template_id BIGINT NOT NULL, item_code VARCHAR(64) NOT NULL COMMENT '字段编码,如 VS_SYSBP', item_name VARCHAR(128) NOT NULL COMMENT '中文名,如 收缩压', item_type VARCHAR(32) NOT NULL COMMENT 'TEXT/NUMBER/DATE/SELECT/RADIO/CALC', unit VARCHAR(32) COMMENT '单位,如 mmHg', required TINYINT DEFAULT 0, options_json JSON COMMENT '下拉/单选的选项', rule_json JSON COMMENT '范围、正则、精度', sort_no INT DEFAULT 0, UNIQUE KEY uk_tpl_code (template_id, item_code) ) COMMENT 'CRF 字段定义'; -- 表单实例:某个受试者某次访视填的一张表,绑定当时的模板版本 CREATE TABLE edc_form_data ( id BIGINT PRIMARY KEY AUTO_INCREMENT, subject_id BIGINT NOT NULL, visit_id BIGINT NOT NULL, template_id BIGINT NOT NULL COMMENT '指向具体版本,不是 template_code', values_json JSON NOT NULL COMMENT '字段值,键是 item_code', status TINYINT DEFAULT 0 COMMENT '0录入中 1已提交 2已核查 3已锁定', UNIQUE KEY uk_sub_visit_tpl (subject_id, visit_id, template_id) ) COMMENT '表单实例';edc_form_data.template_id指向的是带版本号的模板行,这是整个设计里最关键的一笔。表单结构一旦启用就不能改,要改只能新建一个 version=2 的模板,这样历史数据永远按当时的字段名和选项渲染,不会出现「去年的数据今年显示成了新字段名」这种让人怀疑数据完整性的问题。
前端拿到的渲染 schema 是后端拼好的扁平结构:
{ "templateId": 1024, "templateName": "生命体征", "items": [ { "itemCode": "VS_SYSBP", "itemName": "收缩压", "itemType": "NUMBER", "unit": "mmHg", "required": 1, "ruleJson": { "min": 60, "max": 250, "precision": 0 } }, { "itemCode": "VS_DBP", "itemName": "舒张压", "itemType": "NUMBER", "unit": "mmHg", "required": 1, "ruleJson": { "min": 30, "max": 150 } }, { "itemCode": "VS_PULSE", "itemName": "脉搏", "itemType": "NUMBER", "unit": "次/分" } ] }前端按itemType分发组件,不做任何字段硬编码:
// edc-ui/src/views/form/FormRender.vue(节选) // model 是响应式对象,键为 itemCode;schema 由 /form/template/{id}/schema 返回 const renderField = (item, model) => { switch (item.itemType) { case 'NUMBER': // 精度和范围交给组件本地拦一道,减少一次无意义的往返 return h(ElInputNumber, { modelValue: model[item.itemCode], 'onUpdate:modelValue': v => { model[item.itemCode] = v }, precision: item.ruleJson?.precision ?? 0, min: item.ruleJson?.min, max: item.ruleJson?.max, placeholder: item.unit ? `单位 ${item.unit}` : '请输入' }) case 'DATE': // 访视窗口外的日期允许录入,但会被逻辑核查标黄,不在这里硬拦 return h(ElDatePicker, { modelValue: model[item.itemCode], 'onUpdate:modelValue': v => { model[item.itemCode] = v }, type: 'date', valueFormat: 'YYYY-MM-DD' }) case 'SELECT': return h(ElSelect, { modelValue: model[item.itemCode], 'onUpdate:modelValue': v => { model[item.itemCode] = v }, options: item.optionsJson }) default: return h(ElInput, { modelValue: model[item.itemCode] }) } }保存接口是链路最长的入口,它要同时做版本校验、字段校验、留痕、触发核查:
// edc-admin/src/main/java/.../form/FormDataController.java @PostMapping("/form/data/{subjectId}/{visitId}") public R<Void> save(@PathVariable Long subjectId, @PathVariable Long visitId, @RequestBody FormDataDTO dto) { // 1. 版本校验:已锁定(锁库后)的表单一律不给改,前端按钮也得置灰 FormTemplate tpl = templateService.getById(dto.getTemplateId()); if (tpl == null || tpl.getStatus() == FormStatus.DISABLED) { return R.fail("表单模板不存在或已停用"); } FormData exist = formDataService.getOne(subjectId, visitId, dto.getTemplateId()); if (exist != null && exist.getStatus() == FormDataStatus.LOCKED) { return R.fail("该表单已锁库,不可修改"); } // 2. 必填 / 类型 / 范围校验,返回字段级错误,前端逐项标红 List<FieldError> errors = formValidator.validate(tpl, dto.getValues()); if (!errors.isEmpty()) { return R.fail(errors); } // 3. 落库,同时写 form_data_log 做值级留痕 formDataService.saveWithLog(subjectId, visitId, dto); // 4. 逻辑核查异步触发,不阻塞保存响应 editCheckService.checkAsync(subjectId, visitId, dto.getTemplateId()); return R.ok(); }参数说明:subjectId是受试者主键,visitId是访视实例主键,templateId必须带版本,不能传templateCode,否则后端不知道按哪版字段定义校验。dto.getValues()是个Map<String, Object>,键为itemCode。第 4 步异步核查很重要,一张 200 字段的随访表如果同步跑 30 条规则,保存能卡到 5 秒以上,CRC 会疯掉。
3.2 受试者与访视状态机
受试者状态如果写成if (status == 1) ... else if (status == 2),加一个「筛败后重新筛选」的需求就要动十几个地方。源码里用枚举 + 状态转移表:
// edc-study/src/main/java/.../subject/SubjectStatus.java public enum SubjectStatus { SCREENING, // 已签知情,筛选中 ENROLLED, // 入组成功,已分配随机号 ONGOING, // 治疗 / 随访中 COMPLETED, // 完成全部访视 SCREEN_FAIL, // 筛败 WITHDRAWN; // 中途退出 /** 合法流转表,key 是目标状态 */ public boolean canTransitTo(SubjectStatus target) { return switch (this) { case SCREENING -> target == ENROLLED || target == SCREEN_FAIL; case ENROLLED -> target == ONGOING || target == WITHDRAWN; case ONGOING -> target == COMPLETED || target == WITHDRAWN; // 终态不允许再流转,重新入组要新建受试者记录 case COMPLETED, SCREEN_FAIL, WITHDRAWN -> false; }; } }流转时带守卫:入组前必须填完筛选期表单并且没有未关闭的致命质疑:
@Service public class SubjectService { @Transactional(rollbackFor = Exception.class) public void transit(Long subjectId, SubjectStatus target, String reason) { Subject subject = getById(subjectId); if (!subject.getStatus().canTransitTo(target)) { throw new BizException("当前状态 " + subject.getStatus() + " 不能流转到 " + target); } // 入组守卫:筛选期必填表单必须齐全,且没有 OPEN 状态的 CRITICAL 质疑 if (target == SubjectStatus.ENROLLED) { long missing = formDataService.countMissingRequired(subjectId, VisitType.SCREENING); if (missing > 0) { throw new BizException("筛选期还有 " + missing + " 张必填表单未提交"); } long critical = queryService.countOpenCritical(subjectId); if (critical > 0) { throw new BizException("存在 " + critical + " 条未关闭的严重质疑,不允许入组"); } } subject.setStatus(target); subject.setStatusReason(reason); // 状态变更本身也要进稽查轨迹 subjectMapper.updateById(subject); auditService.record("SUBJECT", "TRANSIT", subjectId, target.name()); } }这样加新状态时只需要改枚举和流转表两处,守卫逻辑集中在SubjectService,改造面小得多。访视(Visit)也类似,只是访视的状态更多是被时间窗口驱动:计划访视日 ± 窗口内允许提交,超窗提交会生成一条 WARN 级核查结果,而不是直接拒绝录入——临床上超窗访视是真实存在的,系统要做的是标记,不是拦截。
3.3 逻辑核查与数据质疑
数据核查分两类:一类是录入时的即时校验(必填、类型、范围),另一类是跨字段的逻辑核查,比如「收缩压必须大于舒张压」「如果妊娠试验为阳性,则受试者必须为女性」。源码把后者做成可配置的规则表,表达式用 Aviator 跑:
{ "checkCode": "VS_BP_LOGIC", "checkLevel": "ERROR", "formCode": "VS", "expression": "VS_SYSBP > VS_DBP", "message": "收缩压 ${VS_SYSBP} 应大于舒张压 ${VS_DBP},请核实" }// edc-form/src/main/java/.../check/EditCheckExecutor.java @Component public class EditCheckExecutor { // 表达式编译后缓存,避免每次填表都重新编译;上限 2000 条够用了 private final Cache<String, Expression> exprCache = Caffeine.newBuilder().maximumSize(2000).build(); public List<CheckResult> run(List<EditCheck> checks, Map<String, Object> values) { List<CheckResult> results = new ArrayList<>(); for (EditCheck c : checks) { Expression exp = exprCache.get(c.getCheckCode(), k -> AviatorEvaluator.compile(c.getExpression(), true)); // execute 返回 Boolean,true 表示通过;缺字段时返回 null,按通过处理 Object r = exp.execute(values, true); if (Boolean.FALSE.equals(r)) { results.add(new CheckResult(c.getCheckCode(), c.getCheckLevel(), renderMessage(c.getMessage(), values))); } } return results; } /** 把 ${ITEM_CODE} 占位符替换成实际值,质疑文案里要能看到具体数字 */ private String renderMessage(String tpl, Map<String, Object> values) { Matcher m = Pattern.compile("\\$\\{(\\w+)}").matcher(tpl); StringBuilder sb = new StringBuilder(); while (m.find()) { Object v = values.getOrDefault(m.group(1), ""); m.appendReplacement(sb, String.valueOf(v)); } m.appendTail(sb); return sb.toString(); } }ERROR级核查结果会挡住表单提交,WARN级只提示不拦截。这个分级必须做,否则要么规则太松形同虚设,要么太严导致 CRC 无法录入真实数据。
质疑(Query)是在核查结果之上的人工环节。CRA 在数据核查页面看到一条 WARN,或者肉眼发现某个值可疑,就对具体字段发起一条质疑,状态走OPEN → ANSWERED → CLOSED,中途可以CANCELLED:
// edc-query/src/main/java/.../QueryService.java public void answer(Long queryId, String answerText) { Query q = queryMapper.selectById(queryId); // 状态机守卫:只有 OPEN 的质疑能回复,CLOSED 的回复直接拒绝 if (q.getStatus() != QueryStatus.OPEN) { throw new BizException("质疑当前状态为 " + q.getStatus() + ",不可回复"); } q.setAnswerText(answerText); q.setAnswerBy(SecurityUtils.getUsername()); q.setAnswerTime(LocalDateTime.now()); q.setStatus(QueryStatus.ANSWERED); queryMapper.updateById(q); // 回复后要给发起人发通知,否则质疑会石沉大海 notifyService.send(q.getRaiseBy(), "您发起的质疑已有回复:" + q.getQueryNo()); }质疑记录里存的是form_data_id + item_code,不是字段值本身。字段值后续被研究者改了,质疑依然挂在那个字段上,历史值从稽查轨迹里查——这套关联关系在导出核查报告时特别省事。
3.4 稽查轨迹:每一次写操作都要留下证据
合规要求决定了 EDC 的写操作必须留痕:谁、什么时候、改前是什么、改后是什么、从哪个 IP 来的。源码里用注解加切面统一处理,业务代码里不用手写auditService.record(...):
// edc-framework/src/main/java/.../audit/AuditAspect.java @Around("@annotation(auditLog)") public Object around(ProceedingJoinPoint pjp, AuditLog auditLog) throws Throwable { Object result; try { result = pjp.proceed(); } catch (Throwable e) { // 失败的写操作不留痕,否则轨迹表会被校验失败刷屏 throw e; } AuditRecord rec = new AuditRecord(); rec.setModule(auditLog.module()); rec.setAction(auditLog.action()); // bizId 支持 SpEL,比如 "#dto.id" 或 "#subjectId" rec.setBizId(String.valueOf(SpelUtil.eval(auditLog.bizId(), pjp.getArgs()))); rec.setBeforeJson(AuditContext.getBefore()); // 由 service 层在更新前塞入 rec.setAfterJson(JSON.toJSONString(result)); rec.setOperator(SecurityUtils.getUsername()); rec.setOpIp(IpUtil.getRealIp()); rec.setOpTime(LocalDateTime.now()); auditService.save(rec); return result; }AuditContext也是ThreadLocal,service 里更新实体前把旧值丢进去,切面统一取。审计表只增不改不删,并且不能被业务代码的通用删除接口扫到——源码里给这张表单独做了数据源级别的限制,任何delete都会被 MyBatis 拦截器拦下。
4. 跑起来:环境准备、建库初始化与第一个项目
4.1 环境与依赖清单
本地跑通需要这些:
- JDK 8 或 17(源码的
pom.xml里maven.compiler.source是 8,17 也能编,改一下就行) - Maven 3.8+
- MySQL 8.0,字符集必须是
utf8mb4 - Redis 6,本地单机即可
- Node 16+ 和 pnpm(前端用了 pnpm-lock)
- MinIO,可选;不上传附件的话能跳过,但启动时要关掉文件校验开关
MySQL 字符集这一点要强调:utf8mb4不是建议,是必须。CRF 字段里会有研究者填的中文备注、特殊符号,utf8三字节在存储罕见字时会截断,等到导出的 CSV 里出现乱码才发现就晚了。
4.2 建库与初始化脚本
# 1. 建库,排序规则跟着字符集走 mysql -uroot -p -e "CREATE DATABASE edc DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 建业务账号,生产上别用 root mysql -uroot -p -e "CREATE USER 'edc'@'%' IDENTIFIED BY 'edc@2024'; GRANT ALL ON edc.* TO 'edc'@'%'; FLUSH PRIVILEGES;" # 3. 导表结构与索引 mysql -uedc -p edc < sql/edc_schema.sql # 4. 导初始化数据:菜单、角色、数据字典、一个演示项目 mysql -uedc -p edc < sql/edc_init_data.sqledc_schema.sql里除了业务表,还有两张容易被忽略但必须建的表:edc_audit_record(稽查轨迹)和edc_form_data_log(字段值变更日志)。这两张表是后续做数据核查和锁库导出的基础,漏建了系统能启动,但一到质疑环节就报错。
edc_init_data.sql会塞进去一个演示项目,包含两个研究中心、三个角色账号(研究者、CRA、数据管理员)和一套生命体征表单模板。第一次跑建议直接用这个演示项目点一遍流程,比自己新建快得多。
4.3 配置与启动
后端配置文件是edc-admin/src/main/resources/application-dev.yml:
server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver # serverTimezone 必须显式指定,否则访视日期会整体偏 8 小时 url: jdbc:mysql://127.0.0.1:3306/edc?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: edc password: edc@2024 hikari: maximum-pool-size: 20 redis: host: 127.0.0.1 port: 6379 database: 0 edc: file: enabled: false # 本地没起 MinIO 就设 false endpoint: http://127.0.0.1:9000 bucket: edc-attachment audit: enabled: true # 关掉的话稽查轨迹不落库,仅调试用 edit-check: async-pool-size: 4 # 核查线程池,按表单复杂度调 security: jwt-secret: change-me-in-prod # 上线必须换,源码里的是占位值 token-expire-minutes: 120启动顺序是先后端后前端:
# 后端:在项目根目录,跳过测试加快启动 mvn clean package -DskipTests java -jar edc-admin/target/edc-admin.jar --spring.profiles.active=dev # 前端:另开一个终端 cd edc-ui pnpm install pnpm dev前端vite.config.js里配了代理,/api转发到http://127.0.0.1:8080,所以浏览器直接开http://localhost:5173就行,不用管跨域。
4.4 从新建项目到锁库:一条完整链路
演示数据登录后,按这个顺序走一遍,基本就把系统的主要功能摸清了:
- 用数据管理员账号登录,进入「项目管理」,新建一个研究,填方案编号、适应症、计划例数。
- 在项目下新增两个中心,把研究者和 CRC 账号绑到对应中心。
- 进「表单设计器」,拖几个字段做一张筛选期表单,字段至少包含「出生日期」「性别」「身高」「体重」。
- 在「访视计划」里把表单挂到 V1 筛选期访视上,设置访视窗口为计划日 ±3 天。
- 换研究者账号登录,新增受试者,填筛选号,系统自动生成受试者编号。
- 打开该受试者的访视列表,进 V1 访视填表并提交。
- 故意把身高填成 300,保存后能看到一条 WARN 级核查结果。
- 换 CRA 账号登录,在核查页面看到这条结果,对该字段发起质疑。
- 换回研究者账号,在「我的质疑」里回复质疑。CRA 关闭质疑。
- 数据管理员在「数据导出」里导出 Excel,再到「锁库」里锁定项目,锁定后所有表单变只读。
这一圈走完,表单引擎、状态机、核查、质疑、稽查轨迹、导出六个模块都覆盖到了。如果中间哪一步报错,八成是初始化的角色权限没绑对,去edc_role_menu表里核对一下。
5. 避坑与排查:EDC 上线后最容易翻车的五个点
5.1 时间字段全偏 8 小时
现象:录的是 2024-03-15,导出 Excel 变成 2024-03-14 16:00,换成日期格式后又变成 3 月 14 号。
原因:MySQL 的datetime本身不带时区,JDBC 驱动默认按 JVM 时区解释,而 JVM 在容器里默认是 UTC。三处时区只要有一处不一致,结果就飘。
解决:JDBC URL 显式加serverTimezone=Asia/Shanghai;实体里的纯日期字段(访视日期、知情同意日期)用LocalDate而不是java.util.Date;容器启动参数加-Duser.timezone=Asia/Shanghai。三处全对齐之后,导出再没出过偏差。
5.2 改了表单模板,历史数据跟着变
现象:数据管理员把某个字段的中文名从「体重(kg)」改成「体重」,重新打开三个月前的表单,历史记录显示的全是新名字,看起来像数据被改过。
原因:表单实例只存了values_json,渲染时按template_code查当前启用的模板,等于用新结构解释旧数据。
解决:模板加version,启用后禁止编辑字段结构;edc_form_data.template_id存具体版本行而不是编码。改结构就新建 version+1 的模板,只有新数据用它。这一条是血泪经验,改起来不难,难的是上线半年后才发现问题,那时历史数据已经和新结构混在一起了。
5.3 保存一张随访表要转圈五秒
现象:字段少的表单秒开秒存,字段上百的随访表保存要等五六秒,CRC 以为卡死了会重复点击,结果产生多条form_data_log。
原因:逻辑核查跑在同步链路里,30 条 Aviator 表达式每条都重新compile,编译开销远大于执行开销;再加上表单值校验里对每个字段都查了一次数据字典。
解决:表达式编译结果进 Caffeine 缓存,key 用checkCode;核查整体改成异步,结果落edc_check_result表,前端轮询或走 WebSocket 通知;数据字典启动时全量加载进 Redis,别在循环里查库。改完之后保存响应从 5 秒降到 200ms 以内。
5.4 删了受试者再新增,报唯一键冲突
现象:数据管理员删掉一条录错的受试者,用同样的筛选号重新建一个,直接报Duplicate entry。
原因:唯一索引建在(study_id, screening_no)上,但删除是软删除,del_flag=1的那行还在表里,索引照样生效。
解决:唯一索引带上删除标记,改成(study_id, screening_no, del_flag),软删除时把del_flag改成主键值而不是固定 1。这样多次删除同一筛选号也不会冲突。另一条路是逻辑删除时改写screening_no加时间戳后缀,但那样历史数据查起来别扭,不推荐。
5.5 上传知情同意书扫描件超时
现象:20MB 的 PDF 传到一半报 504,前端提示网络错误,重试还是一样。
原因:文件走了应用服务中转,Spring 的MultipartFile先把整个文件读进内存或临时目录,再转存对象存储,中间还过了网关的请求体大小限制。
解决:改成前端直传。后端提供一个接口生成 MinIO 的预签名上传 URL,前端拿到 URL 后直接 PUT 到对象存储,成功后只把objectKey回传给后端落库。这样应用服务完全不碰文件流,20MB 和 200MB 表现一样。注意预签名 URL 的有效期别设太长,10 分钟足够。
6. 二次开发:从加一种字段类型到接入外部系统
拿到这套源码后,最常见的改造需求不是改页面,是扩字段类型和接外部系统。这两件事的路子摸清了,剩下的都是体力活。
先说加字段类型。假设方案里出现了「矩阵量表」——一行一个问题,一列一个评分等级。改动点是三个,一处漏了就跑不通:后端FormItemType枚举加MATRIX;FormValidator里加矩阵的必填判定(每一行都得有值,不是整个字段有值就行);前端renderField里加case 'MATRIX',用ElTable渲染行和列,值存成{"行编码": 分值}的嵌套对象。三处改完,values_json的存储结构变了,导出模块的扁平化逻辑也得跟着改一行,按itemCode.rowCode拼接列名。
接入随机化系统是另一个高频需求。正确的位置不是在受试者新增接口里直接调外部 HTTP,而是挂在状态机的SCREENING → ENROLLED流转上,作为一个前置动作:
@Component public class RandomizationHandler implements SubjectTransitHandler { @Override public SubjectStatus supportTarget() { return SubjectStatus.ENROLLED; } @Override public void beforeTransit(Subject subject) { // 幂等:已经发过随机号的直接返回,避免网络重试导致重复入组 if (StringUtils.isNotBlank(subject.getRandomNo())) { return; } RandomResp resp = iwrsClient.enroll(subject.getStudyId(), subject.getScreeningNo()); if (!resp.isSuccess()) { // 外部系统失败必须抛异常,让整个流转回滚,不能留下"已入组但没随机号"的脏数据 throw new BizException("随机化失败:" + resp.getMessage()); } subject.setRandomNo(resp.getRandomNo()); subject.setArmCode(resp.getArmCode()); } }配合一个SubjectTransitHandler的收集器,将来接药物管理、接中心实验室,都只是多写一个实现类,SubjectService一行不用动。
| 改造点 | 涉及文件 | 预估工作量 |
|---|---|---|
| 新增字段类型 | 枚举、FormValidator、FormRender.vue、导出扁平化 | 0.5 天 |
| 接入随机化 | 新增 Handler + 外部客户端 + 幂等表 | 1~2 天 |
| ODM-XML 导出 | edc-export 新增 ODM Writer,映射 Study/Form/ItemGroup | 2~3 天 |
| 电子签名 | 签名表 + 密码二次校验 + 签名后表单锁定 | 1 天 |
ODM 导出这块多说一句。CDISC ODM 的结构是Study → MetaDataVersion → ItemGroupDef → ItemDef,跟这套源码的模板/字段/实例三层几乎一一对应,映射关系是现成的。坑在CodeList:枚举值的编码和显示名要严格对应,否则统计方拿到 XML 解析出来全是数字码,得回来找你。
最后说个验证习惯。改完任何跟表单结构相关的代码,我都强制走一遍「老数据回显 → 新数据录入 → 导出对比」这三步:拿一个改造前就存在的受试者,打开历史表单看字段名和值对不对;再用新结构录一张表,看校验和保存是否正常;最后把两份数据都导出成 Excel,逐列对一遍。跳过这三步直接上线,出问题的时候你面对的是一堆没人能解释的历史数据。希望帮到你。
本文还有配套的精品资源,点击获取