EDC临床试验管理系统:Spring Boot+Vue表单引擎与数据核查

发布时间:2026/10/27 16:33:34

EDC临床试验管理系统:Spring Boot+Vue表单引擎与数据核查 简介这份临床研究数据采集管理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.0JSON 字段、窗口函数都能用8.0 的 JSON 函数做字段检索很方便缓存Redis 6字典、会话、表单模板 schema 缓存还有锁库时的分布式锁权限Spring Security JWT无状态多中心跨院区部署时不用管 session 共享对象存储MinIO知情同意书扫描件、检验单照片不能塞数据库前端Vue 3 Element Plus表单设计器要拖拽Element Plus 的组件覆盖度够导出EasyExcel 自研 ODM XMLExcel 给数据管理员看ODM 给统计方用MySQL 选 8.0 不是为了追新。CRF 表单的字段值最终落成一张宽表或者 JSON 列如果用 5.7JSON 的路径查询和函数支持会让你在数据核查阶段写一堆字符串拼接 SQL容易出错。8.0 里JSON_EXTRACT配合生成列加索引查询某个字段有没有值的效率能接受。2.2 后端目录结构与模块职责后端是标准的 Maven 多模块按业务域切分不是按技术层切分。这点挺关键——EDC 的业务边界很清晰按域切分后加一个「随机化」模块或者「药物管理」模块不用动别人的代码。edc/ ├── edc-admin/ # 启动模块Controller、定时任务、启动类 │ └── src/main/java/.../web/ # 只放 Web 层不放业务逻辑 ├── edc-common/ # 统一返回 RT、业务异常、常量、工具类 ├── 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只放行自己绑定的中心 SetLong siteIds user.getSiteIds(); if (CollectionUtils.isEmpty(siteIds)) { // 没绑中心就拼 10宁可查空也不能查全量这是越权事故的高发点 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(); } }切面把条件写进ThreadLocalMapper XML 里用${params.dataScope}拼到 where 后面select idselectSubjectPage resultTypeSubjectVO 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指向的是带版本号的模板行这是整个设计里最关键的一笔。表单结构一旦启用就不能改要改只能新建一个 version2 的模板这样历史数据永远按当时的字段名和选项渲染不会出现「去年的数据今年显示成了新字段名」这种让人怀疑数据完整性的问题。前端拿到的渲染 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 是响应式对象键为 itemCodeschema 由 /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 RVoid 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. 必填 / 类型 / 范围校验返回字段级错误前端逐项标红 ListFieldError 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()是个MapString, 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 CacheString, Expression exprCache Caffeine.newBuilder().maximumSize(2000).build(); public ListCheckResult run(ListEditCheck checks, MapString, Object values) { ListCheckResult results new ArrayList(); for (EditCheck c : checks) { Expression exp exprCache.get(c.getCheckCode(), k - AviatorEvaluator.compile(c.getExpression(), true)); // execute 返回 Booleantrue 表示通过缺字段时返回 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, MapString, 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也是ThreadLocalservice 里更新实体前把旧值丢进去切面统一取。审计表只增不改不删并且不能被业务代码的通用删除接口扫到——源码里给这张表单独做了数据源级别的限制任何delete都会被 MyBatis 拦截器拦下。4. 跑起来环境准备、建库初始化与第一个项目4.1 环境与依赖清单本地跑通需要这些JDK 8 或 17源码的pom.xml里maven.compiler.source是 817 也能编改一下就行Maven 3.8MySQL 8.0字符集必须是utf8mb4Redis 6本地单机即可Node 16 和 pnpm前端用了 pnpm-lockMinIO可选不上传附件的话能跳过但启动时要关掉文件校验开关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 edc2024; 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.ymlserver: 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: edc password: edc2024 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.activedev # 前端另开一个终端 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 显式加serverTimezoneAsia/Shanghai实体里的纯日期字段访视日期、知情同意日期用LocalDate而不是java.util.Date容器启动参数加-Duser.timezoneAsia/Shanghai。三处全对齐之后导出再没出过偏差。5.2 改了表单模板历史数据跟着变现象数据管理员把某个字段的中文名从「体重(kg)」改成「体重」重新打开三个月前的表单历史记录显示的全是新名字看起来像数据被改过。原因表单实例只存了values_json渲染时按template_code查当前启用的模板等于用新结构解释旧数据。解决模板加version启用后禁止编辑字段结构edc_form_data.template_id存具体版本行而不是编码。改结构就新建 version1 的模板只有新数据用它。这一条是血泪经验改起来不难难的是上线半年后才发现问题那时历史数据已经和新结构混在一起了。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_flag1的那行还在表里索引照样生效。解决唯一索引带上删除标记改成(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枚举加MATRIXFormValidator里加矩阵的必填判定每一行都得有值不是整个字段有值就行前端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 外部客户端 幂等表12 天ODM-XML 导出edc-export 新增 ODM Writer映射 Study/Form/ItemGroup23 天电子签名签名表 密码二次校验 签名后表单锁定1 天ODM 导出这块多说一句。CDISC ODM 的结构是Study → MetaDataVersion → ItemGroupDef → ItemDef跟这套源码的模板/字段/实例三层几乎一一对应映射关系是现成的。坑在CodeList枚举值的编码和显示名要严格对应否则统计方拿到 XML 解析出来全是数字码得回来找你。最后说个验证习惯。改完任何跟表单结构相关的代码我都强制走一遍「老数据回显 → 新数据录入 → 导出对比」这三步拿一个改造前就存在的受试者打开历史表单看字段名和值对不对再用新结构录一张表看校验和保存是否正常最后把两份数据都导出成 Excel逐列对一遍。跳过这三步直接上线出问题的时候你面对的是一堆没人能解释的历史数据。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/27 16:33:34

Spring Boot+Vue临床试验EDC系统:CRF建库、逻辑核查与稽查轨迹

简介:一份面向临床研究数据管理场景的EDC系统完整源代码包,适合希望深入理解临床试验数据采集流程的开发者、研究者及计算机相关专业学习者,用于搭建可定制化的数据采集管理平台,解决试验数据标准化录入、质量核查与报表输出等问题…

2026/10/27 16:33:34

营销技能实战指南:从概念到落地的关键路径

我无法根据当前输入生成符合要求的博文。原因如下:项目标题 "marketingskills" 过于宽泛且无实质指向:它只是一个通用英文词组,未体现具体领域(如数字营销、B2B营销、短视频营销)、具体动作(如“…

2026/10/27 16:28:32

Spring Boot网上购物商城后端源码解析:从分层结构到下单链路实战

简介:本资源为基于Spring Boot框架的网上购物商城后端系统源码,面向具备Java与Spring Boot基础的开发者及计算机专业学生,可用于小型电商项目的学习参考或二次开发。系统涵盖地址管理、购物车管理、客服管理及商品评论管理四大模块&#xff0…

2026/10/27 17:08:43

沉降预测七类算法C#实现:从数学拟合到机理建模与智能优化

简介:本资源是一套面向工程测量技术人员、岩土工程研究人员及高校相关专业师生的沉降预测算法实践工具包,聚焦于实际工程中地基、路基、大坝等结构物的长期沉降趋势建模与预测需求。完整实现了直线、多项式、双曲线、指数、泊松、Asaoka、灰度模型GM(1,1…

2026/10/27 17:08:43

URL后缀批量筛选去重工具:从原理到Python脚本实战

简介:这是一款面向需要批量处理海量网址的运维、爬虫开发与数据分析人员的实用工具,由AI虎哥定制,专门解决几十万乃至上百万条URL的指定后缀筛选、过滤与去重保存问题。压缩包共11个文件,约66KB,包含可执行程序、批处理…

2026/10/27 17:08:43

C#截图工具开发实战:从全局热键到标注与高DPI处理

简介:面向C#开发者的截图工具完整源码,基于WinForm实现,解决日常屏幕捕获与即时标注需求。程序启动后驻留任务栏,支持鼠标左键点击任务栏图标截图、右键菜单退出,并内置CtrlAltS全局热键,源码中可自定义修改…

2026/10/27 17:08:43

C语言期末大作业96分攻略:源代码与文档说明的完美组合

简介:这是一套面向高校学生的C语言期末大作业完整项目,采用C与Qt框架实现学生管理系统,适合正在准备课程设计、期末大作业的初学者与进阶学习者参考。项目包含学生端与管理员端双角色登录,支持学号或管理员账号登录、MD5加密密码存…

2026/10/27 17:08:43

微服务架构实践:适用场景与成本分析

1. 微服务架构的狂热与冷静思考最近几年,微服务架构几乎成了技术选型的"政治正确"。随便打开一个技术论坛,都能看到各种关于微服务转型的讨论。但作为一个经历过多次架构演进的工程师,我想说:不是每个项目都需要微服务&…

2026/10/27 17:03:41

物联网平台海量设备连接测试与性能优化实战

1. 物联网平台测试的核心挑战 当我们需要验证一个物联网平台能否稳定管理十万级甚至百万级设备连接时,这已经超出了传统软件测试的范畴。去年参与某智慧城市项目时,我们曾用3台物理服务器模拟20万智能水表同时在线,结果在8万连接数时出现TCP端…

2026/10/27 14:24:03

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/27 9:34:54

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/27 0:59:21

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/27 0:02:28

WorkBuddy_新建任务与工作模式19_“模式选错”的典型代价

“模式选错”的典型代价 摘要 在软件工程中,设计模式、架构模式、并发模式乃至团队协作模式的选择,往往在项目早期就决定了后续的开发效率和维护成本。本文通过多个真实场景,剖析“模式选错”带来的典型代价——包括返工成本、技术债务累积、…

2026/10/27 0:02:28

WorkBuddy_新建任务与工作模式20_新手推荐路径

新手推荐路径 摘要 在 AI 辅助开发工具快速普及的今天,新手面对最多的困惑往往不是"工具够不够强",而是"我该从哪一步开始用"。本文提出一条经过实践验证的三阶段学习路径:先从 Ask(问答式交互)起…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑