mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
数据目录 F2b:剖析的 409、剖析设置的 422 带机读码,界面不再从原话里认是哪一种——服务端用 api/coded.CodedHTTPException:剖析开始不了的五种情况带 code(profile_disabled、datasource_inactive、schema_missing、profile_busy、snapshot_tampered),保存数据源时剖析设置不合规带 code=profile_settings_invalid 和出错的那一项 field(max_queries……),给人看的那句不变;CodedHTTPException 多一个可选的 field;前端 profileBlockOf、profileFieldOfRejection 只认 code 和 field,没带机读码的旧响应按通用提示处理;补后端测试和检查脚本的断言(伪造的 409、422 都带 code)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, ProfileBlock> = {
|
||||
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 的其余键原样带上) */
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
Reference in New Issue
Block a user