first commit

This commit is contained in:
Jafeng 2026-06-18 16:10:40 +08:00
commit 7a9350adcf
31 changed files with 5185 additions and 0 deletions

3
.env.development Normal file
View File

@ -0,0 +1,3 @@
CONFIG_NODE_PORT=8082
CONFIG_MONGO_ENABLED=false
CONFIG_DB_NAME=hospital_adapter

18
.env.production.example Normal file
View 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
View File

@ -0,0 +1,8 @@
node_modules/
dist/
release/
logs/
tmp-*.log
.env.production
.env.local
.env.*.local

199
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View 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
View 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,
};

View 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,
};

View 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
View 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
View 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
View 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,
};

View 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
View 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
View 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
View 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,
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
};

View 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
View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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,
};

View 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,
};

View 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,
};