2026-06-18 16:10:40 +08:00
|
|
|
const { spawn } = require("child_process");
|
|
|
|
|
const iconv = require("iconv-lite");
|
|
|
|
|
const { getConfig, parsePositiveInteger } = require("../../core/config");
|
|
|
|
|
|
2026-06-24 14:55:21 +08:00
|
|
|
const QUERY_META_KEYS = new Set(["page", "pageSize", "saveSnapshot", "_where"]);
|
2026-06-18 16:10:40 +08:00
|
|
|
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}`,
|
2026-06-22 17:49:11 +08:00
|
|
|
"ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD';",
|
|
|
|
|
"ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS';",
|
2026-06-18 16:10:40 +08:00
|
|
|
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 = [];
|
2026-06-24 14:55:21 +08:00
|
|
|
addCustomFilters({ clauses, where: query && query._where, standardFields, fieldMapping });
|
2026-06-18 16:10:40 +08:00
|
|
|
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}`);
|
2026-06-24 14:55:21 +08:00
|
|
|
if (op === "notEmpty") return `${cleanSqlValue(identifier(sourceField))} <> ''`;
|
|
|
|
|
if (op === "isEmpty") return `${cleanSqlValue(identifier(sourceField))} = ''`;
|
2026-06-18 16:10:40 +08:00
|
|
|
if (op === "like") return `${cleanSqlValue(identifier(sourceField))} LIKE ${literal(`%${String(value).trim()}%`)}`;
|
|
|
|
|
return `${cleanSqlValue(identifier(sourceField))} ${op || "="} ${literal(value)}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 14:55:21 +08:00
|
|
|
function addCustomFilters({ clauses, where, standardFields, fieldMapping }) {
|
|
|
|
|
if (!Array.isArray(where)) return;
|
|
|
|
|
for (const item of where) {
|
|
|
|
|
const standardField = item && String(item.field || item.column || "").trim();
|
|
|
|
|
if (!standardField || !standardFields.has(standardField)) continue;
|
|
|
|
|
const op = normalizeCustomOperator(item.op || item.operator || item.type);
|
|
|
|
|
if (!op) continue;
|
|
|
|
|
if (op !== "notEmpty" && op !== "isEmpty" && isEmpty(item.value)) continue;
|
|
|
|
|
clauses.push(buildFilterSql({ standardField, value: item.value, fieldMapping, op }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeCustomOperator(op) {
|
|
|
|
|
const text = String(op || "eq").trim().toLowerCase();
|
|
|
|
|
if (["notempty", "not_empty", "isnotempty", "is_not_empty"].includes(text)) return "notEmpty";
|
|
|
|
|
if (["isempty", "is_empty", "empty"].includes(text)) return "isEmpty";
|
|
|
|
|
if (["like", "contains"].includes(text)) return "like";
|
|
|
|
|
if (["ne", "neq", "!=", "<>"].includes(text)) return "<>";
|
|
|
|
|
if (["eq", "="].includes(text)) return "=";
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 16:10:40 +08:00
|
|
|
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,
|
|
|
|
|
};
|