EDC系统完整源码解析:从数据模型到审计追踪的临床数据管理

简介:在临床研究中,纸质数据采集方式常伴随效率低、易出错与核查困难等问题,而EDC系统正是解决这一系列难题的核心工具。这套EDC系统完整源代码,面向临床试验软件开发者、数据管理人员及希望构建自有数据采集平台的技术团队,提供了一套可运行的Java实现。压缩包共6个文件,包含4个Java源文件与2个scc辅助文件,整体仅17KB,代码轻量、结构清晰。源码涵盖临床试验项目管理、CRF(病例报告表)在线设计、数据录入、疑问管理、逻辑自动校验、CRF与DCF导出PDF、统计报表以及自定义查询等核心环节,尤其便于研究者自定义实验流程与数据验证规则。目前已有1729人学习下载,既能帮助入门者理解EDC系统的分层架构与业务流转,也为有二次开发需求的人员提供了直接可参考的代码基底。

1. EDC系统完整源代码:先说清它到底解决临床研究里的什么问题

做过临床数据管理的人都明白,EDC系统最难的不是把表单做成网页,而是让每一格数据经得起稽查。临床试验里的数据采集,过去靠纸质CRF,填写随意、修改无痕、多中心汇总全靠人工录入,一个数字写错可能要等统计分析阶段才能暴露。EDC把整个过程变成一条可校验、可追踪、可锁定的数据流:填写的每一格都有校验规则,每一次修改都有记录,每一个质疑都有流转状态。拿到这套完整源代码,核心价值不在“有哪些页面”,而在它能不能撑起数据完整性、审计追踪和多中心权限隔离这三件事。适合做医疗信息化的开发者、初创CRO的技术负责人,以及想自建EDC平台的某临床研究团队——读懂并改造这套源码,比从零开始省下至少半年的建模和踩坑时间。

2. 拆解EDC系统核心模块:数据模型和表单引擎决定代码量的半壁江山

2.1 从纸质CRF到eCRF:数据模型怎么设计才能撑起多中心研究

纸质CRF的层级是:研究方案→中心→受试者→访视→表单→填写项。EDC系统要复现这套结构,数据库设计必须按同样的层级拆。最常见的做法是用六张核心表分别对应研究、中心、受试者、访视、表单定义和填写值,而不是把所有填写内容塞进一张大宽表。

研究(study)和中心(site)必须分离。一个研究可能同时有七八个中心在入组,同一个表单在A中心和B中心可能启用不同字段。把研究属性和中心属性拆开,后续做中心级权限、中心级表单配置才有落点。受试者表挂在中心下面,用研究+中心+受试者编号做唯一约束,避免多中心编号重复导致的数据串组。

字段值不建议直接存“男/女”这类显示文本,推荐存编码值加一份字典表。这个我们放到2.3节展开。先看整体表清单,四张核心表的设计能覆盖绝大多数EDC应用场景:

表名作用域说明
study研究级研究方案、分期、状态、开始结束时间
site中心级中心编号、机构名称、启用状态
subject受试者级随机号、入组日期、生命状态
form_def访视+表单级存储eCRF的JSON定义,含字段、校验、跳题逻辑
record_value数据实例每个受试者每个访视每个表单的一条填写记录,值存JSONB
audit_log数据实例字段级修改前值与修改后值,审计追踪只认这张表

受试者表要冗余存一个当前访视序号,业务上叫visit_seq。为什么冗余?因为EDC的访视跳转必须受控:受试者没完成V2访视的必填表单,系统要禁止直接跳到V3。这个状态如果每次查询时现算,访问量和计算开销都高,冗余字段配合状态机更稳。

2.2 动态表单引擎:用JSON Schema存表单定义,避免为每个访视写死页面

每个临床研究的CRF都不一样,同一个研究的访视2和访视3表单也可能完全不同。如果前端为每个表单写一个页面组件,新研究一启动就要改代码,这是所有EDC源码里最拖后腿的写法。可靠的方案是做动态表单引擎:表单结构存JSON,后端负责解析和校验,前端负责按JSON递归渲染。

一份eCRF定义用JSON描述,结构是form加fields数组,每个field声明字段编码、显示名、数据类型、是否必填、候选值和校验规则。下面是最小可用示例:

{ "form_code": "VS_ECG", "form_name": "十二导联心电图", "version": "1.0", "visit_code": "V2", "fields": [ { "field_code": "ECG_DONE", "field_label": "是否完成心电图检查", "data_type": "radio", "required": true, "options": [ { "code": "1", "label": "是" }, { "code": "0", "label": "否" } ] }, { "field_code": "ECG_HR", "field_label": "心率(次/分)", "data_type": "number", "required": true, "unit": "bpm", "validation": { "range": [30, 250] } }, { "field_code": "ECG_NOTE", "field_label": "异常描述", "data_type": "text", "required": false, "skip_logic": { "condition": "ECG_DONE == 1", "explain": "仅当完成检查且结果为异常时填写" } } ] }

这个JSON是form_def表的payload字段内容。解释一下关键设计:field_code是全表单唯一编码,后续的跳题逻辑、质疑生成、审计日志都引用它;data_type决定前端渲染控件和后端存值类型;validation里的range只是字段级校验的入口,真正复杂的跨字段校验在2.4节的规则引擎里。

表单版本必须跟着存。表单定义一旦发布,填写中的数据不能直接用新定义渲染,否则字段编码变了、取值范围变了,旧数据对不上。常见做法是form_def表带version字段,发布后生成新版本,受试者已录入的版本保持不变,新受试者用新版本。这条后面避坑章节还会细讲。

2.3 数据字典与编码值:为什么不直接存“男/女”而要存“1/2”

如果直接在数据库里存“男/女”这种中文文本,统计阶段就等着翻车。EDC的填充分两种:一种来自标准化字典,比如性别、种族、是否吸烟;另一种来自自由文本,比如不良事件描述、合并用药名称。字典类字段的标准做法是存编码值,显示文本通过字典表在渲染时映射。

设计一套data_dict表,结构是dict_type加code加label加sort_order。性别字典就是dict_type='GENDER',code='1'对应label='男'。表单定义里radio/select的options直接引用dict_type,不写死label。这样换成英文界面、或者统计端要求label按某标准术语集输出时,只改字典表,不用动已填写的业务数据。

统计分析阶段SAS程序员最怕的一件事:A中心的“是”存成了"Y",B中心的“是”存成了"1",合并数据集时全部要手工清洗。用统一编码值就是为了规避这个问题。凡是带options的字段,所有中心必须拉同一份字典;如果研究允许中心扩展某些选项,扩展部分必须走申请流程,由数据管理员在系统里开放,不能让中心自己随便加字段值。

2.4 校验规则引擎:SAS风格校验表达式的设计与实现

字段级校验比较简单,范围、正则、必填。真正难的是跨字段和跨表单校验,比如“心电图结果选异常时,异常描述必填”,或者“不良事件开始日期不能晚于研究中心关闭日期”。这类规则不能写死在Java代码里,必须支持配置化。

常见做法是定义一套轻量表达式语法,字段引用用字段编码,操作用==、!=、>、<、&&、||,后端用表达式解析器在保存时执行校验。下面是一段表达式执行的核心逻辑的Java简化实现:

public class EvalRule { // 用简单递归下降解析表达式,避免引入重规则引擎 public boolean eval(String expr, Map<String, Object> values) { // expr示例: "ECG_DONE == 1 && ECG_HR >= 30" expr = expr.replaceAll("\\s+", ""); if (expr.contains("&&")) { String[] parts = expr.split("&&"); for (String p : parts) { if (!evalSingle(p, values)) return false; } return true; } return evalSingle(expr, values); } private boolean evalSingle(String expr, Map<String, Object> values) { String[] parts = expr.split("=="); if (parts.length == 2) { Object actual = values.get(parts[0]); Object expected = typeConvert(parts[1]); return actual != null && actual.equals(expected); } parts = expr.split(">"); if (parts.length == 2) { double actual = Double.parseDouble(values.get(parts[0]).toString()); double expected = Double.parseDouble(parts[1]); return actual > expected; } throw new IllegalArgumentException("不支持的表达式: " + expr); } }

逻辑说明:这段代码处理&&组合和单条件两类场景,单条件支持==和>,实际工程还要补充!=、<、>=、<=以及字段与字段比较。typeConvert负责把表达式右值转成和字段值一致的类型,比如"1"转Integer,日期转LocalDate。注意只支持同一层级字段的引用,跨表单引用需要靠form_code前缀,例如"VS_ECG.ECG_HR > 100",解析时先按前缀取对应表单的值。

参数方面,校验规则的每一条都要配message文案,因为保存失败时前端要展示给录入员看;还要配rule_level,分“报错”和“警示”两级,报错阻止保存,警示允许保存但弹提示。临床数据管理里有很多非硬性校验,比如“体重和上次访视差超过20%”,用警示级最合适,硬报错反而拖慢入组速度。

3. 用Spring Boot落地EDC后端:完整源代码的最小闭环

3.1 搭建工程骨架与数据库表结构:PostgreSQL JSONB就是为EDC准备的

EDC的填写数据天然是半结构化:同一张表单,有的中心可能额外填了备注字段,有的字段在方案修订后会被弃用。用关系表为每个字段建列,改一次方案就要迁移一次表,维护成本极高。实际项目里我见过两种路线:一种是MySQL存大文本序列化内容,另一种是PostgreSQL JSONB。后者有两个优势,JSONB支持字段内索引,而且更新时能够只改部分键,配合GIN索引可以按表单内容检索。

建表SQL至少要覆盖研究、表单定义、填写数据和审计日志这四条链路。下面是精简后的核心建表语句:

CREATE TABLE study ( study_id BIGSERIAL PRIMARY KEY, study_code VARCHAR(40) NOT NULL UNIQUE, study_status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE subject ( subject_id BIGSERIAL PRIMARY KEY, study_id BIGINT NOT NULL REFERENCES study(study_id), site_id BIGINT NOT NULL, subject_no VARCHAR(30) NOT NULL, current_visit_seq INT NOT NULL DEFAULT 1, status VARCHAR(20) NOT NULL DEFAULT 'SCREENING', UNIQUE (study_id, site_id, subject_no) ); CREATE TABLE form_def ( form_def_id BIGSERIAL PRIMARY KEY, study_id BIGINT NOT NULL REFERENCES study(study_id), form_code VARCHAR(50) NOT NULL, version INT NOT NULL DEFAULT 1, payload JSONB NOT NULL, publish_status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', UNIQUE (study_id, form_code, version) ); CREATE TABLE record_value ( record_id BIGSERIAL PRIMARY KEY, subject_id BIGINT NOT NULL REFERENCES subject(subject_id), form_def_id BIGINT NOT NULL REFERENCES form_def(form_def_id), visit_seq INT NOT NULL, visit_date DATE, values_json JSONB NOT NULL, record_status VARCHAR(20) NOT NULL DEFAULT 'SAVED', updated_by VARCHAR(50), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE audit_log ( audit_id BIGSERIAL PRIMARY KEY, record_id BIGINT NOT NULL, field_code VARCHAR(50) NOT NULL, old_value JSONB, new_value JSONB, action VARCHAR(20) NOT NULL, operator VARCHAR(50) NOT NULL, operated_at TIMESTAMPTZ NOT NULL DEFAULT now() );

逻辑说明:form_def以JSONB存表单定义,每次改版生成新version,这样一个研究下同一表单就有多个版本。record_value表的values_json直接存填写内容的键值对,比如{"ECG_DONE":"1","ECG_HR":"75"}。为什么要单独存updated_by而不从session里现取?审计日志的operator必须记录操作时的身份,之后即使账号被禁用,历史的操作者信息依然可查。

参数说明:subject_id上用JSONB字段内值查询,需要为高频检索字段建表达式索引,例如对"values_json->>'ECG_HR'"建index,否则后期按字段筛选会全表扫。record_status建议按临床数据管理习惯分:SAVED已保存、SUBMITTED已提交、LOCKED已锁定,锁定的记录只能由数据管理员解锁。

3.2 实现eCRF渲染接口:把form_def JSON和已填数据拼成视图返回前端

前端要渲染一个表单页面,需要两方面内容:表单定义(字段、类型、选项、跳题逻辑)和这个受试者当前已填写的值。后端接口做的事就是把form_def的payload和record_value的values_json合并返回。

@RestController @RequestMapping("/api/edc") public class EcrfController { @GetMapping("/subjects/{subjectId}/forms/{formDefId}") public ResponseEntity<Map<String, Object>> renderForm( @PathVariable Long subjectId, @PathVariable Long formDefId) { FormDef formDef = formDefService.getById(formDefId); RecordValue record = recordValueService .findBySubjectAndForm(subjectId, formDefId) .orElse(null); Map<String, Object> result = new HashMap<>(); result.put("form_definition", formDef.getPayload()); // 已有值合并到定义里,前端按 field_code 取默认值 result.put("values", record == null ? new HashMap<>() : record.getValuesJson()); // 记录当前填写的record_id,前端保存时回传 result.put("record_id", record == null ? null : record.getRecordId()); // 表单级锁定状态,锁定后前端所有控件只读 result.put("locked", record != null && "LOCKED".equals(record.getRecordStatus())); return ResponseEntity.ok(result); } }

逻辑说明:这个接口的重点不在代码量,而在约定——渲染时后端只负责给数据和状态,前端拿到两个JSON自己做合并。locked字段必须后端判断,不能只靠前端隐藏按钮;临床数据管理里锁定状态是强约束,后端不做校验,用户绕过前端直接调保存接口就废了。

参数说明:渲染接口要考虑空表单场景。受试者还没填过这个表单时,record是null,values返回空Map,record_id返回null。前端据此把保存按钮从“更新”变成“初次保存”,存储走两条不同SQL。另外visit_seq要在渲染时从subject表取,禁止前端传,防止用户跨访视写数据。

3.3 数据保存与稽查轨迹:一个AOP切面解决“谁在什么时间改了什么”

EDC的审计追踪是整个系统的合规生命线。审核时审计人员会抽查某条记录的某个字段,要求看到完整的修改历史:原始值、修改后的值、操作人、操作时间。实现方式是在保存数据的方法上切入AOP,保存前读取更新前的记录,保存后计算字段级差异写入audit_log。

@Aspect @Component public class AuditAspect { private final AuditLogRepository auditRepo; @Around("@annotation(com.example.edc.core.AuditTrail)") public Object auditRecord(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); SaveRecordRequest req = (SaveRecordRequest) args[0]; // 保存前查旧值 RecordValue oldRecord = recordValueRepository.findById(req.getRecordId()).orElse(null); Object result = pjp.proceed(); // 保存后查新值 RecordValue newRecord = recordValueRepository.findById(req.getRecordId()).orElse(null); if (oldRecord == null) { // 首次保存:所有值算新增 newRecord.getValuesJson().forEach((k, v) -> auditRepo.save(buildAudit(k, null, v, "CREATE", req.getOperator()))); } else { Map<String, Object> oldValues = oldRecord.getValuesJson(); Map<String, Object> newValues = newRecord.getValuesJson(); newValues.forEach((k, newVal) -> { Object oldVal = oldValues.get(k); if (!Objects.equals(oldVal, newVal)) { auditRepo.save(buildAudit(k, oldVal, newVal, "UPDATE", req.getOperator())); } }); // 处理被清空的字段 oldValues.forEach((k, oldVal) -> { if (!newValues.containsKey(k)) { auditRepo.save(buildAudit(k, oldVal, null, "UPDATE", req.getOperator())); } }); } return result; } }

逻辑说明:切面拦截带@AuditTrail注解的保存方法,保存前后各查一次记录。比较时用键逐个比对,旧值存在而新值缺失的字段视为清空处理。这里有一个关键边界:审计日志只记录业务字段的变更,不记录updated_at这类系统字段,否则里面全是“系统修改了自己”的噪音。

参数说明:字段级比较建议用Jackson把JSONB转成Map再比较,注意数字类型。比如前端把75.0传回来,Jackson可能解析成Double,而库里存的是Integer,Objects.equals会认为不一致,产生无效审计。解决办法是统一在入库前把数值型JSON值转成BigDecimal再存,比较也要基于BigDecimal。这个属于典型的“代码看着没问题,审计日志里全是假差异”的坑。

3.4 病例状态机与质疑管理:数据清洗的第一道闸门

EDC里数据不是填完就完事了。一条记录要经历已保存→已提交→已锁定三个阶段,锁定后任何修改都要先走解锁流程。状态机控制在后端实现,前端只根据状态渲染可操作按钮。

质疑(Query)是EDC特有的数据清洗工具。所有临床机构都会回答“为什么要改这个数据”问一下数据管理员:某字段值和原始病历不一致,数据管理员发起质疑,中心医生答复或修改,质疑关闭。质疑必须有独立表:query_id、record_id、field_code、question、answer、status、query_type。

质疑状态流转:OPEN(待答复)→ANSWERED(已答复)→CLOSED(已关闭);如果答复不通过,从ANSWERED重新回到OPEN。后端要拦截一个动作:数据已锁定的记录被提出质疑后,中心医生填写答复,此时系统自动解锁该字段,医生修改后重新提交,质疑状态置为CLOSED。这个流程用状态枚举加一张流转记录表做,不要把状态字段存成字符串散在各处。后续表结构要控制好并发——同一时刻只有一条质疑处于待答复状态,可以在queried字段上加唯一索引约束,防止数据管理员重复发同字段质疑。

4. 前端与电子签名:从动态渲染到合规填写的最后一公里

4.1 用Vue实现动态表单渲染器:组件递归渲染与字段联动

后端返回的form_def是一个嵌套JSON,前端渲染器的核心是写一个自定义组件RenderField,根据data_type分发到不同控件:radio渲染单选框、number渲染数字输入框、text渲染文本框。这个组件要递归渲染,因为复杂表单里可能有“重复组”结构,比如合并用药列表,一个组里包含药品名称、给药途径、剂量几个字段,组本身是数组。碰到group类型,组件循环渲染子字段数组,并维护每个子项的索引。

Vue实现的核心逻辑如下:

<template> <div> <template v-for="item in fields" :key="item.field_code"> <component :is="resolveComponent(item.data_type)" :field="item" :model-value="modelValue[item.field_code]" :disabled="locked" v-bind="getComponentProps(item)" @update:model-value="updateField(item.field_code, $event)" /> </template> </div> </template>

逻辑说明:model-value是当前表单的所有值对象,updateField回调负责更新这个对象并触发联动检查。组件分发时注意日期控件和下拉控件的事件签名差异,统一在updateField内部做类型适配。锁定时disabled要传给所有子组件,防止只读状态被绕过。

参数说明:Vue的动态组件resolveComponent需要注册所有可能用到的控件类型,新增自定义控件时必须在注册表里声明,否则后端JSON多一个data_type,前端直接白屏。实际工程中建议在渲染器里做未知类型的fallback,渲染成警告条而不是让整个页面崩掉。

4.2 联动与跳题逻辑:隐藏字段不能只靠前端做

跳题逻辑常被做成前端行为:ECG_DONE选“否”时隐藏ECG_HR输入框,看起来省事。但合规的EDC必须遵守一个原则——凡是跳题逻辑,前端隐藏只是为了录入体验,后端必须用同一份规则执行判定,否则编辑时被隐藏的字段值会被随意丢弃。

实现上,前端在updateField触发时解析form_def里的skip_logic条件,匹配则把对应字段的值从待提交数据里剔除并重置为空。后端在保存接口收到values后,用同一个表达式引擎重新评估所有带skip_logic的字段,把“隐藏却仍有值”的字段清空并计入审计。双向校验的目的只有一个:无论用户怎么操作前端,最终落库的数据集必须满足逻辑一致性。

计算字段也要往前端放一份。比如BMI=体重/身高²,输入身高体重后前端实时算,但真正的BMI存库值必须以后端计算结果为准,前端算的值只做展示,计算结果在保存时由后端重新算好覆盖前端传值。

4.3 保存策略与电子签名:何时自动保存,何时必须提交

临床录入场景的网络很不稳定,尤其在医院病区里,Wi-Fi断连是常态。最常见的保存策略是“自动保存加手动提交”:录入员每30秒或字段失焦时防抖保存一次,保存的数据不入正式库,只update到record_value并标记record_status=SAVED;录入员确认无误后点“提交并签名”,此时系统弹出电子签名框,输入密码后调用签名验证接口,验证通过记录才置为SUBMITTED。

电子签名逻辑不能是前端弹个框确认就算完成。后端签名接口要校验三件事:密码是否正确、当前记录是否处于可提交状态、是否在有效签名时间段内。签名信息落库信号量记录:签名人、签名时间、签名动作(提交/修改/解锁)、关联记录ID。提交后任何值变更都必须生成一条新的质疑或走解锁申请,签名接口本身也纳入审计轨迹。

前端的离线缓存要克制。不要在localStorage里缓存一整个表单的长期数据,只缓存当前未提交的临时编辑。缓存时要带上record_id和表单版本号,重新联网后先校验版本号是否一致,不一致则提示刷新页面,防止老版本表单的数据覆盖新版本定义。

5. EDC系统完整源代码落地避坑:5个临床现场跑出来的血泪经验

5.1 坑一:多中心时区不一致,SAE时间记录全部错乱

现象:某中心录入的严重不良事件开始时间,比实际病历记录的时间早了8小时,审计阶段差一点被判定为数据不真实。原因排查发现,该中心位于不同时区,前端用本地时间生成时间戳,后端按服务器默认时区解析,时间被强制转换了一次。解决:系统内所有时间统一存UTC,前端时间控件一律传UTC字符串,显示时按浏览器时区转换;受试者相关的访视日期属于业务日期,必须由录入员手动选择年月日,禁止用系统时间自动带入。这条在遇到跨国多中心研究时尤其致命。

5.2 坑二:表单版本升级后,已录入数据回填失败

现象:某研究中期修订了CRF,心电图表单新增了一个字段“QTc间期”,版本从1.0升到1.1;结果老版本已经填好的数据在重新打开时,新字段显示空白,保存时还被校验规则拦截,说必填项缺失。原因:前端用新版本定义渲染旧数据,旧记录里没有新字段值,但新版本的必填校验对全记录生效了。解决:表单定义和填写值必须绑定版本——record_value要冗余存form_version,保存时以该版本定义执行校验,渲染时加载该版本的payload,升级版本只影响新记录。核心原则是“一个记录对应一个版本”,既不能拿新版本校验旧数据,也不能用旧定义渲染新数据。

5.3 坑三:稽查日志只记了“改了什么”没记“改前改后”

现象:某次内部模拟稽查,审计人员随机抽查了一条记录,日志显示字段ECG_HR被修改过,但操作时间和修改人都有,唯独看不到修改前的值,结果没法确认这次修改是否合规。原因:早期版本审计日志只存new_value,为了省存储,后来需要回溯时才发现没有old_value,数据完整性链条断了一截。解决:审计日志必须存旧值和新值两列,且值序列化要保留原始类型。补充一个细节:清空字段也要记一条审计,很多实现直接把空值忽略,导致“值被清掉”这个动作在历史里消失。

5.4 坑四:数据字典想当然,统计口径在统计分析阶段崩掉

现象:一个多中心研究,A中心“是否吸烟”下拉框是“是/否/不吸烟”,B中心是“吸烟/不吸烟/未记录”,C中心直接文本输入。统计分析阶段SAS程序员拼数据集时发现,三家的“不吸烟”对应了三种code,只能手工重新映射,花了两个星期清洗。原因:表单定义里的options绕过了数据字典,各中心按自己的习惯配置。解决:字段设计阶段先拉一个枚举清单,radio和select必须引用统一字典配置,禁止在表单JSON里直接写死options;如果有中心特殊需求,走字典扩展流程,扩展的code由数据管理员统一分配,任何中心不得自定义。

5.5 坑五:权限控制只做了角色没做数据隔离

现象:某中心的研究者登录系统后,理论上只能看到本中心受试者数据,结果通过直接拼接URL传subject_id,就能打开其他中心的受试者记录。原因:后端接口只做了登录认证和角色校验,没有做数据权限隔离。解决:所有查询接口强制带site_id范围,数据权限在Service层统一追加过滤条件,不能依赖前端传参。建议写一个基类方法:查询时必须显式调用withScopeData(),由当前登录用户的研究角色决定能访问的中心列表,再和查询条件取交集。这个要做到接口层,不只在菜单层。

6. 把源代码变成可交付的系统:验证链路与二次开发技巧

6.1 用模拟数据跑一遍完整业务链路,验证状态机是否闭环

拿到一套EDC源码,先不要急着加功能,把基础链路跑通再谈扩展。我习惯用SQL造一套最小模拟数据,覆盖“中心创建→受试者入组→表单填写→提交→质疑→修改→重新提交→锁定”的全过程。下面是验证用的核心脚本片段:

-- 模拟验证:确认记录状态流转符合预期 INSERT INTO record_value (record_id, values_json, record_status) SELECT 1, '{"ECG_HR": 78}', 'SAVED' WHERE NOT EXISTS (SELECT 1 FROM record_value WHERE record_id = 1); -- 尝试提交已锁定记录,预期被拦截 UPDATE record_value SET record_status = 'LOCKED' WHERE record_id = 1; -- 断言:锁定后提交操作应拒绝 DO $$ BEGIN IF EXISTS (SELECT 1 FROM record_value WHERE record_id = 1 AND record_status IN ('SAVED', 'SUBMITTED')) THEN RAISE NOTICE '状态异常,记录仍处于可提交状态'; END IF; END $$;

这条脚本验证三类约束:初始状态能否正确创建、状态流转是否受控、锁定后是否被接口层拦截。凡是状态机相关改动,都应该用这类SQL脚本在CI里做回归,防止某次改分支逻辑把锁定校验漏掉。

6.2 二次开发优先级和需要预留的扩展点

临床数据管理的标准化输出是EDC后端绕不开的一环。建议把导出功能做成模板化:先支持常用的数据集格式CSV、SAS传输文件,再支持CDISC标准的ODM XML导出。另外两件事值得优先考虑:一是与外部系统对接的中心化随机化接口,二是不良事件编码的自动映射。前者影响入组效率,后者直接影响药物警戒报告时效。这两块可以在源码基础上做插件式扩展:定义好接口文档,把外部系统对接参数放在配置表里,不要写死在代码里。

我现在的习惯是,任何EDC项目里先跑一遍“录入→质疑→锁定”的干路流程,确认状态机和审计链路没问题,再看字段级权限和报表。一套源码最贵的地方就是这些看不见的边界规则,功能界面再多,状态机有漏洞一样不敢过审计。希望这些经验能帮到你。

本文还有配套的精品资源,点击获取

相关新闻

behave框架实战:用Python BDD让测试聚焦业务行为

behave框架实战:用Python BDD让测试聚焦业务行为

很多团队在测试这块越做越重&#xff0c;但越重越说不清楚业务到底要什么。如果你也有这种感觉&#xff0c;behave 这个 Python BDD 测试框架值得花 10 分钟试一下。它把测试从“验证代码对不对”提升到“验证业务行为对不对”的层面&#xff0c;用接近自然语言的 Gherkin 语法…

2026/10/20 16:46:51 阅读更多 →
Python AI编程入门指南:机器学习、深度学习、NLP与强化学习四大方向全解析

Python AI编程入门指南:机器学习、深度学习、NLP与强化学习四大方向全解析

1. 从零搭建Python AI编程的知识骨架1.1 为什么大多数人学AI编程会卡在入门阶段我带过不少想转AI方向的朋友&#xff0c;也看过很多人的学习路径&#xff0c;发现一个特别普遍的现象&#xff1a;打开一个机器学习教程&#xff0c;前几章讲线性回归、梯度下降&#xff0c;跟着敲…

2026/10/20 16:46:51 阅读更多 →
软考高项备考三个月一次通过:综合知识、案例分析与论文实战全攻略

软考高项备考三个月一次通过:综合知识、案例分析与论文实战全攻略

软考高项这个词&#xff0c;这几年在IT圈和项目管理圈的热度一直没降过。很多人一边刷着“信息系统项目管理师”的报考攻略&#xff0c;一边在纠结要不要报名&#xff0c;报了名又开始焦虑论文怎么憋出一千多字。我自己就是从这种状态里走出来的&#xff0c;备考三个月&#xf…

2026/10/20 16:46:51 阅读更多 →

最新新闻

Ubuntu下PostgreSQL安装配置与Navicat连接指南

Ubuntu下PostgreSQL安装配置与Navicat连接指南

做后端开发或者数据相关工作的朋友&#xff0c;应该都经历过这么一幕&#xff1a;好不容易在 Ubuntu 上把 PostgreSQL 装好了&#xff0c;却发现命令行操作没问题&#xff0c;客户端工具却怎么都连不上&#xff1b;或者反过来&#xff0c;Navicat 装好了&#xff0c;连接时报一…

2026/10/20 17:33:06 阅读更多 →
uniCloud Serverless 实战:英语学习小程序全链路开发

uniCloud Serverless 实战:英语学习小程序全链路开发

简介&#xff1a;这是一套面向英语学习者与小程序开发者的完整项目源码&#xff0c;基于uniapp与uniCloud Serverless架构构建&#xff0c;可直接在微信环境中运行。项目集生词本记录、拍照识别单词、内置词典查询、AI语音翻译、跟读打分评测于一体&#xff0c;并针对考研人群补…

2026/10/20 17:33:06 阅读更多 →
WiFi指纹室内定位实战:从原理到Demo搭建

WiFi指纹室内定位实战:从原理到Demo搭建

1. 室内定位的现实困境与WiFi方案的破局点实话实说&#xff0c;第一次接手“基于WiFi的室内定位”这个方向时&#xff0c;我心里是打鼓的。日常大家用GPS导航已经习惯了&#xff0c;掏出手机就能知道自己在哪&#xff0c;但一旦走进大型商场、地下车库、机场航站楼&#xff0c;…

2026/10/20 17:33:06 阅读更多 →
大模型后端长对话记忆架构全解析:从无状态到有状态

大模型后端长对话记忆架构全解析:从无状态到有状态

做后端的人都有过这种体验&#xff1a;用户和AI助手聊了三十轮&#xff0c;前二十轮里明明已经把需求背景、偏好、禁忌都交代清楚了&#xff0c;结果聊到第三十一轮&#xff0c;AI突然反问一句“您刚刚提到的那个项目背景&#xff0c;方便再描述一下吗&#xff1f;”当场血压就…

2026/10/20 17:33:06 阅读更多 →
D435相机+OpenCV快速上手:彩色图与深度图实时显示全攻略

D435相机+OpenCV快速上手:彩色图与深度图实时显示全攻略

最近不少做视觉入门的朋友卡在第一关——把D435相机的画面真正“看”到屏幕上。很多人买了一台Intel D435深度相机&#xff0c;兴冲冲插上USB线&#xff0c;结果CPU占用飙高、程序报错、画面黑屏&#xff0c;折腾一晚上就劝退了。其实用D435搭配OpenCV显示彩色图和深度图&#…

2026/10/20 17:33:06 阅读更多 →
自考论文降AI率实战:智能体改写与人工复核全流程

自考论文降AI率实战:智能体改写与人工复核全流程

自考论文写到提交前&#xff0c;最让人血压升高的往往不是查重率&#xff0c;而是学校突然要求提交一份AI检测报告。A同学就是这种状况&#xff1a;论文初稿在查重系统里已经降到合格线&#xff0c;结果学院的通知里多了一句“AI疑似率不得超过20%”。他拿自己的稿子去测了一下…

2026/10/20 17:32:04 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →