Commit fd098416 authored by Data Governance Dev's avatar Data Governance Dev

feat(backend): Oracle 端到端打通(thick + health + 11g 兼容 + schema 名修正)

Oracle 11g (11.2.0.4) 数据库连接抽数据字典端到端跑通,解决 4 个独立问题:

1. connect_timeout → tcp_connect_timeout
   oracledb 1.4.2+ 移除了 connect_timeout kwarg(之前误以为兼容),
   改成 tcp_connect_timeout;报错日志加 inspect 自动定位兼容 kwarg。

2. thick 模式加载 + DPI-1047 深度诊断
   - _ensure_oracle_client() 解析 oci.dll 的 PE 导入表,自动列出
     实际依赖的运行时 DLL(如 MSVCR120.dll → VC++ 2013 Redist)
   - DPY-3010(thin 不支持老 Oracle)日志加针对性安装指引
   - 11.2.0.4 必须 thick 模式(thin 不支持 11.2)

3. 健康检查 SQL 分方言
   新建 check_connection.oracle.sql 写 SELECT 1 FROM dual;
   Oracle 严格执行 SQL 标准,不像 MySQL/达梦 兼容裸 SELECT 1。
   sql_loader 按 <name>.<dialect>.sql 优先 + .sql 回退 自动分流。

4. ALL_TABLES 11g 列兼容性
   修 list_tables.oracle.sql:
   - data_length 用 t.BLOCKS * 8192 估算(11g 没 BYTES 列,12c+ 才有)
   - create_time / update_time 取 NULL(11g 没 CREATED/LAST_DDL_TIME,12c+ 才有)
   - 注释贴了 11.2.0.4 ALL_TABLES 完整可用列清单(用户 DESC 确认)

5. schema 名传递修正
   data_dict.py 之前对所有数据库都传 cfg.database 给 list_tables/list_columns,
   但 Oracle 体系里 ALL_TABLES.OWNER 是用户名(=cfg.user),不是 service_name。
   加 cfg.db_type == 'oracle' 分支:schema_name = cfg.user

6. /api/db-defaults 加 oracle_client_dir
   路径从 web/configs/db_defaults.yaml 预填,前端可隐藏输入框。

验证日志:
  [DB] Oracle Instant Client 已加载(thick 模式)lib_dir=...
  [DB] 连接已建立(耗时 ~100ms)
  [DB] 测试连接成功
  [Cache] 写入数据字典: (表 N 张, 字段 M 个)

踩坑详见 docs/WORKLOG.md(2026-08-13 Oracle 端到端打通条目)。
parent e7fb2a8c
......@@ -64,9 +64,10 @@ async def db_defaults():
return {}
defaults = raw.get("db_defaults", {})
# 仅返回前端表单关心的字段,避免泄露无关配置
# 2026-08-13:oracle_client_dir 加入预填(仅 Oracle 有效,留空走 thin 模式)
return {
k: defaults[k]
for k in ("host", "user", "password", "database")
for k in ("host", "user", "password", "database", "oracle_client_dir")
if k in defaults
}
......
......@@ -37,12 +37,16 @@ def extract_data_dictionary(cfg: DBConfig, log: Optional[Callable] = None) -> di
with open_db(cfg) as db:
if log:
log("INFO", "已建立连接,开始读取 information_schema.COLUMNS ...")
columns = db.list_columns(cfg.database)
# 2026-08-13:Oracle 体系里 ALL_TAB_COLUMNS.OWNER / ALL_TABLES.OWNER 是「用户名」,
# 不是 service_name(service_name 在 cfg.database,用于连接);MySQL/达梦 的
# database 既是连接用也是 schema 名,所以单独分支。
schema_name = cfg.user if cfg.db_type == "oracle" else cfg.database
columns = db.list_columns(schema_name)
if log:
log("INFO", f" · COLUMNS: {len(columns)} 行")
if log:
log("INFO", "读取 information_schema.TABLES ...")
tables = db.list_tables(cfg.database)
tables = db.list_tables(schema_name)
if log:
log("INFO", f" · TABLES: {len(tables)} 行")
......
......@@ -84,8 +84,9 @@ class DBConfig:
- database 字段在 Oracle 里是 service_name(如 ORCLPDB1),不是 schema 名
(Oracle schema = user,治理任务通常以「一个 service 扫一个 schema」的视角使用)
- 超时参数叫 connect_timeout(oracledb 1.4.x 风格;2.0+ 改名为 tcp_connect_timeout)
单位秒,默认未指定
- 超时参数叫 tcp_connect_timeout(oracledb 1.4.x 起就用这个名)
单位秒,默认未指定;旧 1.x 风格的 connect_timeout 在 1.4.2+ 已被移除,传了会报
TypeError: connect() got an unexpected keyword argument 'connect_timeout'
- 不传 charset:thin 模式从 OS NLS_LANG / 系统设置取,避免与 NLS 字符集冲突
"""
return {
......@@ -94,7 +95,7 @@ class DBConfig:
"host": self.host,
"port": self.port,
"service_name": self.database,
"connect_timeout": float(self.connect_timeout),
"tcp_connect_timeout": float(self.connect_timeout),
}
......@@ -213,11 +214,98 @@ def _ensure_oracle_client(lib_dir: Optional[str] = None) -> str:
logger.info(f"[DB] Oracle Instant Client 已加载(thick 模式){f'lib_dir={lib_dir}' if lib_dir else '(默认搜索路径)'}")
return "thick"
except Exception as e:
logger.info(
f"[DB] 未找到 Oracle Instant Client({type(e).__name__}: {e})→ 使用 thin 模式,"
f"仅支持 Oracle 11.2+。如连老版本 Oracle 请装 Instant Client"
f"(https://www.oracle.com/database/technologies/instant-client/)"
)
err_str = str(e)
# DPI-1047 + oci.dll 存在:最常见原因是缺 VC++ Redistributable
# (Instant Client 12.x 是用 MSVC 2013 编译的,依赖 MSVCR120.dll;
# 装 vcredist 2015-2022 不够,必须装 VC++ 2013 Redist)
if "DPI-1047" in err_str:
import os
oci_in_lib_dir = False
if lib_dir:
oci_in_lib_dir = os.path.exists(os.path.join(lib_dir, "oci.dll"))
extra_hint = ""
if lib_dir and oci_in_lib_dir:
# 自动扫描 oci.dll 的导入表,列出它依赖的运行时 DLL
# (用 PE 文件格式解析导入表;不依赖 dumpbin / PowerShell)
missing_runtime_hint = ""
try:
import struct
oci_path = os.path.join(lib_dir, "oci.dll")
pe_bytes = open(oci_path, "rb").read()
pe_off = struct.unpack_from("<I", pe_bytes, 0x3C)[0]
opt_off = pe_off + 24
is_64 = (struct.unpack_from("<H", pe_bytes, opt_off)[0] == 0x20B)
data_dir_off = opt_off + (112 if is_64 else 96)
import_rva = struct.unpack_from("<I", pe_bytes, data_dir_off + 8)[0]
# Section table → RVA ↔ file offset
num_sections = struct.unpack_from("<H", pe_bytes, pe_off + 6)[0]
size_opt_hdr = struct.unpack_from("<H", pe_bytes, pe_off + 20)[0]
sec_table_off = pe_off + 24 + size_opt_hdr
def _rva_to_off(rva):
for i in range(num_sections):
so = sec_table_off + i * 40
vaddr = struct.unpack_from("<I", pe_bytes, so + 12)[0]
vsize = struct.unpack_from("<I", pe_bytes, so + 8)[0]
raw_ptr = struct.unpack_from("<I", pe_bytes, so + 20)[0]
if vaddr <= rva < vaddr + vsize:
return raw_ptr + (rva - vaddr)
return None
imports = []
tbl_off = _rva_to_off(import_rva)
while tbl_off is not None:
entry = pe_bytes[tbl_off:tbl_off + 20]
if len(entry) < 20: break
_, _, _, name_rva, _ = struct.unpack("<IIIII", entry)
if name_rva == 0: break
n_off = _rva_to_off(name_rva)
if n_off is None: break
end = pe_bytes.find(b"\x00", n_off)
imports.append(pe_bytes[n_off:end].decode("ascii", errors="replace"))
tbl_off += 20
# 过滤掉系统 DLL,只列可能缺 vcredist 的运行时
system_dlls = {"KERNEL32.DLL", "ADVAPI32.DLL", "USER32.DLL", "GDI32.DLL",
"WS2_32.DLL", "CRYPT32.DLL", "SECUR32.DLL", "OLE32.DLL",
"OLEAUT32.DLL", "SHELL32.DLL", "SHLWAPI.DLL", "WININET.DLL",
"WINHTTP.DLL", "IPHLPAPI.DLL", "NETAPI32.DLL", "PSAPI.DLL",
"MSWSOCK.DLL", "WSOCK32.DLL", "NORMALIZ.DLL", "API-MS-WIN-CRT-*.DLL"}
runtime_deps = [
d for d in imports
if not any(d.upper().startswith(p.upper()) for p in system_dlls)
]
if runtime_deps:
missing_runtime_hint = (
f"\n 诊断:oci.dll 依赖的运行时 DLL = {runtime_deps}\n"
" (如果是 MSVCR120.dll → 装 Visual C++ 2013 Redistributable (x64):"
"https://www.microsoft.com/en-us/download/details.aspx?id=40784)\n"
" (如果是 MSVCP140.dll / VCRUNTIME140.dll → 装 vc_redist.x64.exe:"
"https://aka.ms/vs/17/release/vc_redist.x64.exe)\n"
" 注意:Instant Client 12.x 是 MSVC 2013 编译的(_MSC_VER=1800),"
"装 vc_redist 2015-2022 不够,必须装 2013!"
)
except Exception as parse_e:
missing_runtime_hint = f"\n (PE 解析失败: {parse_e})"
extra_hint = (
"\n 诊断:lib_dir 下 oci.dll 存在,但仍报 DPI-1047 → "
"最常见原因是缺 VC++ Redistributable"
+ missing_runtime_hint
)
elif lib_dir:
extra_hint = (
f"\n 诊断:lib_dir={lib_dir} 下找不到 oci.dll → 路径不对\n"
" 修复:lib_dir 应该填到含 oci.dll 的那一级(不是上一级)"
)
logger.info(
f"[DB] 未找到 Oracle Instant Client(DPI-1047: {e})→ 使用 thin 模式{extra_hint}"
)
else:
logger.info(
f"[DB] 未找到 Oracle Instant Client({type(e).__name__}: {e})→ 使用 thin 模式,"
f"仅支持 Oracle 11.2+。如连老版本 Oracle 请装 Instant Client"
f"(https://www.oracle.com/database/technologies/instant-client/)"
)
return "thin"
......@@ -337,7 +425,44 @@ class DBConnection:
logger.debug(f"[DB] Oracle 连接模式: {mode}")
self._conn = oracledb.connect(**self.cfg.to_oracle_kwargs())
except Exception as e:
# Oracle 连接失败时多打一些诊断信息:oracledb 版本 + connect() 合法 kwarg 列表
# 避免「TypeError: got an unexpected keyword argument 'xxx'」把根因藏起来
logger.error(f"[DB] 连接失败: {type(e).__name__}: {e}")
if self._driver == "oracledb":
err_str = str(e)
# ── 典型错误 1:thin 模式不支持老 Oracle(11.2 以下)──
# DPY-3010: connections to this database server version are not supported
# 修复:装 Oracle Instant Client 让 _ensure_oracle_client() 切 thick
if "DPY-3010" in err_str:
logger.error(
"[DB] Oracle 诊断: 当前 Oracle 服务端版本低于 11.2(或 thin 模式不支持)\n"
" 修复步骤:\n"
" 1. 下载 Instant Client Basic 包(约 100MB):\n"
" https://www.oracle.com/database/technologies/instant-client/downloads.html\n"
" 2. 解压到本地(Windows 示例)D:\\oracle\\instantclient_19_8\n"
" 3. 前端「Oracle Client」输入框填该路径,重启 web 服务后再测\n"
" (或加到 PATH / 设置 ORACLE_HOME 让 _ensure_oracle_client() 自动找到)"
)
# ── 典型错误 2:Instant Client 缺失(DPI-1047)──
# 通常和 DPY-3010 一起出现,因为 _ensure_oracle_client() 没装上 → 走 thin → DPY-3010
elif "DPI-1047" in err_str:
logger.error(
"[DB] Oracle 诊断: Instant Client 缺失(DPI-1047)\n"
" 修复:同 DPY-3010 的步骤 1~3(下载解压并填到前端 Oracle Client 输入框)"
)
# ── 其他错误:照旧打 inspect 信息 ──
else:
try:
import inspect
import oracledb as _odb
valid_kwargs = list(inspect.signature(_odb.connect).parameters.keys())
logger.error(
f"[DB] Oracle 诊断: oracledb.__version__={_odb.__version__}, "
f"connect() 合法 kwarg={valid_kwargs}"
)
logger.error(f"[DB] Oracle 诊断: 实际传入 kwarg={self.cfg.to_oracle_kwargs()}")
except Exception as diag_e:
logger.error(f"[DB] Oracle 诊断打印失败: {diag_e}")
raise
elapsed_ms = (time.monotonic() - self._connect_started) * 1000
logger.info(f"[DB] 连接已建立(耗时 {elapsed_ms:.0f}ms)")
......
-- ============================================================================
-- 健康检查:确认连接可用,返回固定值 1
-- 调用方:web/core/db_adapter.py → test_connection()
-- 方言:Oracle
--
-- Oracle 严格遵循 SQL 标准 —— SELECT 必须有 FROM 子句,
-- 因此不能像 MySQL 那样写 SELECT 1,必须 FROM dual。
-- dual 是 Oracle 内置的单行单列表,恒定返回 1 行。
-- ============================================================================
SELECT 1 FROM dual
\ No newline at end of file
......@@ -6,16 +6,27 @@
-- - Oracle 没有 INFORMATION_SCHEMA,使用 ALL_TABLES(结构与达梦高度一致)
-- - Oracle ALL_TABLES 没有 COMMENTS 列,表注释在 ALL_TAB_COMMENTS 里,
-- LEFT JOIN 出来(与达梦同源——同一类问题同一个修法)
-- - 2026-08-13:实测 11g (11.2.0.4) ALL_TABLES 实际可用列清单(用户跑 DESC ALL_TABLES 确认):
-- OWNER / TABLE_NAME / TABLESPACE_NAME / CLUSTER_NAME / IOT_NAME / STATUS /
-- PCT_FREE / PCT_USED / INI_TRANS / MAX_TRANS /
-- INITIAL_EXTENT / NEXT_EXTENT / MIN_EXTENTS / MAX_EXTENTS / PCT_INCREASE /
-- FREELISTS / FREELIST_GROUPS / LOGGING / BACKED_UP /
-- NUM_ROWS / BLOCKS / EMPTY_BLOCKS / AVG_SPACE / CHAIN_CNT / AVG_ROW_LEN /
-- AVG_SPACE_FREELIST_BLOCKS / NUM_FREELIST_BLOCKS
-- - 11g ALL_TABLES **没有** BYTES / CREATED / LAST_DDL_TIME 列(这三个都是 12c+ 加的),
-- 所以 data_length 用 BLOCKS * 8192 估算(Oracle 默认 block size 8KB;空表为 0,未分配 segment),
-- create_time / update_time 取 NULL。若以后接 12c+,新增 list_tables.oracle.12c.sql
-- 即可(sql_loader 自动优先选方言版本,不影响 11g)。
-- ============================================================================
SELECT t.TABLE_NAME AS table_name,
'BASE TABLE' AS table_type,
NULL AS engine,
t.NUM_ROWS AS table_rows,
t.BYTES AS data_length,
t.BLOCKS * 8192 AS data_length, -- 11g 没 BYTES,用 BLOCKS×8K 估算
0 AS index_length,
tc.COMMENTS AS table_comment,
t.CREATED AS create_time,
t.LAST_DDL_TIME AS update_time
NULL AS create_time, -- 11g ALL_TABLES 没有 CREATED(12c+)
NULL AS update_time -- 11g ALL_TABLES 没有 LAST_DDL_TIME(12c+)
FROM ALL_TABLES t
LEFT JOIN ALL_TAB_COMMENTS tc
ON tc.OWNER = t.OWNER
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment