数据目录 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:
ThearchyHelios
2026-10-04 03:03:23 +08:00
parent bc615670ef
commit 5b3839f787
11 changed files with 172 additions and 60 deletions
+10 -5
View File
@@ -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:
+17 -6
View File
@@ -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)
+7 -4
View File
@@ -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")
+20 -7
View File
@@ -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"]
+39
View File
@@ -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)
+11 -2
View File
@@ -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
+6 -6
View File
@@ -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)
+1 -1
View File
@@ -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)
+25 -19
View File
@@ -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 的其余键原样带上) */
+35 -9
View File
@@ -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(() => {})