mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:21:59 +00:00
0d9f1d9339
- 实测 /api/evals/calibers、/api/schedules/trigger-options 按 Workflow.name 排序遇同名并列时 SQLite、PostgreSQL 顺序不同。用 AST 逐个过了一遍 order_by:最后一个排序键是 created_at / updated_at / last_active_at / at / name / 各种 *_at 之类、不唯一的列,又不带 DISTINCT、GROUP BY 的,补「, 模型.id」(降序补 id.desc()),共 104 处,分布在 api、apps、auth、blueprints、chat、data、engine、eval、ledger、look、memory、process、providers、scheduler(runs.py 里的三处在上一个提交里) - 按唯一列(seq、version、唯一的 name)排的不动;按聚合、coalesce 之类表达式排的说明见 db/postgres
1353 lines
68 KiB
Python
1353 lines
68 KiB
Python
"""数据源的增删改查、连接测试、结构探查。
|
||
|
||
密码的处理规则和 Provider 完全一致(app/api/settings.py):入库加密、
|
||
出站只给掩码、PATCH 时不传表示不动、传空串表示清空。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, Request, UploadFile
|
||
from fastapi.encoders import jsonable_encoder
|
||
from fastapi.responses import JSONResponse
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api import health
|
||
from app.api.coded import (
|
||
INTROSPECT_BUSY, INTROSPECT_FAILED, INTROSPECT_FINISHED, PROFILE_SETTINGS_INVALID, SCOPE_INVALID, SCOPE_TOO_LARGE,
|
||
CodedHTTPException,
|
||
)
|
||
from app.api.deps import actor_of, signer_of, signer_verified
|
||
from app.core.errors import AUTH, NETWORK, TIMEOUT, classify, explain, raw
|
||
from app.core.config import settings
|
||
from app.core.crypto import encrypt, mask
|
||
from app.data import catalog as data_catalog
|
||
from app.data.catalog_profile import profile_settings_rejection, without_legacy
|
||
from app.data import introspect as introspect_mod
|
||
from app.data import introspect_jobs, source_objects
|
||
from app.data import table_versions
|
||
from app.data.engine import (
|
||
CATALOG_DRAFT_GUIDANCE_OPTION, MASK_COLUMNS_OPTION, MAX_QUERY_TIMEOUT_S, QUERY_TIMEOUT_OPTION, SUPPORTED_KINDS,
|
||
SnapshotTampered, build_url, engine_args, engines, mask_columns_problem, query_timeout_problem,
|
||
)
|
||
from app.db.base import get_session, new_id
|
||
from app.db.models import DataSource, ImportStaging, SourceSnapshot, TableImport, TableRecipe
|
||
from app.tools.datasource import tool_names
|
||
|
||
router = APIRouter(prefix="/api/datasources", tags=["datasources"])
|
||
#: 问数据页选数据源用的名单(D-c):main.py 挂 chat_scope,开了问数据的使用者也能读。整组 router 只给搭建者(连接信息、
|
||
#: 表结构、数据目录都在里面),所以单独一个 router;要登记在 router 之前
|
||
names_router = APIRouter(prefix="/api/datasources", tags=["datasources"])
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 名字会成为工具名的一部分(db_query__<name>),而模型的 function name 只认
|
||
# ASCII。所以这里收紧成 slug,中文说明放 description 字段。
|
||
_NAME_PATTERN = r"^[a-z][a-z0-9_]{0,40}$"
|
||
|
||
|
||
class DataSourceIn(BaseModel):
|
||
name: str = Field(pattern=_NAME_PATTERN,
|
||
description="英文标识,会成为工具名的一部分,如 sales")
|
||
kind: str
|
||
host: str | None = None
|
||
port: int | None = None
|
||
database: str | None = None
|
||
username: str | None = None
|
||
password: str | None = None
|
||
options: dict[str, Any] = Field(default_factory=dict)
|
||
readonly: bool = True
|
||
description: str = ""
|
||
enabled: bool = True
|
||
|
||
|
||
class DataSourcePatch(BaseModel):
|
||
kind: str | None = None
|
||
host: str | None = None
|
||
port: int | None = None
|
||
database: str | None = None
|
||
username: str | None = None
|
||
password: str | None = None # 空串=清空;不传=不动
|
||
options: dict[str, Any] | None = None
|
||
readonly: bool | None = None
|
||
description: str | None = None
|
||
enabled: bool | None = None
|
||
|
||
|
||
class DataSourceOut(BaseModel):
|
||
id: str
|
||
name: str
|
||
kind: str
|
||
host: str | None
|
||
port: int | None
|
||
database: str | None
|
||
username: str | None
|
||
options: dict[str, Any]
|
||
readonly: bool
|
||
description: str
|
||
enabled: bool
|
||
password_masked: str = ""
|
||
has_password: bool = False
|
||
table_count: int = 0
|
||
schema_synced_at: str | None = None
|
||
#: 上次探查为什么没拿到表。"还没探查"和"探查失败了"是两回事:
|
||
#: 前者去点一下按钮就行,后者点了也没用,得先解决超时或者换 schema
|
||
schema_error: str = ""
|
||
#: 这台服务器上还有哪些库/schema 可选。探不到表时它就是下一步的线索
|
||
available_schemas: list[str] = Field(default_factory=list)
|
||
tools: list[str] = Field(default_factory=list)
|
||
#: 缓存是按哪个 schema 探的:"" 是默认 schema,None 是没有缓存(或老缓存没记)。
|
||
#: 和 options.schema 对不上时,助手看到的结构不是配置里那个 schema 的
|
||
cached_schema: str | None = None
|
||
#: 最近一次测连接(见 app/api/health.py)。没测过、或者连接配置改过之后都是 None
|
||
last_checked_at: str | None = None
|
||
last_check_ok: bool | None = None
|
||
last_latency_ms: int | None = None
|
||
last_error: str | None = None
|
||
#: upload:上传的表格(连接信息由系统维护,不能手改、不能重新探查);manual:手工登记的连接
|
||
origin: str = "manual"
|
||
#: 上传表格当前启用的版本:{id, created_at, file_name, raw_state}。手工源恒为 None
|
||
current_snapshot: dict[str, Any] | None = None
|
||
#: simple:简单导入的上传表格;recipe:按配方导入;手工源为 None
|
||
import_mode: str | None = None
|
||
#: 按配方导入的源当前启用的配方:{id, seq, origin, activated_at, signed_by}
|
||
current_recipe: dict[str, Any] | None = None
|
||
#: 这个源最近一个未结束的导入暂存区:{id, kind, status, created_at}
|
||
open_staging: dict[str, Any] | None = None
|
||
#: 探查范围的摘要(schema_cache 的 scope:in_scope、reflected、failed、out_of_scope、cap、index_total、各原因几张);
|
||
#: 升级前探查的源、上传表格没有,为 None
|
||
schema_scope: dict[str, Any] | None = None
|
||
#: 最近一个探查任务(进行中、排队中的,或者最近结束的那个):{id, status, total, done, failed, cancelled, waiting_for,
|
||
#: finished_at, error};没探查过(或者是升级前同步探查的)为 None
|
||
introspect_job: dict[str, Any] | None = None
|
||
|
||
|
||
def _cached_schema(cache: dict[str, Any]) -> str | None:
|
||
if not cache or "schema" not in cache:
|
||
return None
|
||
return str(cache.get("schema") or "")
|
||
|
||
|
||
def _import_mode(row: DataSource) -> str | None:
|
||
if row.current_recipe_id:
|
||
return "recipe"
|
||
return "simple" if _is_upload(row) else None
|
||
|
||
|
||
#: options 里由别的接口维护的项:数据源表单不显示、不提交它们,整份回写 options 时原样留着(同模型接入 extra 里的
|
||
#: last_check)。数据目录的起草要求只经 PUT /catalog/guidance 改(api/catalog.py):表单把认不得的项放进「高级参数」
|
||
#: 的单行输入框,多行的要求会被压成一行
|
||
#: 探查范围(introspect_scope)同理:只经 PUT /introspect-scope 改,表单整份回写 options 时原样留着;定时维护的步骤
|
||
#: (catalog_maintenance)只经 PUT /maintenance 改(api/source_changes.py)
|
||
_MANAGED_OPTIONS = (CATALOG_DRAFT_GUIDANCE_OPTION, source_objects.SCOPE_OPTION, "catalog_maintenance")
|
||
|
||
|
||
def _shown_options(options: dict[str, Any] | None) -> dict[str, Any]:
|
||
return {k: v for k, v in (options or {}).items() if k not in _MANAGED_OPTIONS}
|
||
|
||
|
||
def _with_managed(options: dict[str, Any], saved: dict[str, Any] | None) -> dict[str, Any]:
|
||
"""表单提交的 options + 存着的、由别的接口维护的项(提交里带了也不认)。"""
|
||
kept = {k: v for k, v in (saved or {}).items() if k in _MANAGED_OPTIONS}
|
||
return {**_shown_options(options), **kept}
|
||
|
||
|
||
def _to_out(row: DataSource, snapshot: dict[str, Any] | None = None,
|
||
recipe_info: dict[str, Any] | None = None, job: dict[str, Any] | None = None) -> DataSourceOut:
|
||
cache = row.schema_cache or {}
|
||
tables = cache.get("tables") or {}
|
||
recipe_info = recipe_info or {}
|
||
return DataSourceOut(
|
||
schema_scope=dict(cache["scope"]) if isinstance(cache.get("scope"), dict) else None,
|
||
introspect_job=job,
|
||
origin=row.origin or "manual", current_snapshot=snapshot,
|
||
import_mode=_import_mode(row), current_recipe=recipe_info.get("current_recipe"),
|
||
open_staging=recipe_info.get("open_staging"),
|
||
cached_schema=_cached_schema(cache),
|
||
schema_error=str(cache.get("error") or "") if cache.get("failed") else "",
|
||
available_schemas=list(cache.get("available_schemas") or []),
|
||
id=row.id, name=row.name, kind=row.kind, host=row.host, port=row.port,
|
||
database=row.database, username=row.username, options=_shown_options(row.options),
|
||
readonly=row.readonly, description=row.description, enabled=row.enabled,
|
||
password_masked=mask(row.password),
|
||
has_password=bool(row.password),
|
||
table_count=len(tables),
|
||
schema_synced_at=row.schema_synced_at.isoformat() if row.schema_synced_at else None,
|
||
tools=tool_names(row),
|
||
**health.fields(row.last_check),
|
||
)
|
||
|
||
|
||
def _connection(source: Any) -> str | None:
|
||
"""连接配置的指纹:驱动实际拿到的连接串和参数。配置拼不出连接串时为 None。
|
||
|
||
按连接串比而不是按字段比:Oracle 的服务名填在「数据库」还是 options 里,
|
||
连的是同一个库;探查用的 schema 不进连接串,改它也不影响连不连得上。
|
||
"""
|
||
try:
|
||
url, extra = engine_args(source)
|
||
except Exception: # noqa: BLE001 - 拼不出来就谈不上「测的是同一份」
|
||
return None
|
||
return health.fingerprint("datasource", url, extra)
|
||
|
||
|
||
def _probe_record(result: dict[str, Any]) -> dict[str, Any]:
|
||
return health.record(result.get("ok"), result.get("elapsed_ms"), result.get("error"))
|
||
|
||
|
||
async def _get_or_404(session: AsyncSession, source_id: str) -> DataSource:
|
||
row = await session.get(DataSource, source_id)
|
||
if not row:
|
||
raise HTTPException(404, "数据源不存在,可能已被删除")
|
||
return row
|
||
|
||
|
||
def _is_upload(row: DataSource) -> bool:
|
||
return (row.origin or "manual") == "upload"
|
||
|
||
|
||
def _is_records(row: DataSource) -> bool:
|
||
"""记录表(docs/design/normal-user-apps.md N8)登记的只读源:连接、结构由平台维护,随应用的表单变,不在这里改、删。"""
|
||
return (row.origin or "manual") == "records"
|
||
|
||
|
||
_RECORDS_LOCKED = "记录表由应用的表单维护,不能在这里修改「{}」。要改字段,请到应用的「填什么」里改"
|
||
_RECORDS_KEEP = "记录表由应用维护,不能在这里删除:旧记录要一直留着备查"
|
||
|
||
|
||
async def _snapshot_infos(session: AsyncSession, rows: list[DataSource]) -> dict[str, dict[str, Any]]:
|
||
"""各上传源当前版本的摘要:{源 id: {id, created_at, file_name, raw_state, mode, periods, period_start,
|
||
period_end, activated_at}}。两次查询取齐,不逐个查。
|
||
|
||
文件名、原件状态取最近的那一期(按统计期排在最后的);期 3 按期累积时一个快照有多期,卡片另写模式、期数、
|
||
统计期范围。activated_at 是最近一次成为当前版本的时刻:回滚之后卡片写「…启用」,不再写快照当初的导入时间
|
||
(期 3 之前的快照没有这一列,按 created_at)。"""
|
||
wanted = {r.current_snapshot_id: r.id for r in rows if _is_upload(r) and r.current_snapshot_id}
|
||
if not wanted:
|
||
return {}
|
||
snaps = list((await session.execute(
|
||
select(SourceSnapshot).where(SourceSnapshot.id.in_(list(wanted)))
|
||
)).scalars())
|
||
members = {str(x) for s in snaps for x in s.imports or []}
|
||
imports = {
|
||
i.id: i for i in (await session.execute(
|
||
select(TableImport).where(TableImport.id.in_(list(members)))
|
||
)).scalars()
|
||
} if members else {}
|
||
out: dict[str, dict[str, Any]] = {}
|
||
for snap in snaps:
|
||
mine = [imports[str(x)] for x in snap.imports or [] if str(x) in imports]
|
||
imp = mine[-1] if mine else None
|
||
starts = [i.period_start for i in mine if i.period_start]
|
||
ends = [i.period_end for i in mine if i.period_end]
|
||
out[snap.source_id] = {
|
||
"id": snap.id,
|
||
"created_at": snap.created_at.isoformat() if snap.created_at else None,
|
||
"file_name": imp.file_name if imp else "",
|
||
"raw_state": imp.raw_state if imp else "absent",
|
||
"mode": snap.mode or ("replace" if imp is not None and imp.recipe_id else None),
|
||
"periods": len(snap.imports or []),
|
||
"period_start": min(starts) if starts else None,
|
||
"period_end": max(ends) if ends else None,
|
||
"activated_at": _iso(snap.activated_at or snap.created_at),
|
||
}
|
||
return out
|
||
|
||
|
||
def _iso(v: datetime | None) -> str | None:
|
||
return v.isoformat() if v else None
|
||
|
||
|
||
async def _recipe_infos(session: AsyncSession, rows: list[DataSource]) -> dict[str, dict[str, Any]]:
|
||
"""各源的当前配方摘要和最近一个未结束的暂存区:{源 id: {current_recipe, open_staging}}。两次查询取齐。"""
|
||
out: dict[str, dict[str, Any]] = {}
|
||
recipe_ids = {r.current_recipe_id: r.id for r in rows if r.current_recipe_id}
|
||
if recipe_ids:
|
||
for rec in (await session.execute(
|
||
select(TableRecipe).where(TableRecipe.id.in_(list(recipe_ids)))
|
||
)).scalars():
|
||
sid = recipe_ids.get(rec.id)
|
||
if sid is not None:
|
||
out.setdefault(sid, {})["current_recipe"] = {
|
||
"id": rec.id, "seq": rec.seq, "origin": rec.origin,
|
||
"activated_at": _iso(rec.activated_at), "signed_by": rec.signed_by,
|
||
}
|
||
ids = [r.id for r in rows if _is_upload(r)]
|
||
if ids:
|
||
for st in (await session.execute(
|
||
select(ImportStaging).where(
|
||
ImportStaging.source_id.in_(ids), ImportStaging.status.in_(table_versions.STAGING_OPEN),
|
||
).order_by(ImportStaging.created_at, ImportStaging.id)
|
||
)).scalars():
|
||
# 按创建时间升序,后面的覆盖前面的:留下最近的那一个
|
||
out.setdefault(st.source_id, {})["open_staging"] = {
|
||
"id": st.id, "kind": st.kind, "status": st.status, "created_at": _iso(st.created_at),
|
||
}
|
||
return out
|
||
|
||
|
||
async def _out(session: AsyncSession, row: DataSource) -> DataSourceOut:
|
||
return _to_out(row, (await _snapshot_infos(session, [row])).get(row.id),
|
||
(await _recipe_infos(session, [row])).get(row.id),
|
||
await introspect_jobs.job_brief(session, await introspect_jobs.latest_job(session, row.id)))
|
||
|
||
|
||
#: 上传源上由系统维护的字段:连接一律从快照解析,database 只作显示
|
||
_UPLOAD_LOCKED = {
|
||
"kind": "类型", "host": "主机", "port": "端口", "database": "数据库文件路径",
|
||
"username": "用户名", "password": "密码", "readonly": "只读",
|
||
}
|
||
|
||
|
||
#: 手工源指向上传目录时的拒收原因:创建、修改时 422,测连接时作为失败原因交回
|
||
_UPLOAD_DIR_REFUSED = "数据库文件路径不能指向上传表格的存放目录。上传的表格请通过「上传表格」更新"
|
||
#: 升级前就指向上传目录的手工源(启动时已强制只读)想改回可写时的拒收原因
|
||
_UPLOAD_DIR_READONLY = ("这个数据库文件位于上传表格的存放目录,只能以只读方式连接。"
|
||
"上传的表格请通过「上传表格」更新")
|
||
|
||
|
||
#: 手工源指向 AgentLab 自己的数据文件时的拒收原因:创建、修改时 422,测连接时作为失败原因交回
|
||
_APP_FILES_REFUSED = ("数据库文件路径不能指向 AgentLab 自己的数据文件(应用数据库、运行检查点、密钥、工件等)。"
|
||
"请填写存放业务数据的 SQLite 文件")
|
||
|
||
|
||
def _in_upload_dir(kind: str | None, database: str | None) -> bool:
|
||
return (kind or "").lower() == "sqlite" and table_versions.under_uploads(database)
|
||
|
||
|
||
def _in_app_files(kind: str | None, database: str | None) -> bool:
|
||
return (kind or "").lower() == "sqlite" and table_versions.reaches_app_files(database)
|
||
|
||
|
||
def _reserved_reason(kind: str | None, database: str | None) -> str | None:
|
||
"""手工登记的 SQLite 源不许指向的位置:返回拒收原因,没问题时返回 None。
|
||
|
||
上传目录归版本底座管,手工源指过去会绕开只读和版本;应用自己的数据文件(元数据库等)登记成
|
||
数据源,经审批的一条 UPDATE 就能改掉上传源的连接字段和快照登记。两处都按文件身份判断。
|
||
"""
|
||
if _in_app_files(kind, database):
|
||
return _APP_FILES_REFUSED
|
||
if _in_upload_dir(kind, database):
|
||
return _UPLOAD_DIR_REFUSED
|
||
return None
|
||
|
||
|
||
def _refuse_reserved(kind: str | None, database: str | None) -> None:
|
||
reason = _reserved_reason(kind, database)
|
||
if reason:
|
||
raise HTTPException(422, reason)
|
||
|
||
|
||
def _locked_changes(row: DataSource, data: dict[str, Any]) -> list[str]:
|
||
"""上传源上这次要改的、由系统维护的连接字段(给人看的名字)。
|
||
|
||
原样带回的值不算修改(编辑表单会把整份配置一起提交);密码只看是否填了新的——上传源本来就没有密码。
|
||
修改接口和测连接共用:测连接说「能连」、保存却被拒,两边就对不上了。
|
||
"""
|
||
return [label for key, label in _UPLOAD_LOCKED.items() if key in data and (
|
||
bool(data[key]) if key == "password" else data[key] != getattr(row, key)
|
||
)]
|
||
|
||
|
||
def _locked_refusal(changed: list[str]) -> str:
|
||
return f"上传的表格由系统维护连接信息,不能修改「{'」「'.join(changed)}」。如需更新数据,请重新上传"
|
||
|
||
|
||
@names_router.get("/names")
|
||
async def list_source_names(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""问数据页的数据源名单:{items: [{id, name, description, kind}]},按名字排。只列启用着的(停用的问数据用不了),
|
||
只给名字、说明和种类:连接信息、表结构、数据目录都不在这里,使用者(开了问数据)也能读。"""
|
||
rows = (await session.execute(
|
||
select(DataSource.id, DataSource.name, DataSource.description, DataSource.kind)
|
||
.where(DataSource.enabled.is_(True)).order_by(DataSource.name))).all()
|
||
return {"items": [{"id": r.id, "name": r.name, "description": r.description or "", "kind": r.kind} for r in rows]}
|
||
|
||
|
||
@router.get("", response_model=list[DataSourceOut])
|
||
async def list_sources(session: AsyncSession = Depends(get_session)) -> list[DataSourceOut]:
|
||
rows = list((await session.execute(select(DataSource).order_by(DataSource.name))).scalars())
|
||
snaps = await _snapshot_infos(session, rows)
|
||
recipes = await _recipe_infos(session, rows)
|
||
jobs = await introspect_jobs.latest_briefs(session, [r.id for r in rows if not _is_upload(r) and not _is_records(r)])
|
||
return [_to_out(r, snaps.get(r.id), recipes.get(r.id), jobs.get(r.id)) for r in rows]
|
||
|
||
|
||
#: 每种库都有的「查询时限」。数据库按它自己停下语句,不只是后端不再等
|
||
_TIMEOUT_FIELD = {
|
||
"key": QUERY_TIMEOUT_OPTION, "label": "查询时限(秒)", "placeholder": "30",
|
||
"help": f"单条查询的最长执行时间,超时后由数据库终止;留空为 30 秒,最多 {MAX_QUERY_TIMEOUT_S} 秒",
|
||
}
|
||
|
||
|
||
#: 证据面板展示原始行时遮掉的列。每种库都有:遮罩只看查询结果的列名,和方言无关
|
||
_MASK_FIELD = {
|
||
"key": MASK_COLUMNS_OPTION, "label": "遮罩的列", "placeholder": "phone, email",
|
||
# 开登录后对 user 角色遮罩就是边界(A7 第 6 条):它的问数据运行在工具边界就遮,应用视图、证据、工件都按派生遮罩给。
|
||
# 对搭建者(和关登录时的所有人)仍只减少暴露——工件里是完整快照,界面上两句都照实说
|
||
"help": "证据面板展示查询结果的原始行时,这些列显示为「已遮罩」。"
|
||
"对搭建者只减少暴露,不是安全边界:完整快照仍可通过工件获取;开启登录后,使用者看到的查询结果一律遮罩",
|
||
}
|
||
|
||
|
||
def _refuse_bad_options(options: dict[str, Any] | None) -> None:
|
||
problem = query_timeout_problem(options) or mask_columns_problem(options)
|
||
if problem:
|
||
raise HTTPException(422, problem)
|
||
# 剖析设置:带机读码和出错的那一项(field),表单按它把报错落到那一格,不再从原话里认
|
||
if rejected := profile_settings_rejection(options):
|
||
message, field = rejected
|
||
raise CodedHTTPException(422, message, PROFILE_SETTINGS_INVALID, field=field)
|
||
|
||
|
||
@router.get("/kinds")
|
||
async def list_kinds() -> dict[str, Any]:
|
||
"""支持哪些数据库,以及各自需要填什么——前端表单据此渲染。
|
||
|
||
hint 只用表单上看得见的说法。以前写的是「在 options 里给 service_name」
|
||
「options 可填 charset」,表单上根本没有叫 options 的地方。advanced 列出
|
||
「高级连接参数」里常用的键,hint 提到的每个键都在那里找得到。
|
||
"""
|
||
return {
|
||
"kinds": [
|
||
{"value": "mysql", "label": "MySQL / MariaDB", "default_port": 3306,
|
||
"needs": ["host", "database", "username", "password"],
|
||
"hint": "字符集默认为 utf8mb4;如需更改,请在「高级连接参数」中添加 charset",
|
||
"advanced": [{"key": "charset", "label": "字符集", "placeholder": "utf8mb4"},
|
||
_TIMEOUT_FIELD, _MASK_FIELD]},
|
||
{"value": "postgres", "label": "PostgreSQL", "default_port": 5432,
|
||
"needs": ["host", "database", "username", "password"],
|
||
"hint": "「schema」留空时使用 public",
|
||
"advanced": [_TIMEOUT_FIELD, _MASK_FIELD]},
|
||
{"value": "oracle", "label": "Oracle", "default_port": 1521,
|
||
"needs": ["host", "username", "password"],
|
||
"hint": "service_name 与 SID 二选一,通常填写 service_name;无需安装 Instant Client。"
|
||
"若只读账号名下没有对象,请在「schema」中填写数据所在的 schema(如 ANALYTICS)",
|
||
"advanced": [
|
||
{"key": "service_name", "label": "service_name", "placeholder": "ORCLPDB1",
|
||
"help": "与 SID 二选一"},
|
||
{"key": "sid", "label": "SID", "placeholder": "ORCL", "help": "与 service_name 二选一"},
|
||
_TIMEOUT_FIELD,
|
||
_MASK_FIELD,
|
||
]},
|
||
{"value": "sqlite", "label": "SQLite(文件)", "default_port": None,
|
||
"needs": ["database"], "hint": "「数据库文件路径」请填写 .db 文件的绝对路径",
|
||
"advanced": [_TIMEOUT_FIELD, _MASK_FIELD]},
|
||
],
|
||
"supported": list(SUPPORTED_KINDS),
|
||
}
|
||
|
||
|
||
def _oracle_target(options: dict[str, Any] | None) -> tuple[str, str]:
|
||
opts = options or {}
|
||
return str(opts.get("service_name") or "").strip(), str(opts.get("sid") or "").strip()
|
||
|
||
|
||
def _settle_oracle(
|
||
row: DataSource, old_options: dict[str, Any] | None, old_database: str | None
|
||
) -> None:
|
||
"""Oracle 的 service_name / SID 只存一处:options 里,database 清空。
|
||
|
||
引擎按 `options.service_name or database` 取服务名,两者都空才看 sid。老数据
|
||
把服务名存在 database,于是新表单切到 SID 存下去会被它压住、悄悄无视;旧表单
|
||
把 service_name 框绑在 database 上、保存时又原样带回 options,新填的值同样
|
||
被压住。两处都能存,就总有一处在暗中说了算,所以存的时候收成一处:这次
|
||
改了哪一处就听哪一处;都没改就照引擎原来的取法,连的还是同一个库。
|
||
"""
|
||
if (row.kind or "").lower() != "oracle":
|
||
return
|
||
opts = {k: v for k, v in (row.options or {}).items()
|
||
if k not in ("service_name", "sid") or str(v or "").strip()}
|
||
database = (row.database or "").strip()
|
||
picked = any(_oracle_target(opts)) and _oracle_target(opts) != _oracle_target(old_options)
|
||
typed = bool(database) and database != (old_database or "").strip()
|
||
if not picked and (typed or (database and "service_name" not in opts)):
|
||
opts["service_name"] = database
|
||
opts.pop("sid", None)
|
||
row.options = opts
|
||
row.database = None
|
||
|
||
|
||
@router.post("", response_model=DataSourceOut, status_code=201)
|
||
async def create_source(
|
||
payload: DataSourceIn, session: AsyncSession = Depends(get_session)
|
||
) -> DataSourceOut:
|
||
if payload.kind not in SUPPORTED_KINDS:
|
||
raise HTTPException(400, f"不支持「{payload.kind}」类型的数据库,目前支持:{'、'.join(SUPPORTED_KINDS)}")
|
||
exists = (await session.execute(
|
||
select(DataSource).where(DataSource.name == payload.name)
|
||
)).scalar_one_or_none()
|
||
if exists:
|
||
raise HTTPException(409, f"已存在名为「{payload.name}」的数据源,请换一个标识")
|
||
options = without_legacy(payload.options) or {}
|
||
_refuse_bad_options(options)
|
||
_refuse_reserved(payload.kind, payload.database)
|
||
|
||
row = DataSource(
|
||
**payload.model_dump(exclude={"password", "options"}),
|
||
options=_with_managed(options, None),
|
||
password=encrypt(payload.password),
|
||
)
|
||
_settle_oracle(row, None, None)
|
||
# 表单里刚测过这份配置:保存下来就带着那次结果,不必再测一遍
|
||
row.last_check = health.draft_result(_connection(row))
|
||
session.add(row)
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
return _to_out(row)
|
||
|
||
|
||
@router.patch("/{source_id}", response_model=DataSourceOut)
|
||
async def update_source(
|
||
source_id: str, payload: DataSourcePatch, session: AsyncSession = Depends(get_session)
|
||
) -> DataSourceOut:
|
||
row = await _get_or_404(session, source_id)
|
||
data = payload.model_dump(exclude_unset=True)
|
||
if _is_records(row):
|
||
# 连接、只读、结构都由记录表维护(records/tables.py);说明、遮罩列这类照常能改
|
||
changed = _locked_changes(row, data)
|
||
if changed:
|
||
raise HTTPException(422, _RECORDS_LOCKED.format("」「".join(changed)))
|
||
for key in _UPLOAD_LOCKED:
|
||
data.pop(key, None)
|
||
elif _is_upload(row):
|
||
# 上传源的连接由版本底座维护(见 _locked_changes)
|
||
changed = _locked_changes(row, data)
|
||
if changed:
|
||
raise HTTPException(422, _locked_refusal(changed))
|
||
for key in _UPLOAD_LOCKED:
|
||
data.pop(key, None)
|
||
if data.get("options") is not None:
|
||
# 上传库只有一个 main:指定 schema 会让探查去找不存在的对象。原样带回的不算修改,摘掉即可
|
||
options = dict(data["options"])
|
||
wanted = str(options.pop("schema", None) or "").strip()
|
||
if wanted and wanted != str((row.options or {}).get("schema") or "").strip():
|
||
raise HTTPException(422, "上传的表格只有一个库,不能指定 schema。如需更新数据,请重新上传")
|
||
data["options"] = options
|
||
elif "kind" in data or "database" in data:
|
||
_refuse_reserved(data.get("kind", row.kind), data.get("database", row.database))
|
||
elif _in_app_files(row.kind, row.database):
|
||
# 升级前登记的、指向应用自己数据文件的源:启动时已停用(table_versions.startup)。
|
||
# 这类登记没有正当用途,除了改路径和删除,别的修改(重新启用、改回可写……)一律拒
|
||
raise HTTPException(422, _APP_FILES_REFUSED)
|
||
elif data.get("readonly") is False and row.readonly and _in_upload_dir(row.kind, row.database):
|
||
# 升级前就指向上传目录的手工源:启动时已强制只读(table_versions.startup),不许再改回可写
|
||
raise HTTPException(422, _UPLOAD_DIR_READONLY)
|
||
if "options" in data:
|
||
# 剖析设置里还带着以前按整次剖析算的预算(老页面提交的):只摘掉,不报错(catalog_profile.LEGACY_KEYS)
|
||
data["options"] = without_legacy(data["options"])
|
||
_refuse_bad_options(data["options"])
|
||
data["options"] = _with_managed(data["options"] or {}, row.options)
|
||
if "password" in data:
|
||
# 空串表示清空,非空表示换新的
|
||
data["password"] = encrypt(data["password"]) if data["password"] else None
|
||
old_options, old_database = dict(row.options or {}), row.database
|
||
before = _connection(row)
|
||
for key, value in data.items():
|
||
setattr(row, key, value)
|
||
_settle_oracle(row, old_options, old_database)
|
||
after = _connection(row)
|
||
if after != before:
|
||
# 连的已经不是测过的那个了:旧结果作废,表单里测过这一份的话换成那一次
|
||
row.last_check = health.draft_result(after)
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
# 连接参数可能变了,旧连接池不能再用——否则改完密码还在用旧连接,很难排查
|
||
await engines.invalidate(source_id)
|
||
return await _out(session, row)
|
||
|
||
|
||
@router.delete("/{source_id}", status_code=204)
|
||
async def delete_source(source_id: str, session: AsyncSession = Depends(get_session)) -> None:
|
||
row = await _get_or_404(session, source_id)
|
||
if _is_records(row):
|
||
raise HTTPException(409, _RECORDS_KEEP)
|
||
# 上传源的导入记录置 retired 留作审计;库文件和原件交给回收,被运行引用的版本会保留。只读进程(没拿到
|
||
# 版本存储的守卫)不删上传源:retire_source 要删隔离文件、放弃暂存区(第 8 节遗留项 5)。
|
||
# 手工登记的源没有导入记录、配方、暂存区和隔离文件,根本不碰版本存储:不调 retire_source,只读进程里也照常
|
||
# 删除(9.1 只要求上传源回 503,评审意见);删完也没有文件可回收
|
||
upload = _is_upload(row)
|
||
if upload:
|
||
try:
|
||
await table_versions.retire_source(session, source_id)
|
||
except table_versions.StoreUnavailable as e:
|
||
raise CodedHTTPException(503, str(e), "store_unavailable") from e
|
||
await session.delete(row)
|
||
await session.commit()
|
||
await engines.invalidate(source_id)
|
||
if upload:
|
||
try:
|
||
await table_versions.gc(session)
|
||
except Exception: # noqa: BLE001 - 回收失败不影响删除,重启或下次上传时再收
|
||
logger.exception("删除数据源后的回收失败")
|
||
|
||
|
||
#: 测连接最多等多久。内网库防火墙丢包时驱动默认要等一分钟,弹窗里干转一分钟
|
||
#: 等于没有反馈
|
||
_TEST_TIMEOUT = 15
|
||
|
||
|
||
_WRONG_PASSWORD = ("用户名或密码错误", "请核对「用户名」和「密码」;编辑时密码留空表示沿用已保存的密码")
|
||
_CHECK_HOST = "请检查「主机」和「端口」;内网数据库请确认本机可以访问(防火墙、VPN)"
|
||
|
||
|
||
def _explain_connect(e: BaseException, kind: str) -> tuple[str, str]:
|
||
"""连库失败的常见原因。驱动的原文九成能定位问题,但得先翻译成表单上的说法。"""
|
||
low = str(e).lower()
|
||
if any(s in low for s in ("password authentication failed", "access denied", "ora-01017",
|
||
"invalid username/password", "(1045")):
|
||
return _WRONG_PASSWORD
|
||
if "ora-12514" in low:
|
||
return "Oracle 无法识别该 service_name", "请核对 service_name;部分旧版数据库只提供 SID,可改用 SID 后重试"
|
||
if "ora-12505" in low:
|
||
return "Oracle 无法识别该 SID", "请核对 SID,或改用 service_name"
|
||
if "service_name 或 sid" in low:
|
||
return "未填写 service_name 或 SID", "请在「service_name」中填写,或改用 SID"
|
||
if "unknown database" in low or "(1049" in low or (
|
||
"does not exist" in low and "database" in low
|
||
):
|
||
return "服务器上不存在该数据库", "请核对「数据库」一栏"
|
||
if "unable to open database file" in low:
|
||
return "无法打开该数据库文件", "请确认路径是绝对路径、文件存在,且服务端进程有读取权限"
|
||
if "file is not a database" in low:
|
||
return "该文件不是 SQLite 数据库", "请确认路径指向的是 .db 文件"
|
||
if "no module named" in low:
|
||
return "服务端未安装该数据库的驱动", "请执行 pip install 'agentlab-backend[db]',然后重启服务端"
|
||
# 下面按错误类别换成数据库的说法:通用的那几句说的是 API(API Key、Base URL),这里是数据库。
|
||
# 按类别判断,不比较 explain 的文字——那句话改一个字,这里就再也对不上
|
||
category = classify(e)
|
||
if category == AUTH:
|
||
return _WRONG_PASSWORD
|
||
if category == NETWORK:
|
||
return "网络不通,或数据库服务未启动", _CHECK_HOST
|
||
if category == TIMEOUT:
|
||
return "数据库长时间没有响应", _CHECK_HOST
|
||
reason, hint = explain(e)
|
||
if kind == "sqlite" and not hint:
|
||
hint = "请检查数据库文件的路径"
|
||
return reason, hint
|
||
|
||
|
||
def _safe_url(source: Any) -> str:
|
||
try:
|
||
return build_url(source) # 遮掉密码的版本
|
||
except Exception: # noqa: BLE001 - 连串都拼不出来时,原因已经在 error 里了
|
||
return ""
|
||
|
||
|
||
def _missing_sqlite_file(source: Any) -> tuple[str, str, str] | None:
|
||
"""SQLite 的路径指向不存在的文件时,返回 (reason, hint, detail)。
|
||
|
||
可写模式下 SQLite 连一个不存在的路径会当场建一个空库:测试说「连得上」,
|
||
连的却是个空文件,「只测不存」的接口还往磁盘上写了东西。没填路径时连的
|
||
是内存库,一样是假的「连得上」。按 SQLite 自己的解析方式判断(不展开 ~)。
|
||
"""
|
||
from pathlib import Path
|
||
|
||
if (source.kind or "").lower() != "sqlite":
|
||
return None
|
||
path = source.database or ""
|
||
if not path.strip():
|
||
return "未填写数据库文件路径", "请在「数据库文件路径」中填写 .db 文件的绝对路径", "database 为空"
|
||
if path == ":memory:" or Path(path).is_file():
|
||
return None
|
||
return ("无法打开该数据库文件",
|
||
"该路径下没有文件。请确认路径是绝对路径且文件存在(~ 不会被展开)",
|
||
f"文件不存在:{path}")
|
||
|
||
|
||
async def _probe(source: Any, *, cached: bool) -> dict[str, Any]:
|
||
"""真连一次。cached=False 用一次性的 engine,测完就扔——草稿不该进连接池缓存,
|
||
否则改完配置再测,拿到的还是上一版的连接。"""
|
||
import asyncio
|
||
import time
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.ext.asyncio import create_async_engine
|
||
from sqlalchemy.pool import NullPool
|
||
|
||
started = time.perf_counter()
|
||
missing = _missing_sqlite_file(source)
|
||
if missing:
|
||
reason, hint, detail = missing
|
||
return {"ok": False, "error": f"连接失败:{reason}", "hint": hint, "detail": detail,
|
||
"elapsed_ms": 0, "url": _safe_url(source)}
|
||
engine = None
|
||
try:
|
||
if cached:
|
||
engine = await engines.get(source)
|
||
else:
|
||
url, extra = engine_args(source)
|
||
engine = create_async_engine(url, poolclass=NullPool, **extra)
|
||
async with asyncio.timeout(_TEST_TIMEOUT):
|
||
async with engine.connect() as conn:
|
||
oracle = (source.kind or "").lower() == "oracle"
|
||
await conn.execute(text("SELECT 1 FROM DUAL" if oracle else "SELECT 1"))
|
||
return {
|
||
"ok": True,
|
||
"elapsed_ms": int((time.perf_counter() - started) * 1000),
|
||
"url": _safe_url(source),
|
||
}
|
||
except SnapshotTampered as e:
|
||
# 快照文件和登记的哈希对不上:原话就是原因,不翻译成泛泛的连接错误
|
||
return {"ok": False, "error": str(e), "hint": "请重新上传这份表格", "detail": "",
|
||
"elapsed_ms": int((time.perf_counter() - started) * 1000), "url": _safe_url(source)}
|
||
except Exception as e: # noqa: BLE001
|
||
reason, hint = _explain_connect(e, (source.kind or "").lower())
|
||
return {
|
||
"ok": False,
|
||
"error": f"连接失败:{reason}",
|
||
"hint": hint,
|
||
"detail": raw(e),
|
||
"elapsed_ms": int((time.perf_counter() - started) * 1000),
|
||
"url": _safe_url(source),
|
||
}
|
||
finally:
|
||
if engine is not None and not cached:
|
||
await engine.dispose()
|
||
|
||
|
||
class DataSourceTestIn(BaseModel):
|
||
"""一份还没保存(或改了还没保存)的配置。带 id 表示在编辑已有的那个。"""
|
||
|
||
id: str | None = None
|
||
name: str = ""
|
||
kind: str
|
||
host: str | None = None
|
||
port: int | None = None
|
||
database: str | None = None
|
||
username: str | None = None
|
||
password: str | None = None # 编辑时留空=沿用已保存的
|
||
options: dict[str, Any] = Field(default_factory=dict)
|
||
readonly: bool = True
|
||
|
||
|
||
def _draft_source(payload: DataSourceTestIn, saved: DataSource | None) -> DataSource:
|
||
"""拼一个不进会话的临时数据源,只拿来连一下。
|
||
|
||
编辑时密码框留空表示不改——测连接也得用存着的那个,否则改个端口想测一下,
|
||
只会得到一句"密码不对"。
|
||
"""
|
||
if payload.password:
|
||
password = encrypt(payload.password)
|
||
else:
|
||
password = saved.password if saved else None
|
||
return DataSource(
|
||
name=payload.name or (saved.name if saved else "draft"),
|
||
kind=payload.kind, host=payload.host, port=payload.port, database=payload.database,
|
||
username=payload.username, password=password, options=dict(payload.options or {}),
|
||
readonly=payload.readonly,
|
||
)
|
||
|
||
|
||
@router.post("/test")
|
||
async def test_draft(
|
||
payload: DataSourceTestIn, session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, Any]:
|
||
"""测一份没保存的配置,不落库。
|
||
|
||
以前只能测已存在的数据源:填错一项要保存→关弹窗→点测试→看提示→再打开改,
|
||
接内网 Oracle 这类填错一个字段就连不上的东西,来回四五趟。
|
||
"""
|
||
if payload.kind not in SUPPORTED_KINDS:
|
||
return {"ok": False, "error": f"不支持「{payload.kind}」类型的数据库",
|
||
"hint": f"目前支持:{'、'.join(SUPPORTED_KINDS)}", "detail": "", "elapsed_ms": 0, "url": ""}
|
||
saved = await session.get(DataSource, payload.id) if payload.id else None
|
||
if saved is not None and _is_records(saved):
|
||
# 记录表:连接字段由应用维护(同上传源),测的就是登记的那个文件
|
||
changed = _locked_changes(saved, payload.model_dump(exclude_unset=True, include=set(_UPLOAD_LOCKED)))
|
||
if changed:
|
||
return _refused(_RECORDS_LOCKED.format("」「".join(changed)))
|
||
return await _test_saved(session, saved)
|
||
if saved is not None and _is_upload(saved):
|
||
# 上传源:连接字段由系统维护。改了就和保存一样拒;没改,测的就是它当前版本的快照,
|
||
# 不拿表单里的路径去连——否则带上一个上传源的 id,就能把上传目录里任何文件拿来试
|
||
changed = _locked_changes(saved, payload.model_dump(exclude_unset=True, include=set(_UPLOAD_LOCKED)))
|
||
if changed:
|
||
return _refused(_locked_refusal(changed))
|
||
return await _test_saved(session, saved)
|
||
reason = _reserved_reason(payload.kind, payload.database)
|
||
if reason:
|
||
# 保存时会被拒(_refuse_reserved):测连接不能先报「连接成功」,到保存才说不行
|
||
return _refused(reason)
|
||
draft = _draft_source(payload, saved)
|
||
result = await _probe(draft, cached=False)
|
||
last, key = _probe_record(result), _connection(draft)
|
||
health.remember_draft(key, last)
|
||
if saved is not None and key is not None and key == _connection(saved):
|
||
# 编辑框里没改连接、只是再测一次:测的就是已保存的那份
|
||
saved.last_check = last
|
||
await session.commit()
|
||
return result
|
||
|
||
|
||
@router.post("/{source_id}/test")
|
||
async def test_source(source_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""测连接。失败时说清原因和怎么办,驱动的原始报错放在 detail——这类问题九成靠它定位。"""
|
||
return await _test_saved(session, await _get_or_404(session, source_id))
|
||
|
||
|
||
def _refused(reason: str) -> dict[str, Any]:
|
||
"""不去连就能判定失败的测连接结果(形状和 _probe 的一样)。"""
|
||
return {"ok": False, "error": reason, "hint": "", "detail": "", "elapsed_ms": 0, "url": ""}
|
||
|
||
|
||
async def _test_saved(session: AsyncSession, row: DataSource) -> dict[str, Any]:
|
||
"""测一个已保存的数据源,结果记进 last_check(提交)。
|
||
|
||
上传源测的是它当前版本的快照:只读、immutable、建引擎前核对哈希,和查询走同一条路。
|
||
拿数据源行本身去连,既不核对哈希(快照被改过也报「连接成功」),还会在连接缓存里留下
|
||
一个不带版本、不核对的引擎。手工源指向应用自己的数据文件(升级前登记的)时不去连。
|
||
"""
|
||
if _is_upload(row):
|
||
try:
|
||
view = await table_versions.resolve_source(session, row)
|
||
except table_versions.SnapshotMissing as e:
|
||
result = _refused(str(e))
|
||
else:
|
||
result = await _probe(view, cached=True)
|
||
elif _is_records(row):
|
||
# 记录表的文件在应用自己的数据目录里,是平台登记的只读源,照常测
|
||
result = await _probe(row, cached=True)
|
||
elif _in_app_files(row.kind, row.database):
|
||
result = _refused(_APP_FILES_REFUSED)
|
||
else:
|
||
result = await _probe(row, cached=True)
|
||
row.last_check = _probe_record(result)
|
||
await session.commit()
|
||
return result
|
||
|
||
|
||
class IntrospectPreview(BaseModel):
|
||
"""只看不存的探查结果(dry_run):刷新对象索引(配置里的 schema)或者只看(别的 schema),按探查范围的规则数命中。
|
||
不反射、不动表结构缓存和同步时间。"""
|
||
|
||
dry_run: bool = True
|
||
#: 这次探的是哪个 schema,"" 是默认 schema
|
||
schema_: str = Field(default="", serialization_alias="schema")
|
||
#: 对象索引里有几个对象(表和视图)
|
||
table_count: int = 0
|
||
#: 带 schema 前缀的对象全名,最多 200 个(举例用;全部的在对象索引里)
|
||
tables: list[str] = Field(default_factory=list)
|
||
#: 对象多于上面列出的
|
||
truncated: bool = False
|
||
total: int = 0
|
||
schema_error: str = ""
|
||
available_schemas: list[str] = Field(default_factory=list)
|
||
#: 配置里的那个 schema:对象索引存下来了(别的 schema 只看不存)
|
||
stored: bool = False
|
||
#: 按探查范围的规则数的命中(source_objects.ScopePlan.summary:hits、cap、in_scope、pinned、rule、explicit、on_demand、
|
||
#: out_of_scope、index_total);over 是超没超上限
|
||
scope: dict[str, Any] = Field(default_factory=dict)
|
||
#: 对象清单是怎么拿的:dictionary 字典查询 / inspector 退回 Inspector(没有注释);capped 超过了对象索引的上限
|
||
index_method: str = ""
|
||
index_capped: bool = False
|
||
|
||
|
||
#: 预览时举例列出的对象最多几个
|
||
_PREVIEW_NAMES = 200
|
||
|
||
|
||
def _preview_response(preview: IntrospectPreview) -> JSONResponse:
|
||
"""dry_run 是同步的只看不存:200(真探查才是 202 建任务)。"""
|
||
return JSONResponse(status_code=200, content=preview.model_dump(by_alias=True, mode="json"))
|
||
|
||
|
||
def _explained(e: BaseException, row: DataSource) -> str:
|
||
reason, hint = _explain_connect(e, (row.kind or "").lower())
|
||
return f"探查表结构失败:{reason}" + (f"。{hint}" if hint else "")
|
||
|
||
|
||
def _introspect_refusal(row: DataSource) -> None:
|
||
if _is_upload(row):
|
||
raise HTTPException(409, introspect_jobs.UPLOAD_REFUSED)
|
||
if _is_records(row):
|
||
raise HTTPException(409, introspect_jobs.RECORDS_REFUSED)
|
||
|
||
|
||
def _too_large(e: introspect_jobs.ScopeTooLarge) -> CodedHTTPException:
|
||
plan = e.plan
|
||
return CodedHTTPException(409, str(e), SCOPE_TOO_LARGE, extra={
|
||
"hits": plan.capped_count, "cap": plan.cap, "pinned": plan.counts.get("pinned", 0),
|
||
"in_scope": len(plan.in_scope), "index_total": plan.index_total})
|
||
|
||
|
||
@router.post("/{source_id}/introspect", status_code=202, response_model=None)
|
||
async def introspect_source(
|
||
source_id: str,
|
||
schema: str | None = None,
|
||
dry_run: bool = Query(default=False, description="只刷新对象索引、数命中,不反射、不写表结构缓存"),
|
||
x_actor: str | None = Header(default=None),
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> Any:
|
||
"""探查结构:后台任务(202,返回 {job, source};进度读 GET /introspect-jobs/{job_id})。
|
||
|
||
请求里先刷新对象索引(一条字典查询)、按探查范围的规则圈表、核上限:规则命中超过上限 409 scope_too_large(带命中数,
|
||
不建任务、不截断);同源已有排队或进行中的探查任务 409 introspect_busy;对象清单都拿不到 400 introspect_failed(失败
|
||
照实记进表结构缓存,卡片上看得到原因和能选的 schema)。任务在后台按批只读反射,和同源的起草、剖析排同一条队。
|
||
|
||
dry_run=true 只看不存(同步,200):配置里的 schema 刷新对象索引、返回命中数;schema 写了别的时只看那个 schema 里有
|
||
什么,对象索引、表结构缓存、配置都不动(「换个 schema 看看」)。
|
||
|
||
上传的表格不探查:结构在发布那一刻随快照冻结,重新探查只会让缓存和快照对不上。一个数据源一个 schema(目录-D2):
|
||
真探查只按配置里的 schema,要换 schema 先改数据源设置。
|
||
"""
|
||
row = await _get_or_404(session, source_id)
|
||
_introspect_refusal(row)
|
||
if dry_run:
|
||
try:
|
||
got = await introspect_jobs.preview(session, row, schema=schema)
|
||
except introspect_jobs.IntrospectFailed as e:
|
||
return _preview_response(IntrospectPreview(
|
||
schema_=schema or str(source_objects.index_schema(row)), schema_error=str(e),
|
||
available_schemas=await introspect_mod.list_schemas(row)))
|
||
names = got["objects"]
|
||
prefix = got["schema"]
|
||
return _preview_response(IntrospectPreview(
|
||
schema_=prefix, table_count=len(names), total=got["index_total"],
|
||
tables=[f"{prefix}.{n}" if prefix else n for n in names[:_PREVIEW_NAMES]],
|
||
truncated=len(names) > _PREVIEW_NAMES, stored=bool(got.get("stored")),
|
||
scope={**got["scope"], "over": got["scope"]["hits"] > got["scope"]["cap"]},
|
||
index_method=got["index_method"], index_capped=bool(got["index_capped"]),
|
||
available_schemas=[] if names else await introspect_mod.list_schemas(row),
|
||
))
|
||
if schema and schema.strip() and schema.strip() != source_objects.index_schema(row):
|
||
raise HTTPException(422, "一个数据源只探查配置里的那个 schema。要换 schema,请先在数据源设置里改「schema」,"
|
||
"或者用「预览其他 schema」只看不存")
|
||
try:
|
||
job, _n = await introspect_jobs.prepare(session, row, actor=actor_of(x_actor))
|
||
except introspect_jobs.IntrospectBusy as e:
|
||
raise CodedHTTPException(409, str(e), INTROSPECT_BUSY) from e
|
||
except introspect_jobs.ScopeTooLarge as e:
|
||
raise _too_large(e) from e
|
||
except introspect_jobs.IntrospectFailed as e:
|
||
raise CodedHTTPException(400, _explained(e.cause, row), INTROSPECT_FAILED) from e
|
||
introspect_jobs.manager.start(job.id)
|
||
await session.refresh(row)
|
||
return JSONResponse(status_code=202, content=jsonable_encoder({
|
||
"job": await introspect_jobs.job_out(session, job), "source": (await _out(session, row)).model_dump()}))
|
||
|
||
|
||
async def _job_or_404(session: AsyncSession, source_id: str, job_id: str) -> Any:
|
||
job = await introspect_jobs.load(session, source_id, job_id)
|
||
if job is None:
|
||
raise HTTPException(404, "探查任务不存在,可能已被清理")
|
||
return job
|
||
|
||
|
||
@router.get("/{source_id}/introspect-jobs/active")
|
||
async def active_introspect_job(source_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""排队或进行中的探查任务;没有是 {job: null}。"""
|
||
await _get_or_404(session, source_id)
|
||
return {"job": await introspect_jobs.job_out(session, await introspect_jobs.active_job(session, source_id))}
|
||
|
||
|
||
@router.get("/{source_id}/introspect-jobs/latest")
|
||
async def latest_introspect_job(source_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""最近建的探查任务(任何状态);没有是 {job: null}。"""
|
||
await _get_or_404(session, source_id)
|
||
return {"job": await introspect_jobs.job_out(session, await introspect_jobs.latest_job(session, source_id))}
|
||
|
||
|
||
@router.get("/{source_id}/introspect-jobs/{job_id}")
|
||
async def get_introspect_job(source_id: str, job_id: str,
|
||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""一个探查任务(进度、读不到的表)和数据源此刻的样子(结束了以后卡片据此换上新的表数)。"""
|
||
row = await _get_or_404(session, source_id)
|
||
job = await _job_or_404(session, source_id, job_id)
|
||
return {"job": await introspect_jobs.job_out(session, job), "source": (await _out(session, row)).model_dump()}
|
||
|
||
|
||
@router.post("/{source_id}/introspect-jobs/{job_id}/cancel")
|
||
async def cancel_introspect_job(source_id: str, job_id: str,
|
||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""停止探查:排队的直接停;进行中的停在当前这一批之后,读完的表照常更新进表结构。已经结束的 409 introspect_finished。"""
|
||
await _get_or_404(session, source_id)
|
||
job = await _job_or_404(session, source_id, job_id)
|
||
try:
|
||
await introspect_jobs.cancel(session, job)
|
||
except introspect_jobs.IntrospectFinished as e:
|
||
raise CodedHTTPException(409, str(e), INTROSPECT_FINISHED) from e
|
||
session.expire_all()
|
||
row = await _get_or_404(session, source_id)
|
||
job = await _job_or_404(session, source_id, job_id)
|
||
return {"job": await introspect_jobs.job_out(session, job), "source": (await _out(session, row)).model_dump()}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 探查范围
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
class ScopeIn(BaseModel):
|
||
#: {mode, include, exclude, tables, removed, views, max_tables}(source_objects.ScopeRules)。手工读成 dict 核对:说法要
|
||
#: 是中文的一句话(rules_problem)
|
||
scope: dict[str, Any]
|
||
|
||
|
||
async def _scope_out(session: AsyncSession, row: DataSource, rules: source_objects.ScopeRules | None = None,
|
||
) -> dict[str, Any]:
|
||
"""探查范围面板要的:规则、缺省和上限、按规则对着存着的对象索引数的命中、对象索引的情况、pinned 几张(举几个例子)、
|
||
按需加入的表和留痕。没有对象索引的源(升级前探查的、还没探查过的)命中是 null:先「刷新对象索引」或探查一次。"""
|
||
stored = source_objects.ScopeRules.from_options(row.options)
|
||
rules = rules or stored
|
||
schema = source_objects.index_schema(row)
|
||
indexed = await source_objects.has_index(session, row.id, schema)
|
||
preview: dict[str, Any] | None = None
|
||
pinned: list[str] = []
|
||
if indexed:
|
||
plan = await source_objects.plan_for(session, row, rules)
|
||
preview = {**plan.summary(), "over": plan.over}
|
||
pinned = [n for n, r in plan.in_scope.items() if r == "pinned"][:20]
|
||
cache = row.schema_cache or {}
|
||
scope = cache.get("scope") if isinstance(cache.get("scope"), dict) else {}
|
||
return {
|
||
"scope": stored.to_dict(),
|
||
"defaults": {"max_tables": source_objects.SCOPE_DEFAULT, "max_limit": source_objects.SCOPE_MAX,
|
||
"index_limit": source_objects.INDEX_LIMIT, "rules_max": source_objects.RULES_MAX},
|
||
"preview": preview,
|
||
"index": {"ready": indexed, "schema": schema, "method": scope.get("index_method"),
|
||
"capped": bool(scope.get("index_capped")), "at": scope.get("index_at"),
|
||
"total": scope.get("index_total")},
|
||
"pinned_sample": pinned,
|
||
"on_demand": await source_objects.on_demand_list(session, row) if indexed else [],
|
||
}
|
||
|
||
|
||
def _parse_rules(raw: Any) -> source_objects.ScopeRules:
|
||
if problem := source_objects.rules_problem(raw):
|
||
raise CodedHTTPException(422, problem, SCOPE_INVALID)
|
||
return source_objects.ScopeRules.from_dict(raw)
|
||
|
||
|
||
@router.get("/{source_id}/introspect-scope")
|
||
async def get_introspect_scope(source_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""探查范围:规则、命中数(对着存着的对象索引数,不连库)、pinned、按需加入的留痕。"""
|
||
row = await _get_or_404(session, source_id)
|
||
_introspect_refusal(row)
|
||
return await _scope_out(session, row)
|
||
|
||
|
||
@router.post("/{source_id}/introspect-scope/preview")
|
||
async def preview_introspect_scope(source_id: str, payload: ScopeIn,
|
||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""按交来的规则数命中(不存、不连库):面板上边改边看命中多少张、超没超上限。"""
|
||
row = await _get_or_404(session, source_id)
|
||
_introspect_refusal(row)
|
||
return await _scope_out(session, row, _parse_rules(payload.scope))
|
||
|
||
|
||
@router.put("/{source_id}/introspect-scope")
|
||
async def put_introspect_scope(source_id: str, payload: ScopeIn, x_actor: str | None = Header(default=None),
|
||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""保存探查范围的规则(记下谁、什么时候改的)。超上限也存得下:下次探查时会被拒绝并说出命中数,面板上提前看得到。
|
||
规则只在下一次探查时生效:已有的表结构不动。"""
|
||
row = await _get_or_404(session, source_id)
|
||
_introspect_refusal(row)
|
||
rules = _parse_rules(payload.scope)
|
||
rules.updated_by = actor_of(x_actor)
|
||
rules.updated_at = datetime.now(timezone.utc).isoformat()
|
||
row.options = {**(row.options or {}), source_objects.SCOPE_OPTION: rules.to_dict()}
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
return await _scope_out(session, row)
|
||
|
||
|
||
@router.get("/{source_id}/schema")
|
||
async def get_schema(source_id: str, table: str | None = None,
|
||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""表结构。上传的表格返回当前快照里冻结的那份——和查询实际用的库是同一个版本。"""
|
||
found = await _get_or_404(session, source_id)
|
||
try:
|
||
row = await table_versions.resolve_source(session, found)
|
||
except table_versions.SnapshotMissing as e:
|
||
raise HTTPException(409, str(e)) from e
|
||
if table:
|
||
from app.data import schema_view
|
||
|
||
meta = introspect_mod.find_table(row, table)
|
||
# 和 db_schema 工具、助手查看同一个说法(schema_view.table_detail):有数据目录的表,字段清单后面附上目录;
|
||
# 遮罩列只给列名和类型
|
||
entries = await data_catalog.read_catalog(session, found.id)
|
||
from app.data import catalog_profile_jobs
|
||
|
||
key = data_catalog.resolve_table_name(getattr(row, "schema_cache", None), table)
|
||
# 剖析事实(行数、日期范围):四处同一段文字(next-round 遗留 e)
|
||
facts = await catalog_profile_jobs.last_facts(session, found.id, [key]) if key else {}
|
||
detail = schema_view.table_detail(row, table, catalog_notes={n: e.notes for n, e in entries.items()},
|
||
profile=facts)
|
||
if meta is None and (absent := await _absent_text(session, found, row, table)):
|
||
detail = absent
|
||
return {
|
||
"table": table,
|
||
# 给模型看的那段文本;界面画表格用下面的结构化字段
|
||
"detail": detail,
|
||
"found": meta is not None,
|
||
"qualified": meta.get("qualified", table) if meta else None,
|
||
"kind": ("view" if meta.get("is_view") else "table") if meta else None,
|
||
"comment": meta.get("comment") if meta else None,
|
||
"columns": introspect_mod.table_columns(meta) if meta else [],
|
||
}
|
||
return {
|
||
"tables": introspect_mod.table_names(row),
|
||
"summary": introspect_mod.summary(row),
|
||
"synced_at": row.schema_synced_at.isoformat() if row.schema_synced_at else None,
|
||
}
|
||
|
||
|
||
async def _absent_text(session: AsyncSession, found: DataSource, row: Any, table: str) -> str | None:
|
||
"""/schema?table= 点名的表不在表结构里:有对象索引的手工源说清是哪一种「不在」(表已不在、范围外、读不到结构)。"""
|
||
from app.data import schema_view
|
||
|
||
if _is_upload(found) or _is_records(found):
|
||
return None
|
||
reason = (await source_objects.absent_reasons(session, found, [table])).get(table)
|
||
if reason == source_objects.ABSENT_UNKNOWN or reason is None:
|
||
return None
|
||
error = ((row.schema_cache or {}).get("skipped") or {}).get(table)
|
||
return schema_view.absent_detail(row, table, reason, error=error)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 上传表格
|
||
# --------------------------------------------------------------------------
|
||
|
||
#: 「数字列混入非数字」的两种处理:reject 拒收并请用户选择;null 非数字的值存空值、列按数字存
|
||
_MIXED_CHOICES = ("reject", "null")
|
||
|
||
|
||
def _decision(e: Exception) -> dict[str, Any] | None:
|
||
"""解析器要用户先做决定(tabular.NeedsDecision:交叉表、数字列混入非数字)时,取出决定的内容。
|
||
|
||
NeedsDecision 是 UnsupportedTable 的子类,多带 kind 和 details 两个属性;普通的
|
||
UnsupportedTable 没有。按属性认,而不是按类名:接口层只关心「要不要请用户选」。
|
||
"""
|
||
kind, details = getattr(e, "kind", None), getattr(e, "details", None)
|
||
if not kind or not isinstance(details, dict):
|
||
return None
|
||
return {"kind": kind, "details": jsonable_encoder(details)}
|
||
|
||
|
||
def _table_out(t: dict[str, Any]) -> dict[str, Any]:
|
||
"""回执里的一张表。原表头和列名并排回显:表头行取错了(比如文件前两行是标题),当场看得出来。"""
|
||
return {
|
||
"name": t.get("name"), "sheet": t.get("sheet"), "rows": t.get("rows", 0),
|
||
"columns": [
|
||
{"name": c.get("name"), "type": c.get("type"), "header": c.get("header")}
|
||
for c in t.get("columns") or [] if isinstance(c, dict)
|
||
],
|
||
"region": t.get("region"),
|
||
"unshaped": bool(t.get("unshaped")),
|
||
"blank_rows_skipped": t.get("blank_rows_skipped", 0),
|
||
# 原样转交解析器给的值,不替它改类型
|
||
"columns_trimmed": t.get("columns_trimmed", []),
|
||
}
|
||
|
||
|
||
async def read_upload(file: UploadFile) -> bytes:
|
||
"""读上传的文件,边读边数,超了立刻停(413 file_too_large)。简单上传和按配方导入共用。
|
||
|
||
和知识库上传同一套:那个检查拦的是「入库」,不是「占内存」,读完再判等于先把内存吃掉。
|
||
"""
|
||
limit = settings.max_upload_mb * 1024 * 1024
|
||
pieces: list[bytes] = []
|
||
total = 0
|
||
while piece := await file.read(1024 * 1024):
|
||
total += len(piece)
|
||
if total > limit:
|
||
raise CodedHTTPException(413, f"文件超过 {settings.max_upload_mb} MB", "file_too_large")
|
||
pieces.append(piece)
|
||
return b"".join(pieces)
|
||
|
||
|
||
@router.post("/upload", response_model=dict, status_code=201)
|
||
async def upload_table(
|
||
file: UploadFile = File(...),
|
||
name: str = Form(...),
|
||
description: str = Form(""),
|
||
header_row: int = Form(1),
|
||
mixed: str = Form("reject"),
|
||
raw_mode: bool = Form(False),
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> Any:
|
||
"""把 Excel / CSV 变成一个可以用 SQL 查的数据源。
|
||
|
||
落成一个 SQLite 库,数据源 kind='sqlite' 指过去。下游一行都不用改——SQL 守卫、
|
||
结构探查、db_query__<name> 工具、查询快照进工件库、助手的数据源清单,全都白拿。
|
||
|
||
这条路存在的理由不是"多支持一种格式":表格传进知识库只能被切块检索,
|
||
数字就成了模型从片段里读出来的,而这个项目的地基是"所有算术下沉到
|
||
SQL 或口径卡"。表格必须变成表。
|
||
|
||
**同名就地更新**,不新建。数据源名字会成为工具名(db_query__sales),
|
||
而工具名写进了保存过的工作流——重传时另起一个 sales_2 等于悄悄让那些图失效。
|
||
每次上传发布成一个新版本(data/table_versions.py):写新文件、最后切指针,
|
||
失败时旧版本完整可查;原件按哈希存档,不提供下载。
|
||
|
||
解析器需要用户先做决定时(交叉表 / 多块结构、数字列混入非数字)回 422,body 里的
|
||
decision 说明要选什么;mixed、raw_mode 是用户选完再传回来的答案。
|
||
"""
|
||
import re as _re
|
||
|
||
from app.data.tabular import UnsupportedTable
|
||
|
||
if not _re.match(_NAME_PATTERN, name or ""):
|
||
raise HTTPException(
|
||
400, "名称必须以小写字母开头,只能包含字母、数字和下划线(名称会成为工具名的一部分)"
|
||
)
|
||
if mixed not in _MIXED_CHOICES:
|
||
raise HTTPException(400, "数字列混入非数字时,只能选择拒收或将非数字的值存为空值")
|
||
|
||
raw = await read_upload(file)
|
||
|
||
existing = (await session.execute(
|
||
select(DataSource).where(DataSource.name == name)
|
||
)).scalar_one_or_none()
|
||
# 按配方导入的源:重传要按已确认的配方重放(上传新一期),不能退回简单解析把配方和核对绕过去
|
||
if existing is not None and existing.current_recipe_id:
|
||
raise CodedHTTPException(409, f"「{name}」按配方导入,请使用「上传新一期」", "recipe_source")
|
||
# 同名的只有上传表格可以更新。迁移前的老上传(路径还在上传目录里)也算
|
||
if existing is not None and not _is_upload(existing) and not (
|
||
existing.kind == "sqlite" and table_versions.under_uploads(existing.database)
|
||
):
|
||
raise HTTPException(
|
||
409, f"已存在名为「{name}」的数据源(非上传表格),请换一个名称"
|
||
)
|
||
|
||
row = existing
|
||
if row is None:
|
||
# 先不进会话:发布失败时这个源不该被建出来,由 publish_upload 在提交前加入
|
||
row = DataSource(id=new_id(), name=name, kind="sqlite", readonly=True,
|
||
description=description, origin="upload")
|
||
elif description:
|
||
row.description = description
|
||
|
||
try:
|
||
result = await table_versions.publish_upload(
|
||
session, row, raw, file.filename or name,
|
||
header_row=header_row, mixed=mixed, raw_mode=raw_mode,
|
||
)
|
||
except UnsupportedTable as e:
|
||
decision = _decision(e)
|
||
if decision is not None:
|
||
return JSONResponse(status_code=422, content={"detail": str(e), "decision": decision})
|
||
raise HTTPException(400, str(e)) from e
|
||
except table_versions.PublishError as e:
|
||
logger.error("上传表格「%s」发布失败:%s", name, e)
|
||
if e.code == "store_unavailable":
|
||
raise CodedHTTPException(503, str(e), "store_unavailable") from e
|
||
if e.code == "build_conflict":
|
||
# 服务端已有的同版本文件被改过、这次上传也恢复不了:重试也会失败,要管理员处理(第 8 节遗留项 1)
|
||
raise CodedHTTPException(409, str(e), "build_conflict") from e
|
||
raise HTTPException(500, str(e)) from e
|
||
|
||
await session.refresh(row)
|
||
report = result.report
|
||
return {
|
||
"source": (await _out(session, row)).model_dump(),
|
||
"replaced": existing is not None,
|
||
"import_id": result.import_id,
|
||
"snapshot_id": result.snapshot_id,
|
||
"build_reused": result.build_reused,
|
||
# 服务端的同版本数据文件曾被改动,已用本次上传的文件恢复(原文件挪进了隔离区)
|
||
"build_restored": result.build_restored,
|
||
"skipped_sheets": report.get("skipped_sheets") or [],
|
||
"conversions": report.get("conversions") or [],
|
||
"warnings": report.get("warnings") or [],
|
||
"tables": [_table_out(t) for t in report.get("tables") or [] if isinstance(t, dict)],
|
||
}
|
||
|
||
|
||
@router.get("/{source_id}/imports")
|
||
async def list_imports(source_id: str, session: AsyncSession = Depends(get_session)) -> list[dict[str, Any]]:
|
||
"""上传表格的导入记录,新的在前。手工登记的源没有导入记录,返回空列表。
|
||
|
||
期 3(P3-SPEC 7.6)补了接受明细、作废记录、导入清单、配方第几版、各表行数、作废接受的预案(revoke_plan),
|
||
以及同一份原件在别处的引用(raw_shared_with、raw_open_stagings):清除对话框在提交前就要列出来。"""
|
||
from app.data import source_versions
|
||
|
||
row = await _get_or_404(session, source_id)
|
||
rows = list((await session.execute(
|
||
select(TableImport).where(TableImport.source_id == source_id).order_by(TableImport.seq.desc())
|
||
)).scalars())
|
||
return await source_versions.import_records(session, row, rows)
|
||
|
||
|
||
#: 清除原件的理由、署名的字数上限
|
||
_PURGE_REASON_MAX = 500
|
||
_SIGNED_MAX = 100
|
||
|
||
|
||
@router.post("/{source_id}/imports/{import_id}/purge-raw")
|
||
async def purge_raw(
|
||
source_id: str, import_id: str, request: Request, x_actor: str | None = Header(default=None),
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> dict[str, Any]:
|
||
"""清除一次导入的原件。导入记录、哈希和库都保留,原件文件删掉,记下时间、署名和理由。
|
||
|
||
按内容清除:同一份内容可能被别的导入共用(同一个文件传过两次、传给过两个源),清除的
|
||
目的是让这份内容从服务器上消失,所以文件照删,那些导入一并标成已清除,回执的
|
||
also_purged 逐条列出(期 3 的界面要把它展示出来,不能静默带过)。
|
||
|
||
期 3:请求体手工读成 dict(评审二-m8:原来用 pydantic,空理由由 FastAPI 自动回 422,响应体不是 {detail, code}),
|
||
错误一律带机读码。confirm 键收但不强求(7.7:期 1、期 2 的调用方不带它);带了而不是 true 时拒绝。
|
||
"""
|
||
from app.data import source_versions
|
||
|
||
body = await _json_body(request)
|
||
row = await _get_or_404(session, source_id)
|
||
imp = await session.get(TableImport, import_id)
|
||
if imp is None or imp.source_id != source_id:
|
||
raise CodedHTTPException(404, "导入记录不存在", "import_not_found")
|
||
if "confirm" in body and body.get("confirm") is not True:
|
||
raise CodedHTTPException(422, "请在确认框中确认后再清除原件", "confirm_required")
|
||
raw_reason = body.get("reason")
|
||
reason = raw_reason.strip() if isinstance(raw_reason, str) else ""
|
||
if not reason:
|
||
raise CodedHTTPException(422, "请填写清除原件的理由", "reason_required")
|
||
if len(reason) > _PURGE_REASON_MAX:
|
||
raise CodedHTTPException(422, f"理由不能超过 {_PURGE_REASON_MAX} 字", "reason_required")
|
||
# 开了登录是登录的人;关登录时请求体的 signed_by 优先,其次 X-Actor(signer_of)
|
||
signed_by = signer_of(body.get("signed_by"), x_actor, _SIGNED_MAX)
|
||
if imp.raw_state == "absent":
|
||
raise CodedHTTPException(409, "这次导入没有保存原件", "raw_absent")
|
||
if imp.raw_state == "purged":
|
||
raise CodedHTTPException(409, "这次导入的原件已经清除", "raw_already_purged")
|
||
try:
|
||
purged = await table_versions.purge_import_raw(session, imp, reason=reason, signed_by=signed_by,
|
||
signer_verified=signer_verified())
|
||
except table_versions.StoreUnavailable as e:
|
||
raise CodedHTTPException(503, str(e), "store_unavailable") from e
|
||
deleted, others = purged.deleted, purged.others
|
||
names = dict((await session.execute(
|
||
select(DataSource.id, DataSource.name).where(DataSource.id.in_({o.source_id for o in others}))
|
||
)).all()) if others else {}
|
||
[record] = await source_versions.import_records(session, row, [imp])
|
||
return {
|
||
"import": record,
|
||
"file_deleted": deleted,
|
||
"also_purged": [
|
||
{"id": o.id, "source_id": o.source_id, "source_name": names.get(o.source_id),
|
||
"seq": o.seq, "file_name": o.file_name}
|
||
for o in others
|
||
],
|
||
# 引用同一份原件、随之一并放弃的未完成导入(暂存区,已清空)
|
||
"discarded_stagings": [st.id for st in purged.discarded_stagings],
|
||
}
|
||
|
||
|
||
async def _json_body(request: Request) -> dict[str, Any]:
|
||
"""JSON 请求体手工读成 dict(空 body 当作 {});不是 JSON 对象回 422 body_invalid(不触发 FastAPI 自带的 422)。"""
|
||
raw = await request.body()
|
||
if not raw.strip():
|
||
return {}
|
||
try:
|
||
data = await request.json()
|
||
except ValueError:
|
||
data = None
|
||
if not isinstance(data, dict):
|
||
raise CodedHTTPException(422, "请求体必须是一个 JSON 对象", "body_invalid")
|
||
return data
|