Commit 9646cd1a authored by Data Governance Dev's avatar Data Governance Dev

feat(web): 三数据库适配(MySQL/达梦/Oracle)+ 连接默认值独立配置

【Oracle 适配 — 端到端接入】
- web/core/db_adapter.py
  · 新增 to_oracle_kwargs(),oracledb 1.4.x 关键字映射
  · _select_driver() 新增 oracle 分支,未安装 oracledb 时给中文友好报错
  · _ensure_oracle_client() 自动检测 Instant Client,老 Oracle 切 thick 模式
  · DBConfig.oracle_client_dir 字段(显式 lib_dir)
  · TEXT_TYPES 补 Oracle 类型(nclob / long)
- web/sql/info_schema/list_columns.oracle.sql + list_tables.oracle.sql
  · 新增 Oracle 方言的字典查询模板(ALL_TAB_COLUMNS / ALL_TABLES)
  · 表注释 LEFT JOIN ALL_TAB_COMMENTS(同达梦结构)
- web/sql/standards/{sample_field_groups,sample_field_groups_single,
  sample_field_pairs,sample_field_pairs_two_text}.sql
  · \`\${col}\` → \${col | quote} 跨方言重构,加新数据库零 SQL 改动
- web/sql/loader.py:dialect 白名单加 "oracle"
- web/requirements.txt:锁定 oracledb>=1.4.2,<2.0(兼容 Oracle 11.2)
- web/start.py:start 脚本新增 oracledb / dmPython 可选组件检测
- web/app.py / web/core/__init__.py / web/core/step_impl/__init__.py /
  web/core/models.py / web/configs/defaults.yaml / web/README.md
  · 描述/注释/枚举更新为「MySQL / 达梦 / Oracle」
  · models.ConnectRequest 加 oracle_client_dir 字段
  · README 加 Oracle 行 + 三端口映射说明

【达梦适配 — 跑通实测】
- web/core/db_adapter.py
  · to_dm_kwargs() 改用 server= 关键字(不是 host=),删 dmPython 不支持的
    schema / charset / connect_timeout 参数(C 扩展传错会 SystemError 吞真实异常)
  · _rows_as_dicts() 统一 PyMySQL Dict / tuple 两种行格式
  · fetchall / fetchone 从 cur.description 取列名兜底
  · dmPython SystemError 增强日志(提示真实原因 + 给排查脚本路径)
- web/sql/info_schema/list_columns.dameng.sql
  · 列注释 LEFT JOIN ALL_COL_COMMENTS(修 [CODE:-2207] C.COMMENTS)
- web/sql/info_schema/list_tables.dameng.sql
  · BYTES → NULL(达梦 ALL_TABLES 无此列)
  · COMMENTS → LEFT JOIN ALL_TAB_COMMENTS(达梦 ALL_TABLES 无此列)
  · CREATED / LAST_DDL_TIME → NULL(达梦 ALL_TABLES 无时间戳)
    真要看时间戳得 LEFT JOIN ALL_OBJECTS,按 OBJECT_TYPE='TABLE' 过滤

【UI 改进】
- web/static/index.html
  · 数据库类型三选项(MySQL / 达梦 / Oracle)
  · onDbTypeChange 端口切换改用 DB_DEFAULT_PORTS 映射表
    (mysql:3306 / dameng:5236 / oracle:1521),加守卫保留用户自定义端口
  · loadDbDefaults() 启动时从 /api/db-defaults 读连接默认值填表单
  · charset 下拉新增 al32utf8 / zhs16gbk(Oracle NLS 字符集)
  · Oracle radio 暂未实装完成,先注释隐藏(后端代码保留,放开零成本)

【连接默认值独立配置】
- web/configs/db_defaults.yaml(新增,.gitignore 排除,存真实凭据)
- web/api/routes.py:新增 GET /api/db-defaults 端点
- .gitignore:补 web/configs/db_defaults.yaml 防止真实凭据误提交

【诊断与日志】
- scripts/_probe_dm.py:达梦连接独立诊断工具(C 扩展吞异常时排查用)
- docs/WORKLOG.md:本次任务完整工作记录(含三库字典表差异表)

【未提交】
- web/configs/db_defaults.yaml(真实密码,gitignore 排除)
- scripts/_tmp_build_violations_md.py(一次性调试脚本,硬编码绝对路径)
- data_dictionary/standard_fields_violations_report.md(生成报告,非源码)
parent 0f08eab6
...@@ -13,6 +13,7 @@ web/outputs/ ...@@ -13,6 +13,7 @@ web/outputs/
# ── 敏感配置(API Key / 真实凭据) ── # ── 敏感配置(API Key / 真实凭据) ──
web/configs/llm.yaml web/configs/llm.yaml
web/configs/db_defaults.yaml
workflow/config.yaml workflow/config.yaml
# ── Python 字节码缓存 ── # ── Python 字节码缓存 ──
......
...@@ -2,6 +2,608 @@ ...@@ -2,6 +2,608 @@
> 任务做完一次记一次。最近的在最上面。 > 任务做完一次记一次。最近的在最上面。
## 2026-08-11 · UI:暂时隐藏 Oracle radio —— 后端实装未完成
### 现状
- 后端 `db_adapter.py` 已经支持 oracle(驱动 / SQL 模板都加好了)
- 前端 `onDbTypeChange` 里 `DB_DEFAULT_PORTS` 也留了 oracle:1521
- 但用户实际跑 Oracle 时还会撞到未排查的字典表/驱动差异,没必要现阶段硬推
- **前端 radio 先隐掉**,等后端跑通实测后再放出来
### 改动
[web/static/index.html](web/static/index.html) · L58-62:
```html
<el-radio-button label="mysql">MySQL</el-radio-button>
<el-radio-button label="dameng">达梦 (DM)</el-radio-button>
<!-- Oracle 暂未实装完成,先隐藏;见 docs/WORKLOG.md -->
<!-- <el-radio-button label="oracle">Oracle</el-radio-button> -->
```
注释而非删除:方便以后找回(grep `oracle` 即可定位)。
### 没改的地方
- `onDbTypeChange` 的 `DB_DEFAULT_PORTS` **保留 oracle:1521** —— radio 隐藏后该函数不会再被 oracle 触发,留着没坏处;以后放出来不用改
- `db_adapter.py` 的 oracle 分支 / SQL 模板 / `db_defaults.yaml` —— 都不动,等实装完成直接复用
### 影响
- 用户层面:页面只剩 MySQL / 达梦 两个选项,行为与 Oracle 引入前一致
- 后端层面:oracle 路径代码保留,回归测试可以继续在内部跑
- 文档层面:CLAUDE.md / README 提到「支持 MySQL / 达梦 / Oracle」——**未改**,等实装完成再统一改文档
---
## 2026-08-11 · 修复:达梦 ALL_TABLES 无 CREATED / LAST_DDL_TIME 列 —— 时间戳给 NULL
### 现象
修了 COMMENTS 之后,抽数据字典再报:
```
[CODE:-2207]第24 行附近出现错误:
无法解析的成员访问表达式[T.CREATED]
```
### 根因
达梦 `ALL_TABLES` **也没有 `CREATED` 列**(和 `BYTES`、`COMMENTS` 是同一类问题)。
达梦 `ALL_TABLES` 的实际列:OWNER / TABLE_NAME / TABLESPACE_NAME / NUM_ROWS / AVG_ROW_LEN / LAST_ANALYZED / TEMPORARY / ... — **时间戳一概没有**。
Oracle 的 `ALL_TABLES` 有 CREATED / LAST_DDL_TIME,达梦要拿时间戳得查 `ALL_OBJECTS`(OBJECT_TYPE='TABLE' 过滤)。
### 修复
[web/sql/info_schema/list_tables.dameng.sql](web/sql/info_schema/list_tables.dameng.sql):CREATED / LAST_DDL_TIME 都给 NULL。
字段治理主线(字段名、字段类型、注释、空值率、长度规范)用不到表创建/更新时间,先 NULL 兜底不影响主流程。如果以后要看表的时间戳,再 LEFT JOIN `ALL_OBJECTS`。
Oracle 的 [list_tables.oracle.sql](web/sql/info_schema/list_tables.oracle.sql) 保持原样(Oracle 这两列是有的)。
### 经验教训(继续追加)
达梦 `ALL_TABLES` vs Oracle `ALL_TABLES` 列差异:
| 列 | MySQL `TABLES` | Oracle `ALL_TABLES` | 达梦 `ALL_TABLES` |
|---|---|---|---|
| 表名 / 行数 / 表注释 | ✓ | ✓ | ✓(注释在 ALL_TAB_COMMENTS) |
| 字节数 DATA_LENGTH | `DATA_LENGTH` | `BYTES` | ❌(NULL) |
| 创建时间 | `CREATE_TIME` | `CREATED` | ❌(要查 ALL_OBJECTS) |
| 最后 DDL | `UPDATE_TIME` | `LAST_DDL_TIME` | ❌(要查 ALL_OBJECTS) |
下次写达梦字典 SQL 直接对照这张表,避免再踩。
### 用户待自测
再点「测试连接」,数据字典应能完整抽出来。
---
## 2026-08-11 · 修复:达梦 ALL_TABLES 无 COMMENTS 列 —— 表注释 LEFT JOIN ALL_TAB_COMMENTS
### 现象
修了 BYTES 之后,连接成功抽数据字典时报:
```
[CODE:-2111]第21 行附近出现错误:
无效的列名[COMMENTS]
```
### 根因
达梦 `ALL_TABLES` **也没有 `COMMENTS` 列**。这跟之前列注释的 `[CODE:-2207] C.COMMENTS` 是**同一类问题**:
- 列注释 → `ALL_COL_COMMENTS`
- 表注释 → `ALL_TAB_COMMENTS`
Oracle 同样没有 `ALL_TABLES.COMMENTS`(我没验证就假设 Oracle 有,顺手统一掉)。
### 修复
两个 SQL 文件都改成 LEFT JOIN(达梦 + Oracle 同结构):
[web/sql/info_schema/list_tables.dameng.sql](web/sql/info_schema/list_tables.dameng.sql):
```sql
SELECT t.TABLE_NAME,
...,
tc.COMMENTS AS table_comment,
...
FROM ALL_TABLES t
LEFT JOIN ALL_TAB_COMMENTS tc
ON tc.OWNER = t.OWNER
AND tc.TABLE_NAME = t.TABLE_NAME
WHERE t.OWNER = UPPER(?)
```
[web/sql/info_schema/list_tables.oracle.sql](web/sql/info_schema/list_tables.oracle.sql) 同样改造。
LEFT JOIN 而非 INNER:保留没有表注释的表(注释为 NULL)。
### 经验教训
达梦和 Oracle 的字典视图同源,**注释永远不在主表里**:
- `ALL_TAB_COLUMNS` ← 列元数据(无注释)
- `ALL_TAB_COMMENTS` ← 表注释
- `ALL_COL_COMMENTS` ← 列注释
- `ALL_TABLES` ← 表元数据(无注释)
以后写达梦/Oracle 字典 SQL 直接照这四张表的结构走,别再踩这个坑。
### 用户待自测
再点「测试连接」,数据字典应该能完整抽出来(表数 + 字段数 + 字段注释 + 表注释全有)。
---
## 2026-08-11 · 修复:达梦 ALL_TABLES 无 BYTES 列 —— DATA_LENGTH 改 NULL
### 现象
连接成功、抽数据字典时:
```
[CODE:-2111]第17 行附近出现错误:
无效的列名[BYTES]
```
### 根因
达梦 DM8 的 `ALL_TABLES` **没有 `BYTES` 列**(这点和 Oracle 不一样)。
- Oracle `ALL_TABLES`:有 `BYTES`(表占用的字节数)
- 达梦 `ALL_TABLES`:无 `BYTES`,要拿字节数得查 `DBA_SEGMENTS`(按段聚合)
字段治理主线只用 表名 + 行数 + 注释,`DATA_LENGTH` 只是辅助信息,直接 NULL 掉最稳。
### 修复
[web/sql/info_schema/list_tables.dameng.sql](web/sql/info_schema/list_tables.dameng.sql) L10:
```sql
-- 改前
BYTES AS DATA_LENGTH,
-- 改后
NULL AS DATA_LENGTH,
```
Oracle 的 [list_tables.oracle.sql](web/sql/info_schema/list_tables.oracle.sql) 保持原样(Oracle `BYTES` 是真实存在的)。
### 影响范围
- **功能**:零影响——DATA_LENGTH 不是字段治理的输入。
- **UI**:表大小那一列在达梦里恒为空。如果以后要看真实字节数,再单独加一条 `DBA_SEGMENTS` 查询。
### 用户待自测
再点「测试连接」,看是否能抽到表列表(不再报 BYTES 错)。
---
## 2026-08-11 · UI:数据库类型切换端口默认扩为三端口(MySQL 3306 / 达梦 5236 / Oracle 1521)
### 需求
> "达梦的默认端口是 5236,切换数据库类型的时候改一下端口,和 mysql 还有 Oracle 一样"
### 改动
[web/static/index.html](web/static/index.html) `onDbTypeChange` 从「二元判断」改为「端口映射表」:
```js
const DB_DEFAULT_PORTS = {
mysql: 3306,
dameng: 5236,
oracle: 1521,
};
function onDbTypeChange(newType) {
const oldPort = form.port;
const newPort = DB_DEFAULT_PORTS[newType];
// 只在「当前端口是某个默认端口」时才自动切换;保留用户自定义
if (newPort && Object.values(DB_DEFAULT_PORTS).includes(oldPort)) {
form.port = newPort;
}
if (newType === 'oracle') {
form.database = form.database || 'ORCL';
}
}
```
### 关键改进点
之前的逻辑是硬编码 `form.port === 3306 / 1521` 两个值判断,达梦的 5236 永远不会触发。新版:
- 用 `DB_DEFAULT_PORTS` 映射表,**新增数据库类型时只需往里加一行**
- 加 `Object.values(...).includes(oldPort)` 守卫:**只有当用户没改过端口(仍是某个库的默认端口)时才覆盖**;如果用户已经手动改成 3307、1522 之类,不去碰他的设置
### 行为矩阵(切换前 → 切换后)
| 当前 db_type | 当前 port | 切到 mysql | 切到 dameng | 切到 oracle |
|---|---|---|---|---|
| 任意 | 3306 | 不变 | **5236** | **1521** |
| 任意 | 5236 | **3306** | 不变 | **1521** |
| 任意 | 1521 | **3306** | **5236** | 不变 |
| 任意 | 3307 / 其他 | 不变 | 不变 | 不变 |
### 验证
页面无 JS 报错;切到「达梦」时端口自动填 5236,切回 MySQL 回到 3306,切到 Oracle 跳 1521。手改端口(如 5238)后再切类型不会被覆盖。
---
## 2026-08-11 · 修复:达梦/Oracle 列注释字段缺失 —— LEFT JOIN ALL_COL_COMMENTS
### 现象
连接成功后,抽取数据字典时报:
```
[CODE:-2207]第23 行附近出现错误:
无法解析的成员访问表达式[C.COMMENTS]
```
### 根因
`ALL_TAB_COLUMNS` **没有 `COMMENTS` 列**(这一点 Oracle 和 达梦 都一样)。列注释在另一张表 `ALL_COL_COMMENTS` 里,需要 LEFT JOIN 出来。
我之前照 MySQL 风格写 `c.COMMENTS AS column_comment` 是错的。MySQL 的 `INFORMATION_SCHEMA.COLUMNS.COLUMN_COMMENT` 是一字段直出,Oracle/达梦 的列注释是单独一张表。
### 修复
- [web/sql/info_schema/list_columns.dameng.sql](web/sql/info_schema/list_columns.dameng.sql)
- [web/sql/info_schema/list_columns.oracle.sql](web/sql/info_schema/list_columns.oracle.sql)
两处都改为:
```sql
SELECT
...,
cc.COMMENTS AS column_comment, -- ← 不再用 c.COMMENTS
NULL AS extra
FROM ALL_TAB_COLUMNS c
LEFT JOIN ALL_COL_COMMENTS cc
ON cc.OWNER = c.OWNER
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.COLUMN_NAME = c.COLUMN_NAME
WHERE c.OWNER = ? -- 达梦
-- 或
WHERE c.OWNER = UPPER(:1) -- Oracle
ORDER BY c.TABLE_NAME, c.COLUMN_ID
```
LEFT JOIN 而非 INNER JOIN:保留没有注释的列(注释为 NULL)。
### 验证
- 两个 SQL 模板都能正确渲染
- LEFT JOIN 语法对达梦 / Oracle 都通用(达梦方言兼容 Oracle 大部分语法)
### 兜底
如果这台达梦版本 `ALL_COL_COMMENTS` 表不存在(极少数老版本),会再报 `[表或视图不存在]`。到时候用 `SELECT TABLE_NAME FROM ALL_TABLES WHERE TABLE_NAME LIKE '%COL_COMMENT%'` 查真实表名换上去。
### 用户待自测
重启 web 服务,再点「测试连接」,看数据字典是否能完整抽出来(表数 + 字段数 + 字段注释都不为空)。
## 2026-08-11 · 修复:达梦查询行类型不匹配 —— tuple 路径补齐
### 现象
上一轮 `to_dm_kwargs` 修复后,连接已建立(31ms),但 `test_connection` 紧接着报:
```
AttributeError: 'tuple' object has no attribute 'items'
```
### 根因
- PyMySQL 在 `connect()` 里设了 `cursorclass=DictCursor`,查询行是 dict,`.items()` 正常
- dmPython / oracledb / cx_Oracle 默认返回 **tuple**,没有 `.items()`
- `db_adapter.normalize_rows()` 老逻辑只处理 dict,碰到 tuple 就炸
### 修复
[web/core/db_adapter.py](web/core/db_adapter.py) 加新工具函数 [`_rows_as_dicts(rows, col_names)`](web/core/db_adapter.py) 统一处理两种形态:
```python
def _rows_as_dicts(rows, col_names: list[str]) -> list[dict]:
sample = rows[0]
if isinstance(sample, dict):
# PyMySQL DictCursor 路径
return [{normalize_column_name(k): v for k, v in row.items()} for row in rows]
# tuple / list —— 用 cur.description 兜底转 dict
return [{normalize_column_name(c): v for c, v in zip(col_names, row)} for row in rows]
```
`fetchall` 和 `fetchone` 都改走这条路径,提前从 `cur.description` 拿列名:
```python
cur.execute(sql, params or ())
rows = cur.fetchall()
col_names = [d[0] for d in (cur.description or [])] # ← 关键
return _rows_as_dicts(rows, col_names)
```
老的 `normalize_rows` 保留(兼容外部直接调用方),但 `DBConnection` 内部走新函数。
### 验证
- 4 个 Python 包 import OK
- `_rows_as_dicts` 三种场景:
- tuple 输入 + col_names → 转 dict ✓
- dict 输入 + 空 col_names → 走 .items() ✓
- 空集 / None → 边界 OK ✓
### 用户待自测
重启 web 服务,再点「测试连接」+ 「启动治理任务」,应该能完整跑通。
## 2026-08-11 · 修复:达梦 to_dm_kwargs() 关键字参数名错误
### 现象
增强后的 [scripts/_probe_dm.py](scripts/_probe_dm.py) 排除了字符集问题(GB18030 也炸),剩下唯一可能是 **dmPython 不接受我传的 kwarg**。用户分享了一份同环境已验证可连的工作脚本 `export_all_tables.py`,对比发现:
| 参数 | 工作脚本(✓) | 我之前(✗) |
|---|---|---|
| 主机 | `server=host` | `host=host` ← 错 |
| schema | 连上后 `SET SCHEMA` | `schema=database` ← 错 |
| charset | 不传(默认 GB18030) | `charset=utf8mb4` ← 错 |
| connect_timeout | 不传 | `connect_timeout=10` ← 错 |
dmPython 2.5.x 对未知关键字参数非常敏感:传错就在 C 扩展里抛错,C 层没正确转成 Python 异常,吐出 `SystemError: <class 'dmPython.Connection'> returned a result with an exception set`,真实错误(`unknown keyword: host`)被吞。
### 修复
[web/core/db_adapter.py](web/core/db_adapter.py) `to_dm_kwargs()` 改为:
```python
return {
"user": self.user,
"password": self.password,
"server": self.host, # ← 不是 host
"port": self.port,
}
```
把 `schema` / `charset` / `connect_timeout` 全部去掉,理由写在 docstring 里:
- **schema**:达梦 schema = 用户,登录用户默认就在自己的 schema;如果要切到别的 schema,连上后单独 `SET SCHEMA "xxx"` 即可(参考工作脚本)
- **charset**:dmPython 默认 GB18030(与 disql 一致),部分驱动版本对 `local_code` 取值校验严苛(CODE:-70023),让驱动默认最稳
- **connect_timeout**:dmPython 2.5.x 对这个 kwarg 的支持视 wheel 而异,不传最安全(超时由 OS TCP 重传决定)
### 验证
- `db_adapter.py` 语法 OK
- `to_dm_kwargs()` 输出:`{'user': 'HGWXZJ', 'password': '***', 'server': '192.168.20.24', 'port': 5236}`
- 关键检查:含 `server` ✓、不含 `host`/`schema`/`charset`/`connect_timeout` ✓
### 用户待自测
重启 web 服务,再点「测试连接」,应该能直接通。
## 2026-08-11 · 修复:达梦 dmPython SystemError —— 增强错误日志 + 诊断脚本
### 现象
用户接黄冈/武汉公积金达梦(192.168.20.24:5236 / HGWXZJ)报:
```
SystemError: <class 'dmPython.Connection'> returned a result with an exception set
```
### 根因
dmPython 是 C 扩展,连接失败时**真实达梦异常被 C 层吃掉**,只透出一个 `SystemError`。前端看到的 `连接失败: <class 'dmPython.Connection'> returned...` 完全无诊断价值。
可能原因(按概率排序):
1. **dmPython 包版本 vs 本机达梦客户端(`dmdpi.dll` / `libdmdpi.so`)不匹配** —— 最常见
2. **charset 不被达梦支持** —— 默认 `utf8mb4` 在达梦上经常挂,达梦常用 `GB18030` / `UTF-8`
3. **网络/端口/账号密码/服务名错** —— 真实错误被吞
### 改动
#### 1. [web/core/db_adapter.py](web/core/db_adapter.py) `__enter__` dmPython 分支
- 把 `dmPython.connect()` 包在 `try/except SystemError` 里
- 抓到 SystemError 时额外打印:
- 完整入参 `to_dm_kwargs()`(方便核对 host/port/user/schema/charset)
- 三条常见原因提示
- 指向独立诊断脚本 `scripts/_probe_dm.py`
#### 2. 新增独立诊断脚本 [scripts/_probe_dm.py](scripts/_probe_dm.py)
不依赖 web 框架,直接跑 dmPython 看真实异常:
```
python scripts/_probe_dm.py --host 192.168.20.24 --port 5236 --user HGWXZJ --schema HGWXZJ
```
脚本分 4 步:
1. **TCP 探测** —— 确认网络通 + 端口开 + 抓 banner
2. **dmPython 版本检查** —— 确认驱动装上了
3. **dmPython.connect()** —— 用 utf8mb4 试一次,捕获真实异常
4. **charset=GB18030 再试** —— 验证是不是字符集问题
### 用户待操作
```bash
python scripts/_probe_dm.py --host 192.168.20.24 --port 5236 --user HGWXZJ --schema HGWXZJ
# 密码会交互式问,不会回显
```
跑出来的输出贴给我,我能直接定位是版本问题、字符集问题、还是账号问题。
## 2026-08-11 · 修复:Oracle 11.2 DPY-3010 —— 锁定 oracledb 1.4.x
### 用户反馈
```
SQL> SELECT banner FROM v$version WHERE banner LIKE 'Oracle%';
Oracle Database 11g Enterprise Edition Release 11.2.0.4.0 - 64bit Production
```
11.2.0.4 仍报 `DPY-3010`。oracledb **2.0 之后** thin 模式收紧了对老 Oracle 的支持,11.2 也会被拒。
### 用户决策
> **A: 降 oracledb 到 1.4.x(最快)**
> requirements.txt 锁到 oracledb==1.4.2;db_adapter.py 把 tcp_connect_timeout 改回 connect_timeout
### 改动总览(3 文件)
#### 1. [web/requirements.txt](web/requirements.txt)
```diff
-oracledb>=2.0.0 # Oracle 数据库驱动(thin 模式无需 Instant Client)
+oracledb>=1.4.2,<2.0 # 锁定 1.x:2.0+ thin 模式收紧老 Oracle 支持
+ # 1.4.x 对 11.2 ~ 最新版本都正常
+ # 将来只连 12c+/19c/21c 可放开到 >=2.0
```
#### 2. [web/core/db_adapter.py](web/core/db_adapter.py) `to_oracle_kwargs()`
```diff
- "tcp_connect_timeout": float(self.connect_timeout),
+ "connect_timeout": float(self.connect_timeout),
```
oracledb 1.x 的超时参数叫 `connect_timeout`,2.0 改名 `tcp_connect_timeout`。
#### 3. [web/README.md](web/README.md)
- 三数据库适配表:`oracledb (thin)` → `oracledb 1.4.x (thin)`
- DPY-3010 段落改为:以「锁 1.x 解决 11.2 兼容性」为首选方案,thick 模式作为 10g/9i 兜底
### 验证
- `db_adapter.py` 语法 OK
- `to_oracle_kwargs()` 输出:`{'user':..., 'host':..., 'service_name':..., 'connect_timeout': 10.0}` —— 用 1.x 风格的 `connect_timeout`
- 整个 web 包 import 无报错
### 用户待操作
```bash
pip install "oracledb>=1.4.2,<2.0" --force-reinstall
pip show oracledb # 应显示 1.4.x
```
然后重启 web 服务,再点「测试连接」。
## 2026-08-11 · 修复:Oracle thin 模式 DPY-3010 —— 自动切 thick 模式
### 现象
接公积金老版本 Oracle 报:
```
DPY-3010: connections to this database server version are not supported
by python-oracledb in thin mode
```
oracledb 4.0.2 的 **thin 模式**只支持 Oracle 11.2+,老版本(10g / 9i)必须切 **thick 模式**(OCI 原生库)。
### 修复
[web/core/db_adapter.py](web/core/db_adapter.py) 加 Oracle Instant Client 自动检测:
- **新模块级函数** [`_ensure_oracle_client(lib_dir=None)`](web/core/db_adapter.py) —— 调 `oracledb.init_oracle_client()` 尝试加载 Instant Client
- 加载成功 → 返回 `"thick"`,后续连接走 OCI 原生库(支持 Oracle 9i+)
- 加载失败(无 Instant Client)→ 返回 `"thin"`,不抛异常,上层继续用 thin 试连,把 DPY-3010 报给用户
- 进程级幂等:模块级 `_oracle_client_initialized` 标志,重复调用 no-op
- **新字段** `DBConfig.oracle_client_dir: Optional[str] = None` —— 显式指定 `oci.dll` 所在目录;None 时自动搜索 PATH / 注册表 / `ORACLE_HOME`
- **`__enter__` Oracle 分支** — 先 `_ensure_oracle_client()`,记下实际模式,再 connect
- **错误消息更友好** — 没找到 Instant Client 时日志明确提示下载链接
[web/core/models.py](web/core/models.py) `ConnectRequest` 透传 `oracle_client_dir`(None 时用自动搜索)
[web/api/routes.py](web/api/routes.py) 把请求里的 `oracle_client_dir` 透传给 `DBConfig`
[web/README.md](web/README.md)「三数据库适配说明」加 DPY-3010 处置段落 + Instant Client 下载链接
### 验证
- 3 个 Python 文件 `ast.parse` 通过
- `DBConfig.oracle_client_dir` 默认 `None`,可显式指定路径
- `_ensure_oracle_client()` 在没装 Instant Client 时返回 `"thin"`(不抛)
- `ConnectRequest.oracle_client_dir` 默认 `None`
- 整个 web 包 import 无报错
### 用户待操作
1. **确认 Oracle 版本**:用 `sqlplus user/pwd@//host:port/service_name` 或 OEM 看 banner,确认是不是低于 11.2
2. **若是 11.2+**:应该 thin 模式就能连 —— 上一轮 `connect_timeout` 修复已就绪
3. **若是 10g / 9i 等老版本**:
- 下载 [Oracle Instant Client](https://www.oracle.com/database/technologies/instant-client/downloads.html)(Basic 包,约 100MB)
- Windows:解压到 `C:\oracle\instantclient_19_8\`(任选路径)
- 启动前把路径加到 PATH(或在请求里显式传 `oracle_client_dir`)
- 重启 web 服务
4. **重启后再测**:日志应见 `[DB] Oracle Instant Client 已加载(thick 模式)`
## 2026-08-11 · 修复:Oracle 连接 connect_timeout 参数名错误
### 现象
真实 Oracle 环境(192.168.20.196:1521 / ZFGJJ)跑「测试连接」报错:
```
TypeError: connect() got an unexpected keyword argument 'connect_timeout'
```
### 根因
[web/core/db_adapter.py](web/core/db_adapter.py) 上一版 `to_oracle_kwargs()` 把 PyMySQL 的 `connect_timeout`(秒)误传给 oracledb。但 `oracledb.connect()` 不接受 `connect_timeout` 这个 kwarg,正确的参数名是 **`tcp_connect_timeout`**(单位也是秒,默认 20.0)。
```python
# 报错版
return {
...
"connect_timeout": self.connect_timeout * 1000, # ← 错的
}
```
### 修复
[web/core/db_adapter.py:83](web/core/db_adapter.py#L83) 改为:
```python
"tcp_connect_timeout": float(self.connect_timeout),
```
### 验证
- `DBConfig.to_oracle_kwargs()` 输出:`{'user': 'GJJ80', 'password': '...', 'host': '192.168.20.196', 'port': 1521, 'service_name': 'ZFGJJ', 'tcp_connect_timeout': 10.0}`
- 用 `inspect.signature(oracledb.connect)` 比对,所有 6 个 kwarg 都在 v4.0.2 接受的参数列表里 ✓
- MySQL / 达梦 路径不受影响(`to_pymysql_kwargs` / `to_dm_kwargs` 仍用 `connect_timeout`)
## 2026-08-11 · 数据库连接默认值移到配置文件
### 用户原话
> 把链接数据库的默认值(主机IP,用户名,密码,数据库名)写到一个单独的配置文件里面,页面初期渲染的时候从配之里面读取默认值写道页面控件中
### 改动总览(1 增 + 2 改)
#### 1. 新增配置文件 — [web/configs/db_defaults.yaml](web/configs/db_defaults.yaml)
- 4 个字段:`host` / `user` / `password` / `database`
- 默认值与原 `index.html` 硬编码值一致(向后兼容)
- YAML 注释提醒:填真实密码需加入 `.gitignore`,不要提交
#### 2. 新增 API 端点 — [web/api/routes.py](web/api/routes.py)
- `GET /api/db-defaults` → 读取 yaml,返回 4 字段 dict
- 文件缺失 / YAML 损坏 → 返回 `{}`(前端走 JS 兜底)
- 只返回前端表单关心的字段,过滤掉无关配置
#### 3. 前端拉取并覆盖 — [web/static/index.html](web/static/index.html)
- `form` 初始化值保留作 JS 兜底(API 失败时仍可用)
- 新增 `loadDbDefaults()`:`fetch('/api/db-defaults')` → 覆盖 `host/user/password/database`
- `onMounted` 里最先调用(在 `loadSteps / loadAnalysisTree / loadStandards` 之前)
- `setup()` return 块导出 `loadDbDefaults`
### 验证
- `routes.py` 语法 OK
- `db_defaults.yaml` 解析 OK
- 端点正常返回:`{'host': '192.168.20.10', 'user': 'root', 'password': '', 'database': 'smart-build'}`
- 端点配置缺失返回:`{}`
- 端点 YAML 损坏返回:`{}`
- index.html 8 个关键位置(函数定义、API 调用、4 个字段覆盖、onMounted、return 导出)全部到位
### 用户待自测
- 浏览器打开页面,看「主机IP/用户名/数据库名」是否显示 `db_defaults.yaml` 里的值
- 改 `db_defaults.yaml` 里的值,重启 web 服务,刷新页面确认生效
- 把 `db_defaults.yaml` 删掉,确认页面仍能显示 JS 兜底值(不报错)
## 2026-08-11 · UI 恢复「达梦」+ 新增 Oracle 适配
### 用户原话
> 在数据库类型中,显示达梦(之前应该实装了),然后增加oracle适配
### 关键决策
- **Oracle 驱动**:`oracledb`(thin 模式无需 Instant Client,pip install 即用)
- **SQL 跨方言**:`web/sql/standards/*.sql` 里硬编码的反引号统一重构为 `${... | quote}` filter,不引入 `.oracle.sql` 变体
- **UI 暴露度**:MySQL / 达梦 / Oracle 三个 radio 全显示
### 改动总览(15 改 + 2 增)
#### 1. 后端核心 — [web/core/db_adapter.py](web/core/db_adapter.py)
- `DBConfig.db_type` 注释 / `quote_ident` docstring / 报错文案 全部从「mysql / dameng」改为「mysql / dameng / oracle」
- 新增 `to_oracle_kwargs()` —— Oracle 走 `service_name`(用 `database` 字段),`connect_timeout` 转毫秒
- `TEXT_TYPES` 追加 Oracle 类型 `nclob` / `long`
- `_select_driver("oracle")` 延迟导入 `oracledb`,未安装时给与 dmPython 一致的中文友好报错
- `__enter__` 新增 oracledb.connect 分支
#### 2. Oracle 数据字典 SQL 模板
- 新增 [web/sql/info_schema/list_columns.oracle.sql](web/sql/info_schema/list_columns.oracle.sql) —— `ALL_TAB_COLUMNS`,`:1` 绑定参数,`OWNER = UPPER(:1)` 兼容小写 schema,`DATA_TYPE` 在字符类型上拼 `DATA_LENGTH`
- 新增 [web/sql/info_schema/list_tables.oracle.sql](web/sql/info_schema/list_tables.oracle.sql) —— `ALL_TABLES`,结构与达梦一致
#### 3. SQL 模板跨方言统一(4 个文件重构)
把硬编码反引号替换为 `${... | quote}` filter,使同一 SQL 在三种方言下都正确:
- [web/sql/standards/sample_field_groups.sql](web/sql/standards/sample_field_groups.sql)
- [web/sql/standards/sample_field_pairs.sql](web/sql/standards/sample_field_pairs.sql)
- [web/sql/standards/sample_field_groups_single.sql](web/sql/standards/sample_field_groups_single.sql)
- [web/sql/standards/sample_field_pairs_two_text.sql](web/sql/standards/sample_field_pairs_two_text.sql)
`sample_field_values.sql` 已经是 `${... | quote}` 写法,无需改。
#### 4. SQL 加载器 — [web/sql/loader.py](web/sql/loader.py)
- `render()` docstring 加 `oracle`
- `list_templates()` 的 dialect 白名单加 `oracle`
#### 5. UI — [web/static/index.html](web/static/index.html)
- 数据库类型 radio 从 1 个 → 3 个:`MySQL` / `达梦 (DM)` / `Oracle`
- 字符集下拉新增 `al32utf8` / `zhs16gbk`
- `setup()` 新增 `onDbTypeChange(newType)`:选 Oracle 自动把 port 改 1521、切回 mysql/dameng 改回 3306
- 在 return 块导出 `onDbTypeChange`
#### 6. 依赖 & 启动
- [web/requirements.txt](web/requirements.txt) 新增 `oracledb>=2.0.0`
- [web/start.py](web/start.py) `check_optional()` 新增 oracledb 检测段,照搬 dmPython 模板(带版本号)
#### 7. 文档与元信息
- [web/core/models.py](web/core/models.py) `ConnectRequest.db_type` 描述
- [web/core/__init__.py](web/core/__init__.py) docstring
- [web/core/step_impl/__init__.py](web/core/step_impl/__init__.py) docstring
- [web/configs/defaults.yaml](web/configs/defaults.yaml) `db_types` 加 `oracle`
- [web/app.py](web/app.py) FastAPI description
- [web/README.md](web/README.md) 多处替换:「双数据库」→「三数据库」,新增 Oracle 适配说明表格 + Oracle 渲染示例 + service_name 提示
### 验证
- 7 个 Python 文件 `ast.parse` 全部通过
- `defaults.yaml` YAML 解析 OK,db_types = `['mysql', 'dameng', 'oracle']`
- `quote_ident` 三方言输出正确:mysql → 反引号,dameng/oracle → 双引号
- `DBConfig.to_oracle_kwargs()` 输出 `service_name` + `connect_timeout*1000`
- `_select_driver('oracle')` 未装 oracledb 时给友好提示
- `_select_driver('postgres')` 报错文案已含三方言
- `TEXT_TYPES` 含 `nclob` / `long`
- 5 个 standards SQL × 3 个方言渲染后无残留占位符(注释里的 `${... | quote}` 文档正确保留)
- 整个 web 包 import 无报错
### 用户待自测
- 真实 Oracle 环境跑一次「测试连接 → 跑全流程」
- 确认 `database` 字段填的是 service_name(如 `ORCLPDB1`)而非 SID
- MySQL / 达梦 路径回归
## 2026-08-10 · 修复:分析结果折叠树全部落入「其他」组 ## 2026-08-10 · 修复:分析结果折叠树全部落入「其他」组
### 用户反馈 ### 用户反馈
......
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""达梦连接独立诊断脚本
用法:
python scripts/_probe_dm.py
# 或带参:
python scripts/_probe_dm.py --host 192.168.20.24 --port 5236 \
--user HGWXZJ --password *** --schema HGWXZJ
为什么需要它:
dmPython 是 C 扩展,连接失败时经常抛 SystemError 而把真实异常吃掉。
直接跑这个脚本,能看到 dmPython 内部到底报了什么错。
"""
from __future__ import annotations
import argparse
import socket
import sys
import traceback
from pathlib import Path
def tcp_probe(host: str, port: int, timeout: float = 5.0) -> None:
"""先做个 TCP 探测,确认网络通 + 端口开着"""
print(f"\n[1/4] TCP 探测 {host}:{port} ...")
try:
with socket.create_connection((host, port), timeout=timeout) as s:
print(f" ✓ TCP 通,端口开着")
# 试着读一下 banner(达梦服务端会主动发 banner)
try:
s.settimeout(2.0)
banner = s.recv(256)
print(f" banner: {banner!r}")
except socket.timeout:
print(f" (无 banner 或超时——这不一定代表错)")
except (socket.timeout, ConnectionRefusedError, OSError) as e:
print(f" ✗ TCP 不通: {type(e).__name__}: {e}")
print(f" → 网络/防火墙/达梦服务问题,先确认 dm 服务在跑、端口对得上")
def probe_dm_python(host: str, port: int, user: str, password: str,
schema: str, charset: str, connect_timeout: int) -> None:
"""直接调 dmPython.connect 看真实异常"""
print(f"\n[2/4] dmPython 版本与可导入性 ...")
try:
import dmPython
print(f" ✓ dmPython 已装")
print(f" 包位置: {getattr(dmPython, '__file__', '未知')}")
print(f" __version__: {getattr(dmPython, '__version__', '未知(老版本或二进制 wheel 不暴露)')}")
except ImportError:
print(f" ✗ dmPython 未装,请先 pip install dmPython")
return
# ---- pip show dmPython(拿到版本号 + 安装来源) ----
print(f"\n pip show dmPython:")
try:
import subprocess
r = subprocess.run(
[sys.executable, "-m", "pip", "show", "dmPython"],
capture_output=True, text=True, timeout=15,
)
if r.returncode == 0:
for line in r.stdout.splitlines():
if line.startswith(("Version:", "Location:", "Source:", "Requires:")):
print(f" {line}")
else:
print(f" (pip show 失败: {r.stderr.strip().splitlines()[0] if r.stderr else '?'})")
except Exception as e:
print(f" (无法执行 pip show: {e})")
# ---- 查找本机达梦客户端动态库 ----
print(f"\n 本机达梦客户端动态库扫描:")
found = []
for pat in ("dmdpi.dll", "libdmdpi.so", "libdmdpi.so.1"):
# PATH 目录 + 常见安装路径
candidates = []
# 从 PATH
for p in sys.path:
if "site-packages" in p or "dist-packages" in p:
continue
candidates.append(Path(p) / pat)
# 常见路径
candidates += [
Path(r"C:\dmdbms"),
Path(r"C:\Program Files\dmdbms"),
Path(r"D:\dmdbms"),
Path("/opt/dmdbms"),
Path("/usr/lib"),
Path("/usr/local/lib"),
]
for c in candidates:
if c.exists():
found.append(str(c))
if found:
for f in sorted(set(found)):
print(f" ✓ {f}")
else:
print(f" ✗ 未找到 dmdpi.dll / libdmdpi.so —— 达梦客户端可能没装")
print(f"\n[3/4] dmPython.connect({host}:{port}, user={user}, schema={schema}, charset={charset}) ...")
try:
conn = dmPython.connect(
host=host, port=port,
user=user, password=password,
schema=schema, charset=charset,
connect_timeout=connect_timeout,
)
print(f" ✓ 连接成功!")
cur = conn.cursor()
cur.execute("SELECT 1")
print(f" ✓ SELECT 1 返回: {cur.fetchone()}")
cur.close()
conn.close()
return
except SystemError as e:
# 达梦真实错误被 C 层吃了 —— 只能给诊断建议
print(f" ✗ SystemError(C 扩展吞了真实异常): {e}")
print(f" 这是 dmPython 的经典坑。真实错误通常是:")
print(f" a) dmPython 包版本与本机达梦客户端(libdmdpi.so / dmdpi.dll)版本不匹配")
print(f" → 重装匹配版本的 dmPython,或升级/降级达梦客户端")
print(f" b) charset 不被达梦支持")
print(f" → 试试 charset='GB18030' 或 'UTF-8'(不要 utf8mb4)")
print(f" c) 服务名 / 账号 / 密码错(达梦错误码被吃了)")
print(f" → 用 disql 连同一组参数验证一下")
except Exception as e:
print(f" ✗ {type(e).__name__}: {e}")
traceback.print_exc()
print(f"\n[4/4] 改 charset=GB18030 再试一次 ...")
try:
conn = dmPython.connect(
host=host, port=port,
user=user, password=password,
schema=schema, charset="GB18030",
connect_timeout=connect_timeout,
)
print(f" ✓ GB18030 连接成功!→ charset 是不支持的 utf8mb4,改成 GB18030")
conn.close()
return
except Exception as e:
print(f" ✗ GB18030 也失败: {type(e).__name__}: {e}")
if isinstance(e, SystemError):
print(f" → charset 不是原因。剩余最可能:")
print(f" - dmPython 包与本机达梦客户端版本不匹配")
print(f" - 本机没装达梦客户端(只有 pip 的 dmPython wheel)")
print(f" - 账号/密码/服务名错(被 C 层吃了)")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="192.168.20.24")
parser.add_argument("--port", type=int, default=5236)
parser.add_argument("--user", default="HGWXZJ")
parser.add_argument("--password", default="", help="(不回显,建议直接跑交互式填)")
parser.add_argument("--schema", default="HGWXZJ")
parser.add_argument("--charset", default="utf8mb4")
parser.add_argument("--connect-timeout", type=int, default=10)
args = parser.parse_args()
if not args.password:
import getpass
args.password = getpass.getpass("达梦密码: ")
print("=" * 60)
print(f"达梦连接诊断: {args.host}:{args.port} schema={args.schema}")
print("=" * 60)
tcp_probe(args.host, args.port)
probe_dm_python(
args.host, args.port, args.user, args.password,
args.schema, args.charset, args.connect_timeout,
)
print("\n排查建议优先级:")
print(" 1. 看 TCP 探测是否通")
print(" 2. 看 charset 是不是 GB18030")
print(" 3. 用 disql 客户端同参数连一下,验证账号密码对不对")
print(" 4. 看 dmPython 与本机达梦客户端版本是不是匹配")
if __name__ == "__main__":
main()
...@@ -15,7 +15,7 @@ pip install -r web/requirements.txt ...@@ -15,7 +15,7 @@ pip install -r web/requirements.txt
依赖包括: 依赖包括:
- `fastapi` / `uvicorn` — Web 服务 - `fastapi` / `uvicorn` — Web 服务
- `pydantic` / `sse-starlette` — 数据模型 + SSE 流 - `pydantic` / `sse-starlette` — 数据模型 + SSE 流
- `pymysql` / `dmPython` — MySQL / 达梦驱动 - `pymysql` / `dmPython` / `oracledb` — MySQL / 达梦 / Oracle 驱动
- `anthropic` — Claude API(LLM 增强) - `anthropic` — Claude API(LLM 增强)
- `python-docx` — Word 报告生成 - `python-docx` — Word 报告生成
...@@ -111,7 +111,7 @@ python -m uvicorn web.app:app --host 0.0.0.0 --port 8765 ...@@ -111,7 +111,7 @@ python -m uvicorn web.app:app --host 0.0.0.0 --port 8765
| 特性 | 说明 | | 特性 | 说明 |
|------|------| |------|------|
| 🖥️ **Web 界面** | 浏览器访问,Vue 3 + Element Plus 响应式布局 | | 🖥️ **Web 界面** | 浏览器访问,Vue 3 + Element Plus 响应式布局 |
| 🔌 **双数据库** | MySQL(PyMySQL)+ 达梦(dmPython)统一接口 | | 🔌 **三数据库** | MySQL(PyMySQL)+ 达梦(dmPython)+ Oracle(oracledb)统一接口 |
| 🤖 **LLM 增强** | Claude API 用于字段注释推测 / 合并建议 / 长度推断 / 空字段分类 | | 🤖 **LLM 增强** | Claude API 用于字段注释推测 / 合并建议 / 长度推断 / 空字段分类 |
| 📊 **多级表格** | 6 大章节各一张可展开表格,KPI 卡片概览 | | 📊 **多级表格** | 6 大章节各一张可展开表格,KPI 卡片概览 |
| 📜 **国标展示** | 4 项内置标准可视化展示在页面(无需查询) | | 📜 **国标展示** | 4 项内置标准可视化展示在页面(无需查询) |
...@@ -139,7 +139,7 @@ python -m uvicorn web.app:app --host 0.0.0.0 --port 8765 ...@@ -139,7 +139,7 @@ python -m uvicorn web.app:app --host 0.0.0.0 --port 8765
│ │ ├── info_schema/ ← INFORMATION_SCHEMA 查询 │ │ ├── info_schema/ ← INFORMATION_SCHEMA 查询
│ │ ├── verify/ xzqh/ standards/ empty_fields/ health/ │ │ ├── verify/ xzqh/ standards/ empty_fields/ health/
│ ├── core/ ← 核心模块 │ ├── core/ ← 核心模块
│ │ ├── db_adapter.py ← MySQL + 达梦 统一接口 │ │ ├── db_adapter.py ← MySQL + 达梦 + Oracle 统一接口
│ │ ├── data_dict.py ← 数据字典抽取(connect-test 与 orchestrator 共用) │ │ ├── data_dict.py ← 数据字典抽取(connect-test 与 orchestrator 共用)
│ │ ├── data_dict_cache.py ← 数据字典进程内缓存(按连接身份) │ │ ├── data_dict_cache.py ← 数据字典进程内缓存(按连接身份)
│ │ ├── llm.py ← Claude API 封装 │ │ ├── llm.py ← Claude API 封装
...@@ -261,26 +261,38 @@ LLM 调用封装在 [web/core/llm.py](web/core/llm.py)。配置 Key 见上方「 ...@@ -261,26 +261,38 @@ LLM 调用封装在 [web/core/llm.py](web/core/llm.py)。配置 Key 见上方「
--- ---
## 🛡️ 双数据库适配说明 ## 🛡️ 三数据库适配说明
通过 [web/core/db_adapter.py](web/core/db_adapter.py) 统一抽象: 通过 [web/core/db_adapter.py](web/core/db_adapter.py) 统一抽象:
| 项 | MySQL | 达梦 | | 项 | MySQL | 达梦 | Oracle |
|----|-------|------| |----|-------|------|--------|
| 驱动 | `pymysql` | `dmPython` | | 驱动 | `pymysql` | `dmPython` | `oracledb` 1.4.x (thin) |
| 占位符 | `%s` | `:name` (NamedParam) | | 占位符 | `%s` | `?` | `:1` |
| 标识符引用 | `` ` `` (反引号) | `"` (双引号 ANSI) | | 标识符引用 | `` ` `` (反引号) | `"` (双引号 ANSI) | `"` (双引号 ANSI) |
| 字段大小写 | 视配置 | 默认大写 → 统一规范化为小写 | | 字段大小写 | 视配置 | 默认大写 → 统一规范化为小写 | 默认大写 → 统一规范化为小写 |
| 长文本类型 | `text/longtext/...` | `text/clob/longvarchar` | | 长文本类型 | `text/longtext/...` | `text/clob/longvarchar` | `varchar2/clob/nclob/long` |
| `INFORMATION_SCHEMA` | 完全支持 | 完全支持(字段名差异已处理) | | 数据字典 | `INFORMATION_SCHEMA.COLUMNS` | `ALL_TAB_COLUMNS` | `ALL_TAB_COLUMNS` |
| 默认端口 | 3306 | 5236 | 1521 |
> ⚠️ 达梦驱动 `dmPython` 需要本地先安装达梦客户端,否则 `pip install dmPython` 会失败。 > ⚠️ 达梦驱动 `dmPython` 需要本地先安装达梦客户端,否则 `pip install dmPython` 会失败。
> Oracle 用 `oracledb` thin 模式,**无需**安装 Oracle Instant Client;`database` 字段填 Oracle 的 service_name(如 `ORCLPDB1`),不是表 schema 名。
>
> ⚠️ **Oracle 版本兼容**:本仓库把 oracledb 锁在 **1.4.x**(`requirements.txt`),因为 2.0+ 的 thin 模式收紧了对老 Oracle 的支持,**Oracle 11.2 也会报 DPY-3010**:
>
> ```
> DPY-3010: connections to this database server version are not supported by python-oracledb in thin mode
> ```
>
> 1.4.x 的 thin 模式对 11.2 ~ 最新 Oracle 都正常。如果将来只连 12c+ / 19c / 21c,可以放开到 `oracledb>=2.0`。
>
> 若连接 **10g / 9i 等更老版本**,1.x 也报 DPY-3010,则需要装 [Oracle Instant Client](https://www.oracle.com/database/technologies/instant-client/downloads.html),应用会在首次连接时自动 `oracledb.init_oracle_client()` 切到 **thick 模式**(OCI 原生,支持 Oracle 9i+)。Instant Client 默认从 PATH / 注册表 / `ORACLE_HOME` 自动搜索;若不在默认路径,可在请求里显式传 `oracle_client_dir` 字段(如 `r"C:\oracle\instantclient_19_8"`)。
--- ---
## ⚠️ 已知事项 ## ⚠️ 已知事项
1. **MySQL 兼容**:所有 `INFORMATION_SCHEMA` 查询已用 ANSI 写法 + 适配层规范化,达梦可直接复用 1. **多数据库兼容**:所有 SQL 查询已用 ANSI 写法 + 适配层规范化,达梦 / Oracle 可直接复用;Oracle 数据字典走 `ALL_TAB_COLUMNS` / `ALL_TABLES`(无 `INFORMATION_SCHEMA`);Oracle thin 模式不支持的版本会自动尝试加载 Instant Client 切 thick 模式
2. **报告 bug 修复**:新版 Web 工具修正了原 `reporter.py` 的多处字段名不匹配(如 `over_provision_ratio`、`risk/suggestion` 等) 2. **报告 bug 修复**:新版 Web 工具修正了原 `reporter.py` 的多处字段名不匹配(如 `over_provision_ratio`、`risk/suggestion` 等)
3. **单进程**:JobManager 使用进程内 asyncio 队列,并发任务排队执行;如需多任务并行可改为 Celery 3. **单进程**:JobManager 使用进程内 asyncio 队列,并发任务排队执行;如需多任务并行可改为 Celery
4. **数据安全**:数据库密码仅在内存中传递,不写日志;提交前可勾选「启用 LLM」控制是否走外网 4. **数据安全**:数据库密码仅在内存中传递,不写日志;提交前可勾选「启用 LLM」控制是否走外网
...@@ -296,7 +308,7 @@ LLM 调用封装在 [web/core/llm.py](web/core/llm.py)。配置 Key 见上方「 ...@@ -296,7 +308,7 @@ LLM 调用封装在 [web/core/llm.py](web/core/llm.py)。配置 Key 见上方「
| 语法 | 含义 | 示例 | | 语法 | 含义 | 示例 |
|------|------|------| |------|------|------|
| `${var}` | 简单字符串替换 | `${schema}` | | `${var}` | 简单字符串替换 | `${schema}` |
| `${var \| quote}` | 按当前 dialect 自动加引号 | `${table \| quote}` → MySQL 反引号 / 达梦双引号 | | `${var \| quote}` | 按当前 dialect 自动加引号 | `${table \| quote}` → MySQL 反引号 / 达梦双引号 / Oracle 双引号 |
| `${list \| join:","}` | 列表按分隔符拼接 | `${cols \| join:","}` | | `${list \| join:","}` | 列表按分隔符拼接 | `${cols \| join:","}` |
### Dialect 加载规则 ### Dialect 加载规则
...@@ -314,6 +326,9 @@ sql = loader.render("info_schema/list_columns", dialect="mysql", schema="mydb") ...@@ -314,6 +326,9 @@ sql = loader.render("info_schema/list_columns", dialect="mysql", schema="mydb")
sql = loader.render("info_schema/list_columns", dialect="dameng", schema="mydb") sql = loader.render("info_schema/list_columns", dialect="dameng", schema="mydb")
# SELECT ... FROM ALL_TAB_COLUMNS c WHERE c.OWNER = "mydb" # SELECT ... FROM ALL_TAB_COLUMNS c WHERE c.OWNER = "mydb"
sql = loader.render("info_schema/list_columns", dialect="oracle", schema="mydb")
# SELECT ... FROM ALL_TAB_COLUMNS c WHERE c.OWNER = UPPER(:1)
``` ```
详细文档:[sql/README.md](sql/README.md) 详细文档:[sql/README.md](sql/README.md)
...@@ -323,7 +338,7 @@ sql = loader.render("info_schema/list_columns", dialect="dameng", schema="mydb") ...@@ -323,7 +338,7 @@ sql = loader.render("info_schema/list_columns", dialect="dameng", schema="mydb")
## 📝 后续可扩展 ## 📝 后续可扩展
- [ ] 添加更多国标插件(邮箱、银行卡、邮编等) - [ ] 添加更多国标插件(邮箱、银行卡、邮编等)
- [ ] 接入 jaydebeapi 作为达梦 JDBC 替代驱动 - [ ] 接入 jaydebeapi 作为达梦 JDBC 替代驱动(dmPython 装不上的兜底方案)
- [ ] 报告编辑器(在线编辑 Markdown / 一键导出) - [ ] 报告编辑器(在线编辑 Markdown / 一键导出)
- [ ] 多任务并行(Celery + Redis) - [ ] 多任务并行(Celery + Redis)
- [ ] 用户系统(FastAPI Users + JWT) - [ ] 用户系统(FastAPI Users + JWT)
......
...@@ -47,6 +47,30 @@ async def health(): ...@@ -47,6 +47,30 @@ async def health():
return {"status": "ok", "service": "data-governance-web"} return {"status": "ok", "service": "data-governance-web"}
# ── 数据库连接默认值 ──
# 从 web/configs/db_defaults.yaml 读取,供前端表单初始渲染使用
# 文件不存在或解析失败时返回空 dict,前端保留 JS 内置兜底值
@router.get("/db-defaults")
async def db_defaults():
import yaml # 延迟导入,yaml 是可选依赖的隐性兜底
cfg_path = Path(__file__).resolve().parents[2] / "web" / "configs" / "db_defaults.yaml"
if not cfg_path.exists():
logger.warning(f"db_defaults.yaml 缺失: {cfg_path}")
return {}
try:
raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
except Exception as e:
logger.warning(f"db_defaults.yaml 解析失败: {e}")
return {}
defaults = raw.get("db_defaults", {})
# 仅返回前端表单关心的字段,避免泄露无关配置
return {
k: defaults[k]
for k in ("host", "user", "password", "database")
if k in defaults
}
# ── LLM 状态(前端用来显示当前 provider) ── # ── LLM 状态(前端用来显示当前 provider) ──
@router.get("/llm/status") @router.get("/llm/status")
async def llm_status(): async def llm_status():
...@@ -191,6 +215,7 @@ async def connect_test(req: TestConnectionRequest): ...@@ -191,6 +215,7 @@ async def connect_test(req: TestConnectionRequest):
db_type=req.db_type, host=req.host, port=req.port, db_type=req.db_type, host=req.host, port=req.port,
user=req.user, password=req.password, database=req.database, user=req.user, password=req.password, database=req.database,
charset=req.charset, connect_timeout=req.connect_timeout, charset=req.charset, connect_timeout=req.connect_timeout,
oracle_client_dir=req.oracle_client_dir,
) )
ok, msg = test_connection(cfg) ok, msg = test_connection(cfg)
if not ok: if not ok:
......
...@@ -52,7 +52,7 @@ logger.info(f"日志目录: {LOG_DIR}") ...@@ -52,7 +52,7 @@ logger.info(f"日志目录: {LOG_DIR}")
# ── 创建 FastAPI 应用 ── # ── 创建 FastAPI 应用 ──
app = FastAPI( app = FastAPI(
title="数据治理 Web 工具", title="数据治理 Web 工具",
description="基于 FastAPI + Vue 3 的数据库治理平台(支持 MySQL / 达梦)", description="基于 FastAPI + Vue 3 的数据库治理平台(支持 MySQL / 达梦 / Oracle)",
version="1.0.0", version="1.0.0",
) )
logger.info("FastAPI 应用已创建") logger.info("FastAPI 应用已创建")
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
db_types: db_types:
- mysql - mysql
- dameng - dameng
- oracle
# 采样与阈值(与 CLI 工作流一致) # 采样与阈值(与 CLI 工作流一致)
thresholds: thresholds:
......
"""web.core 核心模块 """web.core 核心模块
- db_adapter: MySQL / 达梦 适配 - db_adapter: MySQL / 达梦 / Oracle 适配
- llm: Claude / OpenAI 客户端 - llm: Claude / OpenAI 客户端
- job_manager: 后台任务 + SSE - job_manager: 后台任务 + SSE
- orchestrator: 调度 8 步流程 - orchestrator: 调度 8 步流程
......
"""数据库适配层 """数据库适配层
提供 MySQL / 达梦 的统一连接与查询接口,屏蔽方言差异: 提供 MySQL / 达梦 / Oracle 的统一连接与查询接口,屏蔽方言差异:
- 连接管理 - 连接管理
- 字段大小写规范化(达梦默认大写,MySQL 视配置而定) - 字段大小写规范化(达梦默认大写,MySQL 视配置而定)
- 反引号 vs 双引号 - 反引号 vs 双引号
- information_schema 查询适配 - information_schema / 数据字典 查询适配
- 标识符引用(防 SQL 注入) - 标识符引用(防 SQL 注入)
设计原则: 设计原则:
...@@ -32,14 +32,19 @@ logger = logging.getLogger(__name__) ...@@ -32,14 +32,19 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class DBConfig: class DBConfig:
"""统一的数据库连接配置""" """统一的数据库连接配置"""
db_type: str # "mysql" | "dameng" db_type: str # "mysql" | "dameng" | "oracle"
host: str host: str
port: int port: int
user: str user: str
password: str password: str
database: str # 数据库名/模式名 database: str # 数据库名/模式名(Oracle 用作 service_name)
charset: str = "utf8mb4" charset: str = "utf8mb4"
connect_timeout: int = 10 connect_timeout: int = 10
# Oracle 专用:Oracle Instant Client 的 lib_dir(oci.dll 所在目录)
# None = 自动搜索 PATH / 注册表 / ORACLE_HOME
# 装了 Instant Client 但不在默认路径时显式指定,如 r"C:\oracle\instantclient_19_8"
# 仅当 thin 模式报 DPY-3010(不支持的 Oracle 版本)时才需要
oracle_client_dir: Optional[str] = None
def to_pymysql_kwargs(self) -> dict: def to_pymysql_kwargs(self) -> dict:
return { return {
...@@ -54,15 +59,42 @@ class DBConfig: ...@@ -54,15 +59,42 @@ class DBConfig:
} }
def to_dm_kwargs(self) -> dict: def to_dm_kwargs(self) -> dict:
"""dmPython 关键字参数映射""" """dmPython 关键字参数映射
关键约束(dmPython 2.5.x,传错会触发 C 层 SystemError、真实异常被吞):
- 主机字段是 ``server``,不是 ``host``(PyMySQL 习惯在这里不适用)
- 不要在 connect 时传 ``schema``:达梦 schema = 用户,登录用户默认就在自己的 schema;
如果 user 与 database 不一致,连上后单独 ``SET SCHEMA "xxx"`` 即可
- 不要在 connect 时传 ``charset``:dmPython 默认 GB18030(与 disql 一致),
部分驱动版本对 local_code 取值校验严苛(CODE:-70023),让驱动默认最稳
- 不要在 connect 时传 ``connect_timeout``:dmPython 2.5.x 对这个 kwarg 的
支持视 wheel 而异,不传最安全(超时由 OS TCP 重传决定)
参考:scripts/_probe_dm.py + 同环境已验证可连的 export_all_tables.py 工作脚本
"""
return { return {
"host": self.host, "user": self.user,
"password": self.password,
"server": self.host,
"port": self.port, "port": self.port,
}
def to_oracle_kwargs(self) -> dict:
"""oracledb 关键字参数映射(thin 模式,无需 Instant Client)
- database 字段在 Oracle 里是 service_name(如 ORCLPDB1),不是 schema 名
(Oracle schema = user,治理任务通常以「一个 service 扫一个 schema」的视角使用)
- 超时参数叫 connect_timeout(oracledb 1.4.x 风格;2.0+ 改名为 tcp_connect_timeout)
单位秒,默认未指定
- 不传 charset:thin 模式从 OS NLS_LANG / 系统设置取,避免与 NLS 字符集冲突
"""
return {
"user": self.user, "user": self.user,
"password": self.password, "password": self.password,
"schema": self.database, "host": self.host,
"charset": self.charset, "port": self.port,
"connect_timeout": self.connect_timeout, "service_name": self.database,
"connect_timeout": float(self.connect_timeout),
} }
...@@ -74,7 +106,7 @@ def quote_ident(name: str, db_type: str) -> str: ...@@ -74,7 +106,7 @@ def quote_ident(name: str, db_type: str) -> str:
"""安全引用 SQL 标识符(表名 / 字段名)。 """安全引用 SQL 标识符(表名 / 字段名)。
- MySQL 默认用反引号 - MySQL 默认用反引号
- 达梦用双引号(ANSI 标准) - 达梦 / Oracle 用双引号(ANSI 标准)
- 仅允许字母数字下划线开头,其余字符拒绝以防注入 - 仅允许字母数字下划线开头,其余字符拒绝以防注入
""" """
if not _IDENT_RE.match(name): if not _IDENT_RE.match(name):
...@@ -103,8 +135,24 @@ def normalize_column_name(name: str) -> str: ...@@ -103,8 +135,24 @@ def normalize_column_name(name: str) -> str:
return name.lower() if isinstance(name, str) else name return name.lower() if isinstance(name, str) else name
def _rows_as_dicts(rows, col_names: list[str]) -> list[dict]:
"""把不同驱动的查询结果统一转成 [{col_lower: value}, ...]
- PyMySQL 用 DictCursor → 单行是 dict,直接 .items()
- dmPython / cx_Oracle / oracledb → 单行是 tuple,用 cur.description 拿列名
"""
if not rows:
return []
sample = rows[0]
if isinstance(sample, dict):
# PyMySQL DictCursor 路径
return [{normalize_column_name(k): v for k, v in row.items()} for row in rows]
# tuple / list —— 用 col_names 兜底转 dict
return [{normalize_column_name(c): v for c, v in zip(col_names, row)} for row in rows]
def normalize_rows(rows: list[dict] | None) -> list[dict]: def normalize_rows(rows: list[dict] | None) -> list[dict]:
"""把查询结果的列名统一为小写""" """兼容老调用:单行 dict 的 rows 直接规范化列名"""
if not rows: if not rows:
return [] return []
return [{normalize_column_name(k): v for k, v in row.items()} for row in rows] return [{normalize_column_name(k): v for k, v in row.items()} for row in rows]
...@@ -128,12 +176,59 @@ def _render_health_sql(dialect: str) -> str: ...@@ -128,12 +176,59 @@ def _render_health_sql(dialect: str) -> str:
return get_sql_loader().render("health/check_connection", dialect=dialect) return get_sql_loader().render("health/check_connection", dialect=dialect)
# ── Oracle Instant Client 加载(thick 模式) ──────────────
# oracledb thin 模式只支持 Oracle 11.2+。老版本(10g / 9i)必须切 thick,
# 即加载本地 Oracle Instant Client 的 oci.dll(Windows)/ libclntsh.so(Linux)。
#
# 调用 oracledb.init_oracle_client() 是「进程级」操作:
# - 同一进程多次调用:除首次外都是 no-op
# - 失败后无法在同一进程回退到 thin(要重启进程)
#
# 自动检测策略:默认搜索 PATH / 注册表 / ORACLE_HOME,
# 也可以通过 DBConfig.oracle_client_dir 显式指定 lib_dir。
_oracle_client_initialized: bool = False
def _ensure_oracle_client(lib_dir: Optional[str] = None) -> str:
"""尝试加载 Oracle Instant Client,返回实际生效的连接模式
Returns:
"thick" Instant Client 加载成功(支持老 Oracle)
"thin" 未找到 Instant Client(仅支持 Oracle 11.2+,依赖 oracledb 自带协议栈)
Raises:
不抛异常 —— 即便加载失败也安全返回 "thin",让上层用 thin 试连,
然后把 DPY-3010 报给用户看,提示装 Instant Client。
"""
global _oracle_client_initialized
if _oracle_client_initialized:
return "thick"
try:
import oracledb
if lib_dir:
oracledb.init_oracle_client(lib_dir=lib_dir)
else:
oracledb.init_oracle_client() # 默认搜索 PATH / 注册表 / ORACLE_HOME
_oracle_client_initialized = True
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/)"
)
return "thin"
# ── 字符串类型集合 ──────────────────────────────────────── # ── 字符串类型集合 ────────────────────────────────────────
TEXT_TYPES = { TEXT_TYPES = {
# MySQL # MySQL
"varchar", "char", "text", "longtext", "mediumtext", "tinytext", "enum", "set", "varchar", "char", "text", "longtext", "mediumtext", "tinytext", "enum", "set",
# 达梦 # 达梦
"varchar2", "nvarchar2", "char", "text", "clob", "longvarchar", "varchar2", "nvarchar2", "char", "text", "clob", "longvarchar",
# Oracle(结构与达梦高度重合,新增 nclob / long)
"varchar2", "nvarchar2", "char", "clob", "nclob", "long",
} }
...@@ -170,7 +265,16 @@ class DBConnection: ...@@ -170,7 +265,16 @@ class DBConnection:
"(达梦官方驱动需先安装达梦客户端)" "(达梦官方驱动需先安装达梦客户端)"
) from e ) from e
return "dmPython" return "dmPython"
raise ValueError(f"不支持的数据库类型: {db_type!r}(目前支持 mysql / dameng)") if db_type == "oracle":
try:
import oracledb # noqa: F401
except ImportError as e:
raise RuntimeError(
"未安装 oracledb,请执行: pip install oracledb\n"
"(oracledb thin 模式无需 Oracle Instant Client)"
) from e
return "oracledb"
raise ValueError(f"不支持的数据库类型: {db_type!r}(目前支持 mysql / dameng / oracle)")
def __enter__(self): def __enter__(self):
logger.info( logger.info(
...@@ -181,9 +285,35 @@ class DBConnection: ...@@ -181,9 +285,35 @@ class DBConnection:
try: try:
if self._driver == "pymysql": if self._driver == "pymysql":
self._conn = pymysql.connect(**self.cfg.to_pymysql_kwargs()) self._conn = pymysql.connect(**self.cfg.to_pymysql_kwargs())
else: elif self._driver == "dmPython":
import dmPython import dmPython
self._conn = dmPython.connect(**self.cfg.to_dm_kwargs()) # dmPython 是 C 扩展,底层异常经常被 SystemError 吃掉。
try:
self._conn = dmPython.connect(**self.cfg.to_dm_kwargs())
except SystemError as e:
# dmPython 真实错误经常是以下几种之一:
# - dmPython 与本机达梦客户端(libdmdpi.so / dmdpi.dll)版本不匹配
# - 字符集不支持(达梦常见 GB18030 / UTF-8)
# - 网络/端口/服务名错(达梦错误被 C 层吃掉)
extra_hints = (
"常见原因:\n"
" 1) dmPython 与本机达梦客户端版本不匹配(dmPython 包版本 vs libdmdpi.so / dmdpi.dll)\n"
" 2) charset 不被达梦支持(达梦常见 GB18030 / UTF-8,MySQL 风格的 utf8mb4 / utf8 不一定可用)\n"
" 3) 主机/端口/服务名/账号密码错(真实 ORA-like 错误被 C 层吃了)\n"
"调试: 在终端单独跑 scripts/_probe_dm.py 看真实错误"
)
logger.error(
f"[DB] dmPython 连接失败(C 扩展吞了真实异常): {e}\n"
f" 入参: {self.cfg.to_dm_kwargs()}\n{extra_hints}"
)
raise
else: # oracledb
import oracledb
# 自动检测 Instant Client:能找到就切 thick 模式(兼容老 Oracle)
mode = _ensure_oracle_client(self.cfg.oracle_client_dir)
self._oracle_mode = mode # 记下来给日志用
logger.debug(f"[DB] Oracle 连接模式: {mode}")
self._conn = oracledb.connect(**self.cfg.to_oracle_kwargs())
except Exception as e: except Exception as e:
logger.error(f"[DB] 连接失败: {type(e).__name__}: {e}") logger.error(f"[DB] 连接失败: {type(e).__name__}: {e}")
raise raise
...@@ -211,9 +341,11 @@ class DBConnection: ...@@ -211,9 +341,11 @@ class DBConnection:
try: try:
cur.execute(sql, params or ()) cur.execute(sql, params or ())
rows = cur.fetchall() rows = cur.fetchall()
# 提前取列名:dmPython/oracledb 返回 tuple,需要 description 兜底
col_names = [d[0] for d in (cur.description or [])]
elapsed_ms = (time.monotonic() - started) * 1000 elapsed_ms = (time.monotonic() - started) * 1000
logger.debug(f"[SQL] fetchall {len(rows)} rows ({elapsed_ms:.1f}ms): {sql[:120]}...") logger.debug(f"[SQL] fetchall {len(rows)} rows ({elapsed_ms:.1f}ms): {sql[:120]}...")
return normalize_rows(rows) return _rows_as_dicts(rows, col_names)
finally: finally:
cur.close() cur.close()
...@@ -223,11 +355,14 @@ class DBConnection: ...@@ -223,11 +355,14 @@ class DBConnection:
try: try:
cur.execute(sql, params or ()) cur.execute(sql, params or ())
row = cur.fetchone() row = cur.fetchone()
col_names = [d[0] for d in (cur.description or [])]
elapsed_ms = (time.monotonic() - started) * 1000 elapsed_ms = (time.monotonic() - started) * 1000
logger.debug(f"[SQL] fetchone ({elapsed_ms:.1f}ms): {sql[:120]}...") logger.debug(f"[SQL] fetchone ({elapsed_ms:.1f}ms): {sql[:120]}...")
if row is None: if row is None:
return None return None
return {normalize_column_name(k): v for k, v in row.items()} if isinstance(row, dict):
return {normalize_column_name(k): v for k, v in row.items()}
return {normalize_column_name(c): v for c, v in zip(col_names, row)}
finally: finally:
cur.close() cur.close()
......
...@@ -11,7 +11,7 @@ from pydantic import BaseModel, Field ...@@ -11,7 +11,7 @@ from pydantic import BaseModel, Field
# ── 请求 ──────────────────────────────────────────────── # ── 请求 ────────────────────────────────────────────────
class ConnectRequest(BaseModel): class ConnectRequest(BaseModel):
"""前端「新建治理任务」表单提交""" """前端「新建治理任务」表单提交"""
db_type: str = Field(..., description="mysql / dameng") db_type: str = Field(..., description="mysql / dameng / oracle")
host: str host: str
port: int = 3306 port: int = 3306
user: str user: str
...@@ -19,6 +19,9 @@ class ConnectRequest(BaseModel): ...@@ -19,6 +19,9 @@ class ConnectRequest(BaseModel):
database: str database: str
charset: str = "utf8mb4" charset: str = "utf8mb4"
connect_timeout: int = 10 connect_timeout: int = 10
# Oracle 专用:Oracle Instant Client 的 lib_dir(oci.dll 所在目录)
# None = 自动搜索 PATH / 注册表;thin 模式报 DPY-3010 时显式指定
oracle_client_dir: Optional[str] = None
# 可选:要分析的步骤(按 step_id;为空表示跑全部) # 可选:要分析的步骤(按 step_id;为空表示跑全部)
steps: Optional[list[str]] = None steps: Optional[list[str]] = None
......
"""Step 实现包 — Web 版(兼容 MySQL + 达梦,接 LLM) """Step 实现包 — Web 版(兼容 MySQL + 达梦 + Oracle,接 LLM)
每个 step_* 函数接收通用 cfg + 上游 step_outputs,返回结构化 dict。 每个 step_* 函数接收通用 cfg + 上游 step_outputs,返回结构化 dict。
所有 Step 通过 web.core.orchestrator.run_governance_workflow() 调度。 所有 Step 通过 web.core.orchestrator.run_governance_workflow() 调度。
......
...@@ -10,6 +10,9 @@ pydantic>=2.5.0 ...@@ -10,6 +10,9 @@ pydantic>=2.5.0
# 数据库 # 数据库
pymysql>=1.1.0 # MySQL 驱动 pymysql>=1.1.0 # MySQL 驱动
dmPython>=2.5.0 # 达梦数据库驱动(需本地有达梦客户端) dmPython>=2.5.0 # 达梦数据库驱动(需本地有达梦客户端)
oracledb>=1.4.2,<2.0 # Oracle 数据库驱动 —— 锁定 1.x 是因为 2.0+ 的 thin 模式
# 收紧了 Oracle 版本支持,老版本(如 11.2)会报 DPY-3010
# 若以后只连 12c+/19c/21c,可放开到 >=2.0
# LLM # LLM
anthropic>=0.39.0 # Claude API 官方 SDK anthropic>=0.39.0 # Claude API 官方 SDK
......
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
-- 列出指定 schema 下所有列的元数据 (达梦方言) -- 列出指定 schema 下所有列的元数据 (达梦方言)
-- 调用方:web/core/db_adapter.py → list_columns() -- 调用方:web/core/db_adapter.py → list_columns()
-- 参数:schema 数据库/模式名(绑定参数,由调用方通过 params 传入,勿拼接) -- 参数:schema 数据库/模式名(绑定参数,由调用方通过 params 传入,勿拼接)
-- 说明:达梦的 ALL_TAB_COLUMNS.COMMENTS 列名与 MySQL 的 COLUMN_COMMENT 不同 -- 说明:
-- 达梦的 OWNER 通常为大写 -- - 达梦的 ALL_TAB_COLUMNS.COMMENTS 列名与 MySQL 的 COLUMN_COMMENT 不同
-- - 达梦的 OWNER 通常为大写
-- - 列注释不在 ALL_TAB_COLUMNS 里,要 LEFT JOIN ALL_COL_COMMENTS(Oracle 同源)
-- ============================================================================ -- ============================================================================
SELECT SELECT
c.TABLE_NAME AS table_name, c.TABLE_NAME AS table_name,
...@@ -16,8 +18,12 @@ SELECT ...@@ -16,8 +18,12 @@ SELECT
c.DATA_SCALE AS numeric_scale, c.DATA_SCALE AS numeric_scale,
c.NULLABLE AS is_nullable, c.NULLABLE AS is_nullable,
c.DATA_DEFAULT AS column_default, c.DATA_DEFAULT AS column_default,
c.COMMENTS AS column_comment, cc.COMMENTS AS column_comment,
NULL AS extra NULL AS extra
FROM ALL_TAB_COLUMNS c FROM ALL_TAB_COLUMNS c
LEFT JOIN ALL_COL_COMMENTS cc
ON cc.OWNER = c.OWNER
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.COLUMN_NAME = c.COLUMN_NAME
WHERE c.OWNER = ? WHERE c.OWNER = ?
ORDER BY c.TABLE_NAME, c.COLUMN_ID ORDER BY c.TABLE_NAME, c.COLUMN_ID
\ No newline at end of file
-- ============================================================================
-- 列出指定 schema 下所有列的元数据 (Oracle 方言)
-- 调用方:web/core/db_adapter.py → list_columns()
-- 参数:schema 用户/模式名(绑定参数,调用方通过 params 传入,勿拼接)
-- 说明:
-- - Oracle 没有 INFORMATION_SCHEMA,使用 ALL_TAB_COLUMNS(结构与达梦高度一致)
-- - Oracle 的 OWNER 通常为大写;调用方传入的小写 schema 也用 UPPER() 兼容
-- - 占位符用 :1(oracledb 接收 named/numbered binding)
-- - DATA_TYPE 在字符类型上拼 DATA_LENGTH;NUMBER 类型没有 DATA_LENGTH,单独显示
-- - is_nullable 列在 Oracle 中是 NULLABLE(取值 'Y'/'N'),与达梦相同
-- - 列注释不在 ALL_TAB_COLUMNS 里,要 LEFT JOIN ALL_COL_COMMENTS
-- ============================================================================
SELECT
c.TABLE_NAME AS table_name,
c.COLUMN_NAME AS column_name,
c.COLUMN_ID AS ordinal_position,
CASE
WHEN c.DATA_TYPE IN ('VARCHAR2','NVARCHAR2','CHAR','RAW')
THEN c.DATA_TYPE || '(' || c.DATA_LENGTH || ')'
ELSE c.DATA_TYPE
END AS column_type,
c.DATA_TYPE AS data_type,
c.DATA_LENGTH AS char_max_length,
c.DATA_PRECISION AS numeric_precision,
c.DATA_SCALE AS numeric_scale,
c.NULLABLE AS is_nullable,
c.DATA_DEFAULT AS column_default,
cc.COMMENTS AS column_comment,
NULL AS extra
FROM ALL_TAB_COLUMNS c
LEFT JOIN ALL_COL_COMMENTS cc
ON cc.OWNER = c.OWNER
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.COLUMN_NAME = c.COLUMN_NAME
WHERE c.OWNER = UPPER(:1)
ORDER BY c.TABLE_NAME, c.COLUMN_ID
...@@ -2,16 +2,26 @@ ...@@ -2,16 +2,26 @@
-- 列出指定 schema 下所有表 (达梦方言) -- 列出指定 schema 下所有表 (达梦方言)
-- 调用方:web/core/db_adapter.py → list_tables() -- 调用方:web/core/db_adapter.py → list_tables()
-- 参数:schema 数据库/模式名(绑定参数,由调用方通过 params 传入,勿拼接) -- 参数:schema 数据库/模式名(绑定参数,由调用方通过 params 传入,勿拼接)
-- 说明:
-- - 达梦 ALL_TABLES 没有 BYTES 列(Oracle 才有),DATA_LENGTH 给 NULL
-- - 达梦 ALL_TABLES 也没有 COMMENTS 列,表注释存在 ALL_TAB_COMMENTS 里,
-- 要 LEFT JOIN 出来(与 ALL_COL_COMMENTS / ALL_TAB_COLUMNS 同源结构)
-- - 达梦 ALL_TABLES 也没有 CREATED / LAST_DDL 列(时间戳一概没有),
-- 真要看得 LEFT JOIN ALL_OBJECTS(按 OBJECT_TYPE='TABLE' 过滤),
-- 字段治理主线不用时间戳,先给 NULL 兜底
-- ============================================================================ -- ============================================================================
SELECT TABLE_NAME, SELECT t.TABLE_NAME AS table_name,
'BASE TABLE' AS TABLE_TYPE, 'BASE TABLE' AS table_type,
NULL AS ENGINE, NULL AS engine,
NUM_ROWS AS TABLE_ROWS, t.NUM_ROWS AS table_rows,
BYTES AS DATA_LENGTH, NULL AS data_length,
0 AS INDEX_LENGTH, 0 AS index_length,
COMMENTS AS TABLE_COMMENT, tc.COMMENTS AS table_comment,
CREATED AS CREATE_TIME, NULL AS create_time,
LAST_DDL_TIME AS UPDATE_TIME NULL AS update_time
FROM ALL_TABLES FROM ALL_TABLES t
WHERE OWNER = UPPER(?) LEFT JOIN ALL_TAB_COMMENTS tc
ORDER BY TABLE_NAME ON tc.OWNER = t.OWNER
\ No newline at end of file AND tc.TABLE_NAME = t.TABLE_NAME
WHERE t.OWNER = UPPER(?)
ORDER BY t.TABLE_NAME
\ No newline at end of file
-- ============================================================================
-- 列出指定 schema 下所有表 (Oracle 方言)
-- 调用方:web/core/db_adapter.py → list_tables()
-- 参数:schema 用户/模式名(绑定参数,调用方通过 params 传入,勿拼接)
-- 说明:
-- - Oracle 没有 INFORMATION_SCHEMA,使用 ALL_TABLES(结构与达梦高度一致)
-- - Oracle ALL_TABLES 没有 COMMENTS 列,表注释在 ALL_TAB_COMMENTS 里,
-- LEFT JOIN 出来(与达梦同源——同一类问题同一个修法)
-- ============================================================================
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,
0 AS index_length,
tc.COMMENTS AS table_comment,
t.CREATED AS create_time,
t.LAST_DDL_TIME AS update_time
FROM ALL_TABLES t
LEFT JOIN ALL_TAB_COMMENTS tc
ON tc.OWNER = t.OWNER
AND tc.TABLE_NAME = t.TABLE_NAME
WHERE t.OWNER = UPPER(:1)
ORDER BY t.TABLE_NAME
...@@ -124,7 +124,7 @@ class SQLLoader: ...@@ -124,7 +124,7 @@ class SQLLoader:
Args: Args:
name: 模板路径(不含 .sql 后缀),如 "info_schema/list_columns" name: 模板路径(不含 .sql 后缀),如 "info_schema/list_columns"
dialect: 数据库方言 "mysql" / "dameng" dialect: 数据库方言 "mysql" / "dameng" / "oracle"
**params: 占位符参数 **params: 占位符参数
Returns: Returns:
...@@ -148,7 +148,7 @@ class SQLLoader: ...@@ -148,7 +148,7 @@ class SQLLoader:
if "." in stem: if "." in stem:
# 可能含 dialect 后缀:list_columns.mysql # 可能含 dialect 后缀:list_columns.mysql
base, _, maybe_dialect = stem.rpartition(".") base, _, maybe_dialect = stem.rpartition(".")
if maybe_dialect in ("mysql", "dameng"): if maybe_dialect in ("mysql", "dameng", "oracle"):
stem = base stem = base
dialect = maybe_dialect dialect = maybe_dialect
results.append({ results.append({
......
...@@ -6,12 +6,14 @@ ...@@ -6,12 +6,14 @@
-- ${type_col} 证件类型字段名(自动加引号) -- ${type_col} 证件类型字段名(自动加引号)
-- ${no_col} 证件号字段名(自动加引号) -- ${no_col} 证件号字段名(自动加引号)
-- ${min_count} 阈值(默认 2),出现次数 >= 该值才纳入疑似重复 -- ${min_count} 阈值(默认 2),出现次数 >= 该值才纳入疑似重复
-- 注意:所有标识符统一走 ${... | quote} filter,跨 MySQL / 达梦 / Oracle 方言
-- MySQL → 反引号,达梦/Oracle → 双引号
-- ============================================================================ -- ============================================================================
SELECT `${type_col}` AS id_type, `${no_col}` AS id_no, COUNT(*) AS dup_count SELECT ${type_col | quote} AS id_type, ${no_col | quote} AS id_no, COUNT(*) AS dup_count
FROM `${table}` FROM ${table | quote}
WHERE `${type_col}` IS NOT NULL AND TRIM(`${type_col}`) <> '' WHERE ${type_col | quote} IS NOT NULL AND TRIM(${type_col | quote}) <> ''
AND `${no_col}` IS NOT NULL AND TRIM(`${no_col}`) <> '' AND ${no_col | quote} IS NOT NULL AND TRIM(${no_col | quote}) <> ''
GROUP BY `${type_col}`, `${no_col}` GROUP BY ${type_col | quote}, ${no_col | quote}
HAVING COUNT(*) >= ${min_count} HAVING COUNT(*) >= ${min_count}
ORDER BY dup_count DESC ORDER BY dup_count DESC
LIMIT 200 LIMIT 200
\ No newline at end of file
...@@ -7,11 +7,12 @@ ...@@ -7,11 +7,12 @@
-- ${table} 表名(自动加引号) -- ${table} 表名(自动加引号)
-- ${val_col} 待聚合字段名(自动加引号) -- ${val_col} 待聚合字段名(自动加引号)
-- ${min_count} 阈值(默认 2),出现次数 >= 该值才纳入疑似重复 -- ${min_count} 阈值(默认 2),出现次数 >= 该值才纳入疑似重复
-- 注意:所有标识符统一走 ${... | quote} filter,跨 MySQL / 达梦 / Oracle 方言
-- ============================================================================ -- ============================================================================
SELECT `${val_col}` AS value, COUNT(*) AS dup_count SELECT ${val_col | quote} AS value, COUNT(*) AS dup_count
FROM `${table}` FROM ${table | quote}
WHERE `${val_col}` IS NOT NULL AND TRIM(`${val_col}`) <> '' WHERE ${val_col | quote} IS NOT NULL AND TRIM(${val_col | quote}) <> ''
GROUP BY `${val_col}` GROUP BY ${val_col | quote}
HAVING COUNT(*) >= ${min_count} HAVING COUNT(*) >= ${min_count}
ORDER BY dup_count DESC ORDER BY dup_count DESC
LIMIT 200 LIMIT 200
\ No newline at end of file
...@@ -6,9 +6,10 @@ ...@@ -6,9 +6,10 @@
-- ${type_col} 证件类型字段名(自动加引号) -- ${type_col} 证件类型字段名(自动加引号)
-- ${no_col} 证件号字段名(自动加引号) -- ${no_col} 证件号字段名(自动加引号)
-- ${limit} 抽样上限 -- ${limit} 抽样上限
-- 注意:所有标识符统一走 ${... | quote} filter,跨 MySQL / 达梦 / Oracle 方言
-- ============================================================================ -- ============================================================================
SELECT `${type_col}` AS id_type, `${no_col}` AS id_no SELECT ${type_col | quote} AS id_type, ${no_col | quote} AS id_no
FROM `${table}` FROM ${table | quote}
WHERE `${type_col}` IS NOT NULL AND TRIM(`${type_col}`) <> '' WHERE ${type_col | quote} IS NOT NULL AND TRIM(${type_col | quote}) <> ''
AND `${no_col}` IS NOT NULL AND TRIM(`${no_col}`) <> '' AND ${no_col | quote} IS NOT NULL AND TRIM(${no_col | quote}) <> ''
LIMIT ${limit} LIMIT ${limit}
\ No newline at end of file
...@@ -6,9 +6,10 @@ ...@@ -6,9 +6,10 @@
-- ${col_a} 第 1 字段名(自动加引号) -- ${col_a} 第 1 字段名(自动加引号)
-- ${col_b} 第 2 字段名(自动加引号) -- ${col_b} 第 2 字段名(自动加引号)
-- ${limit} 抽样上限 -- ${limit} 抽样上限
-- 注意:所有标识符统一走 ${... | quote} filter,跨 MySQL / 达梦 / Oracle 方言
-- ============================================================================ -- ============================================================================
SELECT `${col_a}` AS value_a, `${col_b}` AS value_b SELECT ${col_a | quote} AS value_a, ${col_b | quote} AS value_b
FROM `${table}` FROM ${table | quote}
WHERE `${col_a}` IS NOT NULL AND TRIM(`${col_a}`) <> '' WHERE ${col_a | quote} IS NOT NULL AND TRIM(${col_a | quote}) <> ''
AND `${col_b}` IS NOT NULL AND TRIM(`${col_b}`) <> '' AND ${col_b | quote} IS NOT NULL AND TRIM(${col_b | quote}) <> ''
LIMIT ${limit} LIMIT ${limit}
\ No newline at end of file
...@@ -61,7 +61,7 @@ def check_dependencies(): ...@@ -61,7 +61,7 @@ def check_dependencies():
def check_optional(): def check_optional():
"""检查可选依赖(LLM、达梦)""" """检查可选依赖(LLM、达梦、Oracle)"""
info = {} info = {}
try: try:
import anthropic import anthropic
...@@ -77,6 +77,13 @@ def check_optional(): ...@@ -77,6 +77,13 @@ def check_optional():
except ImportError: except ImportError:
info["dmPython"] = "未安装(达梦数据库不可用)" info["dmPython"] = "未安装(达梦数据库不可用)"
logger.info("dmPython 未安装(达梦数据库不可用)") logger.info("dmPython 未安装(达梦数据库不可用)")
try:
import oracledb
info["oracledb"] = f"已安装(v{oracledb.__version__})"
logger.info(f"oracledb 已安装(v{oracledb.__version__},Oracle 数据库可用)")
except ImportError:
info["oracledb"] = "未安装(Oracle 数据库不可用)"
logger.info("oracledb 未安装(Oracle 数据库不可用)")
api_key = os.environ.get("ANTHROPIC_API_KEY") api_key = os.environ.get("ANTHROPIC_API_KEY")
if api_key: if api_key:
info["ANTHROPIC_API_KEY"] = f"已配置({api_key[:8]}...)" info["ANTHROPIC_API_KEY"] = f"已配置({api_key[:8]}...)"
......
...@@ -54,8 +54,11 @@ ...@@ -54,8 +54,11 @@
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="6"> <el-col :span="6">
<el-form-item label="数据库类型"> <el-form-item label="数据库类型">
<el-radio-group v-model="form.db_type"> <el-radio-group v-model="form.db_type" @change="onDbTypeChange">
<el-radio-button label="mysql">MySQL</el-radio-button> <el-radio-button label="mysql">MySQL</el-radio-button>
<el-radio-button label="dameng">达梦 (DM)</el-radio-button>
<!-- Oracle 暂未实装完成,先隐藏;见 docs/WORKLOG.md -->
<!-- <el-radio-button label="oracle">Oracle</el-radio-button> -->
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
</el-col> </el-col>
...@@ -94,6 +97,8 @@ ...@@ -94,6 +97,8 @@
<el-option label="utf8mb4" value="utf8mb4" /> <el-option label="utf8mb4" value="utf8mb4" />
<el-option label="utf8" value="utf8" /> <el-option label="utf8" value="utf8" />
<el-option label="gbk" value="gbk" /> <el-option label="gbk" value="gbk" />
<el-option label="al32utf8" value="al32utf8" />
<el-option label="zhs16gbk" value="zhs16gbk" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
...@@ -651,6 +656,8 @@ ...@@ -651,6 +656,8 @@
// ── 表单 ── // ── 表单 ──
// steps 现在是 step_id 字符串列表(由后端 /api/steps 动态决定) // steps 现在是 step_id 字符串列表(由后端 /api/steps 动态决定)
// tables 由用户在「数据字典浏览器」里勾选,传给后端做 table_filter // tables 由用户在「数据字典浏览器」里勾选,传给后端做 table_filter
// host/user/password/database 的默认值从 /api/db-defaults 拉取(见 web/configs/db_defaults.yaml),
// 下面这些是 JS 内置兜底值(API 失败 / 配置缺失时使用)
const form = reactive({ const form = reactive({
db_type: 'mysql', db_type: 'mysql',
host: '192.168.20.10', host: '192.168.20.10',
...@@ -669,6 +676,28 @@ ...@@ -669,6 +676,28 @@
const connected = ref(false); const connected = ref(false);
const testResult = ref(null); const testResult = ref(null);
// ── 数据库类型切换:根据 db_type 自动给 port / database 提示默认值 ──
// 各数据库默认端口;切换时若当前端口仍是「某库的默认端口」就跟着切换,
// 用户手动改过的端口(如 3307)不会被覆盖。
// Oracle 的 database 字段填 service_name(如 ORCLPDB1)
const DB_DEFAULT_PORTS = {
mysql: 3306,
dameng: 5236,
oracle: 1521,
};
function onDbTypeChange(newType) {
const oldPort = form.port;
const newPort = DB_DEFAULT_PORTS[newType];
// 只在「当前端口是某个默认端口」时才自动切换;保留用户自定义
if (newPort && Object.values(DB_DEFAULT_PORTS).includes(oldPort)) {
form.port = newPort;
}
if (newType === 'oracle') {
form.database = form.database || 'ORCL';
}
}
watch(() => form.db_type, onDbTypeChange);
// ── 步骤 & 标准 ── // ── 步骤 & 标准 ──
// stepsList 保留作 fallback / required 检查;分组后用 analysisTree 驱动 UI // stepsList 保留作 fallback / required 检查;分组后用 analysisTree 驱动 UI
const stepsList = ref([]); const stepsList = ref([]);
...@@ -1105,6 +1134,24 @@ ...@@ -1105,6 +1134,24 @@
testResult.value = null; testResult.value = null;
} }
// ── 加载数据库连接默认值(来自 web/configs/db_defaults.yaml) ──
// 只覆盖 form 里现有字段,未返回的字段保留 JS 内置兜底
async function loadDbDefaults() {
try {
const r = await fetch('/api/db-defaults');
if (!r.ok) return;
const d = await r.json();
if (d.host !== undefined) form.host = d.host;
if (d.user !== undefined) form.user = d.user;
// password 即便是空串也要写入(前端可能需要"用配置里的密码"语义)
if (d.password !== undefined) form.password = d.password;
if (d.database !== undefined) form.database = d.database;
console.log('[db-defaults] 已应用配置默认值');
} catch (e) {
console.warn('[db-defaults] 加载失败,保留 JS 内置兜底:', e);
}
}
// ── 加载步骤(flat 列表 —— 用于 fallback / required 兜底) ── // ── 加载步骤(flat 列表 —— 用于 fallback / required 兜底) ──
async function loadSteps() { async function loadSteps() {
try { try {
...@@ -1483,6 +1530,7 @@ ...@@ -1483,6 +1530,7 @@
} }
onMounted(() => { onMounted(() => {
loadDbDefaults(); // 先拉默认值(覆盖 form 初始值)
loadSteps(); loadSteps();
loadAnalysisTree(); loadAnalysisTree();
loadStandards(); loadStandards();
...@@ -1509,6 +1557,8 @@ ...@@ -1509,6 +1557,8 @@
testConnection, startJob, cancelJob, resetAndStart, testConnection, startJob, cancelJob, resetAndStart,
downloadReport, downloadReport,
loadStandards, loadStandards,
loadDbDefaults,
onDbTypeChange,
selectedField, selectFieldFilter, clearFieldFilter, selectedField, selectFieldFilter, clearFieldFilter,
}; };
} }
......
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