从零写一个 HIS-PACS 检查申请单集成服务:一个 Node.js 轮询中间件的落地实录
医院信息化里最常见的活,不是造轮子,是把两个"性格不合"的系统用一段胶水代码粘起来。这篇文章记录我用 Node.js 写一个 HIS→PACS 申请单同步服务的完整过程——包括三次被真实环境"教育"的踩坑:一个返回 XML 的 JSON 接口、一个 11g 的老 Oracle、一个自己亲手写出来的调度 bug。
一、需求:一张申请单的三种命运
场景很典型。患者门诊开了一张放射检查申请单(CT、DR、彩超……),HIS 收费完成后,PACS(影像归档与通信系统)需要"知道这件事"——已收费的申请单才允许登记检查;退费作废的申请单则要在 PACS 侧同步作废,否则患者退了费还能拍片。
两边都不肯改自己的系统,于是需要一个中间集成服务:
┌─────────┐ ①轮询查询(待确认/待作废) ┌──────────────┐ ②POST确认/作废 ┌──────────┐
│ HIS │ ───────────────────────> │ 集成服务 │ ──────────────> │ PACS │
│ (Oracle)│ <─────────────────────── │ (本服务) │ <────────────── │ (REST) │
└─────────┘ ③回写申请单状态 └──────────────┘ 确认结果 └──────────┘
技术形态是很经典的轮询中间件:每 10 秒查一次 HIS 待处理记录,逐条调 PACS 接口,成功后回写状态。选 Node.js 是因为 IO 密集、逻辑简单、部署轻(一台内网 Windows 机器拷个文件夹就能跑)。
PACS 接口文档只有一个报文示例——确认和作废共用一个接口,靠 chargeFlag 区分,鉴权信息放在请求体的 header 节点里:
{
"header": {
"application": "HIS",
"license": "F9BDEFC9-..."
},
"chargeInfo": {
"applyNo": "申请单号",
"itemCode": "项目代码",
"chargeFlag": "1确认 / 3作废"
}
}
二、先把骨架搭对:幂等是第一设计原则
2.1 整体结构
pacstf/
├── src/
│ ├── index.js 入口
│ ├── config.js 全部配置(.env 驱动)
│ ├── poller.js 轮询编排(核心)
│ ├── pacs.js PACS 接口客户端
│ ├── webServer.js 内置管理控制台(零依赖)
│ ├── sqlTemplates.js SQL 模板加载器(热加载)
│ ├── logger.js 双写日志 + 自动过期清理
│ └── sources/
│ ├── dbSource.js 真实 Oracle 数据源
│ └── fixtureSource.js 本地模拟数据源
└── sql/templates/ 查询/回写 SQL(改完即生效,无需重启)
几个刻意的设计决策:
SQL 外置成热加载模板。 HIS 侧的表结构、状态值约定在项目初期是流动的(后面也确实流了三次)。把 SQL 从代码里挪到 sql/templates/*.sql 文件,每次执行前检查 mtime 重新读取,联调阶段改 SQL 不用重启服务。事实证明这是本项目中性价比最高的决定。
数据源抽象 + fixture。 dbSource(真实 Oracle)和 fixtureSource(本地 JSON 模拟)实现同一套接口,DATA_SOURCE=fixture 一键切换。演示、自测、回归都不依赖真实库,模拟 PACS 也写了一个,两个进程一起跑就是完整的联调环境。
回写必须带原状态条件。 这是幂等的核心:
UPDATE EXAM_APPOINTS
SET STATUS = 1
WHERE EXAM_NO = '{
{applyNo}}'
AND STATUS IS NULL
多实例并发也好、上一轮处理到一半崩了也好,rowsAffected = 0 就意味着"别人已经处理过",直接跳过。接口调用成功但回写失败是最恶心的分支——它依赖 PACS 接口本身的幂等性(同一申请单重复确认不产生副作用),这一点在联调时特意和 PACS 厂商确认过。
2.2 失败策略:经历了三版
这部分被真实需求改了三次,值得单独说。
第一版(想当然):失败本地计数,下轮重试,超过 5 次回写异常状态 9,人工在管理界面重新入队。教科书式的做法。
第二版(医院说:别动 HIS):异常值不回写,失败记录进入本地异常队列。但这样一来 HIS 里 STATUS 还是 NULL,每轮查询还会查到,得加"首次入队才记日志"的去重防刷屏。
第三版(医院最终拍板):失败就跳过,不重试不计数不进队列——HIS 状态不变,下一轮自然重查,正常进入下一次轮询。逻辑瞬间变简单:
async processRecord(rec) {
let pacsResult;
try {
pacsResult = await pacs.confirmOrder(rec); // chargeFlag=1
} catch (err) {
logger.warn('POLL', `申请单 ${
rec.applyNo} 确认失败(本轮跳过): ${
err.message}`);
return 'fail'; // 不重试,下轮见
}
await this.source.updateStatus(rec, 1); // 幂等回写
return 'ok';
}
教训是:重试策略不是技术问题,是业务问题。看起来更"完善"的第一版,对医院来说反而多了一个要人工盯的异常队列;而"每轮都会自然重查"的幂等查询本身,就是一个分布式的、天然的失败恢复机制——前提是你的查询条件能重新捞到失败记录。想明白这一点,一半的重试代码都可以删掉。
三、被真实环境"教育"的三次
3.1 坑一:说好的 JSON 接口,返回的是 XML
对接文档只给了请求格式,没给响应格式。我按行业惯例实现了 HTTP 2xx 且 code===0 的成功判定,联调当天本机 curl 了一下真实接口:
$ curl -X POST http://<PACS>/applyService/apply/syncChargeFlag -d '{...}'
<?xml version="1.0" encoding="UTF-8"?>
<response>
<code>-1</code>
<desc>没有查询到此申请单记录</desc>
</response>
两个问题:响应是 XML;失败时 HTTP 状态码依然是 200,成败只看 <code> 是不是 0。如果直接上生产,所有失败都会被当成成功——这是集成项目里最危险的一类 bug:不报错,只是悄悄地错。
修复是给响应解析加了格式分支:
const text = await res.text();
if (text.trimStart().startsWith('<')) {
body = {
__xml: true, code: xmlTag(text, 'code'), desc: xmlTag(text, 'desc') };
} else {
body = JSON.parse(text); // JSON 格式兜底
}
function judgeSuccess(res, body) {
if (!res.ok) return false;
if (body.__xml) return String(body.code) === '0'; // XML: <code>0</code> 才算成功
if (body.success === false) return false;
return true;
}
心得:接口文档没写响应格式时,先 curl 一发再写解析代码,一分钟的实测能省掉整个错误分支的返工。
3.2 坑二:11g 的 Oracle,两个连环坑
第一个报错来得很快:
NJS-138: connections to this database server version are not supported
by node-oracledb in Thin mode
node-oracledb 6.x 的 Thin 模式(纯 JS 实现,免装客户端)只支持 Oracle 12.1+。这家医院的 HIS 库还是 11g。解决方法是切 Thick 模式 + Oracle Instant Client:
if (cfg.his.db.thickMode) {
oracledb.initOracleClient({
libDir: cfg.his.db.clientDir }); // 指向 Instant Client 目录
}
注意 Instant Client 的版本选择:选 19c(兼容 11.2+ 的数据库),不要选 21c/23ai——它们只支持 12.1+ 的库。这个包免费、免安装、解压即用,内网机器从外网机拷个压缩包过去就行。
刚松口气,第二个报错接踵而至:
ORA-00933: SQL command not properly ended
我模板里写的 FETCH FIRST 50 ROWS ONLY 也是 12c 才有的语法(和 Thin 模式的版本门槛刚好是同一道坎)。11g 时代限制行数要用子查询 + ROWNUM:
SELECT * FROM (
SELECT A.EXAM_NO AS SQDH
FROM EXAM_APPOINTS A
WHERE ... AND A.STATUS IS NULL
ORDER BY A.REQ_DATE_TIME ASC
) WHERE ROWNUM <= 50
两个坑连着踩说明一件事:对接老系统,先把"对方数据库版本"当成必问项。它同时决定了驱动模式和 SQL 方言。
3.3 坑三:自己写的双倍轮询 bug
上线试运行后,从日志里发现轮询间隔不对劲——配置的 10 秒,实际却是这样的节奏:
14:34:22.515 取到 1 条待确认记录
14:34:29.088 取到 1 条待确认记录 ← 只隔了 6.5 秒
14:34:32.645 取到 1 条待确认记录 ← 只隔了 3.5 秒
14:34:39.119 取到 1 条待确认记录
不是 7 秒,也不是随机。把时间戳排开看真相:22、32、42 一条链,29、39 一条链——两条各 10 秒间隔的轮询链在交错运行。
根因 4 行代码:
_schedule(delayMs) {
if (this.stopped) return;
this.timer = setTimeout(() => this._tick(), delayMs); // ← 没清旧 timer!
}
管理界面有个"立即轮询"按钮,点它会调 _schedule(0)。this.timer 被新定时器的引用覆盖,但旧的 10 秒定时器还挂在事件循环里——从点击那一刻起,两条链各自派生下一次调度,永久双倍轮询。修复只加了一行:
_schedule(delayMs) {
if (this.stopped) return;
if (this.timer) clearTimeout(this.timer); // 先清旧的,再挂新的
this.timer = setTimeout(() => this._tick(), delayMs);
}
这个 bug 能写出来,是因为"定时器句柄被覆盖"和"定时器被取消"在 JS 里是两回事;它没被更早发现,是因为双倍轮询不报错,只是白跑一倍量。日志时间戳是排查这类问题的唯一线索——所以日志要按行打时间戳,且精确到毫秒。
四、运维性:一个内网服务的基本尊严
这类服务上线后就是无人值守状态,我把运维面做成了"内置管理控制台"——服务自己托管一个 Web 页面,零前端依赖:

看板五张卡片分别对应确认成功/失败、作废成功/失败、异常待处理;最近处理记录可以看到每张单的结论和失败原因(code=-1 没有查询到此申请单记录——PACS 侧还没建档的单子,下轮自动重查);服务信息里能看到连接模式和接口地址,排查环境问题不用登服务器。
除看板外还有几件配套的小事,都来自"内网服务器没人管"这个现实:
日志双写 + 自动过期。 控制台一份,文件一份(按天切分)。文件日志加了自动清理:启动时清一次,之后每天跨天首条日志时再清一次,超过 LOG_KEEP_DAYS(默认 30 天)的自动删除,只匹配自己命名格式的文件。无人值守跑一年,logs 目录也就几十 MB。
SQL 模板在线编辑。 管理台可以直接改 sql/templates/ 的内容,保存后下一轮轮询生效——HIS 厂商改了视图字段,不用登服务器不用重启服务。
部署形态。 整个项目连同 node_modules 拷贝到内网机(依赖纯 JS 无需编译),PM2 或 NSSM 注册成 Windows 服务,崩溃自动拉起。带 --mock 参数启动就是本地模拟模式,不连任何真实系统。
五、复盘:集成项目的几条心法
回头看这个 2000 行左右的小项目,值得沉淀的大概是这几条:
先实测,再编码。 响应格式(XML)、鉴权方式(license 在 body 而不是 header)、错误码语义(HTTP 200 包业务失败)——这些文档里没写或写得含糊的东西,一次 curl 全部现形。集成项目的最大风险永远在对方系统的"未文档化行为"里。
把易变的部分外置。 SQL、字段映射、状态值约定全部配置化/模板化。这个项目里 HIS 侧约定改了三次,每次都是改个文件的事,核心代码没动。
幂等优先于重试。 带条件回写 + 可重复查询,让"失败后什么都不做"成为安全的默认选择。重试队列、异常状态、人工介入这些机制,只有在业务明确需要时才值得加。
无人值守的服务要有自己的"仪表盘"。 不是为了好看——内网服务器没人登,出问题时第一个知道的人可能是一线的检查技师。让"看一眼状态"的成本低到打开一个网页,是这类服务的自我修养。
最后,如果你也在做医院信息化集成,愿你的接口文档里写着响应格式,你的 Oracle 是 12c 以上,你的定时器记得先 clear。共勉。