diff --git a/backend/app/api/catalog.py b/backend/app/api/catalog.py index f5a85a6b..0526ca13 100644 --- a/backend/app/api/catalog.py +++ b/backend/app/api/catalog.py @@ -226,14 +226,21 @@ async def _detail(session: AsyncSession, source: Any, table: str, entry: catalog async def _table_key(session: AsyncSession, source: Any, table: str) -> tuple[str, catalog.CatalogEntry | None]: - """路径里的表名 → 目录的键。表结构里有的按表结构的写法;表结构里没有、但还留着目录的也认(便于清理)。""" + """路径里的表名 → 目录的键。表结构里有的按表结构的写法;表结构里没有、但还留着目录的也认(便于清理)。都没有时 404, + 说清是哪一种「不在」(_missing)。""" key = catalog.resolve_table_name(source.schema_cache, table) entry = await catalog.read_entry(session, source.id, key or table) if key is None and entry is None: - raise HTTPException(404, f"数据源中没有表 {table},可能已被删除或尚未探查结构") + raise await _missing(session, source, table) return key or table, entry +async def _missing(session: AsyncSession, source: Any, table: str) -> HTTPException: + """表结构里没有这张表的 404:表已不在、数据源里没有、在探查范围外、读不到结构、说不清,和 /schema、/catalog/search、 + 目录清单的 absent_reason 同一套(source_objects.absent_message)。""" + return HTTPException(404, await source_objects.absent_message(session, source, table)) + + def _conflict(e: catalog.CatalogConflict) -> HTTPException: return HTTPException(409, str(e)) @@ -906,7 +913,7 @@ async def catalog_table_assistant(source_id: str, table: str, payload: Assistant row, source = await _resolved(session, source_id) key = catalog.resolve_table_name(source.schema_cache, table) if key is None: - raise HTTPException(404, f"数据源中没有表 {table},可能已被删除或尚未探查结构") + raise await _missing(session, source, table) messages = [m.model_dump() for m in payload.messages] if problem := catalog_assistant.conversation_problem(messages): raise HTTPException(422, problem) diff --git a/backend/app/data/catalog_review.py b/backend/app/data/catalog_review.py index 43e27594..78eb3438 100644 --- a/backend/app/data/catalog_review.py +++ b/backend/app/data/catalog_review.py @@ -41,7 +41,7 @@ from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.data import catalog +from app.data import catalog, source_objects from app.db.models import Run #: 队列的筛选:全部、影响数字的项、名称和说明 @@ -346,7 +346,7 @@ async def review_batch(session: AsyncSession, source: Any, items: Iterable[Batch key = known[item.table_name] if key is None: results[i] = _result(item, ok=False, code=CODE_TABLE_MISSING, - error=f"数据源中没有表 {item.table_name},可能已被删除或尚未探查结构") + error=await source_objects.absent_message(session, source, item.table_name)) continue groups.setdefault(key, []).append(i) at = catalog.now_iso() diff --git a/backend/app/data/source_objects.py b/backend/app/data/source_objects.py index 058fc989..f367045b 100644 --- a/backend/app/data/source_objects.py +++ b/backend/app/data/source_objects.py @@ -825,6 +825,33 @@ async def absent_reasons(session: AsyncSession, row: Any, names: Iterable[str]) return out +#: 表结构里没有的表给人看的一句(接口的 404、批量审阅逐条的错误):和界面的 CATALOG_ABSENT_TEXT、/schema 的说法 +#: (schema_view.absent_detail,给模型看)同一套「不在」。{table} 是点名的表 +ABSENT_MESSAGE = { + ABSENT_REMOVED: "数据源中已经没有表 {table}(已删除或改名)", + ABSENT_MISSING: "数据源中没有表 {table}(全库的对象清单里也没有)", + ABSENT_OUT_OF_SCOPE: ("表 {table} 在探查范围外:对象索引里有它,但没有读取结构。在数据源卡片的「探查范围」里加入它," + "或在数据目录的搜索框搜索它并「加入范围」"), + ABSENT_REFLECT_FAILED: "表 {table} 在探查范围内,但上次探查读不到它的结构(权限不够或对象有问题),修好后重新探查即可", + ABSENT_UNKNOWN: ("表 {table} 不在已探查的表里:升级前的探查只取了一部分表,无法判断它是否还在,请回到数据源卡片重新" + "「探查结构」"), +} +#: 说不出是哪一种「不在」时(上传表格、记录表按版本走;没有对象索引、又没截断过的老缓存认不出名字是不是从来就没有) +ABSENT_PLAIN = "数据源中没有表 {table}" + + +async def absent_message(session: AsyncSession, row: Any, table: str) -> str: + """点名的表不在表结构里时给人看的一句(ABSENT_MESSAGE)。手工接入的源按 absent_reasons 说是哪一种;上传表格、记录表 + 的结构随版本走,只说没有(目录清单把它们记成 removed,是说「那一版里没有」,点名一个从来没有过的名字时说不得「已删除」); + 没有对象索引的老缓存同理:absent_reasons 对没截断的一律记 removed,名字写错了也会这样,只说没有。""" + if (getattr(row, "origin", None) or "manual") != "manual": + return ABSENT_PLAIN.format(table=table) + reason = (await absent_reasons(session, row, [table])).get(table) + if reason == ABSENT_REMOVED and not await has_index(session, row.id, index_schema(row)): + reason = None + return ABSENT_MESSAGE.get(reason or "", ABSENT_PLAIN).format(table=table) + + @dataclass(frozen=True) class OutsideObject: """对象索引里在、范围外的一个对象(助手检索、目录搜索用)。""" @@ -1039,7 +1066,8 @@ async def delete_index(session: AsyncSession, source_id: str) -> None: __all__ = [ - "ABSENT_MISSING", "ABSENT_OUT_OF_SCOPE", "ABSENT_REFLECT_FAILED", "ABSENT_REMOVED", "ABSENT_UNKNOWN", "ADD_LIMIT", "AddOutcome", + "ABSENT_MESSAGE", "ABSENT_MISSING", "ABSENT_OUT_OF_SCOPE", "ABSENT_PLAIN", "ABSENT_REFLECT_FAILED", "ABSENT_REMOVED", + "ABSENT_UNKNOWN", "ADD_LIMIT", "AddOutcome", "INDEX_LIMIT", "IndexResult", "IndexedObject", "OutsideObject", "REFLECT_BATCH", "RULES_MAX", "Reflected", "RefreshStats", "SCOPE_DEFAULT", "SCOPE_MAX", "SCOPE_OPTION", "SCOPE_REASONS", "ScopePlan", "ScopeRules", "Trail", "VIA_CATALOG", "VIA_LOOK", "VIA_SCHEMA_TOOL", "absent_reasons", "add_to_scope", "apply_scope", diff --git a/backend/tests/test_catalog_schema_truncated.py b/backend/tests/test_catalog_schema_truncated.py index f0c99435..e4a9ba70 100644 --- a/backend/tests/test_catalog_schema_truncated.py +++ b/backend/tests/test_catalog_schema_truncated.py @@ -157,3 +157,24 @@ def test_legacy_truncated_cache_still_does_not_deny(): "truncated": True, "total": 205}) text = describe_table(row, "t300") assert "中没有" not in text and "只探查了" in text and "information_schema" in text + + +async def test_catalog_detail_of_an_out_of_scope_table_says_so(client, wide): + """对象索引里有、在探查范围外的表:GET /catalog/{表} 的 404 说「在探查范围外」,和 /schema、/catalog/search 同一套「不在」 + (下一轮验收:以前说「可能已被删除或尚未探查结构」);表助手、批量审阅的同一个错误也一样。对象索引里也没有的说「没有 + 这张表」。""" + sid, _ = wide + r = await client.get(f"/api/datasources/{sid}/catalog/t200") + assert r.status_code == 404 and "探查范围外" in r.json()["detail"], r.text + assert "可能已被删除" not in r.json()["detail"] + found = (await client.get(f"/api/datasources/{sid}/catalog/search", params={"q": "t200"})).json()["items"] + assert [i["absent_reason"] for i in found if i["name"] == "t200"] in (["out_of_scope"], [None]), found + r = await client.post(f"/api/datasources/{sid}/catalog/t201/assistant", json={"messages": [ + {"role": "user", "content": "这张表是干什么的"}]}) + assert r.status_code == 404 and "探查范围外" in r.json()["detail"], r.text + r = await client.post(f"/api/datasources/{sid}/catalog/review/batch", json={"items": [ + {"table_name": "t202", "path": "label", "action": "confirm", "if_version": 0}]}) + [result] = r.json()["results"] + assert result["ok"] is False and "探查范围外" in result["error"], result + r = await client.get(f"/api/datasources/{sid}/catalog/no_such_table") + assert r.status_code == 404 and "没有表 no_such_table" in r.json()["detail"] and "探查范围外" not in r.json()["detail"]