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}`, "ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD'", "ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS'", 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, };