From 5b3839f787a18eb7facdd3d79d67f5da1b97977c Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 4 Oct 2026 03:03:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=20F2b?= =?UTF-8?q?=EF=BC=9A=E5=89=96=E6=9E=90=E7=9A=84=20409=E3=80=81=E5=89=96?= =?UTF-8?q?=E6=9E=90=E8=AE=BE=E7=BD=AE=E7=9A=84=20422=20=E5=B8=A6=E6=9C=BA?= =?UTF-8?q?=E8=AF=BB=E7=A0=81=EF=BC=8C=E7=95=8C=E9=9D=A2=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=BB=8E=E5=8E=9F=E8=AF=9D=E9=87=8C=E8=AE=A4=E6=98=AF=E5=93=AA?= =?UTF-8?q?=E4=B8=80=E7=A7=8D=E2=80=94=E2=80=94=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E7=94=A8=20api/coded.CodedHTTPException=EF=BC=9A=E5=89=96?= =?UTF-8?q?=E6=9E=90=E5=BC=80=E5=A7=8B=E4=B8=8D=E4=BA=86=E7=9A=84=E4=BA=94?= =?UTF-8?q?=E7=A7=8D=E6=83=85=E5=86=B5=E5=B8=A6=20code=EF=BC=88profile=5Fd?= =?UTF-8?q?isabled=E3=80=81datasource=5Finactive=E3=80=81schema=5Fmissing?= =?UTF-8?q?=E3=80=81profile=5Fbusy=E3=80=81snapshot=5Ftampered=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E5=AD=98=E6=95=B0=E6=8D=AE=E6=BA=90=E6=97=B6?= =?UTF-8?q?=E5=89=96=E6=9E=90=E8=AE=BE=E7=BD=AE=E4=B8=8D=E5=90=88=E8=A7=84?= =?UTF-8?q?=E5=B8=A6=20code=3Dprofile=5Fsettings=5Finvalid=20=E5=92=8C?= =?UTF-8?q?=E5=87=BA=E9=94=99=E7=9A=84=E9=82=A3=E4=B8=80=E9=A1=B9=20field?= =?UTF-8?q?=EF=BC=88max=5Fqueries=E2=80=A6=E2=80=A6=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E7=BB=99=E4=BA=BA=E7=9C=8B=E7=9A=84=E9=82=A3=E5=8F=A5=E4=B8=8D?= =?UTF-8?q?=E5=8F=98=EF=BC=9BCodedHTTPException=20=E5=A4=9A=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=8F=AF=E9=80=89=E7=9A=84=20field=EF=BC=9B=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20profileBlockOf=E3=80=81profileFieldOfRejection=20?= =?UTF-8?q?=E5=8F=AA=E8=AE=A4=20code=20=E5=92=8C=20field=EF=BC=8C=E6=B2=A1?= =?UTF-8?q?=E5=B8=A6=E6=9C=BA=E8=AF=BB=E7=A0=81=E7=9A=84=E6=97=A7=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E6=8C=89=E9=80=9A=E7=94=A8=E6=8F=90=E7=A4=BA=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=9B=E8=A1=A5=E5=90=8E=E7=AB=AF=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=92=8C=E6=A3=80=E6=9F=A5=E8=84=9A=E6=9C=AC=E7=9A=84=E6=96=AD?= =?UTF-8?q?=E8=A8=80=EF=BC=88=E4=BC=AA=E9=80=A0=E7=9A=84=20409=E3=80=81422?= =?UTF-8?q?=20=E9=83=BD=E5=B8=A6=20code=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/catalog.py | 15 ++++--- backend/app/api/coded.py | 23 +++++++--- backend/app/api/datasources.py | 11 +++-- backend/app/data/catalog_profile.py | 27 +++++++++--- backend/tests/test_catalog_profile.py | 39 ++++++++++++++++ frontend/src/api/client.ts | 13 +++++- frontend/src/pages/DataSourcesTab.tsx | 12 ++--- frontend/src/pages/catalog/CatalogPage.tsx | 2 +- .../src/pages/catalog/ProfileSettings.tsx | 2 +- frontend/src/pages/catalog/profile.ts | 44 +++++++++++-------- scripts/check-catalog.mjs | 44 +++++++++++++++---- 11 files changed, 172 insertions(+), 60 deletions(-) diff --git a/backend/app/api/catalog.py b/backend/app/api/catalog.py index 46908acb..d52ff971 100644 --- a/backend/app/api/catalog.py +++ b/backend/app/api/catalog.py @@ -23,6 +23,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession +from app.api.coded import PROFILE_BUSY, SCHEMA_MISSING, SNAPSHOT_TAMPERED, CodedHTTPException from app.api.runs import actor_of from app.data import catalog, catalog_impact, catalog_profile, introspect, table_versions from app.data.engine import SnapshotTampered @@ -275,21 +276,25 @@ async def profile_catalog_tables(source_id: str, payload: CatalogProfileIn, 默认关闭:数据源设置里没开启数据剖析、数据源停用,409;同一个数据源已经在剖析,409。 """ row, source = await _resolved(session, source_id) + # 409 都带机读码(api/coded.py),界面按它分五种情况给下一步,不从原话里认 try: catalog_profile.ensure_enabled(row) except catalog_profile.ProfileDisabled as e: - raise HTTPException(409, str(e)) from e + raise CodedHTTPException(409, str(e), e.code) from e cache = source.schema_cache or {} if not cache.get("tables"): - raise HTTPException(409, f"{introspect.why_empty(cache)},无法进行数据剖析。请先在数据源卡片上点「探查结构」") + raise CodedHTTPException(409, f"{introspect.why_empty(cache)},无法进行数据剖析。请先在数据源卡片上点「探查结构」", + SCHEMA_MISSING) actor = actor_of(x_actor) try: report = await catalog_profile.profile_catalog(session, row, source, tables=payload.tables or None, actor=actor) - except (catalog_profile.ProfileDisabled, catalog_profile.ProfileBusy) as e: - raise HTTPException(409, str(e)) from e + except catalog_profile.ProfileDisabled as e: + raise CodedHTTPException(409, str(e), e.code) from e + except catalog_profile.ProfileBusy as e: + raise CodedHTTPException(409, str(e), PROFILE_BUSY) from e except SnapshotTampered as e: - raise HTTPException(409, str(e)) from e + raise CodedHTTPException(409, str(e), SNAPSHOT_TAMPERED) from e rows = [_profile_table_out(t) for t in report.tables] note = None if not rows and report.empty_tables: diff --git a/backend/app/api/coded.py b/backend/app/api/coded.py index bf9a53aa..4cf6da90 100644 --- a/backend/app/api/coded.py +++ b/backend/app/api/coded.py @@ -11,17 +11,28 @@ from fastapi.responses import JSONResponse #: 限定的数据源一个都不在了(删了或停用了),见 copilot._sources DATASOURCE_SCOPE_EMPTY = "datasource_scope_empty" +#: 保存数据源时剖析设置不合规(422):field 是出错的那一项(max_queries……),说不清是哪一项时没有 field +PROFILE_SETTINGS_INVALID = "profile_settings_invalid" +#: 剖析开始不了(409)的五种情况,见 api/catalog.profile_catalog_tables +PROFILE_DISABLED = "profile_disabled" +DATASOURCE_INACTIVE = "datasource_inactive" +SCHEMA_MISSING = "schema_missing" +PROFILE_BUSY = "profile_busy" +SNAPSHOT_TAMPERED = "snapshot_tampered" + class CodedHTTPException(HTTPException): - def __init__(self, status_code: int, detail: str, code: str) -> None: + """field:出错的是哪一项(表单据此把报错落到那一格)。可选,没有就不出现在返回里。""" + + def __init__(self, status_code: int, detail: str, code: str, *, field: str | None = None) -> None: super().__init__(status_code, detail) self.code = code + self.field = field async def coded_handler(_: Request, exc: Exception) -> JSONResponse: assert isinstance(exc, CodedHTTPException) - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.detail, "code": exc.code}, - headers=exc.headers, - ) + content: dict[str, str] = {"detail": exc.detail, "code": exc.code} + if exc.field: + content["field"] = exc.field + return JSONResponse(status_code=exc.status_code, content=content, headers=exc.headers) diff --git a/backend/app/api/datasources.py b/backend/app/api/datasources.py index 64af636e..c4be16a8 100644 --- a/backend/app/api/datasources.py +++ b/backend/app/api/datasources.py @@ -17,13 +17,13 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api import health -from app.api.coded import CodedHTTPException +from app.api.coded import PROFILE_SETTINGS_INVALID, CodedHTTPException from app.api.runs import actor_of 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_problem +from app.data.catalog_profile import profile_settings_rejection from app.data import introspect as introspect_mod from app.data import table_versions from app.data.engine import ( @@ -337,10 +337,13 @@ _MASK_FIELD = { def _refuse_bad_options(options: dict[str, Any] | None) -> None: - problem = (query_timeout_problem(options) or mask_columns_problem(options) - or profile_settings_problem(options)) + 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") diff --git a/backend/app/data/catalog_profile.py b/backend/app/data/catalog_profile.py index cbedcfc5..cc788d28 100644 --- a/backend/app/data/catalog_profile.py +++ b/backend/app/data/catalog_profile.py @@ -115,19 +115,26 @@ def profile_settings(options: dict[str, Any] | None) -> ProfileSettings: def profile_settings_problem(options: dict[str, Any] | None) -> str | None: """保存数据源时查一下剖析设置;没填、或者填对了返回 None。写的时候严格:不认识的项、超出范围的值都拒。""" + found = profile_settings_rejection(options) + return found[0] if found else None + + +def profile_settings_rejection(options: dict[str, Any] | None) -> tuple[str, str | None] | None: + """同 profile_settings_problem,另给出错的是哪一项:(给人看的一句, 字段名)。字段名是 enabled 或 _NUMBER_FIELDS + 的键(max_queries……),格式不对、有认不出的项时为 None。接口带着它回 422,表单不必从原话里认是哪一格。""" raw = (options or {}).get(PROFILE_OPTION) if raw is None: return None if not isinstance(raw, dict): - return "数据剖析设置的格式不正确,请在数据源设置中重新填写" + return "数据剖析设置的格式不正确,请在数据源设置中重新填写", None if unknown := sorted(set(raw) - _KNOWN_KEYS): - return f"数据剖析设置中有无法识别的项「{'、'.join(map(str, unknown))}」,请删除后重试" + return f"数据剖析设置中有无法识别的项「{'、'.join(map(str, unknown))}」,请删除后重试", None if "enabled" in raw and not isinstance(raw["enabled"], bool): - return f"数据剖析的「{_ENABLED_LABEL}」只能是开启或关闭" + return f"数据剖析的「{_ENABLED_LABEL}」只能是开启或关闭", "enabled" for key, (label, low, high, integer) in _NUMBER_FIELDS.items(): if key in raw and _number(raw[key], low, high, integer) is None: kind = "整数" if integer else "数" - return f"数据剖析的「{label}」需要填写 {_fmt(low)} 到 {_fmt(high)} 之间的{kind};当前为「{raw[key]}」" + return f"数据剖析的「{label}」需要填写 {_fmt(low)} 到 {_fmt(high)} 之间的{kind};当前为「{raw[key]}」", key return None @@ -458,7 +465,12 @@ _STOP_DETAIL = { class ProfileDisabled(Exception): - """这个数据源没有开启数据剖析。message 给人看。""" + """这个数据源没有开启数据剖析,或者数据源停用了。message 给人看;code 是接口回 409 时带的机读码 + (api/coded.py:profile_disabled / datasource_inactive)。""" + + def __init__(self, message: str, code: str = "profile_disabled") -> None: + super().__init__(message) + self.code = code class ProfileBusy(Exception): @@ -1228,7 +1240,7 @@ def ensure_enabled(row: Any, settings: ProfileSettings | None = None) -> Profile settings = settings or profile_settings(getattr(row, "options", None)) name = getattr(row, "name", "") or "" if getattr(row, "enabled", True) is False: - raise ProfileDisabled(f"数据源「{name}」已停用,无法进行数据剖析。请先在数据源设置中启用") + raise ProfileDisabled(f"数据源「{name}」已停用,无法进行数据剖析。请先在数据源设置中启用", code="datasource_inactive") if not settings.enabled: raise ProfileDisabled(f"数据源「{name}」未开启数据剖析。剖析会对业务库发查询,请先在数据源设置中开启数据剖析") return settings @@ -1269,4 +1281,5 @@ async def _profile(session: AsyncSession, row: Any, source: Any, tables: list[st __all__ = ["CODES_MAX", "IN_CHUNK", "MAX_CODE_LEN", "MAX_LITERAL_LEN", "NOTE_PREFIX", "PROFILE_DEFAULT_TABLES", "PROFILE_OPTION", "PROFILE_PROBE_TABLES", "ProfileBusy", "ProfileDisabled", "ProfileReport", "ProfileSettings", "ProfileSkip", "SqlDialect", "TableProfile", "TableSize", "ensure_enabled", - "estimate_size", "profile_catalog", "profile_settings", "profile_settings_problem", "sql_literal"] + "estimate_size", "profile_catalog", "profile_settings", "profile_settings_problem", "profile_settings_rejection", + "sql_literal"] diff --git a/backend/tests/test_catalog_profile.py b/backend/tests/test_catalog_profile.py index 6f413fe7..26a877f9 100644 --- a/backend/tests/test_catalog_profile.py +++ b/backend/tests/test_catalog_profile.py @@ -151,6 +151,7 @@ async def test_profile_is_off_by_default_and_says_where_to_turn_it_on(client, ma assert r.status_code == 409 detail = r.json()["detail"] assert "数据源设置" in detail and "开启数据剖析" in detail + assert r.json()["code"] == "profile_disabled" # 界面按机读码认是哪一种,不从原话里认 assert watch.executed == [] # 一条查询都没发 @@ -158,6 +159,12 @@ async def test_settings_are_saved_and_validated_through_datasource_update(client sid = await make_source(scenic_db) r = await client.patch(f"/api/datasources/{sid}", json={"options": _on(max_queries=0)}) assert r.status_code == 422 and "查询次数上限" in r.json()["detail"] + # 机读码和出错的那一项:表单按 field 把报错落到那一格 + assert (r.json()["code"], r.json()["field"]) == ("profile_settings_invalid", "max_queries") + r = await client.patch(f"/api/datasources/{sid}", json={"options": {PROFILE_OPTION: {"enabled": "是"}}}) + assert r.status_code == 422 and (r.json()["code"], r.json()["field"]) == ("profile_settings_invalid", "enabled") + r = await client.patch(f"/api/datasources/{sid}", json={"options": _on(rows=5)}) + assert r.status_code == 422 and r.json()["code"] == "profile_settings_invalid" and "field" not in r.json() r = await client.patch(f"/api/datasources/{sid}", json={"options": _on(max_queries=40, sample_size=500)}) assert r.status_code == 200, r.text assert r.json()["options"][PROFILE_OPTION] == {"enabled": True, "max_queries": 40, "sample_size": 500} @@ -414,6 +421,7 @@ async def test_concurrent_profile_on_same_source_returns_409(client, make_source await asyncio.wait_for(started.wait(), 10) second = await client.post(_url(sid), json={"tables": ["visits"]}, headers=ACTOR) assert second.status_code == 409 and "正在进行数据剖析" in second.json()["detail"] + assert second.json()["code"] == "profile_busy" release.set() done = await asyncio.wait_for(first, 30) assert done.status_code == 200, done.text @@ -422,6 +430,37 @@ async def test_concurrent_profile_on_same_source_returns_409(client, make_source assert again.status_code == 200, again.text +async def test_blocked_profile_says_which_case_by_code(client, make_source, scenic_db, monkeypatch): + """剖析开始不了的 409 都带机读码:数据源停用、没有表结构、快照被改动(未开启、正在剖析见上面两条)。""" + from app.data.engine import SnapshotTampered + + sid = await make_source(scenic_db, options=_on(), draft=["visits"]) + async with SessionLocal() as session: + row = await session.get(DataSource, sid) + row.enabled = False + await session.commit() + r = await client.post(_url(sid), json={"tables": ["visits"]}, headers=ACTOR) + assert r.status_code == 409 and r.json()["code"] == "datasource_inactive" and "已停用" in r.json()["detail"] + + async with SessionLocal() as session: + row = await session.get(DataSource, sid) + row.enabled = True + row.schema_cache = {} + await session.commit() + r = await client.post(_url(sid), json={"tables": ["visits"]}, headers=ACTOR) + assert r.status_code == 409 and r.json()["code"] == "schema_missing" and "探查结构" in r.json()["detail"] + + r = await client.post(f"/api/datasources/{sid}/introspect") + assert r.status_code == 200, r.text + + async def tampered(*_args, **_kwargs): + raise SnapshotTampered("数据文件与登记的版本不一致,可能被修改过,已拒绝查询") + + monkeypatch.setattr(catalog_profile, "profile_catalog", tampered) + r = await client.post(_url(sid), json={"tables": ["visits"]}, headers=ACTOR) + assert r.status_code == 409 and r.json()["code"] == "snapshot_tampered" + + async def test_default_tables_are_used_tables_with_relation_candidates(client, make_source, scenic_db): sid = await make_source(scenic_db, options=_on(), draft=[]) r = await client.post(_url(sid), json={}, headers=ACTOR) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 73fe62c3..5a3b4e81 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -37,6 +37,8 @@ export class ApiError extends Error { * detail 是给人看的话,随时可能改写;要按错误种类分支的认这个 */ code?: string + /** 带机读码的错误里出错的是哪一项(app/api/coded.py 的 field,比如剖析设置的 max_queries);没有就是 undefined */ + field?: string /** * 后端要用户先拍板时随错误一起给的 {kind, details}(比如上传表格的 422:数字列混入非数字、 * 交叉表)。原样保留,按接口各自校验后再用(上传表格见 uploadDecision) @@ -46,7 +48,7 @@ export class ApiError extends Error { constructor( public status: number, message: string, - opts?: { kind?: ApiErrorKind; detail?: unknown; raw?: string; timeoutMs?: number; code?: string; decision?: unknown }, + opts?: { kind?: ApiErrorKind; detail?: unknown; raw?: string; timeoutMs?: number; code?: string; field?: string; decision?: unknown }, ) { super(message) this.name = 'ApiError' @@ -55,6 +57,7 @@ export class ApiError extends Error { this.raw = opts?.raw this.timeoutMs = opts?.timeoutMs this.code = opts?.code + this.field = opts?.field this.decision = opts?.decision } } @@ -180,6 +183,12 @@ const codeOf = (body: unknown): string | undefined => { return typeof code === 'string' && code ? code : undefined } +/** 后端 {detail, code, field} 里出错的那一项;没有就是 undefined */ +const fieldOf = (body: unknown): string | undefined => { + const field = body && typeof body === 'object' ? (body as { field?: unknown }).field : undefined + return typeof field === 'string' && field ? field : undefined +} + /** 后端 {detail, decision} 里要用户拍板的内容:是个对象就原样带上,没有就是 undefined */ const decisionOf = (body: unknown): unknown => { const decision = body && typeof body === 'object' ? (body as { decision?: unknown }).decision : undefined @@ -239,7 +248,7 @@ function failure(status: number, statusText: string, text: string, path?: string const detail = body?.detail ?? body return new ApiError(status, describeDetail(detail, path), { detail, raw: typeof body?.raw === 'string' ? body.raw : `${status} ${text.slice(0, 2000)}`, - code: codeOf(body), decision: decisionOf(body), + code: codeOf(body), field: fieldOf(body), decision: decisionOf(body), }) } const message = status >= 500 diff --git a/frontend/src/pages/DataSourcesTab.tsx b/frontend/src/pages/DataSourcesTab.tsx index 3720cafc..c1211488 100644 --- a/frontend/src/pages/DataSourcesTab.tsx +++ b/frontend/src/pages/DataSourcesTab.tsx @@ -1039,13 +1039,13 @@ function toBody(form: SourceForm, isNew: boolean, wasOracle = true): any { } /** - * 422 的中文 detail 说的是哪一项:查询时限(后端 query_timeout_problem);数据剖析的某一项(profile_settings_problem, - * 记为 profile.<键>,它的「单条查询时限」也带「查询时限」四个字,要先认)。认不出的交回 toast + * 422 说的是哪一项:数据剖析的某一项按机读码和 field 认(profile_settings_rejection,记为 profile.<键>); + * 查询时限(后端 query_timeout_problem)还没有机读码,按 detail 认。认不出的交回 toast */ -function fieldOfRejection(message: string): string | null { - const profile = profileFieldOfRejection(message) +function fieldOfRejection(e: ApiError): string | null { + const profile = profileFieldOfRejection(e) if (profile) return `profile.${profile}` - if (/查询时限/.test(message)) return 'query_timeout_s' + if (/查询时限/.test(e.message)) return 'query_timeout_s' return null } @@ -1151,7 +1151,7 @@ function SourceEditor({ source, kinds, onClose, onSaved }: { onSaved(row, testFresh ? test.result : undefined, connectionOf(body) !== before) } catch (e) { const rejected = e instanceof ApiError && e.status === 422 ? e.message : '' - const key = rejected ? fieldOfRejection(rejected) : null + const key = e instanceof ApiError && rejected ? fieldOfRejection(e) : null if (key?.startsWith('profile.')) { setFieldError({ key, message: rejected }) focusProfileField('ds-profile', key.slice('profile.'.length) as ProfileErrorKey) diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx index ca5393d2..48442f3a 100644 --- a/frontend/src/pages/catalog/CatalogPage.tsx +++ b/frontend/src/pages/catalog/CatalogPage.tsx @@ -168,7 +168,7 @@ export function CatalogPage() { next = { phase: 'done', report, ms: Date.now() - startedAt } } catch (e) { next = e instanceof ApiError && e.status === 409 - ? { phase: 'blocked', kind: profileBlockOf(e.message), message: e.message, tables } + ? { phase: 'blocked', kind: profileBlockOf(e), message: e.message, tables } : { phase: 'failed', error: e, tables } } const here = alive.current && sourceRef.current === src diff --git a/frontend/src/pages/catalog/ProfileSettings.tsx b/frontend/src/pages/catalog/ProfileSettings.tsx index 26131e3f..072b92f2 100644 --- a/frontend/src/pages/catalog/ProfileSettings.tsx +++ b/frontend/src/pages/catalog/ProfileSettings.tsx @@ -128,7 +128,7 @@ export function ProfileSettingsDialog({ row, onClose, onSaved }: { onSaved(next) toast.ok(form.enabled === initial.enabled ? PT.saved(row.name) : form.enabled ? PT.savedOn(row.name) : PT.savedOff(row.name)) } catch (e) { - const key = e instanceof ApiError && e.status === 422 ? profileFieldOfRejection(e.message) : null + const key = e instanceof ApiError && e.status === 422 ? profileFieldOfRejection(e) : null if (key) { setServerError({ key, message: e instanceof ApiError ? e.message : '' }) focusProfileField(idPrefix, key) diff --git a/frontend/src/pages/catalog/profile.ts b/frontend/src/pages/catalog/profile.ts index e541e18d..236b7a99 100644 --- a/frontend/src/pages/catalog/profile.ts +++ b/frontend/src/pages/catalog/profile.ts @@ -1,5 +1,5 @@ import type { CatalogProfileNumberKey, CatalogProfileSettings } from '../../types' -import { PROFILE_FIELD_LABEL, PROFILE_TEXT } from '../../lib/terms' +import { PROFILE_TEXT } from '../../lib/terms' // =========================================================================== // 数据剖析的设置:数据源 options.catalog_profile 和表单之间的来回转换、校验、保存被拒时认出是哪一项。 @@ -120,18 +120,20 @@ export function profileSettingsOfForm(form: ProfileForm): CatalogProfileSettings export const sameProfileForm = (a: ProfileForm, b: ProfileForm): boolean => a.enabled === b.enabled && PROFILE_NUMBER_KEYS.every((k) => a.values[k] === b.values[k]) +/** 剖析设置不合规的 422 带的机读码(服务端 api/coded.PROFILE_SETTINGS_INVALID) */ +export const PROFILE_SETTINGS_INVALID = 'profile_settings_invalid' + /** - * 服务端保存时拒收的 422(一句中文)说的是剖析设置的哪一项: - * 「数据剖析的「查询次数上限」需要填写 1 到 500 之间的整数;当前为「0」」→ max_queries。 - * 说的是剖析设置、但认不出哪一项(格式不对、有认不出的项)返回 'section';说的不是剖析设置返回 null + * 服务端保存时拒收的 422 说的是剖析设置的哪一项:按机读码和 field 认(服务端 profile_settings_rejection),不从原话里认—— + * 文案一改,按原话认的就悄悄失效。说的是剖析设置、但没有 field(格式不对、有认不出的项)返回 'section'; + * 不是剖析设置的、没带机读码的(老服务端)返回 null,交给通用的报错提示 */ -export function profileFieldOfRejection(message: string): 'enabled' | CatalogProfileNumberKey | 'section' | null { - if (!message.includes('数据剖析')) return null - const named = message.match(/「([^」]+)」/)?.[1] ?? '' - for (const k of ['enabled', ...PROFILE_NUMBER_KEYS] as const) { - if (named && named.startsWith(PROFILE_FIELD_LABEL[k])) return k - } - return 'section' +export function profileFieldOfRejection(e: { code?: string; field?: string }): 'enabled' | CatalogProfileNumberKey | 'section' | null { + if (e.code !== PROFILE_SETTINGS_INVALID) return null + const field = e.field ?? '' + return field === 'enabled' || (PROFILE_NUMBER_KEYS as readonly string[]).includes(field) + ? field as 'enabled' | CatalogProfileNumberKey + : 'section' } // --------------------------------------------------------------------------- @@ -156,16 +158,20 @@ export function secondsText(ms: number): string { return s < 60 ? `${s} 秒` : `${Math.floor(s / 60)} 分 ${String(s % 60).padStart(2, '0')} 秒` } -/** 剖析接口回 409 的几种情况(按服务端的原话认:catalog_profile.ensure_enabled、api/catalog.py、engine.SnapshotTampered) */ +/** 剖析接口回 409 的几种情况:按机读码认(服务端 api/coded.py,api/catalog.profile_catalog_tables 带上) */ export type ProfileBlock = 'disabled' | 'inactive' | 'noSchema' | 'busy' | 'tampered' | 'other' -export function profileBlockOf(message: string): ProfileBlock { - if (message.includes('未开启数据剖析')) return 'disabled' - if (message.includes('已停用')) return 'inactive' - if (message.includes('正在进行数据剖析')) return 'busy' - if (message.includes('探查结构')) return 'noSchema' - if (/已拒绝(?:这次)?查询/.test(message)) return 'tampered' - return 'other' +const PROFILE_BLOCK_CODES: Record = { + profile_disabled: 'disabled', + datasource_inactive: 'inactive', + schema_missing: 'noSchema', + profile_busy: 'busy', + snapshot_tampered: 'tampered', +} + +/** 没带机读码(老服务端)或认不出的按「无法开始剖析」说,给重试;不从原话里猜 */ +export function profileBlockOf(e: { code?: string }): ProfileBlock { + return (e.code && PROFILE_BLOCK_CODES[e.code]) || 'other' } /** 设置里有没有剖析设置这一项(options 的其余键原样带上) */ diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs index 1b68bc84..dc647529 100644 --- a/scripts/check-catalog.mjs +++ b/scripts/check-catalog.mjs @@ -1465,7 +1465,8 @@ await section('数据目录 · 数据剖析设置', async () => { await page.locator('#ds-profile-query_timeout_s').fill('20') // 服务端按字段拒收(422):报错落在那一项下面,光标放进去,不只弹提示 const REJECT = '数据剖析的「单条查询时限(秒)」需要填写 1 到 60 之间的数;当前为「20」' - replies.patch.push(json({ detail: REJECT }, 422)) + // 服务端带机读码和出错的那一项(api/coded.py:code + field),界面按它落到那一格,不从原话里认 + replies.patch.push(json({ detail: REJECT, code: 'profile_settings_invalid', field: 'query_timeout_s' }, 422)) await page.locator('[data-source-save]').click() check('保存被拒(422):服务端原话写在「单条查询时限」下面', await until(async () => (await page.locator('#ds-profile-query_timeout_s-error').innerText().catch(() => '')) === REJECT), @@ -1735,6 +1736,22 @@ await section('数据目录 · 数据剖析开始不了(409)', async () => { await settings.waitFor() await settings.locator('[data-profile-enabled]').check() await page.locator(`#profile-${S1}-max_queries`).fill('40') + // 保存被拒:只认机读码和 field。没带机读码的(老服务端)不从原话里猜是哪一项,只弹通用提示,弹窗留着 + const QUERIES_REJECT = '数据剖析的「查询次数上限」需要填写 1 到 500 之间的整数;当前为「40」' + replies.patch.push(json({ detail: QUERIES_REJECT }, 422)) + await page.locator('[data-profile-save]').click() + check('422 没带机读码:不从原话里猜是哪一项,只弹通用提示,弹窗留着', + await until(async () => (await page.locator('[data-toast]').filter({ hasText: '查询次数上限' }).count()) > 0) + && await settings.locator('[aria-invalid="true"]').count() === 0 && await settings.count() === 1) + replies.patch.push(json({ detail: QUERIES_REJECT, code: 'profile_settings_invalid', field: 'max_queries' }, 422)) + await page.locator('[data-profile-save]').click() + check('422 带机读码和 field:报错落在「查询次数上限」那一格,光标放进去', + await until(async () => (await page.locator(`#profile-${S1}-max_queries`).getAttribute('aria-invalid')) === 'true') + && await until(async () => page.evaluate((id) => document.activeElement?.id === id, `profile-${S1}-max_queries`))) + replies.patch.push(json({ detail: '数据剖析设置中有无法识别的项「rows」,请删除后重试', code: 'profile_settings_invalid' }, 422)) + await page.locator('[data-profile-save]').click() + check('422 带机读码、没有 field:报错写在剖析设置这一节上', await until(async () => + (await settings.locator('[data-profile-error="section"]').innerText().catch(() => '')).includes('无法识别的项'))) await page.locator('[data-profile-save]').click() check('就地开启:保存后回到剖析,预算按刚存的设置', await until(async () => (await settings.count()) === 0) && await until(async () => (await profileBox(page).locator('[data-profile-budget]').innerText().catch(() => '')).includes('最多 40 条只读查询')) @@ -1742,15 +1759,24 @@ await section('数据目录 · 数据剖析开始不了(409)', async () => { check('……保存的是这个源的剖析设置', JSON.stringify(writes(sent, /^PATCH /).at(-1)?.body) === JSON.stringify({ options: { catalog_profile: { enabled: true, max_queries: 40 } } }), JSON.stringify(writes(sent, /^PATCH /).at(-1)?.body)) + // 服务端的 409 都带机读码(api/coded.py),界面按它分五种情况,不从原话里认 const cases = [ - ['disabled', '数据源「zzcatscenic」未开启数据剖析。剖析会对业务库发查询,请先在数据源设置中开启数据剖析', '未开启数据剖析', 'data-profile-enable'], - ['inactive', '数据源「zzcatscenic」已停用,无法进行数据剖析。请先在数据源设置中启用', '数据源已停用', 'data-profile-go-source'], - ['noSchema', '尚未探查结构,无法进行数据剖析。请先在数据源卡片上点「探查结构」', '还没有表结构', 'data-profile-go-source'], - ['busy', '数据源「zzcatscenic」正在进行数据剖析,请等待完成后再试', '这个数据源正在剖析', 'data-profile-retry'], - ['tampered', '数据文件与登记的版本不一致,可能被修改过,已拒绝查询', '数据文件核对未通过', 'data-profile-go-source'], + ['disabled', '数据源「zzcatscenic」未开启数据剖析。剖析会对业务库发查询,请先在数据源设置中开启数据剖析', '未开启数据剖析', 'data-profile-enable', 'profile_disabled'], + ['inactive', '数据源「zzcatscenic」已停用,无法进行数据剖析。请先在数据源设置中启用', '数据源已停用', 'data-profile-go-source', 'datasource_inactive'], + ['noSchema', '尚未探查结构,无法进行数据剖析。请先在数据源卡片上点「探查结构」', '还没有表结构', 'data-profile-go-source', 'schema_missing'], + ['busy', '数据源「zzcatscenic」正在进行数据剖析,请等待完成后再试', '这个数据源正在剖析', 'data-profile-retry', 'profile_busy'], + ['tampered', '数据文件与登记的版本不一致,可能被修改过,已拒绝查询', '数据文件核对未通过', 'data-profile-go-source', 'snapshot_tampered'], ] - for (const [kind, detail, title, action] of cases) { - replies.profile.push(json({ detail }, 409)) + // 没带机读码的 409(老服务端):原话里明明写着「正在进行数据剖析」,也不去猜,按「无法开始剖析」给重试 + replies.profile.push(json({ detail: cases[3][1] }, 409)) + await page.locator('[data-profile-start]').click() + check('409 没带机读码:不从原话里猜,按「无法开始剖析」说,给重试', await until(async () => + (await profileBox(page).locator('[data-profile-blocked="other"]').count()) === 1) + && await profileBox(page).locator('[data-profile-blocked="other"] [data-profile-retry]').count() === 1) + await page.locator('[data-profile-back]').click() + await until(async () => (await profilePhase(page)) === 'setup') + for (const [kind, detail, title, action, code] of cases) { + replies.profile.push(json({ detail, code }, 409)) await page.locator('[data-profile-start]').click() const blocked = profileBox(page).locator(`[data-profile-blocked="${kind}"]`) const ok = await until(async () => (await blocked.count()) === 1) @@ -1784,7 +1810,7 @@ await section('数据目录 · 数据剖析开始不了(409)', async () => { check('其余失败:写「剖析未能完成」和原因,能重试', await until(async () => (await profileBox(page).locator('[data-profile-failed]').count()) === 1) && (await profileBox(page).locator('[data-profile-failed]').innerText()).includes('剖析未能完成')) // 去数据源:停用、没有表结构、快照被改动时 - replies.profile.push(json({ detail: cases[1][1] }, 409)) + replies.profile.push(json({ detail: cases[1][1], code: cases[1][4] }, 409)) await profileBox(page).locator('[data-profile-failed]').getByRole('button', { name: /重试/ }).click() await profileBox(page).locator('[data-profile-go-source]').click() await page.waitForURL('**/data/databases', { timeout: 5000 }).catch(() => {})