From 4c4fa8e4e997cd0ad84dbc21aef9292757452abb Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Thu, 15 Oct 2026 19:28:17 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=8C=E9=9C=80=E8=A6=81=E5=89=96=E6=9E=90?= =?UTF-8?q?=E3=80=8D=E5=A4=9A=E8=AE=A4=E4=B8=80=E7=A7=8D=EF=BC=9A=E4=B8=8A?= =?UTF-8?q?=E6=AC=A1=E5=89=96=E6=9E=90=E4=B9=8B=E5=90=8E=E5=A4=9A=E5=87=BA?= =?UTF-8?q?=E4=BA=86=E7=A0=81=E5=80=BC=E3=80=81=E6=97=A5=E6=9C=9F=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=EF=BC=88new=5Fchecks=EF=BC=89=EF=BC=9B=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E6=8E=92=E9=98=9F=E4=BB=BB=E5=8A=A1=E6=97=B6=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=AF=BB=E8=BF=87=E6=9C=9F=E7=9A=84=20job=EF=BC=88?= =?UTF-8?q?=E8=B5=B7=E8=8D=89=E3=80=81=E5=89=96=E6=9E=90=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 以前只认从没剖析过、上次没做完、又冒出了待核实关系三种:上次剖析时没有可剖析的项(指定了表、记为跳过),后来有人把一列 记成状态类,或者日期列变了,「需要剖析的表」永远漏掉它。现在按当前要做的检查对上次剖析的检查项(kind、target),多出了 码值、日期检查就要剖析,原因 new_checks;上次是空表而跳过的仍不算(不发查询不知道有没有数据),界面和 409 的说法写明。 due_reason 改收 plan_checks 列出的检查,没人用的 profile_checks 去掉。 - 停止一个排队中的任务时,排队那一步没停成(刚被后台认领)要回滚,回滚让读到的 job 属性全部过期,再读 job.id 就在异步会话里 同步加载(MissingGreenlet),接口 500。起草的 cancel 在 main 上就是这样写的,剖析照抄了;两处都先把 id、状态取出来。 新测试在旧代码上复现了这个 500。 --- backend/app/api/catalog.py | 5 +- backend/app/data/catalog_jobs.py | 15 +++-- backend/app/data/catalog_profile.py | 8 +-- backend/app/data/catalog_profile_jobs.py | 51 +++++++++------ backend/tests/test_catalog_profile_jobs.py | 75 ++++++++++++++++++++++ frontend/src/lib/terms.ts | 7 +- frontend/src/types.ts | 5 +- scripts/check-catalog-profile.mjs | 2 +- 8 files changed, 127 insertions(+), 41 deletions(-) diff --git a/backend/app/api/catalog.py b/backend/app/api/catalog.py index 865929f0..2f70d78e 100644 --- a/backend/app/api/catalog.py +++ b/backend/app/api/catalog.py @@ -175,8 +175,9 @@ def _summary_row(name: str, meta: dict[str, Any] | None, entry: catalog.CatalogE def _profiling(name: str, meta: dict[str, Any] | None, entry: catalog.CatalogEntry | None, *, tables: dict[str, Any], masked: set[str], last: dict[str, Any] | None) -> dict[str, Any]: notes = entry.notes if entry else {} - checks = catalog_profile.profile_checks(name, meta, notes, tables=tables, masked=masked) - reason = catalog_profile_jobs.due_reason(meta, notes, last, checks) + planned = catalog_profile.plan_checks(name, meta, notes, tables=tables, masked=masked) + checks = sum(1 for c in planned if c.skip is None) + reason = catalog_profile_jobs.due_reason(meta, notes, last, planned) return {"last_profile": catalog_profile_jobs.public_last(last), "profile_due": reason is not None, "profile_due_reason": reason, "profile_checks": checks} diff --git a/backend/app/data/catalog_jobs.py b/backend/app/data/catalog_jobs.py index 233338ad..19c009d9 100644 --- a/backend/app/data/catalog_jobs.py +++ b/backend/app/data/catalog_jobs.py @@ -491,17 +491,20 @@ async def retry_tables(session: AsyncSession, job: CatalogDraftJob) -> list[str] async def cancel(session: AsyncSession, job: CatalogDraftJob) -> None: """停止一个任务。排队的直接 cancelled(在队里等着的后台执行跟着停掉、出队);进行中的交给 manager 停(停掉模型调用、 没写完的表记 cancelled)。已经结束的抛 DraftFinished。""" - if job.status in FINISHED: + # 先取出来:排队的没停成(刚被后台认领)时 cancel_queued 会回滚,回滚让 job 的属性全部过期,再读 job.id 就要在 + # 异步会话里同步地重新加载(MissingGreenlet) + job_id, status = job.id, job.status + if status in FINISHED: raise DraftFinished("这次起草已经结束了,不需要停止") - if job.status == "queued": - if await runtime.cancel_queued(session, CatalogDraftJob, CatalogDraftTask, job.id): - await manager.stop(job.id, "user", grace=STOP_GRACE_S) + if status == "queued": + if await runtime.cancel_queued(session, CatalogDraftJob, CatalogDraftTask, job_id): + await manager.stop(job_id, "user", grace=STOP_GRACE_S) return # 刚被后台认领、开始了:按进行中的停 - if await manager.stop(job.id, "user", grace=STOP_GRACE_S): + if await manager.stop(job_id, "user", grace=STOP_GRACE_S): return # 库里说在进行,这个进程里却没有在跑它(记录对不上):直接记停止 - await _close(session, job.id, "cancelled", None, "cancelled", None) + await _close(session, job_id, "cancelled", None, "cancelled", None) async def _close(session: AsyncSession, job_id: str, job_status: str, job_error: str | None, task_status: str, diff --git a/backend/app/data/catalog_profile.py b/backend/app/data/catalog_profile.py index 0636cd70..8eee4488 100644 --- a/backend/app/data/catalog_profile.py +++ b/backend/app/data/catalog_profile.py @@ -1476,12 +1476,6 @@ def plan_checks(table: str, meta: dict[str, Any] | None, notes: dict[str, Any] | return checks -def profile_checks(table: str, meta: dict[str, Any] | None, notes: dict[str, Any] | None, *, tables: dict[str, Any], - masked: set[str]) -> int: - """这张表要做几项检查(按规则不查的不算):0 表示没有可剖析的项;视图、表结构里没有的表是 0。""" - return sum(1 for c in plan_checks(table, meta, notes, tables=tables, masked=masked) if c.skip is None) - - def has_pending_relations(notes: dict[str, Any] | None) -> bool: """这张表还有没有待核实的关系:要核对的关系里有还是推断(proposed)的。挑表时这样的表排在前面。""" return any(r.get("status") == "proposed" for r in _relation_candidates(notes)) @@ -1867,5 +1861,5 @@ __all__ = ["CODES_MAX", "EMPTY_ERROR", "FILTER_CHECKED", "FILTER_NOT_ENABLED", " "QueryFailed", "SourceRunner", "SqlDialect", "TableBudget", "TableHooks", "TableResult", "TableSize", "UNFINISHED_REASONS", "VIEW_ERROR", "check_valid_filter", "ensure_enabled", "estimate_size", "excluded_share", "has_pending_relations", "make_context", "masked_of", "migrate_settings", "pending_relation_paths", - "plan_checks", "profile_checks", "profile_settings", "profile_settings_problem", "profile_settings_rejection", + "plan_checks", "profile_settings", "profile_settings_problem", "profile_settings_rejection", "profile_table", "run_single_query", "sql_literal", "summarize", "without_legacy"] diff --git a/backend/app/data/catalog_profile_jobs.py b/backend/app/data/catalog_profile_jobs.py index d1f39f58..39bb8801 100644 --- a/backend/app/data/catalog_profile_jobs.py +++ b/backend/app/data/catalog_profile_jobs.py @@ -319,7 +319,8 @@ def _ranked(source_id: str, *columns: Any) -> Any: async def last_profiles(session: AsyncSession, source_id: str) -> dict[str, dict[str, Any]]: """每张表的「上次剖析」(见 _ranked 的 shown):{表名: CatalogLastProfile 加上内部用的 paths(那一项检查项的路径,判断 - 有没有新的待核实关系用)、queued(最近一项还排着或在做:这张表已经在进行中的任务里了)}。在做的就是进行中的状态。 + 有没有新的待核实关系用)、keys(那一项检查项的 (kind, target),判断有没有新的码值、日期检查用)、queued(最近一项还排着 + 或在做:这张表已经在进行中的任务里了)}。在做的就是进行中的状态。 每张表取哪一项在库里挑好。""" inner = _ranked(source_id, CatalogProfileTask.table_name, CatalogProfileTask.job_id, CatalogProfileTask.seq, CatalogProfileTask.status, CatalogProfileTask.started_at, @@ -339,17 +340,18 @@ async def last_profiles(session: AsyncSession, source_id: str) -> dict[str, dict "summary": {**_empty_summary(), **(row["summary"] or {})}, "skipped_count": row["skipped_count"] or 0, "unfinished": row["unfinished"] or 0, "error": row["error"], "row_estimate": row["row_estimate"], "paths": {c.get("path") for c in row["checks"] or [] if isinstance(c, dict) and c.get("path")}, + "keys": {(c.get("kind"), c.get("target")) for c in row["checks"] or [] if isinstance(c, dict)}, "queued": row["table_name"] in queued, } return out #: last_profiles 里只给内部用的键 -_INTERNAL = ("paths", "queued") +_INTERNAL = ("paths", "keys", "queued") def public_last(last: dict[str, Any] | None) -> dict[str, Any] | None: - """接口里的 CatalogLastProfile:去掉内部用的 paths、queued。""" + """接口里的 CatalogLastProfile:去掉内部用的 paths、keys、queued。""" return None if last is None else {k: v for k, v in last.items() if k not in _INTERNAL} @@ -365,14 +367,18 @@ async def last_detail(session: AsyncSession, source_id: str, table: str) -> dict def due_reason(meta: dict[str, Any] | None, notes: dict[str, Any] | None, last: dict[str, Any] | None, - checks: int) -> str | None: + planned: list[catalog_profile.PlannedCheck]) -> str | None: """这张表要不要剖析、为什么:never 从没剖析过;unfinished 上次剖析是 partial / failed / cancelled;new_relations - 目录里有待核实的关系不在上次剖析的检查项里。表结构里没有、视图、没有可剖析的项(checks 为 0)、已经在进行中的任务里 - (排着或在做)的不要。 + 目录里有待核实的关系不在上次剖析的检查项里;new_checks 上次剖析之后多出了要做的码值、日期检查(上次剖析时没有可剖析的 + 项、后来起草或人工把某一列记成了状态类,或者日期列变了)。表结构里没有、视图、没有可剖析的项、已经在进行中的任务里 + (排着或在做)的不要。planned 是按当前目录和表结构列出的检查(catalog_profile.plan_checks)。 「上次剖析」和表清单显示的是同一项(last_profiles,真正开始过的最近一项):上次真正剖析完整了,后来某个任务没轮到它 - 就停了,不算没做完——不然表清单一边显示「上次剖析:完成」,一边说「上次剖析没做完」。一次都没开始过的,按最近一项算。""" - if not isinstance(meta, dict) or meta.get("is_view") or checks <= 0: + 就停了,不算没做完——不然表清单一边显示「上次剖析:完成」,一边说「上次剖析没做完」。一次都没开始过的,按最近一项算。 + + 上次是空表而跳过的不算要剖析:表里有没有数据,不发查询就不知道;空表等有了数据,选「全部表」或单独剖析它。""" + runnable = [c for c in planned if c.skip is None] + if not isinstance(meta, dict) or meta.get("is_view") or not runnable: return None if last is None: return "never" @@ -382,6 +388,9 @@ def due_reason(meta: dict[str, Any] | None, notes: dict[str, Any] | None, last: return "unfinished" if catalog_profile.pending_relation_paths(notes) - set(last.get("paths") or ()): return "new_relations" + seen = last.get("keys") or set() + if any(c.kind != "relation" and (c.kind, c.target) not in seen for c in runnable): + return "new_checks" return None @@ -429,8 +438,8 @@ async def ensure_free(session: AsyncSession, row: Any, tables: list[str] | None #: 挑出 0 张表时的说法 -NOTHING_NEEDED = ("没有需要剖析的表:能剖析的表都剖析过了,上次剖析也都做完了,之后没有新的待核实关系。" - "要重新剖析,请选「全部表」或指定要剖析的表") +NOTHING_NEEDED = ("没有需要剖析的表:能剖析的表都剖析过了,上次剖析也都做完了,之后没有新的待核实关系,也没有新的码值、" + "日期列(上次是空表的不算)。要重新剖析,请选「全部表」或指定要剖析的表") NOTHING_AT_ALL = ("没有可以剖析的表:表结构里的表都是视图,或者没有可剖析的项(要核对的关联关系、像码值的列、日期列都没有," "或者都被遮罩了)。请先起草数据目录,或指定要剖析的表") @@ -487,10 +496,9 @@ async def _pick(session: AsyncSession, row: Any, source: Any, *, tables: list[st if not isinstance(meta, dict) or meta.get("is_view"): continue checks = plan(name) - runnable = sum(1 for c in checks if c.skip is None) - if runnable == 0: + if not any(c.skip is None for c in checks): continue - if scope == "needed" and due_reason(meta, notes(name), last.get(name), runnable) is None: + if scope == "needed" and due_reason(meta, notes(name), last.get(name), checks) is None: continue candidates.append((name, checks)) if not candidates: @@ -627,22 +635,25 @@ async def _close(session: AsyncSession, job_id: str, job_status: str, job_error: async def cancel(session: AsyncSession, job: CatalogProfileJob) -> None: """停止一个剖析任务。排队的直接 cancelled(在队里等着的后台执行跟着停掉、出队);进行中的交给 manager 停:正在等的那条 查询不再等(库那边按单条时限截止),那张表做完的项照常写进目录,后面的表 cancelled。已经结束的抛 ProfileFinished。""" - if job.status in FINISHED: + # 先取出来:排队的没停成(刚被后台认领)时要回滚,回滚让 job 的属性全部过期,再读 job.id 就要在异步会话里同步地 + # 重新加载(MissingGreenlet) + job_id, status = job.id, job.status + if status in FINISHED: raise ProfileFinished("这次剖析已经结束了,不需要停止") - if job.status == "queued": + if status == "queued": claimed = await session.execute( - update(CatalogProfileJob).where(CatalogProfileJob.id == job.id, CatalogProfileJob.status == "queued") + update(CatalogProfileJob).where(CatalogProfileJob.id == job_id, CatalogProfileJob.status == "queued") .values(status="cancelled", finished_at=utcnow()).execution_options(synchronize_session=False)) if claimed.rowcount == 1: - await _close_tasks(session, job.id, "cancelled", None, why=STOPPED_ERROR) + await _close_tasks(session, job_id, "cancelled", None, why=STOPPED_ERROR) await session.commit() - await manager.stop(job.id, "user", grace=STOP_GRACE_S) + await manager.stop(job_id, "user", grace=STOP_GRACE_S) return await session.rollback() # 刚被后台认领、开始了:按进行中的停 - if await manager.stop(job.id, "user", grace=STOP_GRACE_S): + if await manager.stop(job_id, "user", grace=STOP_GRACE_S): return # 库里说在进行,这个进程里却没有在跑它(记录对不上):直接记停止 - await _close(session, job.id, "cancelled", None, "cancelled", None) + await _close(session, job_id, "cancelled", None, "cancelled", None) async def startup() -> int: diff --git a/backend/tests/test_catalog_profile_jobs.py b/backend/tests/test_catalog_profile_jobs.py index e9dc2995..751540be 100644 --- a/backend/tests/test_catalog_profile_jobs.py +++ b/backend/tests/test_catalog_profile_jobs.py @@ -597,6 +597,41 @@ async def test_profile_due_never_unfinished_and_new_relations(client, make_sourc await catalog_profile_jobs.manager.wait(job["id"], timeout=30) +async def test_profile_due_new_checks_after_a_column_becomes_a_status(client, make_source, scenic_db): + """上次剖析时没有可剖析的项(指定了表,记为跳过),后来有人把一列记成状态类:多出了码值检查,又要剖析了,原因是 + new_checks;剖析过(这一次估过行数发现是空表)之后不再要。""" + sid = await make_source(scenic_db, options=_on(), draft=["suppliers"]) + await _profile(client, sid, ["suppliers"]) + rows = await _rows(client, sid) + assert rows["suppliers"]["last_profile"]["status"] == "skipped" + assert (rows["suppliers"]["profile_checks"], rows["suppliers"]["profile_due"]) == (0, False) + + async with SessionLocal() as session: + entry = await catalog.read_entry(session, sid, "suppliers") # 起草没推断出什么时还没有目录 + notes = dict(entry.notes) if entry else {} + columns = dict(notes.get("columns") or {}) + columns["name"] = {**(columns.get("name") or {}), + "measure": {"value": "status", "source": "human", "status": "confirmed"}} + notes["columns"] = columns + await catalog.write_entry(session, sid, "suppliers", notes, if_version=entry.version if entry else 0, + actor=None) + rows = await _rows(client, sid) + assert rows["suppliers"]["profile_checks"] == 1 + assert (rows["suppliers"]["profile_due"], rows["suppliers"]["profile_due_reason"]) == (True, "new_checks") + assert "keys" not in rows["suppliers"]["last_profile"] + job = (await _start(client, sid)).json()["job"] + assert "suppliers" in [t["table_name"] for t in job["tasks"]], "「需要剖析的表」里有它" + await client.post(f"{_url(sid)}/{job['id']}/cancel") + await catalog_profile_jobs.manager.wait(job["id"], timeout=30) + # 景区库的 suppliers 是空表:这次剖析估过行数就跳过了。空表不算要剖析(不发查询不知道有没有数据),等有了数据选 + # 「全部表」或单独剖析它 + await _profile(client, sid, ["suppliers"]) + rows = await _rows(client, sid) + last = rows["suppliers"]["last_profile"] + assert (last["status"], last["error"]) == ("skipped", "空表,没有可剖析的数据") + assert (rows["suppliers"]["profile_due"], rows["suppliers"]["profile_due_reason"]) == (False, None) + + async def test_nothing_to_profile_is_409(client, make_source, tmp_path): path = _db(tmp_path / "plain.db", "CREATE TABLE suppliers (id INTEGER PRIMARY KEY, name TEXT);", {"suppliers": [(1, "甲供应商")]}) @@ -763,3 +798,43 @@ async def test_retention_keeps_recent_jobs_and_each_tables_last_profile(client, left = set((await session.execute(select(CatalogProfileJob.id) .where(CatalogProfileJob.source_id == sid))).scalars()) assert ids[0] in left and job["id"] in left and ids[3] not in left + + +async def test_cancel_a_job_claimed_between_load_and_cancel(client, make_source, scenic_db, monkeypatch): + """停止时读到的是排队中,可在停之前后台刚认领了它:排队的那一步没停成、回滚,接着按进行中的停。回滚让读到的 job + 属性全部过期,以前再读 job.id 会在异步会话里同步加载(MissingGreenlet),接口 500。起草任务同一个写法,一起钉住。""" + sid = await make_source(scenic_db, options=_on(), draft=["visits"]) + stopped: list[str] = [] + + async def fake_stop(job_id, reason, *, grace=0): + stopped.append(job_id) + return True + + for module, model in ((catalog_profile_jobs, CatalogProfileJob), (catalog_jobs, None)): + monkeypatch.setattr(module.manager, "start", lambda job_id: None) # 不在后台跑:认领由下面手工做 + monkeypatch.setattr(module.manager, "stop", fake_stop) + job_id = (await _start(client, sid, ["visits"])).json()["job"]["id"] + async with SessionLocal() as session: + job = await session.get(CatalogProfileJob, job_id) + assert job.status == "queued" + async with SessionLocal() as other: # 后台刚认领 + await other.execute(catalog_profile_jobs.update(CatalogProfileJob).where(CatalogProfileJob.id == job_id) + .values(status="running")) + await other.commit() + await catalog_profile_jobs.cancel(session, job) + assert stopped == [job_id] + + from app.db.models import CatalogDraftJob + + r = await client.post(f"/api/datasources/{sid}/catalog/draft-jobs", json={"tables": ["visits"], "use_model": False}) + assert r.status_code == 202, r.text + draft_id = r.json()["job"]["id"] + async with SessionLocal() as session: + job = await session.get(CatalogDraftJob, draft_id) + assert job.status == "queued" + async with SessionLocal() as other: + await other.execute(catalog_profile_jobs.update(CatalogDraftJob).where(CatalogDraftJob.id == draft_id) + .values(status="running")) + await other.commit() + await catalog_jobs.cancel(session, job) + assert stopped == [job_id, draft_id] diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 340dd609..681674c7 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2850,8 +2850,8 @@ export const PROFILE_TEXT = { scopeSelected: (n: number) => `已选的 ${formatNumber(n)} 张表`, scopeFiltered: (n: number) => `当前筛选结果中的 ${formatNumber(n)} 张表`, scopeNeeded: (n: number) => `需要剖析的 ${formatNumber(n)} 张表`, - scopeNeededHint: '从没剖析过、上次没做完,或剖析之后又出现了待核实关联关系的表', - scopeNeededNone: '没有需要剖析的表:都剖析过、上次都做完了,也没有新的待核实关联关系', + scopeNeededHint: '从没剖析过、上次没做完,或剖析之后又出现了待核实关联关系、要取码值或日期范围的列的表。上次是空表的不算,有了数据请选全部表或单独剖析它', + scopeNeededNone: '没有需要剖析的表:都剖析过、上次都做完了,也没有新的待核实关联关系和新的码值、日期列', scopeAll: (n: number) => `全部 ${formatNumber(n)} 张表`, scopeAllHint: '有可剖析项的表,不含视图', /** 已选、筛选结果里有剖析不了的表:不交给服务端,写明有几张 */ @@ -3073,9 +3073,10 @@ export const PROFILE_TEXT = { never: '从没剖析过', unfinished: '上次剖析没做完', new_relations: '剖析之后目录里出现了新的待核实关联关系', + new_checks: '剖析之后多出了要取码值或日期范围的列', } as Record, dueFilter: '需要剖析', - dueFilterHint: '从没剖析过、上次没做完,或剖析之后又出现了待核实关联关系的表', + dueFilterHint: '从没剖析过、上次没做完,或剖析之后又出现了待核实关联关系、要取码值或日期范围的列的表', // ---- 表详情的「数据剖析」一节 detailHint: '对业务库发只读查询得到的观测:行数、日期范围、核实了什么、跳过了什么', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 78de787e..0bd204f6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2845,7 +2845,8 @@ export interface CatalogTableRow { profile_due?: boolean /** * 为什么需要剖析:never 从没剖析过;unfinished 最近一次剖析没做完(部分完成、失败、已停止);new_relations 目录里有待核实的 - * 关系不在最近一次剖析的检查项里。不需要时为 null + * 关系不在最近一次剖析的检查项里;new_checks 最近一次剖析之后多出了码值、日期检查(某一列被记成了状态类、日期列变了)。 + * 不需要时为 null */ profile_due_reason?: CatalogProfileDueReason | null /** 按当前目录和表结构这张表要做几项检查;0 表示没有可剖析的项(视图为 0) */ @@ -3350,7 +3351,7 @@ export interface CatalogProfileSettings { } /** 为什么需要剖析(GET /catalog 每行的 profile_due_reason) */ -export type CatalogProfileDueReason = 'never' | 'unfinished' | 'new_relations' +export type CatalogProfileDueReason = 'never' | 'unfinished' | 'new_relations' | 'new_checks' /** 剖析的范围:selected 指定的表;needed 需要剖析的表;all 全部可剖析的表(不含视图) */ export type CatalogProfileScope = 'selected' | 'needed' | 'all' diff --git a/scripts/check-catalog-profile.mjs b/scripts/check-catalog-profile.mjs index 1ba26afa..b2a76f49 100644 --- a/scripts/check-catalog-profile.mjs +++ b/scripts/check-catalog-profile.mjs @@ -658,7 +658,7 @@ async function openDialog(page) { await box(page).waitFor() } // 界面不该露的机读码:状态、跳过原因、检查类别、查询结果的英文值 -const CODE_RE = /\b(?:queued|running|succeeded|partial|failed|cancelled|interrupted|pending|skipped|budget|deadline|timeout|rejected|masked|too_large|slow|nothing|row_estimate|relation|codes|verified|proposed|unfinished|new_relations|never)\b/ +const CODE_RE = /\b(?:queued|running|succeeded|partial|failed|cancelled|interrupted|pending|skipped|budget|deadline|timeout|rejected|masked|too_large|slow|nothing|row_estimate|relation|codes|verified|proposed|unfinished|new_relations|new_checks|never)\b/ async function cleanCopy(page, where) { let t = await box(page).innerText().catch(() => '') // 表名、列名、SQL 是库里的标识,照原样显示,先去掉再查