first commit
This commit is contained in:
commit
7a9350adcf
3
.env.development
Normal file
3
.env.development
Normal file
@ -0,0 +1,3 @@
|
||||
CONFIG_NODE_PORT=8082
|
||||
CONFIG_MONGO_ENABLED=false
|
||||
CONFIG_DB_NAME=hospital_adapter
|
||||
18
.env.production.example
Normal file
18
.env.production.example
Normal file
@ -0,0 +1,18 @@
|
||||
CONFIG_NODE_PORT=8082
|
||||
CONFIG_ADAPTER_ROLE=standalone
|
||||
CONFIG_GATEWAY_URL=
|
||||
CONFIG_EDGE_ID=zpfb-edge-1
|
||||
CONFIG_EDGE_HOSPITAL_CODE=zpfb
|
||||
CONFIG_HOSPITAL_ADAPTER_CORP_MAP={}
|
||||
CONFIG_MONGO_ENABLED=false
|
||||
|
||||
CONFIG_ZPFB_SOURCE_TYPE=view
|
||||
CONFIG_ZPFB_DB_TYPE=oracle_sqlplus
|
||||
CONFIG_ZPFB_DB_HOST=172.16.1.116
|
||||
CONFIG_ZPFB_DB_PORT=1521
|
||||
CONFIG_ZPFB_DB_NAME=sknew
|
||||
CONFIG_ZPFB_DB_USER=inte
|
||||
CONFIG_ZPFB_DB_PASSWORD=
|
||||
CONFIG_ZPFB_DB_ORACLE_CONNECT_MODE=service
|
||||
CONFIG_ZPFB_DB_SQLPLUS_PATH=E:\oracle\product\10.2.0\client_1\BIN\sqlplus.exe
|
||||
CONFIG_ZPFB_DB_SQLPLUS_ENCODING=gbk
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
logs/
|
||||
tmp-*.log
|
||||
.env.production
|
||||
.env.local
|
||||
.env.*.local
|
||||
199
README.md
Normal file
199
README.md
Normal file
@ -0,0 +1,199 @@
|
||||
# Hospital Adapter Service
|
||||
|
||||
医院接口中转平台。第一版输出标准数据字段,不输出 YKT 字段。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Node.js
|
||||
- Express
|
||||
- CommonJS
|
||||
- dotenv
|
||||
- MongoDB
|
||||
- esbuild
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
cd hospital-adapter-service
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认端口: `8082`
|
||||
|
||||
前置机轻量部署可以不安装 Mongo,只安装 Node.js 和 PM2。Mongo 默认关闭,查询接口仍可直接访问医院视图。
|
||||
|
||||
## 运行角色
|
||||
|
||||
- `standalone`: 单机调试/院内直接部署,标准资源接口和 YKT 兼容接口都由本机查询 HIS。
|
||||
- `gateway`: 中心网关,给 `ytk-customer-service` 调用,并通过 WebSocket 转发到院内 Agent。
|
||||
- `edge`: 院内 Agent,主动连接中心网关,只负责访问院内 HIS。
|
||||
|
||||
关键配置:
|
||||
|
||||
```text
|
||||
CONFIG_ADAPTER_ROLE=standalone|gateway|edge
|
||||
CONFIG_GATEWAY_URL=http://中心网关地址
|
||||
CONFIG_EDGE_ID=zpfb-edge-1
|
||||
CONFIG_EDGE_HOSPITAL_CODE=zpfb
|
||||
CONFIG_HOSPITAL_ADAPTER_CORP_MAP={"corpId":"zpfb"}
|
||||
```
|
||||
|
||||
## Windows 前置机部署
|
||||
|
||||
本机生成单文件 bundle:
|
||||
|
||||
```cmd
|
||||
cd /d C:\code\yk\ykt\hospital-adapter-service
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建输出:
|
||||
|
||||
```text
|
||||
dist\bundle.js
|
||||
```
|
||||
|
||||
构建时会把本机 `.env.production` 的变量直接写进 bundle。部署到前置机时只需要复制 `bundle.js`,不需要复制源码目录、`node_modules`、`.env` 或启动脚本。
|
||||
|
||||
```cmd
|
||||
node bundle.js
|
||||
```
|
||||
|
||||
PM2 启动示例:
|
||||
|
||||
```cmd
|
||||
pm2 start bundle.js --name hospital-adapter-service
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `CONFIG_NODE_PORT`: 服务端口
|
||||
- `CONFIG_MONGO_ENABLED`: 是否启用 Mongo,默认 `false`
|
||||
- `CONFIG_MONGO_OPTIONAL`: Mongo 连接失败时是否允许服务继续启动,默认 `true`
|
||||
- `CONFIG_MONGO_URI`: Mongo 连接串
|
||||
- `CONFIG_DB_NAME`: 平台库名,默认 `hospital_adapter`
|
||||
- `CONFIG_DEFAULT_PAGE_SIZE`: 默认分页,默认 `20`
|
||||
- `CONFIG_MAX_PAGE_SIZE`: 最大分页,默认 `200`
|
||||
- `CONFIG_HOSPITAL_ADAPTER_TIMEOUT_MS`: 上游超时,默认 `15000`
|
||||
|
||||
每家医院可配置:
|
||||
|
||||
- `CONFIG_<HOSPITAL>_SOURCE_TYPE`: `mock | view | http | webservice`
|
||||
- `CONFIG_<HOSPITAL>_BASE_URL`: HTTP/WebService 上游地址
|
||||
- `CONFIG_<HOSPITAL>_DB_TYPE`: 视图数据库类型,支持 `mssql | oracle | oracle_sqlplus`
|
||||
- `CONFIG_<HOSPITAL>_DB_HOST`: 视图数据库地址
|
||||
- `CONFIG_<HOSPITAL>_DB_PORT`: 视图数据库端口,SQL Server 默认 `1433`
|
||||
- `CONFIG_<HOSPITAL>_DB_NAME`: 视图数据库名
|
||||
- `CONFIG_<HOSPITAL>_DB_USER`: 视图数据库用户名
|
||||
- `CONFIG_<HOSPITAL>_DB_PASSWORD`: 视图数据库密码
|
||||
- `CONFIG_<HOSPITAL>_<RESOURCE>_SOURCE_TYPE`: 单资源数据源覆盖
|
||||
- `CONFIG_<HOSPITAL>_<RESOURCE>_VIEW_NAME`: 单资源视图名覆盖
|
||||
- `CONFIG_<HOSPITAL>_<RESOURCE>_ENDPOINT`: 单资源接口路径覆盖
|
||||
- `CONFIG_<HOSPITAL>_<RESOURCE>_ORDER_BY`: view 分页排序字段
|
||||
- `CONFIG_<HOSPITAL>_<RESOURCE>_DATE_RANGE_FIELD`: `start_date/end_date` 对应的标准日期字段
|
||||
|
||||
## 查询接口
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8082/api/zpfb/patient/query" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id_num":"110101199001011234","page":1,"pageSize":20}'
|
||||
```
|
||||
|
||||
YKT HIS 兼容入口:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8082/api/ykt/customerHisSync" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"hospitalCode":"zpfb","type":"getHisCustomerArchive","idCard":"身份证号"}'
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "获取成功",
|
||||
"list": []
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "查询成功",
|
||||
"data": [],
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
## ytk-customer-service 接入
|
||||
|
||||
主服务保持旧 HIS 分支不变,新医院通过环境变量映射到本平台:
|
||||
|
||||
```bash
|
||||
CONFIG_HOSPITAL_ADAPTER_GATEWAY_URL=http://127.0.0.1:8082
|
||||
```
|
||||
|
||||
主服务收到 `customerHisSync` 后,如果未命中旧 HIS 分支,会转发到本平台的 `/api/ykt/customerHisSync`。本平台返回 YKT 兼容格式 `{ success, message, list }`。
|
||||
|
||||
## 资源
|
||||
|
||||
- `patient`
|
||||
- `outpatientRecord`
|
||||
- `orderInfo`
|
||||
- `pacsInfo`
|
||||
- `lisInfo`
|
||||
- `lisDetail`
|
||||
|
||||
## 通用 View 数据源
|
||||
|
||||
`view` 数据源用于快速接入医院提供的数据库视图。每家医院在 adapter 配置里声明资源启停、视图名、排序字段和字段映射;查询仍然通过 HTTP 接口进入本服务。
|
||||
|
||||
邹平妇保示例:
|
||||
|
||||
```bash
|
||||
CONFIG_ZPFB_SOURCE_TYPE=view
|
||||
CONFIG_ZPFB_DB_TYPE=oracle_sqlplus
|
||||
CONFIG_ZPFB_DB_HOST=172.16.1.116
|
||||
CONFIG_ZPFB_DB_PORT=1521
|
||||
CONFIG_ZPFB_DB_NAME=sknew
|
||||
CONFIG_ZPFB_DB_USER=inte
|
||||
CONFIG_ZPFB_DB_PASSWORD=<deploy-secret>
|
||||
CONFIG_ZPFB_DB_ORACLE_CONNECT_MODE=service
|
||||
CONFIG_ZPFB_DB_SQLPLUS_PATH=E:\oracle\product\10.2.0\client_1\BIN\sqlplus.exe
|
||||
CONFIG_ZPFB_DB_SQLPLUS_ENCODING=gbk
|
||||
```
|
||||
|
||||
前置机 CMD 临时启动示例仅用于未打包时排查。正常单文件部署时,相关变量应写入本机 `.env.production` 后重新 `npm run build`。
|
||||
|
||||
```cmd
|
||||
set CONFIG_NODE_PORT=8082
|
||||
set CONFIG_MONGO_ENABLED=false
|
||||
node index.js
|
||||
```
|
||||
|
||||
邹平第一版默认只启用:
|
||||
|
||||
- `patient` -> `v_youcan_patient`
|
||||
- `outpatientRecord` -> `v_youcan_outpatient_record`
|
||||
|
||||
如果医院视图字段不是标准字段,在对应 adapter 的 `fieldMapping` 中配置 `标准字段 -> 视图字段`。密码只放部署环境变量,不提交到代码。
|
||||
|
||||
## 同步标准快照
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8082/api/his/sync" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"hospitalCode":"zpfb","resource":"patient","maxPages":1,"pageSize":200}'
|
||||
```
|
||||
|
||||
标准快照写入平台 Mongo 的 `standard-snapshot`,不写入 YKT 的 `admin.member` 等主业务集合。
|
||||
|
||||
如果 `CONFIG_MONGO_ENABLED=false`,同步标准快照接口会返回 `MONGO_DISABLED`;普通查询接口不受影响。
|
||||
48
esbuild.config.js
Normal file
48
esbuild.config.js
Normal file
@ -0,0 +1,48 @@
|
||||
const { build } = require("esbuild");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
return fs.readFileSync(filePath, "utf8").split(/\r?\n/).reduce((acc, line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) return acc;
|
||||
const index = trimmed.indexOf("=");
|
||||
if (index <= 0) return acc;
|
||||
const key = trimmed.slice(0, index).trim();
|
||||
const value = trimmed.slice(index + 1).trim();
|
||||
if (key) acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function buildEmbeddedEnvBanner() {
|
||||
const env = loadEnvFile(path.resolve(__dirname, ".env.production"));
|
||||
const lines = Object.entries(env).map(([key, value]) => `process.env[${JSON.stringify(key)}]=${JSON.stringify(value)};`);
|
||||
if (!lines.length) return "";
|
||||
return `/* embedded .env.production */\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
build({
|
||||
entryPoints: ["./index.js"],
|
||||
outfile: "./dist/bundle.js",
|
||||
platform: "node",
|
||||
bundle: true,
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
target: ["node16", "node18", "node20"],
|
||||
banner: {
|
||||
js: buildEmbeddedEnvBanner(),
|
||||
},
|
||||
external: ["mysql2", "pg"],
|
||||
plugins: [
|
||||
{
|
||||
name: "bundle-oracledb-js",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^oracledb$/ }, () => ({
|
||||
path: path.resolve(__dirname, "node_modules", "oracledb", "index.js"),
|
||||
}));
|
||||
},
|
||||
},
|
||||
],
|
||||
}).catch(() => process.exit(1));
|
||||
70
index.js
Normal file
70
index.js
Normal file
@ -0,0 +1,70 @@
|
||||
const express = require("express");
|
||||
const http = require("http");
|
||||
const cors = require("cors");
|
||||
const bodyParser = require("body-parser");
|
||||
const { loadEnv, getConfig } = require("./src/core/config");
|
||||
const { success, fail } = require("./src/core/result");
|
||||
const { handleQuery, queryStandardResource } = require("./src/core/query-controller");
|
||||
const { handleSync } = require("./src/sync/sync-controller");
|
||||
const { connectMongo, ensureIndexes, isMongoAvailable, getMongoDisabledReason } = require("./src/mongo");
|
||||
const { handleYktCustomerHisSync } = require("./src/ykt/customer-his-sync");
|
||||
const { attachGateway, queryEdgeResource, listEdges } = require("./src/gateway/edge-registry");
|
||||
const { startEdgeClient } = require("./src/edge/client");
|
||||
|
||||
loadEnv();
|
||||
|
||||
const app = express();
|
||||
const config = getConfig();
|
||||
const server = http.createServer(app);
|
||||
|
||||
app.set("trust proxy", true);
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json({ limit: "8mb" }));
|
||||
app.use(bodyParser.urlencoded({ extended: true, limit: "8mb" }));
|
||||
app.use(bodyParser.text({ type: ["text/*", "application/xml", "*/xml", "application/soap+xml"], limit: "8mb" }));
|
||||
|
||||
app.get("/healthz", (req, res) => {
|
||||
res.json(
|
||||
success({
|
||||
data: {
|
||||
service: "hospital-adapter-service",
|
||||
status: "ok",
|
||||
role: config.role,
|
||||
mongo: isMongoAvailable() ? "connected" : "disabled",
|
||||
mongoMessage: isMongoAvailable() ? "" : getMongoDisabledReason(),
|
||||
edges: config.role === "gateway" ? listEdges() : undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/ykt/customerHisSync", async (req, res) => {
|
||||
const queryResource =
|
||||
config.role === "gateway"
|
||||
? queryEdgeResource
|
||||
: ({ hospitalCode, resource, query }) => queryStandardResource({ hospitalCode, resource, query, saveSnapshot: false }).then((result) => ({ status: "success", message: "查询成功", ...result }));
|
||||
return handleYktCustomerHisSync(req, res, { queryResource });
|
||||
});
|
||||
|
||||
app.post("/api/his/query", handleQuery);
|
||||
app.post("/api/:hospitalCode/:resource/query", handleQuery);
|
||||
app.post("/api/his/sync", handleSync);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json(fail("接口不存在", "NOT_FOUND"));
|
||||
});
|
||||
|
||||
async function start() {
|
||||
await connectMongo();
|
||||
await ensureIndexes();
|
||||
if (config.role === "gateway") attachGateway(server);
|
||||
server.listen(config.port, "0.0.0.0", () => {
|
||||
console.log(`[hospital-adapter-service] ${config.role} listening on :${config.port}`);
|
||||
if (config.role === "edge") startEdgeClient();
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error("[hospital-adapter-service] start failed", err);
|
||||
process.exit(1);
|
||||
});
|
||||
2757
package-lock.json
generated
Normal file
2757
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "hospital-adapter-service",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "node index.js",
|
||||
"pro": "cross-env NODE_ENV=production node index.js",
|
||||
"build": "node esbuild.config.js",
|
||||
"build:pro": "cross-env NODE_ENV=production node esbuild.config.js",
|
||||
"start": "node dist/bundle.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"body-parser": "^1.20.3",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"mongodb": "^6.10.0",
|
||||
"mssql": "^12.5.5",
|
||||
"oracledb": "^7.0.0",
|
||||
"ws": "^8.21.0",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.24.2"
|
||||
}
|
||||
}
|
||||
15
src/adapters/index.js
Normal file
15
src/adapters/index.js
Normal file
@ -0,0 +1,15 @@
|
||||
const zpfb = require("./zpfb");
|
||||
|
||||
const STATIC_ADAPTERS = [zpfb].reduce((acc, adapter) => {
|
||||
acc[adapter.hospitalCode] = adapter;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
async function getAdapter(hospitalCode) {
|
||||
return STATIC_ADAPTERS[hospitalCode] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAdapter,
|
||||
};
|
||||
|
||||
46
src/adapters/zpfb/index.js
Normal file
46
src/adapters/zpfb/index.js
Normal file
@ -0,0 +1,46 @@
|
||||
const { RESOURCE_DEFINITIONS } = require("../../contracts/resources");
|
||||
const mockData = require("./mock-data");
|
||||
|
||||
const hospitalCode = "zpfb";
|
||||
const envPrefix = "ZPFB";
|
||||
const enabledByDefault = new Set(["patient", "outpatientRecord"]);
|
||||
const viewDefaults = {
|
||||
patient: {
|
||||
defaultOrderBy: "update_time",
|
||||
dateRangeField: "create_date",
|
||||
fieldMapping: {},
|
||||
},
|
||||
outpatientRecord: {
|
||||
defaultOrderBy: "visit_time",
|
||||
dateRangeField: "visit_time",
|
||||
fieldMapping: {},
|
||||
},
|
||||
};
|
||||
|
||||
const resources = Object.entries(RESOURCE_DEFINITIONS).reduce((acc, [resource, definition]) => {
|
||||
const upperResource = resource.replace(/[A-Z]/g, (m) => `_${m}`).toUpperCase();
|
||||
const defaults = viewDefaults[resource] || {};
|
||||
acc[resource] = {
|
||||
enabled: parseBoolean(process.env[`CONFIG_${envPrefix}_${upperResource}_ENABLED`], enabledByDefault.has(resource)),
|
||||
sourceType: process.env[`CONFIG_${envPrefix}_${upperResource}_SOURCE_TYPE`] || process.env.CONFIG_ZPFB_SOURCE_TYPE || "mock",
|
||||
viewName: process.env[`CONFIG_${envPrefix}_${upperResource}_VIEW_NAME`] || definition.viewName,
|
||||
endpoint: process.env[`CONFIG_${envPrefix}_${upperResource}_ENDPOINT`] || definition.endpoint,
|
||||
defaultOrderBy: process.env[`CONFIG_${envPrefix}_${upperResource}_ORDER_BY`] || defaults.defaultOrderBy || definition.fields[0],
|
||||
dateRangeField: process.env[`CONFIG_${envPrefix}_${upperResource}_DATE_RANGE_FIELD`] || defaults.dateRangeField || "create_time",
|
||||
fieldMapping: defaults.fieldMapping || {},
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
function parseBoolean(value, defaultValue) {
|
||||
if (value === undefined || value === null || value === "") return defaultValue;
|
||||
return !["false", "0", "no", "off"].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hospitalCode,
|
||||
name: "邹平妇保",
|
||||
envPrefix,
|
||||
resources,
|
||||
mockData,
|
||||
};
|
||||
141
src/adapters/zpfb/mock-data.js
Normal file
141
src/adapters/zpfb/mock-data.js
Normal file
@ -0,0 +1,141 @@
|
||||
module.exports = {
|
||||
patient: [
|
||||
{
|
||||
patient_id: "P20230001",
|
||||
patient_no: "OP20230001",
|
||||
patient_name: "张三",
|
||||
id_type: "1",
|
||||
id_num: "110101199001011234",
|
||||
mobile_phone_no: "13912345678",
|
||||
sex: "1",
|
||||
dob: "1990-01-01",
|
||||
family_addr_area: "北京市朝阳区",
|
||||
family_addr_detail: "XX街道XX小区1号楼1单元101室",
|
||||
professional: "教师",
|
||||
employer: "XX中学",
|
||||
charge_type: "01",
|
||||
charge_type_name: "城镇职工基本医疗保险",
|
||||
allergy: "青霉素过敏",
|
||||
history: "高血压病史5年",
|
||||
create_date: "2018-05-10",
|
||||
create_time: "2018-05-10 09:00:00",
|
||||
update_time: "2026-06-15 09:00:00",
|
||||
},
|
||||
],
|
||||
outpatientRecord: [
|
||||
{
|
||||
patient_id: "P20230001",
|
||||
patient_no: "OP20230001",
|
||||
patient_name: "张三",
|
||||
visit_time: "2022-08-01 08:00:00",
|
||||
leave_time: "2022-08-01 11:00:00",
|
||||
visit_dept_code: "DEPT001",
|
||||
visit_dept_name: "内科",
|
||||
visit_doctor_code: "DOC001",
|
||||
visit_doctor_name: "李四",
|
||||
visit_service_code: "SER001",
|
||||
visit_service_name: "王五",
|
||||
diagnosis_ids: "E65.x02,E65.x03",
|
||||
diagnosis_names: "背部脂肪堆积,高血压",
|
||||
chief_complaint: "头晕、乏力3天",
|
||||
entrust: "注意休息,按时服药",
|
||||
create_time: "2022-08-01 08:00:00",
|
||||
update_time: "2022-08-01 11:00:00",
|
||||
},
|
||||
],
|
||||
orderInfo: [
|
||||
{
|
||||
order_no: "ORD202300001",
|
||||
patient_id: "P20230001",
|
||||
patient_no: "OP20230001",
|
||||
admission_id: "ADM202300001",
|
||||
dept_code: "DEPT001",
|
||||
ward_code: "WARD001",
|
||||
doctor_code: "DOC001",
|
||||
doctor_name: "李四",
|
||||
order_type: "OT001",
|
||||
order_type_name: "长期医嘱",
|
||||
order_code: "OC001",
|
||||
order_name: "降压治疗",
|
||||
drug_name: "硝苯地平缓释片",
|
||||
drug_country_code: "国药准字H10910052",
|
||||
frequency_code: "QD",
|
||||
dosage: 20,
|
||||
dosage_unit: "mg",
|
||||
route_code: "PO",
|
||||
route_name: "口服",
|
||||
order_status: "在用",
|
||||
enter_time: "2023-05-10 09:30:00",
|
||||
start_time: "2023-05-10 10:00:00",
|
||||
stop_time: "",
|
||||
description: "每日一次,早餐后服用",
|
||||
create_time: "2023-05-10 09:30:00",
|
||||
update_time: "2023-05-10 09:30:00",
|
||||
},
|
||||
],
|
||||
pacsInfo: [
|
||||
{
|
||||
report_id: "REP202300001",
|
||||
patient_id: "P20230001",
|
||||
patient_no: "OP20230001",
|
||||
admission_id: "ADM202300001",
|
||||
request_time: "2023-06-15 08:30:00",
|
||||
app_dept_code: "DEPT002",
|
||||
app_dept_name: "外科",
|
||||
app_ward_code: "WARD002",
|
||||
app_ward_name: "外科二病区",
|
||||
app_doctor_code: "DOC002",
|
||||
app_doctor_name: "赵六",
|
||||
report_name: "胸部CT检查报告",
|
||||
test_position: "胸部",
|
||||
test_position_code: "ACR001",
|
||||
test_result: "双肺纹理清晰,未见明显异常密度影。",
|
||||
test_diag: "胸部未见明显异常",
|
||||
test_link: "https://example.com/imaging/REP202300001",
|
||||
test_remarks: "建议每年进行一次常规体检",
|
||||
test_time: "2023-06-15 10:15:00",
|
||||
report_time: "2023-06-15 11:00:00",
|
||||
report_code: "DOC002",
|
||||
create_time: "2023-06-15 11:00:00",
|
||||
update_time: "2023-06-15 11:00:00",
|
||||
},
|
||||
],
|
||||
lisInfo: [
|
||||
{
|
||||
report_id: "LIS202300001",
|
||||
patient_id: "P20230001",
|
||||
patient_no: "OP20230001",
|
||||
admission_id: "ADM202300001",
|
||||
report_name: "血常规",
|
||||
report_time: "2024-06-10 10:00:00",
|
||||
report_code: "DOC015",
|
||||
request_time: "2024-06-10 08:15:00",
|
||||
app_doctor_code: "DOC009",
|
||||
app_doctor_name: "吴医生",
|
||||
remarks: "各项指标基本正常,未见明显异常",
|
||||
create_time: "2024-06-10 10:00:00",
|
||||
update_time: "2024-06-10 10:00:00",
|
||||
},
|
||||
],
|
||||
lisDetail: [
|
||||
{
|
||||
report_id: "LIS202300001",
|
||||
patient_id: "P20230001",
|
||||
patient_no: "OP20230001",
|
||||
admission_id: "ADM202300001",
|
||||
item_id: "HB",
|
||||
item_name: "血红蛋白",
|
||||
report_details: "130",
|
||||
abnormality_flag: "",
|
||||
critical_flag: "0",
|
||||
high_max_value: "160",
|
||||
low_max_value: "120",
|
||||
value_unit: "g/L",
|
||||
range_description: "120-160",
|
||||
remarks: "",
|
||||
create_time: "2024-06-10 10:00:00",
|
||||
update_time: "2024-06-10 10:00:00",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
168
src/contracts/resources.js
Normal file
168
src/contracts/resources.js
Normal file
@ -0,0 +1,168 @@
|
||||
const RESOURCE_DEFINITIONS = {
|
||||
patient: {
|
||||
viewName: "v_youcan_patient",
|
||||
endpoint: "/api/youcanPatient/query",
|
||||
businessKeyFields: ["patient_id", "patient_no", "id_num"],
|
||||
fields: [
|
||||
"patient_id",
|
||||
"patient_no",
|
||||
"patient_name",
|
||||
"id_type",
|
||||
"id_num",
|
||||
"mobile_phone_no",
|
||||
"sex",
|
||||
"dob",
|
||||
"family_addr_area",
|
||||
"family_addr_detail",
|
||||
"professional",
|
||||
"employer",
|
||||
"charge_type",
|
||||
"charge_type_name",
|
||||
"allergy",
|
||||
"history",
|
||||
"create_date",
|
||||
"create_time",
|
||||
"update_time",
|
||||
],
|
||||
},
|
||||
outpatientRecord: {
|
||||
viewName: "v_youcan_outpatient_record",
|
||||
endpoint: "/api/outpatientRecord/query",
|
||||
businessKeyFields: ["patient_id", "patient_no", "visit_time"],
|
||||
fields: [
|
||||
"patient_id",
|
||||
"patient_no",
|
||||
"patient_name",
|
||||
"visit_time",
|
||||
"leave_time",
|
||||
"visit_dept_code",
|
||||
"visit_dept_name",
|
||||
"visit_doctor_code",
|
||||
"visit_doctor_name",
|
||||
"visit_service_code",
|
||||
"visit_service_name",
|
||||
"diagnosis_ids",
|
||||
"diagnosis_names",
|
||||
"chief_complaint",
|
||||
"entrust",
|
||||
"create_time",
|
||||
"update_time",
|
||||
],
|
||||
},
|
||||
orderInfo: {
|
||||
viewName: "v_youcan_order_info",
|
||||
endpoint: "/api/orderInfo/query",
|
||||
businessKeyFields: ["order_no"],
|
||||
fields: [
|
||||
"order_no",
|
||||
"patient_id",
|
||||
"patient_no",
|
||||
"admission_id",
|
||||
"dept_code",
|
||||
"ward_code",
|
||||
"doctor_code",
|
||||
"doctor_name",
|
||||
"order_type",
|
||||
"order_type_name",
|
||||
"order_code",
|
||||
"order_name",
|
||||
"drug_name",
|
||||
"drug_country_code",
|
||||
"frequency_code",
|
||||
"dosage",
|
||||
"dosage_unit",
|
||||
"route_code",
|
||||
"route_name",
|
||||
"order_status",
|
||||
"enter_time",
|
||||
"start_time",
|
||||
"stop_time",
|
||||
"description",
|
||||
"create_time",
|
||||
"update_time",
|
||||
],
|
||||
},
|
||||
pacsInfo: {
|
||||
viewName: "v_youcan_pacs_info",
|
||||
endpoint: "/api/pacsInfo/query",
|
||||
businessKeyFields: ["report_id"],
|
||||
fields: [
|
||||
"report_id",
|
||||
"patient_id",
|
||||
"patient_no",
|
||||
"admission_id",
|
||||
"request_time",
|
||||
"app_dept_code",
|
||||
"app_dept_name",
|
||||
"app_ward_code",
|
||||
"app_ward_name",
|
||||
"app_doctor_code",
|
||||
"app_doctor_name",
|
||||
"report_name",
|
||||
"test_position",
|
||||
"test_position_code",
|
||||
"test_result",
|
||||
"test_diag",
|
||||
"test_link",
|
||||
"test_remarks",
|
||||
"test_time",
|
||||
"report_time",
|
||||
"report_code",
|
||||
"create_time",
|
||||
"update_time",
|
||||
],
|
||||
},
|
||||
lisInfo: {
|
||||
viewName: "v_youcan_lis_info",
|
||||
endpoint: "/api/lisInfo/query",
|
||||
businessKeyFields: ["report_id"],
|
||||
fields: [
|
||||
"report_id",
|
||||
"patient_id",
|
||||
"patient_no",
|
||||
"admission_id",
|
||||
"report_name",
|
||||
"report_time",
|
||||
"report_code",
|
||||
"request_time",
|
||||
"app_doctor_code",
|
||||
"app_doctor_name",
|
||||
"remarks",
|
||||
"create_time",
|
||||
"update_time",
|
||||
],
|
||||
},
|
||||
lisDetail: {
|
||||
viewName: "v_youcan_lis_detail",
|
||||
endpoint: "/api/lisDetail/query",
|
||||
businessKeyFields: ["report_id", "item_id"],
|
||||
fields: [
|
||||
"report_id",
|
||||
"patient_id",
|
||||
"patient_no",
|
||||
"admission_id",
|
||||
"item_id",
|
||||
"item_name",
|
||||
"report_details",
|
||||
"abnormality_flag",
|
||||
"critical_flag",
|
||||
"high_max_value",
|
||||
"low_max_value",
|
||||
"value_unit",
|
||||
"range_description",
|
||||
"remarks",
|
||||
"create_time",
|
||||
"update_time",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function getResourceDefinition(resource) {
|
||||
return RESOURCE_DEFINITIONS[resource] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESOURCE_DEFINITIONS,
|
||||
getResourceDefinition,
|
||||
};
|
||||
|
||||
80
src/core/config.js
Normal file
80
src/core/config.js
Normal file
@ -0,0 +1,80 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
function loadEnv() {
|
||||
const nodeEnv = process.env.NODE_ENV || "development";
|
||||
const envFileName = `.env.${nodeEnv}`;
|
||||
const entryFile = process.argv && process.argv[1] ? String(process.argv[1]) : "";
|
||||
const entryDir = entryFile ? path.dirname(entryFile) : process.cwd();
|
||||
const inferredProjectRoot = path.resolve(entryDir, "..");
|
||||
const candidates = [
|
||||
path.resolve(process.cwd(), envFileName),
|
||||
path.resolve(__dirname, "..", "..", envFileName),
|
||||
path.resolve(inferredProjectRoot, envFileName),
|
||||
];
|
||||
const picked = candidates.find((p) => {
|
||||
try {
|
||||
return fs.existsSync(p);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
require("dotenv").config({ path: picked || envFileName });
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback) {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
return !["false", "0", "no", "off"].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
return {
|
||||
port: process.env.PORT || process.env.CONFIG_NODE_PORT || 8082,
|
||||
role: String(process.env.CONFIG_ADAPTER_ROLE || "standalone").trim().toLowerCase(),
|
||||
gatewayUrl: process.env.CONFIG_GATEWAY_URL || "",
|
||||
edgeId: process.env.CONFIG_EDGE_ID || "default-edge",
|
||||
edgeHospitalCode: process.env.CONFIG_EDGE_HOSPITAL_CODE || process.env.CONFIG_HOSPITAL_CODE || "zpfb",
|
||||
edgeReconnectMs: parsePositiveInteger(process.env.CONFIG_EDGE_RECONNECT_MS, 5000),
|
||||
rpcTimeoutMs: parsePositiveInteger(process.env.CONFIG_RPC_TIMEOUT_MS, 30000),
|
||||
corpMap: parseMap(process.env.CONFIG_HOSPITAL_ADAPTER_CORP_MAP || process.env.CONFIG_CORP_HOSPITAL_MAP || ""),
|
||||
mongoEnabled: parseBoolean(process.env.CONFIG_MONGO_ENABLED, false),
|
||||
mongoOptional: parseBoolean(process.env.CONFIG_MONGO_OPTIONAL, true),
|
||||
mongoUri: process.env.CONFIG_MONGO_URI || "",
|
||||
dbName: process.env.CONFIG_DB_NAME || "hospital_adapter",
|
||||
dbHost: process.env.CONFIG_DB_HOST || "127.0.0.1",
|
||||
dbPort: process.env.CONFIG_DB_PORT || "27017",
|
||||
dbUsername: process.env.CONFIG_DB_USERNAME || "",
|
||||
dbPassword: process.env.CONFIG_DB_PASSWORD || "",
|
||||
defaultPageSize: parsePositiveInteger(process.env.CONFIG_DEFAULT_PAGE_SIZE, 20),
|
||||
maxPageSize: parsePositiveInteger(process.env.CONFIG_MAX_PAGE_SIZE, 200),
|
||||
timeoutMs: parsePositiveInteger(process.env.CONFIG_HOSPITAL_ADAPTER_TIMEOUT_MS, 15000),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMap(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return text.split(",").reduce((acc, item) => {
|
||||
const [key, mappedValue] = item.split(":").map((part) => String(part || "").trim());
|
||||
if (key && mappedValue) acc[key] = mappedValue;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadEnv,
|
||||
getConfig,
|
||||
parsePositiveInteger,
|
||||
parseBoolean,
|
||||
parseMap,
|
||||
};
|
||||
38
src/core/privacy.js
Normal file
38
src/core/privacy.js
Normal file
@ -0,0 +1,38 @@
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
"id_num",
|
||||
"idCard",
|
||||
"mobile",
|
||||
"mobile_phone_no",
|
||||
"patient_id",
|
||||
"patient_no",
|
||||
"registration_no",
|
||||
"customerNumber",
|
||||
]);
|
||||
|
||||
function maskValue(value) {
|
||||
if (value === undefined || value === null) return "";
|
||||
const text = String(value);
|
||||
if (text.length <= 4) return "***";
|
||||
return `${text.slice(0, 2)}***${text.slice(-2)}`;
|
||||
}
|
||||
|
||||
function sanitizeObject(input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) return input;
|
||||
const output = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
output[key] = maskValue(value);
|
||||
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
output[key] = sanitizeObject(value);
|
||||
} else {
|
||||
output[key] = value;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
maskValue,
|
||||
sanitizeObject,
|
||||
};
|
||||
|
||||
90
src/core/query-controller.js
Normal file
90
src/core/query-controller.js
Normal file
@ -0,0 +1,90 @@
|
||||
const { getAdapter } = require("../adapters");
|
||||
const { getResourceDefinition } = require("../contracts/resources");
|
||||
const { getConfig, parsePositiveInteger } = require("./config");
|
||||
const { success, fail } = require("./result");
|
||||
const { getFieldMapping, writeRequestLog, upsertSnapshots } = require("./repositories");
|
||||
const { normalizeRows } = require("../mappers/standard");
|
||||
const { querySource } = require("../sources");
|
||||
|
||||
async function handleQuery(req, res) {
|
||||
const startedAt = Date.now();
|
||||
const hospitalCode = req.params.hospitalCode || req.body.hospitalCode;
|
||||
const resource = req.params.resource || req.body.resource;
|
||||
const query = req.params.hospitalCode ? req.body || {} : req.body.query || {};
|
||||
const saveSnapshot = req.body && req.body.saveSnapshot === true;
|
||||
|
||||
try {
|
||||
const result = await queryStandardResource({ hospitalCode, resource, query, saveSnapshot });
|
||||
await writeRequestLog({
|
||||
hospitalCode,
|
||||
resource,
|
||||
status: "success",
|
||||
duration: Date.now() - startedAt,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
query,
|
||||
});
|
||||
return res.json(success(result));
|
||||
} catch (err) {
|
||||
await safeWriteErrorLog({ hospitalCode, resource, query, err, startedAt });
|
||||
return res.json(fail(err.message || "查询失败", err.code || "QUERY_FAILED"));
|
||||
}
|
||||
}
|
||||
|
||||
async function queryStandardResource({ hospitalCode, resource, query = {}, saveSnapshot = false }) {
|
||||
if (!hospitalCode) throwError("缺少参数: hospitalCode", "INVALID_PARAM");
|
||||
if (!resource) throwError("缺少参数: resource", "INVALID_PARAM");
|
||||
|
||||
const adapter = await getAdapter(hospitalCode);
|
||||
if (!adapter) throwError("未知医院编码", "HOSPITAL_NOT_FOUND");
|
||||
|
||||
const definition = getResourceDefinition(resource);
|
||||
if (!definition) throwError("未知标准资源", "RESOURCE_NOT_FOUND");
|
||||
|
||||
const resourceConfig = adapter.resources && adapter.resources[resource];
|
||||
if (!resourceConfig || resourceConfig.enabled === false) throwError("医院未启用该资源", "RESOURCE_DISABLED");
|
||||
|
||||
const config = getConfig();
|
||||
const page = parsePositiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(parsePositiveInteger(query.pageSize, config.defaultPageSize), config.maxPageSize);
|
||||
const mapping = await getFieldMapping(hospitalCode, resource);
|
||||
const sourceResult = await querySource({ adapter, resource, resourceConfig, definition, query: { ...query, page, pageSize } });
|
||||
const rows = normalizeRows(sourceResult.rows || [], definition, mapping);
|
||||
const pagedRows = sourceResult.alreadyPaged === false ? rows.slice((page - 1) * pageSize, page * pageSize) : rows;
|
||||
const total = Number(sourceResult.total || rows.length) || rows.length;
|
||||
|
||||
if (saveSnapshot) await upsertSnapshots({ hospitalCode, resource, rows: pagedRows, definition });
|
||||
|
||||
return {
|
||||
data: pagedRows,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async function safeWriteErrorLog({ hospitalCode, resource, query, err, startedAt }) {
|
||||
try {
|
||||
await writeRequestLog({
|
||||
hospitalCode,
|
||||
resource,
|
||||
status: "fail",
|
||||
errorCode: err && err.code,
|
||||
errorMessage: err && err.message,
|
||||
duration: Date.now() - startedAt,
|
||||
query,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function throwError(message, code) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
throw err;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleQuery,
|
||||
queryStandardResource,
|
||||
};
|
||||
59
src/core/repositories.js
Normal file
59
src/core/repositories.js
Normal file
@ -0,0 +1,59 @@
|
||||
const crypto = require("crypto");
|
||||
const { getDb, isMongoAvailable } = require("../mongo");
|
||||
const { sanitizeObject } = require("./privacy");
|
||||
|
||||
async function getFieldMapping(hospitalCode, resource) {
|
||||
if (!isMongoAvailable()) return [];
|
||||
const rows = await getDb().collection("field-mapping").find({ hospitalCode, resource, enabled: { $ne: false } }).toArray();
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function writeRequestLog(log) {
|
||||
if (!isMongoAvailable()) return;
|
||||
await getDb().collection("request-log").insertOne({
|
||||
...log,
|
||||
query: sanitizeObject(log.query || {}),
|
||||
createTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertSnapshots({ hospitalCode, resource, rows, definition }) {
|
||||
if (!isMongoAvailable()) return { upserted: 0 };
|
||||
if (!Array.isArray(rows) || !rows.length) return;
|
||||
const collection = getDb().collection("standard-snapshot");
|
||||
const operations = rows.map((row) => {
|
||||
const businessKey = buildBusinessKey(row, definition.businessKeyFields);
|
||||
return {
|
||||
updateOne: {
|
||||
filter: { hospitalCode, resource, businessKey },
|
||||
update: {
|
||||
$set: {
|
||||
hospitalCode,
|
||||
resource,
|
||||
businessKey,
|
||||
standardData: row,
|
||||
rawDigest: digest(row),
|
||||
syncedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
upsert: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
return collection.bulkWrite(operations, { ordered: false });
|
||||
}
|
||||
|
||||
function buildBusinessKey(row, fields) {
|
||||
const values = fields.map((field) => String(row[field] || "").trim()).filter(Boolean);
|
||||
return values.length ? values.join("||") : digest(row);
|
||||
}
|
||||
|
||||
function digest(value) {
|
||||
return crypto.createHash("sha1").update(JSON.stringify(value || {})).digest("hex");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getFieldMapping,
|
||||
writeRequestLog,
|
||||
upsertSnapshots,
|
||||
};
|
||||
26
src/core/result.js
Normal file
26
src/core/result.js
Normal file
@ -0,0 +1,26 @@
|
||||
function success({ message = "查询成功", data = [], page = 1, pageSize = 20, total = 0 } = {}) {
|
||||
return {
|
||||
status: "success",
|
||||
message,
|
||||
data,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
function fail(message = "请求失败", code = "REQUEST_FAILED", extra = {}) {
|
||||
return {
|
||||
status: "fail",
|
||||
message,
|
||||
code,
|
||||
data: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
success,
|
||||
fail,
|
||||
};
|
||||
|
||||
88
src/edge/client.js
Normal file
88
src/edge/client.js
Normal file
@ -0,0 +1,88 @@
|
||||
const WebSocket = require("ws");
|
||||
const { getConfig } = require("../core/config");
|
||||
const { queryStandardResource } = require("../core/query-controller");
|
||||
|
||||
let socket = null;
|
||||
let reconnectTimer = null;
|
||||
|
||||
function startEdgeClient() {
|
||||
const config = getConfig();
|
||||
if (!config.gatewayUrl) {
|
||||
console.warn("[hospital-adapter-service] edge role missing CONFIG_GATEWAY_URL");
|
||||
return;
|
||||
}
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const config = getConfig();
|
||||
const wsUrl = buildWsUrl(config.gatewayUrl);
|
||||
socket = new WebSocket(wsUrl);
|
||||
|
||||
socket.on("open", () => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "register",
|
||||
edgeId: config.edgeId,
|
||||
hospitalCode: config.edgeHospitalCode,
|
||||
capabilities: ["queryStandardResource"],
|
||||
})
|
||||
);
|
||||
});
|
||||
socket.on("message", (raw) => handleMessage(raw));
|
||||
socket.on("close", scheduleReconnect);
|
||||
socket.on("error", scheduleReconnect);
|
||||
}
|
||||
|
||||
async function handleMessage(raw) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(String(raw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.type === "request" && message.payload) {
|
||||
const request = message.payload;
|
||||
const response = { type: "response", requestId: request.requestId, payload: null };
|
||||
try {
|
||||
if (request.action !== "queryStandardResource") throwEdgeError("不支持的 Agent 动作", "EDGE_ACTION_UNSUPPORTED");
|
||||
const result = await queryStandardResource({
|
||||
hospitalCode: request.hospitalCode,
|
||||
resource: request.resource,
|
||||
query: request.query || {},
|
||||
saveSnapshot: false,
|
||||
});
|
||||
response.payload = { status: "success", message: "查询成功", ...result };
|
||||
} catch (err) {
|
||||
response.payload = { status: "fail", message: err && err.message ? err.message : "Agent 查询失败", code: err && err.code, data: [] };
|
||||
}
|
||||
if (socket && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
const delay = getConfig().edgeReconnectMs;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function buildWsUrl(gatewayUrl) {
|
||||
const base = String(gatewayUrl || "").replace(/\/$/, "");
|
||||
if (base.startsWith("ws://") || base.startsWith("wss://")) return `${base}/api/edge/ws`;
|
||||
if (base.startsWith("https://")) return `wss://${base.slice("https://".length)}/api/edge/ws`;
|
||||
if (base.startsWith("http://")) return `ws://${base.slice("http://".length)}/api/edge/ws`;
|
||||
return `ws://${base}/api/edge/ws`;
|
||||
}
|
||||
|
||||
function throwEdgeError(message, code) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
throw err;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startEdgeClient,
|
||||
};
|
||||
99
src/gateway/edge-registry.js
Normal file
99
src/gateway/edge-registry.js
Normal file
@ -0,0 +1,99 @@
|
||||
const crypto = require("crypto");
|
||||
const WebSocket = require("ws");
|
||||
const { getConfig } = require("../core/config");
|
||||
|
||||
const edges = new Map();
|
||||
const pending = new Map();
|
||||
|
||||
function attachGateway(server) {
|
||||
const wss = new WebSocket.Server({ server, path: "/api/edge/ws" });
|
||||
wss.on("connection", (ws) => {
|
||||
const state = { edgeId: "", hospitalCode: "", capabilities: [] };
|
||||
ws.on("message", (raw) => handleMessage(ws, state, raw));
|
||||
ws.on("close", () => unregisterEdge(state, ws));
|
||||
ws.on("error", () => unregisterEdge(state, ws));
|
||||
});
|
||||
return wss;
|
||||
}
|
||||
|
||||
function handleMessage(ws, state, raw) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(String(raw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.type === "register") {
|
||||
state.edgeId = String(message.edgeId || "").trim();
|
||||
state.hospitalCode = String(message.hospitalCode || "").trim();
|
||||
state.capabilities = Array.isArray(message.capabilities) ? message.capabilities : [];
|
||||
if (!state.edgeId || !state.hospitalCode) return;
|
||||
edges.set(state.hospitalCode, { ws, ...state, lastSeen: Date.now() });
|
||||
ws.send(JSON.stringify({ type: "registered", edgeId: state.edgeId, hospitalCode: state.hospitalCode }));
|
||||
return;
|
||||
}
|
||||
if (message.type === "pong" && state.hospitalCode && edges.has(state.hospitalCode)) {
|
||||
edges.get(state.hospitalCode).lastSeen = Date.now();
|
||||
return;
|
||||
}
|
||||
if (message.type === "response" && message.requestId && pending.has(message.requestId)) {
|
||||
const item = pending.get(message.requestId);
|
||||
clearTimeout(item.timer);
|
||||
pending.delete(message.requestId);
|
||||
item.resolve(message.payload);
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterEdge(state, ws) {
|
||||
if (!state.hospitalCode) return;
|
||||
const registered = edges.get(state.hospitalCode);
|
||||
if (registered && registered.ws === ws) edges.delete(state.hospitalCode);
|
||||
}
|
||||
|
||||
async function queryEdgeResource({ hospitalCode, resource, query }) {
|
||||
const edge = edges.get(hospitalCode);
|
||||
if (!edge || edge.ws.readyState !== WebSocket.OPEN) throwGatewayError("医院 Agent 离线", "EDGE_OFFLINE");
|
||||
const requestId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}${Math.random()}`;
|
||||
const payload = { requestId, action: "queryStandardResource", hospitalCode, resource, query };
|
||||
const timeoutMs = getConfig().rpcTimeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(requestId);
|
||||
rejectGateway(reject, `医院 Agent 响应超时(${timeoutMs}ms)`, "EDGE_TIMEOUT");
|
||||
}, timeoutMs);
|
||||
pending.set(requestId, { resolve, reject, timer });
|
||||
edge.ws.send(JSON.stringify({ type: "request", payload }), (err) => {
|
||||
if (!err) return;
|
||||
clearTimeout(timer);
|
||||
pending.delete(requestId);
|
||||
rejectGateway(reject, err.message || "发送 Agent 请求失败", "EDGE_SEND_FAILED");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function listEdges() {
|
||||
return Array.from(edges.values()).map((edge) => ({
|
||||
edgeId: edge.edgeId,
|
||||
hospitalCode: edge.hospitalCode,
|
||||
capabilities: edge.capabilities,
|
||||
lastSeen: edge.lastSeen,
|
||||
}));
|
||||
}
|
||||
|
||||
function throwGatewayError(message, code) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
throw err;
|
||||
}
|
||||
|
||||
function rejectGateway(reject, message, code) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
reject(err);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attachGateway,
|
||||
queryEdgeResource,
|
||||
listEdges,
|
||||
};
|
||||
44
src/mappers/standard.js
Normal file
44
src/mappers/standard.js
Normal file
@ -0,0 +1,44 @@
|
||||
function normalizeRows(rows, definition, mapping = []) {
|
||||
const fieldMap = buildFieldMap(mapping);
|
||||
return rows.map((row) => normalizeRow(row, definition.fields, fieldMap));
|
||||
}
|
||||
|
||||
function buildFieldMap(mapping) {
|
||||
return mapping.reduce((acc, item) => {
|
||||
if (item && item.sourceField && item.standardField) {
|
||||
acc[item.standardField] = item.sourceField;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function normalizeRow(row, fields, fieldMap) {
|
||||
const output = {};
|
||||
const caseInsensitiveRow = buildCaseInsensitiveRow(row);
|
||||
for (const field of fields) {
|
||||
const sourceField = fieldMap[field] || field;
|
||||
output[field] = normalizeValue(readValue(row, caseInsensitiveRow, sourceField));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildCaseInsensitiveRow(row) {
|
||||
return Object.entries(row || {}).reduce((acc, [key, value]) => {
|
||||
acc[String(key).toLowerCase()] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readValue(row, caseInsensitiveRow, field) {
|
||||
if (Object.prototype.hasOwnProperty.call(row || {}, field)) return row[field];
|
||||
return caseInsensitiveRow[String(field || "").toLowerCase()];
|
||||
}
|
||||
|
||||
function normalizeValue(value) {
|
||||
if (value === undefined || value === null) return "";
|
||||
return value;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeRows,
|
||||
};
|
||||
68
src/mongo/index.js
Normal file
68
src/mongo/index.js
Normal file
@ -0,0 +1,68 @@
|
||||
const { MongoClient } = require("mongodb");
|
||||
const { getConfig } = require("../core/config");
|
||||
|
||||
let client = null;
|
||||
let db = null;
|
||||
let disabledReason = "";
|
||||
|
||||
function buildMongoUri(config) {
|
||||
if (config.mongoUri) return config.mongoUri;
|
||||
const auth =
|
||||
config.dbUsername && config.dbPassword
|
||||
? `${encodeURIComponent(config.dbUsername)}:${encodeURIComponent(config.dbPassword)}@`
|
||||
: "";
|
||||
return `mongodb://${auth}${config.dbHost}:${config.dbPort}/${config.dbName}`;
|
||||
}
|
||||
|
||||
async function connectMongo() {
|
||||
if (db) return db;
|
||||
const config = getConfig();
|
||||
if (!config.mongoEnabled) {
|
||||
disabledReason = "Mongo 未启用";
|
||||
return null;
|
||||
}
|
||||
client = new MongoClient(buildMongoUri(config));
|
||||
try {
|
||||
await client.connect();
|
||||
db = client.db(config.dbName);
|
||||
disabledReason = "";
|
||||
return db;
|
||||
} catch (err) {
|
||||
if (!config.mongoOptional) throw err;
|
||||
disabledReason = err && err.message ? err.message : "Mongo 连接失败";
|
||||
console.warn(`[hospital-adapter-service] Mongo unavailable, optional features disabled: ${disabledReason}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
if (!db) throw new Error("MongoDB 尚未连接");
|
||||
return db;
|
||||
}
|
||||
|
||||
async function ensureIndexes() {
|
||||
const database = await connectMongo();
|
||||
if (!database) return;
|
||||
await database.collection("hospital-config").createIndex({ hospitalCode: 1 }, { unique: true, background: true });
|
||||
await database.collection("resource-config").createIndex({ hospitalCode: 1, resource: 1 }, { unique: true, background: true });
|
||||
await database.collection("field-mapping").createIndex({ hospitalCode: 1, resource: 1 }, { background: true });
|
||||
await database.collection("request-log").createIndex({ hospitalCode: 1, resource: 1, createTime: -1 }, { background: true });
|
||||
await database.collection("standard-snapshot").createIndex({ hospitalCode: 1, resource: 1, businessKey: 1 }, { unique: true, background: true });
|
||||
await database.collection("standard-snapshot").createIndex({ hospitalCode: 1, resource: 1, syncedAt: -1 }, { background: true });
|
||||
}
|
||||
|
||||
function isMongoAvailable() {
|
||||
return Boolean(db);
|
||||
}
|
||||
|
||||
function getMongoDisabledReason() {
|
||||
return disabledReason || "MongoDB 尚未连接";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
connectMongo,
|
||||
getDb,
|
||||
ensureIndexes,
|
||||
isMongoAvailable,
|
||||
getMongoDisabledReason,
|
||||
};
|
||||
39
src/sources/http.js
Normal file
39
src/sources/http.js
Normal file
@ -0,0 +1,39 @@
|
||||
const axios = require("axios");
|
||||
const { getConfig } = require("../core/config");
|
||||
|
||||
async function queryHttp({ adapter, resourceConfig, query }) {
|
||||
const prefix = adapter.envPrefix || adapter.hospitalCode.toUpperCase();
|
||||
const baseUrl = process.env[`CONFIG_${prefix}_BASE_URL`];
|
||||
if (!baseUrl) {
|
||||
const err = new Error(`缺少 HTTP 数据源配置: CONFIG_${prefix}_BASE_URL`);
|
||||
err.code = "HTTP_SOURCE_CONFIG_INVALID";
|
||||
throw err;
|
||||
}
|
||||
const url = `${baseUrl.replace(/\/$/, "")}${resourceConfig.endpoint}`;
|
||||
const response = await axios.post(url, query, {
|
||||
timeout: getConfig().timeoutMs,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return unwrapRows(response.data);
|
||||
}
|
||||
|
||||
function unwrapRows(payload) {
|
||||
if (Array.isArray(payload)) return { rows: payload, total: payload.length, alreadyPaged: true };
|
||||
if (!payload || typeof payload !== "object") return { rows: [], total: 0, alreadyPaged: true };
|
||||
const rows = Array.isArray(payload.data)
|
||||
? payload.data
|
||||
: Array.isArray(payload.list)
|
||||
? payload.list
|
||||
: Array.isArray(payload.rows)
|
||||
? payload.rows
|
||||
: [];
|
||||
return {
|
||||
rows,
|
||||
total: Number(payload.total || payload.count || rows.length) || rows.length,
|
||||
alreadyPaged: true,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queryHttp,
|
||||
};
|
||||
20
src/sources/index.js
Normal file
20
src/sources/index.js
Normal file
@ -0,0 +1,20 @@
|
||||
const { queryMock } = require("./mock");
|
||||
const { queryHttp } = require("./http");
|
||||
const { queryWebservice } = require("./webservice");
|
||||
const { queryView } = require("./view");
|
||||
|
||||
async function querySource(options) {
|
||||
const sourceType = options.resourceConfig.sourceType;
|
||||
if (sourceType === "mock") return queryMock(options);
|
||||
if (sourceType === "http") return queryHttp(options);
|
||||
if (sourceType === "webservice") return queryWebservice(options);
|
||||
if (sourceType === "view") return queryView(options);
|
||||
const err = new Error(`不支持的数据源类型: ${sourceType}`);
|
||||
err.code = "SOURCE_TYPE_UNSUPPORTED";
|
||||
throw err;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
querySource,
|
||||
};
|
||||
|
||||
31
src/sources/mock.js
Normal file
31
src/sources/mock.js
Normal file
@ -0,0 +1,31 @@
|
||||
function queryMock({ adapter, resource, query }) {
|
||||
const rows = adapter.mockData && Array.isArray(adapter.mockData[resource]) ? adapter.mockData[resource] : [];
|
||||
const filteredRows = filterRows(rows, query);
|
||||
const page = toPositiveInteger(query && query.page, 1);
|
||||
const pageSize = toPositiveInteger(query && query.pageSize, 20);
|
||||
return {
|
||||
rows: filteredRows.slice((page - 1) * pageSize, page * pageSize),
|
||||
total: filteredRows.length,
|
||||
alreadyPaged: true,
|
||||
};
|
||||
}
|
||||
|
||||
function filterRows(rows, query) {
|
||||
const filters = Object.entries(query || {}).filter(([key, value]) => {
|
||||
if (["page", "pageSize", "start_date", "end_date", "startCreateTime", "endCreateTime", "startUpdateTime", "endUpdateTime"].includes(key)) return false;
|
||||
return value !== undefined && value !== null && String(value).trim() !== "";
|
||||
});
|
||||
if (!filters.length) return rows;
|
||||
return rows.filter((row) => {
|
||||
return filters.every(([key, value]) => String(row[key] || "").includes(String(value).trim()));
|
||||
});
|
||||
}
|
||||
|
||||
function toPositiveInteger(value, defaultValue) {
|
||||
const numberValue = Number(value);
|
||||
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : defaultValue;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queryMock,
|
||||
};
|
||||
26
src/sources/view.js
Normal file
26
src/sources/view.js
Normal file
@ -0,0 +1,26 @@
|
||||
const { querySqlServerView } = require("./view/mssql");
|
||||
const { queryOracleView } = require("./view/oracle");
|
||||
const { queryOracleSqlplusView } = require("./view/oracle-sqlplus");
|
||||
|
||||
async function queryView({ adapter, resource, resourceConfig, definition, query }) {
|
||||
if (typeof adapter.queryView === "function") {
|
||||
return adapter.queryView({ resource, resourceConfig, definition, query });
|
||||
}
|
||||
const dbType = String(process.env[`CONFIG_${adapter.envPrefix || adapter.hospitalCode.toUpperCase()}_DB_TYPE`] || resourceConfig.dbType || "mssql").trim().toLowerCase();
|
||||
if (["mssql", "sqlserver", "sql_server"].includes(dbType)) {
|
||||
return querySqlServerView({ adapter, resource, resourceConfig, definition, query });
|
||||
}
|
||||
if (["oracle", "oracledb"].includes(dbType)) {
|
||||
return queryOracleView({ adapter, resource, resourceConfig, definition, query });
|
||||
}
|
||||
if (["oracle_sqlplus", "sqlplus", "oracle_legacy"].includes(dbType)) {
|
||||
return queryOracleSqlplusView({ adapter, resource, resourceConfig, definition, query });
|
||||
}
|
||||
const err = new Error(`暂不支持的视图数据库类型: ${dbType}`);
|
||||
err.code = "VIEW_DB_TYPE_UNSUPPORTED";
|
||||
throw err;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queryView,
|
||||
};
|
||||
203
src/sources/view/mssql.js
Normal file
203
src/sources/view/mssql.js
Normal file
@ -0,0 +1,203 @@
|
||||
const sql = require("mssql");
|
||||
const { getConfig, parsePositiveInteger } = require("../../core/config");
|
||||
|
||||
const pools = new Map();
|
||||
const QUERY_META_KEYS = new Set(["page", "pageSize", "saveSnapshot"]);
|
||||
const RANGE_FILTERS = {
|
||||
startCreateTime: { field: "create_time", op: ">=" },
|
||||
endCreateTime: { field: "create_time", op: "<=" },
|
||||
startUpdateTime: { field: "update_time", op: ">=" },
|
||||
endUpdateTime: { field: "update_time", op: "<=" },
|
||||
start_date: { fromConfig: "dateRangeField", op: ">=" },
|
||||
end_date: { fromConfig: "dateRangeField", op: "<=" },
|
||||
};
|
||||
|
||||
async function querySqlServerView({ adapter, resourceConfig, definition, query }) {
|
||||
const prefix = adapter.envPrefix || adapter.hospitalCode.toUpperCase();
|
||||
const connectionConfig = getConnectionConfig(prefix);
|
||||
const viewName = assertIdentifier(resourceConfig.viewName, "viewName");
|
||||
const fields = Array.isArray(definition && definition.fields) ? definition.fields : [];
|
||||
if (!fields.length) throwDriverError("标准资源未定义字段", "VIEW_DEFINITION_INVALID");
|
||||
|
||||
const fieldMapping = normalizeFieldMapping(resourceConfig.fieldMapping);
|
||||
const fieldSpecs = fields.map((standardField) => ({
|
||||
standardField,
|
||||
sourceField: assertIdentifier(fieldMapping[standardField] || standardField, `fieldMapping.${standardField}`),
|
||||
}));
|
||||
|
||||
const page = parsePositiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(parsePositiveInteger(query.pageSize, getConfig().defaultPageSize), getConfig().maxPageSize);
|
||||
const orderByStandardField = resourceConfig.defaultOrderBy || resourceConfig.orderBy || fields[0];
|
||||
const orderBy = assertIdentifier(fieldMapping[orderByStandardField] || orderByStandardField, "defaultOrderBy");
|
||||
const where = buildWhereClause({ query, fields, fieldMapping, resourceConfig });
|
||||
const selectClause = fieldSpecs.map((item) => `${quoteName(item.sourceField)} AS ${quoteName(item.standardField)}`).join(", ");
|
||||
const fromClause = quoteCompoundName(viewName);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const pool = await getPool(prefix, connectionConfig);
|
||||
const totalRequest = pool.request();
|
||||
bindInputs(totalRequest, where.inputs);
|
||||
const totalResult = await totalRequest.query(`SELECT COUNT(1) AS total FROM ${fromClause}${where.sql}`);
|
||||
|
||||
const dataRequest = pool.request();
|
||||
bindInputs(dataRequest, where.inputs);
|
||||
dataRequest.input("offset", sql.Int, offset);
|
||||
dataRequest.input("pageSize", sql.Int, pageSize);
|
||||
const dataResult = await dataRequest.query(
|
||||
`SELECT ${selectClause} FROM ${fromClause}${where.sql} ORDER BY ${quoteName(orderBy)} OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`
|
||||
);
|
||||
|
||||
return {
|
||||
rows: normalizeRowKeys(dataResult.recordset || []),
|
||||
total: Number(totalResult.recordset && totalResult.recordset[0] && totalResult.recordset[0].total) || 0,
|
||||
alreadyPaged: true,
|
||||
};
|
||||
}
|
||||
|
||||
function getConnectionConfig(prefix) {
|
||||
const host = readEnv(prefix, "DB_HOST");
|
||||
const database = readEnv(prefix, "DB_NAME");
|
||||
const user = readEnv(prefix, "DB_USER");
|
||||
const password = readEnv(prefix, "DB_PASSWORD");
|
||||
if (!host) throwDriverError(`缺少 SQL Server 视图配置: CONFIG_${prefix}_DB_HOST`, "VIEW_DB_CONFIG_INVALID");
|
||||
if (!database) throwDriverError(`缺少 SQL Server 视图配置: CONFIG_${prefix}_DB_NAME`, "VIEW_DB_CONFIG_INVALID");
|
||||
if (!user) throwDriverError(`缺少 SQL Server 视图配置: CONFIG_${prefix}_DB_USER`, "VIEW_DB_CONFIG_INVALID");
|
||||
if (!password) throwDriverError(`缺少 SQL Server 视图配置: CONFIG_${prefix}_DB_PASSWORD`, "VIEW_DB_CONFIG_INVALID");
|
||||
|
||||
return {
|
||||
server: host,
|
||||
port: parsePositiveInteger(readEnv(prefix, "DB_PORT"), 1433),
|
||||
database,
|
||||
user,
|
||||
password,
|
||||
connectionTimeout: parsePositiveInteger(readEnv(prefix, "DB_CONNECTION_TIMEOUT_MS"), getConfig().timeoutMs),
|
||||
requestTimeout: parsePositiveInteger(readEnv(prefix, "DB_REQUEST_TIMEOUT_MS"), getConfig().timeoutMs),
|
||||
options: {
|
||||
encrypt: parseBoolean(readEnv(prefix, "DB_ENCRYPT"), false),
|
||||
trustServerCertificate: parseBoolean(readEnv(prefix, "DB_TRUST_SERVER_CERTIFICATE"), true),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getPool(prefix, connectionConfig) {
|
||||
const key = `${prefix}:${connectionConfig.server}:${connectionConfig.port}:${connectionConfig.database}:${connectionConfig.user}`;
|
||||
const cached = pools.get(key);
|
||||
if (cached && cached.connected) return cached;
|
||||
if (cached) {
|
||||
try {
|
||||
await cached.close();
|
||||
} catch {}
|
||||
}
|
||||
const pool = await new sql.ConnectionPool(connectionConfig).connect();
|
||||
pools.set(key, pool);
|
||||
return pool;
|
||||
}
|
||||
|
||||
function buildWhereClause({ query, fields, fieldMapping, resourceConfig }) {
|
||||
const standardFields = new Set(fields);
|
||||
const inputs = [];
|
||||
const clauses = [];
|
||||
|
||||
for (const [key, rawValue] of Object.entries(query || {})) {
|
||||
if (QUERY_META_KEYS.has(key) || isEmpty(rawValue)) continue;
|
||||
|
||||
const rangeFilter = RANGE_FILTERS[key];
|
||||
if (rangeFilter) {
|
||||
const standardField = rangeFilter.field || resourceConfig[rangeFilter.fromConfig];
|
||||
if (!standardField || !standardFields.has(standardField)) continue;
|
||||
addFilter({ clauses, inputs, standardField, value: rawValue, fieldMapping, op: rangeFilter.op });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!standardFields.has(key)) continue;
|
||||
const operator = getFilterOperator(resourceConfig, key);
|
||||
addFilter({ clauses, inputs, standardField: key, value: rawValue, fieldMapping, op: operator });
|
||||
}
|
||||
|
||||
return {
|
||||
sql: clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "",
|
||||
inputs,
|
||||
};
|
||||
}
|
||||
|
||||
function addFilter({ clauses, inputs, standardField, value, fieldMapping, op }) {
|
||||
const sourceField = assertIdentifier(fieldMapping[standardField] || standardField, `filter.${standardField}`);
|
||||
const paramName = `p${inputs.length}`;
|
||||
if (op === "like") {
|
||||
clauses.push(`${quoteName(sourceField)} LIKE @${paramName}`);
|
||||
inputs.push({ name: paramName, value: `%${String(value).trim()}%` });
|
||||
return;
|
||||
}
|
||||
clauses.push(`${quoteName(sourceField)} ${op || "="} @${paramName}`);
|
||||
inputs.push({ name: paramName, value });
|
||||
}
|
||||
|
||||
function getFilterOperator(resourceConfig, standardField) {
|
||||
const configured = resourceConfig.filterOperators && resourceConfig.filterOperators[standardField];
|
||||
if (!configured) return "=";
|
||||
const op = String(configured).trim().toLowerCase();
|
||||
return op === "like" ? "like" : "=";
|
||||
}
|
||||
|
||||
function bindInputs(request, inputs) {
|
||||
for (const input of inputs) request.input(input.name, input.value);
|
||||
}
|
||||
|
||||
function normalizeFieldMapping(fieldMapping) {
|
||||
if (!fieldMapping || typeof fieldMapping !== "object" || Array.isArray(fieldMapping)) return {};
|
||||
return Object.entries(fieldMapping).reduce((acc, [standardField, sourceField]) => {
|
||||
if (!standardField || !sourceField) return acc;
|
||||
acc[standardField] = sourceField;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function normalizeRowKeys(rows) {
|
||||
return rows.map((row) => {
|
||||
const normalized = {};
|
||||
for (const [key, value] of Object.entries(row || {})) normalized[String(key).toLowerCase()] = value;
|
||||
return normalized;
|
||||
});
|
||||
}
|
||||
|
||||
function quoteCompoundName(identifier) {
|
||||
return String(identifier)
|
||||
.split(".")
|
||||
.map((part) => quoteName(assertIdentifier(part, "compoundIdentifier")))
|
||||
.join(".");
|
||||
}
|
||||
|
||||
function quoteName(identifier) {
|
||||
return `[${String(identifier).replace(/]/g, "]]")}]`;
|
||||
}
|
||||
|
||||
function assertIdentifier(value, label) {
|
||||
const text = String(value || "").trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(text)) {
|
||||
throwDriverError(`非法视图标识符: ${label}`, "VIEW_IDENTIFIER_INVALID");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function readEnv(prefix, key) {
|
||||
return process.env[`CONFIG_${prefix}_${key}`] || "";
|
||||
}
|
||||
|
||||
function parseBoolean(value, defaultValue) {
|
||||
if (value === undefined || value === null || value === "") return defaultValue;
|
||||
return !["false", "0", "no", "off"].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
function isEmpty(value) {
|
||||
return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
|
||||
}
|
||||
|
||||
function throwDriverError(message, code) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
throw err;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
querySqlServerView,
|
||||
};
|
||||
281
src/sources/view/oracle-sqlplus.js
Normal file
281
src/sources/view/oracle-sqlplus.js
Normal file
@ -0,0 +1,281 @@
|
||||
const { spawn } = require("child_process");
|
||||
const iconv = require("iconv-lite");
|
||||
const { getConfig, parsePositiveInteger } = require("../../core/config");
|
||||
|
||||
const QUERY_META_KEYS = new Set(["page", "pageSize", "saveSnapshot"]);
|
||||
const RANGE_FILTERS = {
|
||||
startCreateTime: { field: "create_time", op: ">=" },
|
||||
endCreateTime: { field: "create_time", op: "<=" },
|
||||
startUpdateTime: { field: "update_time", op: ">=" },
|
||||
endUpdateTime: { field: "update_time", op: "<=" },
|
||||
start_date: { fromConfig: "dateRangeField", op: ">=" },
|
||||
end_date: { fromConfig: "dateRangeField", op: "<=" },
|
||||
};
|
||||
|
||||
async function queryOracleSqlplusView({ adapter, resourceConfig, definition, query }) {
|
||||
const prefix = adapter.envPrefix || adapter.hospitalCode.toUpperCase();
|
||||
const config = getSqlplusConfig(prefix);
|
||||
const viewName = assertIdentifier(resourceConfig.viewName, "viewName");
|
||||
const fields = Array.isArray(definition && definition.fields) ? definition.fields : [];
|
||||
if (!fields.length) throwDriverError("标准资源未定义字段", "VIEW_DEFINITION_INVALID");
|
||||
|
||||
const fieldMapping = normalizeFieldMapping(resourceConfig.fieldMapping);
|
||||
const fieldSpecs = fields.map((standardField) => ({
|
||||
standardField,
|
||||
sourceField: assertIdentifier(fieldMapping[standardField] || standardField, `fieldMapping.${standardField}`),
|
||||
}));
|
||||
const page = parsePositiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(parsePositiveInteger(query.pageSize, getConfig().defaultPageSize), getConfig().maxPageSize);
|
||||
const offset = (page - 1) * pageSize;
|
||||
const endRow = page * pageSize;
|
||||
const orderByStandardField = resourceConfig.defaultOrderBy || resourceConfig.orderBy || fields[0];
|
||||
const orderBy = assertIdentifier(fieldMapping[orderByStandardField] || orderByStandardField, "defaultOrderBy");
|
||||
const whereSql = buildWhereClause({ query, fields, fieldMapping, resourceConfig });
|
||||
const viewSql = compoundIdentifier(viewName);
|
||||
const innerSelect = fieldSpecs.map((item) => `${identifier(item.sourceField)} AS "${item.standardField}"`).join(", ");
|
||||
const rowConcat = fieldSpecs.map((item) => cleanSqlValue(`"${item.standardField}"`)).join(" || CHR(31) || ");
|
||||
|
||||
const script = buildSqlplusScript({
|
||||
connectString: config.connectString,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
countSql: `SELECT 'YKT_COUNT:' || COUNT(1) FROM ${viewSql}${whereSql};`,
|
||||
dataSql: [
|
||||
`SELECT 'YKT_ROW:' || ${rowConcat}`,
|
||||
"FROM (",
|
||||
" SELECT ykt_page.*, ROWNUM ykt_rn",
|
||||
" FROM (",
|
||||
` SELECT ${innerSelect} FROM ${viewSql}${whereSql} ORDER BY ${identifier(orderBy)}`,
|
||||
" ) ykt_page",
|
||||
` WHERE ROWNUM <= ${endRow}`,
|
||||
")",
|
||||
`WHERE ykt_rn > ${offset};`,
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const output = await runSqlplus({ sqlplusPath: config.sqlplusPath, script, timeoutMs: config.timeoutMs, encoding: config.encoding });
|
||||
return parseSqlplusOutput(output, fields);
|
||||
}
|
||||
|
||||
function getSqlplusConfig(prefix) {
|
||||
const host = readEnv(prefix, "DB_HOST");
|
||||
const port = parsePositiveInteger(readEnv(prefix, "DB_PORT"), 1521);
|
||||
const database = readEnv(prefix, "DB_NAME");
|
||||
const user = readEnv(prefix, "DB_USER");
|
||||
const password = readEnv(prefix, "DB_PASSWORD");
|
||||
const sqlplusPath = readEnv(prefix, "DB_SQLPLUS_PATH") || "sqlplus";
|
||||
const connectString = readEnv(prefix, "DB_CONNECT_STRING") || buildConnectString({ prefix, host, port, database });
|
||||
if (!host && !readEnv(prefix, "DB_CONNECT_STRING")) throwDriverError(`缺少 Oracle SQLPlus 视图配置: CONFIG_${prefix}_DB_HOST`, "VIEW_DB_CONFIG_INVALID");
|
||||
if (!database && !readEnv(prefix, "DB_CONNECT_STRING")) throwDriverError(`缺少 Oracle SQLPlus 视图配置: CONFIG_${prefix}_DB_NAME`, "VIEW_DB_CONFIG_INVALID");
|
||||
if (!user) throwDriverError(`缺少 Oracle SQLPlus 视图配置: CONFIG_${prefix}_DB_USER`, "VIEW_DB_CONFIG_INVALID");
|
||||
if (!password) throwDriverError(`缺少 Oracle SQLPlus 视图配置: CONFIG_${prefix}_DB_PASSWORD`, "VIEW_DB_CONFIG_INVALID");
|
||||
return {
|
||||
connectString,
|
||||
password,
|
||||
sqlplusPath,
|
||||
timeoutMs: parsePositiveInteger(readEnv(prefix, "DB_REQUEST_TIMEOUT_MS"), getConfig().timeoutMs),
|
||||
user,
|
||||
encoding: readEnv(prefix, "DB_SQLPLUS_ENCODING") || "gbk",
|
||||
};
|
||||
}
|
||||
|
||||
function buildConnectString({ prefix, host, port, database }) {
|
||||
const mode = String(readEnv(prefix, "DB_ORACLE_CONNECT_MODE") || "service").trim().toLowerCase();
|
||||
const connectDataKey = mode === "sid" ? "SID" : "SERVICE_NAME";
|
||||
return `(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=${host})(PORT=${port}))(CONNECT_DATA=(${connectDataKey}=${database})))`;
|
||||
}
|
||||
|
||||
function buildSqlplusScript({ connectString, user, password, countSql, dataSql }) {
|
||||
return [
|
||||
"SET HEADING OFF",
|
||||
"SET FEEDBACK OFF",
|
||||
"SET PAGESIZE 0",
|
||||
"SET LINESIZE 32767",
|
||||
"SET LONG 32767",
|
||||
"SET LONGCHUNKSIZE 32767",
|
||||
"SET TRIMSPOOL ON",
|
||||
"SET TAB OFF",
|
||||
"SET VERIFY OFF",
|
||||
"SET ECHO OFF",
|
||||
"WHENEVER OSERROR EXIT 9",
|
||||
"WHENEVER SQLERROR EXIT SQL.SQLCODE",
|
||||
`CONNECT ${escapeConnectPart(user)}/${escapeConnectPart(password)}@${connectString}`,
|
||||
countSql,
|
||||
dataSql,
|
||||
"EXIT",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function runSqlplus({ sqlplusPath, script, timeoutMs, encoding }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(sqlplusPath, ["-S", "/nolog"], {
|
||||
windowsHide: true,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const stdoutChunks = [];
|
||||
const stderrChunks = [];
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
rejectWithCode(reject, `Oracle SQLPlus 查询超时(${timeoutMs}ms)`, "SQLPLUS_TIMEOUT");
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderrChunks.push(chunk);
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
rejectWithCode(reject, `无法启动 sqlplus: ${err.message}`, "SQLPLUS_NOT_FOUND");
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
const stdout = decodeOutput(stdoutChunks, encoding);
|
||||
const stderr = decodeOutput(stderrChunks, encoding);
|
||||
const output = `${stdout}\n${stderr}`.trim();
|
||||
if (code !== 0 || /(^|\n)(ORA-|SP2-)/.test(output)) {
|
||||
rejectWithCode(reject, extractOracleError(output) || `SQLPlus 执行失败(${code})`, "SQLPLUS_QUERY_FAILED");
|
||||
return;
|
||||
}
|
||||
resolve(output);
|
||||
});
|
||||
child.stdin.end(script, "utf8");
|
||||
});
|
||||
}
|
||||
|
||||
function decodeOutput(chunks, encoding) {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
if (!buffer.length) return "";
|
||||
return iconv.decode(buffer, encoding || "gbk");
|
||||
}
|
||||
|
||||
function parseSqlplusOutput(output, fields) {
|
||||
let total = 0;
|
||||
const rows = [];
|
||||
for (const rawLine of String(output || "").split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("YKT_COUNT:")) {
|
||||
total = Number(line.slice("YKT_COUNT:".length).trim()) || 0;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("YKT_ROW:")) {
|
||||
const values = line.slice("YKT_ROW:".length).split(String.fromCharCode(31));
|
||||
const row = {};
|
||||
fields.forEach((field, index) => {
|
||||
row[field] = values[index] === undefined ? "" : values[index];
|
||||
});
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
alreadyPaged: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWhereClause({ query, fields, fieldMapping, resourceConfig }) {
|
||||
const standardFields = new Set(fields);
|
||||
const clauses = [];
|
||||
for (const [key, rawValue] of Object.entries(query || {})) {
|
||||
if (QUERY_META_KEYS.has(key) || isEmpty(rawValue)) continue;
|
||||
|
||||
const rangeFilter = RANGE_FILTERS[key];
|
||||
if (rangeFilter) {
|
||||
const standardField = rangeFilter.field || resourceConfig[rangeFilter.fromConfig];
|
||||
if (!standardField || !standardFields.has(standardField)) continue;
|
||||
clauses.push(buildFilterSql({ standardField, value: rawValue, fieldMapping, op: rangeFilter.op }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!standardFields.has(key)) continue;
|
||||
const operator = getFilterOperator(resourceConfig, key);
|
||||
clauses.push(buildFilterSql({ standardField: key, value: rawValue, fieldMapping, op: operator }));
|
||||
}
|
||||
return clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
||||
}
|
||||
|
||||
function buildFilterSql({ standardField, value, fieldMapping, op }) {
|
||||
const sourceField = assertIdentifier(fieldMapping[standardField] || standardField, `filter.${standardField}`);
|
||||
if (op === "like") return `${cleanSqlValue(identifier(sourceField))} LIKE ${literal(`%${String(value).trim()}%`)}`;
|
||||
return `${cleanSqlValue(identifier(sourceField))} ${op || "="} ${literal(value)}`;
|
||||
}
|
||||
|
||||
function getFilterOperator(resourceConfig, standardField) {
|
||||
const configured = resourceConfig.filterOperators && resourceConfig.filterOperators[standardField];
|
||||
if (!configured) return "=";
|
||||
const op = String(configured).trim().toLowerCase();
|
||||
return op === "like" ? "like" : "=";
|
||||
}
|
||||
|
||||
function cleanSqlValue(columnSql) {
|
||||
return `NVL(REPLACE(REPLACE(TO_CHAR(${columnSql}), CHR(13), ' '), CHR(10), ' '), '')`;
|
||||
}
|
||||
|
||||
function literal(value) {
|
||||
return `'${String(value).trim().replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function normalizeFieldMapping(fieldMapping) {
|
||||
if (!fieldMapping || typeof fieldMapping !== "object" || Array.isArray(fieldMapping)) return {};
|
||||
return Object.entries(fieldMapping).reduce((acc, [standardField, sourceField]) => {
|
||||
if (!standardField || !sourceField) return acc;
|
||||
acc[standardField] = sourceField;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function compoundIdentifier(value) {
|
||||
return String(value)
|
||||
.split(".")
|
||||
.map((part) => identifier(assertIdentifier(part, "compoundIdentifier")))
|
||||
.join(".");
|
||||
}
|
||||
|
||||
function identifier(value) {
|
||||
return String(value).toUpperCase();
|
||||
}
|
||||
|
||||
function assertIdentifier(value, label) {
|
||||
const text = String(value || "").trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(text)) {
|
||||
throwDriverError(`非法视图标识符: ${label}`, "VIEW_IDENTIFIER_INVALID");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function escapeConnectPart(value) {
|
||||
return String(value || "").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function readEnv(prefix, key) {
|
||||
return process.env[`CONFIG_${prefix}_${key}`] || "";
|
||||
}
|
||||
|
||||
function isEmpty(value) {
|
||||
return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
|
||||
}
|
||||
|
||||
function extractOracleError(output) {
|
||||
const match = String(output || "").match(/(ORA-\d+:[^\r\n]+|SP2-\d+:[^\r\n]+)/);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function rejectWithCode(reject, message, code) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
reject(err);
|
||||
}
|
||||
|
||||
function throwDriverError(message, code) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
throw err;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queryOracleSqlplusView,
|
||||
};
|
||||
193
src/sources/view/oracle.js
Normal file
193
src/sources/view/oracle.js
Normal file
@ -0,0 +1,193 @@
|
||||
const oracledb = require("oracledb");
|
||||
const { getConfig, parsePositiveInteger } = require("../../core/config");
|
||||
|
||||
oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT;
|
||||
|
||||
const pools = new Map();
|
||||
const QUERY_META_KEYS = new Set(["page", "pageSize", "saveSnapshot"]);
|
||||
const RANGE_FILTERS = {
|
||||
startCreateTime: { field: "create_time", op: ">=" },
|
||||
endCreateTime: { field: "create_time", op: "<=" },
|
||||
startUpdateTime: { field: "update_time", op: ">=" },
|
||||
endUpdateTime: { field: "update_time", op: "<=" },
|
||||
start_date: { fromConfig: "dateRangeField", op: ">=" },
|
||||
end_date: { fromConfig: "dateRangeField", op: "<=" },
|
||||
};
|
||||
|
||||
async function queryOracleView({ adapter, resourceConfig, definition, query }) {
|
||||
const prefix = adapter.envPrefix || adapter.hospitalCode.toUpperCase();
|
||||
const connectionConfig = getConnectionConfig(prefix);
|
||||
const viewName = assertIdentifier(resourceConfig.viewName, "viewName");
|
||||
const fields = Array.isArray(definition && definition.fields) ? definition.fields : [];
|
||||
if (!fields.length) throwDriverError("标准资源未定义字段", "VIEW_DEFINITION_INVALID");
|
||||
|
||||
const fieldMapping = normalizeFieldMapping(resourceConfig.fieldMapping);
|
||||
const fieldSpecs = fields.map((standardField) => ({
|
||||
standardField,
|
||||
sourceField: assertIdentifier(fieldMapping[standardField] || standardField, `fieldMapping.${standardField}`),
|
||||
}));
|
||||
|
||||
const page = parsePositiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(parsePositiveInteger(query.pageSize, getConfig().defaultPageSize), getConfig().maxPageSize);
|
||||
const orderByStandardField = resourceConfig.defaultOrderBy || resourceConfig.orderBy || fields[0];
|
||||
const orderBy = assertIdentifier(fieldMapping[orderByStandardField] || orderByStandardField, "defaultOrderBy");
|
||||
const where = buildWhereClause({ query, fields, fieldMapping, resourceConfig });
|
||||
const selectClause = fieldSpecs.map((item) => `${identifier(item.sourceField)} AS "${item.standardField}"`).join(", ");
|
||||
const fromClause = compoundIdentifier(viewName);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const pool = await getPool(prefix, connectionConfig);
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const totalResult = await connection.execute(`SELECT COUNT(1) AS "total" FROM ${fromClause}${where.sql}`, where.binds);
|
||||
const dataResult = await connection.execute(
|
||||
`SELECT ${selectClause} FROM ${fromClause}${where.sql} ORDER BY ${identifier(orderBy)} OFFSET :offset ROWS FETCH NEXT :pageSize ROWS ONLY`,
|
||||
{ ...where.binds, offset, pageSize }
|
||||
);
|
||||
|
||||
return {
|
||||
rows: normalizeRowKeys(dataResult.rows || []),
|
||||
total: Number(totalResult.rows && totalResult.rows[0] && totalResult.rows[0].total) || 0,
|
||||
alreadyPaged: true,
|
||||
};
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionConfig(prefix) {
|
||||
const host = readEnv(prefix, "DB_HOST");
|
||||
const port = parsePositiveInteger(readEnv(prefix, "DB_PORT"), 1521);
|
||||
const database = readEnv(prefix, "DB_NAME");
|
||||
const user = readEnv(prefix, "DB_USER");
|
||||
const password = readEnv(prefix, "DB_PASSWORD");
|
||||
const connectString = readEnv(prefix, "DB_CONNECT_STRING") || buildConnectString({ prefix, host, port, database });
|
||||
if (!host && !readEnv(prefix, "DB_CONNECT_STRING")) throwDriverError(`缺少 Oracle 视图配置: CONFIG_${prefix}_DB_HOST`, "VIEW_DB_CONFIG_INVALID");
|
||||
if (!database && !readEnv(prefix, "DB_CONNECT_STRING")) throwDriverError(`缺少 Oracle 视图配置: CONFIG_${prefix}_DB_NAME`, "VIEW_DB_CONFIG_INVALID");
|
||||
if (!user) throwDriverError(`缺少 Oracle 视图配置: CONFIG_${prefix}_DB_USER`, "VIEW_DB_CONFIG_INVALID");
|
||||
if (!password) throwDriverError(`缺少 Oracle 视图配置: CONFIG_${prefix}_DB_PASSWORD`, "VIEW_DB_CONFIG_INVALID");
|
||||
|
||||
return {
|
||||
user,
|
||||
password,
|
||||
connectString,
|
||||
poolMin: 0,
|
||||
poolMax: parsePositiveInteger(readEnv(prefix, "DB_POOL_MAX"), 4),
|
||||
poolIncrement: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildConnectString({ prefix, host, port, database }) {
|
||||
const mode = String(readEnv(prefix, "DB_ORACLE_CONNECT_MODE") || "service").trim().toLowerCase();
|
||||
if (mode === "sid") return `${host}:${port}:${database}`;
|
||||
return `${host}:${port}/${database}`;
|
||||
}
|
||||
|
||||
async function getPool(prefix, connectionConfig) {
|
||||
const key = `${prefix}:${connectionConfig.connectString}:${connectionConfig.user}`;
|
||||
const cached = pools.get(key);
|
||||
if (cached) return cached;
|
||||
const pool = await oracledb.createPool(connectionConfig);
|
||||
pools.set(key, pool);
|
||||
return pool;
|
||||
}
|
||||
|
||||
function buildWhereClause({ query, fields, fieldMapping, resourceConfig }) {
|
||||
const standardFields = new Set(fields);
|
||||
const binds = {};
|
||||
const clauses = [];
|
||||
|
||||
for (const [key, rawValue] of Object.entries(query || {})) {
|
||||
if (QUERY_META_KEYS.has(key) || isEmpty(rawValue)) continue;
|
||||
|
||||
const rangeFilter = RANGE_FILTERS[key];
|
||||
if (rangeFilter) {
|
||||
const standardField = rangeFilter.field || resourceConfig[rangeFilter.fromConfig];
|
||||
if (!standardField || !standardFields.has(standardField)) continue;
|
||||
addFilter({ clauses, binds, standardField, value: rawValue, fieldMapping, op: rangeFilter.op });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!standardFields.has(key)) continue;
|
||||
const operator = getFilterOperator(resourceConfig, key);
|
||||
addFilter({ clauses, binds, standardField: key, value: rawValue, fieldMapping, op: operator });
|
||||
}
|
||||
|
||||
return {
|
||||
sql: clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "",
|
||||
binds,
|
||||
};
|
||||
}
|
||||
|
||||
function addFilter({ clauses, binds, standardField, value, fieldMapping, op }) {
|
||||
const sourceField = assertIdentifier(fieldMapping[standardField] || standardField, `filter.${standardField}`);
|
||||
const paramName = `p${Object.keys(binds).length}`;
|
||||
if (op === "like") {
|
||||
clauses.push(`${identifier(sourceField)} LIKE :${paramName}`);
|
||||
binds[paramName] = `%${String(value).trim()}%`;
|
||||
return;
|
||||
}
|
||||
clauses.push(`${identifier(sourceField)} ${op || "="} :${paramName}`);
|
||||
binds[paramName] = value;
|
||||
}
|
||||
|
||||
function getFilterOperator(resourceConfig, standardField) {
|
||||
const configured = resourceConfig.filterOperators && resourceConfig.filterOperators[standardField];
|
||||
if (!configured) return "=";
|
||||
const op = String(configured).trim().toLowerCase();
|
||||
return op === "like" ? "like" : "=";
|
||||
}
|
||||
|
||||
function normalizeFieldMapping(fieldMapping) {
|
||||
if (!fieldMapping || typeof fieldMapping !== "object" || Array.isArray(fieldMapping)) return {};
|
||||
return Object.entries(fieldMapping).reduce((acc, [standardField, sourceField]) => {
|
||||
if (!standardField || !sourceField) return acc;
|
||||
acc[standardField] = sourceField;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function normalizeRowKeys(rows) {
|
||||
return rows.map((row) => {
|
||||
const normalized = {};
|
||||
for (const [key, value] of Object.entries(row || {})) normalized[String(key).toLowerCase()] = value;
|
||||
return normalized;
|
||||
});
|
||||
}
|
||||
|
||||
function compoundIdentifier(value) {
|
||||
return String(value)
|
||||
.split(".")
|
||||
.map((part) => identifier(assertIdentifier(part, "compoundIdentifier")))
|
||||
.join(".");
|
||||
}
|
||||
|
||||
function identifier(value) {
|
||||
return String(value).toUpperCase();
|
||||
}
|
||||
|
||||
function assertIdentifier(value, label) {
|
||||
const text = String(value || "").trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(text)) {
|
||||
throwDriverError(`非法视图标识符: ${label}`, "VIEW_IDENTIFIER_INVALID");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function readEnv(prefix, key) {
|
||||
return process.env[`CONFIG_${prefix}_${key}`] || "";
|
||||
}
|
||||
|
||||
function isEmpty(value) {
|
||||
return value === undefined || value === null || (typeof value === "string" && value.trim() === "");
|
||||
}
|
||||
|
||||
function throwDriverError(message, code) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
throw err;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queryOracleView,
|
||||
};
|
||||
49
src/sources/webservice.js
Normal file
49
src/sources/webservice.js
Normal file
@ -0,0 +1,49 @@
|
||||
const axios = require("axios");
|
||||
const { parseStringPromise } = require("xml2js");
|
||||
const { getConfig } = require("../core/config");
|
||||
|
||||
async function queryWebservice({ adapter, resource, resourceConfig, query }) {
|
||||
const prefix = adapter.envPrefix || adapter.hospitalCode.toUpperCase();
|
||||
const baseUrl = process.env[`CONFIG_${prefix}_BASE_URL`];
|
||||
if (!baseUrl) {
|
||||
const err = new Error(`缺少 WebService 数据源配置: CONFIG_${prefix}_BASE_URL`);
|
||||
err.code = "WEBSERVICE_SOURCE_CONFIG_INVALID";
|
||||
throw err;
|
||||
}
|
||||
const url = `${baseUrl.replace(/\/$/, "")}${resourceConfig.endpoint}`;
|
||||
const response = await axios.post(url, buildSoapEnvelope(resource, query), {
|
||||
timeout: getConfig().timeoutMs,
|
||||
headers: { "Content-Type": "text/xml; charset=utf-8" },
|
||||
});
|
||||
const parsed = await parseStringPromise(response.data, { explicitArray: false, ignoreAttrs: false });
|
||||
const rows = extractRows(parsed);
|
||||
return { rows, total: rows.length, alreadyPaged: true };
|
||||
}
|
||||
|
||||
function buildSoapEnvelope(resource, query) {
|
||||
const escapedBody = escapeXml(JSON.stringify(query || {}));
|
||||
return `<?xml version="1.0" encoding="utf-8"?><Envelope><Body><query><resource>${escapeXml(resource)}</resource><params>${escapedBody}</params></query></Body></Envelope>`;
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function extractRows(parsed) {
|
||||
const text = JSON.stringify(parsed || {});
|
||||
try {
|
||||
const match = text.match(/\[[\s\S]*\]/);
|
||||
return match ? JSON.parse(match[0]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queryWebservice,
|
||||
};
|
||||
75
src/sync/sync-controller.js
Normal file
75
src/sync/sync-controller.js
Normal file
@ -0,0 +1,75 @@
|
||||
const { queryStandardResource } = require("../core/query-controller");
|
||||
const { success, fail } = require("../core/result");
|
||||
const { getResourceDefinition } = require("../contracts/resources");
|
||||
const { upsertSnapshots, writeRequestLog } = require("../core/repositories");
|
||||
const { parsePositiveInteger } = require("../core/config");
|
||||
const { isMongoAvailable, getMongoDisabledReason } = require("../mongo");
|
||||
|
||||
async function handleSync(req, res) {
|
||||
const startedAt = Date.now();
|
||||
const { hospitalCode, resource, pageSize = 200, maxPages = 1, ...query } = req.body || {};
|
||||
|
||||
try {
|
||||
if (!hospitalCode) throwError("缺少参数: hospitalCode", "INVALID_PARAM");
|
||||
if (!resource) throwError("缺少参数: resource", "INVALID_PARAM");
|
||||
if (!isMongoAvailable()) throwError(`Mongo 未启用,无法同步标准快照: ${getMongoDisabledReason()}`, "MONGO_DISABLED");
|
||||
const definition = getResourceDefinition(resource);
|
||||
if (!definition) throwError("未知标准资源", "RESOURCE_NOT_FOUND");
|
||||
|
||||
const normalizedPageSize = parsePositiveInteger(pageSize, 200);
|
||||
const normalizedMaxPages = parsePositiveInteger(maxPages, 1);
|
||||
let total = 0;
|
||||
let synced = 0;
|
||||
|
||||
for (let page = 1; page <= normalizedMaxPages; page += 1) {
|
||||
const result = await queryStandardResource({
|
||||
hospitalCode,
|
||||
resource,
|
||||
query: { ...query, page, pageSize: normalizedPageSize },
|
||||
saveSnapshot: false,
|
||||
});
|
||||
total = result.total;
|
||||
await upsertSnapshots({ hospitalCode, resource, rows: result.data, definition });
|
||||
synced += result.data.length;
|
||||
if (!result.data.length || synced >= total) break;
|
||||
}
|
||||
|
||||
await writeRequestLog({
|
||||
hospitalCode,
|
||||
resource,
|
||||
status: "success",
|
||||
action: "sync",
|
||||
duration: Date.now() - startedAt,
|
||||
pageSize: normalizedPageSize,
|
||||
total,
|
||||
synced,
|
||||
query,
|
||||
});
|
||||
|
||||
return res.json(success({ message: "同步成功", data: { synced, total }, total: synced, page: 1, pageSize: normalizedPageSize }));
|
||||
} catch (err) {
|
||||
try {
|
||||
await writeRequestLog({
|
||||
hospitalCode,
|
||||
resource,
|
||||
status: "fail",
|
||||
action: "sync",
|
||||
duration: Date.now() - startedAt,
|
||||
errorCode: err && err.code,
|
||||
errorMessage: err && err.message,
|
||||
query,
|
||||
});
|
||||
} catch {}
|
||||
return res.json(fail(err.message || "同步失败", err.code || "SYNC_FAILED"));
|
||||
}
|
||||
}
|
||||
|
||||
function throwError(message, code) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
throw err;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleSync,
|
||||
};
|
||||
172
src/ykt/customer-his-sync.js
Normal file
172
src/ykt/customer-his-sync.js
Normal file
@ -0,0 +1,172 @@
|
||||
const { getConfig } = require("../core/config");
|
||||
|
||||
async function handleYktCustomerHisSync(req, res, options) {
|
||||
const result = await runYktCustomerHisSync(req.body || {}, options);
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
async function runYktCustomerHisSync(event = {}, options = {}) {
|
||||
try {
|
||||
const type = normalizeText(event.type);
|
||||
if (!type) return fail("缺少参数: type");
|
||||
|
||||
if (type === "getHisInHospitalRecord") return successEmpty("中转平台标准资源暂未提供住院接口");
|
||||
if (type === "getHisFeeRecord") return successEmpty("中转平台标准资源暂未提供费用接口");
|
||||
|
||||
const hospitalCode = resolveHospitalCode(event);
|
||||
if (!hospitalCode) return fail("未配置中转平台医院编码");
|
||||
|
||||
if (type === "getHisCustomerArchive") return queryCustomerArchive({ event, hospitalCode, queryResource: options.queryResource });
|
||||
if (type === "getHisOutHospitalRecord") return queryOutHospitalRecord({ event, hospitalCode, queryResource: options.queryResource });
|
||||
|
||||
return fail("未找到对应的方法");
|
||||
} catch (err) {
|
||||
return fail(err && err.message ? err.message : "HIS 中转平台调用失败", err && err.code);
|
||||
}
|
||||
}
|
||||
|
||||
async function queryCustomerArchive({ event, hospitalCode, queryResource }) {
|
||||
const query = cleanObject({
|
||||
patient_id: pick(event, ["patient_id", "patientId", "hisPatId"]),
|
||||
patient_no: pick(event, ["patient_no", "patientNo", "customerNumber", "idNo"]),
|
||||
id_num: pick(event, ["id_num", "idCard"]),
|
||||
mobile_phone_no: pick(event, ["mobile_phone_no", "mobile"]),
|
||||
page: pick(event, ["page", "pageNo"]) || 1,
|
||||
pageSize: pick(event, ["pageSize"]) || getConfig().defaultPageSize,
|
||||
});
|
||||
if (!hasAny(query, ["patient_id", "patient_no", "id_num", "mobile_phone_no"])) return fail("请输入患者信息");
|
||||
const result = await queryResource({ hospitalCode, resource: "patient", query });
|
||||
return fromStandardResult(result, mapPatient);
|
||||
}
|
||||
|
||||
async function queryOutHospitalRecord({ event, hospitalCode, queryResource }) {
|
||||
const query = cleanObject({
|
||||
patient_id: pick(event, ["patient_id", "patientId", "hisPatId"]),
|
||||
patient_no: pick(event, ["patient_no", "patientNo", "customerNumber", "idNo"]),
|
||||
id_num: pick(event, ["id_num", "idCard"]),
|
||||
mobile_phone_no: pick(event, ["mobile_phone_no", "mobile"]),
|
||||
start_date: pick(event, ["start_date", "startDate", "startTime"]),
|
||||
end_date: pick(event, ["end_date", "endDate", "endTime"]),
|
||||
page: pick(event, ["page", "pageNo"]) || 1,
|
||||
pageSize: pick(event, ["pageSize"]) || getConfig().defaultPageSize,
|
||||
});
|
||||
if (!hasAny(query, ["patient_id", "patient_no", "id_num", "mobile_phone_no"])) return fail("缺少患者编号");
|
||||
const result = await queryResource({ hospitalCode, resource: "outpatientRecord", query });
|
||||
return fromStandardResult(result, mapOutpatientRecord);
|
||||
}
|
||||
|
||||
function fromStandardResult(result, mapper) {
|
||||
if (!result || result.status === "fail") return fail((result && result.message) || "中转平台查询失败", result && result.code);
|
||||
const rows = Array.isArray(result.data) ? result.data : [];
|
||||
return {
|
||||
success: true,
|
||||
message: result.message || "获取成功",
|
||||
list: rows.map(mapper),
|
||||
data: { page: result.page, pageSize: result.pageSize, total: result.total },
|
||||
};
|
||||
}
|
||||
|
||||
function mapPatient(row) {
|
||||
return {
|
||||
name: value(row.patient_name),
|
||||
cardType: normalizeCardType(row.id_type, row.id_num),
|
||||
idCard: value(row.id_num),
|
||||
mobile: value(row.mobile_phone_no),
|
||||
address: joinText(row.family_addr_area, row.family_addr_detail),
|
||||
customerNumber: value(row.patient_no || row.patient_id),
|
||||
sex: normalizeSex(row.sex),
|
||||
birthday: value(row.dob),
|
||||
hospitalVisitNo: value(row.patient_id),
|
||||
medicalRecordNo: value(row.patient_id),
|
||||
};
|
||||
}
|
||||
|
||||
function mapOutpatientRecord(row) {
|
||||
return {
|
||||
customerNumber: value(row.patient_no || row.patient_id),
|
||||
visitId: buildVisitId(row),
|
||||
visitTime: value(row.visit_time),
|
||||
deptName: value(row.visit_dept_name),
|
||||
doctor: value(row.visit_doctor_name),
|
||||
diagnosisId: value(row.diagnosis_ids),
|
||||
diagnosisName: value(row.diagnosis_names),
|
||||
chiefComplaint: value(row.chief_complaint),
|
||||
disposalPlan: value(row.entrust),
|
||||
medicalRecordNo: value(row.patient_id),
|
||||
registrationNo: value(row.visit_service_code),
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisitId(row) {
|
||||
return [row.patient_id || row.patient_no, row.visit_time, row.visit_dept_code || row.visit_dept_name].map(normalizeText).filter(Boolean).join("_");
|
||||
}
|
||||
|
||||
function resolveHospitalCode(event) {
|
||||
const config = getConfig();
|
||||
return normalizeText(event.hospitalCode || event.adapterHospitalCode || config.corpMap[event.corpId]);
|
||||
}
|
||||
|
||||
function normalizeSex(sex) {
|
||||
const text = normalizeText(sex);
|
||||
if (["1", "男", "M", "m"].includes(text)) return "男";
|
||||
if (["2", "女", "F", "f"].includes(text)) return "女";
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeCardType(cardType, idNum) {
|
||||
const text = normalizeText(cardType);
|
||||
if (text === "1" || text === "身份证") return "身份证";
|
||||
return text || (idNum ? "身份证" : "");
|
||||
}
|
||||
|
||||
function successEmpty(message) {
|
||||
return { success: true, message, list: [] };
|
||||
}
|
||||
|
||||
function fail(message, code) {
|
||||
const result = { success: false, message, list: [] };
|
||||
if (code) result.err = code;
|
||||
return result;
|
||||
}
|
||||
|
||||
function pick(source, keys) {
|
||||
for (const key of keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source || {}, key)) continue;
|
||||
const pickedValue = source[key];
|
||||
if (pickedValue === undefined || pickedValue === null) continue;
|
||||
if (typeof pickedValue === "string" && pickedValue.trim() === "") continue;
|
||||
return pickedValue;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function cleanObject(source) {
|
||||
return Object.entries(source || {}).reduce((acc, [key, val]) => {
|
||||
if (val === undefined || val === null) return acc;
|
||||
if (typeof val === "string" && val.trim() === "") return acc;
|
||||
acc[key] = val;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function hasAny(query, keys) {
|
||||
return keys.some((key) => query[key] !== undefined && query[key] !== null && String(query[key]).trim() !== "");
|
||||
}
|
||||
|
||||
function normalizeText(input) {
|
||||
if (input === undefined || input === null) return "";
|
||||
return String(input).trim();
|
||||
}
|
||||
|
||||
function value(input) {
|
||||
return input === undefined || input === null ? "" : input;
|
||||
}
|
||||
|
||||
function joinText(...parts) {
|
||||
return parts.map(normalizeText).filter(Boolean).join("");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleYktCustomerHisSync,
|
||||
runYktCustomerHisSync,
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user