真实 Oracle 生产库跑通,修掉三处只有连真库才暴露的问题

在一个企业 BI 库(SAP 物料/采购/凭证 + MES 生产 + IOT 设备)上端到端验证。
连接 3 秒握手,thin 模式,确实不需要 Instant Client。三处问题只有连真库才看得见:

## options.schema 被当成驱动参数拼进了连接串

我自己引入的:build_url 把 options 里所有剩余的 key 都拼成 query 参数,
而 schema 是探查配置不是连接参数,于是 ?service_name=BI&schema=ANALYTICS 直接
被驱动拒掉。options 里混着两类东西,现在显式区分。

## 只读账号名下一张表都没有

这是企业环境的常态而非例外:TAIKE 名下只有 2 个 SYNONYM,数据在 ANALYTICS
(51 个视图) 和 STAGING(9 张表)。默认探查当前用户 schema 得到 0 张表,而原来
只会安静地返回一个空壳——用户完全不知道下一步该干什么。

现在:options.schema 可指定探查目标;探不到任何对象时自动列出可访问的非系统
schema(排掉 MDSYS/CTXSYS/XDB 那一屏 Oracle 内部对象),摘要里直接告诉
Copilot"该账号名下没有对象,数据可能在这些 schema"。

## 表名不带 schema 前缀直接 ORA-00942

只读账号访问的是别人 schema 下的对象,SELECT * FROM V_TRIP_FACT 必然失败。
schema_cache 里为每个对象记 qualified 全名,summary / table_names /
db_query 的工具描述统一用全名——模型照着写就不会漏前缀。describe_table 则
反过来放宽:全名、裸表名、大写形态都认(Oracle 惯用大写,SQLAlchemy 返回小写)。

## 实测

探查 53 个对象 7.8 秒,摘要约 2000 token(含一个 273 字段的宽视图),在预算内。
Oracle 的中文表/列注释正确读出("不合格数据明细表"、"工厂代码,复合主键之一")。

agent 自主查询 8 轮:列对象 → 挑 3 个 MES 视图看结构 → 逐个 COUNT + MAX(load_time)
→ 补查业务时间字段,最后报出 v_demo_table 有 365,633 行、最新数据
2026-09-17 06:53。全程自己带对了 schema 前缀。

安全边界在生产库上复验:
- UPDATE / DELETE / DROP 全被拒,数据未变
- 19.87 亿行的 TG_ECC_MSEG 裸查 SELECT *:0.7 秒返回 200 行即断开,
  流式读取没有把表拉回来
- 203 列宽表的字节上限在第 37 行触发

凭据只进加密库(Fernet 密文,已核验库中无明文),不在仓库、提交历史或任何输出里。
This commit is contained in:
ThearchyHelios
2026-09-17 11:44:22 +08:00
parent 39e7a5b014
commit a127f7049c
4 changed files with 100 additions and 13 deletions
+3 -1
View File
@@ -114,7 +114,9 @@ async def list_kinds() -> dict[str, Any]:
{"value": "oracle", "label": "Oracle", "default_port": 1521,
"needs": ["host", "username", "password"],
"hint": "库名填 service_name,或在 options 里给 service_name / sid。"
"驱动走 thin 模式,不需要装 Instant Client"},
"驱动走 thin 模式,不需要装 Instant Client。"
"只读账号名下通常没有对象——数据在别的 schema 里,"
"用 options.schema 指定(如 ANALYTICS),否则探查结果是空的"},
{"value": "sqlite", "label": "SQLite(文件)", "default_port": None,
"needs": ["database"], "hint": "库名填数据库文件的绝对路径"},
],
+9
View File
@@ -36,6 +36,10 @@ _DEFAULT_PORTS = {
SUPPORTED_KINDS = tuple(sorted(set(_DRIVERS)))
# options 里这些 key 是 AgentLab 自己的配置,不是驱动参数,拼 URL 时要摘掉。
# schema:探查哪个 schema(企业库里只读账号名下常常什么都没有,数据在别处)
_NON_DRIVER_OPTIONS = frozenset({"schema"})
@dataclass
class QueryResult:
@@ -69,6 +73,11 @@ def build_url(source: Any, *, reveal: bool = False) -> str:
raise ValueError(f"不支持的数据库类型:{source.kind}(支持 {', '.join(SUPPORTED_KINDS)})")
options: dict[str, Any] = dict(source.options or {})
# options 混着两类东西:驱动参数(charset、service_name…)和我们自己的配置
# (schema 指探查哪个 schema)。后者不能拼进连接串,否则驱动会把它当成
# 未知的连接参数直接拒掉。
for key in _NON_DRIVER_OPTIONS:
options.pop(key, None)
if kind == "sqlite":
# SQLite 没有主机和账号,database 就是文件路径
+79 -11
View File
@@ -27,13 +27,53 @@ _MAX_TABLES = 200
_PREVIEW_COLUMNS = 6
# 各家的系统 schema,发现可用 schema 时要排掉——否则用户面对的是一屏
# MDSYS / CTXSYS / XDB 这类内部对象,真正的业务库反而埋在里面。
_SYSTEM_SCHEMAS = frozenset({
# Oracle
"sys", "system", "sysaux", "mdsys", "ctxsys", "xdb", "olapsys", "wmsys",
"lbacsys", "ordsys", "orddata", "outln", "dbsnmp", "appqossys", "audsys",
"gsmadmin_internal", "dvsys", "ojvmsys", "dbsfwuser", "remote_scheduler_agent",
# PostgreSQL / MySQL
"information_schema", "pg_catalog", "pg_toast", "performance_schema", "mysql", "sys",
})
async def list_schemas(source: Any) -> list[str]:
"""列出可访问的非系统 schema。
企业环境里只读账号名下往往一张表都没有——数据在别的 schema,靠跨库授权或
synonym 访问。直接返回"0 张表"等于把人晾在那儿,得告诉他数据可能在哪。
"""
engine = await engines.get(source)
def _collect(sync_conn: Any) -> list[str]:
try:
names = sa_inspect(sync_conn).get_schema_names()
except Exception: # noqa: BLE001 - 有些方言不支持
return []
return [n for n in names if n.lower() not in _SYSTEM_SCHEMAS]
async with engine.connect() as conn:
return await conn.run_sync(_collect)
def _target_schema(source: Any, explicit: str | None) -> str | None:
"""探查哪个 schema:显式参数 > 数据源配置 > 当前用户默认。"""
if explicit:
return explicit
configured = (source.options or {}).get("schema")
return str(configured) if configured else None
async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any]:
"""探查整个库的结构,结果直接存进 DataSource.schema_cache。"""
engine = await engines.get(source)
target = _target_schema(source, schema)
def _collect(sync_conn: Any) -> dict[str, Any]:
inspector = sa_inspect(sync_conn)
target_schema = schema or None
target_schema = target
names = inspector.get_table_names(schema=target_schema)
views = inspector.get_view_names(schema=target_schema)
truncated = len(names) + len(views) > _MAX_TABLES
@@ -57,6 +97,10 @@ async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any
comment = None
tables[name] = {
# 写 SQL 要用的全名。没有 schema 前缀时 Oracle 直接 ORA-00942——
# 只读账号名下没有这些对象,全靠跨 schema 授权
"qualified": f"{target_schema}.{name}" if target_schema else name,
"schema": target_schema,
"columns": [
{
"name": c["name"],
@@ -76,7 +120,12 @@ async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any
payload = await conn.run_sync(_collect)
payload["synced_at"] = datetime.now(timezone.utc).isoformat()
payload["schema"] = schema
payload["schema"] = target
# 一张表都没探到时,别只回一个空壳——多半是数据在别的 schema 里,
# 把候选列出来,用户才知道下一步该填什么
if not payload["tables"]:
payload["available_schemas"] = await list_schemas(source)
return payload
@@ -85,20 +134,31 @@ def summary(source: Any, *, max_tables: int = 40) -> str:
cache = source.schema_cache or {}
tables = cache.get("tables") or {}
if not tables:
return f"- {source.name}({source.kind}):{source.description or '未填说明'}|尚未探查结构"
hint = ""
candidates = cache.get("available_schemas") or []
if candidates:
hint = f"|该账号名下没有对象,数据可能在这些 schema:{'、'.join(candidates[:8])}"
return (
f"- {source.name}({source.kind}):{source.description or '未填说明'}"
f"|尚未探查结构{hint}"
)
schema = cache.get("schema")
lines = [
f"- {source.name}({source.kind},{'只读' if source.readonly else '可写'}):"
f"{source.description or '未填说明'}"
+ (f"|schema:{schema}" if schema else "")
]
for name, meta in list(tables.items())[:max_tables]:
cols = meta.get("columns") or []
preview = "、".join(c["name"] for c in cols[:_PREVIEW_COLUMNS])
more = f" 等 {len(cols)} 字段" if len(cols) > _PREVIEW_COLUMNS else ""
note = f"({meta['comment']})" if meta.get("comment") else ""
lines.append(f" · {name}{note}: {preview}{more}")
kind = "视图" if meta.get("is_view") else "表"
# 用全名:模型照着写 SQL 时少了 schema 前缀在 Oracle 上直接报 ORA-00942
lines.append(f" · {meta.get('qualified', name)}[{kind}]{note}: {preview}{more}")
if len(tables) > max_tables:
lines.append(f" · …另有 {len(tables) - max_tables} 张表,用 db_schema 工具查看")
lines.append(f" · …另有 {len(tables) - max_tables} 个对象,用 db_schema 工具查看")
return "\n".join(lines)
@@ -106,16 +166,22 @@ def describe_table(source: Any, table: str) -> str:
"""单表的完整字段说明。agent 调 db_schema 工具时返回这个。"""
cache = source.schema_cache or {}
tables = cache.get("tables") or {}
# 模型可能传全名(ANALYTICS.V_TRIP_FACT),也可能只传表名,两种都认
meta = tables.get(table)
if meta is None and "." in table:
meta = tables.get(table.rsplit(".", 1)[-1])
if meta is None:
lowered = table.rsplit(".", 1)[-1].lower()
meta = next((m for n, m in tables.items() if n.lower() == lowered), None)
if not meta:
available = "、".join(list(tables)[:30]) or "(还没探查过结构)"
return f"数据源「{source.name}」里没有表 {table}。现有的表:{available}"
available = "、".join(
m.get("qualified", n) for n, m in list(tables.items())[:30]
) or "(还没探查过结构)"
return f"数据源「{source.name}」里没有 {table}。现有的对象:{available}"
head = f"表 {table}"
head = f"{'视图' if meta.get('is_view') else '表'} {meta.get('qualified', table)}"
if meta.get("comment"):
head += f"({meta['comment']})"
if meta.get("is_view"):
head += " [视图]"
lines = [head]
pk = set(meta.get("primary_key") or [])
for col in meta.get("columns") or []:
@@ -132,4 +198,6 @@ def describe_table(source: Any, table: str) -> str:
def table_names(source: Any) -> list[str]:
return list((source.schema_cache or {}).get("tables") or {})
"""带 schema 前缀的对象全名。模型照着这个写 SQL 才不会漏掉前缀。"""
tables = (source.schema_cache or {}).get("tables") or {}
return [meta.get("qualified", name) for name, meta in tables.items()]
+9 -1
View File
@@ -75,10 +75,18 @@ async def test_summary_says_so_when_never_introspected(source):
assert "尚未探查" in summary(source)
async def test_describe_table_accepts_qualified_name(source):
"""模型可能传 SCHEMA.TABLE 全名,也可能只传表名,两种都要认。"""
source.schema_cache = await introspect(source)
assert "amount" in describe_table(source, "orders")
assert "amount" in describe_table(source, "main.orders")
assert "amount" in describe_table(source, "ORDERS") # Oracle 惯用大写
async def test_describe_table_lists_unknown_table_options(source):
source.schema_cache = await introspect(source)
out = describe_table(source, "没有这张表")
assert "现有的表" in out and "orders" in out
assert "现有的对象" in out and "orders" in out
async def test_query_returns_rows(source):