From fe200598c024e091d84c7428c3dd4e4f8a1cd8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 14:52:28 +0800 Subject: [PATCH 01/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=85=88=E8=A1=8C?= =?UTF-8?q?=EF=BC=9AAGC=20=E5=91=BD=E4=BB=A4=E9=94=99=E8=AF=AF=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8C=96=E4=B8=8E=E9=94=99=E8=AF=AF=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ADR:命令错误按具体变体结构化(ts-rs 导出)、报告池只收没人处理的错误、前端按 type 分流不匹配文案 - 更新【技术方案】AGC错误报告与诊断上传:采集口径改为"只收没有调用方处理的错误",补命令变体分流与 clientApi 边界 - 更新共享记忆 decision-log:记录本次口径与影响范围 - 更新共享记忆 pitfalls:记录"输错密码被当成客户端缺陷上报"的现象、根因与判据 - 修正 DirectTurnError 模块注释中"命令边界只给字符串"的过期描述,改为结构化拒单载荷 - docs/README.md 登记新 ADR --- .../src-tauri/src/agent/direct_turn_error.rs | 5 +- docs/README.md | 1 + ...GC命令错误结构化与错误报告口径-2026-10-01.md | 89 +++++++++++++++++++ .../shared-memory/decision-log.md | 8 ++ docs/project-memory/shared-memory/pitfalls.md | 8 ++ ...€术方案】AGC错误报告与诊断上传-2026-08-31.md | 5 +- 6 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index 39cbfbb72..ce9303a87 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -16,8 +16,9 @@ //! //! 事件载荷(`thread_manager::wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: //! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 -//! 另一种状态模型。跨进程边界(`#[tauri::command]`)同样只给前端一个字符串:那是**序列化**, -//! 由 `Display` 一处生成;Rust 侧任何地方都不再解析这个字符串。 +//! 另一种状态模型。跨进程边界(`#[tauri::command]`)的**拒单**载荷是结构化的 +//! [`DirectTurnEnqueueFailure`](变体 + 一处生成的 `Display` 文案);其余只需要一句文案的出口 +//! 仍由 `Display` 生成。Rust 侧任何地方都不解析这个字符串。 //! //! 谁负责产生哪个变体: //! - 命令入口与回合编排(`direct_runtime`):调用级拒绝、阶段失败; diff --git a/docs/README.md b/docs/README.md index cca80fe09..536b1346c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,6 +51,7 @@ - [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;拒单前置、失败后置。 - [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 - [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。 +- [AGC 命令错误结构化与错误报告口径](./adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md):AGC 命令失败按具体变体建模并用 ts-rs 导出,前端按变体分流、不匹配文案;报告池只收没人处理的错误。 - [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md new file mode 100644 index 000000000..319d4002c --- /dev/null +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -0,0 +1,89 @@ +# 【ADR】AGC 命令错误结构化与错误报告口径 + +状态:已接受(2026-10-01 落地,实施顺序见同日的决策记录与 +[`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md)) + +## 背景 + +用户在登录页把密码输错一次,报告面板就出现两条"错误事件"并弹出「发现问题」: + +```text +登录失败:密码长度需要在 6 到 128 位之间 — auth · 1 次 +手机号或密码错误 — auth · 1 次 +``` + +根因不是文案,而是两件事叠加: + +1. 登录已经下沉到 Rust(`login_client_with_password` 等命令),命令失败以 `Err(String)` 返回; + Tauri 把 `String` 原样交给 JS,`invoke` 以**裸字符串**拒绝,前端拿到的东西没有任何类型信息。 +2. WebView 侧的 `shouldCaptureClientError` 对"非 object"值走默认 `return true`,于是 + `handleLoginSubmit` 的 catch 把"用户输错密码"当成缺陷事件报进了错误池。 + +这与 [`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md) +已写明的"预期的 4xx 登录/鉴权失败不进入错误报告池"直接冲突——口径早就定了,是错误通道的实现方式违背了它。 + +DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) §4): +命令返回结构化 typed error,前端按变体分流,"认不得的变体或非结构化错误"原样抛出走上报链路。 +本 ADR 把这套口径推广成 AGC 命令错误的通用约定,并同时收窄错误报告池的入口。 + +## 决策 + +### 1. 命令错误按具体变体建模,不按文案匹配 + +- Rust 侧定义具体变体枚举(auth 首个落地:`ClientAuthError`),`#[derive(Serialize, TS)]` + + `#[serde(tag = "type", rename_all = "camelCase")]`,用 ts-rs 导出到 + `apps/ai-game-creator-shell/src/services/generated/`;生成物不手改。 +- `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。 + 这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新约定。 +- 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用 + `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordEntryInputRejected`), + 不假装能区分密码长度/手机号格式;**任何地方都不允许对错误文案做判断**。 +- 每个变体带一份可展示 `message`,文案仍只在 Rust 生成一次;前端不拼文案。 + +### 2. 报告池只收"没有任何调用方处理"的错误 + +谁抛出、谁判定。分层规则: + +- **预期业务拒绝**(用户输入、前置条件、预期 4xx):由调用方消化并给用户反馈,**永不进池**。 +- **真故障**(网络不可达、5xx、写盘/运行时安装失败、agent 终态失败):由调用方带上文重抛, + 经 `window.onerror` / `unhandledrejection` 入池;Rust 侧 agent 终态失败仍由失败投影入池。 +- **WebView 全局 handler 是兜底**:任何没人 catch 的错误都进池。 +- **API 客户端(`clientApi`)在抛出前判定 408/5xx/网络为缺陷**:它是 `fetch` 的调用方, + 这一判定就发生在它这一层;4xx 一律不报,交给上层调用方。这条边界保持现状,不放宽也不收紧。 + +### 3. 前端按变体分流,认不出就抛 + +- `isClientAuthError` 只做形状读取(`type` 是稳定判别键),`clientAuthErrorNotice` 用 + `switch (error.type)` 给出可展示文案;`default → null` 表示"认不出"。 +- 认不出、系统类、非结构化拒绝 → `throw new ClientActionError(message, context, cause)`; + `ClientActionError` 只承载 `source/action/page` 上下文与 `cause`,由全局 handler 用 + `instanceof` 解包后入池(指纹/展示字段与今天一致)。 +- 删除 `shouldCaptureClientError`:不再存在"叶子自己判定要不要报"的口径。 + +### 4. 报告面板与通知行为不变 + +默认选中快照中的全部事件、只由通知中的「查看并报告」打开、poisoned 快照用 fallback 等承诺保持不变; +本次只保证"不该进池的东西不再进池"。 + +## 后果与边界 + +- auth 三命令(`login_client_with_password`、`login_client_with_phone_code`、`send_client_phone_login_code`) + 及其共用链路(`request_auth` / `map_auth_failure` / `response_data` / `network_error_message`)全量改为 + `Result<_, ClientAuthError>`;`read_client_auth_state`、`refresh_client_auth_session`、 + `logout_client_session` 的失败面同步结构化(会话 401/403 仍是"未登录"路径,不是错误)。 +- 未识别变体上调是**故意**的:Rust 与 TS 同包发布,"认不出"意味着有人加了变体忘了接界面,属于缺陷。 +- 仍保留的显式采集点(`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic`) + 在后续变更里按同一口径重抛/删除,本 ADR 不改它们的行为。 + +## 验收 + +```text +npm run ai-game-creator-shell:typecheck +npx vitest run apps/ai-game-creator-shell/tests/errorReporting.test.ts apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx +cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings +npm run ai-game-creator-shell:check:rust:shell +npm run check:generated-bindings +npm run check:doc-index +npm run check:encoding +git diff --check +``` diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a7b1adf7e..97fb31948 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-10-01 AGC 命令错误结构化与错误报告口径 + +- 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端只按 `type` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。 +- 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文重抛(`ClientActionError` 承载 `source/action/page`),经 `window.onerror` / `unhandledrejection` 入池;`clientApi` 作为 `fetch` 的调用方在抛出前判定 408/5xx/网络为缺陷(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。 +- 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordEntryInputRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径重抛/删除留在后续变更。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.ts,errorReporting.ts,clientApi.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts`(ts-rs 生成)。 +- 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 + ## 2026-09-30 release 渠道移除产品名与包名后缀 - 决策:`release` 渠道的正式产品名统一为 `陶泥儿`,Windows NSIS、macOS DMG / updater 归档等由 Tauri `productName` 派生的包名不再包含 `Release` 文本;`identifier=world.genarrative.ai-game-creator.release` 与 `release-win` 更新分区保持不变。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e47120989..1d7122faa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-01 用户输错一次密码被当成"客户端出问题了"引导上报 + +- **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。 +- **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 +- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),UI 调用方按 `type` 分流:认得的业务变体只给用户反馈,系统变体/未识别变体/非结构化拒绝原样抛出走上报链路;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)。 +- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/errorReporting.test.ts`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体经 `unhandledrejection` 只上报一次且 `source=auth`。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/errorReporting.ts`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 + ## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红 - **现象**:`Project CI` 的 AGC 壳 Rust 三条 lane(`npm run check:native-shells:agc-rust-shard-*`)在 `fb130d184` 之后全部失败,日志只有 `error: failed to run custom build command for genarrative-ai-game-creator-shell` 与 `thread 'main' panicked at build.rs:65:28: Claude Agent SDK 缺失;请先执行 npm ci`(run 3083 / job 17521 实测,1 分钟即失败)。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 74f326266..cd4bf8002 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -8,10 +8,11 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 诊断 URL 保留可定位的 API 路由路径,隐藏 origin、URL 账号密码、查询参数、fragment 和路径中的敏感标识;普通资源 URL 与本地文件路径继续隐藏。网络错误、HTTP 错误与响应体超时均应带安全路由,不能只剩 ``。历史已经脱敏的归档不推测或补造原路由。 -- 捕获 React render error、`window.onerror`、`unhandledrejection` 以及显式标记的 Tauri/API/Agent 错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。 +- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文重抛的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 -- 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。 +- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。 +- 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 - 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 - 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中当前快照中的全部事件,用户可取消不想提交的事件。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 -- 2.52.0 From 8d55f7191152559ac63ec6d8fd27a17a567084b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 15:13:57 +0800 Subject: [PATCH 02/70] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E9=94=99=E8=AF=AF=E7=BB=93=E6=9E=84=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 auth_error.rs:ClientAuthError 具体变体枚举,serde tag=type + ts-rs 导出,附变体名契约测试 - auth_session.rs 全量改为 Result<_, ClientAuthError>:请求/响应/凭据落盘/运行时会话安装按具体变体建模 - 路由语义由变体承担:会话 401/403 走 SessionAuthorityRejected/PermissionDenied,登录 401 保留服务端原因 - 400 变体按请求粒度命名(passwordEntryInputRejected 等),服务端只给 status+message,不做文案匹配 - 注册 auth_error 模块,生成 src/services/generated/ClientAuthError.ts --- .../src-tauri/src/auth_error.rs | 317 ++++++++++++ .../src-tauri/src/auth_session.rs | 486 +++++++++++++----- .../src-tauri/src/main.rs | 1 + .../src/services/generated/ClientAuthError.ts | 25 + 4 files changed, 688 insertions(+), 141 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/auth_error.rs create mode 100644 apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs new file mode 100644 index 000000000..a64308628 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -0,0 +1,317 @@ +//! AGC 认证命令的结构化错误:从命令入口到出口只传这一种错误。 +//! +//! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。 +//! +//! 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(平台 `AppError.code` 仍是 +//! 通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(例如 +//! [`ClientAuthError::PasswordEntryInputRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 +//! `401/403` 是"登录态权威失效",登录路由的 `401` 是用户可修正的输入问题,这个区分现在由变体承担, +//! 不再靠 `authentication-required:` 这类文本前缀。 +//! +//! 每个变体都带一份可展示 `message`,文案只在 Rust 生成一次(服务端原文优先,缺失时才用调用点的 +//! 兜底文案);前端对认得的业务变体原样展示,对系统变体 / 未识别变体带上下文重抛,走上报链路。 + +use std::fmt; + +use serde::Serialize; +use ts_rs::TS; + +/// 变体名就是线上的分流键(`type`)。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) enum ClientAuthError { + // ---- 业务:用户自己能改,调用方给提示,不上报 ---- + /// 服务地址不是合法 origin / 非本机未用 HTTPS / 不在构建渠道范围内。 + ServerAddressRejected { message: String }, + /// 本地前置校验:手机号为空或格式不合法。 + PhoneNumberInvalid { message: String }, + /// 本地前置校验:密码为空。 + PasswordMissing { message: String }, + /// 本地前置校验:验证码为空。 + LoginCodeMissing { message: String }, + /// `/api/auth/entry` 返回 400:服务端拒绝本次输入。 + PasswordEntryInputRejected { message: String }, + /// `/api/auth/entry` 返回 401:手机号或密码错误。 + PhoneOrPasswordMismatch { message: String }, + /// `/api/auth/phone/send-code` 返回 400。 + SendCodeInputRejected { message: String }, + /// `/api/auth/phone/send-code` 返回 429:发送过于频繁。 + SmsCodeThrottled { message: String }, + /// `/api/auth/phone/login` 返回 400。 + PhoneLoginInputRejected { message: String }, + /// `/api/auth/phone/login` 返回 401:验证码错误或过期。 + SmsCodeInvalidOrExpired { message: String }, + // ---- 会话:调用方按"未登录"处理,不给用户报错 ---- + /// 会话路由 401:登录态权威失效。 + SessionAuthorityRejected { message: String }, + /// 会话路由 403:当前账号没有执行此操作的权限。 + PermissionDenied { message: String }, + // ---- 系统:调用方处理不了,带上下文重抛 ---- + /// 连接 / 超时 / DNS 等网络失败。 + AuthNetworkUnavailable { message: String }, + /// 服务端 5xx。 + AuthServiceUnavailable { status: u16, message: String }, + /// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 + UnexpectedRejection { status: u16, message: String }, + /// 响应不是合法 JSON、缺少必需字段或契约不成立。 + AuthResponseMalformed { message: String }, + /// 本机登录凭据文件读写失败。 + ClientSessionPersistFailed { message: String }, + /// 本机运行时会话安装 / 清理失败。 + RuntimeSessionInstallFailed { message: String }, + /// 认证网络客户端构建失败。 + AuthClientInitFailed { message: String }, +} + +impl ClientAuthError { + /// 可展示文案:服务端原文优先,缺失时是调用点兜底。 + pub(crate) fn message(&self) -> &str { + match self { + Self::ServerAddressRejected { message } + | Self::PhoneNumberInvalid { message } + | Self::PasswordMissing { message } + | Self::LoginCodeMissing { message } + | Self::PasswordEntryInputRejected { message } + | Self::PhoneOrPasswordMismatch { message } + | Self::SendCodeInputRejected { message } + | Self::SmsCodeThrottled { message } + | Self::PhoneLoginInputRejected { message } + | Self::SmsCodeInvalidOrExpired { message } + | Self::SessionAuthorityRejected { message } + | Self::PermissionDenied { message } + | Self::AuthNetworkUnavailable { message } + | Self::AuthServiceUnavailable { message, .. } + | Self::UnexpectedRejection { message, .. } + | Self::AuthResponseMalformed { message } + | Self::ClientSessionPersistFailed { message } + | Self::RuntimeSessionInstallFailed { message } + | Self::AuthClientInitFailed { message } => message, + } + } + + /// 会话路由的 401/403 是「登录态权威失效」:调用方据此清会话、按未登录处理, + /// 既不给用户报错,也不进错误报告池。 + pub(crate) fn is_authority_failure(&self) -> bool { + matches!( + self, + Self::SessionAuthorityRejected { .. } | Self::PermissionDenied { .. } + ) + } + + /// 登录态投影里 `errorKind` 的取值:只描述"哪一类失败",不参与任何前端分支。 + pub(crate) fn error_kind(&self) -> &'static str { + match self { + Self::AuthNetworkUnavailable { .. } => "network", + Self::AuthServiceUnavailable { .. } | Self::UnexpectedRejection { .. } => "service", + Self::AuthResponseMalformed { .. } => "response", + Self::ClientSessionPersistFailed { .. } => "storage", + Self::RuntimeSessionInstallFailed { .. } | Self::AuthClientInitFailed { .. } => { + "runtime" + } + _ => "auth", + } + } +} + +impl fmt::Display for ClientAuthError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message()) + } +} + +/// 只需要一句文案的边界用它,与 `DirectTurnError` 的收口方式一致。 +impl From for String { + fn from(error: ClientAuthError) -> Self { + error.message().to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_variant_names_are_the_frontend_dispatch_keys() { + let cases = [ + ( + ClientAuthError::ServerAddressRejected { + message: "x".to_string(), + }, + "serverAddressRejected", + ), + ( + ClientAuthError::PhoneNumberInvalid { + message: "x".to_string(), + }, + "phoneNumberInvalid", + ), + ( + ClientAuthError::PasswordMissing { + message: "x".to_string(), + }, + "passwordMissing", + ), + ( + ClientAuthError::LoginCodeMissing { + message: "x".to_string(), + }, + "loginCodeMissing", + ), + ( + ClientAuthError::PasswordEntryInputRejected { + message: "x".to_string(), + }, + "passwordEntryInputRejected", + ), + ( + ClientAuthError::PhoneOrPasswordMismatch { + message: "x".to_string(), + }, + "phoneOrPasswordMismatch", + ), + ( + ClientAuthError::SendCodeInputRejected { + message: "x".to_string(), + }, + "sendCodeInputRejected", + ), + ( + ClientAuthError::SmsCodeThrottled { + message: "x".to_string(), + }, + "smsCodeThrottled", + ), + ( + ClientAuthError::PhoneLoginInputRejected { + message: "x".to_string(), + }, + "phoneLoginInputRejected", + ), + ( + ClientAuthError::SmsCodeInvalidOrExpired { + message: "x".to_string(), + }, + "smsCodeInvalidOrExpired", + ), + ( + ClientAuthError::SessionAuthorityRejected { + message: "x".to_string(), + }, + "sessionAuthorityRejected", + ), + ( + ClientAuthError::PermissionDenied { + message: "x".to_string(), + }, + "permissionDenied", + ), + ( + ClientAuthError::AuthNetworkUnavailable { + message: "x".to_string(), + }, + "authNetworkUnavailable", + ), + ( + ClientAuthError::AuthServiceUnavailable { + status: 503, + message: "x".to_string(), + }, + "authServiceUnavailable", + ), + ( + ClientAuthError::UnexpectedRejection { + status: 409, + message: "x".to_string(), + }, + "unexpectedRejection", + ), + ( + ClientAuthError::AuthResponseMalformed { + message: "x".to_string(), + }, + "authResponseMalformed", + ), + ( + ClientAuthError::ClientSessionPersistFailed { + message: "x".to_string(), + }, + "clientSessionPersistFailed", + ), + ( + ClientAuthError::RuntimeSessionInstallFailed { + message: "x".to_string(), + }, + "runtimeSessionInstallFailed", + ), + ( + ClientAuthError::AuthClientInitFailed { + message: "x".to_string(), + }, + "authClientInitFailed", + ), + ]; + for (error, expected_type) in cases { + let value = serde_json::to_value(&error).expect("serialize auth error"); + assert_eq!( + value.get("type").and_then(|value| value.as_str()), + Some(expected_type) + ); + assert_eq!( + value.get("message").and_then(|value| value.as_str()), + Some(error.message()) + ); + } + } + + #[test] + fn machine_context_fields_survive_serialization() { + let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable { + status: 503, + message: "账号服务暂不可用".to_string(), + }) + .expect("serialize auth error"); + assert_eq!(unavailable["type"], "authServiceUnavailable"); + assert_eq!(unavailable["status"], 503); + + let rejection = serde_json::to_value(ClientAuthError::UnexpectedRejection { + status: 409, + message: "冲突".to_string(), + }) + .expect("serialize auth error"); + assert_eq!(rejection["status"], 409); + } + + #[test] + fn only_session_authority_failures_count_as_authority_failures() { + assert!(ClientAuthError::SessionAuthorityRejected { + message: "x".to_string() + } + .is_authority_failure()); + assert!(ClientAuthError::PermissionDenied { + message: "x".to_string() + } + .is_authority_failure()); + assert!(!ClientAuthError::PhoneOrPasswordMismatch { + message: "x".to_string() + } + .is_authority_failure()); + assert!(!ClientAuthError::AuthNetworkUnavailable { + message: "x".to_string() + } + .is_authority_failure()); + } + + #[test] + fn display_and_string_conversion_use_the_display_message() { + let error = ClientAuthError::PhoneOrPasswordMismatch { + message: "手机号或密码错误".to_string(), + }; + assert_eq!(error.to_string(), "手机号或密码错误"); + assert_eq!(String::from(error), "手机号或密码错误"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 2b0e3e940..a1b76d1a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -4,6 +4,7 @@ //! 只在 Rust 内存与本进程会话快照里,refresh 凭据只写在 AppData 私有文件里。换号、登出或 //! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 +use crate::auth_error::ClientAuthError; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; use reqwest::{header::SET_COOKIE, Method, StatusCode}; @@ -35,7 +36,8 @@ const AGC_CLIENT_MARKER_VALUE: &str = "agc"; const AUTH_NETWORK_ERROR: &str = "network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试"; const AUTH_NETWORK_TIMEOUT: &str = "network-error: 登录服务响应超时,请检查服务器地址和网络后重试"; -const AUTH_AUTHORITY_ERROR: &str = "authentication-required: 登录状态已失效,请重新登录"; +/// 会话路由 401 且服务端没给原因时的兜底文案。 +const AUTH_AUTHORITY_MESSAGE: &str = "登录状态已失效,请重新登录"; /// 认证态投影:只含状态、用户展示字段与 origin,不含 token 或 refresh 凭据。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -47,7 +49,7 @@ pub(crate) struct ClientAuthStateView { pub(crate) user: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) api_base_url: Option, - /// 失败分类:`network` / `authority` / `contract`;成功或未登录时为 `None`。 + /// 失败分类:`network` / `service` / `response` / `storage` / `runtime` / `auth`;成功或未登录时为 `None`。 #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) error_kind: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -221,11 +223,34 @@ struct SendCodeResponse { expires_in_seconds: u64, } -fn session_file_path(app: &tauri::AppHandle) -> Result { +fn session_file_path(app: &tauri::AppHandle) -> Result { app.path() .app_data_dir() .map(|root| root.join(SESSION_FILE_NAME)) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) + .map_err(|error| ClientAuthError::ClientSessionPersistFailed { + message: format!("无法读取 AGC 应用数据目录:{error}"), + }) +} + +/// 凭据文件相关的字符串错误统一收口成凭据落盘失败变体。 +fn session_persist_error(message: impl Into) -> ClientAuthError { + ClientAuthError::ClientSessionPersistFailed { + message: message.into(), + } +} + +/// 本机运行时安装 / 清理失败的字符串错误统一收口。 +fn runtime_session_error(message: impl Into) -> ClientAuthError { + ClientAuthError::RuntimeSessionInstallFailed { + message: message.into(), + } +} + +/// 服务地址校验(含渠道范围门禁)失败统一收口成业务变体。 +fn server_address_rejected(message: impl Into) -> ClientAuthError { + ClientAuthError::ServerAddressRejected { + message: message.into(), + } } /// 校验并归一化平台服务 origin。 @@ -271,19 +296,25 @@ fn read_session_file_at(path: &Path) -> Option { file.is_complete().then_some(file) } -fn write_session_file_at(path: &Path, session: Option<&ClientSessionFile>) -> Result<(), String> { +fn write_session_file_at( + path: &Path, + session: Option<&ClientSessionFile>, +) -> Result<(), ClientAuthError> { let Some(session) = session else { if fs::symlink_metadata(path).is_ok() { - fs::remove_file(path).map_err(|error| format!("清除客户端登录凭据失败:{error}"))?; + fs::remove_file(path).map_err(|error| { + session_persist_error(format!("清除客户端登录凭据失败:{error}")) + })?; } return Ok(()); }; let parent = path .parent() - .ok_or_else(|| "客户端登录凭据缺少父目录".to_string())?; - crate::ensure_game_creator_private_directory_tree(parent, "客户端登录凭据目录")?; + .ok_or_else(|| session_persist_error("客户端登录凭据缺少父目录"))?; + crate::ensure_game_creator_private_directory_tree(parent, "客户端登录凭据目录") + .map_err(session_persist_error)?; let content = serde_json::to_string_pretty(session) - .map_err(|error| format!("序列化客户端登录凭据失败:{error}"))?; + .map_err(|error| session_persist_error(format!("序列化客户端登录凭据失败:{error}")))?; let temp_path = path.with_file_name(format!( ".{}.tmp.{}.{}", path.file_name() @@ -307,15 +338,15 @@ fn write_session_file_at(path: &Path, session: Option<&ClientSessionFile>) -> Re use std::os::windows::fs::OpenOptionsExt; options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); } - let mut handle = options - .open(&temp_path) - .map_err(|error| format!("创建客户端登录凭据临时文件失败:{error}"))?; + let mut handle = options.open(&temp_path).map_err(|error| { + session_persist_error(format!("创建客户端登录凭据临时文件失败:{error}")) + })?; if let Err(error) = crate::harden_new_game_creator_private_path(&temp_path, false, "客户端登录凭据") { drop(handle); let _ = fs::remove_file(&temp_path); - return Err(error); + return Err(session_persist_error(error)); } let write_result = handle .write_all(format!("{content}\n").as_bytes()) @@ -323,23 +354,30 @@ fn write_session_file_at(path: &Path, session: Option<&ClientSessionFile>) -> Re drop(handle); if let Err(error) = write_result { let _ = fs::remove_file(&temp_path); - return Err(format!("写入客户端登录凭据失败:{error}")); + return Err(session_persist_error(format!( + "写入客户端登录凭据失败:{error}" + ))); } if fs::symlink_metadata(path).is_ok() { - crate::prepare_game_creator_private_path_for_read(path, false, "客户端登录凭据")?; + crate::prepare_game_creator_private_path_for_read(path, false, "客户端登录凭据") + .map_err(session_persist_error)?; #[cfg(windows)] fs::remove_file(path).map_err(|error| { let _ = fs::remove_file(&temp_path); - format!("替换客户端登录凭据失败:{error}") + session_persist_error(format!("替换客户端登录凭据失败:{error}")) })?; } if let Err(error) = fs::rename(&temp_path, path) { let _ = fs::remove_file(&temp_path); - return Err(format!("提交客户端登录凭据失败:{error}")); + return Err(session_persist_error(format!( + "提交客户端登录凭据失败:{error}" + ))); } Ok(()) } -fn require_app_session(app: &tauri::AppHandle) -> Result, String> { +fn require_app_session( + app: &tauri::AppHandle, +) -> Result, ClientAuthError> { let path = session_file_path(app)?; let mut state = auth_state() .lock() @@ -372,13 +410,13 @@ fn current_session_origin() -> Option { current_platform_session().map(|snapshot| snapshot.api_base_url) } -fn endpoint(origin: &str, route: &str) -> Result { +fn endpoint(origin: &str, route: &str) -> Result { let mut url = Url::parse(&format!("{}/", origin.trim_end_matches('/'))) - .map_err(|_| "陶泥儿服务地址无效".to_string())?; + .map_err(|_| server_address_rejected("陶泥儿服务地址无效"))?; { let mut segments = url .path_segments_mut() - .map_err(|_| "陶泥儿服务地址无效".to_string())?; + .map_err(|_| server_address_rejected("陶泥儿服务地址无效"))?; for segment in route.trim_start_matches('/').split('/') { if segment.is_empty() { continue; @@ -389,19 +427,23 @@ fn endpoint(origin: &str, route: &str) -> Result { Ok(url.to_string()) } -fn build_client() -> Result { +fn build_client() -> Result { agc_main_site_client_builder() .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建登录网络客户端失败".to_string()) + .map_err(|_| ClientAuthError::AuthClientInitFailed { + message: "创建登录网络客户端失败".to_string(), + }) } -fn network_error_message(error: &reqwest::Error) -> String { - if error.is_timeout() { - AUTH_NETWORK_TIMEOUT.to_string() - } else { - AUTH_NETWORK_ERROR.to_string() +fn network_error_message(error: &reqwest::Error) -> ClientAuthError { + ClientAuthError::AuthNetworkUnavailable { + message: if error.is_timeout() { + AUTH_NETWORK_TIMEOUT.to_string() + } else { + AUTH_NETWORK_ERROR.to_string() + }, } } @@ -416,64 +458,133 @@ fn error_message(body: &str) -> Option { .map(ToString::to_string) } -/// 认证路由语义:会话路由的 401/403 是权威失效,登录路由的 401/403 是用户可修正的输入问题。 +/// 认证路由语义:路由决定 401/403/429 落到哪个具体变体。 +/// +/// 会话路由的 `401/403` 是登录态权威失效,登录路由的 `401` 是用户可修正的输入问题; +/// 这个区分现在由 [`ClientAuthError`] 的变体承担,不再靠文本前缀。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum AuthRouteKind { - Login, +enum AuthRoute { + PasswordEntry, + PhoneLogin, + SendCode, Session, + Other, } -fn auth_route_kind(route: &str) -> AuthRouteKind { - if route.ends_with("/me") || route.ends_with("/refresh") { - AuthRouteKind::Session +fn auth_route(route: &str) -> AuthRoute { + let route = route.trim_end_matches('/'); + if route.ends_with("/api/auth/entry") { + AuthRoute::PasswordEntry + } else if route.ends_with("/api/auth/phone/login") { + AuthRoute::PhoneLogin + } else if route.ends_with("/api/auth/phone/send-code") { + AuthRoute::SendCode + } else if route.ends_with("/api/auth/me") + || route.ends_with("/api/auth/refresh") + || route.ends_with("/api/auth/logout") + { + AuthRoute::Session } else { - AuthRouteKind::Login + AuthRoute::Other } } -/// 把一次认证 HTTP 响应归类成稳定文案。 +/// 把一次认证 HTTP 响应归类成具体变体。 /// -/// 会话路由的 `401/403` 带 `authentication-required` / `permission-denied` 前缀,调用方 -/// 可以据此清会话;登录路由保留服务端原因(「手机号或密码错误」),不能被改写成登录失效。 -/// 网络、5xx 与契约异常必须保留会话。 -fn map_auth_failure(status: StatusCode, body: &str, fallback: &str, kind: AuthRouteKind) -> String { +/// 服务端原因(「手机号或密码错误」)原样保留,不能被改写成登录失效;会话路由的 `401/403` +/// 归到权威失效变体,调用方据此清会话。网络、5xx 与契约异常必须保留会话。 +fn map_auth_failure( + status: StatusCode, + body: &str, + fallback: &str, + route: AuthRoute, +) -> ClientAuthError { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); + let status_code = status.as_u16(); + let server_message = error_message(body); + let prefixed = || { + format!( + "{fallback}:{}", + server_message + .clone() + .unwrap_or_else(|| format!("HTTP {status_code}")) + ) + }; if status == StatusCode::UNAUTHORIZED { - return match (kind, error_message(body)) { - (AuthRouteKind::Session, Some(message)) => { - format!("authentication-required: {message}") - } - (AuthRouteKind::Session, None) => AUTH_AUTHORITY_ERROR.to_string(), - (AuthRouteKind::Login, Some(message)) => message, - (AuthRouteKind::Login, None) => fallback.to_string(), + return match route { + AuthRoute::Session => ClientAuthError::SessionAuthorityRejected { + message: server_message.unwrap_or_else(|| AUTH_AUTHORITY_MESSAGE.to_string()), + }, + AuthRoute::PasswordEntry => ClientAuthError::PhoneOrPasswordMismatch { + message: server_message.unwrap_or_else(|| fallback.to_string()), + }, + AuthRoute::PhoneLogin => ClientAuthError::SmsCodeInvalidOrExpired { + message: server_message.unwrap_or_else(|| fallback.to_string()), + }, + _ => ClientAuthError::UnexpectedRejection { + status: status_code, + message: server_message.unwrap_or_else(|| fallback.to_string()), + }, }; } if status == StatusCode::FORBIDDEN { - return match (kind, error_message(body)) { - (AuthRouteKind::Session, Some(message)) => format!("permission-denied: {message}"), - (AuthRouteKind::Session, None) => { - "permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string() - } - (AuthRouteKind::Login, Some(message)) => message, - (AuthRouteKind::Login, None) => fallback.to_string(), + return match route { + AuthRoute::Session => ClientAuthError::PermissionDenied { + message: server_message + .unwrap_or_else(|| "当前陶泥儿账号没有执行此操作的权限".to_string()), + }, + _ => ClientAuthError::UnexpectedRejection { + status: status_code, + message: server_message.unwrap_or_else(|| fallback.to_string()), + }, }; } - let detail = error_message(body).unwrap_or_else(|| format!("HTTP {}", status.as_u16())); - format!("{fallback}:{detail}") + if status == StatusCode::TOO_MANY_REQUESTS && route == AuthRoute::SendCode { + return ClientAuthError::SmsCodeThrottled { + message: prefixed(), + }; + } + if status == StatusCode::BAD_REQUEST { + return match route { + AuthRoute::PasswordEntry => ClientAuthError::PasswordEntryInputRejected { + message: prefixed(), + }, + AuthRoute::PhoneLogin => ClientAuthError::PhoneLoginInputRejected { + message: prefixed(), + }, + AuthRoute::SendCode => ClientAuthError::SendCodeInputRejected { + message: prefixed(), + }, + _ => ClientAuthError::UnexpectedRejection { + status: status_code, + message: prefixed(), + }, + }; + } + if status.is_server_error() { + return ClientAuthError::AuthServiceUnavailable { + status: status_code, + message: prefixed(), + }; + } + ClientAuthError::UnexpectedRejection { + status: status_code, + message: prefixed(), + } } -fn is_authority_failure(message: &str) -> bool { - message.starts_with("authentication-required") || message.starts_with("permission-denied") -} - -fn response_data(body: &str, fallback: &str) -> Result { +fn response_data(body: &str, fallback: &str) -> Result { let value: Value = - serde_json::from_str(body).map_err(|_| format!("{fallback}:登录服务响应不是合法 JSON"))?; + serde_json::from_str(body).map_err(|_| ClientAuthError::AuthResponseMalformed { + message: format!("{fallback}:登录服务响应不是合法 JSON"), + })?; if value.get("ok").and_then(Value::as_bool) == Some(false) { - return Err(format!( - "{fallback}:{}", - error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) - )); + return Err(ClientAuthError::AuthResponseMalformed { + message: format!( + "{fallback}:{}", + error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) + ), + }); } Ok(value.get("data").cloned().unwrap_or(value)) } @@ -521,7 +632,7 @@ async fn request_auth( refresh_cookie: Option<&(String, String)>, policy: CookiePolicy, fallback: &str, -) -> Result { +) -> Result { let method = match policy { // 读取类路由用 GET;写入类路由是 POST。 CookiePolicy::Ignore if route.ends_with("/me") => Method::GET, @@ -553,17 +664,16 @@ async fn request_auth( let text = response .text() .await - .map_err(|_| format!("{fallback}:读取响应失败"))?; + .map_err(|_| ClientAuthError::AuthResponseMalformed { + message: format!("{fallback}:读取响应失败"), + })?; if !status.is_success() { - return Err(map_auth_failure( - status, - &text, - fallback, - auth_route_kind(route), - )); + return Err(map_auth_failure(status, &text, fallback, auth_route(route))); } if matches!(policy, CookiePolicy::Require) && captured.is_none() { - return Err("result-unknown: 登录服务未返回新的续期凭据,已停止使用旧凭据".to_string()); + return Err(ClientAuthError::AuthResponseMalformed { + message: "result-unknown: 登录服务未返回新的续期凭据,已停止使用旧凭据".to_string(), + }); } Ok(AuthResponse { data: response_data(&text, fallback)?, @@ -584,7 +694,7 @@ enum SessionIdentity { impl SessionIdentity { /// 拆出会话主体与展示用的用户投影:空主体在这里失败关闭,既不能写凭据文件,也不能 /// 装进本进程会话。 - fn resolve(self) -> Result<(String, Option), String> { + fn resolve(self) -> Result<(String, Option), ClientAuthError> { match self { Self::Login(user) => Ok((validated_session_user_id(&user.id)?, Some(user))), Self::Persisted(user_id) => Ok((validated_session_user_id(&user_id)?, None)), @@ -600,9 +710,11 @@ async fn commit_authenticated_session( token: String, refresh_cookie: (String, String), identity_change: bool, -) -> Result { +) -> Result { if token.chars().count() > MAX_SECRET_CHARS { - return Err("登录服务返回的凭据无效".to_string()); + return Err(ClientAuthError::AuthResponseMalformed { + message: "登录服务返回的凭据无效".to_string(), + }); } // 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。 let (user_id, known_user) = identity.resolve()?; @@ -630,7 +742,8 @@ async fn commit_authenticated_session( identity_generation, revision, ) - .await?; + .await + .map_err(runtime_session_error)?; // 续期路径只知道 user_id:展示字段随后会用新 token 通过 /api/auth/me 复核。 Ok(known_user.unwrap_or_else(|| AuthUserPayload { id: user_id, @@ -647,7 +760,7 @@ async fn commit_authenticated_session( })) } -async fn clear_authenticated_session(app: &tauri::AppHandle) -> Result<(), String> { +async fn clear_authenticated_session(app: &tauri::AppHandle) -> Result<(), ClientAuthError> { let path = session_file_path(app)?; write_session_file_at(&path, None)?; { @@ -658,7 +771,9 @@ async fn clear_authenticated_session(app: &tauri::AppHandle) -> Result<(), Strin state.access_token_issued_at = None; } let (identity_generation, revision) = reserve_session_write(true); - crate::commands::clear_client_session_locally(identity_generation, revision).await + crate::commands::clear_client_session_locally(identity_generation, revision) + .await + .map_err(runtime_session_error) } fn auth_state_view( @@ -689,10 +804,12 @@ fn phone_is_valid(phone: &str) -> bool { /// /// 主体缺失时不能退化成空串,否则本机凭据文件不完整、安装会话还会以「陶泥儿登录用户 /// 身份无效」失败关闭;这里先给出登录语义的明确原因。 -fn validated_session_user_id(user_id: &str) -> Result { +fn validated_session_user_id(user_id: &str) -> Result { let user_id = user_id.trim(); if user_id.is_empty() { - return Err("登录失败:登录服务未返回用户身份".to_string()); + return Err(ClientAuthError::AuthResponseMalformed { + message: "登录失败:登录服务未返回用户身份".to_string(), + }); } Ok(user_id.to_string()) } @@ -712,7 +829,7 @@ fn credential_rotated_elsewhere( async fn fetch_current_user( client: &reqwest::Client, snapshot: &PlatformSessionSnapshot, -) -> Result, String> { +) -> Result, ClientAuthError> { let response = request_auth( client, &snapshot.api_base_url, @@ -724,8 +841,11 @@ async fn fetch_current_user( "读取当前用户失败", ) .await?; - let me: MeResponse = serde_json::from_value(response.data) - .map_err(|_| "读取当前用户失败:响应格式无效".to_string())?; + let me: MeResponse = serde_json::from_value(response.data).map_err(|_| { + ClientAuthError::AuthResponseMalformed { + message: "读取当前用户失败:响应格式无效".to_string(), + } + })?; Ok(me.user) } @@ -736,7 +856,7 @@ async fn fetch_current_user( async fn refresh_session_inner( app: &tauri::AppHandle, expected_user_id: Option<&str>, -) -> Result { +) -> Result { let Some(session) = require_app_session(app)? else { return Ok(ClientAuthRefreshView { status: "unauthenticated".to_string(), @@ -782,7 +902,7 @@ async fn refresh_session_inner( let refreshed = match refreshed { Ok(response) => response, Err(error) => { - if is_authority_failure(&error) { + if error.is_authority_failure() { clear_authenticated_session(app).await?; let view = auth_state_view("unauthenticated", None, None); emit_auth_state(app, &view); @@ -797,16 +917,22 @@ async fn refresh_session_inner( status: "failed".to_string(), user: None, authoritative: false, - error_message: Some(error), + error_message: Some(error.message().to_string()), }); } }; - let token: TokenResponse = serde_json::from_value(refreshed.data) - .map_err(|_| "刷新登录状态失败:凭据响应格式无效".to_string())?; + let token: TokenResponse = serde_json::from_value(refreshed.data).map_err(|_| { + ClientAuthError::AuthResponseMalformed { + message: "刷新登录状态失败:凭据响应格式无效".to_string(), + } + })?; let install_token = token.token.clone(); - let new_cookie = refreshed - .refresh_cookie - .ok_or_else(|| "刷新登录状态失败:缺少新的续期凭据".to_string())?; + let new_cookie = + refreshed + .refresh_cookie + .ok_or_else(|| ClientAuthError::AuthResponseMalformed { + message: "刷新登录状态失败:缺少新的续期凭据".to_string(), + })?; commit_authenticated_session( app, &session.api_base_url, @@ -855,7 +981,7 @@ async fn refresh_session_inner( error_message: None, }) } - Err(error) if is_authority_failure(&error) => { + Err(error) if error.is_authority_failure() => { clear_authenticated_session(app).await?; let view = auth_state_view("unauthenticated", None, None); emit_auth_state(app, &view); @@ -870,7 +996,7 @@ async fn refresh_session_inner( status: "failed".to_string(), user: None, authoritative: false, - error_message: Some(error), + error_message: Some(error.message().to_string()), }), } } @@ -880,7 +1006,7 @@ async fn refresh_session_inner( pub(crate) async fn read_client_auth_state( app: tauri::AppHandle, expected_api_base_url: Option, -) -> Result { +) -> Result { let Some(session) = require_app_session(&app)? else { if let Some(snapshot) = current_platform_session() { // 凭据文件缺失但本进程仍有会话(例如同一次启动内刚登录):以会话为准。 @@ -900,7 +1026,7 @@ pub(crate) async fn read_client_auth_state( .map(str::trim) .filter(|value| !value.is_empty()) { - Some(value) => validate_client_api_base_url(value)?, + Some(value) => validate_client_api_base_url(value).map_err(server_address_rejected)?, None => session.api_base_url.clone(), }; if expected != session.api_base_url { @@ -927,8 +1053,8 @@ pub(crate) async fn read_client_auth_state( status: "unavailable".to_string(), user: None, api_base_url: Some(session.api_base_url), - error_kind: Some("network".to_string()), - error_message: Some(error), + error_kind: Some(error.error_kind().to_string()), + error_message: Some(error.message().to_string()), }), } } @@ -937,7 +1063,7 @@ pub(crate) async fn read_client_auth_state( pub(crate) async fn refresh_client_auth_session( app: tauri::AppHandle, expected_user_id: Option, -) -> Result { +) -> Result { let expected = expected_user_id .as_deref() .map(str::trim) @@ -950,11 +1076,13 @@ pub(crate) async fn refresh_client_auth_session( pub(crate) async fn send_client_phone_login_code( api_base_url: String, phone: String, -) -> Result { - let origin = validate_client_api_base_url(&api_base_url)?; +) -> Result { + let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err("请输入正确的手机号".to_string()); + return Err(ClientAuthError::PhoneNumberInvalid { + message: "请输入正确的手机号".to_string(), + }); } let client = build_client()?; let response = request_auth( @@ -972,8 +1100,11 @@ pub(crate) async fn send_client_phone_login_code( "发送验证码失败", ) .await?; - let payload: SendCodeResponse = serde_json::from_value(response.data) - .map_err(|_| "发送验证码失败:响应格式无效".to_string())?; + let payload: SendCodeResponse = serde_json::from_value(response.data).map_err(|_| { + ClientAuthError::AuthResponseMalformed { + message: "发送验证码失败:响应格式无效".to_string(), + } + })?; Ok(ClientLoginCodeView { cooldown_seconds: payload.cooldown_seconds, expires_in_seconds: payload.expires_in_seconds, @@ -984,12 +1115,17 @@ async fn complete_login( app: &tauri::AppHandle, origin: &str, response: AuthResponse, -) -> Result { - let payload: TokenUserResponse = serde_json::from_value(response.data) - .map_err(|_| "登录失败:登录服务响应格式无效".to_string())?; +) -> Result { + let payload: TokenUserResponse = serde_json::from_value(response.data).map_err(|_| { + ClientAuthError::AuthResponseMalformed { + message: "登录失败:登录服务响应格式无效".to_string(), + } + })?; let cookie = response .refresh_cookie - .ok_or_else(|| "登录失败:登录服务未返回续期凭据".to_string())?; + .ok_or_else(|| ClientAuthError::AuthResponseMalformed { + message: "登录失败:登录服务未返回续期凭据".to_string(), + })?; let user = commit_authenticated_session( app, origin, @@ -1015,14 +1151,18 @@ pub(crate) async fn login_client_with_password( api_base_url: String, phone: String, password: String, -) -> Result { - let origin = validate_client_api_base_url(&api_base_url)?; +) -> Result { + let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err("请输入正确的手机号".to_string()); + return Err(ClientAuthError::PhoneNumberInvalid { + message: "请输入正确的手机号".to_string(), + }); } if password.trim().is_empty() { - return Err("请输入密码".to_string()); + return Err(ClientAuthError::PasswordMissing { + message: "请输入密码".to_string(), + }); } let client = build_client()?; let response = request_auth( @@ -1049,14 +1189,18 @@ pub(crate) async fn login_client_with_phone_code( api_base_url: String, phone: String, code: String, -) -> Result { - let origin = validate_client_api_base_url(&api_base_url)?; +) -> Result { + let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err("请输入正确的手机号".to_string()); + return Err(ClientAuthError::PhoneNumberInvalid { + message: "请输入正确的手机号".to_string(), + }); } if code.trim().is_empty() { - return Err("请输入验证码".to_string()); + return Err(ClientAuthError::LoginCodeMissing { + message: "请输入验证码".to_string(), + }); } let client = build_client()?; let response = request_auth( @@ -1079,7 +1223,7 @@ pub(crate) async fn login_client_with_phone_code( /// 登出:先尝试服务端撤销,再无条件清掉本地凭据与本进程会话。 #[tauri::command] -pub(crate) async fn logout_client_session(app: tauri::AppHandle) -> Result<(), String> { +pub(crate) async fn logout_client_session(app: tauri::AppHandle) -> Result<(), ClientAuthError> { let origin = current_session_origin() .or_else(|| { auth_state() @@ -1150,33 +1294,43 @@ mod tests { } #[test] - fn session_routes_classify_401_403_as_authority_failures() { - assert_eq!(auth_route_kind("/api/auth/refresh"), AuthRouteKind::Session); - assert_eq!(auth_route_kind("/api/auth/me"), AuthRouteKind::Session); - assert_eq!(auth_route_kind("/api/auth/entry"), AuthRouteKind::Login); + fn routes_are_classified_by_their_concrete_endpoint() { + assert_eq!(auth_route("/api/auth/refresh"), AuthRoute::Session); + assert_eq!(auth_route("/api/auth/me"), AuthRoute::Session); + assert_eq!(auth_route("/api/auth/entry"), AuthRoute::PasswordEntry); + assert_eq!(auth_route("/api/auth/phone/login"), AuthRoute::PhoneLogin); + assert_eq!(auth_route("/api/auth/phone/send-code"), AuthRoute::SendCode); + assert_eq!(auth_route("/api/other"), AuthRoute::Other); + } - assert!(is_authority_failure(&map_auth_failure( + #[test] + fn session_routes_classify_401_403_as_authority_failures() { + assert!(map_auth_failure( StatusCode::UNAUTHORIZED, "{}", "刷新失败", - AuthRouteKind::Session - ))); - assert!(is_authority_failure(&map_auth_failure( + AuthRoute::Session + ) + .is_authority_failure()); + assert!(map_auth_failure( StatusCode::FORBIDDEN, r#"{"error":{"message":"无权"}}"#, "刷新失败", - AuthRouteKind::Session - ))); + AuthRoute::Session + ) + .is_authority_failure()); let transient = map_auth_failure( StatusCode::INTERNAL_SERVER_ERROR, "{}", "刷新失败", - AuthRouteKind::Session, + AuthRoute::Session, ); - assert!(!is_authority_failure(&transient)); - assert!(transient.starts_with("刷新失败")); - assert!(!is_authority_failure(AUTH_NETWORK_ERROR)); - assert!(is_authority_failure(AUTH_AUTHORITY_ERROR)); + assert!(!transient.is_authority_failure()); + assert!(matches!( + transient, + ClientAuthError::AuthServiceUnavailable { status: 500, .. } + )); + assert!(transient.message().starts_with("刷新失败")); } #[test] @@ -1185,18 +1339,65 @@ mod tests { StatusCode::UNAUTHORIZED, r#"{"error":{"message":"手机号或密码错误"}}"#, "登录失败", - AuthRouteKind::Login, + AuthRoute::PasswordEntry, ); - assert_eq!(wrong_password, "手机号或密码错误"); - assert!(!is_authority_failure(&wrong_password)); + assert_eq!( + wrong_password, + ClientAuthError::PhoneOrPasswordMismatch { + message: "手机号或密码错误".to_string() + } + ); + assert!(!wrong_password.is_authority_failure()); let missing_reason = map_auth_failure( StatusCode::UNAUTHORIZED, "{}", "登录失败", - AuthRouteKind::Login, + AuthRoute::PasswordEntry, ); - assert_eq!(missing_reason, "登录失败"); + assert_eq!(missing_reason.message(), "登录失败"); + } + + #[test] + fn input_rejections_and_throttling_keep_the_server_text() { + let password_length = map_auth_failure( + StatusCode::BAD_REQUEST, + r#"{"error":{"message":"密码长度需要在 6 到 128 位之间"}}"#, + "登录失败", + AuthRoute::PasswordEntry, + ); + assert!(matches!( + password_length, + ClientAuthError::PasswordEntryInputRejected { .. } + )); + assert_eq!( + password_length.message(), + "登录失败:密码长度需要在 6 到 128 位之间" + ); + + let throttled = map_auth_failure( + StatusCode::TOO_MANY_REQUESTS, + r#"{"error":{"message":"发送过于频繁"}}"#, + "发送验证码失败", + AuthRoute::SendCode, + ); + assert!(matches!( + throttled, + ClientAuthError::SmsCodeThrottled { .. } + )); + assert_eq!(throttled.message(), "发送验证码失败:发送过于频繁"); + + let bad_code = map_auth_failure( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"验证码错误"}}"#, + "登录失败", + AuthRoute::PhoneLogin, + ); + assert!(matches!( + bad_code, + ClientAuthError::SmsCodeInvalidOrExpired { .. } + )); + assert_eq!(bad_code.message(), "验证码错误"); } #[test] @@ -1397,7 +1598,7 @@ mod tests { // 空主体必须在登录路径就以登录语义失败:安装会话时的「陶泥儿登录用户身份无效」 // 是内部不变式,不是用户能理解的登录失败原因。 assert_eq!( - validated_session_user_id(" ").unwrap_err(), + validated_session_user_id(" ").unwrap_err().message(), "登录失败:登录服务未返回用户身份" ); } @@ -1456,7 +1657,10 @@ mod tests { .expect("login response fixture"); // 没有主体的登录响应在写凭据文件之前就以登录语义失败关闭。 assert_eq!( - SessionIdentity::Login(payload.user).resolve().unwrap_err(), + SessionIdentity::Login(payload.user) + .resolve() + .unwrap_err() + .message(), "登录失败:登录服务未返回用户身份" ); // 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 31d9323e1..582c14fd9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -118,6 +118,7 @@ mod agent_native_tools; mod analytics; mod asset_generation_tasks; mod assets; +mod auth_error; mod auth_session; mod browser; mod builtin_plugins; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts new file mode 100644 index 000000000..c80d66ae0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 变体名就是线上的分流键(`type`)。 + */ +export type ClientAuthError = + | { type: 'serverAddressRejected'; message: string } + | { type: 'phoneNumberInvalid'; message: string } + | { type: 'passwordMissing'; message: string } + | { type: 'loginCodeMissing'; message: string } + | { type: 'passwordEntryInputRejected'; message: string } + | { type: 'phoneOrPasswordMismatch'; message: string } + | { type: 'sendCodeInputRejected'; message: string } + | { type: 'smsCodeThrottled'; message: string } + | { type: 'phoneLoginInputRejected'; message: string } + | { type: 'smsCodeInvalidOrExpired'; message: string } + | { type: 'sessionAuthorityRejected'; message: string } + | { type: 'permissionDenied'; message: string } + | { type: 'authNetworkUnavailable'; message: string } + | { type: 'authServiceUnavailable'; status: number; message: string } + | { type: 'unexpectedRejection'; status: number; message: string } + | { type: 'authResponseMalformed'; message: string } + | { type: 'clientSessionPersistFailed'; message: string } + | { type: 'runtimeSessionInstallFailed'; message: string } + | { type: 'authClientInitFailed'; message: string }; -- 2.52.0 From bb9b0b4d6c409c3862dc7c44ea67c912f7a80791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 15:24:42 +0800 Subject: [PATCH 03/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=EF=BC=9AAGC=20=E6=8A=A5=E5=91=8A=E6=B1=A0=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E8=B0=83=E7=94=A8=E6=96=B9=E5=B8=A6=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E4=BA=A4=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修订 ADR §2/§3:真故障由调用方包成 ClientActionError 交给错误池,不再把 rejection 留在无人接手的 Promise 上 - 说明 408/5xx/网络判定发生在调用方 catch,shouldCaptureClientError 随本 ADR 删除 - 同步技术方案、decision-log、pitfalls 的重抛口径与影响文件清单 --- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 25 +++++++++++-------- .../shared-memory/decision-log.md | 6 ++--- docs/project-memory/shared-memory/pitfalls.md | 4 +-- ...€术方案】AGC错误报告与诊断上传-2026-08-31.md | 2 +- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 319d4002c..e896f0188 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -45,19 +45,24 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 谁抛出、谁判定。分层规则: - **预期业务拒绝**(用户输入、前置条件、预期 4xx):由调用方消化并给用户反馈,**永不进池**。 -- **真故障**(网络不可达、5xx、写盘/运行时安装失败、agent 终态失败):由调用方带上文重抛, - 经 `window.onerror` / `unhandledrejection` 入池;Rust 侧 agent 终态失败仍由失败投影入池。 +- **真故障**(网络不可达、5xx、写盘/运行时安装失败、agent 终态失败):由调用方带上文交给错误池 + (`ClientActionError` + `captureClientError`);`window.onerror` / `unhandledrejection` 只兜底 + 没人接手的错误。Rust 侧 agent 终态失败仍由失败投影入池。 - **WebView 全局 handler 是兜底**:任何没人 catch 的错误都进池。 -- **API 客户端(`clientApi`)在抛出前判定 408/5xx/网络为缺陷**:它是 `fetch` 的调用方, - 这一判定就发生在它这一层;4xx 一律不报,交给上层调用方。这条边界保持现状,不放宽也不收紧。 +- **408/5xx/网络的判定由调用方在 catch 里做**:AGC shell 的 WebView 侧没有 fetch 边界的自动判定 + (`shouldCaptureClientError` 只认测试构造过、生产代码从不产生的 `{status}` / `{networkError}` + 形状,随本 ADR 删除);4xx 一律不报,交给上层调用方。 -### 3. 前端按变体分流,认不出就抛 +### 3. 前端按变体分流,认不出就交池 -- `isClientAuthError` 只做形状读取(`type` 是稳定判别键),`clientAuthErrorNotice` 用 - `switch (error.type)` 给出可展示文案;`default → null` 表示"认不出"。 -- 认不出、系统类、非结构化拒绝 → `throw new ClientActionError(message, context, cause)`; - `ClientActionError` 只承载 `source/action/page` 上下文与 `cause`,由全局 handler 用 - `instanceof` 解包后入池(指纹/展示字段与今天一致)。 +- `isClientAuthError` 只做形状读取(`type` 是稳定判别键),`clientAuthErrorKind` 用 + `switch (error.type)` 给出 `input` / `session` / `fault` 三类;`fault` 表示"认不出或宿主/环境事实"。 +- 认不出、系统类、非结构化拒绝 → 调用方包成 `ClientActionError(message, context, cause)` 交给 + `captureClientError`:`instanceof` 解包 `context` 与 `cause`,指纹/展示字段与今天一致。 + **不把 rejection 留在没人接手的 Promise 上**:`onSubmit` / `onClick` 这类 `void` 掉的 handler 抛错 + 最终以 `unhandledrejection` 结算,生产 WebView 里也能入池,但在 jsdom 下既不触发 `window` + 的 `unhandledrejection` 事件、又会让 `vitest run` 以 unhandled error 失败;调用方判定完直接 + 交给错误池,语义相同、可断言。全局 handler 仍保留同一套解包,接住真正漏出的 `ClientActionError`。 - 删除 `shouldCaptureClientError`:不再存在"叶子自己判定要不要报"的口径。 ### 4. 报告面板与通知行为不变 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 97fb31948..38e3d5b51 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,9 +3,9 @@ ## 2026-10-01 AGC 命令错误结构化与错误报告口径 - 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端只按 `type` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。 -- 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文重抛(`ClientActionError` 承载 `source/action/page`),经 `window.onerror` / `unhandledrejection` 入池;`clientApi` 作为 `fetch` 的调用方在抛出前判定 408/5xx/网络为缺陷(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。 -- 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordEntryInputRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径重抛/删除留在后续变更。 -- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.ts,errorReporting.ts,clientApi.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts`(ts-rs 生成)。 +- 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文交给错误池(`ClientActionError` 承载 `source/action/page`,`captureClientError` 用 `instanceof` 解包),`window.onerror` / `unhandledrejection` 只兜底没人接手的错误;408/5xx/网络的判定由调用方在 catch 里做(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。 +- 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordEntryInputRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径改造或删除留在后续变更。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts`(ts-rs 生成)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 ## 2026-09-30 release 渠道移除产品名与包名后缀 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 1d7122faa..763312568 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6,8 +6,8 @@ - **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。 - **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 -- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),UI 调用方按 `type` 分流:认得的业务变体只给用户反馈,系统变体/未识别变体/非结构化拒绝原样抛出走上报链路;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)。 -- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/errorReporting.test.ts`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体经 `unhandledrejection` 只上报一次且 `source=auth`。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 +- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),UI 调用方按 `type` 分流:认得的业务变体只给用户反馈,系统变体/未识别变体/非结构化拒绝由调用方包成 `ClientActionError` 交给错误池;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)。 +- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/errorReporting.test.ts`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体带上文只上报一次且 `source=auth`。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/errorReporting.ts`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 ## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index cd4bf8002..21c23cfe0 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -8,7 +8,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 诊断 URL 保留可定位的 API 路由路径,隐藏 origin、URL 账号密码、查询参数、fragment 和路径中的敏感标识;普通资源 URL 与本地文件路径继续隐藏。网络错误、HTTP 错误与响应体超时均应带安全路由,不能只剩 ``。历史已经脱敏的归档不推测或补造原路由。 -- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文重抛的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)。 +- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientActionError`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 - 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。 -- 2.52.0 From 5013f4385f648c9fc047218f647e24006029497f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 15:27:31 +0800 Subject: [PATCH 04/70] =?UTF-8?q?AGC=20=E5=89=8D=E7=AB=AF=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=88=86=E6=B5=81=E5=9F=BA=E5=85=83=EF=BC=9AClientAct?= =?UTF-8?q?ionError=20=E4=B8=8E=E8=AE=A4=E8=AF=81=E5=8F=98=E4=BD=93?= =?UTF-8?q?=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 clientActionError.ts:ClientActionError 承载 source/action/page 上下文与 cause - 新增 clientAuthError.ts:isClientAuthError 形状读取 + clientAuthErrorKind 的 input/session/fault 分类 + clientAuthErrorNotice 文案 - captureClientError 解包 ClientActionError 的上下文,并优先使用 cause 的栈信息 - getClientAuthErrorMessage 认识结构化拒绝,非 Error 形状不再字符串化成 [object Object] - 新增 clientAuthError.test.ts,并在 errorReporting.test.ts 用解包用例替换 shouldCaptureClientError 用例 --- .../src/services/clientActionError.ts | 32 +++++++ .../src/services/clientAuth.ts | 14 ++- .../src/services/clientAuthError.ts | 59 ++++++++++++ .../src/services/errorReporting.ts | 23 +++-- .../tests/clientAuthError.test.ts | 89 +++++++++++++++++++ .../tests/errorReporting.test.ts | 29 ++++-- 6 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/services/clientActionError.ts create mode 100644 apps/ai-game-creator-shell/src/services/clientAuthError.ts create mode 100644 apps/ai-game-creator-shell/tests/clientAuthError.test.ts diff --git a/apps/ai-game-creator-shell/src/services/clientActionError.ts b/apps/ai-game-creator-shell/src/services/clientActionError.ts new file mode 100644 index 000000000..2e19e38f6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientActionError.ts @@ -0,0 +1,32 @@ +/** + * 错误上报上下文:`source` 是错误池的一级维度,`action` / `page` 用于细分指纹。 + * + * 含义与 [`captureClientError`](./errorReporting.ts) 的入参完全一致。 + */ +export type ClientErrorReportContext = { + source: string; + action?: string; + page?: string; +}; + +/** + * 调用方判定「这是真故障 / 认不出」时的统一载体:`message` 是可展示文案,`context` 决定错误池的 + * 指纹维度,`cause` 保留原始拒绝值(Rust 的结构化 `ClientAuthError`、裸字符串或 `Error`)。 + * + * 分流只看类型化的变体,**不要用文案判断**。`captureClientError` 用 `instanceof` 解包 + * `context` 与 `cause`,所以它与显式调用 `captureClientError(error, context)` 的指纹、展示字段一致; + * 需要"带上文继续抛出"的调用方也可以直接 `throw` 它。 + */ +export class ClientActionError extends Error { + readonly context: ClientErrorReportContext; + + constructor( + message: string, + context: ClientErrorReportContext, + cause?: unknown, + ) { + super(message, { cause }); + this.name = 'ClientActionError'; + this.context = context; + } +} diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index acbe37ed3..b1840ab85 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,5 +1,6 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; +import { isClientAuthError } from './clientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; /** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */ @@ -36,10 +37,19 @@ function requireInvoke() { return invoke; } +/** + * 从任意拒绝值里取一条可显示文案。 + * + * 命令失败现在是结构化的(`ClientAuthError`),这里必须按形状取 Rust 那一份文案;裸字符串 + * 是旧的 `Err(String)` 残留与浏览器环境的形态。**非 Error 的其它形状不再做字符串化**—— + * `String({type,message})` 只会得到 `[object Object]`,把它当文案显示比回落更糟。 + */ export function getClientAuthErrorMessage(error: unknown, fallback: string) { + const structured = isClientAuthError(error); + if (structured) return structured.message.trim() || fallback; if (error instanceof Error && error.message.trim()) return error.message; - const message = String(error ?? '').trim(); - return message || fallback; + if (typeof error === 'string') return error.trim() || fallback; + return fallback; } type RustAuthStateView = { diff --git a/apps/ai-game-creator-shell/src/services/clientAuthError.ts b/apps/ai-game-creator-shell/src/services/clientAuthError.ts new file mode 100644 index 000000000..741e1ce6d --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientAuthError.ts @@ -0,0 +1,59 @@ +import type { ClientAuthError } from './generated/ClientAuthError'; + +export type { ClientAuthError }; + +/** + * `invoke` 拒绝时拿到的是 Rust 序列化出来的普通对象(不是 `Error`)。这里只做形状读取: + * `type` 是稳定判别键,`message` 是 Rust 生成的可展示文案,**文案不参与任何判断**。 + * + * 不在名单里的 `type` 也算"形状合法":新变体会落到 [`clientAuthErrorKind`] 的 `fault`, + * 由调用方交给错误池——这是故意的,见 ADR 的"未识别变体上调"。 + */ +export function isClientAuthError(value: unknown): ClientAuthError | null { + if (!value || typeof value !== 'object') return null; + const candidate = value as { type?: unknown; message?: unknown }; + if (typeof candidate.type !== 'string' || !candidate.type) return null; + if (typeof candidate.message !== 'string') return null; + return value as ClientAuthError; +} + +/** + * 变体分流的稳定分类,与 Rust `ClientAuthError` 的三段注释一一对应: + * + * - `input`:用户自己能改的输入 / 前置条件(登录 400/401、发码 429 等),调用方给提示后消化掉。 + * - `session`:会话路由 401/403,调用方按"未登录"处理,不报错也不进池。 + * - `fault`:网络 / 5xx / 写盘 / 运行时 / 响应不合法,以及**未识别变体**,由调用方交给错误池。 + */ +export type ClientAuthErrorKind = 'input' | 'session' | 'fault'; + +export function clientAuthErrorKind( + error: ClientAuthError, +): ClientAuthErrorKind { + switch (error.type) { + case 'serverAddressRejected': + case 'phoneNumberInvalid': + case 'passwordMissing': + case 'loginCodeMissing': + case 'passwordEntryInputRejected': + case 'phoneOrPasswordMismatch': + case 'sendCodeInputRejected': + case 'smsCodeThrottled': + case 'phoneLoginInputRejected': + case 'smsCodeInvalidOrExpired': + return 'input'; + case 'sessionAuthorityRejected': + case 'permissionDenied': + return 'session'; + default: + return 'fault'; + } +} + +/** + * 用户可读的提示:`input` / `session` 原样使用 Rust 生成的那一份文案;`fault` 返回 `null`, + * 表示"调用方处理不了,交给错误池"。空文案按"没有提示"处理,避免调用方拿空串当提示显示。 + */ +export function clientAuthErrorNotice(error: ClientAuthError): string | null { + if (clientAuthErrorKind(error) === 'fault') return null; + return error.message.trim() || null; +} diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index e7acc5329..7348b51bf 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -1,5 +1,9 @@ import { invoke } from '@tauri-apps/api/core'; +import { + ClientActionError, + type ClientErrorReportContext, +} from './clientActionError'; import { ackErrorReports, getPendingErrorReports, @@ -84,17 +88,26 @@ export function normalizeDiagnosticText(value: string) { export async function captureClientError( error: unknown, - context: { source?: string; action?: string; page?: string } = {}, + context: Partial = {}, ) { + // 调用方"带上文重抛"时用 ClientActionError 承载上下文与原始拒绝值;显式传参作为兜底。 + const actionError = error instanceof ClientActionError ? error : null; const errorValue = error instanceof Error ? error : new Error(String(error)); const message = errorValue.message || '未知客户端错误'; - const stack = errorValue.stack ? errorValue.stack.slice(0, 8_000) : undefined; + // 原始失败的栈信息比包装点更有诊断价值(包装点只是 catch 的位置)。 + const stackSource = + actionError?.cause instanceof Error && actionError.cause.stack + ? actionError.cause + : errorValue; + const stack = stackSource.stack + ? stackSource.stack.slice(0, 8_000) + : undefined; return reportClientError({ - source: context.source ?? 'client', + source: actionError?.context.source ?? context.source ?? 'client', message, stack, - action: context.action, - page: context.page, + action: actionError?.context.action ?? context.action, + page: actionError?.context.page ?? context.page, }).catch(() => undefined); } diff --git a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts new file mode 100644 index 000000000..cfaadde96 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + clientAuthErrorKind, + clientAuthErrorNotice, + isClientAuthError, +} from '../src/services/clientAuthError'; + +describe('AGC 认证命令错误分流', () => { + it('只按形状读取 Rust 的结构化拒绝,裸字符串与 Error 都不算', () => { + expect( + isClientAuthError({ + type: 'phoneOrPasswordMismatch', + message: '手机号或密码错误', + }), + ).toEqual({ type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }); + expect(isClientAuthError('手机号或密码错误')).toBeNull(); + expect(isClientAuthError(new Error('手机号或密码错误'))).toBeNull(); + expect( + isClientAuthError({ type: '', message: '手机号或密码错误' }), + ).toBeNull(); + expect(isClientAuthError({ type: 'phoneNumberInvalid' })).toBeNull(); + expect(isClientAuthError(null)).toBeNull(); + }); + + it('业务输入给提示、会话变体按未登录、系统与未识别变体交给错误池', () => { + expect( + clientAuthErrorKind({ + type: 'passwordEntryInputRejected', + message: '密码长度需要在 6 到 128 位之间', + }), + ).toBe('input'); + expect( + clientAuthErrorKind({ + type: 'phoneOrPasswordMismatch', + message: '手机号或密码错误', + }), + ).toBe('input'); + expect( + clientAuthErrorKind({ + type: 'sessionAuthorityRejected', + message: '登录已失效,请重新登录', + }), + ).toBe('session'); + expect( + clientAuthErrorKind({ + type: 'authNetworkUnavailable', + message: '无法连接登录服务', + }), + ).toBe('fault'); + expect( + clientAuthErrorKind({ + type: 'runtimeSessionInstallFailed', + message: '本机运行时会话安装失败', + }), + ).toBe('fault'); + // 未识别变体按 fault 处理:Rust 加了变体但界面没接,属于缺陷,必须进池。 + expect( + clientAuthErrorKind( + isClientAuthError({ type: 'brandNewRejection', message: '新变体' })!, + ), + ).toBe('fault'); + }); + + it('input / session 原样使用 Rust 文案,fault 与空文案不给提示', () => { + expect( + clientAuthErrorNotice({ + type: 'phoneOrPasswordMismatch', + message: '手机号或密码错误', + }), + ).toBe('手机号或密码错误'); + expect( + clientAuthErrorNotice({ + type: 'sessionAuthorityRejected', + message: '登录已失效,请重新登录', + }), + ).toBe('登录已失效,请重新登录'); + expect( + clientAuthErrorNotice({ + type: 'authServiceUnavailable', + status: 503, + message: '登录服务暂时不可用', + }), + ).toBeNull(); + expect( + clientAuthErrorNotice({ type: 'phoneNumberInvalid', message: ' ' }), + ).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index ec1788898..304e2e03e 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -71,6 +71,7 @@ vi.mock('@tauri-apps/api/core', () => ({ })); import { invoke } from '@tauri-apps/api/core'; +import { ClientActionError } from '../src/services/clientActionError'; import { ackClientErrorEventsWithRetry, captureAgentRuntimeError, @@ -80,7 +81,6 @@ import { markClientErrorEventsSubmitted, normalizeDiagnosticText, resetClientErrorEventsForTests, - shouldCaptureClientError, submitErrorReportBatch, subscribeClientErrorEvents, } from '../src/services/errorReporting'; @@ -209,13 +209,26 @@ describe('客户端错误报告池', () => { expect(await getPendingClientErrorEvents()).toEqual([event]); }); - it('只采集网络错误、408 和 5xx', () => { - expect(shouldCaptureClientError({ status: 400 })).toBe(false); - expect(shouldCaptureClientError({ status: 401 })).toBe(false); - expect(shouldCaptureClientError({ status: 429 })).toBe(false); - expect(shouldCaptureClientError({ status: 408 })).toBe(true); - expect(shouldCaptureClientError({ status: 503 })).toBe(true); - expect(shouldCaptureClientError({ networkError: true })).toBe(true); + it('解包调用方补的上报上下文与原始错误', async () => { + const original = new Error('无法连接登录服务,请确认网络后重试'); + + await captureClientError( + new ClientActionError( + '无法连接登录服务,请确认网络后重试', + { source: 'auth', action: 'login' }, + original, + ), + // 显式入参是兜底:ClientActionError 自带的上下文优先。 + { source: 'unhandledrejection' }, + ); + + expect(invoke).toHaveBeenCalledWith('report_client_error', { + source: 'auth', + message: '无法连接登录服务,请确认网络后重试', + stack: original.stack, + action: 'login', + page: undefined, + }); }); it('保留 API 路由但隐藏 URL origin 与查询参数', () => { -- 2.52.0 From b61fa52e8fdd4955c58dad05b3416e1ca4b79f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 15:33:47 +0800 Subject: [PATCH 05/70] =?UTF-8?q?AGC=20=E7=99=BB=E5=BD=95=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=8C=89=E7=B1=BB=E5=9E=8B=E5=8C=96=E5=8F=98=E4=BD=93?= =?UTF-8?q?=E5=88=86=E6=B5=81=EF=BC=9A=E4=B8=9A=E5=8A=A1=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=BF=9B=E9=94=99=E8=AF=AF=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthenticatedClient 登录/发码 catch 改用 resolveClientAuthFailure:input/session 变体只给提示,fault 变体与未识别拒绝包成 ClientActionError 交给错误池 - clientAuth.ts 新增 resolveClientAuthFailure,判定只看结构化 type,不做文案匹配 - 删除 errorReporting.ts 的 shouldCaptureClientError,不再存在叶子自行判定要不要上报的口径 - clientAuthHost.test.ts 新增分流用例,auth.suite.ts 的登录失败用例改为结构化拒绝并断言提示文案 --- .../src/app/AuthenticatedClient.tsx | 37 ++++++++++---- .../src/services/clientAuth.ts | 26 +++++++++- .../src/services/errorReporting.ts | 10 ---- .../tests/appSurface/auth.suite.ts | 14 +++--- .../tests/clientAuthHost.test.ts | 50 +++++++++++++++++++ 5 files changed, 110 insertions(+), 27 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 947a8c4ac..1f8d92924 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -11,6 +11,7 @@ import { import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice'; +import { ClientActionError } from '../services/clientActionError'; import { getClientAuthErrorMessage, loginClientWithPassword, @@ -18,6 +19,7 @@ import { logoutClientAuthSession, normalizeAuthPhoneInput, readClientAuthState, + resolveClientAuthFailure, sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; @@ -34,7 +36,6 @@ import { import { captureClientError, installWebviewLogBridge, - shouldCaptureClientError, } from '../services/errorReporting'; import { beginPlatformSessionClearTransition, @@ -347,12 +348,18 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { - if (shouldCaptureClientError(error)) - void captureClientError(error, { - source: 'auth', - action: 'send-login-code', - }); - setLoginStatus(error instanceof Error ? error.message : String(error)); + // 业务拒绝(手机号格式 / 发码限流等)只给提示;系统变体与未识别变体带上文交给错误池。 + const failure = resolveClientAuthFailure(error, '发送验证码失败'); + setLoginStatus(failure.message); + if (failure.kind === 'report') { + void captureClientError( + new ClientActionError( + failure.message, + { source: 'auth', action: 'send-login-code' }, + error, + ), + ); + } } finally { setCodeBusy(false); } @@ -417,9 +424,19 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - if (shouldCaptureClientError(error)) - void captureClientError(error, { source: 'auth', action: 'login' }); - setLoginStatus(getClientAuthErrorMessage(error, '登录失败')); + // 预期业务拒绝(密码输错 / 密码长度不合规 / 手机号格式等)在这里消化掉,永不进错误池; + // 真故障与未识别变体带上文交给错误池,指纹仍与显式采集时一致。 + const failure = resolveClientAuthFailure(error, '登录失败'); + setLoginStatus(failure.message); + if (failure.kind === 'report') { + void captureClientError( + new ClientActionError( + failure.message, + { source: 'auth', action: 'login' }, + error, + ), + ); + } } finally { setLoginBusy(false); } diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index b1840ab85..2af0f6ce7 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,6 +1,6 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; -import { isClientAuthError } from './clientAuthError'; +import { clientAuthErrorNotice, isClientAuthError } from './clientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; /** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */ @@ -52,6 +52,30 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { return fallback; } +/** + * 登录 / 发码失败的调用方分流:`notice` 是"用户自己能改或本来就该按未登录处理"的变体, + * 只显示 Rust 文案;`report` 是系统变体 / 未识别变体 / 非结构化拒绝,显示文案之外还要带上下文 + * 交给错误池(见 [`captureClientError`](./errorReporting.ts))。 + * + * 判定只看类型化的 `type`,不做任何文案匹配;这条规则是"输错一次密码不该被引导上报"的判据。 + */ +export type ClientAuthFailureResolution = + | { kind: 'notice'; message: string } + | { kind: 'report'; message: string }; + +export function resolveClientAuthFailure( + error: unknown, + fallback: string, +): ClientAuthFailureResolution { + const structured = isClientAuthError(error); + const notice = structured ? clientAuthErrorNotice(structured) : null; + if (notice !== null) return { kind: 'notice', message: notice }; + return { + kind: 'report', + message: getClientAuthErrorMessage(error, fallback), + }; +} + type RustAuthStateView = { status?: string; user?: AuthUser | null; diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 7348b51bf..080bb2e4a 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -25,16 +25,6 @@ export type DiagnosticLogFile = { name: string; content: string }; type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log'; -export function shouldCaptureClientError(error: unknown) { - if (!error || typeof error !== 'object') return true; - const candidate = error as { status?: unknown; networkError?: unknown }; - if (candidate.networkError === true) return true; - if (typeof candidate.status === 'number') { - return candidate.status === 408 || candidate.status >= 500; - } - return true; -} - export async function invokeDiagnostic( invokeFn: (command: string, args?: Record) => Promise, command: string, diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index a12fc9b44..8fc0df66a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -228,13 +228,14 @@ export function registerAuthTests() { }); }); - it('shows the backend reason when the password login is rejected', async () => { + it('shows the typed business reason when the password login is rejected', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'read_client_auth_state') { return { status: 'unauthenticated' }; } if (command === 'login_client_with_password') { - throw new Error('手机号或密码错误'); + // Rust 命令的结构化拒绝:前端只按 `type` 分流,不解析文案。 + throw { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }; } return null; }); @@ -259,9 +260,10 @@ export function registerAuthTests() { return { status: 'unauthenticated' }; } if (command === 'login_client_with_phone_code') { - throw new Error( - 'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - ); + throw { + type: 'authNetworkUnavailable', + message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + }; } return null; }); @@ -270,7 +272,7 @@ export function registerAuthTests() { expect( await screen.findByText( - 'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', ), ).not.toBeNull(); expect(document.body.textContent).not.toContain('ECONNREFUSED'); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 4b10a4a2f..68d8a612a 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -17,6 +17,7 @@ import { normalizeAuthPhoneInput, readClientAuthState, refreshClientAuthSession, + resolveClientAuthFailure, sendClientPhoneLoginCode, } from '../src/services/clientAuth'; @@ -185,3 +186,52 @@ test('错误文案优先使用服务端原因,缺失时回落到调用方文 ).toBe('手机号或密码错误'); expect(getClientAuthErrorMessage('', '登录失败')).toBe('登录失败'); }); + +test('登录失败分流只看类型化变体:业务拒绝给提示,系统与未识别变体才进池', () => { + expect( + resolveClientAuthFailure( + { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }, + '登录失败', + ), + ).toEqual({ kind: 'notice', message: '手机号或密码错误' }); + expect( + resolveClientAuthFailure( + { + type: 'passwordEntryInputRejected', + message: '密码长度需要在 6 到 128 位之间', + }, + '登录失败', + ), + ).toEqual({ kind: 'notice', message: '密码长度需要在 6 到 128 位之间' }); + // 会话 401/403 按"未登录"处理:给提示,但不进错误池。 + expect( + resolveClientAuthFailure( + { type: 'sessionAuthorityRejected', message: '登录已失效,请重新登录' }, + '登录失败', + ), + ).toEqual({ kind: 'notice', message: '登录已失效,请重新登录' }); + // 系统变体与未识别变体:显示 Rust 文案,同时交给错误池。 + expect( + resolveClientAuthFailure( + { type: 'authNetworkUnavailable', message: '无法连接登录服务' }, + '登录失败', + ), + ).toEqual({ kind: 'report', message: '无法连接登录服务' }); + expect( + resolveClientAuthFailure( + { type: 'brandNewRejection', message: '新变体' }, + '登录失败', + ), + ).toEqual({ kind: 'report', message: '新变体' }); + // 非结构化:裸字符串保留原文,其它形状回落调用方文案,不显示 `[object Object]`。 + expect( + resolveClientAuthFailure('network-error: 连接超时', '登录失败'), + ).toEqual({ kind: 'report', message: 'network-error: 连接超时' }); + expect( + resolveClientAuthFailure(new Error('runner clear rejected'), '登录失败'), + ).toEqual({ kind: 'report', message: 'runner clear rejected' }); + expect(resolveClientAuthFailure({ status: 500 }, '登录失败')).toEqual({ + kind: 'report', + message: '登录失败', + }); +}); -- 2.52.0 From 8b50db989362984bc2b8e12bd7da33eee50f80f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 16:05:47 +0800 Subject: [PATCH 06/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=88=86=E6=B5=81=E6=8A=8A=E7=A9=BA=E6=96=87?= =?UTF-8?q?=E6=A1=88=E7=9A=84=E4=B8=9A=E5=8A=A1=E5=8F=98=E4=BD=93=E8=AF=AF?= =?UTF-8?q?=E5=88=A4=E4=B8=BA=E9=9C=80=E4=B8=8A=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveClientAuthFailure 改用 clientAuthErrorKind 判分流,不再用 clientAuthErrorNotice 是否为 null 当判据 - 空文案的 input/session 变体改为给兜底提示、不进错误池,保持"预期拒绝永不进池"的约束 - clientAuthErrorNotice 文档注明 null 同时表示 fault 与空文案,不能当分流判据 - clientAuthHost.test.ts 增加空文案业务变体的分流用例 --- .../src/services/clientAuth.ts | 21 +++++++++++++++---- .../src/services/clientAuthError.ts | 5 ++++- .../tests/clientAuthHost.test.ts | 8 +++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 2af0f6ce7..04c60af19 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,6 +1,10 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; -import { clientAuthErrorNotice, isClientAuthError } from './clientAuthError'; +import { + clientAuthErrorKind, + clientAuthErrorNotice, + isClientAuthError, +} from './clientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; /** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */ @@ -57,7 +61,8 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { * 只显示 Rust 文案;`report` 是系统变体 / 未识别变体 / 非结构化拒绝,显示文案之外还要带上下文 * 交给错误池(见 [`captureClientError`](./errorReporting.ts))。 * - * 判定只看类型化的 `type`,不做任何文案匹配;这条规则是"输错一次密码不该被引导上报"的判据。 + * 判定只看类型化的 `type`(`clientAuthErrorKind`),不做任何文案匹配;这条规则是 + * "输错一次密码不该被引导上报"的判据。 */ export type ClientAuthFailureResolution = | { kind: 'notice'; message: string } @@ -68,8 +73,16 @@ export function resolveClientAuthFailure( fallback: string, ): ClientAuthFailureResolution { const structured = isClientAuthError(error); - const notice = structured ? clientAuthErrorNotice(structured) : null; - if (notice !== null) return { kind: 'notice', message: notice }; + // 分流判据是 `clientAuthErrorKind`,不是"有没有提示":`clientAuthErrorNotice` 对空文案也返回 + // `null`,拿它当判据会把"Rust 给了可识别的 input/session 变体、但文案为空"错当成 fault 上报。 + if (structured && clientAuthErrorKind(structured) !== 'fault') { + return { + kind: 'notice', + message: + clientAuthErrorNotice(structured) ?? + getClientAuthErrorMessage(error, fallback), + }; + } return { kind: 'report', message: getClientAuthErrorMessage(error, fallback), diff --git a/apps/ai-game-creator-shell/src/services/clientAuthError.ts b/apps/ai-game-creator-shell/src/services/clientAuthError.ts index 741e1ce6d..94797fd16 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuthError.ts @@ -51,7 +51,10 @@ export function clientAuthErrorKind( /** * 用户可读的提示:`input` / `session` 原样使用 Rust 生成的那一份文案;`fault` 返回 `null`, - * 表示"调用方处理不了,交给错误池"。空文案按"没有提示"处理,避免调用方拿空串当提示显示。 + * 表示"调用方处理不了,交给错误池"。空文案也按"没有提示"处理,避免调用方拿空串当提示显示。 + * + * **`null` 同时表示这两件事,所以它不是分流判据**:判定"要不要进错误池"必须用 + * [`clientAuthErrorKind`],只有 `fault` 才上报;否则一个文案为空的可识别变体会被误报。 */ export function clientAuthErrorNotice(error: ClientAuthError): string | null { if (clientAuthErrorKind(error) === 'fault') return null; diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 68d8a612a..2d2d51493 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -210,6 +210,14 @@ test('登录失败分流只看类型化变体:业务拒绝给提示,系统 '登录失败', ), ).toEqual({ kind: 'notice', message: '登录已失效,请重新登录' }); + // 文案为空的可识别变体仍按"用户可改"处理:给兜底提示,不进错误池 + // (分流判据是 kind,不是"有没有提示文案")。 + expect( + resolveClientAuthFailure( + { type: 'phoneNumberInvalid', message: ' ' }, + '登录失败', + ), + ).toEqual({ kind: 'notice', message: '登录失败' }); // 系统变体与未识别变体:显示 Rust 文案,同时交给错误池。 expect( resolveClientAuthFailure( -- 2.52.0 From 90d37481a4a086365a276432e1cb911342a88a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 16:06:12 +0800 Subject: [PATCH 07/70] =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=BE=84=E6=B8=85?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E9=94=99=E8=AF=AF=E5=BD=A2=E7=8A=B6=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E4=B8=8D=E6=A0=A1=E9=AA=8C=20status=20=E6=98=AF?= =?UTF-8?q?=E6=9C=89=E6=84=8F=E4=B8=BA=E4=B9=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isClientAuthError 文档写明 status 故意不校验:无调用方读它,纳入校验会把可用文案换成兜底文案 - 需要 status 参与判断时先补校验再用 --- apps/ai-game-creator-shell/src/services/clientAuthError.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/ai-game-creator-shell/src/services/clientAuthError.ts b/apps/ai-game-creator-shell/src/services/clientAuthError.ts index 94797fd16..1ac31eaa0 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuthError.ts @@ -8,6 +8,11 @@ export type { ClientAuthError }; * * 不在名单里的 `type` 也算"形状合法":新变体会落到 [`clientAuthErrorKind`] 的 `fault`, * 由调用方交给错误池——这是故意的,见 ADR 的"未识别变体上调"。 + * + * **`status` 故意不校验**:`authServiceUnavailable` / `unexpectedRejection` 在生成类型里带必填 + * `status`,但全仓没有调用方读它(这两个变体只走 `fault` 分支显示 `message`)。把 `status` + * 纳入校验只会让"缺 `status` 但 `message` 可用"的拒绝落回非结构化分支,把 Rust 的文案换成 + * 调用方兜底文案——诊断信息更少、行为更差。将来真要用 `status` 做判断时,先补校验再用。 */ export function isClientAuthError(value: unknown): ClientAuthError | null { if (!value || typeof value !== 'object') return null; -- 2.52.0 From 5d5aafe251542e2b1ef7a0ae91558ad75ec936dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 16:39:14 +0800 Subject: [PATCH 08/70] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A0=E4=BA=BA?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E7=9A=84=E8=AE=A4=E8=AF=81=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=AD=97=E6=AE=B5=E4=B8=8E=20error=5Fkind=20?= =?UTF-8?q?=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 ClientAuthError::error_kind():该分类由本次改动自行发明,全仓无消费方 - 删除 ClientAuthStateView.error_kind 字段及 4 个赋值点(含 read_client_auth_state 里写死的 "network") - 删除 clientAuth.ts RustAuthStateView 的 errorKind 声明,该字段从未被读取 - 保留行为不变:失败仍以 error_message 投影,前端只看 status/user/apiBaseUrl/errorMessage --- .../src-tauri/src/auth_error.rs | 14 -------------- .../src-tauri/src/auth_session.rs | 7 ------- .../src/services/clientAuth.ts | 1 - 3 files changed, 22 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index a64308628..172a7372c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -102,20 +102,6 @@ impl ClientAuthError { Self::SessionAuthorityRejected { .. } | Self::PermissionDenied { .. } ) } - - /// 登录态投影里 `errorKind` 的取值:只描述"哪一类失败",不参与任何前端分支。 - pub(crate) fn error_kind(&self) -> &'static str { - match self { - Self::AuthNetworkUnavailable { .. } => "network", - Self::AuthServiceUnavailable { .. } | Self::UnexpectedRejection { .. } => "service", - Self::AuthResponseMalformed { .. } => "response", - Self::ClientSessionPersistFailed { .. } => "storage", - Self::RuntimeSessionInstallFailed { .. } | Self::AuthClientInitFailed { .. } => { - "runtime" - } - _ => "auth", - } - } } impl fmt::Display for ClientAuthError { diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index a1b76d1a9..c0f247999 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -49,9 +49,6 @@ pub(crate) struct ClientAuthStateView { pub(crate) user: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) api_base_url: Option, - /// 失败分类:`network` / `service` / `response` / `storage` / `runtime` / `auth`;成功或未登录时为 `None`。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) error_kind: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) error_message: Option, } @@ -785,7 +782,6 @@ fn auth_state_view( status: status.to_string(), user, api_base_url, - error_kind: None, error_message: None, } } @@ -1045,7 +1041,6 @@ pub(crate) async fn read_client_auth_state( status: "unavailable".to_string(), user: None, api_base_url: Some(session.api_base_url), - error_kind: Some("network".to_string()), error_message: refresh.error_message, }), }, @@ -1053,7 +1048,6 @@ pub(crate) async fn read_client_auth_state( status: "unavailable".to_string(), user: None, api_base_url: Some(session.api_base_url), - error_kind: Some(error.error_kind().to_string()), error_message: Some(error.message().to_string()), }), } @@ -1472,7 +1466,6 @@ mod tests { wechat_account: None, }), api_base_url: Some(DEVELOPMENT_ORIGIN.to_string()), - error_kind: None, error_message: None, }; let serialized = serde_json::to_string(&view).expect("serialize auth state"); diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 04c60af19..183730522 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -93,7 +93,6 @@ type RustAuthStateView = { status?: string; user?: AuthUser | null; apiBaseUrl?: string | null; - errorKind?: string | null; errorMessage?: string | null; }; -- 2.52.0 From b202c702716002a3789512550cc8d7fec1679dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 16:42:43 +0800 Subject: [PATCH 09/70] =?UTF-8?q?=E9=80=80=E5=BD=B9=E8=AF=8D=E8=A1=A8?= =?UTF-8?q?=EF=BC=9ADirectProject=20=E5=9F=9F=E4=B8=8D=E5=86=8D=E4=BD=BF?= =?UTF-8?q?=E7=94=A8"=E6=8B=92=E5=8D=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct_turn_error.rs 模块文档把"拒单载荷"改写为"入队失败载荷",与 2026-09-24 ADR 的词表一致 - agentRuntimeModel.test.ts 的用例名与注释同步改成"入队失败" --- .../src-tauri/src/agent/direct_turn_error.rs | 2 +- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index ce9303a87..f6411436e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -16,7 +16,7 @@ //! //! 事件载荷(`thread_manager::wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: //! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 -//! 另一种状态模型。跨进程边界(`#[tauri::command]`)的**拒单**载荷是结构化的 +//! 另一种状态模型。跨进程边界(`#[tauri::command]`)的**入队失败**载荷是结构化的 //! [`DirectTurnEnqueueFailure`](变体 + 一处生成的 `Display` 文案);其余只需要一句文案的出口 //! 仍由 `Display` 生成。Rust 侧任何地方都不解析这个字符串。 //! diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index 36b65a319..5a37fbda6 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -663,8 +663,8 @@ describe('Agent Runtime Provider 状态投影', () => { ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); }); - test('拒单文案只取收口文案里的脱敏摘要与建议,不套阶段标签', () => { - // 阶段说的是"失败发生在交付的哪一步",而拒单是"这一轮没有开始":阶段只会是默认值, + test('入队失败文案只取收口文案里的脱敏摘要与建议,不套阶段标签', () => { + // 阶段说的是"失败发生在交付的哪一步",而入队失败是"这一轮没有开始":阶段只会是默认值, // 套上去会把没发生的事讲成发生了。 expect( projectRuntimeVisibleRejectionError( @@ -682,7 +682,7 @@ describe('Agent Runtime Provider 状态投影', () => { ), ).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); // 已知边界(本轮不动):映射里"拒绝"那条子串分支会先认领"拒绝访问"这类文件系统事实; - // 目录锚不定的拒单在聊天里走 `Display` 原样显示(它不在上报名单里),所以摸不到这句。 + // 目录锚不定的入队失败在聊天里走 `Display` 原样显示(它不在上报名单里),所以摸不到这句。 expect( projectRuntimeVisibleRejectionError( '无法锚定 Direct 调用项目目录:拒绝访问', -- 2.52.0 From d1e637b9fd79a7566c51b04e557bd84330674824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 16:43:03 +0800 Subject: [PATCH 10/70] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20DirectTurnEnqueueFai?= =?UTF-8?q?lure=20=E6=96=87=E6=A1=A3=E5=AF=B9=20message=20=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E7=9A=84=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原文写"一处生成的 Display 文案"不准确:可留痕的宿主/环境失败走 record_direct_codex_failure 的 direct-codex-failure:v2 收口文案 - 文档改为写明两条来源(Display 与脱敏诊断收口文案),并保留"Rust 侧不解析该字符串"的约定 --- .../src-tauri/src/agent/direct_turn_error.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index f6411436e..196ef735a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -17,8 +17,11 @@ //! 事件载荷(`thread_manager::wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: //! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 //! 另一种状态模型。跨进程边界(`#[tauri::command]`)的**入队失败**载荷是结构化的 -//! [`DirectTurnEnqueueFailure`](变体 + 一处生成的 `Display` 文案);其余只需要一句文案的出口 -//! 仍由 `Display` 生成。Rust 侧任何地方都不解析这个字符串。 +//! [`DirectTurnEnqueueFailure`](变体 + 一条可展示 `message`);`message` 有两条来源: +//! 不需要留痕的失败用 [`DirectTurnError`] 的 `Display`,需要留痕的宿主 / 环境事实改用 +//! `record_direct_codex_failure` 生成的脱敏诊断收口文案(`direct-codex-failure:v2 …`,同时写 +//! `.agent/runtime/errors` 与错误上报池)。其余只需要一句文案的出口仍由 `Display` 生成。 +//! 无论哪条来源,Rust 侧任何地方都不解析这个字符串。 //! //! 谁负责产生哪个变体: //! - 命令入口与回合编排(`direct_runtime`):调用级拒绝、阶段失败; -- 2.52.0 From 698b3ffc9076db069da7ee13d9c8fb7629c99d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 16:44:36 +0800 Subject: [PATCH 11/70] =?UTF-8?q?=E6=8A=BD=E5=87=BA=20presentAuthFailure?= =?UTF-8?q?=20=E6=94=B6=E5=8F=A3=E7=99=BB=E5=BD=95=E4=B8=8E=E5=8F=91?= =?UTF-8?q?=E7=A0=81=E7=9A=84=E5=A4=B1=E8=B4=A5=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录/发码两处 catch 的"分流 + 提示 + 上报"逻辑抽成 presentAuthFailure(error, fallback, action) - 错误池指纹的 action 由调用点传入,避免新增认证动作时各自拼一套导致指纹漂移 --- .../src/app/AuthenticatedClient.tsx | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 1f8d92924..82553f8e1 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -327,6 +327,28 @@ export function AuthenticatedClient({ return () => window.clearInterval(timer); }, [codeCooldownSeconds]); + /** + * 认证动作失败的统一收口:可识别的业务 / 会话拒绝只给用户提示;真故障与未识别变体带上文交给 + * 错误池。`action` 决定错误池指纹,新增认证动作必须走这里、不要在各自的 catch 里另拼一套。 + */ + function presentAuthFailure( + error: unknown, + fallback: string, + action: string, + ) { + const failure = resolveClientAuthFailure(error, fallback); + setLoginStatus(failure.message); + if (failure.kind === 'report') { + void captureClientError( + new ClientActionError( + failure.message, + { source: 'auth', action }, + error, + ), + ); + } + } + async function handleSendCode() { if (codeBusy || codeCooldownSeconds > 0) { return; @@ -348,18 +370,7 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { - // 业务拒绝(手机号格式 / 发码限流等)只给提示;系统变体与未识别变体带上文交给错误池。 - const failure = resolveClientAuthFailure(error, '发送验证码失败'); - setLoginStatus(failure.message); - if (failure.kind === 'report') { - void captureClientError( - new ClientActionError( - failure.message, - { source: 'auth', action: 'send-login-code' }, - error, - ), - ); - } + presentAuthFailure(error, '发送验证码失败', 'send-login-code'); } finally { setCodeBusy(false); } @@ -424,19 +435,7 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - // 预期业务拒绝(密码输错 / 密码长度不合规 / 手机号格式等)在这里消化掉,永不进错误池; - // 真故障与未识别变体带上文交给错误池,指纹仍与显式采集时一致。 - const failure = resolveClientAuthFailure(error, '登录失败'); - setLoginStatus(failure.message); - if (failure.kind === 'report') { - void captureClientError( - new ClientActionError( - failure.message, - { source: 'auth', action: 'login' }, - error, - ), - ); - } + presentAuthFailure(error, '登录失败', 'login'); } finally { setLoginBusy(false); } -- 2.52.0 From c5153a7ed2257c8b7026a7bf6c938cba79ebbd5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 16:46:46 +0800 Subject: [PATCH 12/70] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E5=A4=B1=E8=B4=A5=E6=96=87=E6=A1=88=E9=87=8C?= =?UTF-8?q?=E7=9A=84=20network-error=20=E6=9C=BA=E5=99=A8=E5=89=8D?= =?UTF-8?q?=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AUTH_NETWORK_ERROR / AUTH_NETWORK_TIMEOUT 不再带 network-error: 前缀,用户看到的就是完整中文提示 - 前端按 type 分流后不再解析该前缀,没有任何 Rust/TS 消费方依赖它 - auth.suite.ts 与 clientAuthHost.test.ts 的对应 fixture/断言同步更新 --- apps/ai-game-creator-shell/src-tauri/src/auth_session.rs | 5 ++--- apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts | 6 ++---- apps/ai-game-creator-shell/tests/clientAuthHost.test.ts | 8 ++++---- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index c0f247999..0f9258741 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -33,9 +33,8 @@ const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; const AGC_CLIENT_MARKER_VALUE: &str = "agc"; -const AUTH_NETWORK_ERROR: &str = - "network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试"; -const AUTH_NETWORK_TIMEOUT: &str = "network-error: 登录服务响应超时,请检查服务器地址和网络后重试"; +const AUTH_NETWORK_ERROR: &str = "无法连接登录服务,请确认配套后端或 API 代理已启动后重试"; +const AUTH_NETWORK_TIMEOUT: &str = "登录服务响应超时,请检查服务器地址和网络后重试"; /// 会话路由 401 且服务端没给原因时的兜底文案。 const AUTH_AUTHORITY_MESSAGE: &str = "登录状态已失效,请重新登录"; diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 8fc0df66a..0628c31ef 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -286,7 +286,7 @@ export function registerAuthTests() { return reads === 1 ? { status: 'unavailable', - errorMessage: 'network-error: 无法连接登录服务', + errorMessage: '无法连接登录服务', } : { status: 'unauthenticated' }; } @@ -294,9 +294,7 @@ export function registerAuthTests() { }); renderAuthSurface(invoke); - expect( - await screen.findByText('network-error: 无法连接登录服务'), - ).not.toBeNull(); + expect(await screen.findByText('无法连接登录服务')).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '重试登录状态检查' })); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 2d2d51493..79b279bd8 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -75,11 +75,11 @@ test('恢复登录态映射为 authenticated / unauthenticated / unavailable', a invoke.mockResolvedValueOnce({ status: 'unavailable', - errorMessage: 'network-error: 无法连接登录服务', + errorMessage: '无法连接登录服务', }); await expect(readClientAuthState()).resolves.toEqual({ status: 'unavailable', - message: 'network-error: 无法连接登录服务', + message: '无法连接登录服务', }); }); @@ -156,11 +156,11 @@ test('续期结果区分成功、失效、身份变化与暂时失败', async () invoke.mockResolvedValueOnce({ status: 'failed', authoritative: false, - errorMessage: 'network-error: 无法连接登录服务', + errorMessage: '无法连接登录服务', }); await expect(refreshClientAuthSession()).resolves.toEqual({ status: 'failed', - message: 'network-error: 无法连接登录服务', + message: '无法连接登录服务', authoritative: false, }); }); -- 2.52.0 From 402233ae9ea294f2e23f9685158a289970c531a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 16:47:12 +0800 Subject: [PATCH 13/70] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E7=BB=AD=E6=9C=9F?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E7=BC=BA=E5=A4=B1=E6=96=87=E6=A1=88=E9=87=8C?= =?UTF-8?q?=E7=9A=84=20result-unknown=20=E6=9C=BA=E5=99=A8=E5=89=8D?= =?UTF-8?q?=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthResponseMalformed 变体就是分流键,该前缀没有任何解析方,去掉后用户看到完整中文提示 - 该模块的 result-unknown 前缀只在本条出现,其它 result-unknown 属于 resource_editor 等其它链路 --- apps/ai-game-creator-shell/src-tauri/src/auth_session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 0f9258741..9cd958cb8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -668,7 +668,7 @@ async fn request_auth( } if matches!(policy, CookiePolicy::Require) && captured.is_none() { return Err(ClientAuthError::AuthResponseMalformed { - message: "result-unknown: 登录服务未返回新的续期凭据,已停止使用旧凭据".to_string(), + message: "登录服务未返回新的续期凭据,已停止使用旧凭据".to_string(), }); } Ok(AuthResponse { -- 2.52.0 From c2a27aed2af2a779b4d871988b49715ae4fab02b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 17:41:45 +0800 Subject: [PATCH 14/70] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=B8=8A=E6=8A=A5=E5=88=A4=E6=8D=AE=E7=9A=84?= =?UTF-8?q?=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新测试 mock 模块级 @tauri-apps/api/core invoke,从而能观察 report_client_error 是否被调用 - 覆盖:手机号或密码错误、发码限流不写错误池;网络不可达带 auth/login 上报一次 - 已用变异验证:把分流改成一律上报时,两条业务用例会失败 --- .../tests/authFailureReporting.test.tsx | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx new file mode 100644 index 000000000..5255f6c47 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -0,0 +1,128 @@ +/** @vitest-environment jsdom */ + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * 认证失败分流的上报判据(端到端,判据是"这条失败到底有没有进错误池")。 + * + * 认证命令与错误上报走的不是同一个 invoke 入口:认证命令来自 `window.__TAURI__.core.invoke` + * (本文件注入替身),错误上报来自 `@tauri-apps/api/core` 的模块级 `invoke`。所以只有 mock 掉 + * 模块级 invoke,才能观察到 `report_client_error` 是否真的被调用。 + */ +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(async (command: string, _args?: Record) => { + if (command === 'get_pending_error_reports') return []; + return undefined; + }), +})); + +import { invoke } from '@tauri-apps/api/core'; + +import { AuthenticatedClient } from '../src/app/AuthenticatedClient'; +import { resetPlatformSessionForTests } from '../src/services/platformSession'; + +type AuthInvoke = ( + command: string, + args?: Record, +) => Promise; + +function reportCalls() { + return vi + .mocked(invoke) + .mock.calls.filter(([command]) => command === 'report_client_error'); +} + +function renderLogin(invokeAuth: AuthInvoke) { + window.__TAURI__ = { core: { invoke: invokeAuth } } as never; + render( + createElement(AuthenticatedClient, null, () => + createElement('main', { 'aria-label': '已登录' }), + ), + ); +} + +async function submitPasswordLogin(invokeAuth: AuthInvoke) { + renderLogin(invokeAuth); + await screen.findByRole('main', { name: '登录' }); + fireEvent.click(screen.getByRole('button', { name: '密码登录' })); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.change(screen.getByLabelText('密码'), { + target: { value: 'wrong-password' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); +} + +describe('认证失败的上报判据', () => { + afterEach(() => { + cleanup(); + resetPlatformSessionForTests(); + delete window.__TAURI__; + vi.mocked(invoke).mockClear(); + }); + + it('业务拒绝(手机号或密码错误)只显示文案,不写错误池', async () => { + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }; + } + return null; + }); + + expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); + expect(reportCalls()).toHaveLength(0); + }); + + it('发码限流同样只显示文案,不写错误池', async () => { + renderLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'send_client_phone_login_code') { + throw { type: 'smsCodeThrottled', message: '发送过于频繁,请稍后再试' }; + } + return null; + }); + await screen.findByRole('main', { name: '登录' }); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.click(screen.getByRole('button', { name: '获取验证码' })); + + expect(await screen.findByText('发送过于频繁,请稍后再试')).not.toBeNull(); + expect(reportCalls()).toHaveLength(0); + }); + + it('系统变体(网络不可达)带 auth/login 上下文上报一次', async () => { + const message = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw { type: 'authNetworkUnavailable', message }; + } + return null; + }); + + expect(await screen.findByText(message)).not.toBeNull(); + await waitFor(() => expect(reportCalls()).toHaveLength(1)); + expect(reportCalls()[0]?.[1]).toMatchObject({ + source: 'auth', + action: 'login', + message, + }); + }); +}); -- 2.52.0 From b561db92498fe81f250016c285d083a2b9d48049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 17:55:54 +0800 Subject: [PATCH 15/70] =?UTF-8?q?=E5=88=A0=E9=99=A4=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=B8=8A=E6=8A=A5=E7=9A=84=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=B1=82=EF=BC=8C=E6=94=B9=E4=B8=BA=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E6=8C=89=E5=85=B7=E4=BD=93=E5=8F=98=E4=BD=93?= =?UTF-8?q?=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 clientAuthErrorKind / clientAuthErrorNotice,clientAuthError.ts 只留 isClientAuthError 形状读取 - 删除 resolveClientAuthFailure 与 ClientAuthFailureResolution,AuthenticatedClient 直接 switch 具体变体名 - 同步重写相关单测与 ADR 第 3 节前端分流描述 --- .../src/app/AuthenticatedClient.tsx | 47 ++++++++---- .../src/services/clientAuth.ts | 41 +---------- .../src/services/clientAuthError.ts | 54 ++------------ .../tests/clientAuthError.test.ts | 72 +++---------------- .../tests/clientAuthHost.test.ts | 58 --------------- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 5 +- 6 files changed, 56 insertions(+), 221 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 82553f8e1..a77f00f30 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -19,10 +19,10 @@ import { logoutClientAuthSession, normalizeAuthPhoneInput, readClientAuthState, - resolveClientAuthFailure, sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; +import { isClientAuthError } from '../services/clientAuthError'; import { type ClientServerPreset, type ClientServerSelection, @@ -328,25 +328,46 @@ export function AuthenticatedClient({ }, [codeCooldownSeconds]); /** - * 认证动作失败的统一收口:可识别的业务 / 会话拒绝只给用户提示;真故障与未识别变体带上文交给 - * 错误池。`action` 决定错误池指纹,新增认证动作必须走这里、不要在各自的 catch 里另拼一套。 + * 认证动作失败的统一收口:**要不要上报由这里按具体变体判**,不做任何文案匹配。 + * + * 名单里的是"用户自己能改的输入 / 前置条件"与"会话路由 401/403":只给提示。其余(网络、 + * 5xx、写盘、运行时、响应不合法,以及 Rust 新增而这里没接的变体)带上文交给错误池—— + * `default` 指向上报,新变体不会被静默吞掉。`action` 决定错误池指纹,新增认证动作必须走这里。 */ function presentAuthFailure( error: unknown, fallback: string, action: string, ) { - const failure = resolveClientAuthFailure(error, fallback); - setLoginStatus(failure.message); - if (failure.kind === 'report') { - void captureClientError( - new ClientActionError( - failure.message, - { source: 'auth', action }, - error, - ), - ); + const structured = isClientAuthError(error); + if (structured) { + switch (structured.type) { + // 用户自己能改的输入 / 前置条件,以及会话路由 401/403(按“未登录”处理): + // 这些都给用户提示,不进错误池。 + case 'serverAddressRejected': + case 'phoneNumberInvalid': + case 'passwordMissing': + case 'loginCodeMissing': + case 'passwordEntryInputRejected': + case 'phoneOrPasswordMismatch': + case 'sendCodeInputRejected': + case 'smsCodeThrottled': + case 'phoneLoginInputRejected': + case 'smsCodeInvalidOrExpired': + case 'sessionAuthorityRejected': + case 'permissionDenied': { + setLoginStatus(structured.message.trim() || fallback); + return; + } + default: + break; + } } + const message = getClientAuthErrorMessage(error, fallback); + setLoginStatus(message); + void captureClientError( + new ClientActionError(message, { source: 'auth', action }, error), + ); } async function handleSendCode() { diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 183730522..8b76fd3fb 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,10 +1,6 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; -import { - clientAuthErrorKind, - clientAuthErrorNotice, - isClientAuthError, -} from './clientAuthError'; +import { isClientAuthError } from './clientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; /** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */ @@ -47,6 +43,8 @@ function requireInvoke() { * 命令失败现在是结构化的(`ClientAuthError`),这里必须按形状取 Rust 那一份文案;裸字符串 * 是旧的 `Err(String)` 残留与浏览器环境的形态。**非 Error 的其它形状不再做字符串化**—— * `String({type,message})` 只会得到 `[object Object]`,把它当文案显示比回落更糟。 + * + * 它只取文案,**不判要不要上报**:那由调用方在 catch 里按具体变体决定。 */ export function getClientAuthErrorMessage(error: unknown, fallback: string) { const structured = isClientAuthError(error); @@ -56,39 +54,6 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { return fallback; } -/** - * 登录 / 发码失败的调用方分流:`notice` 是"用户自己能改或本来就该按未登录处理"的变体, - * 只显示 Rust 文案;`report` 是系统变体 / 未识别变体 / 非结构化拒绝,显示文案之外还要带上下文 - * 交给错误池(见 [`captureClientError`](./errorReporting.ts))。 - * - * 判定只看类型化的 `type`(`clientAuthErrorKind`),不做任何文案匹配;这条规则是 - * "输错一次密码不该被引导上报"的判据。 - */ -export type ClientAuthFailureResolution = - | { kind: 'notice'; message: string } - | { kind: 'report'; message: string }; - -export function resolveClientAuthFailure( - error: unknown, - fallback: string, -): ClientAuthFailureResolution { - const structured = isClientAuthError(error); - // 分流判据是 `clientAuthErrorKind`,不是"有没有提示":`clientAuthErrorNotice` 对空文案也返回 - // `null`,拿它当判据会把"Rust 给了可识别的 input/session 变体、但文案为空"错当成 fault 上报。 - if (structured && clientAuthErrorKind(structured) !== 'fault') { - return { - kind: 'notice', - message: - clientAuthErrorNotice(structured) ?? - getClientAuthErrorMessage(error, fallback), - }; - } - return { - kind: 'report', - message: getClientAuthErrorMessage(error, fallback), - }; -} - type RustAuthStateView = { status?: string; user?: AuthUser | null; diff --git a/apps/ai-game-creator-shell/src/services/clientAuthError.ts b/apps/ai-game-creator-shell/src/services/clientAuthError.ts index 1ac31eaa0..6029e91ee 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuthError.ts @@ -6,13 +6,13 @@ export type { ClientAuthError }; * `invoke` 拒绝时拿到的是 Rust 序列化出来的普通对象(不是 `Error`)。这里只做形状读取: * `type` 是稳定判别键,`message` 是 Rust 生成的可展示文案,**文案不参与任何判断**。 * - * 不在名单里的 `type` 也算"形状合法":新变体会落到 [`clientAuthErrorKind`] 的 `fault`, - * 由调用方交给错误池——这是故意的,见 ADR 的"未识别变体上调"。 + * 这里**不做任何分类**:要不要上报由调用方在 catch 里按具体变体判(`switch (error.type)`), + * 不在名单里的新变体由调用方的 `default` 分支交给错误池——见 ADR 的"未识别变体上调"。 * * **`status` 故意不校验**:`authServiceUnavailable` / `unexpectedRejection` 在生成类型里带必填 - * `status`,但全仓没有调用方读它(这两个变体只走 `fault` 分支显示 `message`)。把 `status` - * 纳入校验只会让"缺 `status` 但 `message` 可用"的拒绝落回非结构化分支,把 Rust 的文案换成 - * 调用方兜底文案——诊断信息更少、行为更差。将来真要用 `status` 做判断时,先补校验再用。 + * `status`,但全仓没有调用方读它(这两个变体只显示 `message`)。把 `status` 纳入校验只会让 + * "缺 `status` 但 `message` 可用"的拒绝落回非结构化分支,把 Rust 的文案换成调用方兜底文案 + * ——诊断信息更少、行为更差。将来真要用 `status` 做判断时,先补校验再用。 */ export function isClientAuthError(value: unknown): ClientAuthError | null { if (!value || typeof value !== 'object') return null; @@ -21,47 +21,3 @@ export function isClientAuthError(value: unknown): ClientAuthError | null { if (typeof candidate.message !== 'string') return null; return value as ClientAuthError; } - -/** - * 变体分流的稳定分类,与 Rust `ClientAuthError` 的三段注释一一对应: - * - * - `input`:用户自己能改的输入 / 前置条件(登录 400/401、发码 429 等),调用方给提示后消化掉。 - * - `session`:会话路由 401/403,调用方按"未登录"处理,不报错也不进池。 - * - `fault`:网络 / 5xx / 写盘 / 运行时 / 响应不合法,以及**未识别变体**,由调用方交给错误池。 - */ -export type ClientAuthErrorKind = 'input' | 'session' | 'fault'; - -export function clientAuthErrorKind( - error: ClientAuthError, -): ClientAuthErrorKind { - switch (error.type) { - case 'serverAddressRejected': - case 'phoneNumberInvalid': - case 'passwordMissing': - case 'loginCodeMissing': - case 'passwordEntryInputRejected': - case 'phoneOrPasswordMismatch': - case 'sendCodeInputRejected': - case 'smsCodeThrottled': - case 'phoneLoginInputRejected': - case 'smsCodeInvalidOrExpired': - return 'input'; - case 'sessionAuthorityRejected': - case 'permissionDenied': - return 'session'; - default: - return 'fault'; - } -} - -/** - * 用户可读的提示:`input` / `session` 原样使用 Rust 生成的那一份文案;`fault` 返回 `null`, - * 表示"调用方处理不了,交给错误池"。空文案也按"没有提示"处理,避免调用方拿空串当提示显示。 - * - * **`null` 同时表示这两件事,所以它不是分流判据**:判定"要不要进错误池"必须用 - * [`clientAuthErrorKind`],只有 `fault` 才上报;否则一个文案为空的可识别变体会被误报。 - */ -export function clientAuthErrorNotice(error: ClientAuthError): string | null { - if (clientAuthErrorKind(error) === 'fault') return null; - return error.message.trim() || null; -} diff --git a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts index cfaadde96..83ec77c2a 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts @@ -1,12 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { - clientAuthErrorKind, - clientAuthErrorNotice, - isClientAuthError, -} from '../src/services/clientAuthError'; +import { isClientAuthError } from '../src/services/clientAuthError'; -describe('AGC 认证命令错误分流', () => { +describe('AGC 认证命令错误的形状读取', () => { it('只按形状读取 Rust 的结构化拒绝,裸字符串与 Error 都不算', () => { expect( isClientAuthError({ @@ -23,67 +19,21 @@ describe('AGC 认证命令错误分流', () => { expect(isClientAuthError(null)).toBeNull(); }); - it('业务输入给提示、会话变体按未登录、系统与未识别变体交给错误池', () => { + it('未知变体也算形状合法,由调用方按 default 交给错误池', () => { expect( - clientAuthErrorKind({ - type: 'passwordEntryInputRejected', - message: '密码长度需要在 6 到 128 位之间', - }), - ).toBe('input'); - expect( - clientAuthErrorKind({ - type: 'phoneOrPasswordMismatch', - message: '手机号或密码错误', - }), - ).toBe('input'); - expect( - clientAuthErrorKind({ - type: 'sessionAuthorityRejected', - message: '登录已失效,请重新登录', - }), - ).toBe('session'); - expect( - clientAuthErrorKind({ - type: 'authNetworkUnavailable', - message: '无法连接登录服务', - }), - ).toBe('fault'); - expect( - clientAuthErrorKind({ - type: 'runtimeSessionInstallFailed', - message: '本机运行时会话安装失败', - }), - ).toBe('fault'); - // 未识别变体按 fault 处理:Rust 加了变体但界面没接,属于缺陷,必须进池。 - expect( - clientAuthErrorKind( - isClientAuthError({ type: 'brandNewRejection', message: '新变体' })!, - ), - ).toBe('fault'); + isClientAuthError({ type: 'brandNewRejection', message: '新变体' }), + ).toEqual({ type: 'brandNewRejection', message: '新变体' }); }); - it('input / session 原样使用 Rust 文案,fault 与空文案不给提示', () => { + it('status 故意不校验:缺 status 但 message 可用的拒绝仍可读', () => { expect( - clientAuthErrorNotice({ - type: 'phoneOrPasswordMismatch', - message: '手机号或密码错误', - }), - ).toBe('手机号或密码错误'); - expect( - clientAuthErrorNotice({ - type: 'sessionAuthorityRejected', - message: '登录已失效,请重新登录', - }), - ).toBe('登录已失效,请重新登录'); - expect( - clientAuthErrorNotice({ + isClientAuthError({ type: 'authServiceUnavailable', - status: 503, message: '登录服务暂时不可用', }), - ).toBeNull(); - expect( - clientAuthErrorNotice({ type: 'phoneNumberInvalid', message: ' ' }), - ).toBeNull(); + ).toEqual({ + type: 'authServiceUnavailable', + message: '登录服务暂时不可用', + }); }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 79b279bd8..e6cef4552 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -17,7 +17,6 @@ import { normalizeAuthPhoneInput, readClientAuthState, refreshClientAuthSession, - resolveClientAuthFailure, sendClientPhoneLoginCode, } from '../src/services/clientAuth'; @@ -186,60 +185,3 @@ test('错误文案优先使用服务端原因,缺失时回落到调用方文 ).toBe('手机号或密码错误'); expect(getClientAuthErrorMessage('', '登录失败')).toBe('登录失败'); }); - -test('登录失败分流只看类型化变体:业务拒绝给提示,系统与未识别变体才进池', () => { - expect( - resolveClientAuthFailure( - { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }, - '登录失败', - ), - ).toEqual({ kind: 'notice', message: '手机号或密码错误' }); - expect( - resolveClientAuthFailure( - { - type: 'passwordEntryInputRejected', - message: '密码长度需要在 6 到 128 位之间', - }, - '登录失败', - ), - ).toEqual({ kind: 'notice', message: '密码长度需要在 6 到 128 位之间' }); - // 会话 401/403 按"未登录"处理:给提示,但不进错误池。 - expect( - resolveClientAuthFailure( - { type: 'sessionAuthorityRejected', message: '登录已失效,请重新登录' }, - '登录失败', - ), - ).toEqual({ kind: 'notice', message: '登录已失效,请重新登录' }); - // 文案为空的可识别变体仍按"用户可改"处理:给兜底提示,不进错误池 - // (分流判据是 kind,不是"有没有提示文案")。 - expect( - resolveClientAuthFailure( - { type: 'phoneNumberInvalid', message: ' ' }, - '登录失败', - ), - ).toEqual({ kind: 'notice', message: '登录失败' }); - // 系统变体与未识别变体:显示 Rust 文案,同时交给错误池。 - expect( - resolveClientAuthFailure( - { type: 'authNetworkUnavailable', message: '无法连接登录服务' }, - '登录失败', - ), - ).toEqual({ kind: 'report', message: '无法连接登录服务' }); - expect( - resolveClientAuthFailure( - { type: 'brandNewRejection', message: '新变体' }, - '登录失败', - ), - ).toEqual({ kind: 'report', message: '新变体' }); - // 非结构化:裸字符串保留原文,其它形状回落调用方文案,不显示 `[object Object]`。 - expect( - resolveClientAuthFailure('network-error: 连接超时', '登录失败'), - ).toEqual({ kind: 'report', message: 'network-error: 连接超时' }); - expect( - resolveClientAuthFailure(new Error('runner clear rejected'), '登录失败'), - ).toEqual({ kind: 'report', message: 'runner clear rejected' }); - expect(resolveClientAuthFailure({ status: 500 }, '登录失败')).toEqual({ - kind: 'report', - message: '登录失败', - }); -}); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index e896f0188..2420735e5 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -55,8 +55,9 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 ### 3. 前端按变体分流,认不出就交池 -- `isClientAuthError` 只做形状读取(`type` 是稳定判别键),`clientAuthErrorKind` 用 - `switch (error.type)` 给出 `input` / `session` / `fault` 三类;`fault` 表示"认不出或宿主/环境事实"。 +- `isClientAuthError` 只做形状读取(`type` 是稳定判别键),**不做分类、不产出派生值**。 +- 调用方自己 `switch (error.type)`,逐个列出具体变体:业务输入 / 会话变体给提示,`default`(系统变体 + 与 Rust 新增而界面没接的变体)带上文交池。判据是具体变体名,不是聚合出来的类别,也不是文案匹配。 - 认不出、系统类、非结构化拒绝 → 调用方包成 `ClientActionError(message, context, cause)` 交给 `captureClientError`:`instanceof` 解包 `context` 与 `cause`,指纹/展示字段与今天一致。 **不把 rejection 留在没人接手的 Promise 上**:`onSubmit` / `onClick` 这类 `void` 掉的 handler 抛错 -- 2.52.0 From 2f51e043b7fa8fa4f983c7ea770547d5c9b86681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 18:08:00 +0800 Subject: [PATCH 16/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=85=88=E8=A1=8C?= =?UTF-8?q?=EF=BC=9AAGC=20=E8=AE=A4=E8=AF=81=E9=94=99=E8=AF=AF=E7=9A=84=20?= =?UTF-8?q?TS=20=E5=BD=A2=E7=8A=B6=E6=94=B9=E4=B8=BA=E6=AF=8F=E4=B8=AA?= =?UTF-8?q?=E5=8F=98=E4=BD=93=E4=B8=80=E4=B8=AA=E5=85=B7=E5=90=8D=E8=BD=BD?= =?UTF-8?q?=E8=8D=B7=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR §1 增加"每个变体一个具名载荷结构体 + ts-rs 生成 `{ type } & X`"的决策与代价 - ADR §3 说明 switch 分支里错误已窄化成具名类型 - 技术方案同步命令错误形状描述 - decision-log / pitfalls 记录形状约定:不手写这层类型、不再包派生分类与提示文案函数 --- .../【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md | 9 ++++++++- docs/project-memory/shared-memory/decision-log.md | 3 ++- docs/project-memory/shared-memory/pitfalls.md | 1 + .../【技术方案】AGC错误报告与诊断上传-2026-08-31.md | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 2420735e5..8bf16e0d7 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -33,6 +33,12 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Rust 侧定义具体变体枚举(auth 首个落地:`ClientAuthError`),`#[derive(Serialize, TS)]` + `#[serde(tag = "type", rename_all = "camelCase")]`,用 ts-rs 导出到 `apps/ai-game-creator-shell/src/services/generated/`;生成物不手改。 +- **每个变体一个具名载荷结构体**,枚举用 newtype 变体持有它(`PhoneNumberInvalid(PhoneNumberInvalid)`)。 + ts-rs 因此生成 `{ type: 'phoneNumberInvalid' } & PhoneNumberInvalid`,以及每个变体一个 + `generated/<变体名>.ts`:前端 `switch (error.type)` 的每个分支都落到一个有名字的类型,等价于 Java 的 + `catch (PhoneNumberInvalid e)`,不需要 `as` 断言,载荷类型自己带 JSDoc。载荷必须能序列化成 map + (serde 的 internally tagged 表示只接受 struct / map),所以没有无字段变体;代价是 Rust 构造点统一写成 + `ClientAuthError::X(X { message })`。这个形状在本仓已有先例(`DirectCodexUserContentPart`)。 - `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。 这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新约定。 - 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用 @@ -57,7 +63,8 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - `isClientAuthError` 只做形状读取(`type` 是稳定判别键),**不做分类、不产出派生值**。 - 调用方自己 `switch (error.type)`,逐个列出具体变体:业务输入 / 会话变体给提示,`default`(系统变体 - 与 Rust 新增而界面没接的变体)带上文交池。判据是具体变体名,不是聚合出来的类别,也不是文案匹配。 + 与 Rust 新增而界面没接的变体)带上文交池。判据是具体变体名,不是聚合出来的类别,也不是文案匹配; + 每个 `case` 里 `error` 已窄化成 §1 的具名载荷类型(如 `PhoneNumberInvalid`)。 - 认不出、系统类、非结构化拒绝 → 调用方包成 `ClientActionError(message, context, cause)` 交给 `captureClientError`:`instanceof` 解包 `context` 与 `cause`,指纹/展示字段与今天一致。 **不把 rejection 留在没人接手的 Promise 上**:`onSubmit` / `onClick` 这类 `void` 掉的 handler 抛错 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4c9da0551..673a0de80 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5,7 +5,8 @@ - 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端只按 `type` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。 - 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文交给错误池(`ClientActionError` 承载 `source/action/page`,`captureClientError` 用 `instanceof` 解包),`window.onerror` / `unhandledrejection` 只兜底没人接手的错误;408/5xx/网络的判定由调用方在 catch 里做(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。 - 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordEntryInputRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径改造或删除留在后续变更。 -- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts`(ts-rs 生成)。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 每个变体一个载荷文件)。 +- 决策(补充):TS 形状用**每个变体一个具名载荷类型**——Rust 枚举是 newtype 变体持有同名 `#[ts(export)]` 结构体,ts-rs 生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`,前端 `switch (error.type)` 的每个分支直接拿到具名类型。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 ## 2026-10-01 DirectProject 审批拒绝原因留痕 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f99d42740..54a4d1fcc 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -8,6 +8,7 @@ - **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 - **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),UI 调用方按 `type` 分流:认得的业务变体只给用户反馈,系统变体/未识别变体/非结构化拒绝由调用方包成 `ClientActionError` 交给错误池;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)。 - **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/errorReporting.test.ts`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体带上文只上报一次且 `source=auth`。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 +- **形状约定**:`ClientAuthError` 的每个变体在 TS 里是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律 `switch (error.type)`,每个分支里 `error` 已经窄化成具名载荷类型。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/errorReporting.ts`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 ## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 21c23cfe0..fae769780 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -11,7 +11,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientActionError`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 -- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。 +- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 - 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 -- 2.52.0 From f435d483992efd128656e7ff75843f513208298e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 18:08:18 +0800 Subject: [PATCH 17/70] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=94=B9=E4=B8=BA=E6=AF=8F=E4=B8=AA=E5=8F=98=E4=BD=93?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E5=85=B7=E5=90=8D=E8=BD=BD=E8=8D=B7=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_error.rs:枚举改为 newtype 变体,每个变体持有一个同名 `#[ts(export)]` 载荷结构体 - auth_session.rs:41 处构造点改为 `ClientAuthError::X(X { .. })` 并导入载荷类型 - 重新生成 services/generated:ClientAuthError.ts 变成 `{ type } & 具名类型`,新增 19 个载荷类型文件 - tests/clientAuthError.test.ts:新增编译期用例,switch (error.type) 分支里错误窄化成具名载荷类型 --- .../src-tauri/src/auth_error.rs | 443 ++++++++++++------ .../src-tauri/src/auth_session.rs | 230 +++++---- .../generated/AuthClientInitFailed.ts | 6 + .../generated/AuthNetworkUnavailable.ts | 6 + .../generated/AuthResponseMalformed.ts | 6 + .../generated/AuthServiceUnavailable.ts | 6 + .../src/services/generated/ClientAuthError.ts | 59 ++- .../generated/ClientSessionPersistFailed.ts | 6 + .../services/generated/LoginCodeMissing.ts | 6 + .../generated/PasswordEntryInputRejected.ts | 6 + .../src/services/generated/PasswordMissing.ts | 6 + .../services/generated/PermissionDenied.ts | 6 + .../generated/PhoneLoginInputRejected.ts | 6 + .../services/generated/PhoneNumberInvalid.ts | 6 + .../generated/PhoneOrPasswordMismatch.ts | 6 + .../generated/RuntimeSessionInstallFailed.ts | 6 + .../generated/SendCodeInputRejected.ts | 6 + .../generated/ServerAddressRejected.ts | 6 + .../generated/SessionAuthorityRejected.ts | 6 + .../generated/SmsCodeInvalidOrExpired.ts | 6 + .../services/generated/SmsCodeThrottled.ts | 6 + .../services/generated/UnexpectedRejection.ts | 6 + .../tests/clientAuthError.test.ts | 38 ++ 23 files changed, 613 insertions(+), 271 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index 172a7372c..1d8a19ecc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -2,10 +2,16 @@ //! //! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。 //! +//! 每个变体持有自己的**具名载荷类型**([`ServerAddressRejected`] … [`AuthClientInitFailed`]),不是内联 +//! 匿名结构体。ts-rs 因此生成 `{ type: 'phoneNumberInvalid' } & PhoneNumberInvalid`:前端 +//! `switch (error.type)` 的每个分支都拿到一个有名字的类型,等价于 Java 的 +//! `catch (PhoneNumberInvalid e)`,不需要 `as` 断言。载荷类型必须能序列化成 map——serde 的 +//! internally tagged 表示只接受 struct / map——所以全部是命名结构体,没有无字段变体。 +//! //! 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(平台 `AppError.code` 仍是 //! 通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(例如 -//! [`ClientAuthError::PasswordEntryInputRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 -//! `401/403` 是"登录态权威失效",登录路由的 `401` 是用户可修正的输入问题,这个区分现在由变体承担, +//! [`PasswordEntryInputRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 `401/403` 是 +//! "登录态权威失效",登录路由的 `401` 是用户可修正的输入问题,这个区分现在由变体承担, //! 不再靠 `authentication-required:` 这类文本前缀。 //! //! 每个变体都带一份可展示 `message`,文案只在 Rust 生成一次(服务端原文优先,缺失时才用调用点的 @@ -16,81 +22,218 @@ use std::fmt; use serde::Serialize; use ts_rs::TS; -/// 变体名就是线上的分流键(`type`)。 +/// 变体名就是线上的分流键(`type`),每个变体持有同名载荷类型。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde( - tag = "type", - rename_all = "camelCase", - rename_all_fields = "camelCase" -)] +#[serde(tag = "type", rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) enum ClientAuthError { // ---- 业务:用户自己能改,调用方给提示,不上报 ---- - /// 服务地址不是合法 origin / 非本机未用 HTTPS / 不在构建渠道范围内。 - ServerAddressRejected { message: String }, - /// 本地前置校验:手机号为空或格式不合法。 - PhoneNumberInvalid { message: String }, - /// 本地前置校验:密码为空。 - PasswordMissing { message: String }, - /// 本地前置校验:验证码为空。 - LoginCodeMissing { message: String }, - /// `/api/auth/entry` 返回 400:服务端拒绝本次输入。 - PasswordEntryInputRejected { message: String }, - /// `/api/auth/entry` 返回 401:手机号或密码错误。 - PhoneOrPasswordMismatch { message: String }, - /// `/api/auth/phone/send-code` 返回 400。 - SendCodeInputRejected { message: String }, - /// `/api/auth/phone/send-code` 返回 429:发送过于频繁。 - SmsCodeThrottled { message: String }, - /// `/api/auth/phone/login` 返回 400。 - PhoneLoginInputRejected { message: String }, - /// `/api/auth/phone/login` 返回 401:验证码错误或过期。 - SmsCodeInvalidOrExpired { message: String }, + ServerAddressRejected(ServerAddressRejected), + PhoneNumberInvalid(PhoneNumberInvalid), + PasswordMissing(PasswordMissing), + LoginCodeMissing(LoginCodeMissing), + PasswordEntryInputRejected(PasswordEntryInputRejected), + PhoneOrPasswordMismatch(PhoneOrPasswordMismatch), + SendCodeInputRejected(SendCodeInputRejected), + SmsCodeThrottled(SmsCodeThrottled), + PhoneLoginInputRejected(PhoneLoginInputRejected), + SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired), // ---- 会话:调用方按"未登录"处理,不给用户报错 ---- - /// 会话路由 401:登录态权威失效。 - SessionAuthorityRejected { message: String }, - /// 会话路由 403:当前账号没有执行此操作的权限。 - PermissionDenied { message: String }, + SessionAuthorityRejected(SessionAuthorityRejected), + PermissionDenied(PermissionDenied), // ---- 系统:调用方处理不了,带上下文重抛 ---- - /// 连接 / 超时 / DNS 等网络失败。 - AuthNetworkUnavailable { message: String }, - /// 服务端 5xx。 - AuthServiceUnavailable { status: u16, message: String }, - /// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 - UnexpectedRejection { status: u16, message: String }, - /// 响应不是合法 JSON、缺少必需字段或契约不成立。 - AuthResponseMalformed { message: String }, - /// 本机登录凭据文件读写失败。 - ClientSessionPersistFailed { message: String }, - /// 本机运行时会话安装 / 清理失败。 - RuntimeSessionInstallFailed { message: String }, - /// 认证网络客户端构建失败。 - AuthClientInitFailed { message: String }, + AuthNetworkUnavailable(AuthNetworkUnavailable), + AuthServiceUnavailable(AuthServiceUnavailable), + UnexpectedRejection(UnexpectedRejection), + AuthResponseMalformed(AuthResponseMalformed), + ClientSessionPersistFailed(ClientSessionPersistFailed), + RuntimeSessionInstallFailed(RuntimeSessionInstallFailed), + AuthClientInitFailed(AuthClientInitFailed), +} + +// ---- 业务:用户自己能改,调用方给提示,不上报 ---- + +/// 服务地址不是合法 origin / 非本机未用 HTTPS / 不在构建渠道范围内。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct ServerAddressRejected { + pub(crate) message: String, +} + +/// 本地前置校验:手机号为空或格式不合法。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct PhoneNumberInvalid { + pub(crate) message: String, +} + +/// 本地前置校验:密码为空。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct PasswordMissing { + pub(crate) message: String, +} + +/// 本地前置校验:验证码为空。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct LoginCodeMissing { + pub(crate) message: String, +} + +/// `/api/auth/entry` 返回 400:服务端拒绝本次输入。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct PasswordEntryInputRejected { + pub(crate) message: String, +} + +/// `/api/auth/entry` 返回 401:手机号或密码错误。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct PhoneOrPasswordMismatch { + pub(crate) message: String, +} + +/// `/api/auth/phone/send-code` 返回 400。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct SendCodeInputRejected { + pub(crate) message: String, +} + +/// `/api/auth/phone/send-code` 返回 429:发送过于频繁。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct SmsCodeThrottled { + pub(crate) message: String, +} + +/// `/api/auth/phone/login` 返回 400。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct PhoneLoginInputRejected { + pub(crate) message: String, +} + +/// `/api/auth/phone/login` 返回 401:验证码错误或过期。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct SmsCodeInvalidOrExpired { + pub(crate) message: String, +} + +// ---- 会话:调用方按"未登录"处理,不给用户报错 ---- + +/// 会话路由 401:登录态权威失效。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct SessionAuthorityRejected { + pub(crate) message: String, +} + +/// 会话路由 403:当前账号没有执行此操作的权限。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct PermissionDenied { + pub(crate) message: String, +} + +// ---- 系统:调用方处理不了,带上下文重抛 ---- + +/// 连接 / 超时 / DNS 等网络失败。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct AuthNetworkUnavailable { + pub(crate) message: String, +} + +/// 服务端 5xx。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct AuthServiceUnavailable { + pub(crate) status: u16, + pub(crate) message: String, +} + +/// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct UnexpectedRejection { + pub(crate) status: u16, + pub(crate) message: String, +} + +/// 响应不是合法 JSON、缺少必需字段或契约不成立。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct AuthResponseMalformed { + pub(crate) message: String, +} + +/// 本机登录凭据文件读写失败。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct ClientSessionPersistFailed { + pub(crate) message: String, +} + +/// 本机运行时会话安装 / 清理失败。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct RuntimeSessionInstallFailed { + pub(crate) message: String, +} + +/// 认证网络客户端构建失败。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct AuthClientInitFailed { + pub(crate) message: String, } impl ClientAuthError { /// 可展示文案:服务端原文优先,缺失时是调用点兜底。 pub(crate) fn message(&self) -> &str { match self { - Self::ServerAddressRejected { message } - | Self::PhoneNumberInvalid { message } - | Self::PasswordMissing { message } - | Self::LoginCodeMissing { message } - | Self::PasswordEntryInputRejected { message } - | Self::PhoneOrPasswordMismatch { message } - | Self::SendCodeInputRejected { message } - | Self::SmsCodeThrottled { message } - | Self::PhoneLoginInputRejected { message } - | Self::SmsCodeInvalidOrExpired { message } - | Self::SessionAuthorityRejected { message } - | Self::PermissionDenied { message } - | Self::AuthNetworkUnavailable { message } - | Self::AuthServiceUnavailable { message, .. } - | Self::UnexpectedRejection { message, .. } - | Self::AuthResponseMalformed { message } - | Self::ClientSessionPersistFailed { message } - | Self::RuntimeSessionInstallFailed { message } - | Self::AuthClientInitFailed { message } => message, + Self::ServerAddressRejected(payload) => &payload.message, + Self::PhoneNumberInvalid(payload) => &payload.message, + Self::PasswordMissing(payload) => &payload.message, + Self::LoginCodeMissing(payload) => &payload.message, + Self::PasswordEntryInputRejected(payload) => &payload.message, + Self::PhoneOrPasswordMismatch(payload) => &payload.message, + Self::SendCodeInputRejected(payload) => &payload.message, + Self::SmsCodeThrottled(payload) => &payload.message, + Self::PhoneLoginInputRejected(payload) => &payload.message, + Self::SmsCodeInvalidOrExpired(payload) => &payload.message, + Self::SessionAuthorityRejected(payload) => &payload.message, + Self::PermissionDenied(payload) => &payload.message, + Self::AuthNetworkUnavailable(payload) => &payload.message, + Self::AuthServiceUnavailable(payload) => &payload.message, + Self::UnexpectedRejection(payload) => &payload.message, + Self::AuthResponseMalformed(payload) => &payload.message, + Self::ClientSessionPersistFailed(payload) => &payload.message, + Self::RuntimeSessionInstallFailed(payload) => &payload.message, + Self::AuthClientInitFailed(payload) => &payload.message, } } @@ -99,7 +242,7 @@ impl ClientAuthError { pub(crate) fn is_authority_failure(&self) -> bool { matches!( self, - Self::SessionAuthorityRejected { .. } | Self::PermissionDenied { .. } + Self::SessionAuthorityRejected(_) | Self::PermissionDenied(_) ) } } @@ -121,123 +264,116 @@ impl From for String { mod tests { use super::*; + /// 单字段载荷的构造简写:这些变体的载荷只有一个 `message`。 + fn text(message: &str) -> String { + message.to_string() + } + #[test] fn wire_variant_names_are_the_frontend_dispatch_keys() { let cases = [ ( - ClientAuthError::ServerAddressRejected { - message: "x".to_string(), - }, + ClientAuthError::ServerAddressRejected(ServerAddressRejected { + message: text("x"), + }), "serverAddressRejected", ), ( - ClientAuthError::PhoneNumberInvalid { - message: "x".to_string(), - }, + ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { message: text("x") }), "phoneNumberInvalid", ), ( - ClientAuthError::PasswordMissing { - message: "x".to_string(), - }, + ClientAuthError::PasswordMissing(PasswordMissing { message: text("x") }), "passwordMissing", ), ( - ClientAuthError::LoginCodeMissing { - message: "x".to_string(), - }, + ClientAuthError::LoginCodeMissing(LoginCodeMissing { message: text("x") }), "loginCodeMissing", ), ( - ClientAuthError::PasswordEntryInputRejected { - message: "x".to_string(), - }, + ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { + message: text("x"), + }), "passwordEntryInputRejected", ), ( - ClientAuthError::PhoneOrPasswordMismatch { - message: "x".to_string(), - }, + ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { + message: text("x"), + }), "phoneOrPasswordMismatch", ), ( - ClientAuthError::SendCodeInputRejected { - message: "x".to_string(), - }, + ClientAuthError::SendCodeInputRejected(SendCodeInputRejected { + message: text("x"), + }), "sendCodeInputRejected", ), ( - ClientAuthError::SmsCodeThrottled { - message: "x".to_string(), - }, + ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { message: text("x") }), "smsCodeThrottled", ), ( - ClientAuthError::PhoneLoginInputRejected { - message: "x".to_string(), - }, + ClientAuthError::PhoneLoginInputRejected(PhoneLoginInputRejected { + message: text("x"), + }), "phoneLoginInputRejected", ), ( - ClientAuthError::SmsCodeInvalidOrExpired { - message: "x".to_string(), - }, + ClientAuthError::SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired { + message: text("x"), + }), "smsCodeInvalidOrExpired", ), ( - ClientAuthError::SessionAuthorityRejected { - message: "x".to_string(), - }, + ClientAuthError::SessionAuthorityRejected(SessionAuthorityRejected { + message: text("x"), + }), "sessionAuthorityRejected", ), ( - ClientAuthError::PermissionDenied { - message: "x".to_string(), - }, + ClientAuthError::PermissionDenied(PermissionDenied { message: text("x") }), "permissionDenied", ), ( - ClientAuthError::AuthNetworkUnavailable { - message: "x".to_string(), - }, + ClientAuthError::AuthNetworkUnavailable(AuthNetworkUnavailable { + message: text("x"), + }), "authNetworkUnavailable", ), ( - ClientAuthError::AuthServiceUnavailable { + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503, - message: "x".to_string(), - }, + message: text("x"), + }), "authServiceUnavailable", ), ( - ClientAuthError::UnexpectedRejection { + ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: 409, - message: "x".to_string(), - }, + message: text("x"), + }), "unexpectedRejection", ), ( - ClientAuthError::AuthResponseMalformed { - message: "x".to_string(), - }, + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { + message: text("x"), + }), "authResponseMalformed", ), ( - ClientAuthError::ClientSessionPersistFailed { - message: "x".to_string(), - }, + ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { + message: text("x"), + }), "clientSessionPersistFailed", ), ( - ClientAuthError::RuntimeSessionInstallFailed { - message: "x".to_string(), - }, + ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { + message: text("x"), + }), "runtimeSessionInstallFailed", ), ( - ClientAuthError::AuthClientInitFailed { - message: "x".to_string(), - }, + ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { message: text("x") }), "authClientInitFailed", ), ]; @@ -256,47 +392,54 @@ mod tests { #[test] fn machine_context_fields_survive_serialization() { - let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable { - status: 503, - message: "账号服务暂不可用".to_string(), - }) + let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable( + AuthServiceUnavailable { + status: 503, + message: "账号服务暂不可用".to_string(), + }, + )) .expect("serialize auth error"); assert_eq!(unavailable["type"], "authServiceUnavailable"); assert_eq!(unavailable["status"], 503); - let rejection = serde_json::to_value(ClientAuthError::UnexpectedRejection { - status: 409, - message: "冲突".to_string(), - }) - .expect("serialize auth error"); + let rejection = + serde_json::to_value(ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: 409, + message: "冲突".to_string(), + })) + .expect("serialize auth error"); assert_eq!(rejection["status"], 409); } #[test] fn only_session_authority_failures_count_as_authority_failures() { - assert!(ClientAuthError::SessionAuthorityRejected { - message: "x".to_string() - } - .is_authority_failure()); - assert!(ClientAuthError::PermissionDenied { - message: "x".to_string() - } - .is_authority_failure()); - assert!(!ClientAuthError::PhoneOrPasswordMismatch { - message: "x".to_string() - } - .is_authority_failure()); - assert!(!ClientAuthError::AuthNetworkUnavailable { - message: "x".to_string() - } - .is_authority_failure()); + assert!( + ClientAuthError::SessionAuthorityRejected(SessionAuthorityRejected { + message: text("x"), + }) + .is_authority_failure() + ); + assert!( + ClientAuthError::PermissionDenied(PermissionDenied { message: text("x") }) + .is_authority_failure() + ); + assert!( + !ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { + message: text("x"), + }) + .is_authority_failure() + ); + assert!( + !ClientAuthError::AuthNetworkUnavailable(AuthNetworkUnavailable { message: text("x") }) + .is_authority_failure() + ); } #[test] fn display_and_string_conversion_use_the_display_message() { - let error = ClientAuthError::PhoneOrPasswordMismatch { + let error = ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { message: "手机号或密码错误".to_string(), - }; + }); assert_eq!(error.to_string(), "手机号或密码错误"); assert_eq!(String::from(error), "手机号或密码错误"); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 9cd958cb8..efb7a210c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -4,7 +4,14 @@ //! 只在 Rust 内存与本进程会话快照里,refresh 凭据只写在 AppData 私有文件里。换号、登出或 //! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 -use crate::auth_error::ClientAuthError; +use crate::auth_error::{ + AuthClientInitFailed, AuthNetworkUnavailable, AuthResponseMalformed, AuthServiceUnavailable, + ClientAuthError, ClientSessionPersistFailed, LoginCodeMissing, PasswordEntryInputRejected, + PasswordMissing, PermissionDenied, PhoneLoginInputRejected, PhoneNumberInvalid, + PhoneOrPasswordMismatch, RuntimeSessionInstallFailed, SendCodeInputRejected, + ServerAddressRejected, SessionAuthorityRejected, SmsCodeInvalidOrExpired, SmsCodeThrottled, + UnexpectedRejection, +}; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; use reqwest::{header::SET_COOKIE, Method, StatusCode}; @@ -223,30 +230,32 @@ fn session_file_path(app: &tauri::AppHandle) -> Result app.path() .app_data_dir() .map(|root| root.join(SESSION_FILE_NAME)) - .map_err(|error| ClientAuthError::ClientSessionPersistFailed { - message: format!("无法读取 AGC 应用数据目录:{error}"), + .map_err(|error| { + ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { + message: format!("无法读取 AGC 应用数据目录:{error}"), + }) }) } /// 凭据文件相关的字符串错误统一收口成凭据落盘失败变体。 fn session_persist_error(message: impl Into) -> ClientAuthError { - ClientAuthError::ClientSessionPersistFailed { + ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { message: message.into(), - } + }) } /// 本机运行时安装 / 清理失败的字符串错误统一收口。 fn runtime_session_error(message: impl Into) -> ClientAuthError { - ClientAuthError::RuntimeSessionInstallFailed { + ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { message: message.into(), - } + }) } /// 服务地址校验(含渠道范围门禁)失败统一收口成业务变体。 fn server_address_rejected(message: impl Into) -> ClientAuthError { - ClientAuthError::ServerAddressRejected { + ClientAuthError::ServerAddressRejected(ServerAddressRejected { message: message.into(), - } + }) } /// 校验并归一化平台服务 origin。 @@ -428,19 +437,21 @@ fn build_client() -> Result { .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| ClientAuthError::AuthClientInitFailed { - message: "创建登录网络客户端失败".to_string(), + .map_err(|_| { + ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { + message: "创建登录网络客户端失败".to_string(), + }) }) } fn network_error_message(error: &reqwest::Error) -> ClientAuthError { - ClientAuthError::AuthNetworkUnavailable { + ClientAuthError::AuthNetworkUnavailable(AuthNetworkUnavailable { message: if error.is_timeout() { AUTH_NETWORK_TIMEOUT.to_string() } else { AUTH_NETWORK_ERROR.to_string() }, - } + }) } fn error_message(body: &str) -> Option { @@ -508,79 +519,92 @@ fn map_auth_failure( }; if status == StatusCode::UNAUTHORIZED { return match route { - AuthRoute::Session => ClientAuthError::SessionAuthorityRejected { - message: server_message.unwrap_or_else(|| AUTH_AUTHORITY_MESSAGE.to_string()), - }, - AuthRoute::PasswordEntry => ClientAuthError::PhoneOrPasswordMismatch { - message: server_message.unwrap_or_else(|| fallback.to_string()), - }, - AuthRoute::PhoneLogin => ClientAuthError::SmsCodeInvalidOrExpired { - message: server_message.unwrap_or_else(|| fallback.to_string()), - }, - _ => ClientAuthError::UnexpectedRejection { + AuthRoute::Session => { + ClientAuthError::SessionAuthorityRejected(SessionAuthorityRejected { + message: server_message.unwrap_or_else(|| AUTH_AUTHORITY_MESSAGE.to_string()), + }) + } + AuthRoute::PasswordEntry => { + ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { + message: server_message.unwrap_or_else(|| fallback.to_string()), + }) + } + AuthRoute::PhoneLogin => { + ClientAuthError::SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired { + message: server_message.unwrap_or_else(|| fallback.to_string()), + }) + } + _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, message: server_message.unwrap_or_else(|| fallback.to_string()), - }, + }), }; } if status == StatusCode::FORBIDDEN { return match route { - AuthRoute::Session => ClientAuthError::PermissionDenied { + AuthRoute::Session => ClientAuthError::PermissionDenied(PermissionDenied { message: server_message .unwrap_or_else(|| "当前陶泥儿账号没有执行此操作的权限".to_string()), - }, - _ => ClientAuthError::UnexpectedRejection { + }), + _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, message: server_message.unwrap_or_else(|| fallback.to_string()), - }, + }), }; } if status == StatusCode::TOO_MANY_REQUESTS && route == AuthRoute::SendCode { - return ClientAuthError::SmsCodeThrottled { + return ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { message: prefixed(), - }; + }); } if status == StatusCode::BAD_REQUEST { return match route { - AuthRoute::PasswordEntry => ClientAuthError::PasswordEntryInputRejected { + AuthRoute::PasswordEntry => { + ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { + message: prefixed(), + }) + } + AuthRoute::PhoneLogin => { + ClientAuthError::PhoneLoginInputRejected(PhoneLoginInputRejected { + message: prefixed(), + }) + } + AuthRoute::SendCode => ClientAuthError::SendCodeInputRejected(SendCodeInputRejected { message: prefixed(), - }, - AuthRoute::PhoneLogin => ClientAuthError::PhoneLoginInputRejected { - message: prefixed(), - }, - AuthRoute::SendCode => ClientAuthError::SendCodeInputRejected { - message: prefixed(), - }, - _ => ClientAuthError::UnexpectedRejection { + }), + _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, message: prefixed(), - }, + }), }; } if status.is_server_error() { - return ClientAuthError::AuthServiceUnavailable { + return ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: status_code, message: prefixed(), - }; + }); } - ClientAuthError::UnexpectedRejection { + ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, message: prefixed(), - } + }) } fn response_data(body: &str, fallback: &str) -> Result { - let value: Value = - serde_json::from_str(body).map_err(|_| ClientAuthError::AuthResponseMalformed { + let value: Value = serde_json::from_str(body).map_err(|_| { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: format!("{fallback}:登录服务响应不是合法 JSON"), - })?; + }) + })?; if value.get("ok").and_then(Value::as_bool) == Some(false) { - return Err(ClientAuthError::AuthResponseMalformed { - message: format!( - "{fallback}:{}", - error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) - ), - }); + return Err(ClientAuthError::AuthResponseMalformed( + AuthResponseMalformed { + message: format!( + "{fallback}:{}", + error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) + ), + }, + )); } Ok(value.get("data").cloned().unwrap_or(value)) } @@ -657,19 +681,20 @@ async fn request_auth( CookiePolicy::Capture | CookiePolicy::Require => refresh_cookie_from_response(&response), CookiePolicy::Ignore => None, }; - let text = response - .text() - .await - .map_err(|_| ClientAuthError::AuthResponseMalformed { + let text = response.text().await.map_err(|_| { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: format!("{fallback}:读取响应失败"), - })?; + }) + })?; if !status.is_success() { return Err(map_auth_failure(status, &text, fallback, auth_route(route))); } if matches!(policy, CookiePolicy::Require) && captured.is_none() { - return Err(ClientAuthError::AuthResponseMalformed { - message: "登录服务未返回新的续期凭据,已停止使用旧凭据".to_string(), - }); + return Err(ClientAuthError::AuthResponseMalformed( + AuthResponseMalformed { + message: "登录服务未返回新的续期凭据,已停止使用旧凭据".to_string(), + }, + )); } Ok(AuthResponse { data: response_data(&text, fallback)?, @@ -708,9 +733,11 @@ async fn commit_authenticated_session( identity_change: bool, ) -> Result { if token.chars().count() > MAX_SECRET_CHARS { - return Err(ClientAuthError::AuthResponseMalformed { - message: "登录服务返回的凭据无效".to_string(), - }); + return Err(ClientAuthError::AuthResponseMalformed( + AuthResponseMalformed { + message: "登录服务返回的凭据无效".to_string(), + }, + )); } // 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。 let (user_id, known_user) = identity.resolve()?; @@ -802,9 +829,11 @@ fn phone_is_valid(phone: &str) -> bool { fn validated_session_user_id(user_id: &str) -> Result { let user_id = user_id.trim(); if user_id.is_empty() { - return Err(ClientAuthError::AuthResponseMalformed { - message: "登录失败:登录服务未返回用户身份".to_string(), - }); + return Err(ClientAuthError::AuthResponseMalformed( + AuthResponseMalformed { + message: "登录失败:登录服务未返回用户身份".to_string(), + }, + )); } Ok(user_id.to_string()) } @@ -837,9 +866,9 @@ async fn fetch_current_user( ) .await?; let me: MeResponse = serde_json::from_value(response.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: "读取当前用户失败:响应格式无效".to_string(), - } + }) })?; Ok(me.user) } @@ -917,17 +946,16 @@ async fn refresh_session_inner( } }; let token: TokenResponse = serde_json::from_value(refreshed.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: "刷新登录状态失败:凭据响应格式无效".to_string(), - } + }) })?; let install_token = token.token.clone(); - let new_cookie = - refreshed - .refresh_cookie - .ok_or_else(|| ClientAuthError::AuthResponseMalformed { - message: "刷新登录状态失败:缺少新的续期凭据".to_string(), - })?; + let new_cookie = refreshed.refresh_cookie.ok_or_else(|| { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { + message: "刷新登录状态失败:缺少新的续期凭据".to_string(), + }) + })?; commit_authenticated_session( app, &session.api_base_url, @@ -1073,9 +1101,9 @@ pub(crate) async fn send_client_phone_login_code( let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err(ClientAuthError::PhoneNumberInvalid { + return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { message: "请输入正确的手机号".to_string(), - }); + })); } let client = build_client()?; let response = request_auth( @@ -1094,9 +1122,9 @@ pub(crate) async fn send_client_phone_login_code( ) .await?; let payload: SendCodeResponse = serde_json::from_value(response.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: "发送验证码失败:响应格式无效".to_string(), - } + }) })?; Ok(ClientLoginCodeView { cooldown_seconds: payload.cooldown_seconds, @@ -1110,15 +1138,15 @@ async fn complete_login( response: AuthResponse, ) -> Result { let payload: TokenUserResponse = serde_json::from_value(response.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: "登录失败:登录服务响应格式无效".to_string(), - } + }) })?; - let cookie = response - .refresh_cookie - .ok_or_else(|| ClientAuthError::AuthResponseMalformed { + let cookie = response.refresh_cookie.ok_or_else(|| { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: "登录失败:登录服务未返回续期凭据".to_string(), - })?; + }) + })?; let user = commit_authenticated_session( app, origin, @@ -1148,14 +1176,14 @@ pub(crate) async fn login_client_with_password( let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err(ClientAuthError::PhoneNumberInvalid { + return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { message: "请输入正确的手机号".to_string(), - }); + })); } if password.trim().is_empty() { - return Err(ClientAuthError::PasswordMissing { + return Err(ClientAuthError::PasswordMissing(PasswordMissing { message: "请输入密码".to_string(), - }); + })); } let client = build_client()?; let response = request_auth( @@ -1186,14 +1214,14 @@ pub(crate) async fn login_client_with_phone_code( let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err(ClientAuthError::PhoneNumberInvalid { + return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { message: "请输入正确的手机号".to_string(), - }); + })); } if code.trim().is_empty() { - return Err(ClientAuthError::LoginCodeMissing { + return Err(ClientAuthError::LoginCodeMissing(LoginCodeMissing { message: "请输入验证码".to_string(), - }); + })); } let client = build_client()?; let response = request_auth( @@ -1321,7 +1349,7 @@ mod tests { assert!(!transient.is_authority_failure()); assert!(matches!( transient, - ClientAuthError::AuthServiceUnavailable { status: 500, .. } + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500, .. }) )); assert!(transient.message().starts_with("刷新失败")); } @@ -1336,9 +1364,9 @@ mod tests { ); assert_eq!( wrong_password, - ClientAuthError::PhoneOrPasswordMismatch { + ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { message: "手机号或密码错误".to_string() - } + }) ); assert!(!wrong_password.is_authority_failure()); @@ -1361,7 +1389,7 @@ mod tests { ); assert!(matches!( password_length, - ClientAuthError::PasswordEntryInputRejected { .. } + ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { .. }) )); assert_eq!( password_length.message(), @@ -1376,7 +1404,7 @@ mod tests { ); assert!(matches!( throttled, - ClientAuthError::SmsCodeThrottled { .. } + ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { .. }) )); assert_eq!(throttled.message(), "发送验证码失败:发送过于频繁"); @@ -1388,7 +1416,7 @@ mod tests { ); assert!(matches!( bad_code, - ClientAuthError::SmsCodeInvalidOrExpired { .. } + ClientAuthError::SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired { .. }) )); assert_eq!(bad_code.message(), "验证码错误"); } diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts b/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts new file mode 100644 index 000000000..c714a8c25 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 认证网络客户端构建失败。 + */ +export type AuthClientInitFailed = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts new file mode 100644 index 000000000..87df5480a --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 连接 / 超时 / DNS 等网络失败。 + */ +export type AuthNetworkUnavailable = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts new file mode 100644 index 000000000..e7fa63683 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 响应不是合法 JSON、缺少必需字段或契约不成立。 + */ +export type AuthResponseMalformed = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts new file mode 100644 index 000000000..ea46b2edd --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 服务端 5xx。 + */ +export type AuthServiceUnavailable = { status: number; message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts index c80d66ae0..fb7dc66ce 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -1,25 +1,44 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AuthClientInitFailed } from './AuthClientInitFailed'; +import type { AuthNetworkUnavailable } from './AuthNetworkUnavailable'; +import type { AuthResponseMalformed } from './AuthResponseMalformed'; +import type { AuthServiceUnavailable } from './AuthServiceUnavailable'; +import type { ClientSessionPersistFailed } from './ClientSessionPersistFailed'; +import type { LoginCodeMissing } from './LoginCodeMissing'; +import type { PasswordEntryInputRejected } from './PasswordEntryInputRejected'; +import type { PasswordMissing } from './PasswordMissing'; +import type { PermissionDenied } from './PermissionDenied'; +import type { PhoneLoginInputRejected } from './PhoneLoginInputRejected'; +import type { PhoneNumberInvalid } from './PhoneNumberInvalid'; +import type { PhoneOrPasswordMismatch } from './PhoneOrPasswordMismatch'; +import type { RuntimeSessionInstallFailed } from './RuntimeSessionInstallFailed'; +import type { SendCodeInputRejected } from './SendCodeInputRejected'; +import type { ServerAddressRejected } from './ServerAddressRejected'; +import type { SessionAuthorityRejected } from './SessionAuthorityRejected'; +import type { SmsCodeInvalidOrExpired } from './SmsCodeInvalidOrExpired'; +import type { SmsCodeThrottled } from './SmsCodeThrottled'; +import type { UnexpectedRejection } from './UnexpectedRejection'; /** - * 变体名就是线上的分流键(`type`)。 + * 变体名就是线上的分流键(`type`),每个变体持有同名载荷类型。 */ export type ClientAuthError = - | { type: 'serverAddressRejected'; message: string } - | { type: 'phoneNumberInvalid'; message: string } - | { type: 'passwordMissing'; message: string } - | { type: 'loginCodeMissing'; message: string } - | { type: 'passwordEntryInputRejected'; message: string } - | { type: 'phoneOrPasswordMismatch'; message: string } - | { type: 'sendCodeInputRejected'; message: string } - | { type: 'smsCodeThrottled'; message: string } - | { type: 'phoneLoginInputRejected'; message: string } - | { type: 'smsCodeInvalidOrExpired'; message: string } - | { type: 'sessionAuthorityRejected'; message: string } - | { type: 'permissionDenied'; message: string } - | { type: 'authNetworkUnavailable'; message: string } - | { type: 'authServiceUnavailable'; status: number; message: string } - | { type: 'unexpectedRejection'; status: number; message: string } - | { type: 'authResponseMalformed'; message: string } - | { type: 'clientSessionPersistFailed'; message: string } - | { type: 'runtimeSessionInstallFailed'; message: string } - | { type: 'authClientInitFailed'; message: string }; + | ({ type: 'serverAddressRejected' } & ServerAddressRejected) + | ({ type: 'phoneNumberInvalid' } & PhoneNumberInvalid) + | ({ type: 'passwordMissing' } & PasswordMissing) + | ({ type: 'loginCodeMissing' } & LoginCodeMissing) + | ({ type: 'passwordEntryInputRejected' } & PasswordEntryInputRejected) + | ({ type: 'phoneOrPasswordMismatch' } & PhoneOrPasswordMismatch) + | ({ type: 'sendCodeInputRejected' } & SendCodeInputRejected) + | ({ type: 'smsCodeThrottled' } & SmsCodeThrottled) + | ({ type: 'phoneLoginInputRejected' } & PhoneLoginInputRejected) + | ({ type: 'smsCodeInvalidOrExpired' } & SmsCodeInvalidOrExpired) + | ({ type: 'sessionAuthorityRejected' } & SessionAuthorityRejected) + | ({ type: 'permissionDenied' } & PermissionDenied) + | ({ type: 'authNetworkUnavailable' } & AuthNetworkUnavailable) + | ({ type: 'authServiceUnavailable' } & AuthServiceUnavailable) + | ({ type: 'unexpectedRejection' } & UnexpectedRejection) + | ({ type: 'authResponseMalformed' } & AuthResponseMalformed) + | ({ type: 'clientSessionPersistFailed' } & ClientSessionPersistFailed) + | ({ type: 'runtimeSessionInstallFailed' } & RuntimeSessionInstallFailed) + | ({ type: 'authClientInitFailed' } & AuthClientInitFailed); diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts new file mode 100644 index 000000000..ca606de34 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本机登录凭据文件读写失败。 + */ +export type ClientSessionPersistFailed = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts b/apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts new file mode 100644 index 000000000..f2aba1175 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本地前置校验:验证码为空。 + */ +export type LoginCodeMissing = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts new file mode 100644 index 000000000..b301d0721 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/entry` 返回 400:服务端拒绝本次输入。 + */ +export type PasswordEntryInputRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts b/apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts new file mode 100644 index 000000000..4ebefdc04 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本地前置校验:密码为空。 + */ +export type PasswordMissing = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts b/apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts new file mode 100644 index 000000000..95f45c21b --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会话路由 403:当前账号没有执行此操作的权限。 + */ +export type PermissionDenied = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts new file mode 100644 index 000000000..0e6ddc711 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/phone/login` 返回 400。 + */ +export type PhoneLoginInputRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts new file mode 100644 index 000000000..93946112e --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本地前置校验:手机号为空或格式不合法。 + */ +export type PhoneNumberInvalid = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts new file mode 100644 index 000000000..e7dc6f7a7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/entry` 返回 401:手机号或密码错误。 + */ +export type PhoneOrPasswordMismatch = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts new file mode 100644 index 000000000..57a6af5a9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本机运行时会话安装 / 清理失败。 + */ +export type RuntimeSessionInstallFailed = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts b/apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts new file mode 100644 index 000000000..df085f092 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/phone/send-code` 返回 400。 + */ +export type SendCodeInputRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts b/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts new file mode 100644 index 000000000..c37d41541 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 服务地址不是合法 origin / 非本机未用 HTTPS / 不在构建渠道范围内。 + */ +export type ServerAddressRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts b/apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts new file mode 100644 index 000000000..3b145b00f --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会话路由 401:登录态权威失效。 + */ +export type SessionAuthorityRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts b/apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts new file mode 100644 index 000000000..031366003 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/phone/login` 返回 401:验证码错误或过期。 + */ +export type SmsCodeInvalidOrExpired = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts b/apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts new file mode 100644 index 000000000..7202e4abc --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/phone/send-code` 返回 429:发送过于频繁。 + */ +export type SmsCodeThrottled = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts b/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts new file mode 100644 index 000000000..8a26dc85b --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 + */ +export type UnexpectedRejection = { status: number; message: string }; diff --git a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts index 83ec77c2a..584a63cd2 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts @@ -1,8 +1,46 @@ import { describe, expect, it } from 'vitest'; import { isClientAuthError } from '../src/services/clientAuthError'; +import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; +import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; +import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid'; + +/** + * 编译期用例:每个变体在 `type` 上判别后都能落到一个**具名载荷类型**, + * 等价于 Java 的 `catch (PhoneNumberInvalid e)`;类型不匹配时 `tsc` 会直接报错。 + */ +function handleByNamedType(error: ClientAuthError) { + switch (error.type) { + case 'phoneNumberInvalid': { + const payload: PhoneNumberInvalid = error; + return payload.message; + } + case 'authServiceUnavailable': { + const payload: AuthServiceUnavailable = error; + return `${payload.status}:${payload.message}`; + } + default: + return ''; + } +} describe('AGC 认证命令错误的形状读取', () => { + it('判别后按具名载荷类型分支处理', () => { + expect( + handleByNamedType({ + type: 'phoneNumberInvalid', + message: '请输入正确的手机号', + }), + ).toBe('请输入正确的手机号'); + expect( + handleByNamedType({ + type: 'authServiceUnavailable', + status: 503, + message: '登录服务暂时不可用', + }), + ).toBe('503:登录服务暂时不可用'); + }); + it('只按形状读取 Rust 的结构化拒绝,裸字符串与 Error 都不算', () => { expect( isClientAuthError({ -- 2.52.0 From 478b9c989176de9e9fba1fe4bb3eb9800e131508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 20:33:47 +0800 Subject: [PATCH 18/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=85=88=E8=A1=8C?= =?UTF-8?q?=EF=BC=9AAGC=20=E8=AE=A4=E8=AF=81=E5=A4=B1=E8=B4=A5=E7=9A=84=20?= =?UTF-8?q?JS=20=E4=BE=A7=E8=BD=BD=E4=BD=93=E4=B8=8E=E6=8A=9B=E5=87=BA?= =?UTF-8?q?=E6=97=B6=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ADR:认证命令统一经 invokeClientAuth 把结构化拒绝转成 ClientAuthFailure(payload 判别联合);判定只写在 catch 子句里,系统变体原样抛出经 unhandledrejection 入池,default: expectNever 编译期挡住漏接变体 - 修订【ADR】AGC命令错误结构化与错误报告口径 §3 与后果:撤销"未识别变体上调是故意的",改为编译期穷尽 - 技术方案同步认证命令的封装形态与两个 ADR 链接 - decision-log / pitfalls 记录第二轮口径:删除形状读取与文案回落层、Rust 失败不再降级成字符串 - docs/README.md 登记新 ADR --- docs/README.md | 1 + ...GC命令错误结构化与错误报告口径-2026-10-01.md | 22 ++-- ...GC认证失败的JS侧载体与抛出时机-2026-10-01.md | 121 ++++++++++++++++++ .../shared-memory/decision-log.md | 4 + docs/project-memory/shared-memory/pitfalls.md | 9 +- ...€术方案】AGC错误报告与诊断上传-2026-08-31.md | 4 +- 6 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md diff --git a/docs/README.md b/docs/README.md index 536b1346c..07278dae9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,6 +52,7 @@ - [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 - [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。 - [AGC 命令错误结构化与错误报告口径](./adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md):AGC 命令失败按具体变体建模并用 ts-rs 导出,前端按变体分流、不匹配文案;报告池只收没人处理的错误。 +- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把结构化拒绝转成 `ClientAuthFailure`(`payload` 判别联合);判定只写在 catch 子句里,系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。 - [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 8bf16e0d7..a580d5b04 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -59,18 +59,16 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 (`shouldCaptureClientError` 只认测试构造过、生产代码从不产生的 `{status}` / `{networkError}` 形状,随本 ADR 删除);4xx 一律不报,交给上层调用方。 -### 3. 前端按变体分流,认不出就交池 +### 3. 前端按变体分流(2026-10-01 修订) -- `isClientAuthError` 只做形状读取(`type` 是稳定判别键),**不做分类、不产出派生值**。 -- 调用方自己 `switch (error.type)`,逐个列出具体变体:业务输入 / 会话变体给提示,`default`(系统变体 - 与 Rust 新增而界面没接的变体)带上文交池。判据是具体变体名,不是聚合出来的类别,也不是文案匹配; - 每个 `case` 里 `error` 已窄化成 §1 的具名载荷类型(如 `PhoneNumberInvalid`)。 -- 认不出、系统类、非结构化拒绝 → 调用方包成 `ClientActionError(message, context, cause)` 交给 - `captureClientError`:`instanceof` 解包 `context` 与 `cause`,指纹/展示字段与今天一致。 - **不把 rejection 留在没人接手的 Promise 上**:`onSubmit` / `onClick` 这类 `void` 掉的 handler 抛错 - 最终以 `unhandledrejection` 结算,生产 WebView 里也能入池,但在 jsdom 下既不触发 `window` - 的 `unhandledrejection` 事件、又会让 `vitest run` 以 unhandled error 失败;调用方判定完直接 - 交给错误池,语义相同、可断言。全局 handler 仍保留同一套解包,接住真正漏出的 `ClientActionError`。 +- 本节原先的"调用方用 `isClientAuthError` 读形状、`switch (error.type)` 分流、`default` 交池"已被 + [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](./【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md) + 取代:`invokeClientAuth` 把结构化拒绝转成 `ClientAuthFailure`(`payload` 是判别联合),判定只写在 + catch 子句里,每个 `case` 用 `as` 取具名载荷类型。 +- "未识别变体上调是**故意**的"不再成立:`default` 改为 `expectNever(error.payload)`,漏接变体是 + **编译错误**,不再是运行时报进池。 +- 系统变体与非结构化拒绝仍由调用方原样 `throw`,经全局 `unhandledrejection` 入池; + `captureClientError` 的 `instanceof ClientActionError` 解包保持不变。 - 删除 `shouldCaptureClientError`:不再存在"叶子自己判定要不要报"的口径。 ### 4. 报告面板与通知行为不变 @@ -84,7 +82,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 及其共用链路(`request_auth` / `map_auth_failure` / `response_data` / `network_error_message`)全量改为 `Result<_, ClientAuthError>`;`read_client_auth_state`、`refresh_client_auth_session`、 `logout_client_session` 的失败面同步结构化(会话 401/403 仍是"未登录"路径,不是错误)。 -- 未识别变体上调是**故意**的:Rust 与 TS 同包发布,"认不出"意味着有人加了变体忘了接界面,属于缺陷。 +- 未识别变体不再靠运行时"上调"兜底:前端 switch 必须列全变体,靠 `expectNever` 在编译期挡住漏接。 - 仍保留的显式采集点(`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic`) 在后续变更里按同一口径重抛/删除,本 ADR 不改它们的行为。 diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md new file mode 100644 index 000000000..5bf93e66c --- /dev/null +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -0,0 +1,121 @@ +# 【ADR】AGC 认证失败的 JS 侧载体与抛出时机 + +状态:已接受(2026-10-01 落地,实施顺序见同日的决策记录与 +[`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md)) + +前置:[`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](./【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) +已经把 Rust 侧的认证命令失败建模成 `ClientAuthError`,ts-rs 生成 +`{ type: 'x' } & X` 的判别联合。本 ADR 只解决这份结构化拒绝到了 WebView 之后 +"怎么传、谁来判、什么时候抛"。 + +## 背景 + +Rust 已经返回结构化错误,但 Tauri 的 `invoke` 拒绝值是**普通对象**,不是 `Error`: + +- 调用方 `catch (error)` 拿到的是 `{ type, message, ... }`,没有栈。原样 `throw` 它, + 上报链路的 `error instanceof Error` 判断会把它降级成 `new Error(String(error))` + (`[object Object]`),文案与类型一起丢掉。 +- 前一版在渲染层加了 `isClientAuthError` / `getClientAuthErrorMessage` / + `presentAuthFailure` 三层:形状读取、文案回落、分类提示。它们既不是类型事实源,又在 + "取文案"里悄悄承担了"要不要上报"的判断,与"由调用方判定"的口径冲突。 + +## 决策 + +### 1. 一个 JS 载体类:`ClientAuthFailure`,结构化字段名 `payload` + +```ts +class ClientAuthFailure extends ClientActionError { + readonly payload: ClientAuthError; // ts-rs 判别联合,payload.type 是分流键 +} +``` + +- 类名不叫 `ClientAuthError`:那个名字已经被 ts-rs 生成物占用;JS 类与生成类型同名会让 + 两个事实源混在一起。 +- 它继承 `ClientActionError`,所以 `captureClientError` 能用 `instanceof` 解包 + `source: 'auth'`、`action: 命令名`(错误池指纹维度)与 `cause: 原始拒绝值`。 + 上报方不需要知道上游是谁。 +- `payload` 是完整的结构化拒绝(判别联合),不是再抄一份的派生字段;`payload.type` + 就是分流键。**不再新增 `kind` / `notice` / `severity` 之类的派生值。** + +### 2. 一个包装函数:`invokeClientAuth` + +`clientAuth.ts` 里所有认证命令都经它调用: + +```ts +async function invokeClientAuth(command, args): Promise { + try { + return await requireInvoke()(command, args); + } catch (error) { + const rejection = error as { type?: unknown }; + if (typeof rejection?.type !== 'string') throw error; // 非结构化:原样抛出 + throw new ClientAuthFailure(...); + } +} +``` + +- `type` 是 `string` 即认为是 Rust 结构化拒绝:形状由 ts-rs 保证,**不校验字段名与文案**。 +- 非结构化拒绝原样抛出:那是 Tauri / JS 运行时自己的错误,不属于认证命令契约。 +- 该包装是"Rust 结构化错误 → JS 错误对象"的唯一转换点:不做分类、不读文案判断、不兜底文案。 + +### 3. 判定只写在 catch 子句里,用具体变体 + +```ts +catch (error) { + if (!(error instanceof ClientAuthFailure)) throw error; + switch (error.payload.type) { + case 'phoneNumberInvalid': { + const payload = error.payload as PhoneNumberInvalid; + setLoginStatus(payload.message); + break; + } + // ... 每个业务 / 会话变体一个分支 + case 'authNetworkUnavailable': + // ... 系统变体逐个列出后原样抛出 + throw error; + default: + expectNever(error.payload); + } +} +``` + +- **每个 `case` 用 `as` 取自己的具名载荷类型**,不写 `error.payload.message` 这种跨变体的 + 通用读取;等价于 Java 的 `catch (PhoneNumberInvalid e)`。 +- 业务 / 会话变体:把载荷自带的 `message` 原样交给用户,**不加兜底文案**(那就是 Rust 生成的 + 那一份)。 +- 系统变体:调用方处理不了,**原样 `throw`**。`onSubmit` / `onClick` 这类 `void` 掉的 handler + 抛出的拒绝最终以 `unhandledrejection` 结算,由全局 handler 交给错误池。 +- `default: expectNever(error.payload)`(`expectNever(value: never)`)让"Rust 加了变体而这里 + 没接"变成**编译错误**。这是上一版"未识别变体上调是故意的"的替代方案:判据从运行时前移到 + 编译期。 +- 不把这段 switch 抽成 presenter / helper 函数:判定必须发生在 catch 里,包装函数只负责 + "把结构化拒绝转成 JS 错误"。 + +### 4. Rust 侧失败不再降级成字符串 + +结构化必须一路到底:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`, +`ClientAuthStateView` / `ClientAuthRefreshView` 不再有 `errorMessage` 字段,续期结果不再有 +`failed` 状态(`authoritative` 只在"未登录"上为 true,`failed` 恒为 false,删除它不丢信息)。 + +- `ClientAuthState` 收敛为 `authenticated | unauthenticated`:读状态失败就是命令失败,由 + `invokeClientAuth` 转成 `ClientAuthFailure`,不再有第三种 "unavailable 投影"。 +- `ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 + +## 后果与边界 + +- 新增认证命令或新增 `ClientAuthError` 变体,必须同时改两个 catch 的 switch,否则 `tsc` 失败。 +- `platformSession` 续期失败继续按"网络类失败不降级身份、不标权威失败"处理 + (`authoritative: false`),与旧 `failed` 分支语义一致。 +- 用户可见行为:业务拒绝仍是登录页一行提示;系统失败仍是错误池事件 + 「发现问题」通知。 +- 全局 `unhandledrejection` 是系统变体的唯一出口,调用方不再直接调 `captureClientError`。 + +## 验收 + +```text +npm run ai-game-creator-shell:typecheck +npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx apps/ai-game-creator-shell/tests/clientAuthHost.test.ts apps/ai-game-creator-shell/tests/platformSession.test.ts +cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error +npm run check:generated-bindings +npm run check:doc-index +npm run check:encoding +git diff --check +``` diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 673a0de80..63db45b0f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8,6 +8,10 @@ - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 每个变体一个载荷文件)。 - 决策(补充):TS 形状用**每个变体一个具名载荷类型**——Rust 枚举是 newtype 变体持有同名 `#[ts(export)]` 结构体,ts-rs 生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`,前端 `switch (error.type)` 的每个分支直接拿到具名类型。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 +- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;结构化拒绝转成 `ClientAuthFailure`(`extends ClientActionError`,`readonly payload` 是 ts-rs 判别联合,`context = { source: 'auth', action: command }`,`cause` 是原始拒绝值),非结构化拒绝原样抛出。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 +- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (error.payload.type)` 判,每个 `case` 用 `as` 取具名载荷类型;业务 / 会话变体把载荷自带 `message` 原样给用户(无兜底文案),系统变体 `throw error` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包上下文),`default: expectNever(error.payload)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 +- 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 +- 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuthFailure.ts,clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 ## 2026-10-01 DirectProject 审批拒绝原因留痕 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 54a4d1fcc..a9830eed7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6,10 +6,11 @@ - **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。 - **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 -- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),UI 调用方按 `type` 分流:认得的业务变体只给用户反馈,系统变体/未识别变体/非结构化拒绝由调用方包成 `ClientActionError` 交给错误池;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)。 -- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/errorReporting.test.ts`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体带上文只上报一次且 `source=auth`。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 -- **形状约定**:`ClientAuthError` 的每个变体在 TS 里是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律 `switch (error.type)`,每个分支里 `error` 已经窄化成具名载荷类型。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier。 -- **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/errorReporting.ts`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 +- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝转成 `ClientAuthFailure`(`payload` 是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 +- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次且 `source=auth`(`ClientAuthFailure.context.action` 是命令名)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 +- **形状约定**:`ClientAuthError` 的每个变体在 TS 里是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`switch (error.payload.type)`,每个 `case` 用 `as` 取具名载荷类型,`default: expectNever(error.payload)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier。 +- **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuthFailure.ts,clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 ## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index fae769780..73c35757a 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -8,10 +8,10 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 诊断 URL 保留可定位的 API 路由路径,隐藏 origin、URL 账号密码、查询参数、fragment 和路径中的敏感标识;普通资源 URL 与本地文件路径继续隐藏。网络错误、HTTP 错误与响应体超时均应带安全路由,不能只剩 ``。历史已经脱敏的归档不推测或补造原路由。 -- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientActionError`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)。 +- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientActionError`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 -- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。 +- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 把结构化拒绝转成 `ClientAuthFailure`(`extends ClientActionError`,`payload` 为判别联合,`context.action` 是命令名),判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 - 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 -- 2.52.0 From 0798bf52194237d8ab6644e54f90e358a93a9dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 20:35:47 +0800 Subject: [PATCH 19/70] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E7=BB=AD?= =?UTF-8?q?=E6=9C=9F=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=8D=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E6=88=90=E5=AD=97=E7=AC=A6=E4=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refresh_session_inner 的非权威失败直接返回 Err(ClientAuthError),不再折成 error_message 字符串 - ClientAuthStateView 删除 error_message 字段,状态收窄为 authenticated / unauthenticated - ClientAuthRefreshView 删除 error_message 与 failed 状态,authoritative 只在未登录时为 true - read_client_auth_state 把续期失败原样透传给命令出口,不再投影出 unavailable 第三态 --- .../src-tauri/src/auth_session.rs | 52 ++++--------------- 1 file changed, 11 insertions(+), 41 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index efb7a210c..592f5ef95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -49,14 +49,12 @@ const AUTH_AUTHORITY_MESSAGE: &str = "登录状态已失效,请重新登录"; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ClientAuthStateView { - /// `authenticated` / `unauthenticated` / `unavailable`。 + /// `authenticated` / `unauthenticated`。 pub(crate) status: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) user: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) api_base_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) error_message: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -66,18 +64,17 @@ pub(crate) struct ClientLoginCodeView { pub(crate) expires_in_seconds: u64, } -/// 续期结果。`stale` 表示续期期间身份已经变化,调用方不得重放旧身份请求。 +/// 续期结果。`stale` 表示续期期间身份已经变化,调用方不得重放旧身份请求; +/// 非权威失败(网络 / 5xx / 契约异常)不在这里降级成字符串,直接以 `Err(ClientAuthError)` 返回。 #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ClientAuthRefreshView { - /// `refreshed` / `unauthenticated` / `stale` / `failed`。 + /// `refreshed` / `unauthenticated` / `stale`。 pub(crate) status: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) user: Option, #[serde(default)] pub(crate) authoritative: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) error_message: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] @@ -808,7 +805,6 @@ fn auth_state_view( status: status.to_string(), user, api_base_url, - error_message: None, } } @@ -886,7 +882,6 @@ async fn refresh_session_inner( status: "unauthenticated".to_string(), user: None, authoritative: true, - error_message: None, }); }; let _guard = refresh_lock().lock().await; @@ -902,7 +897,6 @@ async fn refresh_session_inner( status: "refreshed".to_string(), user: Some(user), authoritative: false, - error_message: None, }); } } @@ -934,15 +928,11 @@ async fn refresh_session_inner( status: "unauthenticated".to_string(), user: None, authoritative: true, - error_message: None, }); } - return Ok(ClientAuthRefreshView { - status: "failed".to_string(), - user: None, - authoritative: false, - error_message: Some(error.message().to_string()), - }); + // 非权威失败不再降级成字符串:把 typed error 原样交给命令出口, + // 由前端 `invokeClientAuth` 转成 `ClientAuthFailure` 并按变体分流。 + return Err(error); } }; let token: TokenResponse = serde_json::from_value(refreshed.data).map_err(|_| { @@ -977,7 +967,6 @@ async fn refresh_session_inner( status: "stale".to_string(), user: None, authoritative: false, - error_message: None, }); } let view = auth_state_view( @@ -990,7 +979,6 @@ async fn refresh_session_inner( status: "refreshed".to_string(), user: Some(user), authoritative: false, - error_message: None, }) } Ok(None) => { @@ -1001,7 +989,6 @@ async fn refresh_session_inner( status: "unauthenticated".to_string(), user: None, authoritative: true, - error_message: None, }) } Err(error) if error.is_authority_failure() => { @@ -1012,15 +999,9 @@ async fn refresh_session_inner( status: "unauthenticated".to_string(), user: None, authoritative: true, - error_message: None, }) } - Err(error) => Ok(ClientAuthRefreshView { - status: "failed".to_string(), - user: None, - authoritative: false, - error_message: Some(error.message().to_string()), - }), + Err(error) => Err(error), } } @@ -1063,20 +1044,10 @@ pub(crate) async fn read_client_auth_state( refresh.user, Some(session.api_base_url), )), - "stale" | "unauthenticated" => Ok(auth_state_view("unauthenticated", None, None)), - _ => Ok(ClientAuthStateView { - status: "unavailable".to_string(), - user: None, - api_base_url: Some(session.api_base_url), - error_message: refresh.error_message, - }), + // `stale`(续期期间身份已变化)与 `unauthenticated` 都按未登录处理。 + _ => Ok(auth_state_view("unauthenticated", None, None)), }, - Err(error) => Ok(ClientAuthStateView { - status: "unavailable".to_string(), - user: None, - api_base_url: Some(session.api_base_url), - error_message: Some(error.message().to_string()), - }), + Err(error) => Err(error), } } @@ -1493,7 +1464,6 @@ mod tests { wechat_account: None, }), api_base_url: Some(DEVELOPMENT_ORIGIN.to_string()), - error_message: None, }; let serialized = serde_json::to_string(&view).expect("serialize auth state"); for forbidden in [ -- 2.52.0 From c940bf502c8a2b0be14d863b729d46934a3fd47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 20:49:43 +0800 Subject: [PATCH 20/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E8=AE=A4=E8=AF=81=20ADR=20=E5=AF=B9=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=8F=98=E4=BD=93=E5=88=86=E6=94=AF=E7=9A=84=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 说明系统变体不读载荷、只原样抛出,但变体名必须逐个列出以让 default: expectNever 成立 - 业务 / 会话变体仍要求每个 case 用 as 取具名载荷类型 --- .../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index 5bf93e66c..9763dd0fa 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -78,8 +78,9 @@ catch (error) { } ``` -- **每个 `case` 用 `as` 取自己的具名载荷类型**,不写 `error.payload.message` 这种跨变体的 - 通用读取;等价于 Java 的 `catch (PhoneNumberInvalid e)`。 +- **每个业务 / 会话 `case` 用 `as` 取自己的具名载荷类型**,不写 `error.payload.message` 这种 + 跨变体的通用读取;等价于 Java 的 `catch (PhoneNumberInvalid e)`。系统变体不读载荷(调用方 + 只负责原样抛出),但变体名必须逐个列出,`default` 的 `expectNever` 才成立。 - 业务 / 会话变体:把载荷自带的 `message` 原样交给用户,**不加兜底文案**(那就是 Rust 生成的 那一份)。 - 系统变体:调用方处理不了,**原样 `throw`**。`onSubmit` / `onClick` 这类 `void` 掉的 handler -- 2.52.0 From ce54c729e82f5f718b2905020d426ec32304a837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 20:49:48 +0800 Subject: [PATCH 21/70] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=A4=B1=E8=B4=A5=E6=94=B9=E4=B8=BA=20JS=20=E4=BE=A7?= =?UTF-8?q?=E8=BD=BD=E4=BD=93=E5=B9=B6=E5=8F=AA=E6=8C=89=E5=8F=98=E4=BD=93?= =?UTF-8?q?=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ClientAuthFailure:payload 为 ts-rs 判别联合,context 固定 source=auth + 命令名,cause 为原始拒绝值 - clientAuth 的六个命令统一走 invokeClientAuth:结构化拒绝转成 ClientAuthFailure,非结构化拒绝原样抛出,不兜底文案 - 删除 clientAuthError.ts 的形状读取与 getClientAuthErrorMessage 文案回落层 - AuthenticatedClient 的两个 catch 内联 19 个变体的 switch:业务 / 会话变体原样展示载荷 message,系统变体原样抛出经 unhandledrejection 入池,default: expectNever 保证漏接变体编译失败 - hydrateAuth 失败先离开 loading 态;系统变体不再伪装成 unavailable 投影 - ClientAuthState 收窄为 authenticated / unauthenticated,ClientAuthRefreshResult 收窄为 refreshed / unauthenticated / stale - platformSession 续期只保留 refreshed / unauthenticated / stale 三条路径 - check-config 的 App 调用扫描登记 invokeClientAuth,继续静态证明命令名可达 - 测试:新增 clientAuthFailure 载体与编译期穷尽用例、jsdom unhandledrejection 桥;删除 clientAuthError.test.ts;clientAuthHost / authFailureReporting / authSurface 跟改新口径 - 共享记忆记录 check-config 扫描形态新增 invokeClientAuth --- .../scripts/check-config.mjs | 1 + .../src/app/AuthenticatedClient.tsx | 318 +++++++++++++++--- .../src/services/clientAuth.ts | 136 ++++---- .../src/services/clientAuthError.ts | 23 -- .../src/services/clientAuthFailure.ts | 30 ++ .../src/services/platformSession.ts | 10 +- .../tests/appSurface/auth.suite.ts | 111 +++--- .../tests/authFailureReporting.test.tsx | 63 +++- .../tests/clientAuthError.test.ts | 77 ----- .../tests/clientAuthFailure.test.ts | 164 +++++++++ .../tests/clientAuthHost.test.ts | 40 +-- .../tests/unhandledRejectionBridge.ts | 27 ++ .../shared-memory/decision-log.md | 2 +- 13 files changed, 672 insertions(+), 330 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/services/clientAuthError.ts create mode 100644 apps/ai-game-creator-shell/src/services/clientAuthFailure.ts delete mode 100644 apps/ai-game-creator-shell/tests/clientAuthError.test.ts create mode 100644 apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts create mode 100644 apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 1b964573f..5a2b4bf24 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -432,6 +432,7 @@ const APP_INVOKE_BARE_CALL_NAMES = new Set([ 'directInvoke', 'invokeInput', 'invokeAuthenticatedInput', + 'invokeClientAuth', 'invokeDiagnostic', ]); diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index a77f00f30..af9ab45f6 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -11,9 +11,7 @@ import { import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice'; -import { ClientActionError } from '../services/clientActionError'; import { - getClientAuthErrorMessage, loginClientWithPassword, loginClientWithPhoneCode, logoutClientAuthSession, @@ -22,7 +20,7 @@ import { sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; -import { isClientAuthError } from '../services/clientAuthError'; +import { ClientAuthFailure } from '../services/clientAuthFailure'; import { type ClientServerPreset, type ClientServerSelection, @@ -37,6 +35,18 @@ import { captureClientError, installWebviewLogBridge, } from '../services/errorReporting'; +import type { LoginCodeMissing } from '../services/generated/LoginCodeMissing'; +import type { PasswordEntryInputRejected } from '../services/generated/PasswordEntryInputRejected'; +import type { PasswordMissing } from '../services/generated/PasswordMissing'; +import type { PermissionDenied } from '../services/generated/PermissionDenied'; +import type { PhoneLoginInputRejected } from '../services/generated/PhoneLoginInputRejected'; +import type { PhoneNumberInvalid } from '../services/generated/PhoneNumberInvalid'; +import type { PhoneOrPasswordMismatch } from '../services/generated/PhoneOrPasswordMismatch'; +import type { SendCodeInputRejected } from '../services/generated/SendCodeInputRejected'; +import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected'; +import type { SessionAuthorityRejected } from '../services/generated/SessionAuthorityRejected'; +import type { SmsCodeInvalidOrExpired } from '../services/generated/SmsCodeInvalidOrExpired'; +import type { SmsCodeThrottled } from '../services/generated/SmsCodeThrottled'; import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, @@ -67,6 +77,14 @@ const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000; // 30s startup deadline. Keep the UI fence slightly above that worst case. const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000; +/** + * 编译期穷尽检查:参数只能是 `never`,switch 漏掉任何一个变体这一行就编译不过。 + * 运行时不做事(`void`),所以 `default` 分支还要自己 `throw`。 + */ +function expectNever(value: never): void { + void value; +} + function withAuthCheckTimeout( promise: Promise, timeoutMs: number, @@ -237,12 +255,6 @@ export function AuthenticatedClient({ '检查登录状态超时,请检查服务器地址和网络后重试', ); if (!isActiveRun()) return; - if (state.status === 'unavailable') { - setAuthCheckError(state.message); - setLoginStatus(state.message); - setAuthStatus('unauthenticated'); - return; - } if (state.status !== 'authenticated') { setAuthStatus('unauthenticated'); return; @@ -263,13 +275,89 @@ export function AuthenticatedClient({ setAuthStatus('authenticated'); } catch (error) { if (!isActiveRun()) return; - const message = getClientAuthErrorMessage( - error, - '登录服务暂时不可用,请稍后重试', - ); - setAuthCheckError(message); - setLoginStatus(message); + // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); + if (!(error instanceof ClientAuthFailure)) throw error; + switch (error.payload.type) { + // 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。 + case 'serverAddressRejected': { + const payload = error.payload as ServerAddressRejected; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'phoneNumberInvalid': { + const payload = error.payload as PhoneNumberInvalid; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'passwordMissing': { + const payload = error.payload as PasswordMissing; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'loginCodeMissing': { + const payload = error.payload as LoginCodeMissing; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'passwordEntryInputRejected': { + const payload = error.payload as PasswordEntryInputRejected; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'phoneOrPasswordMismatch': { + const payload = error.payload as PhoneOrPasswordMismatch; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'sendCodeInputRejected': { + const payload = error.payload as SendCodeInputRejected; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'smsCodeThrottled': { + const payload = error.payload as SmsCodeThrottled; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'phoneLoginInputRejected': { + const payload = error.payload as PhoneLoginInputRejected; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + case 'smsCodeInvalidOrExpired': { + const payload = error.payload as SmsCodeInvalidOrExpired; + setAuthCheckError(payload.message); + setLoginStatus(payload.message); + break; + } + // 会话权威失效:Rust 已按未登录处理;走到这里也只按未登录呈现。 + case 'sessionAuthorityRejected': + case 'permissionDenied': + break; + // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkUnavailable': + case 'authServiceUnavailable': + case 'unexpectedRejection': + case 'authResponseMalformed': + case 'clientSessionPersistFailed': + case 'runtimeSessionInstallFailed': + case 'authClientInitFailed': + throw error; + default: { + expectNever(error.payload); + throw error; + } + } } } void hydrateAuth(); @@ -328,47 +416,15 @@ export function AuthenticatedClient({ }, [codeCooldownSeconds]); /** - * 认证动作失败的统一收口:**要不要上报由这里按具体变体判**,不做任何文案匹配。 + * 认证失败的分流判据直接写在每个 catch 里: * - * 名单里的是"用户自己能改的输入 / 前置条件"与"会话路由 401/403":只给提示。其余(网络、 - * 5xx、写盘、运行时、响应不合法,以及 Rust 新增而这里没接的变体)带上文交给错误池—— - * `default` 指向上报,新变体不会被静默吞掉。`action` 决定错误池指纹,新增认证动作必须走这里。 + * - 承载:命令失败由 `invokeClientAuth` 转成 `ClientAuthFailure`,`payload.type` 是唯一分流键; + * - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池; + * - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池; + * - `default: expectNever` 保证 Rust 新增变体时这里编译失败。 + * + * 不把判据抽成函数:判定必须发生在 catch 里。 */ - function presentAuthFailure( - error: unknown, - fallback: string, - action: string, - ) { - const structured = isClientAuthError(error); - if (structured) { - switch (structured.type) { - // 用户自己能改的输入 / 前置条件,以及会话路由 401/403(按“未登录”处理): - // 这些都给用户提示,不进错误池。 - case 'serverAddressRejected': - case 'phoneNumberInvalid': - case 'passwordMissing': - case 'loginCodeMissing': - case 'passwordEntryInputRejected': - case 'phoneOrPasswordMismatch': - case 'sendCodeInputRejected': - case 'smsCodeThrottled': - case 'phoneLoginInputRejected': - case 'smsCodeInvalidOrExpired': - case 'sessionAuthorityRejected': - case 'permissionDenied': { - setLoginStatus(structured.message.trim() || fallback); - return; - } - default: - break; - } - } - const message = getClientAuthErrorMessage(error, fallback); - setLoginStatus(message); - void captureClientError( - new ClientActionError(message, { source: 'auth', action }, error), - ); - } async function handleSendCode() { if (codeBusy || codeCooldownSeconds > 0) { @@ -391,7 +447,82 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { - presentAuthFailure(error, '发送验证码失败', 'send-login-code'); + if (!(error instanceof ClientAuthFailure)) throw error; + switch (error.payload.type) { + case 'serverAddressRejected': { + const payload = error.payload as ServerAddressRejected; + setLoginStatus(payload.message); + break; + } + case 'phoneNumberInvalid': { + const payload = error.payload as PhoneNumberInvalid; + setLoginStatus(payload.message); + break; + } + case 'passwordMissing': { + const payload = error.payload as PasswordMissing; + setLoginStatus(payload.message); + break; + } + case 'loginCodeMissing': { + const payload = error.payload as LoginCodeMissing; + setLoginStatus(payload.message); + break; + } + case 'passwordEntryInputRejected': { + const payload = error.payload as PasswordEntryInputRejected; + setLoginStatus(payload.message); + break; + } + case 'phoneOrPasswordMismatch': { + const payload = error.payload as PhoneOrPasswordMismatch; + setLoginStatus(payload.message); + break; + } + case 'sendCodeInputRejected': { + const payload = error.payload as SendCodeInputRejected; + setLoginStatus(payload.message); + break; + } + case 'smsCodeThrottled': { + const payload = error.payload as SmsCodeThrottled; + setLoginStatus(payload.message); + break; + } + case 'phoneLoginInputRejected': { + const payload = error.payload as PhoneLoginInputRejected; + setLoginStatus(payload.message); + break; + } + case 'smsCodeInvalidOrExpired': { + const payload = error.payload as SmsCodeInvalidOrExpired; + setLoginStatus(payload.message); + break; + } + case 'sessionAuthorityRejected': { + const payload = error.payload as SessionAuthorityRejected; + setLoginStatus(payload.message); + break; + } + case 'permissionDenied': { + const payload = error.payload as PermissionDenied; + setLoginStatus(payload.message); + break; + } + // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkUnavailable': + case 'authServiceUnavailable': + case 'unexpectedRejection': + case 'authResponseMalformed': + case 'clientSessionPersistFailed': + case 'runtimeSessionInstallFailed': + case 'authClientInitFailed': + throw error; + default: { + expectNever(error.payload); + throw error; + } + } } finally { setCodeBusy(false); } @@ -456,7 +587,82 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - presentAuthFailure(error, '登录失败', 'login'); + if (!(error instanceof ClientAuthFailure)) throw error; + switch (error.payload.type) { + case 'serverAddressRejected': { + const payload = error.payload as ServerAddressRejected; + setLoginStatus(payload.message); + break; + } + case 'phoneNumberInvalid': { + const payload = error.payload as PhoneNumberInvalid; + setLoginStatus(payload.message); + break; + } + case 'passwordMissing': { + const payload = error.payload as PasswordMissing; + setLoginStatus(payload.message); + break; + } + case 'loginCodeMissing': { + const payload = error.payload as LoginCodeMissing; + setLoginStatus(payload.message); + break; + } + case 'passwordEntryInputRejected': { + const payload = error.payload as PasswordEntryInputRejected; + setLoginStatus(payload.message); + break; + } + case 'phoneOrPasswordMismatch': { + const payload = error.payload as PhoneOrPasswordMismatch; + setLoginStatus(payload.message); + break; + } + case 'sendCodeInputRejected': { + const payload = error.payload as SendCodeInputRejected; + setLoginStatus(payload.message); + break; + } + case 'smsCodeThrottled': { + const payload = error.payload as SmsCodeThrottled; + setLoginStatus(payload.message); + break; + } + case 'phoneLoginInputRejected': { + const payload = error.payload as PhoneLoginInputRejected; + setLoginStatus(payload.message); + break; + } + case 'smsCodeInvalidOrExpired': { + const payload = error.payload as SmsCodeInvalidOrExpired; + setLoginStatus(payload.message); + break; + } + case 'sessionAuthorityRejected': { + const payload = error.payload as SessionAuthorityRejected; + setLoginStatus(payload.message); + break; + } + case 'permissionDenied': { + const payload = error.payload as PermissionDenied; + setLoginStatus(payload.message); + break; + } + // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkUnavailable': + case 'authServiceUnavailable': + case 'unexpectedRejection': + case 'authResponseMalformed': + case 'clientSessionPersistFailed': + case 'runtimeSessionInstallFailed': + case 'authClientInitFailed': + throw error; + default: { + expectNever(error.payload); + throw error; + } + } } finally { setLoginBusy(false); } diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 8b76fd3fb..de7369bcb 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,6 +1,7 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; -import { isClientAuthError } from './clientAuthError'; +import { ClientAuthFailure } from './clientAuthFailure'; +import type { ClientAuthError } from './generated/ClientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; /** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */ @@ -8,14 +9,12 @@ export const CLIENT_AUTH_STATE_CHANGED_EVENT = 'agc-client-auth-state-changed'; export type ClientAuthState = | { status: 'authenticated'; user: AuthUser; apiBaseUrl: string } - | { status: 'unauthenticated' } - | { status: 'unavailable'; message: string }; + | { status: 'unauthenticated' }; export type ClientAuthRefreshResult = | { status: 'refreshed'; user: AuthUser } | { status: 'unauthenticated' } - | { status: 'stale' } - | { status: 'failed'; message: string; authoritative: boolean }; + | { status: 'stale' }; export type ClientLoginCodeResult = { cooldownSeconds: number; @@ -38,55 +37,67 @@ function requireInvoke() { } /** - * 从任意拒绝值里取一条可显示文案。 + * 认证命令的统一入口:把 Rust 结构化拒绝转成 JS 侧的 `ClientAuthFailure`, + * 非结构化拒绝(Tauri / JS 运行时自己的错误)原样抛出。 * - * 命令失败现在是结构化的(`ClientAuthError`),这里必须按形状取 Rust 那一份文案;裸字符串 - * 是旧的 `Err(String)` 残留与浏览器环境的形态。**非 Error 的其它形状不再做字符串化**—— - * `String({type,message})` 只会得到 `[object Object]`,把它当文案显示比回落更糟。 - * - * 它只取文案,**不判要不要上报**:那由调用方在 catch 里按具体变体决定。 + * 只判 `type` 是不是字符串(这是 ts-rs 判别联合的稳定键),**不校验字段名、不读文案判断、 + * 不兜底文案**。要不要上报、给不给用户提示,由调用方在 catch 里按 `payload.type` 决定。 */ -export function getClientAuthErrorMessage(error: unknown, fallback: string) { - const structured = isClientAuthError(error); - if (structured) return structured.message.trim() || fallback; - if (error instanceof Error && error.message.trim()) return error.message; - if (typeof error === 'string') return error.trim() || fallback; - return fallback; +async function invokeClientAuth( + command: string, + args?: Record, +): Promise { + try { + const invoke = requireInvoke(); + // 不带参数时保持 `invoke(command)` 的单参调用形态,别给命令多塞一个 undefined。 + return args === undefined + ? await invoke(command) + : await invoke(command, args); + } catch (error) { + const rejection = error as { type?: unknown; message?: unknown }; + if (typeof rejection?.type !== 'string') throw error; + throw new ClientAuthFailure( + typeof rejection.message === 'string' ? rejection.message : '', + error as ClientAuthError, + command, + error, + ); + } } -type RustAuthStateView = { - status?: string; - user?: AuthUser | null; - apiBaseUrl?: string | null; - errorMessage?: string | null; -}; +/** Rust 认证态投影,与 `ClientAuthStateView` 一一对应。 */ +type RustAuthStateView = + | { status: 'authenticated'; user: AuthUser; apiBaseUrl: string } + | { status: 'unauthenticated' }; + +/** Rust 续期结果投影,与 `ClientAuthRefreshView` 一一对应。 */ +type RustAuthRefreshView = + | { status: 'refreshed'; user: AuthUser } + | { status: 'unauthenticated' } + | { status: 'stale' }; /** * 恢复登录态。 * - * 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;这里只把结果投影成 - * `authenticated` / `unauthenticated` / `unavailable` 三态,供登录页决定展示分支。 + * 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败, + * 由 `invokeClientAuth` 转成 `ClientAuthFailure`,不再有第三态投影。 */ export async function readClientAuthState( expectedApiBaseUrl?: string, ): Promise { - const invoke = requireInvoke(); - const view = await invoke('read_client_auth_state', { - expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null, - }); - if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) { + const view = await invokeClientAuth( + 'read_client_auth_state', + { + expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null, + }, + ); + if (view.status === 'authenticated') { return { status: 'authenticated', user: view.user, apiBaseUrl: view.apiBaseUrl, }; } - if (view?.status === 'unavailable') { - return { - status: 'unavailable', - message: view.errorMessage?.trim() || '登录服务暂时不可用,请稍后重试', - }; - } return { status: 'unauthenticated' }; } @@ -94,8 +105,7 @@ export async function sendClientPhoneLoginCode( phone: string, apiBaseUrl: string, ): Promise { - const invoke = requireInvoke(); - const result = await invoke<{ + const result = await invokeClientAuth<{ cooldownSeconds?: number; expiresInSeconds?: number; }>('send_client_phone_login_code', { @@ -113,8 +123,7 @@ export async function loginClientWithPassword( password: string, apiBaseUrl: string, ): Promise { - const invoke = requireInvoke(); - return invoke('login_client_with_password', { + return invokeClientAuth('login_client_with_password', { apiBaseUrl, phone: normalizeAuthPhoneInput(phone), password: password.trim(), @@ -126,8 +135,7 @@ export async function loginClientWithPhoneCode( code: string, apiBaseUrl: string, ): Promise { - const invoke = requireInvoke(); - return invoke('login_client_with_phone_code', { + return invokeClientAuth('login_client_with_phone_code', { apiBaseUrl, phone: normalizeAuthPhoneInput(phone), code: code.trim(), @@ -136,43 +144,23 @@ export async function loginClientWithPhoneCode( /** 登出:Rust 负责服务端撤销、凭据清除与本机运行时会话清理。 */ export async function logoutClientAuthSession(): Promise { - const invoke = requireInvoke(); - await invoke('logout_client_session'); + await invokeClientAuth('logout_client_session'); } export async function refreshClientAuthSession( expectedUserId?: string, ): Promise { - const invoke = requireInvoke(); - const view = await invoke<{ - status?: string; - user?: AuthUser | null; - authoritative?: boolean; - errorMessage?: string | null; - }>('refresh_client_auth_session', { - expectedUserId: expectedUserId?.trim() || null, - }); - switch (view?.status) { - case 'refreshed': - if (!view.user) { - return { - status: 'failed', - message: '刷新登录状态失败', - authoritative: false, - }; - } - return { status: 'refreshed', user: view.user }; - case 'unauthenticated': - return { status: 'unauthenticated' }; - case 'stale': - return { status: 'stale' }; - default: - return { - status: 'failed', - message: view?.errorMessage?.trim() || '刷新登录状态失败', - authoritative: view?.authoritative === true, - }; + const view = await invokeClientAuth( + 'refresh_client_auth_session', + { expectedUserId: expectedUserId?.trim() || null }, + ); + if (view.status === 'refreshed') { + return { status: 'refreshed', user: view.user }; } + if (view.status === 'unauthenticated') { + return { status: 'unauthenticated' }; + } + return { status: 'stale' }; } /** 订阅 Rust 认证态事件,返回幂等释放函数。 */ @@ -183,7 +171,7 @@ export function subscribeClientAuthState( CLIENT_AUTH_STATE_CHANGED_EVENT, (event) => { const view = event.payload; - if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) { + if (view.status === 'authenticated') { listener({ status: 'authenticated', user: view.user, diff --git a/apps/ai-game-creator-shell/src/services/clientAuthError.ts b/apps/ai-game-creator-shell/src/services/clientAuthError.ts deleted file mode 100644 index 6029e91ee..000000000 --- a/apps/ai-game-creator-shell/src/services/clientAuthError.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ClientAuthError } from './generated/ClientAuthError'; - -export type { ClientAuthError }; - -/** - * `invoke` 拒绝时拿到的是 Rust 序列化出来的普通对象(不是 `Error`)。这里只做形状读取: - * `type` 是稳定判别键,`message` 是 Rust 生成的可展示文案,**文案不参与任何判断**。 - * - * 这里**不做任何分类**:要不要上报由调用方在 catch 里按具体变体判(`switch (error.type)`), - * 不在名单里的新变体由调用方的 `default` 分支交给错误池——见 ADR 的"未识别变体上调"。 - * - * **`status` 故意不校验**:`authServiceUnavailable` / `unexpectedRejection` 在生成类型里带必填 - * `status`,但全仓没有调用方读它(这两个变体只显示 `message`)。把 `status` 纳入校验只会让 - * "缺 `status` 但 `message` 可用"的拒绝落回非结构化分支,把 Rust 的文案换成调用方兜底文案 - * ——诊断信息更少、行为更差。将来真要用 `status` 做判断时,先补校验再用。 - */ -export function isClientAuthError(value: unknown): ClientAuthError | null { - if (!value || typeof value !== 'object') return null; - const candidate = value as { type?: unknown; message?: unknown }; - if (typeof candidate.type !== 'string' || !candidate.type) return null; - if (typeof candidate.message !== 'string') return null; - return value as ClientAuthError; -} diff --git a/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts b/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts new file mode 100644 index 000000000..4cd4386f8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts @@ -0,0 +1,30 @@ +import { ClientActionError } from './clientActionError'; +import type { ClientAuthError } from './generated/ClientAuthError'; + +/** + * 认证命令失败在 JS 侧的载体。 + * + * Tauri 的 `invoke` 拒绝值是普通对象(Rust 序列化的结构化错误),不是 `Error`;直接抛出会被 + * 上报链路降级成 `String(obj)`,类型与文案一起丢掉。`invokeClientAuth` 统一把它转成本类: + * + * - `payload` 是 ts-rs 生成的判别联合,`payload.type` 是唯一的稳定分流键; + * - `context` 固定为 `{ source: 'auth', action: 命令名 }`,给错误池定指纹; + * - `cause` 是原始拒绝值,保留给上报链路取栈与排障。 + * + * 本类只承载事实,**不做分类、不产派生值、不兜底文案**:要不要上报、给不给用户提示,由调用方 + * 在 catch 里按 `payload.type` 判。 + */ +export class ClientAuthFailure extends ClientActionError { + readonly payload: ClientAuthError; + + constructor( + message: string, + payload: ClientAuthError, + command: string, + cause: unknown, + ) { + super(message, { source: 'auth', action: command }, cause); + this.name = 'ClientAuthFailure'; + this.payload = payload; + } +} diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts index 372aa7ca9..4e8886b51 100644 --- a/apps/ai-game-creator-shell/src/services/platformSession.ts +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -168,14 +168,8 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { authoritative: true, }; } - if (result.status === 'stale') { - return { status: 'stale' }; - } - return { - status: 'failed', - error: new Error(result.message), - authoritative: result.authoritative, - }; + // `stale`:续期期间身份已变化,调用方不得重放旧身份请求。 + return { status: 'stale' }; })().then((result) => { notifyPlatformSessionRefresh(result); return result; diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 0628c31ef..b8c37b473 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -5,6 +5,7 @@ import { currentPlatformSessionGeneration, resetPlatformSessionForTests, } from '../../src/services/platformSession'; +import { installUnhandledRejectionBridge } from '../unhandledRejectionBridge'; import { act, AuthenticatedClient, @@ -99,26 +100,40 @@ export function registerAuthTests() { delete window.__TAURI__; }); - it('leaves startup loading with an actionable retry after auth check timeout', async () => { - renderAuthSurface(async (command: string) => { - if (command === 'read_client_auth_state') { - return await new Promise(() => {}); - } - return null; - }); + it('throws a startup timeout through to unhandledrejection and leaves the loading state', async () => { + const uninstall = installUnhandledRejectionBridge(); + const reasons: unknown[] = []; + const onRejection = (event: Event) => { + reasons.push((event as Event & { reason?: unknown }).reason); + }; + window.addEventListener('unhandledrejection', onRejection); + try { + renderAuthSurface(async (command: string) => { + if (command === 'read_client_auth_state') { + return await new Promise(() => {}); + } + return null; + }); - expect( - await screen.findByText( - '检查登录状态超时,请检查服务器地址和网络后重试', - undefined, - { - timeout: 20_000, - }, - ), - ).not.toBeNull(); - expect( - screen.getByRole('button', { name: '重试登录状态检查' }), - ).toHaveProperty('disabled', false); + await waitFor( + () => + expect( + reasons.some( + (reason) => + reason instanceof Error && + reason.message.includes('检查登录状态超时'), + ), + ).toBe(true), + { timeout: 20_000 }, + ); + expect( + screen.queryByRole('button', { name: '重试登录状态检查' }), + ).toBeNull(); + await screen.findByRole('main', { name: '登录' }); + } finally { + window.removeEventListener('unhandledrejection', onRejection); + uninstall(); + } }, 30_000); it('renders the unauthenticated client with the shared light platform theme and product image', async () => { @@ -254,47 +269,51 @@ export function registerAuthTests() { expect(screen.queryByLabelText('已登录')).toBeNull(); }); - it('keeps transport details out of the login failure copy', async () => { - const invoke = vi.fn(async (command: string) => { - if (command === 'read_client_auth_state') { - return { status: 'unauthenticated' }; - } - if (command === 'login_client_with_phone_code') { - throw { - type: 'authNetworkUnavailable', - message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - }; - } - return null; - }); - renderAuthSurface(invoke); - await loginWithCode(); + it('keeps transport details out of the login copy by throwing system variants through', async () => { + const uninstall = installUnhandledRejectionBridge(); + try { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_phone_code') { + throw { + type: 'authNetworkUnavailable', + message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + }; + } + return null; + }); + renderAuthSurface(invoke); + await loginWithCode(); - expect( - await screen.findByText( - '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - ), - ).not.toBeNull(); - expect(document.body.textContent).not.toContain('ECONNREFUSED'); + await waitFor(() => + expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(), + ); + expect(document.body.textContent).not.toContain('ECONNREFUSED'); + await screen.findByRole('main', { name: '登录' }); + } finally { + uninstall(); + } }); - it('keeps a retry when the startup check reports the service unavailable', async () => { + it('keeps a retry when the startup check is rejected by a business variant', async () => { let reads = 0; const invoke = vi.fn(async (command: string) => { if (command === 'read_client_auth_state') { reads += 1; return reads === 1 - ? { - status: 'unavailable', - errorMessage: '无法连接登录服务', - } + ? Promise.reject({ + type: 'serverAddressRejected', + message: '服务器地址无效', + }) : { status: 'unauthenticated' }; } return null; }); renderAuthSurface(invoke); - expect(await screen.findByText('无法连接登录服务')).not.toBeNull(); + expect(await screen.findByText('服务器地址无效')).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '重试登录状态检查' })); diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index 5255f6c47..3ff8a3cec 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -28,6 +28,7 @@ import { invoke } from '@tauri-apps/api/core'; import { AuthenticatedClient } from '../src/app/AuthenticatedClient'; import { resetPlatformSessionForTests } from '../src/services/platformSession'; +import { installUnhandledRejectionBridge } from './unhandledRejectionBridge'; type AuthInvoke = ( command: string, @@ -105,24 +106,52 @@ describe('认证失败的上报判据', () => { expect(reportCalls()).toHaveLength(0); }); - it('系统变体(网络不可达)带 auth/login 上下文上报一次', async () => { + it('系统变体(网络不可达)原样抛出,经 unhandledrejection 上报一次', async () => { const message = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; - await submitPasswordLogin(async (command) => { - if (command === 'read_client_auth_state') { - return { status: 'unauthenticated' }; - } - if (command === 'login_client_with_password') { - throw { type: 'authNetworkUnavailable', message }; - } - return null; - }); + const uninstall = installUnhandledRejectionBridge(); + try { + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw { type: 'authNetworkUnavailable', message }; + } + return null; + }); - expect(await screen.findByText(message)).not.toBeNull(); - await waitFor(() => expect(reportCalls()).toHaveLength(1)); - expect(reportCalls()[0]?.[1]).toMatchObject({ - source: 'auth', - action: 'login', - message, - }); + await waitFor(() => expect(reportCalls()).toHaveLength(1)); + expect(reportCalls()[0]?.[1]).toMatchObject({ + source: 'auth', + action: 'login_client_with_password', + message, + }); + expect(screen.queryByText(message)).toBeNull(); + } finally { + uninstall(); + } + }); + + it('非结构化拒绝原样抛出,由 unhandledrejection 兜底上报', async () => { + const uninstall = installUnhandledRejectionBridge(); + try { + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw new Error('IPC 桥接不可用'); + } + return null; + }); + + await waitFor(() => expect(reportCalls()).toHaveLength(1)); + expect(reportCalls()[0]?.[1]).toMatchObject({ + source: 'unhandledrejection', + message: 'IPC 桥接不可用', + }); + } finally { + uninstall(); + } }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts deleted file mode 100644 index 584a63cd2..000000000 --- a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { isClientAuthError } from '../src/services/clientAuthError'; -import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; -import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; -import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid'; - -/** - * 编译期用例:每个变体在 `type` 上判别后都能落到一个**具名载荷类型**, - * 等价于 Java 的 `catch (PhoneNumberInvalid e)`;类型不匹配时 `tsc` 会直接报错。 - */ -function handleByNamedType(error: ClientAuthError) { - switch (error.type) { - case 'phoneNumberInvalid': { - const payload: PhoneNumberInvalid = error; - return payload.message; - } - case 'authServiceUnavailable': { - const payload: AuthServiceUnavailable = error; - return `${payload.status}:${payload.message}`; - } - default: - return ''; - } -} - -describe('AGC 认证命令错误的形状读取', () => { - it('判别后按具名载荷类型分支处理', () => { - expect( - handleByNamedType({ - type: 'phoneNumberInvalid', - message: '请输入正确的手机号', - }), - ).toBe('请输入正确的手机号'); - expect( - handleByNamedType({ - type: 'authServiceUnavailable', - status: 503, - message: '登录服务暂时不可用', - }), - ).toBe('503:登录服务暂时不可用'); - }); - - it('只按形状读取 Rust 的结构化拒绝,裸字符串与 Error 都不算', () => { - expect( - isClientAuthError({ - type: 'phoneOrPasswordMismatch', - message: '手机号或密码错误', - }), - ).toEqual({ type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }); - expect(isClientAuthError('手机号或密码错误')).toBeNull(); - expect(isClientAuthError(new Error('手机号或密码错误'))).toBeNull(); - expect( - isClientAuthError({ type: '', message: '手机号或密码错误' }), - ).toBeNull(); - expect(isClientAuthError({ type: 'phoneNumberInvalid' })).toBeNull(); - expect(isClientAuthError(null)).toBeNull(); - }); - - it('未知变体也算形状合法,由调用方按 default 交给错误池', () => { - expect( - isClientAuthError({ type: 'brandNewRejection', message: '新变体' }), - ).toEqual({ type: 'brandNewRejection', message: '新变体' }); - }); - - it('status 故意不校验:缺 status 但 message 可用的拒绝仍可读', () => { - expect( - isClientAuthError({ - type: 'authServiceUnavailable', - message: '登录服务暂时不可用', - }), - ).toEqual({ - type: 'authServiceUnavailable', - message: '登录服务暂时不可用', - }); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts b/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts new file mode 100644 index 000000000..4f87031b9 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts @@ -0,0 +1,164 @@ +// @vitest-environment jsdom +/** + * 认证命令失败的 JS 侧载体。 + * + * Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 转成 `ClientAuthFailure`,非结构化拒绝 + * 原样抛出;分流只按 `payload.type`,不读文案。另外用编译期用例钉住"每个变体一个具名载荷 + * 类型":漏列任何变体,`expectNever(error)` 都编译不过。 + */ +import { describe, expect, it, vi } from 'vitest'; + +import { ClientActionError } from '../src/services/clientActionError'; +import { loginClientWithPassword } from '../src/services/clientAuth'; +import { ClientAuthFailure } from '../src/services/clientAuthFailure'; +import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed'; +import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable'; +import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed'; +import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; +import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; +import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed'; +import type { LoginCodeMissing } from '../src/services/generated/LoginCodeMissing'; +import type { PasswordEntryInputRejected } from '../src/services/generated/PasswordEntryInputRejected'; +import type { PasswordMissing } from '../src/services/generated/PasswordMissing'; +import type { PermissionDenied } from '../src/services/generated/PermissionDenied'; +import type { PhoneLoginInputRejected } from '../src/services/generated/PhoneLoginInputRejected'; +import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid'; +import type { PhoneOrPasswordMismatch } from '../src/services/generated/PhoneOrPasswordMismatch'; +import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed'; +import type { SendCodeInputRejected } from '../src/services/generated/SendCodeInputRejected'; +import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected'; +import type { SessionAuthorityRejected } from '../src/services/generated/SessionAuthorityRejected'; +import type { SmsCodeInvalidOrExpired } from '../src/services/generated/SmsCodeInvalidOrExpired'; +import type { SmsCodeThrottled } from '../src/services/generated/SmsCodeThrottled'; +import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection'; + +function expectNever(value: never): void { + void value; +} + +/** 编译期用例:19 个变体逐个列全,每个分支用 `as` 取具名载荷类型。 */ +function variantMessage(error: ClientAuthError): string { + switch (error.type) { + case 'serverAddressRejected': + return (error as ServerAddressRejected).message; + case 'phoneNumberInvalid': + return (error as PhoneNumberInvalid).message; + case 'passwordMissing': + return (error as PasswordMissing).message; + case 'loginCodeMissing': + return (error as LoginCodeMissing).message; + case 'passwordEntryInputRejected': + return (error as PasswordEntryInputRejected).message; + case 'phoneOrPasswordMismatch': + return (error as PhoneOrPasswordMismatch).message; + case 'sendCodeInputRejected': + return (error as SendCodeInputRejected).message; + case 'smsCodeThrottled': + return (error as SmsCodeThrottled).message; + case 'phoneLoginInputRejected': + return (error as PhoneLoginInputRejected).message; + case 'smsCodeInvalidOrExpired': + return (error as SmsCodeInvalidOrExpired).message; + case 'sessionAuthorityRejected': + return (error as SessionAuthorityRejected).message; + case 'permissionDenied': + return (error as PermissionDenied).message; + case 'authNetworkUnavailable': + return (error as AuthNetworkUnavailable).message; + case 'authServiceUnavailable': + return (error as AuthServiceUnavailable).message; + case 'unexpectedRejection': + return (error as UnexpectedRejection).message; + case 'authResponseMalformed': + return (error as AuthResponseMalformed).message; + case 'clientSessionPersistFailed': + return (error as ClientSessionPersistFailed).message; + case 'runtimeSessionInstallFailed': + return (error as RuntimeSessionInstallFailed).message; + case 'authClientInitFailed': + return (error as AuthClientInitFailed).message; + default: { + expectNever(error); + return ''; + } + } +} + +function installInvoke(handler: () => Promise) { + window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never; +} + +describe('ClientAuthFailure', () => { + it('结构化拒绝转成带 payload / context / cause 的 JS 错误', async () => { + const rejection = { + type: 'phoneNumberInvalid', + message: '请输入正确的手机号', + }; + installInvoke(async () => { + throw rejection; + }); + + const failure = await loginClientWithPassword( + '13800000000', + 'secret', + 'https://dev.genarrative.world', + ).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ClientAuthFailure); + expect(failure).toBeInstanceOf(ClientActionError); + expect((failure as ClientAuthFailure).payload).toEqual(rejection); + expect((failure as ClientAuthFailure).context).toEqual({ + source: 'auth', + action: 'login_client_with_password', + }); + expect((failure as ClientAuthFailure).cause).toBe(rejection); + expect((failure as Error).message).toBe('请输入正确的手机号'); + }); + + it('非结构化拒绝原样抛出,不包装也不改写', async () => { + const rejection = new Error('需要在 Tauri App 内登录'); + installInvoke(async () => { + throw rejection; + }); + + await expect( + loginClientWithPassword( + '13800000000', + 'secret', + 'https://dev.genarrative.world', + ), + ).rejects.toBe(rejection); + }); + + it('结构化拒绝缺 message 时 Error.message 为空,不造兜底文案', async () => { + installInvoke(async () => { + throw { type: 'unexpectedRejection', status: 409 }; + }); + + const failure = await loginClientWithPassword( + '13800000000', + 'secret', + 'https://dev.genarrative.world', + ).catch((error: unknown) => error); + + expect((failure as Error).message).toBe(''); + expect((failure as ClientAuthFailure).payload.type).toBe( + 'unexpectedRejection', + ); + }); +}); + +describe('变体分流', () => { + it('判别后按具名载荷类型取值', () => { + expect( + variantMessage({ type: 'phoneNumberInvalid', message: '格式不对' }), + ).toBe('格式不对'); + expect( + variantMessage({ + type: 'authServiceUnavailable', + status: 503, + message: '服务暂不可用', + }), + ).toBe('服务暂不可用'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index e6cef4552..d4092efcc 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -10,7 +10,6 @@ import { beforeEach, expect, test, vi } from 'vitest'; const invoke = vi.fn(); import { - getClientAuthErrorMessage, loginClientWithPassword, loginClientWithPhoneCode, logoutClientAuthSession, @@ -47,7 +46,7 @@ test('手机号归一化只保留可提交的纯号码', () => { expect(normalizeAuthPhoneInput('0086-138-0000-0000')).toBe('008613800000000'); }); -test('恢复登录态映射为 authenticated / unauthenticated / unavailable', async () => { +test('恢复登录态映射为 authenticated / unauthenticated', async () => { invoke.mockResolvedValueOnce({ status: 'authenticated', user: testUser, @@ -71,15 +70,6 @@ test('恢复登录态映射为 authenticated / unauthenticated / unavailable', a expect(invoke).toHaveBeenCalledWith('read_client_auth_state', { expectedApiBaseUrl: null, }); - - invoke.mockResolvedValueOnce({ - status: 'unavailable', - errorMessage: '无法连接登录服务', - }); - await expect(readClientAuthState()).resolves.toEqual({ - status: 'unavailable', - message: '无法连接登录服务', - }); }); test('登录只提交结构化参数,凭据由 Rust 持有', async () => { @@ -135,7 +125,7 @@ test('登录只提交结构化参数,凭据由 Rust 持有', async () => { expect(serialized).not.toContain('cookie'); }); -test('续期结果区分成功、失效、身份变化与暂时失败', async () => { +test('续期结果区分成功、失效与身份变化', async () => { invoke.mockResolvedValueOnce({ status: 'refreshed', user: testUser }); await expect(refreshClientAuthSession('user-1')).resolves.toEqual({ status: 'refreshed', @@ -151,17 +141,6 @@ test('续期结果区分成功、失效、身份变化与暂时失败', async () await expect(refreshClientAuthSession()).resolves.toEqual({ status: 'stale', }); - - invoke.mockResolvedValueOnce({ - status: 'failed', - authoritative: false, - errorMessage: '无法连接登录服务', - }); - await expect(refreshClientAuthSession()).resolves.toEqual({ - status: 'failed', - message: '无法连接登录服务', - authoritative: false, - }); }); test('没有原生宿主时登录能力明确失败关闭', async () => { @@ -179,9 +158,14 @@ test('没有原生宿主时登录能力明确失败关闭', async () => { expect(invoke).not.toHaveBeenCalled(); }); -test('错误文案优先使用服务端原因,缺失时回落到调用方文案', () => { - expect( - getClientAuthErrorMessage(new Error('手机号或密码错误'), '登录失败'), - ).toBe('手机号或密码错误'); - expect(getClientAuthErrorMessage('', '登录失败')).toBe('登录失败'); +test('结构化失败不降级成投影值,而是以 ClientAuthFailure 拒绝', async () => { + const rejection = { + type: 'authNetworkUnavailable', + message: '无法连接登录服务', + }; + invoke.mockRejectedValueOnce(rejection); + await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ + payload: rejection, + context: { source: 'auth', action: 'refresh_client_auth_session' }, + }); }); diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts new file mode 100644 index 000000000..01f440388 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts @@ -0,0 +1,27 @@ +/** + * 把 Node 的 `unhandledRejection` 桥接成 jsdom `window` 的 `unhandledrejection` 事件。 + * + * 生产 WebView 里,`void` 掉的 async handler(`onSubmit` / `onClick`)抛出的拒绝会派发 + * `unhandledrejection`,被 `AuthenticatedClient` 的全局 handler 交给错误池。jsdom 既不派发 + * 该事件,vitest 又会把未处理的拒绝当成用例失败;需要观察这条链路的用例用它接管 Node + * 监听器,再合成一个带 `reason` 的 window 事件。 + * + * 用法:用例内 `const uninstall = installUnhandledRejectionBridge()`,在 `finally` 或 + * `afterEach` 里 `uninstall()`。 + */ +export function installUnhandledRejectionBridge() { + const originalListeners = process.listeners('unhandledRejection'); + process.removeAllListeners('unhandledRejection'); + const handleRejection = (reason: unknown) => { + const event = new Event('unhandledrejection'); + Object.defineProperty(event, 'reason', { value: reason }); + window.dispatchEvent(event); + }; + process.on('unhandledRejection', handleRejection); + return () => { + process.removeListener('unhandledRejection', handleRejection); + for (const listener of originalListeners) { + process.on('unhandledRejection', listener); + } + }; +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 63db45b0f..642b64e7b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8106,7 +8106,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-22 AGC Tauri 命令调用可达性失败关闭 -- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。 +- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput`、认证命令的 `invokeClientAuth` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。 - allowlist 边界:前端源码已调用的命令不得继续保留在 explicit native-only allowlist。allowlist 只承载确实由原生窗口或原生侧流程触发、App 源码不直接调用的 handler;源码调用与 allowlist 必须互斥。 - 门禁:逐文件使用仓库锁定的 TypeScript AST 解析,设置文件数量、单文件 / 总源码长度、命令长度和调用数量上限。回归测试同时锁定直接、wrapper、对象字段的正例与诱饵 / 动态 / 畸形输入的反例,并证明删除真实 wrapper 调用后 handler 可达性检查失败,不能由错误 allowlist 继续误绿。 -- 2.52.0 From 08b8d0d1677c6cc657358a24e0d653d885dc4622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 21:12:45 +0800 Subject: [PATCH 22/70] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=B3=BB=E7=BB=9F=E5=8F=98=E4=BD=93=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E7=9A=84=E8=B4=9F=E5=90=91=E6=96=AD=E8=A8=80=E7=AB=9E?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 先等系统变体经 unhandledrejection 抛出(并校验抛出物带 Rust 原文),再断言登录页没有该文案 - 原写法在首个 waitFor tick 就因 queryByText 返回 null 而通过,没真正验证拒绝落地后的文案 --- .../tests/appSurface/auth.suite.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index b8c37b473..76d997050 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -271,6 +271,11 @@ export function registerAuthTests() { it('keeps transport details out of the login copy by throwing system variants through', async () => { const uninstall = installUnhandledRejectionBridge(); + const reasons: unknown[] = []; + const onRejection = (event: Event) => { + reasons.push((event as Event & { reason?: unknown }).reason); + }; + window.addEventListener('unhandledrejection', onRejection); try { const invoke = vi.fn(async (command: string) => { if (command === 'read_client_auth_state') { @@ -287,12 +292,16 @@ export function registerAuthTests() { renderAuthSurface(invoke); await loginWithCode(); - await waitFor(() => - expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(), - ); + // 先等系统变体真的经 unhandledrejection 抛出,再断言它没落到登录页文案。 + await waitFor(() => expect(reasons).toHaveLength(1)); + expect(reasons[0]).toMatchObject({ + message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + }); + expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(); expect(document.body.textContent).not.toContain('ECONNREFUSED'); await screen.findByRole('main', { name: '登录' }); } finally { + window.removeEventListener('unhandledrejection', onRejection); uninstall(); } }); -- 2.52.0 From 06a4f9346f9edc1f383dd4601f5c37f1108e6f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 21:15:03 +0800 Subject: [PATCH 23/70] =?UTF-8?q?unhandledrejection=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=A1=A5=E5=8F=AA=E5=90=9E=E8=A2=AB=20window=20=E4=BE=A7?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E7=9A=84=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合成事件的 reason 改为访问器:有监听方读过就算有人接手;没人读则交回安装前的监听器(含 vitest 失败上报) - 新增 unhandledRejectionBridge.test.ts 钉住两条路径:无人接手会交回、window 读过则不再交回 - 避免桥把无关的未处理拒绝静默吞掉 --- .../tests/unhandledRejectionBridge.test.ts | 73 +++++++++++++++++++ .../tests/unhandledRejectionBridge.ts | 19 ++++- 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts new file mode 100644 index 000000000..3022d8f4e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +/** + * jsdom `unhandledrejection` 桥的「只吞被消费的拒绝」语义。 + * + * 桥接管 Node 监听器是为了让组件能收到 window 事件;但没人消费的拒绝必须交回原监听器, + * 否则用例会静默吞掉真实的未处理拒绝。 + */ +import { afterEach, expect, test } from 'vitest'; + +import { installUnhandledRejectionBridge } from './unhandledRejectionBridge'; + +const installed: Array<() => void> = []; +const processListeners: Array<(reason: unknown, promise: unknown) => void> = []; +const windowListeners: Array<(event: Event) => void> = []; +let parkedListeners: Array<(reason: unknown, promise: unknown) => void> = []; + +afterEach(() => { + while (installed.length > 0) installed.pop()?.(); + while (processListeners.length > 0) { + process.removeListener('unhandledRejection', processListeners.pop()!); + } + while (windowListeners.length > 0) { + window.removeEventListener('unhandledrejection', windowListeners.pop()!); + } + for (const listener of parkedListeners) { + process.on('unhandledRejection', listener); + } + parkedListeners = []; +}); + +/** 停放 vitest 自己的监听器,好观察「交回原监听器」而不触发用例失败上报。 */ +function parkProcessListeners() { + parkedListeners = process.listeners('unhandledRejection'); + process.removeAllListeners('unhandledRejection'); +} + +function trackProcessListener() { + const forwarded: unknown[] = []; + const listener = (reason: unknown) => { + forwarded.push(reason); + }; + processListeners.push(listener); + process.on('unhandledRejection', listener); + return forwarded; +} + +test('没人读 reason 的拒绝会交回原监听器', () => { + parkProcessListeners(); + const forwarded = trackProcessListener(); + installed.push(installUnhandledRejectionBridge()); + + const reason = new Error('意外的拒绝'); + process.emit('unhandledRejection', reason, Promise.resolve()); + + expect(forwarded).toEqual([reason]); +}); + +test('window 侧读过 reason 的拒绝不再交回原监听器', () => { + const forwarded = trackProcessListener(); + const consumed: unknown[] = []; + const listener = (event: Event) => { + consumed.push((event as Event & { reason?: unknown }).reason); + }; + windowListeners.push(listener); + window.addEventListener('unhandledrejection', listener); + installed.push(installUnhandledRejectionBridge()); + + const reason = new Error('已接手的拒绝'); + process.emit('unhandledRejection', reason, Promise.resolve()); + + expect(consumed).toEqual([reason]); + expect(forwarded).toEqual([]); +}); diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts index 01f440388..85dc4c24e 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts @@ -6,16 +6,31 @@ * 该事件,vitest 又会把未处理的拒绝当成用例失败;需要观察这条链路的用例用它接管 Node * 监听器,再合成一个带 `reason` 的 window 事件。 * + * 只抑制**被 window 侧消费**的拒绝:合成事件的 `reason` 是访问器,任何监听方读过它就算 + * 有人接手;没人读说明这条拒绝是意外的,交回安装前的监听器(含 vitest 的失败上报), + * 不会被静默吞掉。 + * * 用法:用例内 `const uninstall = installUnhandledRejectionBridge()`,在 `finally` 或 * `afterEach` 里 `uninstall()`。 */ export function installUnhandledRejectionBridge() { const originalListeners = process.listeners('unhandledRejection'); process.removeAllListeners('unhandledRejection'); - const handleRejection = (reason: unknown) => { + const handleRejection = (reason: unknown, promise: Promise) => { + let consumed = false; const event = new Event('unhandledrejection'); - Object.defineProperty(event, 'reason', { value: reason }); + Object.defineProperty(event, 'reason', { + get() { + consumed = true; + return reason; + }, + }); window.dispatchEvent(event); + if (!consumed) { + for (const listener of originalListeners) { + listener(reason, promise); + } + } }; process.on('unhandledRejection', handleRejection); return () => { -- 2.52.0 From f9c8c429be90a118a2d9433cc64d76ffc00ec1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 21:17:11 +0800 Subject: [PATCH 24/70] =?UTF-8?q?=E8=A1=A5=20Rust=20=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E9=92=89=E4=BD=8F=E5=8F=91=E7=A0=81=20401/403=20=E5=B1=9E?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E5=8F=98=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map_auth_failure 对 AuthRoute::SendCode 的 401/403 仍归 UnexpectedRejection(不是权威失效、不是业务输入) - 依据:api-server 的 send_phone_code 只返回 400 与 5xx,401/403 出现即为契约异常,应带上文进上报池 --- .../src-tauri/src/auth_session.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 592f5ef95..08f192e1f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -1350,6 +1350,28 @@ mod tests { assert_eq!(missing_reason.message(), "登录失败"); } + /// 发码端点不带凭据:401/403 不是「用户可改的输入」,而是协议异常,属系统变体(带上文上报)。 + /// + /// api-server 的 `send_phone_code` 只会返回 400(手机号登录未启用)与 5xx;401/403 真出现时 + /// 说明契约破了,不该被当成业务提示吞掉。 + #[test] + fn send_code_401_403_stay_system_rejections() { + for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { + let error = map_auth_failure( + status, + r#"{"error":{"message":"手机号登录暂未启用"}}"#, + "发送验证码失败", + AuthRoute::SendCode, + ); + assert!(matches!( + error, + ClientAuthError::UnexpectedRejection(UnexpectedRejection { .. }) + )); + assert!(!error.is_authority_failure()); + assert_eq!(error.message(), "手机号登录暂未启用"); + } + } + #[test] fn input_rejections_and_throttling_keep_the_server_text() { let password_length = map_auth_failure( -- 2.52.0 From 35f10c205158d043b47bbfe82359e34ebaa3da21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 01:11:01 +0800 Subject: [PATCH 25/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E9=94=99=E8=AF=AF=E5=8C=85=E8=A3=85=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E4=BF=A1=E4=BB=BB=20ts-rs=20=E6=98=A0=E5=B0=84=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=81=9A=E8=BF=90=E8=A1=8C=E6=97=B6=E5=BD=A2=E7=8A=B6?= =?UTF-8?q?=E5=97=85=E6=8E=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR §2 去掉 type 存在性检查:Rust 与 TS 同包发布、形状由 ts-rs 保证,异常形状属 Tauri/Rust 缺陷,仍由 switch 的 default 抛出去上报 - requireInvoke 移到 try 之外,认证桥未安装的错误保持原样抛出 - Error.message 只从拒绝值读可展示字符串,不是分流判据 - ADR §1 记录保留 19 个具名载荷类型的理由:它们是每个分支 as 的目标;内联 struct 变体虽能少 19 个文件,但分支就拿不到可 as 的具名类型 --- ...GC认证失败的JS侧载体与抛出时机-2026-10-01.md | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index 9763dd0fa..ece808530 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -16,7 +16,7 @@ Rust 已经返回结构化错误,但 Tauri 的 `invoke` 拒绝值是**普通 上报链路的 `error instanceof Error` 判断会把它降级成 `new Error(String(error))` (`[object Object]`),文案与类型一起丢掉。 - 前一版在渲染层加了 `isClientAuthError` / `getClientAuthErrorMessage` / - `presentAuthFailure` 三层:形状读取、文案回落、分类提示。它们既不是类型事实源,又在 + `presentAuthFailure` 三层:形状读取、文案回落、分类提示(均已删除)。它们既不是类型事实源,又在 "取文案"里悄悄承担了"要不要上报"的判断,与"由调用方判定"的口径冲突。 ## 决策 @@ -36,6 +36,10 @@ class ClientAuthFailure extends ClientActionError { 上报方不需要知道上游是谁。 - `payload` 是完整的结构化拒绝(判别联合),不是再抄一份的派生字段;`payload.type` 就是分流键。**不再新增 `kind` / `notice` / `severity` 之类的派生值。** +- 19 个具名载荷类型是有意保留的:它们是 §3 每个 `case` 里 `as X` 的目标,也正是"不要假设 + 所有变体字段相同"的落点。改成内联 struct 变体确实能让 ts-rs 把字段内联进联合成员、少掉 + 19 个生成文件,但每个分支就再也拿不到可 `as` 的具名类型(只能手写内联对象类型,等于放弃 + ts-rs)。本 ADR 选择保留具名载荷。 ### 2. 一个包装函数:`invokeClientAuth` @@ -43,18 +47,29 @@ class ClientAuthFailure extends ClientActionError { ```ts async function invokeClientAuth(command, args): Promise { + const invoke = requireInvoke(); // 认证桥未装:我们自己的失败关闭错误,原样抛出 try { - return await requireInvoke()(command, args); + return await invoke(command, args); } catch (error) { - const rejection = error as { type?: unknown }; - if (typeof rejection?.type !== 'string') throw error; // 非结构化:原样抛出 - throw new ClientAuthFailure(...); + // 信任 tauri + ts-rs 的映射:认证命令的拒绝就是 ClientAuthError,不做运行时形状校验。 + const rejection = error as { message?: unknown }; + throw new ClientAuthFailure( + typeof rejection.message === 'string' ? rejection.message : '', + error as ClientAuthError, + command, + error, + ); } } ``` -- `type` 是 `string` 即认为是 Rust 结构化拒绝:形状由 ts-rs 保证,**不校验字段名与文案**。 -- 非结构化拒绝原样抛出:那是 Tauri / JS 运行时自己的错误,不属于认证命令契约。 +- **不做运行时形状嗅探**:不再检查 `type` 存不存在。Rust 与 TS 同包发布,形状由 ts-rs 保证; + 出现别的形状属于 Tauri / Rust 侧的缺陷,`switch` 的 `default` 分支仍会把它抛出去上报,不会 + 静默吞掉——只是不再在包装层替 Tauri 兜底。 +- `requireInvoke()` 放在 `try` 之外:认证桥未安装是我们自己的失败关闭错误,不是命令拒绝,保持 + 原样抛出(`需要在 Tauri App 内登录`)。 +- `Error.message` 只从拒绝值里读一个可展示字符串(`typeof === 'string'`),**不是分流判据**; + 分流永远只按 `payload.type`。 - 该包装是"Rust 结构化错误 → JS 错误对象"的唯一转换点:不做分类、不读文案判断、不兜底文案。 ### 3. 判定只写在 catch 子句里,用具体变体 -- 2.52.0 From b0d7d403d0b0ba1aaabec35770914f39c6f0a4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 01:12:25 +0800 Subject: [PATCH 26/70] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=8C=85=E8=A3=85=E5=8E=BB=E6=8E=89=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E5=BD=A2=E7=8A=B6=E5=97=85=E6=8E=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - invokeClientAuth 不再检查 type 是否存在:认证命令的拒绝按 ts-rs 映射直接转成 ClientAuthFailure - requireInvoke 提前到 try 之外:认证桥未安装是我们自己的失败关闭错误,保持原样抛出 - Error.message 仍只取拒绝值里的可展示字符串,只做展示,不参与分流 - 测试:新增"桥未安装保持原样抛出"与"Tauri 意外形状也按映射包装"两条判据;authFailureReporting 的意外形状用例改为断言 source=auth + 命令名 --- .../src/services/clientAuth.ts | 15 +++++----- .../tests/authFailureReporting.test.tsx | 5 ++-- .../tests/clientAuthFailure.test.ts | 30 +++++++++++++++---- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index de7369bcb..19c751d20 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -37,25 +37,26 @@ function requireInvoke() { } /** - * 认证命令的统一入口:把 Rust 结构化拒绝转成 JS 侧的 `ClientAuthFailure`, - * 非结构化拒绝(Tauri / JS 运行时自己的错误)原样抛出。 + * 认证命令的统一入口:把 Tauri 的拒绝按 ts-rs 映射转成 JS 侧的 `ClientAuthFailure`。 * - * 只判 `type` 是不是字符串(这是 ts-rs 判别联合的稳定键),**不校验字段名、不读文案判断、 - * 不兜底文案**。要不要上报、给不给用户提示,由调用方在 catch 里按 `payload.type` 决定。 + * **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 + * `ClientAuthError`;出现别的形状属于 Tauri / Rust 侧的缺陷,调用方 `switch` 的 `default` + * 分支仍会把它抛出去上报。也不读文案判断、不兜底文案。 */ async function invokeClientAuth( command: string, args?: Record, ): Promise { + // 认证桥未安装是我们自己的失败关闭错误,不是命令拒绝:放在 try 之外,原样抛出。 + const invoke = requireInvoke(); try { - const invoke = requireInvoke(); // 不带参数时保持 `invoke(command)` 的单参调用形态,别给命令多塞一个 undefined。 return args === undefined ? await invoke(command) : await invoke(command, args); } catch (error) { - const rejection = error as { type?: unknown; message?: unknown }; - if (typeof rejection?.type !== 'string') throw error; + // `message` 只是给 Error 用的可展示字符串,不是分流判据;分流只按 `payload.type`。 + const rejection = error as { message?: unknown }; throw new ClientAuthFailure( typeof rejection.message === 'string' ? rejection.message : '', error as ClientAuthError, diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index 3ff8a3cec..435d56e37 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -132,7 +132,7 @@ describe('认证失败的上报判据', () => { } }); - it('非结构化拒绝原样抛出,由 unhandledrejection 兜底上报', async () => { + it('Tauri 的意外拒绝形状也被包装后原样抛出上报', async () => { const uninstall = installUnhandledRejectionBridge(); try { await submitPasswordLogin(async (command) => { @@ -147,7 +147,8 @@ describe('认证失败的上报判据', () => { await waitFor(() => expect(reportCalls()).toHaveLength(1)); expect(reportCalls()[0]?.[1]).toMatchObject({ - source: 'unhandledrejection', + source: 'auth', + action: 'login_client_with_password', message: 'IPC 桥接不可用', }); } finally { diff --git a/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts b/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts index 4f87031b9..c093d98a2 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts @@ -115,11 +115,8 @@ describe('ClientAuthFailure', () => { expect((failure as Error).message).toBe('请输入正确的手机号'); }); - it('非结构化拒绝原样抛出,不包装也不改写', async () => { - const rejection = new Error('需要在 Tauri App 内登录'); - installInvoke(async () => { - throw rejection; - }); + it('认证桥未安装时保持原样抛出,不包装成命令失败', async () => { + delete window.__TAURI__; await expect( loginClientWithPassword( @@ -127,7 +124,28 @@ describe('ClientAuthFailure', () => { 'secret', 'https://dev.genarrative.world', ), - ).rejects.toBe(rejection); + ).rejects.toThrow('需要在 Tauri App 内登录'); + }); + + it('Tauri 的意外形状也按映射包装:不做嗅探,但仍是可上报的 Error', async () => { + const rejection = new Error('IPC 桥接异常'); + installInvoke(async () => { + throw rejection; + }); + + const failure = await loginClientWithPassword( + '13800000000', + 'secret', + 'https://dev.genarrative.world', + ).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ClientAuthFailure); + expect((failure as Error).message).toBe('IPC 桥接异常'); + expect((failure as ClientAuthFailure).payload).toBe(rejection); + expect((failure as ClientAuthFailure).context).toEqual({ + source: 'auth', + action: 'login_client_with_password', + }); }); it('结构化拒绝缺 message 时 Error.message 为空,不造兜底文案', async () => { -- 2.52.0 From 19c75e816778a66318e90aca6ff9c2f59453da67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 01:22:55 +0800 Subject: [PATCH 27/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=A4=B1=E8=B4=A5=20JS=20=E4=BE=A7=E8=BD=BD=E4=BD=93?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=B7=B2=E6=9C=89=20ClientActionError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR 认证失败载体:删除手写 ClientAuthFailure 类,拒绝装进 ClientActionError,cause 即判别联合 - ADR 命令错误结构化:同步第 3 节对 JS 载体的描述 - 技术方案:认证命令封装形态改为 ClientActionError + cause - 决策记录:影响范围去掉 clientAuthFailure.ts - 排障记忆:载体、判据与关联文件路径同步 --- docs/README.md | 2 +- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 2 +- ...GC认证失败的JS侧载体与抛出时机-2026-10-01.md | 56 +++++++++---------- .../shared-memory/decision-log.md | 6 +- docs/project-memory/shared-memory/pitfalls.md | 8 +-- ...€术方案】AGC错误报告与诊断上传-2026-08-31.md | 2 +- 6 files changed, 37 insertions(+), 39 deletions(-) diff --git a/docs/README.md b/docs/README.md index 07278dae9..606afeda1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,7 +52,7 @@ - [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 - [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。 - [AGC 命令错误结构化与错误报告口径](./adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md):AGC 命令失败按具体变体建模并用 ts-rs 导出,前端按变体分流、不匹配文案;报告池只收没人处理的错误。 -- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把结构化拒绝转成 `ClientAuthFailure`(`payload` 判别联合);判定只写在 catch 子句里,系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。 +- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientActionError`(`cause` 就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,每个分支 `as` 具名载荷,系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。 - [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index a580d5b04..9c28e77bd 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -63,7 +63,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 本节原先的"调用方用 `isClientAuthError` 读形状、`switch (error.type)` 分流、`default` 交池"已被 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](./【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md) - 取代:`invokeClientAuth` 把结构化拒绝转成 `ClientAuthFailure`(`payload` 是判别联合),判定只写在 + 取代:`invokeClientAuth` 把结构化拒绝装进 `ClientActionError`(`cause` 是判别联合),判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型。 - "未识别变体上调是**故意**的"不再成立:`default` 改为 `expectNever(error.payload)`,漏接变体是 **编译错误**,不再是运行时报进池。 diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index ece808530..cf0e37e67 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -21,21 +21,17 @@ Rust 已经返回结构化错误,但 Tauri 的 `invoke` 拒绝值是**普通 ## 决策 -### 1. 一个 JS 载体类:`ClientAuthFailure`,结构化字段名 `payload` +### 1. 不新增 JS 错误类型:直接用生成的 `ClientAuthError` -```ts -class ClientAuthFailure extends ClientActionError { - readonly payload: ClientAuthError; // ts-rs 判别联合,payload.type 是分流键 -} -``` +ts-rs 已经把 `ClientAuthError` 生成成判别联合(`src/services/generated/ClientAuthError.ts`), +前端只有这一个错误事实源,**不再另建 `ClientAuthFailure` 之类的手写类**——那只会退化成 +`payload` / `cause` 的一层别名,给同一个事实源挂第二个名字。 + +原始拒绝值是普通对象,直接 `throw` 会被上报链路降级成 `String(obj)`;所以包装层把它装进 +**已有**的 `ClientActionError`:`context = { source: 'auth', action: 命令名 }`, +`cause = 原始拒绝值`(也就是那个判别联合)。`captureClientError` 用 `instanceof` 解包上下文、 +用 `cause` 取栈,指纹与展示字段都不变;catch 里 `error.cause as ClientAuthError` 直接分流。 -- 类名不叫 `ClientAuthError`:那个名字已经被 ts-rs 生成物占用;JS 类与生成类型同名会让 - 两个事实源混在一起。 -- 它继承 `ClientActionError`,所以 `captureClientError` 能用 `instanceof` 解包 - `source: 'auth'`、`action: 命令名`(错误池指纹维度)与 `cause: 原始拒绝值`。 - 上报方不需要知道上游是谁。 -- `payload` 是完整的结构化拒绝(判别联合),不是再抄一份的派生字段;`payload.type` - 就是分流键。**不再新增 `kind` / `notice` / `severity` 之类的派生值。** - 19 个具名载荷类型是有意保留的:它们是 §3 每个 `case` 里 `as X` 的目标,也正是"不要假设 所有变体字段相同"的落点。改成内联 struct 变体确实能让 ts-rs 把字段内联进联合成员、少掉 19 个生成文件,但每个分支就再也拿不到可 `as` 的具名类型(只能手写内联对象类型,等于放弃 @@ -51,13 +47,12 @@ async function invokeClientAuth(command, args): Promise { try { return await invoke(command, args); } catch (error) { - // 信任 tauri + ts-rs 的映射:认证命令的拒绝就是 ClientAuthError,不做运行时形状校验。 - const rejection = error as { message?: unknown }; - throw new ClientAuthFailure( - typeof rejection.message === 'string' ? rejection.message : '', - error as ClientAuthError, - command, - error, + // 信任 tauri + ts-rs 的映射:认证命令的拒绝就是 ClientAuthError。 + const failure = error as ClientAuthError; + throw new ClientActionError( + failure.message, + { source: 'auth', action: command }, + failure, ); } } @@ -68,18 +63,20 @@ async function invokeClientAuth(command, args): Promise { 静默吞掉——只是不再在包装层替 Tauri 兜底。 - `requireInvoke()` 放在 `try` 之外:认证桥未安装是我们自己的失败关闭错误,不是命令拒绝,保持 原样抛出(`需要在 Tauri App 内登录`)。 -- `Error.message` 只从拒绝值里读一个可展示字符串(`typeof === 'string'`),**不是分流判据**; - 分流永远只按 `payload.type`。 +- `failure.message` 是判别联合上的展示文案:它要求**每个变体都带 `message`**,这是编译期契约 + (Rust 侧 `ClientAuthError::message()` 有同样的约定),不是"假设所有变体形状相同";分流永远 + 只按 `failure.type`,每个分支各自 `as` 自己的载荷类型。 - 该包装是"Rust 结构化错误 → JS 错误对象"的唯一转换点:不做分类、不读文案判断、不兜底文案。 ### 3. 判定只写在 catch 子句里,用具体变体 ```ts catch (error) { - if (!(error instanceof ClientAuthFailure)) throw error; - switch (error.payload.type) { + if (!(error instanceof ClientActionError)) throw error; // 超时 / 桥未装等我们自己的错误 + const failure = error.cause as ClientAuthError; + switch (failure.type) { case 'phoneNumberInvalid': { - const payload = error.payload as PhoneNumberInvalid; + const payload = failure as PhoneNumberInvalid; setLoginStatus(payload.message); break; } @@ -88,19 +85,19 @@ catch (error) { // ... 系统变体逐个列出后原样抛出 throw error; default: - expectNever(error.payload); + expectNever(failure); } } ``` -- **每个业务 / 会话 `case` 用 `as` 取自己的具名载荷类型**,不写 `error.payload.message` 这种 +- **每个业务 / 会话 `case` 用 `as` 取自己的具名载荷类型**,不写 `failure.message` 这种 跨变体的通用读取;等价于 Java 的 `catch (PhoneNumberInvalid e)`。系统变体不读载荷(调用方 只负责原样抛出),但变体名必须逐个列出,`default` 的 `expectNever` 才成立。 - 业务 / 会话变体:把载荷自带的 `message` 原样交给用户,**不加兜底文案**(那就是 Rust 生成的 那一份)。 - 系统变体:调用方处理不了,**原样 `throw`**。`onSubmit` / `onClick` 这类 `void` 掉的 handler 抛出的拒绝最终以 `unhandledrejection` 结算,由全局 handler 交给错误池。 -- `default: expectNever(error.payload)`(`expectNever(value: never)`)让"Rust 加了变体而这里 +- `default: expectNever(failure)`(`expectNever(value: never)`)让"Rust 加了变体而这里 没接"变成**编译错误**。这是上一版"未识别变体上调是故意的"的替代方案:判据从运行时前移到 编译期。 - 不把这段 switch 抽成 presenter / helper 函数:判定必须发生在 catch 里,包装函数只负责 @@ -113,7 +110,8 @@ catch (error) { `failed` 状态(`authoritative` 只在"未登录"上为 true,`failed` 恒为 false,删除它不丢信息)。 - `ClientAuthState` 收敛为 `authenticated | unauthenticated`:读状态失败就是命令失败,由 - `invokeClientAuth` 转成 `ClientAuthFailure`,不再有第三种 "unavailable 投影"。 + `invokeClientAuth` 装进 `ClientActionError`(`cause` 是判别联合),不再有第三种 + "unavailable 投影"。 - `ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 ## 后果与边界 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 642b64e7b..f2e266ae7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8,10 +8,10 @@ - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 每个变体一个载荷文件)。 - 决策(补充):TS 形状用**每个变体一个具名载荷类型**——Rust 枚举是 newtype 变体持有同名 `#[ts(export)]` 结构体,ts-rs 生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`,前端 `switch (error.type)` 的每个分支直接拿到具名类型。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 -- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;结构化拒绝转成 `ClientAuthFailure`(`extends ClientActionError`,`readonly payload` 是 ts-rs 判别联合,`context = { source: 'auth', action: command }`,`cause` 是原始拒绝值),非结构化拒绝原样抛出。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 -- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (error.payload.type)` 判,每个 `case` 用 `as` 取具名载荷类型;业务 / 会话变体把载荷自带 `message` 原样给用户(无兜底文案),系统变体 `throw error` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包上下文),`default: expectNever(error.payload)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 +- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值装进已有的 `ClientActionError`(`context = { source: 'auth', action: command }`,`cause` 就是 ts-rs 生成的 `ClientAuthError` 判别联合),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 +- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.cause as ClientAuthError`,每个 `case` 用 `as` 取具名载荷类型;业务 / 会话变体把载荷自带 `message` 原样给用户(无兜底文案),系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包上下文),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 -- 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuthFailure.ts,clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 +- 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 ## 2026-10-01 DirectProject 审批拒绝原因留痕 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a9830eed7..b0c0543e7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6,11 +6,11 @@ - **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。 - **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 -- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝转成 `ClientAuthFailure`(`payload` 是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 -- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次且 `source=auth`(`ClientAuthFailure.context.action` 是命令名)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 -- **形状约定**:`ClientAuthError` 的每个变体在 TS 里是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`switch (error.payload.type)`,每个 `case` 用 `as` 取具名载荷类型,`default: expectNever(error.payload)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier。 +- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝装进已有的 `ClientActionError`(`cause` 是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 +- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次且 `source=auth`(`ClientActionError.context.action` 是命令名)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 +- **形状约定**:`ClientAuthError` 的每个变体在 TS 里是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.cause as ClientAuthError; switch (failure.type)`,每个 `case` 用 `as` 取具名载荷类型,`default: expectNever(failure)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier。 - **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。 -- **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuthFailure.ts,clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 ## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 73c35757a..ca87742e8 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -11,7 +11,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientActionError`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 -- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 把结构化拒绝转成 `ClientAuthFailure`(`extends ClientActionError`,`payload` 为判别联合,`context.action` 是命令名),判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。 +- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 把结构化拒绝装进**已有**的 `ClientActionError`(`context.action` 是命令名,`cause` 就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 - 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 -- 2.52.0 From e43efeb2699fe8c527b817f9b2a743a78a4298d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 01:27:18 +0800 Subject: [PATCH 28/70] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=8E=BB=E6=8E=89=E6=89=8B=E5=86=99=E8=BD=BD=E4=BD=93?= =?UTF-8?q?=E7=B1=BB=EF=BC=8C=E7=9B=B4=E6=8E=A5=E7=94=A8=20ClientActionErr?= =?UTF-8?q?or=20+=20=E7=94=9F=E6=88=90=E5=88=A4=E5=88=AB=E8=81=94=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 clientAuthFailure.ts,invokeClientAuth 改为把拒绝装进已有 ClientActionError,cause 即 ts-rs 生成的 ClientAuthError - 三处 catch 改为 instanceof ClientActionError 后取 error.cause 分流,每个 case 用 as 取具名载荷 - 认证载体用例改名 clientAuthErrorCarrier.test.ts,断言改为 ClientActionError.cause - clientAuthHost 用例与 auth_session.rs 注释同步新载体 --- .../src/app/AuthenticatedClient.tsx | 95 ++++++++++--------- .../src/services/clientAuth.ts | 25 +++-- .../src/services/clientAuthFailure.ts | 30 ------ ...test.ts => clientAuthErrorCarrier.test.ts} | 29 +++--- .../tests/clientAuthHost.test.ts | 4 +- 5 files changed, 77 insertions(+), 106 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/services/clientAuthFailure.ts rename apps/ai-game-creator-shell/tests/{clientAuthFailure.test.ts => clientAuthErrorCarrier.test.ts} (86%) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index af9ab45f6..f029ec937 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -11,6 +11,7 @@ import { import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice'; +import { ClientActionError } from '../services/clientActionError'; import { loginClientWithPassword, loginClientWithPhoneCode, @@ -20,7 +21,6 @@ import { sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; -import { ClientAuthFailure } from '../services/clientAuthFailure'; import { type ClientServerPreset, type ClientServerSelection, @@ -35,6 +35,7 @@ import { captureClientError, installWebviewLogBridge, } from '../services/errorReporting'; +import type { ClientAuthError } from '../services/generated/ClientAuthError'; import type { LoginCodeMissing } from '../services/generated/LoginCodeMissing'; import type { PasswordEntryInputRejected } from '../services/generated/PasswordEntryInputRejected'; import type { PasswordMissing } from '../services/generated/PasswordMissing'; @@ -277,65 +278,66 @@ export function AuthenticatedClient({ if (!isActiveRun()) return; // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); - if (!(error instanceof ClientAuthFailure)) throw error; - switch (error.payload.type) { + if (!(error instanceof ClientActionError)) throw error; + const failure = error.cause as ClientAuthError; + switch (failure.type) { // 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。 case 'serverAddressRejected': { - const payload = error.payload as ServerAddressRejected; + const payload = failure as ServerAddressRejected; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'phoneNumberInvalid': { - const payload = error.payload as PhoneNumberInvalid; + const payload = failure as PhoneNumberInvalid; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'passwordMissing': { - const payload = error.payload as PasswordMissing; + const payload = failure as PasswordMissing; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'loginCodeMissing': { - const payload = error.payload as LoginCodeMissing; + const payload = failure as LoginCodeMissing; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'passwordEntryInputRejected': { - const payload = error.payload as PasswordEntryInputRejected; + const payload = failure as PasswordEntryInputRejected; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'phoneOrPasswordMismatch': { - const payload = error.payload as PhoneOrPasswordMismatch; + const payload = failure as PhoneOrPasswordMismatch; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'sendCodeInputRejected': { - const payload = error.payload as SendCodeInputRejected; + const payload = failure as SendCodeInputRejected; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'smsCodeThrottled': { - const payload = error.payload as SmsCodeThrottled; + const payload = failure as SmsCodeThrottled; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'phoneLoginInputRejected': { - const payload = error.payload as PhoneLoginInputRejected; + const payload = failure as PhoneLoginInputRejected; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'smsCodeInvalidOrExpired': { - const payload = error.payload as SmsCodeInvalidOrExpired; + const payload = failure as SmsCodeInvalidOrExpired; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; @@ -354,7 +356,7 @@ export function AuthenticatedClient({ case 'authClientInitFailed': throw error; default: { - expectNever(error.payload); + expectNever(failure); throw error; } } @@ -418,7 +420,8 @@ export function AuthenticatedClient({ /** * 认证失败的分流判据直接写在每个 catch 里: * - * - 承载:命令失败由 `invokeClientAuth` 转成 `ClientAuthFailure`,`payload.type` 是唯一分流键; + * - 承载:命令失败由 `invokeClientAuth` 装进 `ClientActionError`,`cause` 就是判别联合, + * `failure.type` 是唯一分流键; * - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池; * - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池; * - `default: expectNever` 保证 Rust 新增变体时这里编译失败。 @@ -447,65 +450,66 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { - if (!(error instanceof ClientAuthFailure)) throw error; - switch (error.payload.type) { + if (!(error instanceof ClientActionError)) throw error; + const failure = error.cause as ClientAuthError; + switch (failure.type) { case 'serverAddressRejected': { - const payload = error.payload as ServerAddressRejected; + const payload = failure as ServerAddressRejected; setLoginStatus(payload.message); break; } case 'phoneNumberInvalid': { - const payload = error.payload as PhoneNumberInvalid; + const payload = failure as PhoneNumberInvalid; setLoginStatus(payload.message); break; } case 'passwordMissing': { - const payload = error.payload as PasswordMissing; + const payload = failure as PasswordMissing; setLoginStatus(payload.message); break; } case 'loginCodeMissing': { - const payload = error.payload as LoginCodeMissing; + const payload = failure as LoginCodeMissing; setLoginStatus(payload.message); break; } case 'passwordEntryInputRejected': { - const payload = error.payload as PasswordEntryInputRejected; + const payload = failure as PasswordEntryInputRejected; setLoginStatus(payload.message); break; } case 'phoneOrPasswordMismatch': { - const payload = error.payload as PhoneOrPasswordMismatch; + const payload = failure as PhoneOrPasswordMismatch; setLoginStatus(payload.message); break; } case 'sendCodeInputRejected': { - const payload = error.payload as SendCodeInputRejected; + const payload = failure as SendCodeInputRejected; setLoginStatus(payload.message); break; } case 'smsCodeThrottled': { - const payload = error.payload as SmsCodeThrottled; + const payload = failure as SmsCodeThrottled; setLoginStatus(payload.message); break; } case 'phoneLoginInputRejected': { - const payload = error.payload as PhoneLoginInputRejected; + const payload = failure as PhoneLoginInputRejected; setLoginStatus(payload.message); break; } case 'smsCodeInvalidOrExpired': { - const payload = error.payload as SmsCodeInvalidOrExpired; + const payload = failure as SmsCodeInvalidOrExpired; setLoginStatus(payload.message); break; } case 'sessionAuthorityRejected': { - const payload = error.payload as SessionAuthorityRejected; + const payload = failure as SessionAuthorityRejected; setLoginStatus(payload.message); break; } case 'permissionDenied': { - const payload = error.payload as PermissionDenied; + const payload = failure as PermissionDenied; setLoginStatus(payload.message); break; } @@ -519,7 +523,7 @@ export function AuthenticatedClient({ case 'authClientInitFailed': throw error; default: { - expectNever(error.payload); + expectNever(failure); throw error; } } @@ -587,65 +591,66 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - if (!(error instanceof ClientAuthFailure)) throw error; - switch (error.payload.type) { + if (!(error instanceof ClientActionError)) throw error; + const failure = error.cause as ClientAuthError; + switch (failure.type) { case 'serverAddressRejected': { - const payload = error.payload as ServerAddressRejected; + const payload = failure as ServerAddressRejected; setLoginStatus(payload.message); break; } case 'phoneNumberInvalid': { - const payload = error.payload as PhoneNumberInvalid; + const payload = failure as PhoneNumberInvalid; setLoginStatus(payload.message); break; } case 'passwordMissing': { - const payload = error.payload as PasswordMissing; + const payload = failure as PasswordMissing; setLoginStatus(payload.message); break; } case 'loginCodeMissing': { - const payload = error.payload as LoginCodeMissing; + const payload = failure as LoginCodeMissing; setLoginStatus(payload.message); break; } case 'passwordEntryInputRejected': { - const payload = error.payload as PasswordEntryInputRejected; + const payload = failure as PasswordEntryInputRejected; setLoginStatus(payload.message); break; } case 'phoneOrPasswordMismatch': { - const payload = error.payload as PhoneOrPasswordMismatch; + const payload = failure as PhoneOrPasswordMismatch; setLoginStatus(payload.message); break; } case 'sendCodeInputRejected': { - const payload = error.payload as SendCodeInputRejected; + const payload = failure as SendCodeInputRejected; setLoginStatus(payload.message); break; } case 'smsCodeThrottled': { - const payload = error.payload as SmsCodeThrottled; + const payload = failure as SmsCodeThrottled; setLoginStatus(payload.message); break; } case 'phoneLoginInputRejected': { - const payload = error.payload as PhoneLoginInputRejected; + const payload = failure as PhoneLoginInputRejected; setLoginStatus(payload.message); break; } case 'smsCodeInvalidOrExpired': { - const payload = error.payload as SmsCodeInvalidOrExpired; + const payload = failure as SmsCodeInvalidOrExpired; setLoginStatus(payload.message); break; } case 'sessionAuthorityRejected': { - const payload = error.payload as SessionAuthorityRejected; + const payload = failure as SessionAuthorityRejected; setLoginStatus(payload.message); break; } case 'permissionDenied': { - const payload = error.payload as PermissionDenied; + const payload = failure as PermissionDenied; setLoginStatus(payload.message); break; } @@ -659,7 +664,7 @@ export function AuthenticatedClient({ case 'authClientInitFailed': throw error; default: { - expectNever(error.payload); + expectNever(failure); throw error; } } diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 19c751d20..c3f0e3c5a 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,6 +1,6 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; -import { ClientAuthFailure } from './clientAuthFailure'; +import { ClientActionError } from './clientActionError'; import type { ClientAuthError } from './generated/ClientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; @@ -37,11 +37,11 @@ function requireInvoke() { } /** - * 认证命令的统一入口:把 Tauri 的拒绝按 ts-rs 映射转成 JS 侧的 `ClientAuthFailure`。 + * 认证命令的统一入口:把 Tauri 的拒绝装进已有的 `ClientActionError`。 * - * **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 - * `ClientAuthError`;出现别的形状属于 Tauri / Rust 侧的缺陷,调用方 `switch` 的 `default` - * 分支仍会把它抛出去上报。也不读文案判断、不兜底文案。 + * **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 ts-rs 生成的 + * `ClientAuthError` 判别联合,原样放进 `cause`;出现别的形状属于 Tauri / Rust 侧缺陷,调用方 + * `switch` 的 `default` 分支仍会把它抛出去上报。也不读文案判断、不兜底文案。 */ async function invokeClientAuth( command: string, @@ -55,13 +55,12 @@ async function invokeClientAuth( ? await invoke(command) : await invoke(command, args); } catch (error) { - // `message` 只是给 Error 用的可展示字符串,不是分流判据;分流只按 `payload.type`。 - const rejection = error as { message?: unknown }; - throw new ClientAuthFailure( - typeof rejection.message === 'string' ? rejection.message : '', - error as ClientAuthError, - command, - error, + // 拒绝值就是判别联合;`message` 是每个变体都有的展示文案,分流只按 `type`。 + const failure = error as ClientAuthError; + throw new ClientActionError( + failure.message, + { source: 'auth', action: command }, + failure, ); } } @@ -81,7 +80,7 @@ type RustAuthRefreshView = * 恢复登录态。 * * 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败, - * 由 `invokeClientAuth` 转成 `ClientAuthFailure`,不再有第三态投影。 + * 由 `invokeClientAuth` 装进 `ClientActionError`(`cause` 是判别联合),不再有第三态投影。 */ export async function readClientAuthState( expectedApiBaseUrl?: string, diff --git a/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts b/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts deleted file mode 100644 index 4cd4386f8..000000000 --- a/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ClientActionError } from './clientActionError'; -import type { ClientAuthError } from './generated/ClientAuthError'; - -/** - * 认证命令失败在 JS 侧的载体。 - * - * Tauri 的 `invoke` 拒绝值是普通对象(Rust 序列化的结构化错误),不是 `Error`;直接抛出会被 - * 上报链路降级成 `String(obj)`,类型与文案一起丢掉。`invokeClientAuth` 统一把它转成本类: - * - * - `payload` 是 ts-rs 生成的判别联合,`payload.type` 是唯一的稳定分流键; - * - `context` 固定为 `{ source: 'auth', action: 命令名 }`,给错误池定指纹; - * - `cause` 是原始拒绝值,保留给上报链路取栈与排障。 - * - * 本类只承载事实,**不做分类、不产派生值、不兜底文案**:要不要上报、给不给用户提示,由调用方 - * 在 catch 里按 `payload.type` 判。 - */ -export class ClientAuthFailure extends ClientActionError { - readonly payload: ClientAuthError; - - constructor( - message: string, - payload: ClientAuthError, - command: string, - cause: unknown, - ) { - super(message, { source: 'auth', action: command }, cause); - this.name = 'ClientAuthFailure'; - this.payload = payload; - } -} diff --git a/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts similarity index 86% rename from apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts rename to apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index c093d98a2..cde523737 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -2,15 +2,14 @@ /** * 认证命令失败的 JS 侧载体。 * - * Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 转成 `ClientAuthFailure`,非结构化拒绝 - * 原样抛出;分流只按 `payload.type`,不读文案。另外用编译期用例钉住"每个变体一个具名载荷 - * 类型":漏列任何变体,`expectNever(error)` 都编译不过。 + * Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 装进已有的 `ClientActionError`: + * `cause` 就是 ts-rs 生成的判别联合,`context.action` 是命令名;分流只按 `cause.type`,不读文案。 + * 另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体,`expectNever(error)` 都编译不过。 */ import { describe, expect, it, vi } from 'vitest'; import { ClientActionError } from '../src/services/clientActionError'; import { loginClientWithPassword } from '../src/services/clientAuth'; -import { ClientAuthFailure } from '../src/services/clientAuthFailure'; import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed'; import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable'; import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed'; @@ -88,8 +87,8 @@ function installInvoke(handler: () => Promise) { window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never; } -describe('ClientAuthFailure', () => { - it('结构化拒绝转成带 payload / context / cause 的 JS 错误', async () => { +describe('认证命令失败的 JS 侧载体', () => { + it('结构化拒绝装进 ClientActionError,cause 是判别联合', async () => { const rejection = { type: 'phoneNumberInvalid', message: '请输入正确的手机号', @@ -104,14 +103,12 @@ describe('ClientAuthFailure', () => { 'https://dev.genarrative.world', ).catch((error: unknown) => error); - expect(failure).toBeInstanceOf(ClientAuthFailure); expect(failure).toBeInstanceOf(ClientActionError); - expect((failure as ClientAuthFailure).payload).toEqual(rejection); - expect((failure as ClientAuthFailure).context).toEqual({ + expect((failure as ClientActionError).cause).toEqual(rejection); + expect((failure as ClientActionError).context).toEqual({ source: 'auth', action: 'login_client_with_password', }); - expect((failure as ClientAuthFailure).cause).toBe(rejection); expect((failure as Error).message).toBe('请输入正确的手机号'); }); @@ -139,10 +136,10 @@ describe('ClientAuthFailure', () => { 'https://dev.genarrative.world', ).catch((error: unknown) => error); - expect(failure).toBeInstanceOf(ClientAuthFailure); + expect(failure).toBeInstanceOf(ClientActionError); expect((failure as Error).message).toBe('IPC 桥接异常'); - expect((failure as ClientAuthFailure).payload).toBe(rejection); - expect((failure as ClientAuthFailure).context).toEqual({ + expect((failure as ClientActionError).cause).toBe(rejection); + expect((failure as ClientActionError).context).toEqual({ source: 'auth', action: 'login_client_with_password', }); @@ -160,9 +157,9 @@ describe('ClientAuthFailure', () => { ).catch((error: unknown) => error); expect((failure as Error).message).toBe(''); - expect((failure as ClientAuthFailure).payload.type).toBe( - 'unexpectedRejection', - ); + expect((failure as ClientActionError).cause).toMatchObject({ + type: 'unexpectedRejection', + }); }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index d4092efcc..cfeadcab1 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -158,14 +158,14 @@ test('没有原生宿主时登录能力明确失败关闭', async () => { expect(invoke).not.toHaveBeenCalled(); }); -test('结构化失败不降级成投影值,而是以 ClientAuthFailure 拒绝', async () => { +test('结构化失败不降级成投影值,而是以带 cause 的 ClientActionError 拒绝', async () => { const rejection = { type: 'authNetworkUnavailable', message: '无法连接登录服务', }; invoke.mockRejectedValueOnce(rejection); await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ - payload: rejection, + cause: rejection, context: { source: 'auth', action: 'refresh_client_auth_session' }, }); }); -- 2.52.0 From 3fbcef82037823e45fc57558fca4a29baf1cbf72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 01:27:31 +0800 Subject: [PATCH 29/70] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E7=BB=AD?= =?UTF-8?q?=E6=9C=9F=E6=B3=A8=E9=87=8A=E5=90=8C=E6=AD=A5=E6=96=B0=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E8=BD=BD=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_session.rs 注释改为 invokeClientAuth 装进 ClientActionError,cause 是判别联合 --- apps/ai-game-creator-shell/src-tauri/src/auth_session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 08f192e1f..17c467a24 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -931,7 +931,7 @@ async fn refresh_session_inner( }); } // 非权威失败不再降级成字符串:把 typed error 原样交给命令出口, - // 由前端 `invokeClientAuth` 转成 `ClientAuthFailure` 并按变体分流。 + // 由前端 `invokeClientAuth` 装进 `ClientActionError`(`cause` 是判别联合)后按变体分流。 return Err(error); } }; -- 2.52.0 From 218fba95b01a81f56085bd0d5f388d373b65f64f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 01:35:10 +0800 Subject: [PATCH 30/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=A4=B1=E8=B4=A5=E8=BD=BD=E4=BD=93=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E4=B8=BA=E5=8F=AA=E6=8C=81=20error=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E7=9A=84=E8=96=84=E5=8C=85=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR 认证失败载体:ClientActionError 只保留 error 字段,不读 message、不塞 context、message 留空 - 技术方案与决策记录同步载体形状与 error.error 分流 - 排障记忆:判据改为全局 unhandledrejection 显式上下文 --- ...GC认证失败的JS侧载体与抛出时机-2026-10-01.md | 30 +++++++++---------- .../shared-memory/decision-log.md | 4 +-- docs/project-memory/shared-memory/pitfalls.md | 6 ++-- ...€术方案】AGC错误报告与诊断上传-2026-08-31.md | 2 +- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index cf0e37e67..ba290ec49 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -28,9 +28,10 @@ ts-rs 已经把 `ClientAuthError` 生成成判别联合(`src/services/generate `payload` / `cause` 的一层别名,给同一个事实源挂第二个名字。 原始拒绝值是普通对象,直接 `throw` 会被上报链路降级成 `String(obj)`;所以包装层把它装进 -**已有**的 `ClientActionError`:`context = { source: 'auth', action: 命令名 }`, -`cause = 原始拒绝值`(也就是那个判别联合)。`captureClientError` 用 `instanceof` 解包上下文、 -用 `cause` 取栈,指纹与展示字段都不变;catch 里 `error.cause as ClientAuthError` 直接分流。 +**已有**的 `ClientActionError`,载体只持有一个 `error` 字段,值就是原始拒绝值(也就是那个 +判别联合)。它**不读、不产任何派生值**:不读变体上的 `message`(变体不保证都有这个字段), +不塞 `context`,`Error.message` 留空。上报的 `source` / `action` 由调用 `captureClientError` +时的显式入参决定;catch 里 `error.error as ClientAuthError` 直接分流。 - 19 个具名载荷类型是有意保留的:它们是 §3 每个 `case` 里 `as X` 的目标,也正是"不要假设 所有变体字段相同"的落点。改成内联 struct 变体确实能让 ts-rs 把字段内联进联合成员、少掉 @@ -47,13 +48,8 @@ async function invokeClientAuth(command, args): Promise { try { return await invoke(command, args); } catch (error) { - // 信任 tauri + ts-rs 的映射:认证命令的拒绝就是 ClientAuthError。 - const failure = error as ClientAuthError; - throw new ClientActionError( - failure.message, - { source: 'auth', action: command }, - failure, - ); + // 原样把 Rust 的拒绝装成 JS Error;不读字段、不加字段。 + throw new ClientActionError(error); } } ``` @@ -63,9 +59,9 @@ async function invokeClientAuth(command, args): Promise { 静默吞掉——只是不再在包装层替 Tauri 兜底。 - `requireInvoke()` 放在 `try` 之外:认证桥未安装是我们自己的失败关闭错误,不是命令拒绝,保持 原样抛出(`需要在 Tauri App 内登录`)。 -- `failure.message` 是判别联合上的展示文案:它要求**每个变体都带 `message`**,这是编译期契约 - (Rust 侧 `ClientAuthError::message()` 有同样的约定),不是"假设所有变体形状相同";分流永远 - 只按 `failure.type`,每个分支各自 `as` 自己的载荷类型。 +- **不读、不产任何派生值**:不读变体上的 `message`(变体不保证都有这个字段),不注入 + `source` / `action`,`Error.message` 留空;载体只把原始拒绝值原样放进 `error`。展示文案与 + 上报上下文都由 catch 子句里拿到具名载荷的调用方决定。 - 该包装是"Rust 结构化错误 → JS 错误对象"的唯一转换点:不做分类、不读文案判断、不兜底文案。 ### 3. 判定只写在 catch 子句里,用具体变体 @@ -73,7 +69,7 @@ async function invokeClientAuth(command, args): Promise { ```ts catch (error) { if (!(error instanceof ClientActionError)) throw error; // 超时 / 桥未装等我们自己的错误 - const failure = error.cause as ClientAuthError; + const failure = error.error as ClientAuthError; switch (failure.type) { case 'phoneNumberInvalid': { const payload = failure as PhoneNumberInvalid; @@ -110,7 +106,7 @@ catch (error) { `failed` 状态(`authoritative` 只在"未登录"上为 true,`failed` 恒为 false,删除它不丢信息)。 - `ClientAuthState` 收敛为 `authenticated | unauthenticated`:读状态失败就是命令失败,由 - `invokeClientAuth` 装进 `ClientActionError`(`cause` 是判别联合),不再有第三种 + `invokeClientAuth` 装进 `ClientActionError`(`error` 是判别联合),不再有第三种 "unavailable 投影"。 - `ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 @@ -120,7 +116,9 @@ catch (error) { - `platformSession` 续期失败继续按"网络类失败不降级身份、不标权威失败"处理 (`authoritative: false`),与旧 `failed` 分支语义一致。 - 用户可见行为:业务拒绝仍是登录页一行提示;系统失败仍是错误池事件 + 「发现问题」通知。 -- 全局 `unhandledrejection` 是系统变体的唯一出口,调用方不再直接调 `captureClientError`。 +- 全局 `unhandledrejection` 是系统变体的唯一出口,调用方不再直接调 `captureClientError`; + 系统变体上报的 `source` 就是该 handler 的显式入参(`unhandledrejection`),载体不再携带 + `action`;结构化拒绝没有 JS `Error.message`,上报文案落回调用方给的默认值。 ## 验收 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f2e266ae7..2edd61ab0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8,8 +8,8 @@ - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 每个变体一个载荷文件)。 - 决策(补充):TS 形状用**每个变体一个具名载荷类型**——Rust 枚举是 newtype 变体持有同名 `#[ts(export)]` 结构体,ts-rs 生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`,前端 `switch (error.type)` 的每个分支直接拿到具名类型。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 -- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值装进已有的 `ClientActionError`(`context = { source: 'auth', action: command }`,`cause` 就是 ts-rs 生成的 `ClientAuthError` 判别联合),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 -- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.cause as ClientAuthError`,每个 `case` 用 `as` 取具名载荷类型;业务 / 会话变体把载荷自带 `message` 原样给用户(无兜底文案),系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包上下文),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 +- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientActionError`(载体只有一个 `error` 字段,值就是 ts-rs 生成的 `ClientAuthError` 判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 +- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error as ClientAuthError`,每个 `case` 用 `as` 取具名载荷类型;业务 / 会话变体把载荷自带 `message` 原样给用户(无兜底文案),系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 - 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b0c0543e7..2340e8539 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6,9 +6,9 @@ - **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。 - **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 -- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝装进已有的 `ClientActionError`(`cause` 是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 -- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次且 `source=auth`(`ClientActionError.context.action` 是命令名)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 -- **形状约定**:`ClientAuthError` 的每个变体在 TS 里是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.cause as ClientAuthError; switch (failure.type)`,每个 `case` 用 `as` 取具名载荷类型,`default: expectNever(failure)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier。 +- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientActionError`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 +- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次(`source` 取全局 `unhandledrejection` handler 的显式入参;载体不再携带 `action`)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 +- **形状约定**:`ClientAuthError` 的每个变体在 TS 里是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error as ClientAuthError; switch (failure.type)`,每个 `case` 用 `as` 取具名载荷类型,`default: expectNever(failure)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier。 - **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index ca87742e8..7cf58a91b 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -11,7 +11,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientActionError`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 -- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 把结构化拒绝装进**已有**的 `ClientActionError`(`context.action` 是命令名,`cause` 就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。 +- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientActionError`(载体只有一个 `error` 字段,值就是 ts-rs 生成的 `ClientAuthError` 判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),不新增手写错误类;判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 - 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 -- 2.52.0 From 45774f1c02f113f3b86c904589aec93caacb385e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 01:37:37 +0800 Subject: [PATCH 31/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=A4=B1=E8=B4=A5=E8=BD=BD=E4=BD=93=E7=9A=84=20error?= =?UTF-8?q?=20=E5=AD=97=E6=AE=B5=E6=A0=87=E6=B3=A8=20ClientAuthError=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR、技术方案、决策记录同步 error 字段的显式类型标注 --- docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md | 2 +- docs/project-memory/shared-memory/decision-log.md | 2 +- docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index ba290ec49..0b9fe5328 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -28,7 +28,7 @@ ts-rs 已经把 `ClientAuthError` 生成成判别联合(`src/services/generate `payload` / `cause` 的一层别名,给同一个事实源挂第二个名字。 原始拒绝值是普通对象,直接 `throw` 会被上报链路降级成 `String(obj)`;所以包装层把它装进 -**已有**的 `ClientActionError`,载体只持有一个 `error` 字段,值就是原始拒绝值(也就是那个 +**已有**的 `ClientActionError`,载体只持有一个 `ClientAuthError` 类型的 `error` 字段,值就是原始拒绝值(也就是那个 判别联合)。它**不读、不产任何派生值**:不读变体上的 `message`(变体不保证都有这个字段), 不塞 `context`,`Error.message` 留空。上报的 `source` / `action` 由调用 `captureClientError` 时的显式入参决定;catch 里 `error.error as ClientAuthError` 直接分流。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2edd61ab0..1100376cd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8,7 +8,7 @@ - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 每个变体一个载荷文件)。 - 决策(补充):TS 形状用**每个变体一个具名载荷类型**——Rust 枚举是 newtype 变体持有同名 `#[ts(export)]` 结构体,ts-rs 生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`,前端 `switch (error.type)` 的每个分支直接拿到具名类型。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 -- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientActionError`(载体只有一个 `error` 字段,值就是 ts-rs 生成的 `ClientAuthError` 判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 +- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientActionError`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 - 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error as ClientAuthError`,每个 `case` 用 `as` 取具名载荷类型;业务 / 会话变体把载荷自带 `message` 原样给用户(无兜底文案),系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 - 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 7cf58a91b..3b39d8cf0 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -11,7 +11,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientActionError`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 -- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientActionError`(载体只有一个 `error` 字段,值就是 ts-rs 生成的 `ClientAuthError` 判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),不新增手写错误类;判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。 +- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientActionError`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),不新增手写错误类;判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 - 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 -- 2.52.0 From 2a51f0b98cb31b5ded11dfdf1429dc8dbbabfd8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 01:40:14 +0800 Subject: [PATCH 32/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=A4=B1=E8=B4=A5=E8=BD=BD=E4=BD=93=E7=B1=BB=E6=94=B9?= =?UTF-8?q?=E5=90=8D=20ClientAuthErrorWrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR、README、技术方案、决策记录与排障记忆统一新类名 - auth_session.rs 注释同步 --- apps/ai-game-creator-shell/src-tauri/src/auth_session.rs | 2 +- docs/README.md | 2 +- .../【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md | 6 +++--- .../【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md | 8 ++++---- docs/project-memory/shared-memory/decision-log.md | 4 ++-- docs/project-memory/shared-memory/pitfalls.md | 2 +- .../【技术方案】AGC错误报告与诊断上传-2026-08-31.md | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 17c467a24..599356ad9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -931,7 +931,7 @@ async fn refresh_session_inner( }); } // 非权威失败不再降级成字符串:把 typed error 原样交给命令出口, - // 由前端 `invokeClientAuth` 装进 `ClientActionError`(`cause` 是判别联合)后按变体分流。 + // 由前端 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`cause` 是判别联合)后按变体分流。 return Err(error); } }; diff --git a/docs/README.md b/docs/README.md index 606afeda1..4c296810a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,7 +52,7 @@ - [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 - [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。 - [AGC 命令错误结构化与错误报告口径](./adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md):AGC 命令失败按具体变体建模并用 ts-rs 导出,前端按变体分流、不匹配文案;报告池只收没人处理的错误。 -- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientActionError`(`cause` 就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,每个分支 `as` 具名载荷,系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。 +- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientAuthErrorWrapper`(`cause` 就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,每个分支 `as` 具名载荷,系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。 - [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 9c28e77bd..d7adfcdc2 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -52,7 +52,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - **预期业务拒绝**(用户输入、前置条件、预期 4xx):由调用方消化并给用户反馈,**永不进池**。 - **真故障**(网络不可达、5xx、写盘/运行时安装失败、agent 终态失败):由调用方带上文交给错误池 - (`ClientActionError` + `captureClientError`);`window.onerror` / `unhandledrejection` 只兜底 + (`ClientAuthErrorWrapper` + `captureClientError`);`window.onerror` / `unhandledrejection` 只兜底 没人接手的错误。Rust 侧 agent 终态失败仍由失败投影入池。 - **WebView 全局 handler 是兜底**:任何没人 catch 的错误都进池。 - **408/5xx/网络的判定由调用方在 catch 里做**:AGC shell 的 WebView 侧没有 fetch 边界的自动判定 @@ -63,12 +63,12 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 本节原先的"调用方用 `isClientAuthError` 读形状、`switch (error.type)` 分流、`default` 交池"已被 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](./【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md) - 取代:`invokeClientAuth` 把结构化拒绝装进 `ClientActionError`(`cause` 是判别联合),判定只写在 + 取代:`invokeClientAuth` 把结构化拒绝装进 `ClientAuthErrorWrapper`(`cause` 是判别联合),判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型。 - "未识别变体上调是**故意**的"不再成立:`default` 改为 `expectNever(error.payload)`,漏接变体是 **编译错误**,不再是运行时报进池。 - 系统变体与非结构化拒绝仍由调用方原样 `throw`,经全局 `unhandledrejection` 入池; - `captureClientError` 的 `instanceof ClientActionError` 解包保持不变。 + `captureClientError` 的 `instanceof ClientAuthErrorWrapper` 解包保持不变。 - 删除 `shouldCaptureClientError`:不再存在"叶子自己判定要不要报"的口径。 ### 4. 报告面板与通知行为不变 diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index 0b9fe5328..97b2180f0 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -28,7 +28,7 @@ ts-rs 已经把 `ClientAuthError` 生成成判别联合(`src/services/generate `payload` / `cause` 的一层别名,给同一个事实源挂第二个名字。 原始拒绝值是普通对象,直接 `throw` 会被上报链路降级成 `String(obj)`;所以包装层把它装进 -**已有**的 `ClientActionError`,载体只持有一个 `ClientAuthError` 类型的 `error` 字段,值就是原始拒绝值(也就是那个 +**已有**的 `ClientAuthErrorWrapper`,载体只持有一个 `ClientAuthError` 类型的 `error` 字段,值就是原始拒绝值(也就是那个 判别联合)。它**不读、不产任何派生值**:不读变体上的 `message`(变体不保证都有这个字段), 不塞 `context`,`Error.message` 留空。上报的 `source` / `action` 由调用 `captureClientError` 时的显式入参决定;catch 里 `error.error as ClientAuthError` 直接分流。 @@ -49,7 +49,7 @@ async function invokeClientAuth(command, args): Promise { return await invoke(command, args); } catch (error) { // 原样把 Rust 的拒绝装成 JS Error;不读字段、不加字段。 - throw new ClientActionError(error); + throw new ClientAuthErrorWrapper(error); } } ``` @@ -68,7 +68,7 @@ async function invokeClientAuth(command, args): Promise { ```ts catch (error) { - if (!(error instanceof ClientActionError)) throw error; // 超时 / 桥未装等我们自己的错误 + if (!(error instanceof ClientAuthErrorWrapper)) throw error; // 超时 / 桥未装等我们自己的错误 const failure = error.error as ClientAuthError; switch (failure.type) { case 'phoneNumberInvalid': { @@ -106,7 +106,7 @@ catch (error) { `failed` 状态(`authoritative` 只在"未登录"上为 true,`failed` 恒为 false,删除它不丢信息)。 - `ClientAuthState` 收敛为 `authenticated | unauthenticated`:读状态失败就是命令失败,由 - `invokeClientAuth` 装进 `ClientActionError`(`error` 是判别联合),不再有第三种 + `invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`error` 是判别联合),不再有第三种 "unavailable 投影"。 - `ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1100376cd..a69731a6a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,12 +3,12 @@ ## 2026-10-01 AGC 命令错误结构化与错误报告口径 - 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端只按 `type` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。 -- 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文交给错误池(`ClientActionError` 承载 `source/action/page`,`captureClientError` 用 `instanceof` 解包),`window.onerror` / `unhandledrejection` 只兜底没人接手的错误;408/5xx/网络的判定由调用方在 catch 里做(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。 +- 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文交给错误池(`ClientAuthErrorWrapper` 承载 `source/action/page`,`captureClientError` 用 `instanceof` 解包),`window.onerror` / `unhandledrejection` 只兜底没人接手的错误;408/5xx/网络的判定由调用方在 catch 里做(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。 - 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordEntryInputRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径改造或删除留在后续变更。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 每个变体一个载荷文件)。 - 决策(补充):TS 形状用**每个变体一个具名载荷类型**——Rust 枚举是 newtype 变体持有同名 `#[ts(export)]` 结构体,ts-rs 生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`,前端 `switch (error.type)` 的每个分支直接拿到具名类型。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 -- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientActionError`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 +- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 - 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error as ClientAuthError`,每个 `case` 用 `as` 取具名载荷类型;业务 / 会话变体把载荷自带 `message` 原样给用户(无兜底文案),系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 - 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2340e8539..0189a4f72 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6,7 +6,7 @@ - **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。 - **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 -- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientActionError`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 +- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientAuthErrorWrapper`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次(`source` 取全局 `unhandledrejection` handler 的显式入参;载体不再携带 `action`)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 - **形状约定**:`ClientAuthError` 的每个变体在 TS 里是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error as ClientAuthError; switch (failure.type)`,每个 `case` 用 `as` 取具名载荷类型,`default: expectNever(failure)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier。 - **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 3b39d8cf0..fbb21b0f0 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -8,10 +8,10 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 诊断 URL 保留可定位的 API 路由路径,隐藏 origin、URL 账号密码、查询参数、fragment 和路径中的敏感标识;普通资源 URL 与本地文件路径继续隐藏。网络错误、HTTP 错误与响应体超时均应带安全路由,不能只剩 ``。历史已经脱敏的归档不推测或补造原路由。 -- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientActionError`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 +- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientAuthErrorWrapper`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 -- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientActionError`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),不新增手写错误类;判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。 +- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),不新增手写错误类;判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 - 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 -- 2.52.0 From 3a4ccb1861a839524477670603ab7437125dc912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 01:40:28 +0800 Subject: [PATCH 33/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E8=AE=B0=E5=BD=95=E5=BD=B1=E5=93=8D=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E8=BD=BD=E4=BD=93=E6=96=87=E4=BB=B6=E6=96=B0?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 第一轮影响范围里的 clientActionError.ts 改为 clientAuthErrorWrapper.ts --- docs/project-memory/shared-memory/decision-log.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a69731a6a..abb1c792b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5,7 +5,7 @@ - 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端只按 `type` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。 - 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文交给错误池(`ClientAuthErrorWrapper` 承载 `source/action/page`,`captureClientError` 用 `instanceof` 解包),`window.onerror` / `unhandledrejection` 只兜底没人接手的错误;408/5xx/网络的判定由调用方在 catch 里做(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。 - 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordEntryInputRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径改造或删除留在后续变更。 -- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 每个变体一个载荷文件)。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientAuthErrorWrapper.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 每个变体一个载荷文件)。 - 决策(补充):TS 形状用**每个变体一个具名载荷类型**——Rust 枚举是 newtype 变体持有同名 `#[ts(export)]` 结构体,ts-rs 生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`,前端 `switch (error.type)` 的每个分支直接拿到具名类型。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 - 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 -- 2.52.0 From e1628b2ccda1285b2f24285fda6ed61b4938369b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 01:42:17 +0800 Subject: [PATCH 34/70] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=94=B9=E7=94=A8=20ClientAuthErrorWrapper=20?= =?UTF-8?q?=E8=96=84=E5=8C=85=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clientActionError.ts 改名 clientAuthErrorWrapper.ts,类名同步为 ClientAuthErrorWrapper,error 字段标注 ClientAuthError - invokeClientAuth 改为薄包装:不读变体 message、不塞 context,只把拒绝原样装成 JS Error - 三处 catch 改用 error.error 分流,每个 case 继续用 as 取具名载荷 - captureClientError 从 error 字段取原始错误;上下文只认显式入参 - 载体、host、错误上报与 appSurface 用例同步新载体与新默认文案 --- .../src/app/AuthenticatedClient.tsx | 17 +++--- .../src/services/clientActionError.ts | 32 ------------ .../src/services/clientAuth.ts | 20 +++---- .../src/services/clientAuthErrorWrapper.ts | 30 +++++++++++ .../src/services/errorReporting.ts | 33 ++++++------ .../tests/appSurface/auth.suite.ts | 6 ++- .../tests/authFailureReporting.test.tsx | 8 ++- .../tests/clientAuthErrorCarrier.test.ts | 52 +++++-------------- .../tests/clientAuthHost.test.ts | 5 +- .../tests/errorReporting.test.ts | 28 +++++----- 10 files changed, 99 insertions(+), 132 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/services/clientActionError.ts create mode 100644 apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index f029ec937..f380ab5d6 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -11,7 +11,6 @@ import { import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice'; -import { ClientActionError } from '../services/clientActionError'; import { loginClientWithPassword, loginClientWithPhoneCode, @@ -21,6 +20,7 @@ import { sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; +import { ClientAuthErrorWrapper } from '../services/clientAuthErrorWrapper'; import { type ClientServerPreset, type ClientServerSelection, @@ -35,7 +35,6 @@ import { captureClientError, installWebviewLogBridge, } from '../services/errorReporting'; -import type { ClientAuthError } from '../services/generated/ClientAuthError'; import type { LoginCodeMissing } from '../services/generated/LoginCodeMissing'; import type { PasswordEntryInputRejected } from '../services/generated/PasswordEntryInputRejected'; import type { PasswordMissing } from '../services/generated/PasswordMissing'; @@ -278,8 +277,8 @@ export function AuthenticatedClient({ if (!isActiveRun()) return; // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); - if (!(error instanceof ClientActionError)) throw error; - const failure = error.cause as ClientAuthError; + if (!(error instanceof ClientAuthErrorWrapper)) throw error; + const failure = error.error; switch (failure.type) { // 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。 case 'serverAddressRejected': { @@ -420,7 +419,7 @@ export function AuthenticatedClient({ /** * 认证失败的分流判据直接写在每个 catch 里: * - * - 承载:命令失败由 `invokeClientAuth` 装进 `ClientActionError`,`cause` 就是判别联合, + * - 承载:命令失败由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`,`error` 就是判别联合, * `failure.type` 是唯一分流键; * - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池; * - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池; @@ -450,8 +449,8 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { - if (!(error instanceof ClientActionError)) throw error; - const failure = error.cause as ClientAuthError; + if (!(error instanceof ClientAuthErrorWrapper)) throw error; + const failure = error.error; switch (failure.type) { case 'serverAddressRejected': { const payload = failure as ServerAddressRejected; @@ -591,8 +590,8 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - if (!(error instanceof ClientActionError)) throw error; - const failure = error.cause as ClientAuthError; + if (!(error instanceof ClientAuthErrorWrapper)) throw error; + const failure = error.error; switch (failure.type) { case 'serverAddressRejected': { const payload = failure as ServerAddressRejected; diff --git a/apps/ai-game-creator-shell/src/services/clientActionError.ts b/apps/ai-game-creator-shell/src/services/clientActionError.ts deleted file mode 100644 index 2e19e38f6..000000000 --- a/apps/ai-game-creator-shell/src/services/clientActionError.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 错误上报上下文:`source` 是错误池的一级维度,`action` / `page` 用于细分指纹。 - * - * 含义与 [`captureClientError`](./errorReporting.ts) 的入参完全一致。 - */ -export type ClientErrorReportContext = { - source: string; - action?: string; - page?: string; -}; - -/** - * 调用方判定「这是真故障 / 认不出」时的统一载体:`message` 是可展示文案,`context` 决定错误池的 - * 指纹维度,`cause` 保留原始拒绝值(Rust 的结构化 `ClientAuthError`、裸字符串或 `Error`)。 - * - * 分流只看类型化的变体,**不要用文案判断**。`captureClientError` 用 `instanceof` 解包 - * `context` 与 `cause`,所以它与显式调用 `captureClientError(error, context)` 的指纹、展示字段一致; - * 需要"带上文继续抛出"的调用方也可以直接 `throw` 它。 - */ -export class ClientActionError extends Error { - readonly context: ClientErrorReportContext; - - constructor( - message: string, - context: ClientErrorReportContext, - cause?: unknown, - ) { - super(message, { cause }); - this.name = 'ClientActionError'; - this.context = context; - } -} diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index c3f0e3c5a..1f93ce84b 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,6 +1,6 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; -import { ClientActionError } from './clientActionError'; +import { ClientAuthErrorWrapper } from './clientAuthErrorWrapper'; import type { ClientAuthError } from './generated/ClientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; @@ -37,11 +37,12 @@ function requireInvoke() { } /** - * 认证命令的统一入口:把 Tauri 的拒绝装进已有的 `ClientActionError`。 + * 认证命令的统一入口:把 Tauri 的拒绝原样装进已有的 `ClientAuthErrorWrapper`。 * * **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 ts-rs 生成的 - * `ClientAuthError` 判别联合,原样放进 `cause`;出现别的形状属于 Tauri / Rust 侧缺陷,调用方 - * `switch` 的 `default` 分支仍会把它抛出去上报。也不读文案判断、不兜底文案。 + * `ClientAuthError` 判别联合;出现别的形状属于 Tauri / Rust 侧缺陷,调用方 `switch` 的 + * `default` 分支仍会把它抛出去上报。包装本身不读变体字段(`message` 之类不保证每个变体都有)、 + * 不注入上下文、不兜底文案。 */ async function invokeClientAuth( command: string, @@ -55,13 +56,8 @@ async function invokeClientAuth( ? await invoke(command) : await invoke(command, args); } catch (error) { - // 拒绝值就是判别联合;`message` 是每个变体都有的展示文案,分流只按 `type`。 - const failure = error as ClientAuthError; - throw new ClientActionError( - failure.message, - { source: 'auth', action: command }, - failure, - ); + // 薄包装:原样把 Rust 的拒绝装成 JS Error;不读字段、不加字段。 + throw new ClientAuthErrorWrapper(error as ClientAuthError); } } @@ -80,7 +76,7 @@ type RustAuthRefreshView = * 恢复登录态。 * * 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败, - * 由 `invokeClientAuth` 装进 `ClientActionError`(`cause` 是判别联合),不再有第三态投影。 + * 由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`error` 是判别联合),不再有第三态投影。 */ export async function readClientAuthState( expectedApiBaseUrl?: string, diff --git a/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts b/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts new file mode 100644 index 000000000..d72e7483e --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts @@ -0,0 +1,30 @@ +import type { ClientAuthError } from './generated/ClientAuthError'; + +/** + * 错误上报上下文:`source` 是错误池的一级维度,`action` / `page` 用于细分指纹。 + * + * 含义与 [`captureClientError`](./errorReporting.ts) 的入参完全一致。 + */ +export type ClientErrorReportContext = { + source: string; + action?: string; + page?: string; +}; + +/** + * 认证命令失败的 JS 侧载体:`error` 就是 Rust 的结构化拒绝(ts-rs 生成的 `ClientAuthError` + * 判别联合)。 + * + * 本类只把原始拒绝值装成 JS `Error`,不读、不产任何派生值:不读变体上的 `message`(变体不 + * 保证都有这个字段),不注入 `source` / `action`,`Error.message` 留空。分流只看类型化的变体, + * **不要用文案判断**;上报上下文由调用 `captureClientError` 时的显式入参决定。 + */ +export class ClientAuthErrorWrapper extends Error { + readonly error: ClientAuthError; + + constructor(error: ClientAuthError) { + super(); + this.name = 'ClientAuthErrorWrapper'; + this.error = error; + } +} diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 080bb2e4a..09c1fb0b1 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -1,9 +1,9 @@ import { invoke } from '@tauri-apps/api/core'; import { - ClientActionError, + ClientAuthErrorWrapper, type ClientErrorReportContext, -} from './clientActionError'; +} from './clientAuthErrorWrapper'; import { ackErrorReports, getPendingErrorReports, @@ -80,24 +80,25 @@ export async function captureClientError( error: unknown, context: Partial = {}, ) { - // 调用方"带上文重抛"时用 ClientActionError 承载上下文与原始拒绝值;显式传参作为兜底。 - const actionError = error instanceof ClientActionError ? error : null; - const errorValue = error instanceof Error ? error : new Error(String(error)); + // ClientAuthErrorWrapper 只把原始拒绝值装成 JS Error:真的 message / stack 在 `error` 字段里, + // 载体自身是空文案。结构化 Rust 拒绝没有 JS Error 语义,落回默认文案。 + const actionError = error instanceof ClientAuthErrorWrapper ? error : null; + // 类型上 `error` 是 ClientAuthError;Tauri 在映射外抛 Error 属于它的缺陷,这里按 unknown 兜底取文案/栈。 + const carried: unknown = actionError?.error; + const errorValue = + carried instanceof Error + ? carried + : error instanceof Error + ? error + : new Error(String(error)); const message = errorValue.message || '未知客户端错误'; - // 原始失败的栈信息比包装点更有诊断价值(包装点只是 catch 的位置)。 - const stackSource = - actionError?.cause instanceof Error && actionError.cause.stack - ? actionError.cause - : errorValue; - const stack = stackSource.stack - ? stackSource.stack.slice(0, 8_000) - : undefined; + const stack = errorValue.stack?.slice(0, 8_000); return reportClientError({ - source: actionError?.context.source ?? context.source ?? 'client', + source: context.source ?? 'client', message, stack, - action: actionError?.context.action ?? context.action, - page: actionError?.context.page ?? context.page, + action: context.action, + page: context.page, }).catch(() => undefined); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 76d997050..fc58a3350 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -1,5 +1,6 @@ import { afterEach } from 'vitest'; +import { ClientAuthErrorWrapper } from '../../src/services/clientAuthErrorWrapper'; import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp'; import { currentPlatformSessionGeneration, @@ -294,7 +295,10 @@ export function registerAuthTests() { // 先等系统变体真的经 unhandledrejection 抛出,再断言它没落到登录页文案。 await waitFor(() => expect(reasons).toHaveLength(1)); - expect(reasons[0]).toMatchObject({ + // 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。 + expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper); + expect((reasons[0] as ClientAuthErrorWrapper).error).toMatchObject({ + type: 'authNetworkUnavailable', message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', }); expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index 435d56e37..c631d955f 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -122,9 +122,8 @@ describe('认证失败的上报判据', () => { await waitFor(() => expect(reportCalls()).toHaveLength(1)); expect(reportCalls()[0]?.[1]).toMatchObject({ - source: 'auth', - action: 'login_client_with_password', - message, + source: 'unhandledrejection', + message: '未知客户端错误', }); expect(screen.queryByText(message)).toBeNull(); } finally { @@ -147,8 +146,7 @@ describe('认证失败的上报判据', () => { await waitFor(() => expect(reportCalls()).toHaveLength(1)); expect(reportCalls()[0]?.[1]).toMatchObject({ - source: 'auth', - action: 'login_client_with_password', + source: 'unhandledrejection', message: 'IPC 桥接不可用', }); } finally { diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index cde523737..51e0d77b4 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -2,14 +2,15 @@ /** * 认证命令失败的 JS 侧载体。 * - * Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 装进已有的 `ClientActionError`: - * `cause` 就是 ts-rs 生成的判别联合,`context.action` 是命令名;分流只按 `cause.type`,不读文案。 - * 另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体,`expectNever(error)` 都编译不过。 + * `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体 + * 字段(`message` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按 + * `error.error.type`。另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体, + * `expectNever(error)` 都编译不过。 */ import { describe, expect, it, vi } from 'vitest'; -import { ClientActionError } from '../src/services/clientActionError'; import { loginClientWithPassword } from '../src/services/clientAuth'; +import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper'; import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed'; import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable'; import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed'; @@ -88,11 +89,9 @@ function installInvoke(handler: () => Promise) { } describe('认证命令失败的 JS 侧载体', () => { - it('结构化拒绝装进 ClientActionError,cause 是判别联合', async () => { - const rejection = { - type: 'phoneNumberInvalid', - message: '请输入正确的手机号', - }; + it('结构化拒绝原样装进 error 字段,不读变体字段', async () => { + // 故意不带 message:包装层绝不能假设每个变体都有这个字段。 + const rejection = { type: 'phoneNumberInvalid', status: 400 }; installInvoke(async () => { throw rejection; }); @@ -103,13 +102,9 @@ describe('认证命令失败的 JS 侧载体', () => { 'https://dev.genarrative.world', ).catch((error: unknown) => error); - expect(failure).toBeInstanceOf(ClientActionError); - expect((failure as ClientActionError).cause).toEqual(rejection); - expect((failure as ClientActionError).context).toEqual({ - source: 'auth', - action: 'login_client_with_password', - }); - expect((failure as Error).message).toBe('请输入正确的手机号'); + expect(failure).toBeInstanceOf(ClientAuthErrorWrapper); + expect((failure as ClientAuthErrorWrapper).error).toBe(rejection); + expect((failure as Error).message).toBe(''); }); it('认证桥未安装时保持原样抛出,不包装成命令失败', async () => { @@ -136,30 +131,9 @@ describe('认证命令失败的 JS 侧载体', () => { 'https://dev.genarrative.world', ).catch((error: unknown) => error); - expect(failure).toBeInstanceOf(ClientActionError); - expect((failure as Error).message).toBe('IPC 桥接异常'); - expect((failure as ClientActionError).cause).toBe(rejection); - expect((failure as ClientActionError).context).toEqual({ - source: 'auth', - action: 'login_client_with_password', - }); - }); - - it('结构化拒绝缺 message 时 Error.message 为空,不造兜底文案', async () => { - installInvoke(async () => { - throw { type: 'unexpectedRejection', status: 409 }; - }); - - const failure = await loginClientWithPassword( - '13800000000', - 'secret', - 'https://dev.genarrative.world', - ).catch((error: unknown) => error); - + expect(failure).toBeInstanceOf(ClientAuthErrorWrapper); expect((failure as Error).message).toBe(''); - expect((failure as ClientActionError).cause).toMatchObject({ - type: 'unexpectedRejection', - }); + expect((failure as ClientAuthErrorWrapper).error).toBe(rejection); }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index cfeadcab1..9fcea5cca 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -158,14 +158,13 @@ test('没有原生宿主时登录能力明确失败关闭', async () => { expect(invoke).not.toHaveBeenCalled(); }); -test('结构化失败不降级成投影值,而是以带 cause 的 ClientActionError 拒绝', async () => { +test('结构化失败不降级成投影值,而是以携带原始拒绝值的 ClientAuthErrorWrapper 拒绝', async () => { const rejection = { type: 'authNetworkUnavailable', message: '无法连接登录服务', }; invoke.mockRejectedValueOnce(rejection); await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ - cause: rejection, - context: { source: 'auth', action: 'refresh_client_auth_session' }, + error: rejection, }); }); diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index 304e2e03e..a19cd3184 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -71,7 +71,7 @@ vi.mock('@tauri-apps/api/core', () => ({ })); import { invoke } from '@tauri-apps/api/core'; -import { ClientActionError } from '../src/services/clientActionError'; +import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper'; import { ackClientErrorEventsWithRetry, captureAgentRuntimeError, @@ -209,23 +209,21 @@ describe('客户端错误报告池', () => { expect(await getPendingClientErrorEvents()).toEqual([event]); }); - it('解包调用方补的上报上下文与原始错误', async () => { - const original = new Error('无法连接登录服务,请确认网络后重试'); + it('解包 ClientAuthErrorWrapper 里的原始拒绝值,上下文只认显式入参', async () => { + const original = { + type: 'authNetworkUnavailable', + message: '无法连接登录服务,请确认网络后重试', + }; - await captureClientError( - new ClientActionError( - '无法连接登录服务,请确认网络后重试', - { source: 'auth', action: 'login' }, - original, - ), - // 显式入参是兜底:ClientActionError 自带的上下文优先。 - { source: 'unhandledrejection' }, - ); + await captureClientError(new ClientAuthErrorWrapper(original), { + source: 'unhandledrejection', + action: 'login', + }); expect(invoke).toHaveBeenCalledWith('report_client_error', { - source: 'auth', - message: '无法连接登录服务,请确认网络后重试', - stack: original.stack, + source: 'unhandledrejection', + message: '未知客户端错误', + stack: expect.any(String), action: 'login', page: undefined, }); -- 2.52.0 From 47392d13b89a8872f5ed3d07274489323067f733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:23:51 +0800 Subject: [PATCH 35/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9AAGC=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E9=94=99=E8=AF=AF=E6=94=B6=E6=95=9B=E4=B8=BA=E6=8B=8D?= =?UTF-8?q?=E5=B9=B3=E5=8F=98=E4=BD=93=20-=20=E6=9B=B4=E6=96=B0=20AGC=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E9=94=99=E8=AF=AF=E7=BB=93=E6=9E=84=E5=8C=96?= =?UTF-8?q?=20ADR=EF=BC=9A=E5=8F=98=E4=BD=93=E6=8B=8D=E5=B9=B3=E4=B8=8D?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=EF=BC=8C=E6=97=A0=E5=AD=97=E6=AE=B5=E5=8F=98?= =?UTF-8?q?=E4=BD=93=E7=94=9F=E6=88=90=20{=20type=20}=EF=BC=8C=E6=9C=89?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=8F=98=E4=BD=93=E7=94=9F=E6=88=90=20{=20ty?= =?UTF-8?q?pe=20}=20&=20=E8=BD=BD=E8=8D=B7=E7=B1=BB=E5=9E=8B=20-=20?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=9C=AC=E5=9C=B0=E5=89=8D=E7=BD=AE=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E6=8B=BF=E4=B8=8D=E5=88=B0=E7=BB=86=E5=88=86=E4=BA=8B?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E4=B8=8D=E7=BC=96=E5=AD=97=E6=AE=B5=EF=BC=8C?= =?UTF-8?q?phoneNumberInvalid=20=E4=BF=9D=E6=8C=81=E6=97=A0=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20-=20=E6=9B=B4=E6=96=B0=20AGC=20=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=20JS=20=E4=BE=A7=E8=BD=BD=E4=BD=93=20ADR?= =?UTF-8?q?=EF=BC=9A=E6=97=A0=E5=AD=97=E6=AE=B5=E5=88=86=E6=94=AF=E7=94=A8?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E6=96=87=E6=A1=88=EF=BC=8C=E5=B8=A6=E8=BD=BD?= =?UTF-8?q?=E8=8D=B7=E5=88=86=E6=94=AF=E5=85=88=20as=20=E5=86=8D=E8=AF=BB?= =?UTF-8?q?=20serverMessage/status/detail=20-=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8A=A5=E5=91=8A=E6=8A=80=E6=9C=AF=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E4=B8=8E=20README=20=E7=B4=A2=E5=BC=95=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=8B=8D=E5=B9=B3=E5=8F=98=E4=BD=93=E4=B8=8E?= =?UTF-8?q?=E5=8E=BB=20reason=20=E5=8F=A3=E5=BE=84=20-=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95=E4=B8=8E=E8=B8=A9?= =?UTF-8?q?=E5=9D=91=EF=BC=9Ats-rs=20=E5=8F=AA=E5=86=99=E4=B8=8D=E5=88=A0?= =?UTF-8?q?=EF=BC=8C=E5=8F=98=E4=BD=93=E6=94=B9=E6=88=90=E6=97=A0=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=97=B6=E8=A6=81=E6=89=8B=E5=8A=A8=E6=B8=85=E5=AD=A4?= =?UTF-8?q?=E7=AB=8B=E8=BD=BD=E8=8D=B7=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 4 +- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 27 +++++++------ ...GC认证失败的JS侧载体与抛出时机-2026-10-01.md | 38 +++++++++++-------- .../shared-memory/decision-log.md | 11 +++--- docs/project-memory/shared-memory/pitfalls.md | 3 +- ...€术方案】AGC错误报告与诊断上传-2026-08-31.md | 2 +- 6 files changed, 50 insertions(+), 35 deletions(-) diff --git a/docs/README.md b/docs/README.md index 4c296810a..a62df7401 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,7 +52,7 @@ - [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 - [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。 - [AGC 命令错误结构化与错误报告口径](./adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md):AGC 命令失败按具体变体建模并用 ts-rs 导出,前端按变体分流、不匹配文案;报告池只收没人处理的错误。 -- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientAuthErrorWrapper`(`cause` 就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,每个分支 `as` 具名载荷,系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。 +- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientAuthErrorWrapper`(`error` 字段就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,无字段变体用固定文案、带载荷分支先 `as` 取自己的具名载荷类型,系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。 - [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 @@ -128,7 +128,7 @@ - [UI 编辑器图片素材选择器](./technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md) - [后台 Dashboard 运营看板方案](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md) - [后台多账号与 Tab 访问权限方案](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md) -- [Pingora 独立网关试点](<./technical/【开发运维】Pingora独立网关试点-2026-06-11.md>) +- [Pingora 独立网关试点](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md) - [AGC 后台模型别名与对话选择](./technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md):官方目录、本地自定义 LLM 开关、端点模型勾选与预览。 - [UI 编辑器工作流完成通知弹窗](./technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md) - [官网 SEO 地基实施约定](./technical/【SEO】官网SEO地基实施约定-2026-07-10.md) diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index d7adfcdc2..718d0e9cd 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -33,18 +33,23 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Rust 侧定义具体变体枚举(auth 首个落地:`ClientAuthError`),`#[derive(Serialize, TS)]` + `#[serde(tag = "type", rename_all = "camelCase")]`,用 ts-rs 导出到 `apps/ai-game-creator-shell/src/services/generated/`;生成物不手改。 -- **每个变体一个具名载荷结构体**,枚举用 newtype 变体持有它(`PhoneNumberInvalid(PhoneNumberInvalid)`)。 - ts-rs 因此生成 `{ type: 'phoneNumberInvalid' } & PhoneNumberInvalid`,以及每个变体一个 - `generated/<变体名>.ts`:前端 `switch (error.type)` 的每个分支都落到一个有名字的类型,等价于 Java 的 - `catch (PhoneNumberInvalid e)`,不需要 `as` 断言,载荷类型自己带 JSDoc。载荷必须能序列化成 map - (serde 的 internally tagged 表示只接受 struct / map),所以没有无字段变体;代价是 Rust 构造点统一写成 - `ClientAuthError::X(X { message })`。这个形状在本仓已有先例(`DirectCodexUserContentPart`)。 +- **变体拍平在同一层,不嵌套子枚举**:可枚举的事实各自成一个变体,只有类型本身说不出来的事实才进载荷 + (服务端原文、HTTP 状态码、本机 IO 明细)。internally tagged 下无字段变体就是 `{ type: 'x' }`;带 + 载荷变体是 `{ type: 'x', ... }`,ts-rs 为它生成 `{ type: 'x' } & X` 与 `generated/X.ts`。例如服务地址 + 校验的 7 种失败各自成一个无字段变体,前端只按 `type` 选提示。 +- 前端 `switch (error.type)` 必须列全变体:无字段变体直接取本 catch 的固定文案;带载荷变体先 `as` + 取自己的具名载荷类型,再读它自己的字段(等价于 Java 的 `catch (X e)`)。`default` 用 `expectNever` + 让漏接变体变成**编译错误**。**不假设所有变体都有同一个字段**,也不做任何文案匹配。 +- Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文 `serverMessage`、HTTP 状态码、 + 本机 IO 的 `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前 + 操作拼接。 - `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。 这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新约定。 - 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用 - `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordEntryInputRejected`), + `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`), 不假装能区分密码长度/手机号格式;**任何地方都不允许对错误文案做判断**。 -- 每个变体带一份可展示 `message`,文案仍只在 Rust 生成一次;前端不拼文案。 +- 本地前置校验只做布尔判定、拿不到更细事实时不编字段:手机号校验 `phone_is_valid` 只回 true/false, + 所以 `phoneNumberInvalid` 保持无字段,提示由调用方给固定文案;编不出事实的"细分原因"不硬造。 ### 2. 报告池只收"没有任何调用方处理"的错误 @@ -63,9 +68,9 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 本节原先的"调用方用 `isClientAuthError` 读形状、`switch (error.type)` 分流、`default` 交池"已被 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](./【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md) - 取代:`invokeClientAuth` 把结构化拒绝装进 `ClientAuthErrorWrapper`(`cause` 是判别联合),判定只写在 - catch 子句里,每个 `case` 用 `as` 取具名载荷类型。 -- "未识别变体上调是**故意**的"不再成立:`default` 改为 `expectNever(error.payload)`,漏接变体是 + 取代:`invokeClientAuth` 把结构化拒绝装进 `ClientAuthErrorWrapper`(`error` 字段是判别联合),判定只写在 + catch 子句里,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as` 取具名载荷类型。 +- "未识别变体上调是**故意**的"不再成立:`default` 改为 `expectNever(failure)`,漏接变体是 **编译错误**,不再是运行时报进池。 - 系统变体与非结构化拒绝仍由调用方原样 `throw`,经全局 `unhandledrejection` 入池; `captureClientError` 的 `instanceof ClientAuthErrorWrapper` 解包保持不变。 diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index 97b2180f0..2cacb44db 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -12,7 +12,7 @@ Rust 已经返回结构化错误,但 Tauri 的 `invoke` 拒绝值是**普通对象**,不是 `Error`: -- 调用方 `catch (error)` 拿到的是 `{ type, message, ... }`,没有栈。原样 `throw` 它, +- 调用方 `catch (error)` 拿到的是 `{ type, reason, ... }`,没有栈。原样 `throw` 它, 上报链路的 `error instanceof Error` 判断会把它降级成 `new Error(String(error))` (`[object Object]`),文案与类型一起丢掉。 - 前一版在渲染层加了 `isClientAuthError` / `getClientAuthErrorMessage` / @@ -29,14 +29,13 @@ ts-rs 已经把 `ClientAuthError` 生成成判别联合(`src/services/generate 原始拒绝值是普通对象,直接 `throw` 会被上报链路降级成 `String(obj)`;所以包装层把它装进 **已有**的 `ClientAuthErrorWrapper`,载体只持有一个 `ClientAuthError` 类型的 `error` 字段,值就是原始拒绝值(也就是那个 -判别联合)。它**不读、不产任何派生值**:不读变体上的 `message`(变体不保证都有这个字段), +判别联合)。它**不读、不产任何派生值**:不读变体字段, 不塞 `context`,`Error.message` 留空。上报的 `source` / `action` 由调用 `captureClientError` 时的显式入参决定;catch 里 `error.error as ClientAuthError` 直接分流。 -- 19 个具名载荷类型是有意保留的:它们是 §3 每个 `case` 里 `as X` 的目标,也正是"不要假设 - 所有变体字段相同"的落点。改成内联 struct 变体确实能让 ts-rs 把字段内联进联合成员、少掉 - 19 个生成文件,但每个分支就再也拿不到可 `as` 的具名类型(只能手写内联对象类型,等于放弃 - ts-rs)。本 ADR 选择保留具名载荷。 +- 只有带载荷的变体才有具名载荷类型:无字段变体在 ts-rs 里就是 `{ type: 'x' }`,不生成文件; + 有字段的变体才生成 `X.ts`。这些具名类型是 §3 每个带载荷 `case` 里 `as X` 的目标,也正是 + "不要假设所有变体字段相同"的落点——没有字段可读的变体不需要、也不允许硬造一个空载荷类型。 ### 2. 一个包装函数:`invokeClientAuth` @@ -59,7 +58,7 @@ async function invokeClientAuth(command, args): Promise { 静默吞掉——只是不再在包装层替 Tauri 兜底。 - `requireInvoke()` 放在 `try` 之外:认证桥未安装是我们自己的失败关闭错误,不是命令拒绝,保持 原样抛出(`需要在 Tauri App 内登录`)。 -- **不读、不产任何派生值**:不读变体上的 `message`(变体不保证都有这个字段),不注入 +- **不读、不产任何派生值**:不读变体字段,不注入 `source` / `action`,`Error.message` 留空;载体只把原始拒绝值原样放进 `error`。展示文案与 上报上下文都由 catch 子句里拿到具名载荷的调用方决定。 - 该包装是"Rust 结构化错误 → JS 错误对象"的唯一转换点:不做分类、不读文案判断、不兜底文案。 @@ -72,12 +71,19 @@ catch (error) { const failure = error.error as ClientAuthError; switch (failure.type) { case 'phoneNumberInvalid': { - const payload = failure as PhoneNumberInvalid; - setLoginStatus(payload.message); + // 无字段变体:文案由本 catch 给,不读任何字段。 + setLoginStatus('手机号无效: 需为纯数字且不超过 32 位'); + break; + } + case 'passwordLoginRejected': { + const payload = failure as PasswordLoginRejected; + // 前缀由本 catch 按当前操作提供;Rust 只给服务端原文(可能为 null)。 + setLoginStatus(`登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`); break; } // ... 每个业务 / 会话变体一个分支 - case 'authNetworkUnavailable': + case 'authNetworkTimeout': + case 'authNetworkUnreachable': // ... 系统变体逐个列出后原样抛出 throw error; default: @@ -86,11 +92,13 @@ catch (error) { } ``` -- **每个业务 / 会话 `case` 用 `as` 取自己的具名载荷类型**,不写 `failure.message` 这种 - 跨变体的通用读取;等价于 Java 的 `catch (PhoneNumberInvalid e)`。系统变体不读载荷(调用方 - 只负责原样抛出),但变体名必须逐个列出,`default` 的 `expectNever` 才成立。 -- 业务 / 会话变体:把载荷自带的 `message` 原样交给用户,**不加兜底文案**(那就是 Rust 生成的 - 那一份)。 +- **每个带载荷的业务 / 会话 `case` 用 `as` 取自己的具名载荷类型**,再读它自己的字段;无字段的 + `case` 直接用本 catch 的固定文案。不写跨变体的通用读取,也不让 Rust 预拼上下文。前缀取自 + 当前 catch 的操作语义(登录、发码、启动检查各自可以不同),等价于 Java 的 `catch (PasswordLoginRejected e)`。 + 系统变体不读载荷(调用方只负责原样抛出),但变体名必须逐个列出,`default` 的 `expectNever` 才成立。 +- 业务 / 会话变体:Rust 只给可判定事实(无字段变体连字段都没有;带载荷变体给 `serverMessage` / + `status` / `detail`),调用方在自己的 catch 里补上本次操作的上下文前缀(例如「服务器地址非法: + 远程地址必须使用 https」、「登录失败: 密码长度需要在 6 到 128 位之间」)。 - 系统变体:调用方处理不了,**原样 `throw`**。`onSubmit` / `onClick` 这类 `void` 掉的 handler 抛出的拒绝最终以 `unhandledrejection` 结算,由全局 handler 交给错误池。 - `default: expectNever(failure)`(`expectNever(value: never)`)让"Rust 加了变体而这里 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index abb1c792b..a0cfb2889 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,12 +4,12 @@ - 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端只按 `type` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。 - 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文交给错误池(`ClientAuthErrorWrapper` 承载 `source/action/page`,`captureClientError` 用 `instanceof` 解包),`window.onerror` / `unhandledrejection` 只兜底没人接手的错误;408/5xx/网络的判定由调用方在 catch 里做(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。 -- 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordEntryInputRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径改造或删除留在后续变更。 -- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientAuthErrorWrapper.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 每个变体一个载荷文件)。 -- 决策(补充):TS 形状用**每个变体一个具名载荷类型**——Rust 枚举是 newtype 变体持有同名 `#[ts(export)]` 结构体,ts-rs 生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`,前端 `switch (error.type)` 的每个分支直接拿到具名类型。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 +- 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径改造或删除留在后续变更。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthErrorWrapper.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 有字段变体的载荷文件)。 +- 决策(补充):TS 形状**只有带载荷的变体才有具名载荷类型**——无字段变体在 ts-rs 里就是 `{ type: 'x' }`,有字段的变体是 newtype 变体持有同名 `#[ts(export)]` 结构体,生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`;前端 `switch (error.type)` 的无字段分支用固定文案,带载荷分支先 `as X` 再读它自己的字段。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 -- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 -- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error as ClientAuthError`,每个 `case` 用 `as` 取具名载荷类型;业务 / 会话变体把载荷自带 `message` 原样给用户(无兜底文案),系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 +- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 +- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `serverMessage` / `status` / `detail` 拼上本次操作的上下文前缀;Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 - 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 @@ -9138,6 +9138,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src-tauri/build_support/{package-layout.json,package-layout.generated.rs,package_layout.rs,godot_bundle.rs}`、`src-tauri/build.rs`、`scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,check-package-layout.mjs,build-release.mjs}`、两份 `.taurignore`、技术方案 §4.9/§8、M3 里程碑、运维文档、决策日志与排障经验。 - 验证:准备步骤 13 条用例通过(含三类准备步骤调度、指纹跳过、缺产物失败关闭、幂等与失败关闭);`npm run agc:bundled-resources:check` 通过;`cargo check --no-default-features` 通过(构建脚本仅剩只读校验,且不再出现在随包资源的写入路径上)。 - 边界(未验证):Windows 真机未验证——powershell/cargo 两条命令路径、Unity/Godot/Cocos 产物归位、包内容一致性与客户端加载,需按 M3 里程碑的验收清单在 Windows 上确认。 + ## 2026-09-24 命令入队化与待发消息队列归宿主:放行归 Thread Manager,CLI 直连入口退役 - 决策(词表):「接单 / 拒单」退役,命令边界的成功与失败改叫「入队 / 入队失败」;旧「接单」的语义角色 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0189a4f72..8e6521e58 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -8,7 +8,7 @@ - **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 - **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientAuthErrorWrapper`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次(`source` 取全局 `unhandledrejection` handler 的显式入参;载体不再携带 `action`)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 -- **形状约定**:`ClientAuthError` 的每个变体在 TS 里是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error as ClientAuthError; switch (failure.type)`,每个 `case` 用 `as` 取具名载荷类型,`default: expectNever(failure)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier。 +- **形状约定**:`ClientAuthError` 的变体拍平不嵌套——无字段变体在 TS 里就是 `{ type: 'x' }`;带载荷变体是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error; switch (failure.type)`,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as X` 再读自己的 `serverMessage` / `status` / `detail`,`default: expectNever(failure)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier;ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`。 - **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 @@ -147,6 +147,7 @@ - **现象**:直接编辑 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs`,或另写一份组件白名单,`npm run agc:typecheck`(链内含 `npm run agc:bundled-resources:check`)会立刻失败并报「随包资源声明与 Rust 常量不一致」。 - **正确做法**:改 `build_support/package-layout.json`,运行 `npm run agc:bundled-resources:sync` 重新生成;改布局同时递增 `layoutVersion`(参与准备步骤的缓存 key)。声明里的 `codex.version` 必须与应用锁定的 `@openai/codex` 一致,门禁会对照 `apps/ai-game-creator-shell/package.json` 校验。 - **边界(M1 完成时)**:准备步骤 `scripts/prepare-bundled-resources.mjs` 尚未接入 dev / 发布入口,`npm run agc` 仍由构建脚本 staging;构建脚本当前既写资源又做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验。构建脚本重建 `resources/plugins` 时会整体删除该目录,所以插件侧的准备步骤清单要等 M2 接管写入后才成立,插件目录现在只校验必需组件与符号链接。 + ## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析 - **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index fbb21b0f0..c3995cf25 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -11,7 +11,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientAuthErrorWrapper`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 -- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),不新增手写错误类;判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。 +- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;变体拍平不嵌套,无字段变体生成 `{ type }`,带载荷变体生成 `{ type } & 载荷类型`),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`、`Error.message` 留空),不新增手写错误类;判定只写在 catch 子句里,无字段变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名载荷类型、再用它自己的 `serverMessage` / `status` / `detail` 拼上本次操作的上下文前缀,`default` 用 `expectNever` 在编译期挡住漏接变体。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 - 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 -- 2.52.0 From d549b3cb16f4f46004265c9075ec518106130ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:24:39 +0800 Subject: [PATCH 36/70] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8B=8D=E5=B9=B3=E5=8F=98=E4=BD=93=E5=B9=B6?= =?UTF-8?q?=E5=8F=AA=E4=BC=A0=E5=8E=9F=E5=A7=8B=E4=BA=8B=E5=AE=9E=20-=20au?= =?UTF-8?q?th=5Ferror.rs=EF=BC=9A=E5=8F=98=E4=BD=93=E6=8B=8D=E5=B9=B3?= =?UTF-8?q?=E5=88=B0=E5=90=8C=E4=B8=80=E5=B1=82=EF=BC=8C=E6=97=A0=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=8F=98=E4=BD=93=E5=BA=8F=E5=88=97=E5=8C=96=E4=B8=BA?= =?UTF-8?q?=20{=20type=20}=EF=BC=8C=E8=BD=BD=E8=8D=B7=E5=8F=98=E4=BD=93?= =?UTF-8?q?=E5=8F=AA=E8=A3=85=20serverMessage/status/detail=20-=20?= =?UTF-8?q?=E8=BD=BD=E8=8D=B7=E7=BB=93=E6=9E=84=E6=94=B9=E5=90=8D=E5=B9=B6?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E9=A2=84=E6=8B=BC=E6=96=87=E6=A1=88=EF=BC=9A?= =?UTF-8?q?PasswordLoginRejected/SendCodeRejected/PhoneCodeLoginRejected?= =?UTF-8?q?=20=E6=8C=81=20Option=20serverMessage=20-=20auth=5Fsession.rs?= =?UTF-8?q?=EF=BC=9A=E6=9C=8D=E5=8A=A1=E5=9C=B0=E5=9D=80=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=207=20=E7=A7=8D=E5=A4=B1=E8=B4=A5=E5=90=84=E6=88=90=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=8F=98=E4=BD=93=EF=BC=8C=E7=BD=91=E7=BB=9C=E6=8B=86?= =?UTF-8?q?=E6=88=90=E8=B6=85=E6=97=B6/=E4=B8=8D=E5=8F=AF=E8=BE=BE?= =?UTF-8?q?=EF=BC=8C=E5=93=8D=E5=BA=94=E5=A5=91=E7=BA=A6=E6=8B=86=E6=88=90?= =?UTF-8?q?=205=20=E7=A7=8D=E5=8F=98=E4=BD=93=20-=20map=5Fauth=5Ffailure/r?= =?UTF-8?q?esponse=5Fdata/request=5Fauth=20=E5=8E=BB=E6=8E=89=20fallback?= =?UTF-8?q?=20=E6=96=87=E6=A1=88=E5=8F=82=E6=95=B0=EF=BC=8C=E5=8F=AA?= =?UTF-8?q?=E6=8C=89=E5=8F=AF=E5=88=A4=E5=AE=9A=E4=BA=8B=E5=AE=9E=E6=98=A0?= =?UTF-8?q?=E5=B0=84=20-=20=E5=AE=9A=E5=90=91=E7=94=A8=E4=BE=8B=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E6=96=B0=E5=8F=98=E4=BD=93=E5=90=8D=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=AF=B9=E5=8E=9F=E5=A7=8B=E8=BD=BD=E8=8D=B7=E5=81=9A=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/auth_error.rs | 486 +++++++----------- .../src-tauri/src/auth_session.rs | 443 +++++++--------- 2 files changed, 364 insertions(+), 565 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index 1d8a19ecc..bc602dfa8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -2,261 +2,170 @@ //! //! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。 //! -//! 每个变体持有自己的**具名载荷类型**([`ServerAddressRejected`] … [`AuthClientInitFailed`]),不是内联 -//! 匿名结构体。ts-rs 因此生成 `{ type: 'phoneNumberInvalid' } & PhoneNumberInvalid`:前端 -//! `switch (error.type)` 的每个分支都拿到一个有名字的类型,等价于 Java 的 -//! `catch (PhoneNumberInvalid e)`,不需要 `as` 断言。载荷类型必须能序列化成 map——serde 的 -//! internally tagged 表示只接受 struct / map——所以全部是命名结构体,没有无字段变体。 +//! 变体一律拍平在同一层,不嵌套子枚举:可枚举的事实各自成变体(例如服务地址校验的 7 种失败、 +//! 网络超时 / 不可达、响应契约的 5 种破损),类型本身说不出来的事实才进载荷(服务端原文、HTTP +//! 状态码、本机 IO 明细)。internally tagged 下无字段变体序列化成 `{ "type": "x" }`,带载荷的 +//! 是 `{ "type": "x", ... }`;ts-rs 生成 `{ type: 'x' }` 或 `{ type: 'x' } & X`,前端每个分支 +//! 拿到的形状与 Rust 一致。 +//! +//! Rust **不预拼用户可见文案**:载荷只存原始事实(服务端 400 的原文、本机 IO 的 `detail`), +//! 前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。 //! //! 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(平台 `AppError.code` 仍是 //! 通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(例如 -//! [`PasswordEntryInputRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 `401/403` 是 -//! "登录态权威失效",登录路由的 `401` 是用户可修正的输入问题,这个区分现在由变体承担, -//! 不再靠 `authentication-required:` 这类文本前缀。 -//! -//! 每个变体都带一份可展示 `message`,文案只在 Rust 生成一次(服务端原文优先,缺失时才用调用点的 -//! 兜底文案);前端对认得的业务变体原样展示,对系统变体 / 未识别变体带上下文重抛,走上报链路。 - -use std::fmt; +//! [`PasswordLoginRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 `401/403` 是 +//! "登录态失效",登录路由的 `401` 是用户可修正的输入问题,这个区分由变体承担。 use serde::Serialize; use ts_rs::TS; -/// 变体名就是线上的分流键(`type`),每个变体持有同名载荷类型。 +/// 变体名就是线上的分流键(`type`),带载荷的变体持有同名载荷类型。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) enum ClientAuthError { // ---- 业务:用户自己能改,调用方给提示,不上报 ---- - ServerAddressRejected(ServerAddressRejected), - PhoneNumberInvalid(PhoneNumberInvalid), - PasswordMissing(PasswordMissing), - LoginCodeMissing(LoginCodeMissing), - PasswordEntryInputRejected(PasswordEntryInputRejected), - PhoneOrPasswordMismatch(PhoneOrPasswordMismatch), - SendCodeInputRejected(SendCodeInputRejected), - SmsCodeThrottled(SmsCodeThrottled), - PhoneLoginInputRejected(PhoneLoginInputRejected), - SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired), + /// 服务地址为空或超长。 + ServerAddressEmptyOrTooLong, + /// 服务地址不是合法 URL。 + ServerAddressNotAUrl, + /// 服务地址带用户名 / 密码。 + ServerAddressHasCredentials, + /// 服务地址带路径、查询或 fragment。 + ServerAddressHasPathOrQueryOrFragment, + /// 非本机服务地址不是 HTTPS。 + ServerAddressNotHttps, + /// 服务地址 scheme 不是 http(s)。 + ServerAddressUnsupportedScheme, + /// 发布构建里服务地址不在当前构建渠道范围内。 + ServerAddressOutsideChannel, + /// 本地前置校验:手机号为空、超长或不是纯数字。 + PhoneNumberInvalid, + /// 本地前置校验:密码为空。 + PasswordMissing, + /// 本地前置校验:验证码为空。 + LoginCodeMissing, + /// `/api/auth/entry` 返回 400:服务端拒绝本次输入。 + PasswordLoginRejected(PasswordLoginRejected), + /// `/api/auth/entry` 返回 401:手机号或密码错误。 + PhoneOrPasswordMismatch, + /// `/api/auth/phone/send-code` 返回 400:服务端拒绝本次输入。 + SendCodeRejected(SendCodeRejected), + /// `/api/auth/phone/send-code` 返回 429:发送过于频繁。 + SmsCodeThrottled, + /// `/api/auth/phone/login` 返回 400:服务端拒绝本次输入。 + PhoneCodeLoginRejected(PhoneCodeLoginRejected), + /// `/api/auth/phone/login` 返回 401:验证码错误或过期。 + SmsCodeRejected, + // ---- 会话:调用方按"未登录"处理,不给用户报错 ---- - SessionAuthorityRejected(SessionAuthorityRejected), - PermissionDenied(PermissionDenied), + /// 会话路由 401:登录态失效。 + SessionInvalidated, + /// 会话路由 403:当前账号没有执行此操作的权限。 + PermissionDenied, + // ---- 系统:调用方处理不了,带上下文重抛 ---- - AuthNetworkUnavailable(AuthNetworkUnavailable), + /// 连接登录服务超时。 + AuthNetworkTimeout, + /// 连接登录服务失败(DNS / 连接被拒等)。 + AuthNetworkUnreachable, + /// 登录服务 5xx。 AuthServiceUnavailable(AuthServiceUnavailable), + /// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 UnexpectedRejection(UnexpectedRejection), - AuthResponseMalformed(AuthResponseMalformed), + /// 登录服务响应不是合法 JSON。 + AuthResponseNotJson, + /// 登录服务响应不是预期结构(缺字段 / 类型不符)。 + AuthResponseInvalidBody, + /// 登录 / 续期响应没有下发新的续期凭据。 + AuthResponseMissingRefreshCookie, + /// 登录响应没有带上会话主体(用户身份)。 + AuthResponseMissingUserIdentity, + /// 登录服务在响应体里显式拒绝(`ok: false`)。 + AuthResponseServerRejected(AuthResponseServerRejected), + /// 本机登录凭据文件读写失败。 ClientSessionPersistFailed(ClientSessionPersistFailed), + /// 本机运行时会话安装 / 清理失败。 RuntimeSessionInstallFailed(RuntimeSessionInstallFailed), - AuthClientInitFailed(AuthClientInitFailed), + /// 认证网络客户端构建失败。 + AuthClientInitFailed, } -// ---- 业务:用户自己能改,调用方给提示,不上报 ---- +// ---- 载荷:只装类型说不出来的事实 ---- -/// 服务地址不是合法 origin / 非本机未用 HTTPS / 不在构建渠道范围内。 +/// `/api/auth/entry` 返回 400 时服务端给的原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct ServerAddressRejected { - pub(crate) message: String, +pub(crate) struct PasswordLoginRejected { + /// 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + pub(crate) server_message: Option, } -/// 本地前置校验:手机号为空或格式不合法。 +/// `/api/auth/phone/send-code` 返回 400 时服务端给的原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct PhoneNumberInvalid { - pub(crate) message: String, +pub(crate) struct SendCodeRejected { + /// 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + pub(crate) server_message: Option, } -/// 本地前置校验:密码为空。 +/// `/api/auth/phone/login` 返回 400 时服务端给的原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct PasswordMissing { - pub(crate) message: String, +pub(crate) struct PhoneCodeLoginRejected { + /// 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + pub(crate) server_message: Option, } -/// 本地前置校验:验证码为空。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct LoginCodeMissing { - pub(crate) message: String, -} - -/// `/api/auth/entry` 返回 400:服务端拒绝本次输入。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct PasswordEntryInputRejected { - pub(crate) message: String, -} - -/// `/api/auth/entry` 返回 401:手机号或密码错误。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct PhoneOrPasswordMismatch { - pub(crate) message: String, -} - -/// `/api/auth/phone/send-code` 返回 400。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct SendCodeInputRejected { - pub(crate) message: String, -} - -/// `/api/auth/phone/send-code` 返回 429:发送过于频繁。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct SmsCodeThrottled { - pub(crate) message: String, -} - -/// `/api/auth/phone/login` 返回 400。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct PhoneLoginInputRejected { - pub(crate) message: String, -} - -/// `/api/auth/phone/login` 返回 401:验证码错误或过期。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct SmsCodeInvalidOrExpired { - pub(crate) message: String, -} - -// ---- 会话:调用方按"未登录"处理,不给用户报错 ---- - -/// 会话路由 401:登录态权威失效。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct SessionAuthorityRejected { - pub(crate) message: String, -} - -/// 会话路由 403:当前账号没有执行此操作的权限。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct PermissionDenied { - pub(crate) message: String, -} - -// ---- 系统:调用方处理不了,带上下文重抛 ---- - -/// 连接 / 超时 / DNS 等网络失败。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct AuthNetworkUnavailable { - pub(crate) message: String, -} - -/// 服务端 5xx。 +/// 登录服务 5xx 的状态码。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct AuthServiceUnavailable { pub(crate) status: u16, - pub(crate) message: String, } -/// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 +/// 其它未识别拒绝的状态码与服务端原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct UnexpectedRejection { pub(crate) status: u16, - pub(crate) message: String, + pub(crate) server_message: Option, } -/// 响应不是合法 JSON、缺少必需字段或契约不成立。 +/// 登录服务在响应体里显式拒绝时给的原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct AuthResponseMalformed { - pub(crate) message: String, +pub(crate) struct AuthResponseServerRejected { + pub(crate) server_message: Option, } -/// 本机登录凭据文件读写失败。 +/// 本机凭据文件读写的原始错误明细。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct ClientSessionPersistFailed { - pub(crate) message: String, + pub(crate) detail: String, } -/// 本机运行时会话安装 / 清理失败。 +/// 本机运行时会话安装 / 清理的原始错误明细。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct RuntimeSessionInstallFailed { - pub(crate) message: String, -} - -/// 认证网络客户端构建失败。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct AuthClientInitFailed { - pub(crate) message: String, + pub(crate) detail: String, } impl ClientAuthError { - /// 可展示文案:服务端原文优先,缺失时是调用点兜底。 - pub(crate) fn message(&self) -> &str { - match self { - Self::ServerAddressRejected(payload) => &payload.message, - Self::PhoneNumberInvalid(payload) => &payload.message, - Self::PasswordMissing(payload) => &payload.message, - Self::LoginCodeMissing(payload) => &payload.message, - Self::PasswordEntryInputRejected(payload) => &payload.message, - Self::PhoneOrPasswordMismatch(payload) => &payload.message, - Self::SendCodeInputRejected(payload) => &payload.message, - Self::SmsCodeThrottled(payload) => &payload.message, - Self::PhoneLoginInputRejected(payload) => &payload.message, - Self::SmsCodeInvalidOrExpired(payload) => &payload.message, - Self::SessionAuthorityRejected(payload) => &payload.message, - Self::PermissionDenied(payload) => &payload.message, - Self::AuthNetworkUnavailable(payload) => &payload.message, - Self::AuthServiceUnavailable(payload) => &payload.message, - Self::UnexpectedRejection(payload) => &payload.message, - Self::AuthResponseMalformed(payload) => &payload.message, - Self::ClientSessionPersistFailed(payload) => &payload.message, - Self::RuntimeSessionInstallFailed(payload) => &payload.message, - Self::AuthClientInitFailed(payload) => &payload.message, - } - } - - /// 会话路由的 401/403 是「登录态权威失效」:调用方据此清会话、按未登录处理, + /// 会话路由的 401/403 是「登录态失效」:调用方据此清会话、按未登录处理, /// 既不给用户报错,也不进错误报告池。 pub(crate) fn is_authority_failure(&self) -> bool { - matches!( - self, - Self::SessionAuthorityRejected(_) | Self::PermissionDenied(_) - ) - } -} - -impl fmt::Display for ClientAuthError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.message()) - } -} - -/// 只需要一句文案的边界用它,与 `DirectTurnError` 的收口方式一致。 -impl From for String { - fn from(error: ClientAuthError) -> Self { - error.message().to_string() + matches!(self, Self::SessionInvalidated | Self::PermissionDenied) } } @@ -264,116 +173,115 @@ impl From for String { mod tests { use super::*; - /// 单字段载荷的构造简写:这些变体的载荷只有一个 `message`。 - fn text(message: &str) -> String { - message.to_string() - } - #[test] fn wire_variant_names_are_the_frontend_dispatch_keys() { let cases = [ ( - ClientAuthError::ServerAddressRejected(ServerAddressRejected { - message: text("x"), + ClientAuthError::ServerAddressEmptyOrTooLong, + "serverAddressEmptyOrTooLong", + ), + ( + ClientAuthError::ServerAddressNotAUrl, + "serverAddressNotAUrl", + ), + ( + ClientAuthError::ServerAddressHasCredentials, + "serverAddressHasCredentials", + ), + ( + ClientAuthError::ServerAddressHasPathOrQueryOrFragment, + "serverAddressHasPathOrQueryOrFragment", + ), + ( + ClientAuthError::ServerAddressNotHttps, + "serverAddressNotHttps", + ), + ( + ClientAuthError::ServerAddressUnsupportedScheme, + "serverAddressUnsupportedScheme", + ), + ( + ClientAuthError::ServerAddressOutsideChannel, + "serverAddressOutsideChannel", + ), + (ClientAuthError::PhoneNumberInvalid, "phoneNumberInvalid"), + (ClientAuthError::PasswordMissing, "passwordMissing"), + (ClientAuthError::LoginCodeMissing, "loginCodeMissing"), + ( + ClientAuthError::PasswordLoginRejected(PasswordLoginRejected { + server_message: Some("x".to_string()), }), - "serverAddressRejected", + "passwordLoginRejected", ), ( - ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { message: text("x") }), - "phoneNumberInvalid", - ), - ( - ClientAuthError::PasswordMissing(PasswordMissing { message: text("x") }), - "passwordMissing", - ), - ( - ClientAuthError::LoginCodeMissing(LoginCodeMissing { message: text("x") }), - "loginCodeMissing", - ), - ( - ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { - message: text("x"), - }), - "passwordEntryInputRejected", - ), - ( - ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { - message: text("x"), - }), + ClientAuthError::PhoneOrPasswordMismatch, "phoneOrPasswordMismatch", ), ( - ClientAuthError::SendCodeInputRejected(SendCodeInputRejected { - message: text("x"), + ClientAuthError::SendCodeRejected(SendCodeRejected { + server_message: Some("x".to_string()), }), - "sendCodeInputRejected", + "sendCodeRejected", ), + (ClientAuthError::SmsCodeThrottled, "smsCodeThrottled"), ( - ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { message: text("x") }), - "smsCodeThrottled", - ), - ( - ClientAuthError::PhoneLoginInputRejected(PhoneLoginInputRejected { - message: text("x"), + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { + server_message: Some("x".to_string()), }), - "phoneLoginInputRejected", + "phoneCodeLoginRejected", + ), + (ClientAuthError::SmsCodeRejected, "smsCodeRejected"), + (ClientAuthError::SessionInvalidated, "sessionInvalidated"), + (ClientAuthError::PermissionDenied, "permissionDenied"), + (ClientAuthError::AuthNetworkTimeout, "authNetworkTimeout"), + ( + ClientAuthError::AuthNetworkUnreachable, + "authNetworkUnreachable", ), ( - ClientAuthError::SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired { - message: text("x"), - }), - "smsCodeInvalidOrExpired", - ), - ( - ClientAuthError::SessionAuthorityRejected(SessionAuthorityRejected { - message: text("x"), - }), - "sessionAuthorityRejected", - ), - ( - ClientAuthError::PermissionDenied(PermissionDenied { message: text("x") }), - "permissionDenied", - ), - ( - ClientAuthError::AuthNetworkUnavailable(AuthNetworkUnavailable { - message: text("x"), - }), - "authNetworkUnavailable", - ), - ( - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { - status: 503, - message: text("x"), - }), + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }), "authServiceUnavailable", ), ( ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: 409, - message: text("x"), + server_message: None, }), "unexpectedRejection", ), + (ClientAuthError::AuthResponseNotJson, "authResponseNotJson"), ( - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: text("x"), + ClientAuthError::AuthResponseInvalidBody, + "authResponseInvalidBody", + ), + ( + ClientAuthError::AuthResponseMissingRefreshCookie, + "authResponseMissingRefreshCookie", + ), + ( + ClientAuthError::AuthResponseMissingUserIdentity, + "authResponseMissingUserIdentity", + ), + ( + ClientAuthError::AuthResponseServerRejected(AuthResponseServerRejected { + server_message: None, }), - "authResponseMalformed", + "authResponseServerRejected", ), ( ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { - message: text("x"), + detail: "x".to_string(), }), "clientSessionPersistFailed", ), ( ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { - message: text("x"), + detail: "x".to_string(), }), "runtimeSessionInstallFailed", ), ( - ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { message: text("x") }), + ClientAuthError::AuthClientInitFailed, "authClientInitFailed", ), ]; @@ -383,20 +291,20 @@ mod tests { value.get("type").and_then(|value| value.as_str()), Some(expected_type) ); - assert_eq!( - value.get("message").and_then(|value| value.as_str()), - Some(error.message()) - ); } } #[test] - fn machine_context_fields_survive_serialization() { + fn unit_variants_serialize_without_payload_fields() { + let value = + serde_json::to_value(ClientAuthError::LoginCodeMissing).expect("serialize auth error"); + assert_eq!(value, serde_json::json!({ "type": "loginCodeMissing" })); + } + + #[test] + fn payload_variants_keep_machine_facts_and_server_text() { let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable( - AuthServiceUnavailable { - status: 503, - message: "账号服务暂不可用".to_string(), - }, + AuthServiceUnavailable { status: 503 }, )) .expect("serialize auth error"); assert_eq!(unavailable["type"], "authServiceUnavailable"); @@ -405,42 +313,26 @@ mod tests { let rejection = serde_json::to_value(ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: 409, - message: "冲突".to_string(), + server_message: Some("冲突".to_string()), })) .expect("serialize auth error"); assert_eq!(rejection["status"], 409); + assert_eq!(rejection["serverMessage"], "冲突"); + + let persist = serde_json::to_value(ClientAuthError::ClientSessionPersistFailed( + ClientSessionPersistFailed { + detail: "磁盘只读".to_string(), + }, + )) + .expect("serialize auth error"); + assert_eq!(persist["detail"], "磁盘只读"); } #[test] - fn only_session_authority_failures_count_as_authority_failures() { - assert!( - ClientAuthError::SessionAuthorityRejected(SessionAuthorityRejected { - message: text("x"), - }) - .is_authority_failure() - ); - assert!( - ClientAuthError::PermissionDenied(PermissionDenied { message: text("x") }) - .is_authority_failure() - ); - assert!( - !ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { - message: text("x"), - }) - .is_authority_failure() - ); - assert!( - !ClientAuthError::AuthNetworkUnavailable(AuthNetworkUnavailable { message: text("x") }) - .is_authority_failure() - ); - } - - #[test] - fn display_and_string_conversion_use_the_display_message() { - let error = ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { - message: "手机号或密码错误".to_string(), - }); - assert_eq!(error.to_string(), "手机号或密码错误"); - assert_eq!(String::from(error), "手机号或密码错误"); + fn only_session_failures_count_as_authority_failures() { + assert!(ClientAuthError::SessionInvalidated.is_authority_failure()); + assert!(ClientAuthError::PermissionDenied.is_authority_failure()); + assert!(!ClientAuthError::PhoneOrPasswordMismatch.is_authority_failure()); + assert!(!ClientAuthError::AuthNetworkTimeout.is_authority_failure()); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 599356ad9..5625e958d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -5,12 +5,9 @@ //! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 use crate::auth_error::{ - AuthClientInitFailed, AuthNetworkUnavailable, AuthResponseMalformed, AuthServiceUnavailable, - ClientAuthError, ClientSessionPersistFailed, LoginCodeMissing, PasswordEntryInputRejected, - PasswordMissing, PermissionDenied, PhoneLoginInputRejected, PhoneNumberInvalid, - PhoneOrPasswordMismatch, RuntimeSessionInstallFailed, SendCodeInputRejected, - ServerAddressRejected, SessionAuthorityRejected, SmsCodeInvalidOrExpired, SmsCodeThrottled, - UnexpectedRejection, + AuthResponseServerRejected, AuthServiceUnavailable, ClientAuthError, + ClientSessionPersistFailed, PasswordLoginRejected, PhoneCodeLoginRejected, + RuntimeSessionInstallFailed, SendCodeRejected, UnexpectedRejection, }; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; @@ -40,10 +37,6 @@ const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; const AGC_CLIENT_MARKER_VALUE: &str = "agc"; -const AUTH_NETWORK_ERROR: &str = "无法连接登录服务,请确认配套后端或 API 代理已启动后重试"; -const AUTH_NETWORK_TIMEOUT: &str = "登录服务响应超时,请检查服务器地址和网络后重试"; -/// 会话路由 401 且服务端没给原因时的兜底文案。 -const AUTH_AUTHORITY_MESSAGE: &str = "登录状态已失效,请重新登录"; /// 认证态投影:只含状态、用户展示字段与 origin,不含 token 或 refresh 凭据。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -229,7 +222,7 @@ fn session_file_path(app: &tauri::AppHandle) -> Result .map(|root| root.join(SESSION_FILE_NAME)) .map_err(|error| { ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { - message: format!("无法读取 AGC 应用数据目录:{error}"), + detail: format!("无法读取 AGC 应用数据目录:{error}"), }) }) } @@ -237,21 +230,14 @@ fn session_file_path(app: &tauri::AppHandle) -> Result /// 凭据文件相关的字符串错误统一收口成凭据落盘失败变体。 fn session_persist_error(message: impl Into) -> ClientAuthError { ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { - message: message.into(), + detail: message.into(), }) } /// 本机运行时安装 / 清理失败的字符串错误统一收口。 fn runtime_session_error(message: impl Into) -> ClientAuthError { ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { - message: message.into(), - }) -} - -/// 服务地址校验(含渠道范围门禁)失败统一收口成业务变体。 -fn server_address_rejected(message: impl Into) -> ClientAuthError { - ClientAuthError::ServerAddressRejected(ServerAddressRejected { - message: message.into(), + detail: message.into(), }) } @@ -259,31 +245,33 @@ fn server_address_rejected(message: impl Into) -> ClientAuthError { /// /// 只接受纯 origin:不允许凭据、路径、查询或 fragment;非本机必须 HTTPS。发布构建额外 /// 限制在已知渠道 origin 内,避免调试期写入的自定义地址在正式包里被继续使用。 -pub(crate) fn validate_client_api_base_url(value: &str) -> Result { +/// +/// 每种拒绝各成一个变体:调用方只按变体选提示,不需要读任何文案。 +pub(crate) fn validate_client_api_base_url(value: &str) -> Result { let trimmed = value.trim().trim_end_matches('/'); if trimmed.is_empty() || trimmed.chars().count() > MAX_ORIGIN_CHARS { - return Err("陶泥儿服务地址无效".to_string()); + return Err(ClientAuthError::ServerAddressEmptyOrTooLong); } - let parsed = Url::parse(trimmed).map_err(|_| "陶泥儿服务地址无效".to_string())?; + let parsed = Url::parse(trimmed).map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; if !parsed.username().is_empty() || parsed.password().is_some() { - return Err("陶泥儿服务地址不能包含凭据".to_string()); + return Err(ClientAuthError::ServerAddressHasCredentials); } if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some() { - return Err("陶泥儿服务地址必须是纯地址,不能带路径或参数".to_string()); + return Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment); } let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase(); match parsed.scheme() { "https" => {} "http" if matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") => {} - "http" => return Err("非本机服务器必须使用 HTTPS".to_string()), - _ => return Err("陶泥儿服务地址必须是 HTTP(S) 地址".to_string()), + "http" => return Err(ClientAuthError::ServerAddressNotHttps), + _ => return Err(ClientAuthError::ServerAddressUnsupportedScheme), } if !cfg!(debug_assertions) && !matches!(trimmed, RELEASE_ORIGIN | DEVELOPMENT_ORIGIN) && !matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") { - return Err("服务器地址不在当前构建渠道范围内".to_string()); + return Err(ClientAuthError::ServerAddressOutsideChannel); } Ok(trimmed.to_string()) } @@ -414,11 +402,11 @@ fn current_session_origin() -> Option { fn endpoint(origin: &str, route: &str) -> Result { let mut url = Url::parse(&format!("{}/", origin.trim_end_matches('/'))) - .map_err(|_| server_address_rejected("陶泥儿服务地址无效"))?; + .map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; { let mut segments = url .path_segments_mut() - .map_err(|_| server_address_rejected("陶泥儿服务地址无效"))?; + .map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; for segment in route.trim_start_matches('/').split('/') { if segment.is_empty() { continue; @@ -434,21 +422,15 @@ fn build_client() -> Result { .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| { - ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { - message: "创建登录网络客户端失败".to_string(), - }) - }) + .map_err(|_| ClientAuthError::AuthClientInitFailed) } -fn network_error_message(error: &reqwest::Error) -> ClientAuthError { - ClientAuthError::AuthNetworkUnavailable(AuthNetworkUnavailable { - message: if error.is_timeout() { - AUTH_NETWORK_TIMEOUT.to_string() - } else { - AUTH_NETWORK_ERROR.to_string() - }, - }) +fn network_error(error: &reqwest::Error) -> ClientAuthError { + if error.is_timeout() { + ClientAuthError::AuthNetworkTimeout + } else { + ClientAuthError::AuthNetworkUnreachable + } } fn error_message(body: &str) -> Option { @@ -495,111 +477,71 @@ fn auth_route(route: &str) -> AuthRoute { /// 把一次认证 HTTP 响应归类成具体变体。 /// -/// 服务端原因(「手机号或密码错误」)原样保留,不能被改写成登录失效;会话路由的 `401/403` -/// 归到权威失效变体,调用方据此清会话。网络、5xx 与契约异常必须保留会话。 -fn map_auth_failure( - status: StatusCode, - body: &str, - fallback: &str, - route: AuthRoute, -) -> ClientAuthError { +/// 会话路由的 `401/403` 归到权威失效变体,调用方据此清会话;登录路由的 `401` 是用户可修正的 +/// 输入问题。可判定的用户输入原因只进业务变体,认不出的才落到 `UnexpectedRejection`。 +/// 网络、5xx 与契约异常必须保留会话。 +fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientAuthError { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); let status_code = status.as_u16(); let server_message = error_message(body); - let prefixed = || { - format!( - "{fallback}:{}", - server_message - .clone() - .unwrap_or_else(|| format!("HTTP {status_code}")) - ) - }; if status == StatusCode::UNAUTHORIZED { return match route { - AuthRoute::Session => { - ClientAuthError::SessionAuthorityRejected(SessionAuthorityRejected { - message: server_message.unwrap_or_else(|| AUTH_AUTHORITY_MESSAGE.to_string()), - }) - } - AuthRoute::PasswordEntry => { - ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { - message: server_message.unwrap_or_else(|| fallback.to_string()), - }) - } - AuthRoute::PhoneLogin => { - ClientAuthError::SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired { - message: server_message.unwrap_or_else(|| fallback.to_string()), - }) - } + AuthRoute::Session => ClientAuthError::SessionInvalidated, + AuthRoute::PasswordEntry => ClientAuthError::PhoneOrPasswordMismatch, + AuthRoute::PhoneLogin => ClientAuthError::SmsCodeRejected, _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, - message: server_message.unwrap_or_else(|| fallback.to_string()), + server_message, }), }; } if status == StatusCode::FORBIDDEN { return match route { - AuthRoute::Session => ClientAuthError::PermissionDenied(PermissionDenied { - message: server_message - .unwrap_or_else(|| "当前陶泥儿账号没有执行此操作的权限".to_string()), - }), + AuthRoute::Session => ClientAuthError::PermissionDenied, _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, - message: server_message.unwrap_or_else(|| fallback.to_string()), + server_message, }), }; } if status == StatusCode::TOO_MANY_REQUESTS && route == AuthRoute::SendCode { - return ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { - message: prefixed(), - }); + return ClientAuthError::SmsCodeThrottled; } if status == StatusCode::BAD_REQUEST { return match route { AuthRoute::PasswordEntry => { - ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { - message: prefixed(), - }) + ClientAuthError::PasswordLoginRejected(PasswordLoginRejected { server_message }) } AuthRoute::PhoneLogin => { - ClientAuthError::PhoneLoginInputRejected(PhoneLoginInputRejected { - message: prefixed(), - }) + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { server_message }) + } + AuthRoute::SendCode => { + ClientAuthError::SendCodeRejected(SendCodeRejected { server_message }) } - AuthRoute::SendCode => ClientAuthError::SendCodeInputRejected(SendCodeInputRejected { - message: prefixed(), - }), _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, - message: prefixed(), + server_message, }), }; } if status.is_server_error() { return ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: status_code, - message: prefixed(), }); } ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, - message: prefixed(), + server_message, }) } -fn response_data(body: &str, fallback: &str) -> Result { - let value: Value = serde_json::from_str(body).map_err(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: format!("{fallback}:登录服务响应不是合法 JSON"), - }) - })?; +fn response_data(body: &str) -> Result { + let value: Value = + serde_json::from_str(body).map_err(|_| ClientAuthError::AuthResponseNotJson)?; if value.get("ok").and_then(Value::as_bool) == Some(false) { - return Err(ClientAuthError::AuthResponseMalformed( - AuthResponseMalformed { - message: format!( - "{fallback}:{}", - error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) - ), + return Err(ClientAuthError::AuthResponseServerRejected( + AuthResponseServerRejected { + server_message: error_message(body), }, )); } @@ -648,7 +590,6 @@ async fn request_auth( bearer: Option<&str>, refresh_cookie: Option<&(String, String)>, policy: CookiePolicy, - fallback: &str, ) -> Result { let method = match policy { // 读取类路由用 GET;写入类路由是 POST。 @@ -672,29 +613,25 @@ async fn request_auth( let response = request .send() .await - .map_err(|error| network_error_message(&error))?; + .map_err(|error| network_error(&error))?; let status = response.status(); let captured = match policy { CookiePolicy::Capture | CookiePolicy::Require => refresh_cookie_from_response(&response), CookiePolicy::Ignore => None, }; - let text = response.text().await.map_err(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: format!("{fallback}:读取响应失败"), - }) - })?; + // 拿到状态码后读 body 失败属于传输层故障:按网络不可达上报,不猜契约。 + let text = response + .text() + .await + .map_err(|_| ClientAuthError::AuthNetworkUnreachable)?; if !status.is_success() { - return Err(map_auth_failure(status, &text, fallback, auth_route(route))); + return Err(map_auth_failure(status, &text, auth_route(route))); } if matches!(policy, CookiePolicy::Require) && captured.is_none() { - return Err(ClientAuthError::AuthResponseMalformed( - AuthResponseMalformed { - message: "登录服务未返回新的续期凭据,已停止使用旧凭据".to_string(), - }, - )); + return Err(ClientAuthError::AuthResponseMissingRefreshCookie); } Ok(AuthResponse { - data: response_data(&text, fallback)?, + data: response_data(&text)?, refresh_cookie: captured, }) } @@ -730,11 +667,7 @@ async fn commit_authenticated_session( identity_change: bool, ) -> Result { if token.chars().count() > MAX_SECRET_CHARS { - return Err(ClientAuthError::AuthResponseMalformed( - AuthResponseMalformed { - message: "登录服务返回的凭据无效".to_string(), - }, - )); + return Err(ClientAuthError::AuthResponseInvalidBody); } // 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。 let (user_id, known_user) = identity.resolve()?; @@ -821,15 +754,11 @@ fn phone_is_valid(phone: &str) -> bool { /// 归一化会话主体:凭据文件与进程快照都靠它判定「是谁」。 /// /// 主体缺失时不能退化成空串,否则本机凭据文件不完整、安装会话还会以「陶泥儿登录用户 -/// 身份无效」失败关闭;这里先给出登录语义的明确原因。 +/// 身份无效」失败关闭;这里先按「响应缺主体」失败关闭。 fn validated_session_user_id(user_id: &str) -> Result { let user_id = user_id.trim(); if user_id.is_empty() { - return Err(ClientAuthError::AuthResponseMalformed( - AuthResponseMalformed { - message: "登录失败:登录服务未返回用户身份".to_string(), - }, - )); + return Err(ClientAuthError::AuthResponseMissingUserIdentity); } Ok(user_id.to_string()) } @@ -858,14 +787,10 @@ async fn fetch_current_user( Some(&snapshot.access_token), None, CookiePolicy::Ignore, - "读取当前用户失败", ) .await?; - let me: MeResponse = serde_json::from_value(response.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "读取当前用户失败:响应格式无效".to_string(), - }) - })?; + let me: MeResponse = serde_json::from_value(response.data) + .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; Ok(me.user) } @@ -914,7 +839,6 @@ async fn refresh_session_inner( None, Some(&cookie), CookiePolicy::Require, - "刷新登录状态失败", ) .await; let refreshed = match refreshed { @@ -935,17 +859,12 @@ async fn refresh_session_inner( return Err(error); } }; - let token: TokenResponse = serde_json::from_value(refreshed.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "刷新登录状态失败:凭据响应格式无效".to_string(), - }) - })?; + let token: TokenResponse = serde_json::from_value(refreshed.data) + .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; let install_token = token.token.clone(); - let new_cookie = refreshed.refresh_cookie.ok_or_else(|| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "刷新登录状态失败:缺少新的续期凭据".to_string(), - }) - })?; + let new_cookie = refreshed + .refresh_cookie + .ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?; commit_authenticated_session( app, &session.api_base_url, @@ -1030,7 +949,7 @@ pub(crate) async fn read_client_auth_state( .map(str::trim) .filter(|value| !value.is_empty()) { - Some(value) => validate_client_api_base_url(value).map_err(server_address_rejected)?, + Some(value) => validate_client_api_base_url(value)?, None => session.api_base_url.clone(), }; if expected != session.api_base_url { @@ -1069,12 +988,10 @@ pub(crate) async fn send_client_phone_login_code( api_base_url: String, phone: String, ) -> Result { - let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; + let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { - message: "请输入正确的手机号".to_string(), - })); + return Err(ClientAuthError::PhoneNumberInvalid); } let client = build_client()?; let response = request_auth( @@ -1089,14 +1006,10 @@ pub(crate) async fn send_client_phone_login_code( None, None, CookiePolicy::Ignore, - "发送验证码失败", ) .await?; - let payload: SendCodeResponse = serde_json::from_value(response.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "发送验证码失败:响应格式无效".to_string(), - }) - })?; + let payload: SendCodeResponse = serde_json::from_value(response.data) + .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; Ok(ClientLoginCodeView { cooldown_seconds: payload.cooldown_seconds, expires_in_seconds: payload.expires_in_seconds, @@ -1108,16 +1021,11 @@ async fn complete_login( origin: &str, response: AuthResponse, ) -> Result { - let payload: TokenUserResponse = serde_json::from_value(response.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "登录失败:登录服务响应格式无效".to_string(), - }) - })?; - let cookie = response.refresh_cookie.ok_or_else(|| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "登录失败:登录服务未返回续期凭据".to_string(), - }) - })?; + let payload: TokenUserResponse = serde_json::from_value(response.data) + .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; + let cookie = response + .refresh_cookie + .ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?; let user = commit_authenticated_session( app, origin, @@ -1144,17 +1052,13 @@ pub(crate) async fn login_client_with_password( phone: String, password: String, ) -> Result { - let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; + let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { - message: "请输入正确的手机号".to_string(), - })); + return Err(ClientAuthError::PhoneNumberInvalid); } if password.trim().is_empty() { - return Err(ClientAuthError::PasswordMissing(PasswordMissing { - message: "请输入密码".to_string(), - })); + return Err(ClientAuthError::PasswordMissing); } let client = build_client()?; let response = request_auth( @@ -1169,7 +1073,6 @@ pub(crate) async fn login_client_with_password( None, None, CookiePolicy::Capture, - "登录失败", ) .await?; complete_login(&app, &origin, response).await @@ -1182,17 +1085,13 @@ pub(crate) async fn login_client_with_phone_code( phone: String, code: String, ) -> Result { - let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; + let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { - message: "请输入正确的手机号".to_string(), - })); + return Err(ClientAuthError::PhoneNumberInvalid); } if code.trim().is_empty() { - return Err(ClientAuthError::LoginCodeMissing(LoginCodeMissing { - message: "请输入验证码".to_string(), - })); + return Err(ClientAuthError::LoginCodeMissing); } let client = build_client()?; let response = request_auth( @@ -1207,7 +1106,6 @@ pub(crate) async fn login_client_with_phone_code( None, None, CookiePolicy::Capture, - "登录失败", ) .await?; complete_login(&app, &origin, response).await @@ -1236,7 +1134,6 @@ pub(crate) async fn logout_client_session(app: tauri::AppHandle) -> Result<(), C Some(&snapshot.access_token), None, CookiePolicy::Ignore, - "退出登录失败", ) .await; } @@ -1264,12 +1161,46 @@ mod tests { #[test] fn origins_reject_credentials_paths_and_plain_remote_http() { - assert!(validate_client_api_base_url("https://user:pass@example.com").is_err()); - assert!(validate_client_api_base_url("https://example.com/api").is_err()); - assert!(validate_client_api_base_url("https://example.com?a=1").is_err()); - assert!(validate_client_api_base_url("http://example.com").is_err()); - assert!(validate_client_api_base_url("ftp://example.com").is_err()); - assert!(validate_client_api_base_url(" ").is_err()); + // 每种拒绝各成一个变体:前端只按变体选提示,不读任何文案。 + assert_eq!( + validate_client_api_base_url("https://user:pass@example.com"), + Err(ClientAuthError::ServerAddressHasCredentials) + ); + assert_eq!( + validate_client_api_base_url("https://example.com/api"), + Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + ); + assert_eq!( + validate_client_api_base_url("https://example.com?a=1"), + Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + ); + assert_eq!( + validate_client_api_base_url("https://example.com#frag"), + Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + ); + assert_eq!( + validate_client_api_base_url("http://example.com"), + Err(ClientAuthError::ServerAddressNotHttps) + ); + assert_eq!( + validate_client_api_base_url("ftp://example.com"), + Err(ClientAuthError::ServerAddressUnsupportedScheme) + ); + assert_eq!( + validate_client_api_base_url(" "), + Err(ClientAuthError::ServerAddressEmptyOrTooLong) + ); + assert_eq!( + validate_client_api_base_url("不是地址"), + Err(ClientAuthError::ServerAddressNotAUrl) + ); + assert_eq!( + validate_client_api_base_url(&format!( + "https://example.com/{}", + "a".repeat(MAX_ORIGIN_CHARS) + )), + Err(ClientAuthError::ServerAddressEmptyOrTooLong) + ); assert_eq!( validate_client_api_base_url("http://127.0.0.1:10001").expect("loopback debug"), "http://127.0.0.1:10001" @@ -1297,57 +1228,47 @@ mod tests { #[test] fn session_routes_classify_401_403_as_authority_failures() { - assert!(map_auth_failure( - StatusCode::UNAUTHORIZED, - "{}", - "刷新失败", - AuthRoute::Session - ) - .is_authority_failure()); + assert!( + map_auth_failure(StatusCode::UNAUTHORIZED, "{}", AuthRoute::Session) + .is_authority_failure() + ); assert!(map_auth_failure( StatusCode::FORBIDDEN, r#"{"error":{"message":"无权"}}"#, - "刷新失败", AuthRoute::Session ) .is_authority_failure()); - let transient = map_auth_failure( - StatusCode::INTERNAL_SERVER_ERROR, - "{}", - "刷新失败", - AuthRoute::Session, - ); - assert!(!transient.is_authority_failure()); - assert!(matches!( - transient, - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500, .. }) - )); - assert!(transient.message().starts_with("刷新失败")); - } - - #[test] - fn login_routes_keep_the_server_reason_instead_of_reporting_expiry() { - let wrong_password = map_auth_failure( - StatusCode::UNAUTHORIZED, - r#"{"error":{"message":"手机号或密码错误"}}"#, - "登录失败", - AuthRoute::PasswordEntry, + assert_eq!( + map_auth_failure(StatusCode::UNAUTHORIZED, "{}", AuthRoute::Session), + ClientAuthError::SessionInvalidated ); assert_eq!( - wrong_password, - ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { - message: "手机号或密码错误".to_string() - }) + map_auth_failure( + StatusCode::FORBIDDEN, + r#"{"error":{"message":"无权"}}"#, + AuthRoute::Session, + ), + ClientAuthError::PermissionDenied ); - assert!(!wrong_password.is_authority_failure()); + let transient = + map_auth_failure(StatusCode::INTERNAL_SERVER_ERROR, "{}", AuthRoute::Session); + assert!(!transient.is_authority_failure()); + assert_eq!( + transient, + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500 }) + ); + } - let missing_reason = map_auth_failure( - StatusCode::UNAUTHORIZED, - "{}", - "登录失败", - AuthRoute::PasswordEntry, - ); - assert_eq!(missing_reason.message(), "登录失败"); + /// 登录路由的 401 是用户可修正的输入问题,不能和会话失效混在一起。 + /// + /// 服务端文案不再进载荷:变体本身已经说清事实,前缀/展示文案由前端调用方按当前操作给。 + #[test] + fn login_route_401_stays_a_user_correctable_mismatch() { + for body in [r#"{"error":{"message":"手机号或密码错误"}}"#, "{}"] { + let error = map_auth_failure(StatusCode::UNAUTHORIZED, body, AuthRoute::PasswordEntry); + assert_eq!(error, ClientAuthError::PhoneOrPasswordMismatch); + assert!(!error.is_authority_failure()); + } } /// 发码端点不带凭据:401/403 不是「用户可改的输入」,而是协议异常,属系统变体(带上文上报)。 @@ -1360,58 +1281,47 @@ mod tests { let error = map_auth_failure( status, r#"{"error":{"message":"手机号登录暂未启用"}}"#, - "发送验证码失败", AuthRoute::SendCode, ); - assert!(matches!( - error, - ClientAuthError::UnexpectedRejection(UnexpectedRejection { .. }) - )); assert!(!error.is_authority_failure()); - assert_eq!(error.message(), "手机号登录暂未启用"); + let ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: 401 | 403, + server_message: Some(message), + }) = error + else { + panic!("发码端点 401/403 必须落成带原文的系统变体"); + }; + assert_eq!(message, "手机号登录暂未启用"); } } #[test] - fn input_rejections_and_throttling_keep_the_server_text() { + fn input_rejections_keep_the_server_text_while_self_describing_variants_stay_bare() { let password_length = map_auth_failure( StatusCode::BAD_REQUEST, r#"{"error":{"message":"密码长度需要在 6 到 128 位之间"}}"#, - "登录失败", AuthRoute::PasswordEntry, ); - assert!(matches!( - password_length, - ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { .. }) - )); assert_eq!( - password_length.message(), - "登录失败:密码长度需要在 6 到 128 位之间" + password_length, + ClientAuthError::PasswordLoginRejected(PasswordLoginRejected { + server_message: Some("密码长度需要在 6 到 128 位之间".to_string()), + }) ); let throttled = map_auth_failure( StatusCode::TOO_MANY_REQUESTS, r#"{"error":{"message":"发送过于频繁"}}"#, - "发送验证码失败", AuthRoute::SendCode, ); - assert!(matches!( - throttled, - ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { .. }) - )); - assert_eq!(throttled.message(), "发送验证码失败:发送过于频繁"); + assert_eq!(throttled, ClientAuthError::SmsCodeThrottled); let bad_code = map_auth_failure( StatusCode::UNAUTHORIZED, r#"{"error":{"message":"验证码错误"}}"#, - "登录失败", AuthRoute::PhoneLogin, ); - assert!(matches!( - bad_code, - ClientAuthError::SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired { .. }) - )); - assert_eq!(bad_code.message(), "验证码错误"); + assert_eq!(bad_code, ClientAuthError::SmsCodeRejected); } #[test] @@ -1607,11 +1517,11 @@ mod tests { validated_session_user_id(" user-1 ").expect("user id"), "user-1" ); - // 空主体必须在登录路径就以登录语义失败:安装会话时的「陶泥儿登录用户身份无效」 + // 空主体必须在登录路径就失败关闭:安装会话时的「陶泥儿登录用户身份无效」 // 是内部不变式,不是用户能理解的登录失败原因。 assert_eq!( - validated_session_user_id(" ").unwrap_err().message(), - "登录失败:登录服务未返回用户身份" + validated_session_user_id(" ").unwrap_err(), + ClientAuthError::AuthResponseMissingUserIdentity ); } @@ -1669,11 +1579,8 @@ mod tests { .expect("login response fixture"); // 没有主体的登录响应在写凭据文件之前就以登录语义失败关闭。 assert_eq!( - SessionIdentity::Login(payload.user) - .resolve() - .unwrap_err() - .message(), - "登录失败:登录服务未返回用户身份" + SessionIdentity::Login(payload.user).resolve().unwrap_err(), + ClientAuthError::AuthResponseMissingUserIdentity ); // 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。 assert!(SessionIdentity::Persisted(String::new()).resolve().is_err()); -- 2.52.0 From 3ecee094319380b0fb2ac8cd753ea7dde98a262e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:24:51 +0800 Subject: [PATCH 37/70] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90=20ts-rs=20=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=20-=20=E6=96=B0=E5=A2=9E=E6=9C=89=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=8F=98=E4=BD=93=E7=9A=84=E8=BD=BD=E8=8D=B7=E6=96=87=E4=BB=B6?= =?UTF-8?q?=20PasswordLoginRejected/SendCodeRejected/PhoneCodeLoginRejecte?= =?UTF-8?q?d/AuthResponseServerRejected=20-=20=E5=88=A0=E9=99=A4=E6=94=B9?= =?UTF-8?q?=E6=88=90=E6=97=A0=E5=AD=97=E6=AE=B5=E5=8F=98=E4=BD=93=E5=90=8E?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=94=9F=E6=88=90=E7=9A=84=E5=AD=A4=E7=AB=8B?= =?UTF-8?q?=E8=BD=BD=E8=8D=B7=E6=96=87=E4=BB=B6=20-=20ClientAuthError=20?= =?UTF-8?q?=E8=81=94=E5=90=88=E5=90=8C=E6=AD=A5=E4=B8=BA=20{=20type=20}=20?= =?UTF-8?q?=E4=B8=8E=20{=20type=20}=20&=20=E8=BD=BD=E8=8D=B7=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../generated/AuthClientInitFailed.ts | 6 -- .../generated/AuthNetworkUnavailable.ts | 6 -- .../generated/AuthResponseMalformed.ts | 6 -- .../generated/AuthResponseServerRejected.ts | 6 ++ .../generated/AuthServiceUnavailable.ts | 4 +- .../src/services/generated/ClientAuthError.ts | 62 +++++++++---------- .../generated/ClientSessionPersistFailed.ts | 4 +- .../services/generated/LoginCodeMissing.ts | 6 -- .../generated/PasswordEntryInputRejected.ts | 6 -- .../generated/PasswordLoginRejected.ts | 11 ++++ .../src/services/generated/PasswordMissing.ts | 6 -- .../services/generated/PermissionDenied.ts | 6 -- .../generated/PhoneCodeLoginRejected.ts | 11 ++++ .../generated/PhoneLoginInputRejected.ts | 6 -- .../services/generated/PhoneNumberInvalid.ts | 6 -- .../generated/PhoneOrPasswordMismatch.ts | 6 -- .../generated/RuntimeSessionInstallFailed.ts | 4 +- .../generated/SendCodeInputRejected.ts | 6 -- .../services/generated/SendCodeRejected.ts | 11 ++++ .../generated/ServerAddressRejected.ts | 6 -- .../generated/SessionAuthorityRejected.ts | 6 -- .../generated/SmsCodeInvalidOrExpired.ts | 6 -- .../services/generated/SmsCodeThrottled.ts | 6 -- .../services/generated/UnexpectedRejection.ts | 7 ++- 24 files changed, 81 insertions(+), 129 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthResponseServerRejected.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts b/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts deleted file mode 100644 index c714a8c25..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 认证网络客户端构建失败。 - */ -export type AuthClientInitFailed = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts deleted file mode 100644 index 87df5480a..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 连接 / 超时 / DNS 等网络失败。 - */ -export type AuthNetworkUnavailable = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts deleted file mode 100644 index e7fa63683..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 响应不是合法 JSON、缺少必需字段或契约不成立。 - */ -export type AuthResponseMalformed = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseServerRejected.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseServerRejected.ts new file mode 100644 index 000000000..15034b0f3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthResponseServerRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 登录服务在响应体里显式拒绝时给的原文。 + */ +export type AuthResponseServerRejected = { serverMessage: string | null }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts index ea46b2edd..600fdcc93 100644 --- a/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts +++ b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * 服务端 5xx。 + * 登录服务 5xx 的状态码。 */ -export type AuthServiceUnavailable = { status: number; message: string }; +export type AuthServiceUnavailable = { status: number }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts index fb7dc66ce..63ee7887e 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -1,44 +1,44 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AuthClientInitFailed } from './AuthClientInitFailed'; -import type { AuthNetworkUnavailable } from './AuthNetworkUnavailable'; -import type { AuthResponseMalformed } from './AuthResponseMalformed'; +import type { AuthResponseServerRejected } from './AuthResponseServerRejected'; import type { AuthServiceUnavailable } from './AuthServiceUnavailable'; import type { ClientSessionPersistFailed } from './ClientSessionPersistFailed'; -import type { LoginCodeMissing } from './LoginCodeMissing'; -import type { PasswordEntryInputRejected } from './PasswordEntryInputRejected'; -import type { PasswordMissing } from './PasswordMissing'; -import type { PermissionDenied } from './PermissionDenied'; -import type { PhoneLoginInputRejected } from './PhoneLoginInputRejected'; -import type { PhoneNumberInvalid } from './PhoneNumberInvalid'; -import type { PhoneOrPasswordMismatch } from './PhoneOrPasswordMismatch'; +import type { PasswordLoginRejected } from './PasswordLoginRejected'; +import type { PhoneCodeLoginRejected } from './PhoneCodeLoginRejected'; import type { RuntimeSessionInstallFailed } from './RuntimeSessionInstallFailed'; -import type { SendCodeInputRejected } from './SendCodeInputRejected'; -import type { ServerAddressRejected } from './ServerAddressRejected'; -import type { SessionAuthorityRejected } from './SessionAuthorityRejected'; -import type { SmsCodeInvalidOrExpired } from './SmsCodeInvalidOrExpired'; -import type { SmsCodeThrottled } from './SmsCodeThrottled'; +import type { SendCodeRejected } from './SendCodeRejected'; import type { UnexpectedRejection } from './UnexpectedRejection'; /** - * 变体名就是线上的分流键(`type`),每个变体持有同名载荷类型。 + * 变体名就是线上的分流键(`type`),带载荷的变体持有同名载荷类型。 */ export type ClientAuthError = - | ({ type: 'serverAddressRejected' } & ServerAddressRejected) - | ({ type: 'phoneNumberInvalid' } & PhoneNumberInvalid) - | ({ type: 'passwordMissing' } & PasswordMissing) - | ({ type: 'loginCodeMissing' } & LoginCodeMissing) - | ({ type: 'passwordEntryInputRejected' } & PasswordEntryInputRejected) - | ({ type: 'phoneOrPasswordMismatch' } & PhoneOrPasswordMismatch) - | ({ type: 'sendCodeInputRejected' } & SendCodeInputRejected) - | ({ type: 'smsCodeThrottled' } & SmsCodeThrottled) - | ({ type: 'phoneLoginInputRejected' } & PhoneLoginInputRejected) - | ({ type: 'smsCodeInvalidOrExpired' } & SmsCodeInvalidOrExpired) - | ({ type: 'sessionAuthorityRejected' } & SessionAuthorityRejected) - | ({ type: 'permissionDenied' } & PermissionDenied) - | ({ type: 'authNetworkUnavailable' } & AuthNetworkUnavailable) + | { type: 'serverAddressEmptyOrTooLong' } + | { type: 'serverAddressNotAUrl' } + | { type: 'serverAddressHasCredentials' } + | { type: 'serverAddressHasPathOrQueryOrFragment' } + | { type: 'serverAddressNotHttps' } + | { type: 'serverAddressUnsupportedScheme' } + | { type: 'serverAddressOutsideChannel' } + | { type: 'phoneNumberInvalid' } + | { type: 'passwordMissing' } + | { type: 'loginCodeMissing' } + | ({ type: 'passwordLoginRejected' } & PasswordLoginRejected) + | { type: 'phoneOrPasswordMismatch' } + | ({ type: 'sendCodeRejected' } & SendCodeRejected) + | { type: 'smsCodeThrottled' } + | ({ type: 'phoneCodeLoginRejected' } & PhoneCodeLoginRejected) + | { type: 'smsCodeRejected' } + | { type: 'sessionInvalidated' } + | { type: 'permissionDenied' } + | { type: 'authNetworkTimeout' } + | { type: 'authNetworkUnreachable' } | ({ type: 'authServiceUnavailable' } & AuthServiceUnavailable) | ({ type: 'unexpectedRejection' } & UnexpectedRejection) - | ({ type: 'authResponseMalformed' } & AuthResponseMalformed) + | { type: 'authResponseNotJson' } + | { type: 'authResponseInvalidBody' } + | { type: 'authResponseMissingRefreshCookie' } + | { type: 'authResponseMissingUserIdentity' } + | ({ type: 'authResponseServerRejected' } & AuthResponseServerRejected) | ({ type: 'clientSessionPersistFailed' } & ClientSessionPersistFailed) | ({ type: 'runtimeSessionInstallFailed' } & RuntimeSessionInstallFailed) - | ({ type: 'authClientInitFailed' } & AuthClientInitFailed); + | { type: 'authClientInitFailed' }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts index ca606de34..258f43fb0 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * 本机登录凭据文件读写失败。 + * 本机凭据文件读写的原始错误明细。 */ -export type ClientSessionPersistFailed = { message: string }; +export type ClientSessionPersistFailed = { detail: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts b/apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts deleted file mode 100644 index f2aba1175..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 本地前置校验:验证码为空。 - */ -export type LoginCodeMissing = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts deleted file mode 100644 index b301d0721..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * `/api/auth/entry` 返回 400:服务端拒绝本次输入。 - */ -export type PasswordEntryInputRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts new file mode 100644 index 000000000..41cd897bd --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/entry` 返回 400 时服务端给的原文。 + */ +export type PasswordLoginRejected = { + /** + * 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + */ + serverMessage: string | null; +}; diff --git a/apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts b/apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts deleted file mode 100644 index 4ebefdc04..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 本地前置校验:密码为空。 - */ -export type PasswordMissing = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts b/apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts deleted file mode 100644 index 95f45c21b..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 会话路由 403:当前账号没有执行此操作的权限。 - */ -export type PermissionDenied = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts new file mode 100644 index 000000000..aa37d135b --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/phone/login` 返回 400 时服务端给的原文。 + */ +export type PhoneCodeLoginRejected = { + /** + * 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + */ + serverMessage: string | null; +}; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts deleted file mode 100644 index 0e6ddc711..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * `/api/auth/phone/login` 返回 400。 - */ -export type PhoneLoginInputRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts deleted file mode 100644 index 93946112e..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 本地前置校验:手机号为空或格式不合法。 - */ -export type PhoneNumberInvalid = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts deleted file mode 100644 index e7dc6f7a7..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * `/api/auth/entry` 返回 401:手机号或密码错误。 - */ -export type PhoneOrPasswordMismatch = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts index 57a6af5a9..df2940b5d 100644 --- a/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts +++ b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * 本机运行时会话安装 / 清理失败。 + * 本机运行时会话安装 / 清理的原始错误明细。 */ -export type RuntimeSessionInstallFailed = { message: string }; +export type RuntimeSessionInstallFailed = { detail: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts b/apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts deleted file mode 100644 index df085f092..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * `/api/auth/phone/send-code` 返回 400。 - */ -export type SendCodeInputRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts b/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts new file mode 100644 index 000000000..6d13fba2c --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/phone/send-code` 返回 400 时服务端给的原文。 + */ +export type SendCodeRejected = { + /** + * 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + */ + serverMessage: string | null; +}; diff --git a/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts b/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts deleted file mode 100644 index c37d41541..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 服务地址不是合法 origin / 非本机未用 HTTPS / 不在构建渠道范围内。 - */ -export type ServerAddressRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts b/apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts deleted file mode 100644 index 3b145b00f..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 会话路由 401:登录态权威失效。 - */ -export type SessionAuthorityRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts b/apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts deleted file mode 100644 index 031366003..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * `/api/auth/phone/login` 返回 401:验证码错误或过期。 - */ -export type SmsCodeInvalidOrExpired = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts b/apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts deleted file mode 100644 index 7202e4abc..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * `/api/auth/phone/send-code` 返回 429:发送过于频繁。 - */ -export type SmsCodeThrottled = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts b/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts index 8a26dc85b..b2c4041eb 100644 --- a/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts +++ b/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts @@ -1,6 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 + * 其它未识别拒绝的状态码与服务端原文。 */ -export type UnexpectedRejection = { status: number; message: string }; +export type UnexpectedRejection = { + status: number; + serverMessage: string | null; +}; -- 2.52.0 From aed2bb9c341b8f33e185ab6a19a3ede2d40a9129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:25:02 +0800 Subject: [PATCH 38/70] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=8C=89=E6=96=B0=E5=8F=98=E4=BD=93=E5=88=86=E6=B5=81=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E4=B8=8A=E4=B8=8B=E6=96=87=20-=20AuthenticatedClient?= =?UTF-8?q?=20=E7=9A=84=E4=B8=89=E4=B8=AA=20catch=20=E5=88=97=E5=85=A8?= =?UTF-8?q?=E6=96=B0=E5=8F=98=E4=BD=93=EF=BC=9A=E6=97=A0=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=8F=98=E4=BD=93=E7=9B=B4=E6=8E=A5=E7=BB=99=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E6=96=87=E6=A1=88=EF=BC=8C=E5=B8=A6=E8=BD=BD=E8=8D=B7=E5=8F=98?= =?UTF-8?q?=E4=BD=93=E5=85=88=20as=20=E5=86=8D=E8=AF=BB=20serverMessage=20?= =?UTF-8?q?-=20=E7=B3=BB=E7=BB=9F=E5=8F=98=E4=BD=93=E9=80=90=E4=B8=AA?= =?UTF-8?q?=E5=88=97=E5=87=BA=E5=90=8E=E5=8E=9F=E6=A0=B7=E6=8A=9B=E5=87=BA?= =?UTF-8?q?=EF=BC=8Cdefault=20=E4=BB=8D=E7=94=A8=20expectNever=20=E6=8A=8A?= =?UTF-8?q?=E6=BC=8F=E6=8E=A5=E5=8F=98=E4=BD=93=E5=8D=A1=E5=9C=A8=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E6=9C=9F=20-=20clientAuth/ClientAuthErrorWrapper=20?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E5=8E=BB=E6=8E=89=E5=AF=B9=20message=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E7=9A=84=E5=81=87=E8=AE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/AuthenticatedClient.tsx | 322 +++++++++++------- .../src/services/clientAuth.ts | 4 +- .../src/services/clientAuthErrorWrapper.ts | 4 +- 3 files changed, 210 insertions(+), 120 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index f380ab5d6..a03f53d93 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -35,18 +35,9 @@ import { captureClientError, installWebviewLogBridge, } from '../services/errorReporting'; -import type { LoginCodeMissing } from '../services/generated/LoginCodeMissing'; -import type { PasswordEntryInputRejected } from '../services/generated/PasswordEntryInputRejected'; -import type { PasswordMissing } from '../services/generated/PasswordMissing'; -import type { PermissionDenied } from '../services/generated/PermissionDenied'; -import type { PhoneLoginInputRejected } from '../services/generated/PhoneLoginInputRejected'; -import type { PhoneNumberInvalid } from '../services/generated/PhoneNumberInvalid'; -import type { PhoneOrPasswordMismatch } from '../services/generated/PhoneOrPasswordMismatch'; -import type { SendCodeInputRejected } from '../services/generated/SendCodeInputRejected'; -import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected'; -import type { SessionAuthorityRejected } from '../services/generated/SessionAuthorityRejected'; -import type { SmsCodeInvalidOrExpired } from '../services/generated/SmsCodeInvalidOrExpired'; -import type { SmsCodeThrottled } from '../services/generated/SmsCodeThrottled'; +import type { PasswordLoginRejected } from '../services/generated/PasswordLoginRejected'; +import type { PhoneCodeLoginRejected } from '../services/generated/PhoneCodeLoginRejected'; +import type { SendCodeRejected } from '../services/generated/SendCodeRejected'; import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, @@ -280,76 +271,120 @@ export function AuthenticatedClient({ if (!(error instanceof ClientAuthErrorWrapper)) throw error; const failure = error.error; switch (failure.type) { - // 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。 - case 'serverAddressRejected': { - const payload = failure as ServerAddressRejected; - setAuthCheckError(payload.message); - setLoginStatus(payload.message); + // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 + case 'serverAddressEmptyOrTooLong': { + const text = '服务器地址非法: 不能为空或超长'; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'serverAddressNotAUrl': { + const text = '服务器地址非法: 不是合法地址'; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'serverAddressHasCredentials': { + const text = '服务器地址非法: 不能包含用户名或密码'; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'serverAddressHasPathOrQueryOrFragment': { + const text = '服务器地址非法: 不能带路径、查询或片段'; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'serverAddressNotHttps': { + const text = '服务器地址非法: 远程地址必须使用 https'; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'serverAddressUnsupportedScheme': { + const text = '服务器地址非法: 只支持 http 或 https'; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'serverAddressOutsideChannel': { + const text = '服务器地址非法: 不在当前构建渠道范围内'; + setAuthCheckError(text); + setLoginStatus(text); break; } case 'phoneNumberInvalid': { - const payload = failure as PhoneNumberInvalid; - setAuthCheckError(payload.message); - setLoginStatus(payload.message); + const text = '手机号无效: 需为纯数字且不超过 32 位'; + setAuthCheckError(text); + setLoginStatus(text); break; } case 'passwordMissing': { - const payload = failure as PasswordMissing; - setAuthCheckError(payload.message); - setLoginStatus(payload.message); + const text = '密码无效: 不能为空'; + setAuthCheckError(text); + setLoginStatus(text); break; } case 'loginCodeMissing': { - const payload = failure as LoginCodeMissing; - setAuthCheckError(payload.message); - setLoginStatus(payload.message); + const text = '验证码无效: 不能为空'; + setAuthCheckError(text); + setLoginStatus(text); break; } - case 'passwordEntryInputRejected': { - const payload = failure as PasswordEntryInputRejected; - setAuthCheckError(payload.message); - setLoginStatus(payload.message); + case 'passwordLoginRejected': { + const payload = failure as PasswordLoginRejected; + const text = `登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`; + setAuthCheckError(text); + setLoginStatus(text); break; } case 'phoneOrPasswordMismatch': { - const payload = failure as PhoneOrPasswordMismatch; - setAuthCheckError(payload.message); - setLoginStatus(payload.message); + const text = '手机号或密码错误'; + setAuthCheckError(text); + setLoginStatus(text); break; } - case 'sendCodeInputRejected': { - const payload = failure as SendCodeInputRejected; - setAuthCheckError(payload.message); - setLoginStatus(payload.message); + case 'sendCodeRejected': { + const payload = failure as SendCodeRejected; + const text = `发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`; + setAuthCheckError(text); + setLoginStatus(text); break; } case 'smsCodeThrottled': { - const payload = failure as SmsCodeThrottled; - setAuthCheckError(payload.message); - setLoginStatus(payload.message); + const text = '发送验证码过于频繁,请稍后再试'; + setAuthCheckError(text); + setLoginStatus(text); break; } - case 'phoneLoginInputRejected': { - const payload = failure as PhoneLoginInputRejected; - setAuthCheckError(payload.message); - setLoginStatus(payload.message); + case 'phoneCodeLoginRejected': { + const payload = failure as PhoneCodeLoginRejected; + const text = `验证码登录失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`; + setAuthCheckError(text); + setLoginStatus(text); break; } - case 'smsCodeInvalidOrExpired': { - const payload = failure as SmsCodeInvalidOrExpired; - setAuthCheckError(payload.message); - setLoginStatus(payload.message); + case 'smsCodeRejected': { + const text = '验证码错误或已过期'; + setAuthCheckError(text); + setLoginStatus(text); break; } // 会话权威失效:Rust 已按未登录处理;走到这里也只按未登录呈现。 - case 'sessionAuthorityRejected': + case 'sessionInvalidated': case 'permissionDenied': break; // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 - case 'authNetworkUnavailable': + case 'authNetworkTimeout': + case 'authNetworkUnreachable': case 'authServiceUnavailable': case 'unexpectedRejection': - case 'authResponseMalformed': + case 'authResponseNotJson': + case 'authResponseInvalidBody': + case 'authResponseMissingRefreshCookie': + case 'authResponseMissingUserIdentity': + case 'authResponseServerRejected': case 'clientSessionPersistFailed': case 'runtimeSessionInstallFailed': case 'authClientInitFailed': @@ -421,7 +456,8 @@ export function AuthenticatedClient({ * * - 承载:命令失败由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`,`error` 就是判别联合, * `failure.type` 是唯一分流键; - * - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池; + * - 业务 / 会话变体:无载荷变体用本 catch 的固定文案;带载荷变体先 `as` 取具名类型, + * 再用它自己的 `serverMessage` 拼上下文(Rust 不预拼用户可见文案),永不进错误池; * - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池; * - `default: expectNever` 保证 Rust 新增变体时这里编译失败。 * @@ -452,71 +488,98 @@ export function AuthenticatedClient({ if (!(error instanceof ClientAuthErrorWrapper)) throw error; const failure = error.error; switch (failure.type) { - case 'serverAddressRejected': { - const payload = failure as ServerAddressRejected; - setLoginStatus(payload.message); + // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 + case 'serverAddressEmptyOrTooLong': { + setLoginStatus('服务器地址非法: 不能为空或超长'); + break; + } + case 'serverAddressNotAUrl': { + setLoginStatus('服务器地址非法: 不是合法地址'); + break; + } + case 'serverAddressHasCredentials': { + setLoginStatus('服务器地址非法: 不能包含用户名或密码'); + break; + } + case 'serverAddressHasPathOrQueryOrFragment': { + setLoginStatus('服务器地址非法: 不能带路径、查询或片段'); + break; + } + case 'serverAddressNotHttps': { + setLoginStatus('服务器地址非法: 远程地址必须使用 https'); + break; + } + case 'serverAddressUnsupportedScheme': { + setLoginStatus('服务器地址非法: 只支持 http 或 https'); + break; + } + case 'serverAddressOutsideChannel': { + setLoginStatus('服务器地址非法: 不在当前构建渠道范围内'); break; } case 'phoneNumberInvalid': { - const payload = failure as PhoneNumberInvalid; - setLoginStatus(payload.message); + setLoginStatus('手机号无效: 需为纯数字且不超过 32 位'); break; } case 'passwordMissing': { - const payload = failure as PasswordMissing; - setLoginStatus(payload.message); + setLoginStatus('密码无效: 不能为空'); break; } case 'loginCodeMissing': { - const payload = failure as LoginCodeMissing; - setLoginStatus(payload.message); + setLoginStatus('验证码无效: 不能为空'); break; } - case 'passwordEntryInputRejected': { - const payload = failure as PasswordEntryInputRejected; - setLoginStatus(payload.message); + case 'passwordLoginRejected': { + const payload = failure as PasswordLoginRejected; + setLoginStatus( + `登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`, + ); break; } case 'phoneOrPasswordMismatch': { - const payload = failure as PhoneOrPasswordMismatch; - setLoginStatus(payload.message); + setLoginStatus('手机号或密码错误'); break; } - case 'sendCodeInputRejected': { - const payload = failure as SendCodeInputRejected; - setLoginStatus(payload.message); + case 'sendCodeRejected': { + const payload = failure as SendCodeRejected; + setLoginStatus( + `发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`, + ); break; } case 'smsCodeThrottled': { - const payload = failure as SmsCodeThrottled; - setLoginStatus(payload.message); + setLoginStatus('发送验证码过于频繁,请稍后再试'); break; } - case 'phoneLoginInputRejected': { - const payload = failure as PhoneLoginInputRejected; - setLoginStatus(payload.message); + case 'phoneCodeLoginRejected': { + const payload = failure as PhoneCodeLoginRejected; + setLoginStatus( + `验证码登录失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`, + ); break; } - case 'smsCodeInvalidOrExpired': { - const payload = failure as SmsCodeInvalidOrExpired; - setLoginStatus(payload.message); + case 'smsCodeRejected': { + setLoginStatus('验证码错误或已过期'); break; } - case 'sessionAuthorityRejected': { - const payload = failure as SessionAuthorityRejected; - setLoginStatus(payload.message); + case 'sessionInvalidated': { + setLoginStatus('登录状态已失效,请重新登录'); break; } case 'permissionDenied': { - const payload = failure as PermissionDenied; - setLoginStatus(payload.message); + setLoginStatus('当前账号没有权限执行此操作'); break; } // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 - case 'authNetworkUnavailable': + case 'authNetworkTimeout': + case 'authNetworkUnreachable': case 'authServiceUnavailable': case 'unexpectedRejection': - case 'authResponseMalformed': + case 'authResponseNotJson': + case 'authResponseInvalidBody': + case 'authResponseMissingRefreshCookie': + case 'authResponseMissingUserIdentity': + case 'authResponseServerRejected': case 'clientSessionPersistFailed': case 'runtimeSessionInstallFailed': case 'authClientInitFailed': @@ -593,71 +656,98 @@ export function AuthenticatedClient({ if (!(error instanceof ClientAuthErrorWrapper)) throw error; const failure = error.error; switch (failure.type) { - case 'serverAddressRejected': { - const payload = failure as ServerAddressRejected; - setLoginStatus(payload.message); + // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 + case 'serverAddressEmptyOrTooLong': { + setLoginStatus('服务器地址非法: 不能为空或超长'); + break; + } + case 'serverAddressNotAUrl': { + setLoginStatus('服务器地址非法: 不是合法地址'); + break; + } + case 'serverAddressHasCredentials': { + setLoginStatus('服务器地址非法: 不能包含用户名或密码'); + break; + } + case 'serverAddressHasPathOrQueryOrFragment': { + setLoginStatus('服务器地址非法: 不能带路径、查询或片段'); + break; + } + case 'serverAddressNotHttps': { + setLoginStatus('服务器地址非法: 远程地址必须使用 https'); + break; + } + case 'serverAddressUnsupportedScheme': { + setLoginStatus('服务器地址非法: 只支持 http 或 https'); + break; + } + case 'serverAddressOutsideChannel': { + setLoginStatus('服务器地址非法: 不在当前构建渠道范围内'); break; } case 'phoneNumberInvalid': { - const payload = failure as PhoneNumberInvalid; - setLoginStatus(payload.message); + setLoginStatus('手机号无效: 需为纯数字且不超过 32 位'); break; } case 'passwordMissing': { - const payload = failure as PasswordMissing; - setLoginStatus(payload.message); + setLoginStatus('密码无效: 不能为空'); break; } case 'loginCodeMissing': { - const payload = failure as LoginCodeMissing; - setLoginStatus(payload.message); + setLoginStatus('验证码无效: 不能为空'); break; } - case 'passwordEntryInputRejected': { - const payload = failure as PasswordEntryInputRejected; - setLoginStatus(payload.message); + case 'passwordLoginRejected': { + const payload = failure as PasswordLoginRejected; + setLoginStatus( + `登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`, + ); break; } case 'phoneOrPasswordMismatch': { - const payload = failure as PhoneOrPasswordMismatch; - setLoginStatus(payload.message); + setLoginStatus('手机号或密码错误'); break; } - case 'sendCodeInputRejected': { - const payload = failure as SendCodeInputRejected; - setLoginStatus(payload.message); + case 'sendCodeRejected': { + const payload = failure as SendCodeRejected; + setLoginStatus( + `发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`, + ); break; } case 'smsCodeThrottled': { - const payload = failure as SmsCodeThrottled; - setLoginStatus(payload.message); + setLoginStatus('发送验证码过于频繁,请稍后再试'); break; } - case 'phoneLoginInputRejected': { - const payload = failure as PhoneLoginInputRejected; - setLoginStatus(payload.message); + case 'phoneCodeLoginRejected': { + const payload = failure as PhoneCodeLoginRejected; + setLoginStatus( + `验证码登录失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`, + ); break; } - case 'smsCodeInvalidOrExpired': { - const payload = failure as SmsCodeInvalidOrExpired; - setLoginStatus(payload.message); + case 'smsCodeRejected': { + setLoginStatus('验证码错误或已过期'); break; } - case 'sessionAuthorityRejected': { - const payload = failure as SessionAuthorityRejected; - setLoginStatus(payload.message); + case 'sessionInvalidated': { + setLoginStatus('登录状态已失效,请重新登录'); break; } case 'permissionDenied': { - const payload = failure as PermissionDenied; - setLoginStatus(payload.message); + setLoginStatus('当前账号没有权限执行此操作'); break; } // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 - case 'authNetworkUnavailable': + case 'authNetworkTimeout': + case 'authNetworkUnreachable': case 'authServiceUnavailable': case 'unexpectedRejection': - case 'authResponseMalformed': + case 'authResponseNotJson': + case 'authResponseInvalidBody': + case 'authResponseMissingRefreshCookie': + case 'authResponseMissingUserIdentity': + case 'authResponseServerRejected': case 'clientSessionPersistFailed': case 'runtimeSessionInstallFailed': case 'authClientInitFailed': diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 1f93ce84b..4ab11e049 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -41,8 +41,8 @@ function requireInvoke() { * * **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 ts-rs 生成的 * `ClientAuthError` 判别联合;出现别的形状属于 Tauri / Rust 侧缺陷,调用方 `switch` 的 - * `default` 分支仍会把它抛出去上报。包装本身不读变体字段(`message` 之类不保证每个变体都有)、 - * 不注入上下文、不兜底文案。 + * `default` 分支仍会把它抛出去上报。包装本身不读变体字段、 + * 不注入上下文、不拼用户可见文案。 */ async function invokeClientAuth( command: string, diff --git a/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts b/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts index d72e7483e..0f9b0a4f2 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts @@ -15,8 +15,8 @@ export type ClientErrorReportContext = { * 认证命令失败的 JS 侧载体:`error` 就是 Rust 的结构化拒绝(ts-rs 生成的 `ClientAuthError` * 判别联合)。 * - * 本类只把原始拒绝值装成 JS `Error`,不读、不产任何派生值:不读变体上的 `message`(变体不 - * 保证都有这个字段),不注入 `source` / `action`,`Error.message` 留空。分流只看类型化的变体, + * 本类只把原始拒绝值装成 JS `Error`,不读、不产任何派生值:不读变体字段,不注入 + * `source` / `action`,`Error.message` 留空。分流只看类型化的变体, * **不要用文案判断**;上报上下文由调用 `captureClientError` 时的显式入参决定。 */ export class ClientAuthErrorWrapper extends Error { -- 2.52.0 From 3d6eade206259e55b0c075cc99c9ae64f32b9101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:25:09 +0800 Subject: [PATCH 39/70] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E5=AF=B9=E9=BD=90=E6=8B=8D=E5=B9=B3=E5=8F=98?= =?UTF-8?q?=E4=BD=93=20-=20=E8=BD=BD=E4=BD=93=E7=94=A8=E4=BE=8B=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=8F=98=E4=BD=93=E5=85=A8=E9=9B=86=EF=BC=9A=E6=97=A0?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=8F=98=E4=BD=93=E5=8F=96=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E6=96=87=E6=A1=88=EF=BC=8C=E5=B8=A6=E8=BD=BD=E8=8D=B7=E5=8F=98?= =?UTF-8?q?=E4=BD=93=20as=20=E5=90=8E=E8=AF=BB=20serverMessage/status/deta?= =?UTF-8?q?il=20-=20=E4=B8=8A=E6=8A=A5=E5=88=A4=E6=8D=AE=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20passwordLoginRejected=EF=BC=9A=E6=9C=8D=E5=8A=A1=E7=AB=AF=20?= =?UTF-8?q?400=20=E5=8E=9F=E6=96=87=E5=8F=AA=E5=B1=95=E7=A4=BA=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E5=86=99=E9=94=99=E8=AF=AF=E6=B1=A0=20-=20=E7=BD=91?= =?UTF-8?q?=E5=85=B3=E7=94=A8=E4=BE=8B=E6=94=B9=E7=94=A8=20authNetworkUnre?= =?UTF-8?q?achable=20/=20serverAddressNotHttps=20=E7=AD=89=E6=96=B0?= =?UTF-8?q?=E5=8F=98=E4=BD=93=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/appSurface/auth.suite.ts | 24 ++-- .../tests/authFailureReporting.test.tsx | 32 ++++- .../tests/clientAuthErrorCarrier.test.ts | 132 ++++++++++-------- .../tests/clientAuthHost.test.ts | 5 +- .../tests/errorReporting.test.ts | 5 +- 5 files changed, 117 insertions(+), 81 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index fc58a3350..37dec4a95 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -251,7 +251,10 @@ export function registerAuthTests() { } if (command === 'login_client_with_password') { // Rust 命令的结构化拒绝:前端只按 `type` 分流,不解析文案。 - throw { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }; + throw { + type: 'passwordLoginRejected', + serverMessage: '手机号或密码错误', + }; } return null; }); @@ -266,7 +269,9 @@ export function registerAuthTests() { }); fireEvent.click(screen.getByRole('button', { name: '登录' })); - expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); + expect( + await screen.findByText('登录失败: 手机号或密码错误'), + ).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); }); @@ -283,10 +288,7 @@ export function registerAuthTests() { return { status: 'unauthenticated' }; } if (command === 'login_client_with_phone_code') { - throw { - type: 'authNetworkUnavailable', - message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - }; + throw { type: 'authNetworkUnreachable' }; } return null; }); @@ -298,8 +300,7 @@ export function registerAuthTests() { // 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。 expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper); expect((reasons[0] as ClientAuthErrorWrapper).error).toMatchObject({ - type: 'authNetworkUnavailable', - message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + type: 'authNetworkUnreachable', }); expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(); expect(document.body.textContent).not.toContain('ECONNREFUSED'); @@ -317,8 +318,7 @@ export function registerAuthTests() { reads += 1; return reads === 1 ? Promise.reject({ - type: 'serverAddressRejected', - message: '服务器地址无效', + type: 'serverAddressNotHttps', }) : { status: 'unauthenticated' }; } @@ -326,7 +326,9 @@ export function registerAuthTests() { }); renderAuthSurface(invoke); - expect(await screen.findByText('服务器地址无效')).not.toBeNull(); + expect( + await screen.findByText('服务器地址非法: 远程地址必须使用 https'), + ).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '重试登录状态检查' })); diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index c631d955f..338a5ff09 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -77,7 +77,7 @@ describe('认证失败的上报判据', () => { return { status: 'unauthenticated' }; } if (command === 'login_client_with_password') { - throw { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }; + throw { type: 'phoneOrPasswordMismatch' }; } return null; }); @@ -86,13 +86,33 @@ describe('认证失败的上报判据', () => { expect(reportCalls()).toHaveLength(0); }); + it('服务端 400 的原文经 passwordLoginRejected 透出,仍不写错误池', async () => { + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw { + type: 'passwordLoginRejected', + serverMessage: '密码长度需要在 6 到 128 位之间', + }; + } + return null; + }); + + expect( + await screen.findByText('登录失败: 密码长度需要在 6 到 128 位之间'), + ).not.toBeNull(); + expect(reportCalls()).toHaveLength(0); + }); + it('发码限流同样只显示文案,不写错误池', async () => { renderLogin(async (command) => { if (command === 'read_client_auth_state') { return { status: 'unauthenticated' }; } if (command === 'send_client_phone_login_code') { - throw { type: 'smsCodeThrottled', message: '发送过于频繁,请稍后再试' }; + throw { type: 'smsCodeThrottled' }; } return null; }); @@ -102,12 +122,13 @@ describe('认证失败的上报判据', () => { }); fireEvent.click(screen.getByRole('button', { name: '获取验证码' })); - expect(await screen.findByText('发送过于频繁,请稍后再试')).not.toBeNull(); + expect( + await screen.findByText('发送验证码过于频繁,请稍后再试'), + ).not.toBeNull(); expect(reportCalls()).toHaveLength(0); }); it('系统变体(网络不可达)原样抛出,经 unhandledrejection 上报一次', async () => { - const message = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; const uninstall = installUnhandledRejectionBridge(); try { await submitPasswordLogin(async (command) => { @@ -115,7 +136,7 @@ describe('认证失败的上报判据', () => { return { status: 'unauthenticated' }; } if (command === 'login_client_with_password') { - throw { type: 'authNetworkUnavailable', message }; + throw { type: 'authNetworkUnreachable' }; } return null; }); @@ -125,7 +146,6 @@ describe('认证失败的上报判据', () => { source: 'unhandledrejection', message: '未知客户端错误', }); - expect(screen.queryByText(message)).toBeNull(); } finally { uninstall(); } diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index 51e0d77b4..156c72079 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -3,80 +3,95 @@ * 认证命令失败的 JS 侧载体。 * * `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体 - * 字段(`message` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按 - * `error.error.type`。另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体, - * `expectNever(error)` 都编译不过。 + * 字段(`serverMessage` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按 + * `error.error.type`。另外用编译期用例钉住变体全集:无字段变体直接取固定文案,带载荷变体先 + * `as` 取具名类型;漏列任何变体,`expectNever(error)` 都编译不过。 */ import { describe, expect, it, vi } from 'vitest'; import { loginClientWithPassword } from '../src/services/clientAuth'; import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper'; -import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed'; -import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable'; -import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed'; +import type { AuthResponseServerRejected } from '../src/services/generated/AuthResponseServerRejected'; import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed'; -import type { LoginCodeMissing } from '../src/services/generated/LoginCodeMissing'; -import type { PasswordEntryInputRejected } from '../src/services/generated/PasswordEntryInputRejected'; -import type { PasswordMissing } from '../src/services/generated/PasswordMissing'; -import type { PermissionDenied } from '../src/services/generated/PermissionDenied'; -import type { PhoneLoginInputRejected } from '../src/services/generated/PhoneLoginInputRejected'; -import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid'; -import type { PhoneOrPasswordMismatch } from '../src/services/generated/PhoneOrPasswordMismatch'; +import type { PasswordLoginRejected } from '../src/services/generated/PasswordLoginRejected'; +import type { PhoneCodeLoginRejected } from '../src/services/generated/PhoneCodeLoginRejected'; import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed'; -import type { SendCodeInputRejected } from '../src/services/generated/SendCodeInputRejected'; -import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected'; -import type { SessionAuthorityRejected } from '../src/services/generated/SessionAuthorityRejected'; -import type { SmsCodeInvalidOrExpired } from '../src/services/generated/SmsCodeInvalidOrExpired'; -import type { SmsCodeThrottled } from '../src/services/generated/SmsCodeThrottled'; +import type { SendCodeRejected } from '../src/services/generated/SendCodeRejected'; import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection'; function expectNever(value: never): void { void value; } -/** 编译期用例:19 个变体逐个列全,每个分支用 `as` 取具名载荷类型。 */ +/** 编译期用例:变体逐个列全;带载荷的分支先 `as` 再读自己的字段。 */ function variantMessage(error: ClientAuthError): string { switch (error.type) { - case 'serverAddressRejected': - return (error as ServerAddressRejected).message; + case 'serverAddressEmptyOrTooLong': + return '不能为空或超长'; + case 'serverAddressNotAUrl': + return '不是合法地址'; + case 'serverAddressHasCredentials': + return '不能包含凭据'; + case 'serverAddressHasPathOrQueryOrFragment': + return '不能带路径、查询或 fragment'; + case 'serverAddressNotHttps': + return '远程地址不是 https'; + case 'serverAddressUnsupportedScheme': + return '不是 http(s) 地址'; + case 'serverAddressOutsideChannel': + return '不在当前构建渠道范围内'; case 'phoneNumberInvalid': - return (error as PhoneNumberInvalid).message; + return '手机号格式不正确'; case 'passwordMissing': - return (error as PasswordMissing).message; + return '密码为空'; case 'loginCodeMissing': - return (error as LoginCodeMissing).message; - case 'passwordEntryInputRejected': - return (error as PasswordEntryInputRejected).message; + return '验证码为空'; + case 'passwordLoginRejected': + return (error as PasswordLoginRejected).serverMessage ?? '登录输入被拒绝'; case 'phoneOrPasswordMismatch': - return (error as PhoneOrPasswordMismatch).message; - case 'sendCodeInputRejected': - return (error as SendCodeInputRejected).message; + return '手机号或密码错误'; + case 'sendCodeRejected': + return (error as SendCodeRejected).serverMessage ?? '发码被拒绝'; case 'smsCodeThrottled': - return (error as SmsCodeThrottled).message; - case 'phoneLoginInputRejected': - return (error as PhoneLoginInputRejected).message; - case 'smsCodeInvalidOrExpired': - return (error as SmsCodeInvalidOrExpired).message; - case 'sessionAuthorityRejected': - return (error as SessionAuthorityRejected).message; + return '发码过于频繁'; + case 'phoneCodeLoginRejected': + return ( + (error as PhoneCodeLoginRejected).serverMessage ?? '验证码登录被拒绝' + ); + case 'smsCodeRejected': + return '验证码错误或过期'; + case 'sessionInvalidated': + return '登录态失效'; case 'permissionDenied': - return (error as PermissionDenied).message; - case 'authNetworkUnavailable': - return (error as AuthNetworkUnavailable).message; + return '权限不足'; + case 'authNetworkTimeout': + return '连接超时'; + case 'authNetworkUnreachable': + return '连接失败'; case 'authServiceUnavailable': - return (error as AuthServiceUnavailable).message; + return `HTTP ${(error as AuthServiceUnavailable).status}`; case 'unexpectedRejection': - return (error as UnexpectedRejection).message; - case 'authResponseMalformed': - return (error as AuthResponseMalformed).message; + return (error as UnexpectedRejection).serverMessage ?? '未识别拒绝'; + case 'authResponseNotJson': + return '响应不是 JSON'; + case 'authResponseInvalidBody': + return '响应结构不符'; + case 'authResponseMissingRefreshCookie': + return '缺续期凭据'; + case 'authResponseMissingUserIdentity': + return '缺用户主体'; + case 'authResponseServerRejected': + return ( + (error as AuthResponseServerRejected).serverMessage ?? '服务端显式拒绝' + ); case 'clientSessionPersistFailed': - return (error as ClientSessionPersistFailed).message; + return (error as ClientSessionPersistFailed).detail; case 'runtimeSessionInstallFailed': - return (error as RuntimeSessionInstallFailed).message; + return (error as RuntimeSessionInstallFailed).detail; case 'authClientInitFailed': - return (error as AuthClientInitFailed).message; + return '网络客户端构建失败'; default: { expectNever(error); return ''; @@ -90,8 +105,8 @@ function installInvoke(handler: () => Promise) { describe('认证命令失败的 JS 侧载体', () => { it('结构化拒绝原样装进 error 字段,不读变体字段', async () => { - // 故意不带 message:包装层绝不能假设每个变体都有这个字段。 - const rejection = { type: 'phoneNumberInvalid', status: 400 }; + // 故意不带 serverMessage:包装层绝不能假设每个变体都有这个字段。 + const rejection = { type: 'phoneNumberInvalid' }; installInvoke(async () => { throw rejection; }); @@ -138,16 +153,21 @@ describe('认证命令失败的 JS 侧载体', () => { }); describe('变体分流', () => { - it('判别后按具名载荷类型取值', () => { - expect( - variantMessage({ type: 'phoneNumberInvalid', message: '格式不对' }), - ).toBe('格式不对'); + it('无字段变体直接取固定文案,带载荷变体按具名类型取值', () => { + expect(variantMessage({ type: 'phoneNumberInvalid' })).toBe( + '手机号格式不正确', + ); expect( variantMessage({ - type: 'authServiceUnavailable', - status: 503, - message: '服务暂不可用', + type: 'passwordLoginRejected', + serverMessage: '密码长度需要在 6 到 128 位之间', }), - ).toBe('服务暂不可用'); + ).toBe('密码长度需要在 6 到 128 位之间'); + expect( + variantMessage({ type: 'passwordLoginRejected', serverMessage: null }), + ).toBe('登录输入被拒绝'); + expect( + variantMessage({ type: 'authServiceUnavailable', status: 503 }), + ).toBe('HTTP 503'); }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 9fcea5cca..660f74f4f 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -159,10 +159,7 @@ test('没有原生宿主时登录能力明确失败关闭', async () => { }); test('结构化失败不降级成投影值,而是以携带原始拒绝值的 ClientAuthErrorWrapper 拒绝', async () => { - const rejection = { - type: 'authNetworkUnavailable', - message: '无法连接登录服务', - }; + const rejection = { type: 'authNetworkUnreachable' }; invoke.mockRejectedValueOnce(rejection); await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ error: rejection, diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index a19cd3184..1265153f0 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -210,10 +210,7 @@ describe('客户端错误报告池', () => { }); it('解包 ClientAuthErrorWrapper 里的原始拒绝值,上下文只认显式入参', async () => { - const original = { - type: 'authNetworkUnavailable', - message: '无法连接登录服务,请确认网络后重试', - }; + const original = { type: 'authNetworkUnreachable' }; await captureClientError(new ClientAuthErrorWrapper(original), { source: 'unhandledrejection', -- 2.52.0 From 9fe692ba874808ce40502df9513dd72932c6c4cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:42:15 +0800 Subject: [PATCH 40/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9AAGC=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E9=94=99=E8=AF=AF=E6=94=B9=E4=B8=BA=E9=A1=B6=E5=B1=82?= =?UTF-8?q?=E7=B1=BB=E5=88=AB=E5=8A=A0=E7=B1=BB=E5=9E=8B=E5=8C=96reason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 AGC 命令错误结构化 ADR:顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 reason 字段,不再拆成几十个顶层变体、也不用字符串 - 更新 AGC 认证失败 JS 侧载体 ADR:带载荷分支先 as 取具名类型,reason 是枚举时再 switch(payload.reason),两处 default 都用 expectNever - 同步错误报告技术方案、README 索引、决策记录与踩坑的拍平变体口径 --- docs/README.md | 2 +- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 25 +++++++------ ...GC认证失败的JS侧载体与抛出时机-2026-10-01.md | 36 ++++++++++++------- .../shared-memory/decision-log.md | 6 ++-- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...€术方案】AGC错误报告与诊断上传-2026-08-31.md | 2 +- 6 files changed, 45 insertions(+), 28 deletions(-) diff --git a/docs/README.md b/docs/README.md index a62df7401..688cb4663 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,7 +52,7 @@ - [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 - [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。 - [AGC 命令错误结构化与错误报告口径](./adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md):AGC 命令失败按具体变体建模并用 ts-rs 导出,前端按变体分流、不匹配文案;报告池只收没人处理的错误。 -- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientAuthErrorWrapper`(`error` 字段就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,无字段变体用固定文案、带载荷分支先 `as` 取自己的具名载荷类型,系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。 +- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientAuthErrorWrapper`(`error` 字段就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,无字段变体用固定文案、带载荷分支先 `as` 取自己的具名载荷类型(可枚举细分再 `switch (payload.reason)` 在类型化枚举上分流),系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。 - [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 718d0e9cd..a6de13786 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -33,16 +33,21 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Rust 侧定义具体变体枚举(auth 首个落地:`ClientAuthError`),`#[derive(Serialize, TS)]` + `#[serde(tag = "type", rename_all = "camelCase")]`,用 ts-rs 导出到 `apps/ai-game-creator-shell/src/services/generated/`;生成物不手改。 -- **变体拍平在同一层,不嵌套子枚举**:可枚举的事实各自成一个变体,只有类型本身说不出来的事实才进载荷 - (服务端原文、HTTP 状态码、本机 IO 明细)。internally tagged 下无字段变体就是 `{ type: 'x' }`;带 - 载荷变体是 `{ type: 'x', ... }`,ts-rs 为它生成 `{ type: 'x' } & X` 与 `generated/X.ts`。例如服务地址 - 校验的 7 种失败各自成一个无字段变体,前端只按 `type` 选提示。 -- 前端 `switch (error.type)` 必须列全变体:无字段变体直接取本 catch 的固定文案;带载荷变体先 `as` - 取自己的具名载荷类型,再读它自己的字段(等价于 Java 的 `catch (X e)`)。`default` 用 `expectNever` - 让漏接变体变成**编译错误**。**不假设所有变体都有同一个字段**,也不做任何文案匹配。 -- Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文 `serverMessage`、HTTP 状态码、 - 本机 IO 的 `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前 - 操作拼接。 +- **顶层只放调用方要分流的类别,可枚举的细分原因收进类型化 `reason` 字段**——既不拆成几十个顶层变体, + 也不用字符串。`reason` 自己也是 `#[derive(Serialize, TS)]` 的枚举。internally tagged 下无字段变体是 + `{ type: 'x' }`;newtype 变体是 `{ type: 'x' } & X`,ts-rs 为它生成 `generated/X.ts`。例如服务地址 + 校验是 `serverAddressRejected` + `ServerAddressReason`(`emptyOrTooLong` / `notAUrl` / `hasCredentials` / + `hasPathOrQueryOrFragment` / `notHttps` / `unsupportedScheme` / `outsideChannel`),网络失败是 + `authNetworkFailure` + `AuthNetworkReason`(`timeout` / `unreachable`),响应契约破损是 + `authResponseInvalid` + `AuthResponseInvalidReason`(`notJson` / `invalidBody` / `missingRefreshCookie` / + `missingUserIdentity` / `serverRejected`),而不是拆成 7 + 2 + 5 个顶层变体。 +- 前端 `switch (error.type)` 必须列全顶层变体:无字段变体直接取本 catch 的固定文案;带载荷变体先 `as` + 取自己的具名载荷类型,可枚举的细分再 `switch (payload.reason)` 在**类型化**的 `reason` 上分流(等价于 + Java 的嵌套 `switch`,仍不碰文案)。顶层与 `reason` 的 `default` 都用 `expectNever`,漏接变体或漏接 + `reason` 都是**编译错误**。**不假设所有变体都有同一个字段**,也不做任何文案匹配。 +- Rust **不预拼用户可见文案**:载荷只装原始事实(类型化的 `reason`、服务端 400 的原文 `serverMessage`、 + HTTP 状态码、本机 IO 的 `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch + 分支按当前操作拼接。 - `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。 这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新约定。 - 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用 diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index 2cacb44db..273ddb3a6 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -34,8 +34,10 @@ ts-rs 已经把 `ClientAuthError` 生成成判别联合(`src/services/generate 时的显式入参决定;catch 里 `error.error as ClientAuthError` 直接分流。 - 只有带载荷的变体才有具名载荷类型:无字段变体在 ts-rs 里就是 `{ type: 'x' }`,不生成文件; - 有字段的变体才生成 `X.ts`。这些具名类型是 §3 每个带载荷 `case` 里 `as X` 的目标,也正是 - "不要假设所有变体字段相同"的落点——没有字段可读的变体不需要、也不允许硬造一个空载荷类型。 + 有字段的变体才生成 `X.ts`(可枚举的细分 `reason` 字段自己也是生成的枚举,如 + `ServerAddressReason.ts` / `AuthNetworkReason.ts` / `AuthResponseInvalidReason.ts`)。这些具名类型是 §3 + 每个带载荷 `case` 里 `as X` 的目标,也正是"不要假设所有变体字段相同"的落点——没有字段可读的变体 + 不需要、也不允许硬造一个空载荷类型。 ### 2. 一个包装函数:`invokeClientAuth` @@ -82,23 +84,33 @@ catch (error) { break; } // ... 每个业务 / 会话变体一个分支 - case 'authNetworkTimeout': - case 'authNetworkUnreachable': - // ... 系统变体逐个列出后原样抛出 + case 'authNetworkFailure': { + const payload = failure as AuthNetworkFailure; + // 可枚举的细分在类型化 reason 上再分流,仍然不碰文案。 + switch (payload.reason) { + case 'timeout': + case 'unreachable': + break; + default: + expectNever(payload.reason); + } throw error; + } + // ... 其余系统变体逐个列出后原样抛出 default: expectNever(failure); } } ``` -- **每个带载荷的业务 / 会话 `case` 用 `as` 取自己的具名载荷类型**,再读它自己的字段;无字段的 - `case` 直接用本 catch 的固定文案。不写跨变体的通用读取,也不让 Rust 预拼上下文。前缀取自 - 当前 catch 的操作语义(登录、发码、启动检查各自可以不同),等价于 Java 的 `catch (PasswordLoginRejected e)`。 - 系统变体不读载荷(调用方只负责原样抛出),但变体名必须逐个列出,`default` 的 `expectNever` 才成立。 -- 业务 / 会话变体:Rust 只给可判定事实(无字段变体连字段都没有;带载荷变体给 `serverMessage` / - `status` / `detail`),调用方在自己的 catch 里补上本次操作的上下文前缀(例如「服务器地址非法: - 远程地址必须使用 https」、「登录失败: 密码长度需要在 6 到 128 位之间」)。 +- **每个带载荷的业务 / 会话 `case` 用 `as` 取自己的具名载荷类型**,再读它自己的字段;字段是可枚举的 + 细分 `reason` 时,再 `switch (payload.reason)` 在类型化枚举上分流。无字段的 `case` 直接用本 catch 的 + 固定文案。不写跨变体的通用读取,也不让 Rust 预拼上下文。前缀取自当前 catch 的操作语义(登录、发码、 + 启动检查各自可以不同),等价于 Java 的 `catch (PasswordLoginRejected e)`。系统变体不读载荷(调用方 + 只负责原样抛出),但变体名必须逐个列出,`default` 的 `expectNever` 才成立。 +- 业务 / 会话变体:Rust 只给可判定事实(无字段变体连字段都没有;带载荷变体给类型化 `reason` / + `serverMessage` / `status` / `detail`),调用方在自己的 catch 里补上本次操作的上下文前缀(例如 + 「服务器地址非法: 远程地址必须使用 https」、「登录失败: 密码长度需要在 6 到 128 位之间」)。 - 系统变体:调用方处理不了,**原样 `throw`**。`onSubmit` / `onClick` 这类 `void` 掉的 handler 抛出的拒绝最终以 `unhandledrejection` 结算,由全局 handler 交给错误池。 - `default: expectNever(failure)`(`expectNever(value: never)`)让"Rust 加了变体而这里 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a0cfb2889..308b6512c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,14 +2,14 @@ ## 2026-10-01 AGC 命令错误结构化与错误报告口径 -- 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端只按 `type` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。 +- 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端先按 `type` 选类别、可枚举细分再按类型化 `reason` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。 - 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文交给错误池(`ClientAuthErrorWrapper` 承载 `source/action/page`,`captureClientError` 用 `instanceof` 解包),`window.onerror` / `unhandledrejection` 只兜底没人接手的错误;408/5xx/网络的判定由调用方在 catch 里做(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。 - 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径改造或删除留在后续变更。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthErrorWrapper.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 有字段变体的载荷文件)。 -- 决策(补充):TS 形状**只有带载荷的变体才有具名载荷类型**——无字段变体在 ts-rs 里就是 `{ type: 'x' }`,有字段的变体是 newtype 变体持有同名 `#[ts(export)]` 结构体,生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`;前端 `switch (error.type)` 的无字段分支用固定文案,带载荷分支先 `as X` 再读它自己的字段。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 +- 决策(补充):TS 形状**只有带载荷的变体才有具名载荷类型**——无字段变体在 ts-rs 里就是 `{ type: 'x' }`,有字段的变体是 newtype 变体持有同名 `#[ts(export)]` 结构体,生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`;可枚举的细分原因是类型化枚举字段(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`),不是字符串、也不各拆一个顶层变体。前端 `switch (error.type)` 的无字段分支用固定文案,带载荷分支先 `as X` 再读它自己的字段,`reason` 是枚举时再 `switch (payload.reason)`(`default` 同样用 `expectNever`)。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 - 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 -- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `serverMessage` / `status` / `detail` 拼上本次操作的上下文前缀;Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 +- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `reason` / `serverMessage` / `status` / `detail` 拼上本次操作的上下文前缀(`reason` 是枚举时再 `switch (payload.reason)`);Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 - 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8e6521e58..558560d9f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -8,7 +8,7 @@ - **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 - **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientAuthErrorWrapper`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次(`source` 取全局 `unhandledrejection` handler 的显式入参;载体不再携带 `action`)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 -- **形状约定**:`ClientAuthError` 的变体拍平不嵌套——无字段变体在 TS 里就是 `{ type: 'x' }`;带载荷变体是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error; switch (failure.type)`,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as X` 再读自己的 `serverMessage` / `status` / `detail`,`default: expectNever(failure)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier;ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`。 +- **形状约定**:`ClientAuthError` 顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段——无字段变体在 TS 里就是 `{ type: 'x' }`;带载荷变体是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体),细分原因枚举(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`)同样由 ts-rs 生成。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error; switch (failure.type)`,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as X` 再读自己的字段,`reason` 是枚举时再 `switch (payload.reason)`,两处 `default` 都用 `expectNever` 保证漏接编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier;ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`(本次 `AuthResponseServerRejected.ts` 就是这样删的)。 - **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index c3995cf25..2930da8e5 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -11,7 +11,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientAuthErrorWrapper`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 -- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;变体拍平不嵌套,无字段变体生成 `{ type }`,带载荷变体生成 `{ type } & 载荷类型`),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`、`Error.message` 留空),不新增手写错误类;判定只写在 catch 子句里,无字段变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名载荷类型、再用它自己的 `serverMessage` / `status` / `detail` 拼上本次操作的上下文前缀,`default` 用 `expectNever` 在编译期挡住漏接变体。 +- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段,无字段变体生成 `{ type }`,带载荷变体生成 `{ type } & 载荷类型`),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`、`Error.message` 留空),不新增手写错误类;判定只写在 catch 子句里,无字段变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名载荷类型(`reason` 是枚举时再 `switch (payload.reason)`)、再用它自己的字段拼上本次操作的上下文前缀,`default` 用 `expectNever` 在编译期挡住漏接变体。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 - 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 -- 2.52.0 From 254f2767ccdb8b4e0eb2d08873bcd44bcc375086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:42:17 +0800 Subject: [PATCH 41/70] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=A1=B6=E5=B1=82=E7=B1=BB=E5=88=AB=E5=8A=A0?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=8C=96reason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_error.rs:顶层从拍平变体收敛为类别变体,服务地址/网络失败/响应契约破损各占一个变体,不再拆成 7+2+5 个 - auth_error.rs:新增 ServerAddressReason、AuthNetworkReason、AuthResponseInvalidReason 三个 ts-rs 枚举,payload 改持类型化 reason - auth_session.rs:校验与映射改用 server_address_rejected / response_invalid 收口,按类型化 reason 构造 --- .../src-tauri/src/auth_error.rs | 217 +++++++++++------- .../src-tauri/src/auth_session.rs | 130 +++++++---- 2 files changed, 210 insertions(+), 137 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index bc602dfa8..8d3a9f55d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -2,19 +2,13 @@ //! //! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。 //! -//! 变体一律拍平在同一层,不嵌套子枚举:可枚举的事实各自成变体(例如服务地址校验的 7 种失败、 -//! 网络超时 / 不可达、响应契约的 5 种破损),类型本身说不出来的事实才进载荷(服务端原文、HTTP -//! 状态码、本机 IO 明细)。internally tagged 下无字段变体序列化成 `{ "type": "x" }`,带载荷的 -//! 是 `{ "type": "x", ... }`;ts-rs 生成 `{ type: 'x' }` 或 `{ type: 'x' } & X`,前端每个分支 -//! 拿到的形状与 Rust 一致。 +//! 顶层只放**调用方要分流的类别**;同一类里可枚举的细分原因收进**类型化 `reason` 字段**, +//! 而不是各拆一个变体,也不是字符串。例如服务地址校验的 7 种失败、网络失败的超时/不可达、 +//! 响应契约的 5 种破损各自只占一个变体:前端 `switch (failure.type)` 选到类别后,再用 +//! `switch (payload.reason)` 在**类型化**的细分上分流,仍然不碰文案。 //! -//! Rust **不预拼用户可见文案**:载荷只存原始事实(服务端 400 的原文、本机 IO 的 `detail`), -//! 前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。 -//! -//! 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(平台 `AppError.code` 仍是 -//! 通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(例如 -//! [`PasswordLoginRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 `401/403` 是 -//! "登录态失效",登录路由的 `401` 是用户可修正的输入问题,这个区分由变体承担。 +//! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码、本机 IO 的 +//! `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。 use serde::Serialize; use ts_rs::TS; @@ -25,21 +19,9 @@ use ts_rs::TS; #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) enum ClientAuthError { // ---- 业务:用户自己能改,调用方给提示,不上报 ---- - /// 服务地址为空或超长。 - ServerAddressEmptyOrTooLong, - /// 服务地址不是合法 URL。 - ServerAddressNotAUrl, - /// 服务地址带用户名 / 密码。 - ServerAddressHasCredentials, - /// 服务地址带路径、查询或 fragment。 - ServerAddressHasPathOrQueryOrFragment, - /// 非本机服务地址不是 HTTPS。 - ServerAddressNotHttps, - /// 服务地址 scheme 不是 http(s)。 - ServerAddressUnsupportedScheme, - /// 发布构建里服务地址不在当前构建渠道范围内。 - ServerAddressOutsideChannel, - /// 本地前置校验:手机号为空、超长或不是纯数字。 + /// 服务地址校验失败(含渠道范围门禁),细分原因见 [`ServerAddressReason`]。 + ServerAddressRejected(ServerAddressRejected), + /// 本地前置校验:手机号为空、超长或不是纯数字(校验器只回 bool,没有更细的事实)。 PhoneNumberInvalid, /// 本地前置校验:密码为空。 PasswordMissing, @@ -65,24 +47,14 @@ pub(crate) enum ClientAuthError { PermissionDenied, // ---- 系统:调用方处理不了,带上下文重抛 ---- - /// 连接登录服务超时。 - AuthNetworkTimeout, - /// 连接登录服务失败(DNS / 连接被拒等)。 - AuthNetworkUnreachable, + /// 连接登录服务的传输层失败,细分原因见 [`AuthNetworkReason`]。 + AuthNetworkFailure(AuthNetworkFailure), /// 登录服务 5xx。 AuthServiceUnavailable(AuthServiceUnavailable), /// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 UnexpectedRejection(UnexpectedRejection), - /// 登录服务响应不是合法 JSON。 - AuthResponseNotJson, - /// 登录服务响应不是预期结构(缺字段 / 类型不符)。 - AuthResponseInvalidBody, - /// 登录 / 续期响应没有下发新的续期凭据。 - AuthResponseMissingRefreshCookie, - /// 登录响应没有带上会话主体(用户身份)。 - AuthResponseMissingUserIdentity, - /// 登录服务在响应体里显式拒绝(`ok: false`)。 - AuthResponseServerRejected(AuthResponseServerRejected), + /// 登录服务响应的契约破损,细分原因见 [`AuthResponseInvalidReason`]。 + AuthResponseInvalid(AuthResponseInvalid), /// 本机登录凭据文件读写失败。 ClientSessionPersistFailed(ClientSessionPersistFailed), /// 本机运行时会话安装 / 清理失败。 @@ -91,8 +63,67 @@ pub(crate) enum ClientAuthError { AuthClientInitFailed, } +// ---- 可枚举的细分原因:类型化字段,不是字符串 ---- + +/// 服务地址校验失败的具体原因。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) enum ServerAddressReason { + /// 为空或超长。 + EmptyOrTooLong, + /// 不是合法 URL。 + NotAUrl, + /// 带用户名 / 密码。 + HasCredentials, + /// 带路径、查询或 fragment。 + HasPathOrQueryOrFragment, + /// 非本机地址不是 HTTPS。 + NotHttps, + /// scheme 不是 http(s)。 + UnsupportedScheme, + /// 发布构建里不在当前构建渠道范围内。 + OutsideChannel, +} + +/// 连接登录服务失败的具体原因。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) enum AuthNetworkReason { + /// 超时。 + Timeout, + /// DNS / 连接被拒 / 读响应失败等。 + Unreachable, +} + +/// 登录响应契约破损的具体原因。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) enum AuthResponseInvalidReason { + /// 不是合法 JSON。 + NotJson, + /// 不是预期结构(缺字段 / 类型不符 / 凭据格式无效)。 + InvalidBody, + /// 没有下发新的续期凭据。 + MissingRefreshCookie, + /// 没有带上会话主体(用户身份)。 + MissingUserIdentity, + /// 响应体里显式拒绝(`ok: false`)。 + ServerRejected, +} + // ---- 载荷:只装类型说不出来的事实 ---- +/// 服务地址被拒的具体原因。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct ServerAddressRejected { + pub(crate) reason: ServerAddressReason, +} + /// `/api/auth/entry` 返回 400 时服务端给的原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] @@ -120,8 +151,16 @@ pub(crate) struct PhoneCodeLoginRejected { pub(crate) server_message: Option, } +/// 连接登录服务的传输层失败原因。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct AuthNetworkFailure { + pub(crate) reason: AuthNetworkReason, +} + /// 登录服务 5xx 的状态码。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct AuthServiceUnavailable { @@ -137,11 +176,13 @@ pub(crate) struct UnexpectedRejection { pub(crate) server_message: Option, } -/// 登录服务在响应体里显式拒绝时给的原文。 +/// 登录响应契约破损的原因与服务端原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct AuthResponseServerRejected { +pub(crate) struct AuthResponseInvalid { + pub(crate) reason: AuthResponseInvalidReason, + /// 只有 [`AuthResponseInvalidReason::ServerRejected`] 可能带服务端原文;其余是 `None`。 pub(crate) server_message: Option, } @@ -177,32 +218,10 @@ mod tests { fn wire_variant_names_are_the_frontend_dispatch_keys() { let cases = [ ( - ClientAuthError::ServerAddressEmptyOrTooLong, - "serverAddressEmptyOrTooLong", - ), - ( - ClientAuthError::ServerAddressNotAUrl, - "serverAddressNotAUrl", - ), - ( - ClientAuthError::ServerAddressHasCredentials, - "serverAddressHasCredentials", - ), - ( - ClientAuthError::ServerAddressHasPathOrQueryOrFragment, - "serverAddressHasPathOrQueryOrFragment", - ), - ( - ClientAuthError::ServerAddressNotHttps, - "serverAddressNotHttps", - ), - ( - ClientAuthError::ServerAddressUnsupportedScheme, - "serverAddressUnsupportedScheme", - ), - ( - ClientAuthError::ServerAddressOutsideChannel, - "serverAddressOutsideChannel", + ClientAuthError::ServerAddressRejected(ServerAddressRejected { + reason: ServerAddressReason::NotHttps, + }), + "serverAddressRejected", ), (ClientAuthError::PhoneNumberInvalid, "phoneNumberInvalid"), (ClientAuthError::PasswordMissing, "passwordMissing"), @@ -233,10 +252,11 @@ mod tests { (ClientAuthError::SmsCodeRejected, "smsCodeRejected"), (ClientAuthError::SessionInvalidated, "sessionInvalidated"), (ClientAuthError::PermissionDenied, "permissionDenied"), - (ClientAuthError::AuthNetworkTimeout, "authNetworkTimeout"), ( - ClientAuthError::AuthNetworkUnreachable, - "authNetworkUnreachable", + ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { + reason: AuthNetworkReason::Timeout, + }), + "authNetworkFailure", ), ( ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }), @@ -249,24 +269,12 @@ mod tests { }), "unexpectedRejection", ), - (ClientAuthError::AuthResponseNotJson, "authResponseNotJson"), ( - ClientAuthError::AuthResponseInvalidBody, - "authResponseInvalidBody", - ), - ( - ClientAuthError::AuthResponseMissingRefreshCookie, - "authResponseMissingRefreshCookie", - ), - ( - ClientAuthError::AuthResponseMissingUserIdentity, - "authResponseMissingUserIdentity", - ), - ( - ClientAuthError::AuthResponseServerRejected(AuthResponseServerRejected { + ClientAuthError::AuthResponseInvalid(AuthResponseInvalid { + reason: AuthResponseInvalidReason::ServerRejected, server_message: None, }), - "authResponseServerRejected", + "authResponseInvalid", ), ( ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { @@ -301,6 +309,25 @@ mod tests { assert_eq!(value, serde_json::json!({ "type": "loginCodeMissing" })); } + #[test] + fn reason_fields_are_typed_enums_not_strings() { + let rejected = serde_json::to_value(ClientAuthError::ServerAddressRejected( + ServerAddressRejected { + reason: ServerAddressReason::HasPathOrQueryOrFragment, + }, + )) + .expect("serialize auth error"); + assert_eq!(rejected["type"], "serverAddressRejected"); + assert_eq!(rejected["reason"], "hasPathOrQueryOrFragment"); + + let network = + serde_json::to_value(ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { + reason: AuthNetworkReason::Unreachable, + })) + .expect("serialize auth error"); + assert_eq!(network["reason"], "unreachable"); + } + #[test] fn payload_variants_keep_machine_facts_and_server_text() { let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable( @@ -319,6 +346,15 @@ mod tests { assert_eq!(rejection["status"], 409); assert_eq!(rejection["serverMessage"], "冲突"); + let invalid = + serde_json::to_value(ClientAuthError::AuthResponseInvalid(AuthResponseInvalid { + reason: AuthResponseInvalidReason::ServerRejected, + server_message: Some("登录服务请求失败".to_string()), + })) + .expect("serialize auth error"); + assert_eq!(invalid["reason"], "serverRejected"); + assert_eq!(invalid["serverMessage"], "登录服务请求失败"); + let persist = serde_json::to_value(ClientAuthError::ClientSessionPersistFailed( ClientSessionPersistFailed { detail: "磁盘只读".to_string(), @@ -333,6 +369,9 @@ mod tests { assert!(ClientAuthError::SessionInvalidated.is_authority_failure()); assert!(ClientAuthError::PermissionDenied.is_authority_failure()); assert!(!ClientAuthError::PhoneOrPasswordMismatch.is_authority_failure()); - assert!(!ClientAuthError::AuthNetworkTimeout.is_authority_failure()); + assert!(!ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { + reason: AuthNetworkReason::Timeout, + }) + .is_authority_failure()); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 5625e958d..416566950 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -5,9 +5,10 @@ //! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 use crate::auth_error::{ - AuthResponseServerRejected, AuthServiceUnavailable, ClientAuthError, - ClientSessionPersistFailed, PasswordLoginRejected, PhoneCodeLoginRejected, - RuntimeSessionInstallFailed, SendCodeRejected, UnexpectedRejection, + AuthNetworkFailure, AuthNetworkReason, AuthResponseInvalid, AuthResponseInvalidReason, + AuthServiceUnavailable, ClientAuthError, ClientSessionPersistFailed, PasswordLoginRejected, + PhoneCodeLoginRejected, RuntimeSessionInstallFailed, SendCodeRejected, ServerAddressReason, + ServerAddressRejected, UnexpectedRejection, }; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; @@ -241,6 +242,19 @@ fn runtime_session_error(message: impl Into) -> ClientAuthError { }) } +/// 服务地址校验失败统一收口成带类型化原因的变体。 +fn server_address_rejected(reason: ServerAddressReason) -> ClientAuthError { + ClientAuthError::ServerAddressRejected(ServerAddressRejected { reason }) +} + +/// 响应契约破损(不含服务端原文)统一收口。 +fn response_invalid(reason: AuthResponseInvalidReason) -> ClientAuthError { + ClientAuthError::AuthResponseInvalid(AuthResponseInvalid { + reason, + server_message: None, + }) +} + /// 校验并归一化平台服务 origin。 /// /// 只接受纯 origin:不允许凭据、路径、查询或 fragment;非本机必须 HTTPS。发布构建额外 @@ -250,28 +264,35 @@ fn runtime_session_error(message: impl Into) -> ClientAuthError { pub(crate) fn validate_client_api_base_url(value: &str) -> Result { let trimmed = value.trim().trim_end_matches('/'); if trimmed.is_empty() || trimmed.chars().count() > MAX_ORIGIN_CHARS { - return Err(ClientAuthError::ServerAddressEmptyOrTooLong); + return Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong)); } - let parsed = Url::parse(trimmed).map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; + let parsed = + Url::parse(trimmed).map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?; if !parsed.username().is_empty() || parsed.password().is_some() { - return Err(ClientAuthError::ServerAddressHasCredentials); + return Err(server_address_rejected(ServerAddressReason::HasCredentials)); } if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some() { - return Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment); + return Err(server_address_rejected( + ServerAddressReason::HasPathOrQueryOrFragment, + )); } let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase(); match parsed.scheme() { "https" => {} "http" if matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") => {} - "http" => return Err(ClientAuthError::ServerAddressNotHttps), - _ => return Err(ClientAuthError::ServerAddressUnsupportedScheme), + "http" => return Err(server_address_rejected(ServerAddressReason::NotHttps)), + _ => { + return Err(server_address_rejected( + ServerAddressReason::UnsupportedScheme, + )) + } } if !cfg!(debug_assertions) && !matches!(trimmed, RELEASE_ORIGIN | DEVELOPMENT_ORIGIN) && !matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") { - return Err(ClientAuthError::ServerAddressOutsideChannel); + return Err(server_address_rejected(ServerAddressReason::OutsideChannel)); } Ok(trimmed.to_string()) } @@ -402,11 +423,11 @@ fn current_session_origin() -> Option { fn endpoint(origin: &str, route: &str) -> Result { let mut url = Url::parse(&format!("{}/", origin.trim_end_matches('/'))) - .map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; + .map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?; { let mut segments = url .path_segments_mut() - .map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; + .map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?; for segment in route.trim_start_matches('/').split('/') { if segment.is_empty() { continue; @@ -426,11 +447,12 @@ fn build_client() -> Result { } fn network_error(error: &reqwest::Error) -> ClientAuthError { - if error.is_timeout() { - ClientAuthError::AuthNetworkTimeout + let reason = if error.is_timeout() { + AuthNetworkReason::Timeout } else { - ClientAuthError::AuthNetworkUnreachable - } + AuthNetworkReason::Unreachable + }; + ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { reason }) } fn error_message(body: &str) -> Option { @@ -536,14 +558,13 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA } fn response_data(body: &str) -> Result { - let value: Value = - serde_json::from_str(body).map_err(|_| ClientAuthError::AuthResponseNotJson)?; + let value: Value = serde_json::from_str(body) + .map_err(|_| response_invalid(AuthResponseInvalidReason::NotJson))?; if value.get("ok").and_then(Value::as_bool) == Some(false) { - return Err(ClientAuthError::AuthResponseServerRejected( - AuthResponseServerRejected { - server_message: error_message(body), - }, - )); + return Err(ClientAuthError::AuthResponseInvalid(AuthResponseInvalid { + reason: AuthResponseInvalidReason::ServerRejected, + server_message: error_message(body), + })); } Ok(value.get("data").cloned().unwrap_or(value)) } @@ -620,15 +641,18 @@ async fn request_auth( CookiePolicy::Ignore => None, }; // 拿到状态码后读 body 失败属于传输层故障:按网络不可达上报,不猜契约。 - let text = response - .text() - .await - .map_err(|_| ClientAuthError::AuthNetworkUnreachable)?; + let text = response.text().await.map_err(|_| { + ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { + reason: AuthNetworkReason::Unreachable, + }) + })?; if !status.is_success() { return Err(map_auth_failure(status, &text, auth_route(route))); } if matches!(policy, CookiePolicy::Require) && captured.is_none() { - return Err(ClientAuthError::AuthResponseMissingRefreshCookie); + return Err(response_invalid( + AuthResponseInvalidReason::MissingRefreshCookie, + )); } Ok(AuthResponse { data: response_data(&text)?, @@ -667,7 +691,7 @@ async fn commit_authenticated_session( identity_change: bool, ) -> Result { if token.chars().count() > MAX_SECRET_CHARS { - return Err(ClientAuthError::AuthResponseInvalidBody); + return Err(response_invalid(AuthResponseInvalidReason::InvalidBody)); } // 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。 let (user_id, known_user) = identity.resolve()?; @@ -758,7 +782,9 @@ fn phone_is_valid(phone: &str) -> bool { fn validated_session_user_id(user_id: &str) -> Result { let user_id = user_id.trim(); if user_id.is_empty() { - return Err(ClientAuthError::AuthResponseMissingUserIdentity); + return Err(response_invalid( + AuthResponseInvalidReason::MissingUserIdentity, + )); } Ok(user_id.to_string()) } @@ -790,7 +816,7 @@ async fn fetch_current_user( ) .await?; let me: MeResponse = serde_json::from_value(response.data) - .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; Ok(me.user) } @@ -860,11 +886,11 @@ async fn refresh_session_inner( } }; let token: TokenResponse = serde_json::from_value(refreshed.data) - .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; let install_token = token.token.clone(); let new_cookie = refreshed .refresh_cookie - .ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?; + .ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?; commit_authenticated_session( app, &session.api_base_url, @@ -1009,7 +1035,7 @@ pub(crate) async fn send_client_phone_login_code( ) .await?; let payload: SendCodeResponse = serde_json::from_value(response.data) - .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; Ok(ClientLoginCodeView { cooldown_seconds: payload.cooldown_seconds, expires_in_seconds: payload.expires_in_seconds, @@ -1022,10 +1048,10 @@ async fn complete_login( response: AuthResponse, ) -> Result { let payload: TokenUserResponse = serde_json::from_value(response.data) - .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; let cookie = response .refresh_cookie - .ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?; + .ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?; let user = commit_authenticated_session( app, origin, @@ -1161,45 +1187,53 @@ mod tests { #[test] fn origins_reject_credentials_paths_and_plain_remote_http() { - // 每种拒绝各成一个变体:前端只按变体选提示,不读任何文案。 + // 顶层只占一个变体,细分走类型化 reason:前端 switch(reason) 仍然按类型分流,不读文案。 assert_eq!( validate_client_api_base_url("https://user:pass@example.com"), - Err(ClientAuthError::ServerAddressHasCredentials) + Err(server_address_rejected(ServerAddressReason::HasCredentials)) ); assert_eq!( validate_client_api_base_url("https://example.com/api"), - Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + Err(server_address_rejected( + ServerAddressReason::HasPathOrQueryOrFragment + )) ); assert_eq!( validate_client_api_base_url("https://example.com?a=1"), - Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + Err(server_address_rejected( + ServerAddressReason::HasPathOrQueryOrFragment + )) ); assert_eq!( validate_client_api_base_url("https://example.com#frag"), - Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + Err(server_address_rejected( + ServerAddressReason::HasPathOrQueryOrFragment + )) ); assert_eq!( validate_client_api_base_url("http://example.com"), - Err(ClientAuthError::ServerAddressNotHttps) + Err(server_address_rejected(ServerAddressReason::NotHttps)) ); assert_eq!( validate_client_api_base_url("ftp://example.com"), - Err(ClientAuthError::ServerAddressUnsupportedScheme) + Err(server_address_rejected( + ServerAddressReason::UnsupportedScheme + )) ); assert_eq!( validate_client_api_base_url(" "), - Err(ClientAuthError::ServerAddressEmptyOrTooLong) + Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong)) ); assert_eq!( validate_client_api_base_url("不是地址"), - Err(ClientAuthError::ServerAddressNotAUrl) + Err(server_address_rejected(ServerAddressReason::NotAUrl)) ); assert_eq!( validate_client_api_base_url(&format!( "https://example.com/{}", "a".repeat(MAX_ORIGIN_CHARS) )), - Err(ClientAuthError::ServerAddressEmptyOrTooLong) + Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong)) ); assert_eq!( validate_client_api_base_url("http://127.0.0.1:10001").expect("loopback debug"), @@ -1521,7 +1555,7 @@ mod tests { // 是内部不变式,不是用户能理解的登录失败原因。 assert_eq!( validated_session_user_id(" ").unwrap_err(), - ClientAuthError::AuthResponseMissingUserIdentity + response_invalid(AuthResponseInvalidReason::MissingUserIdentity) ); } @@ -1580,7 +1614,7 @@ mod tests { // 没有主体的登录响应在写凭据文件之前就以登录语义失败关闭。 assert_eq!( SessionIdentity::Login(payload.user).resolve().unwrap_err(), - ClientAuthError::AuthResponseMissingUserIdentity + response_invalid(AuthResponseInvalidReason::MissingUserIdentity) ); // 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。 assert!(SessionIdentity::Persisted(String::new()).resolve().is_err()); -- 2.52.0 From d5516d43fa0f14c27383cfc7389e1bca8016850d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:42:26 +0800 Subject: [PATCH 42/70] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90ts-rs=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClientAuthError 联合收敛为类别变体 + 类型化 reason,无字段变体仍是 { type } - 新增 AuthNetworkFailure/AuthNetworkReason/AuthResponseInvalid/AuthResponseInvalidReason/ServerAddressRejected/ServerAddressReason - 删除拍平变体后不再生成的 AuthResponseServerRejected 载荷文件 --- .../services/generated/AuthNetworkFailure.ts | 7 +++++++ .../services/generated/AuthNetworkReason.ts | 6 ++++++ .../services/generated/AuthResponseInvalid.ts | 13 ++++++++++++ .../generated/AuthResponseInvalidReason.ts | 11 ++++++++++ .../generated/AuthResponseServerRejected.ts | 6 ------ .../src/services/generated/ClientAuthError.ts | 21 ++++++------------- .../services/generated/ServerAddressReason.ts | 13 ++++++++++++ .../generated/ServerAddressRejected.ts | 7 +++++++ 8 files changed, 63 insertions(+), 21 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthNetworkFailure.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthNetworkReason.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalidReason.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthResponseServerRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/ServerAddressReason.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkFailure.ts b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkFailure.ts new file mode 100644 index 000000000..a852d6759 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkFailure.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AuthNetworkReason } from './AuthNetworkReason'; + +/** + * 连接登录服务的传输层失败原因。 + */ +export type AuthNetworkFailure = { reason: AuthNetworkReason }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkReason.ts b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkReason.ts new file mode 100644 index 000000000..73f2a73f9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkReason.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 连接登录服务失败的具体原因。 + */ +export type AuthNetworkReason = 'timeout' | 'unreachable'; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts new file mode 100644 index 000000000..3ecbcc333 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AuthResponseInvalidReason } from './AuthResponseInvalidReason'; + +/** + * 登录响应契约破损的原因与服务端原文。 + */ +export type AuthResponseInvalid = { + reason: AuthResponseInvalidReason; + /** + * 只有 [`AuthResponseInvalidReason::ServerRejected`] 可能带服务端原文;其余是 `None`。 + */ + serverMessage: string | null; +}; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalidReason.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalidReason.ts new file mode 100644 index 000000000..cd7dd488f --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalidReason.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 登录响应契约破损的具体原因。 + */ +export type AuthResponseInvalidReason = + | 'notJson' + | 'invalidBody' + | 'missingRefreshCookie' + | 'missingUserIdentity' + | 'serverRejected'; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseServerRejected.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseServerRejected.ts deleted file mode 100644 index 15034b0f3..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/AuthResponseServerRejected.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 登录服务在响应体里显式拒绝时给的原文。 - */ -export type AuthResponseServerRejected = { serverMessage: string | null }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts index 63ee7887e..6cb924232 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -1,24 +1,20 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AuthResponseServerRejected } from './AuthResponseServerRejected'; +import type { AuthNetworkFailure } from './AuthNetworkFailure'; +import type { AuthResponseInvalid } from './AuthResponseInvalid'; import type { AuthServiceUnavailable } from './AuthServiceUnavailable'; import type { ClientSessionPersistFailed } from './ClientSessionPersistFailed'; import type { PasswordLoginRejected } from './PasswordLoginRejected'; import type { PhoneCodeLoginRejected } from './PhoneCodeLoginRejected'; import type { RuntimeSessionInstallFailed } from './RuntimeSessionInstallFailed'; import type { SendCodeRejected } from './SendCodeRejected'; +import type { ServerAddressRejected } from './ServerAddressRejected'; import type { UnexpectedRejection } from './UnexpectedRejection'; /** * 变体名就是线上的分流键(`type`),带载荷的变体持有同名载荷类型。 */ export type ClientAuthError = - | { type: 'serverAddressEmptyOrTooLong' } - | { type: 'serverAddressNotAUrl' } - | { type: 'serverAddressHasCredentials' } - | { type: 'serverAddressHasPathOrQueryOrFragment' } - | { type: 'serverAddressNotHttps' } - | { type: 'serverAddressUnsupportedScheme' } - | { type: 'serverAddressOutsideChannel' } + | ({ type: 'serverAddressRejected' } & ServerAddressRejected) | { type: 'phoneNumberInvalid' } | { type: 'passwordMissing' } | { type: 'loginCodeMissing' } @@ -30,15 +26,10 @@ export type ClientAuthError = | { type: 'smsCodeRejected' } | { type: 'sessionInvalidated' } | { type: 'permissionDenied' } - | { type: 'authNetworkTimeout' } - | { type: 'authNetworkUnreachable' } + | ({ type: 'authNetworkFailure' } & AuthNetworkFailure) | ({ type: 'authServiceUnavailable' } & AuthServiceUnavailable) | ({ type: 'unexpectedRejection' } & UnexpectedRejection) - | { type: 'authResponseNotJson' } - | { type: 'authResponseInvalidBody' } - | { type: 'authResponseMissingRefreshCookie' } - | { type: 'authResponseMissingUserIdentity' } - | ({ type: 'authResponseServerRejected' } & AuthResponseServerRejected) + | ({ type: 'authResponseInvalid' } & AuthResponseInvalid) | ({ type: 'clientSessionPersistFailed' } & ClientSessionPersistFailed) | ({ type: 'runtimeSessionInstallFailed' } & RuntimeSessionInstallFailed) | { type: 'authClientInitFailed' }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ServerAddressReason.ts b/apps/ai-game-creator-shell/src/services/generated/ServerAddressReason.ts new file mode 100644 index 000000000..c60ebb82b --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ServerAddressReason.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 服务地址校验失败的具体原因。 + */ +export type ServerAddressReason = + | 'emptyOrTooLong' + | 'notAUrl' + | 'hasCredentials' + | 'hasPathOrQueryOrFragment' + | 'notHttps' + | 'unsupportedScheme' + | 'outsideChannel'; diff --git a/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts b/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts new file mode 100644 index 000000000..fdcc96aa0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ServerAddressReason } from './ServerAddressReason'; + +/** + * 服务地址被拒的具体原因。 + */ +export type ServerAddressRejected = { reason: ServerAddressReason }; -- 2.52.0 From e955d799849166b9b4c1c84b5777f87145ac4cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:42:30 +0800 Subject: [PATCH 43/70] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=8C=89=E7=B1=BB=E5=88=AB=E5=8F=98=E4=BD=93=E4=B8=8E=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=8C=96reason=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthenticatedClient 三个 catch 把 authNetworkFailure/authResponseInvalid 收成一个分支,服务地址 7 种失败与响应契约 5 种破损改为在 payload.reason 上二次 switch - 每个带载荷分支先 as 取自己的具名类型,reason/default 用 expectNever 让漏接 reason 也在编译期失败 - 系统变体仍原样 throw,经全局 unhandledrejection 交给错误池 --- .../src/app/AuthenticatedClient.tsx | 213 +++++++++--------- 1 file changed, 101 insertions(+), 112 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index a03f53d93..825c7e98d 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -38,6 +38,7 @@ import { import type { PasswordLoginRejected } from '../services/generated/PasswordLoginRejected'; import type { PhoneCodeLoginRejected } from '../services/generated/PhoneCodeLoginRejected'; import type { SendCodeRejected } from '../services/generated/SendCodeRejected'; +import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected'; import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, @@ -272,44 +273,36 @@ export function AuthenticatedClient({ const failure = error.error; switch (failure.type) { // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 - case 'serverAddressEmptyOrTooLong': { - const text = '服务器地址非法: 不能为空或超长'; - setAuthCheckError(text); - setLoginStatus(text); - break; - } - case 'serverAddressNotAUrl': { - const text = '服务器地址非法: 不是合法地址'; - setAuthCheckError(text); - setLoginStatus(text); - break; - } - case 'serverAddressHasCredentials': { - const text = '服务器地址非法: 不能包含用户名或密码'; - setAuthCheckError(text); - setLoginStatus(text); - break; - } - case 'serverAddressHasPathOrQueryOrFragment': { - const text = '服务器地址非法: 不能带路径、查询或片段'; - setAuthCheckError(text); - setLoginStatus(text); - break; - } - case 'serverAddressNotHttps': { - const text = '服务器地址非法: 远程地址必须使用 https'; - setAuthCheckError(text); - setLoginStatus(text); - break; - } - case 'serverAddressUnsupportedScheme': { - const text = '服务器地址非法: 只支持 http 或 https'; - setAuthCheckError(text); - setLoginStatus(text); - break; - } - case 'serverAddressOutsideChannel': { - const text = '服务器地址非法: 不在当前构建渠道范围内'; + case 'serverAddressRejected': { + const payload = failure as ServerAddressRejected; + let text: string; + switch (payload.reason) { + case 'emptyOrTooLong': + text = '服务器地址非法: 不能为空或超长'; + break; + case 'notAUrl': + text = '服务器地址非法: 不是合法地址'; + break; + case 'hasCredentials': + text = '服务器地址非法: 不能包含用户名或密码'; + break; + case 'hasPathOrQueryOrFragment': + text = '服务器地址非法: 不能带路径、查询或片段'; + break; + case 'notHttps': + text = '服务器地址非法: 远程地址必须使用 https'; + break; + case 'unsupportedScheme': + text = '服务器地址非法: 只支持 http 或 https'; + break; + case 'outsideChannel': + text = '服务器地址非法: 不在当前构建渠道范围内'; + break; + default: { + expectNever(payload.reason); + text = '服务器地址非法'; + } + } setAuthCheckError(text); setLoginStatus(text); break; @@ -376,15 +369,10 @@ export function AuthenticatedClient({ case 'permissionDenied': break; // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 - case 'authNetworkTimeout': - case 'authNetworkUnreachable': + case 'authNetworkFailure': case 'authServiceUnavailable': case 'unexpectedRejection': - case 'authResponseNotJson': - case 'authResponseInvalidBody': - case 'authResponseMissingRefreshCookie': - case 'authResponseMissingUserIdentity': - case 'authResponseServerRejected': + case 'authResponseInvalid': case 'clientSessionPersistFailed': case 'runtimeSessionInstallFailed': case 'authClientInitFailed': @@ -457,7 +445,8 @@ export function AuthenticatedClient({ * - 承载:命令失败由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`,`error` 就是判别联合, * `failure.type` 是唯一分流键; * - 业务 / 会话变体:无载荷变体用本 catch 的固定文案;带载荷变体先 `as` 取具名类型, - * 再用它自己的 `serverMessage` 拼上下文(Rust 不预拼用户可见文案),永不进错误池; + * 可枚举的细分再用 `switch (payload.reason)` 在类型化 reason 上分流并拼上下文 + * (Rust 不预拼用户可见文案),永不进错误池; * - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池; * - `default: expectNever` 保证 Rust 新增变体时这里编译失败。 * @@ -489,32 +478,37 @@ export function AuthenticatedClient({ const failure = error.error; switch (failure.type) { // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 - case 'serverAddressEmptyOrTooLong': { - setLoginStatus('服务器地址非法: 不能为空或超长'); - break; - } - case 'serverAddressNotAUrl': { - setLoginStatus('服务器地址非法: 不是合法地址'); - break; - } - case 'serverAddressHasCredentials': { - setLoginStatus('服务器地址非法: 不能包含用户名或密码'); - break; - } - case 'serverAddressHasPathOrQueryOrFragment': { - setLoginStatus('服务器地址非法: 不能带路径、查询或片段'); - break; - } - case 'serverAddressNotHttps': { - setLoginStatus('服务器地址非法: 远程地址必须使用 https'); - break; - } - case 'serverAddressUnsupportedScheme': { - setLoginStatus('服务器地址非法: 只支持 http 或 https'); - break; - } - case 'serverAddressOutsideChannel': { - setLoginStatus('服务器地址非法: 不在当前构建渠道范围内'); + case 'serverAddressRejected': { + const payload = failure as ServerAddressRejected; + let text: string; + switch (payload.reason) { + case 'emptyOrTooLong': + text = '服务器地址非法: 不能为空或超长'; + break; + case 'notAUrl': + text = '服务器地址非法: 不是合法地址'; + break; + case 'hasCredentials': + text = '服务器地址非法: 不能包含用户名或密码'; + break; + case 'hasPathOrQueryOrFragment': + text = '服务器地址非法: 不能带路径、查询或片段'; + break; + case 'notHttps': + text = '服务器地址非法: 远程地址必须使用 https'; + break; + case 'unsupportedScheme': + text = '服务器地址非法: 只支持 http 或 https'; + break; + case 'outsideChannel': + text = '服务器地址非法: 不在当前构建渠道范围内'; + break; + default: { + expectNever(payload.reason); + text = '服务器地址非法'; + } + } + setLoginStatus(text); break; } case 'phoneNumberInvalid': { @@ -571,15 +565,10 @@ export function AuthenticatedClient({ break; } // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 - case 'authNetworkTimeout': - case 'authNetworkUnreachable': + case 'authNetworkFailure': case 'authServiceUnavailable': case 'unexpectedRejection': - case 'authResponseNotJson': - case 'authResponseInvalidBody': - case 'authResponseMissingRefreshCookie': - case 'authResponseMissingUserIdentity': - case 'authResponseServerRejected': + case 'authResponseInvalid': case 'clientSessionPersistFailed': case 'runtimeSessionInstallFailed': case 'authClientInitFailed': @@ -657,32 +646,37 @@ export function AuthenticatedClient({ const failure = error.error; switch (failure.type) { // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 - case 'serverAddressEmptyOrTooLong': { - setLoginStatus('服务器地址非法: 不能为空或超长'); - break; - } - case 'serverAddressNotAUrl': { - setLoginStatus('服务器地址非法: 不是合法地址'); - break; - } - case 'serverAddressHasCredentials': { - setLoginStatus('服务器地址非法: 不能包含用户名或密码'); - break; - } - case 'serverAddressHasPathOrQueryOrFragment': { - setLoginStatus('服务器地址非法: 不能带路径、查询或片段'); - break; - } - case 'serverAddressNotHttps': { - setLoginStatus('服务器地址非法: 远程地址必须使用 https'); - break; - } - case 'serverAddressUnsupportedScheme': { - setLoginStatus('服务器地址非法: 只支持 http 或 https'); - break; - } - case 'serverAddressOutsideChannel': { - setLoginStatus('服务器地址非法: 不在当前构建渠道范围内'); + case 'serverAddressRejected': { + const payload = failure as ServerAddressRejected; + let text: string; + switch (payload.reason) { + case 'emptyOrTooLong': + text = '服务器地址非法: 不能为空或超长'; + break; + case 'notAUrl': + text = '服务器地址非法: 不是合法地址'; + break; + case 'hasCredentials': + text = '服务器地址非法: 不能包含用户名或密码'; + break; + case 'hasPathOrQueryOrFragment': + text = '服务器地址非法: 不能带路径、查询或片段'; + break; + case 'notHttps': + text = '服务器地址非法: 远程地址必须使用 https'; + break; + case 'unsupportedScheme': + text = '服务器地址非法: 只支持 http 或 https'; + break; + case 'outsideChannel': + text = '服务器地址非法: 不在当前构建渠道范围内'; + break; + default: { + expectNever(payload.reason); + text = '服务器地址非法'; + } + } + setLoginStatus(text); break; } case 'phoneNumberInvalid': { @@ -739,15 +733,10 @@ export function AuthenticatedClient({ break; } // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 - case 'authNetworkTimeout': - case 'authNetworkUnreachable': + case 'authNetworkFailure': case 'authServiceUnavailable': case 'unexpectedRejection': - case 'authResponseNotJson': - case 'authResponseInvalidBody': - case 'authResponseMissingRefreshCookie': - case 'authResponseMissingUserIdentity': - case 'authResponseServerRejected': + case 'authResponseInvalid': case 'clientSessionPersistFailed': case 'runtimeSessionInstallFailed': case 'authClientInitFailed': -- 2.52.0 From ba7c4a9bbf6412216142609838c57c450f4adc39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:42:36 +0800 Subject: [PATCH 44/70] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E5=AF=B9=E9=BD=90=E7=B1=BB=E5=88=AB=E5=8F=98?= =?UTF-8?q?=E4=BD=93=E4=B8=8E=E7=B1=BB=E5=9E=8B=E5=8C=96reason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 载体用例:authNetworkFailure 与 authResponseInvalid 改为内层 reason switch,覆盖全部 reason 与 default expectNever - 网关、上报、页面用例改用 authNetworkFailure 等新变体名与类型化 reason 载荷 - 新增可枚举细分不读服务端文案的分流断言 --- .../tests/appSurface/auth.suite.ts | 8 +- .../tests/authFailureReporting.test.tsx | 2 +- .../tests/clientAuthErrorCarrier.test.ts | 120 +++++++++++++----- .../tests/clientAuthHost.test.ts | 2 +- .../tests/errorReporting.test.ts | 2 +- 5 files changed, 95 insertions(+), 39 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 37dec4a95..0fad9ed16 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -288,7 +288,7 @@ export function registerAuthTests() { return { status: 'unauthenticated' }; } if (command === 'login_client_with_phone_code') { - throw { type: 'authNetworkUnreachable' }; + throw { type: 'authNetworkFailure', reason: 'unreachable' }; } return null; }); @@ -300,7 +300,8 @@ export function registerAuthTests() { // 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。 expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper); expect((reasons[0] as ClientAuthErrorWrapper).error).toMatchObject({ - type: 'authNetworkUnreachable', + type: 'authNetworkFailure', + reason: 'unreachable', }); expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(); expect(document.body.textContent).not.toContain('ECONNREFUSED'); @@ -318,7 +319,8 @@ export function registerAuthTests() { reads += 1; return reads === 1 ? Promise.reject({ - type: 'serverAddressNotHttps', + type: 'serverAddressRejected', + reason: 'notHttps', }) : { status: 'unauthenticated' }; } diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index 338a5ff09..c83a3a5fa 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -136,7 +136,7 @@ describe('认证失败的上报判据', () => { return { status: 'unauthenticated' }; } if (command === 'login_client_with_password') { - throw { type: 'authNetworkUnreachable' }; + throw { type: 'authNetworkFailure', reason: 'unreachable' }; } return null; }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index 156c72079..ac048cd8f 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -5,13 +5,15 @@ * `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体 * 字段(`serverMessage` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按 * `error.error.type`。另外用编译期用例钉住变体全集:无字段变体直接取固定文案,带载荷变体先 - * `as` 取具名类型;漏列任何变体,`expectNever(error)` 都编译不过。 + * `as` 取具名类型,可枚举的细分再按类型化 reason 分流;漏列任何变体或 reason, + * `expectNever(...)` 都编译不过。 */ import { describe, expect, it, vi } from 'vitest'; import { loginClientWithPassword } from '../src/services/clientAuth'; import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper'; -import type { AuthResponseServerRejected } from '../src/services/generated/AuthResponseServerRejected'; +import type { AuthNetworkFailure } from '../src/services/generated/AuthNetworkFailure'; +import type { AuthResponseInvalid } from '../src/services/generated/AuthResponseInvalid'; import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed'; @@ -19,29 +21,39 @@ import type { PasswordLoginRejected } from '../src/services/generated/PasswordLo import type { PhoneCodeLoginRejected } from '../src/services/generated/PhoneCodeLoginRejected'; import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed'; import type { SendCodeRejected } from '../src/services/generated/SendCodeRejected'; +import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected'; import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection'; function expectNever(value: never): void { void value; } -/** 编译期用例:变体逐个列全;带载荷的分支先 `as` 再读自己的字段。 */ +/** 编译期用例:顶层变体逐个列全;带载荷的分支先 `as`,reason 再按类型化枚举分流。 */ function variantMessage(error: ClientAuthError): string { switch (error.type) { - case 'serverAddressEmptyOrTooLong': - return '不能为空或超长'; - case 'serverAddressNotAUrl': - return '不是合法地址'; - case 'serverAddressHasCredentials': - return '不能包含凭据'; - case 'serverAddressHasPathOrQueryOrFragment': - return '不能带路径、查询或 fragment'; - case 'serverAddressNotHttps': - return '远程地址不是 https'; - case 'serverAddressUnsupportedScheme': - return '不是 http(s) 地址'; - case 'serverAddressOutsideChannel': - return '不在当前构建渠道范围内'; + case 'serverAddressRejected': { + const payload = error as ServerAddressRejected; + switch (payload.reason) { + case 'emptyOrTooLong': + return '不能为空或超长'; + case 'notAUrl': + return '不是合法地址'; + case 'hasCredentials': + return '不能包含凭据'; + case 'hasPathOrQueryOrFragment': + return '不能带路径、查询或 fragment'; + case 'notHttps': + return '远程地址不是 https'; + case 'unsupportedScheme': + return '不是 http(s) 地址'; + case 'outsideChannel': + return '不在当前构建渠道范围内'; + default: { + expectNever(payload.reason); + return ''; + } + } + } case 'phoneNumberInvalid': return '手机号格式不正确'; case 'passwordMissing': @@ -66,26 +78,42 @@ function variantMessage(error: ClientAuthError): string { return '登录态失效'; case 'permissionDenied': return '权限不足'; - case 'authNetworkTimeout': - return '连接超时'; - case 'authNetworkUnreachable': - return '连接失败'; + case 'authNetworkFailure': { + const payload = error as AuthNetworkFailure; + switch (payload.reason) { + case 'timeout': + return '连接超时'; + case 'unreachable': + return '连接失败'; + default: { + expectNever(payload.reason); + return ''; + } + } + } case 'authServiceUnavailable': return `HTTP ${(error as AuthServiceUnavailable).status}`; case 'unexpectedRejection': return (error as UnexpectedRejection).serverMessage ?? '未识别拒绝'; - case 'authResponseNotJson': - return '响应不是 JSON'; - case 'authResponseInvalidBody': - return '响应结构不符'; - case 'authResponseMissingRefreshCookie': - return '缺续期凭据'; - case 'authResponseMissingUserIdentity': - return '缺用户主体'; - case 'authResponseServerRejected': - return ( - (error as AuthResponseServerRejected).serverMessage ?? '服务端显式拒绝' - ); + case 'authResponseInvalid': { + const payload = error as AuthResponseInvalid; + switch (payload.reason) { + case 'notJson': + return '响应不是 JSON'; + case 'invalidBody': + return '响应结构不符'; + case 'missingRefreshCookie': + return '缺续期凭据'; + case 'missingUserIdentity': + return '缺用户主体'; + case 'serverRejected': + return payload.serverMessage ?? '服务端显式拒绝'; + default: { + expectNever(payload.reason); + return ''; + } + } + } case 'clientSessionPersistFailed': return (error as ClientSessionPersistFailed).detail; case 'runtimeSessionInstallFailed': @@ -170,4 +198,30 @@ describe('变体分流', () => { variantMessage({ type: 'authServiceUnavailable', status: 503 }), ).toBe('HTTP 503'); }); + + it('可枚举细分按类型化 reason 分流,不读服务端文案', () => { + expect( + variantMessage({ + type: 'serverAddressRejected', + reason: 'notHttps', + }), + ).toBe('远程地址不是 https'); + expect( + variantMessage({ type: 'authNetworkFailure', reason: 'timeout' }), + ).toBe('连接超时'); + expect( + variantMessage({ + type: 'authResponseInvalid', + reason: 'serverRejected', + serverMessage: '登录服务请求失败', + }), + ).toBe('登录服务请求失败'); + expect( + variantMessage({ + type: 'authResponseInvalid', + reason: 'missingRefreshCookie', + serverMessage: null, + }), + ).toBe('缺续期凭据'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 660f74f4f..6c6393676 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -159,7 +159,7 @@ test('没有原生宿主时登录能力明确失败关闭', async () => { }); test('结构化失败不降级成投影值,而是以携带原始拒绝值的 ClientAuthErrorWrapper 拒绝', async () => { - const rejection = { type: 'authNetworkUnreachable' }; + const rejection = { type: 'authNetworkFailure', reason: 'unreachable' }; invoke.mockRejectedValueOnce(rejection); await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ error: rejection, diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index 1265153f0..26f83c1bd 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -210,7 +210,7 @@ describe('客户端错误报告池', () => { }); it('解包 ClientAuthErrorWrapper 里的原始拒绝值,上下文只认显式入参', async () => { - const original = { type: 'authNetworkUnreachable' }; + const original = { type: 'authNetworkFailure', reason: 'unreachable' }; await captureClientError(new ClientAuthErrorWrapper(original), { source: 'unhandledrejection', -- 2.52.0 From 79a5707a186328f24292493bb4f102bcc827d57e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:55:47 +0800 Subject: [PATCH 45/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9AAGC=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=A4=B1=E8=B4=A5ADR=E4=BF=AE=E6=AD=A3=E4=B8=BA?= =?UTF-8?q?=E4=B8=8D=E6=94=B9=E5=8F=98=E7=94=A8=E6=88=B7=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除「后果与边界」里不再准确的「用户可见行为」一行(系统失败不是只进错误池) - 该节改为说明系统变体先按载荷原始事实给一行可见反馈,再原样抛出 --- ...�ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index 273ddb3a6..975b4efc7 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -106,13 +106,15 @@ catch (error) { - **每个带载荷的业务 / 会话 `case` 用 `as` 取自己的具名载荷类型**,再读它自己的字段;字段是可枚举的 细分 `reason` 时,再 `switch (payload.reason)` 在类型化枚举上分流。无字段的 `case` 直接用本 catch 的 固定文案。不写跨变体的通用读取,也不让 Rust 预拼上下文。前缀取自当前 catch 的操作语义(登录、发码、 - 启动检查各自可以不同),等价于 Java 的 `catch (PasswordLoginRejected e)`。系统变体不读载荷(调用方 - 只负责原样抛出),但变体名必须逐个列出,`default` 的 `expectNever` 才成立。 + 启动检查各自可以不同),等价于 Java 的 `catch (PasswordLoginRejected e)`。系统变体先按载荷里的原始 + 事实(`reason` / `status` / `detail` / `serverMessage`)给一行可见反馈,再原样抛出;变体名必须逐个 + 列出,`default` 的 `expectNever` 才成立。 - 业务 / 会话变体:Rust 只给可判定事实(无字段变体连字段都没有;带载荷变体给类型化 `reason` / `serverMessage` / `status` / `detail`),调用方在自己的 catch 里补上本次操作的上下文前缀(例如 「服务器地址非法: 远程地址必须使用 https」、「登录失败: 密码长度需要在 6 到 128 位之间」)。 -- 系统变体:调用方处理不了,**原样 `throw`**。`onSubmit` / `onClick` 这类 `void` 掉的 handler - 抛出的拒绝最终以 `unhandledrejection` 结算,由全局 handler 交给错误池。 +- 系统变体:调用方处理不了,先给一行可见反馈(载荷原始事实,不建兜底文案层),再**原样 `throw`**。 + `onSubmit` / `onClick` 这类 `void` 掉的 handler 抛出的拒绝最终以 `unhandledrejection` 结算,由全局 + handler 交给错误池。 - `default: expectNever(failure)`(`expectNever(value: never)`)让"Rust 加了变体而这里 没接"变成**编译错误**。这是上一版"未识别变体上调是故意的"的替代方案:判据从运行时前移到 编译期。 @@ -135,7 +137,6 @@ catch (error) { - 新增认证命令或新增 `ClientAuthError` 变体,必须同时改两个 catch 的 switch,否则 `tsc` 失败。 - `platformSession` 续期失败继续按"网络类失败不降级身份、不标权威失败"处理 (`authoritative: false`),与旧 `failed` 分支语义一致。 -- 用户可见行为:业务拒绝仍是登录页一行提示;系统失败仍是错误池事件 + 「发现问题」通知。 - 全局 `unhandledrejection` 是系统变体的唯一出口,调用方不再直接调 `captureClientError`; 系统变体上报的 `source` 就是该 handler 的显式入参(`unhandledrejection`),载体不再携带 `action`;结构化拒绝没有 JS `Error.message`,上报文案落回调用方给的默认值。 -- 2.52.0 From 2954f7b76953c01166c4590fb21309da1c636537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:56:00 +0800 Subject: [PATCH 46/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=A4=B1=E8=B4=A5=E4=B8=8D=E7=BB=99=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E5=8F=8D=E9=A6=88=E7=9A=84=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hydrateAuth 的 catch 恢复改动前的可见反馈:登录状态检查超时等非载体错误先写 authCheckError/loginStatus 再原样抛出 - 系统变体先按类型化载荷(reason/status/detail/serverMessage)拼出提示文案,再原样抛出交全局 unhandledrejection 上报 - appSurface 用例改为断言启动超时后登录页显示原因并保留「重试登录状态检查」 --- .../src/app/AuthenticatedClient.tsx | 98 +++++++++++++++++-- .../tests/appSurface/auth.suite.ts | 8 +- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 825c7e98d..c26b260bf 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -35,10 +35,16 @@ import { captureClientError, installWebviewLogBridge, } from '../services/errorReporting'; +import type { AuthNetworkFailure } from '../services/generated/AuthNetworkFailure'; +import type { AuthResponseInvalid } from '../services/generated/AuthResponseInvalid'; +import type { AuthServiceUnavailable } from '../services/generated/AuthServiceUnavailable'; +import type { ClientSessionPersistFailed } from '../services/generated/ClientSessionPersistFailed'; import type { PasswordLoginRejected } from '../services/generated/PasswordLoginRejected'; import type { PhoneCodeLoginRejected } from '../services/generated/PhoneCodeLoginRejected'; +import type { RuntimeSessionInstallFailed } from '../services/generated/RuntimeSessionInstallFailed'; import type { SendCodeRejected } from '../services/generated/SendCodeRejected'; import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected'; +import type { UnexpectedRejection } from '../services/generated/UnexpectedRejection'; import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, @@ -269,7 +275,13 @@ export function AuthenticatedClient({ if (!isActiveRun()) return; // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); - if (!(error instanceof ClientAuthErrorWrapper)) throw error; + // 启动检查超时等我们自己的失败:改动前会显示原因 + 重试按钮,这里恢复同样的可见反馈。 + if (!(error instanceof ClientAuthErrorWrapper)) { + const text = error instanceof Error ? error.message : String(error); + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } const failure = error.error; switch (failure.type) { // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 @@ -368,15 +380,83 @@ export function AuthenticatedClient({ case 'sessionInvalidated': case 'permissionDenied': break; - // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 - case 'authNetworkFailure': - case 'authServiceUnavailable': - case 'unexpectedRejection': - case 'authResponseInvalid': - case 'clientSessionPersistFailed': - case 'runtimeSessionInstallFailed': - case 'authClientInitFailed': + // 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkFailure': { + const payload = failure as AuthNetworkFailure; + const text = + payload.reason === 'timeout' + ? '登录服务响应超时,请检查服务器地址和网络后重试' + : '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; + setAuthCheckError(text); + setLoginStatus(text); throw error; + } + case 'authServiceUnavailable': { + const payload = failure as AuthServiceUnavailable; + const text = `登录服务暂时不可用(HTTP ${payload.status})`; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + case 'unexpectedRejection': { + const payload = failure as UnexpectedRejection; + const text = payload.serverMessage + ? `登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}` + : `登录服务请求失败(HTTP ${payload.status})`; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + case 'authResponseInvalid': { + const payload = failure as AuthResponseInvalid; + let text: string; + switch (payload.reason) { + case 'notJson': + text = '登录服务响应不是合法 JSON'; + break; + case 'invalidBody': + text = '登录服务响应结构不符合契约'; + break; + case 'missingRefreshCookie': + text = '登录服务未返回新的续期凭据'; + break; + case 'missingUserIdentity': + text = '登录服务未返回用户身份'; + break; + case 'serverRejected': + text = payload.serverMessage + ? `登录服务拒绝了本次请求:${payload.serverMessage}` + : '登录服务拒绝了本次请求'; + break; + default: { + expectNever(payload.reason); + text = '登录服务响应不符合契约'; + } + } + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + case 'clientSessionPersistFailed': { + const payload = failure as ClientSessionPersistFailed; + const text = `本机登录凭据读写失败:${payload.detail}`; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + case 'runtimeSessionInstallFailed': { + const payload = failure as RuntimeSessionInstallFailed; + const text = `本机运行时会话安装失败:${payload.detail}`; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + case 'authClientInitFailed': { + const text = '创建登录网络客户端失败'; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } default: { expectNever(failure); throw error; diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 0fad9ed16..9ead2250f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -127,9 +127,15 @@ export function registerAuthTests() { ).toBe(true), { timeout: 20_000 }, ); + // 启动检查失败在改动前就会显示原因 + 重试按钮;上报是叠加的,不是替换。 + expect( + await screen.findByText( + '检查登录状态超时,请检查服务器地址和网络后重试', + ), + ).not.toBeNull(); expect( screen.queryByRole('button', { name: '重试登录状态检查' }), - ).toBeNull(); + ).not.toBeNull(); await screen.findByRole('main', { name: '登录' }); } finally { window.removeEventListener('unhandledrejection', onRejection); -- 2.52.0 From ff5949b98dd4f84d27145ce3d5eebc4b9266e3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:56:08 +0800 Subject: [PATCH 47/70] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E4=B8=8E=E5=8F=91=E7=A0=81=E7=B3=BB=E7=BB=9F=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=AE=8B=E7=95=99=E5=BF=99=E7=A2=8C=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleSendCode/handleLoginSubmit 的 catch 在抛出前把系统失败原因写进 loginStatus,离开「正在发送验证码」「正在登录」 - 系统变体按类型化载荷拼出提示文案后再原样抛出,恢复改动前「显示 + 上报」的可见行为 - 本地运行时超时等非载体错误同样先写 loginStatus 再抛出 - appSurface 用例改为断言系统变体文案落到登录页且仍经 unhandledrejection 抛出 --- .../src/app/AuthenticatedClient.tsx | 168 ++++++++++++++++-- .../tests/appSurface/auth.suite.ts | 11 +- 2 files changed, 158 insertions(+), 21 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index c26b260bf..a35b82a5d 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -554,7 +554,11 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { - if (!(error instanceof ClientAuthErrorWrapper)) throw error; + // 本地运行时超时等我们自己的失败也要离开「正在发送验证码」并给出原因,改动前就是如此。 + if (!(error instanceof ClientAuthErrorWrapper)) { + setLoginStatus(error instanceof Error ? error.message : String(error)); + throw error; + } const failure = error.error; switch (failure.type) { // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 @@ -644,15 +648,79 @@ export function AuthenticatedClient({ setLoginStatus('当前账号没有权限执行此操作'); break; } - // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 - case 'authNetworkFailure': - case 'authServiceUnavailable': - case 'unexpectedRejection': - case 'authResponseInvalid': - case 'clientSessionPersistFailed': - case 'runtimeSessionInstallFailed': - case 'authClientInitFailed': + // 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkFailure': { + const payload = failure as AuthNetworkFailure; + setLoginStatus( + payload.reason === 'timeout' + ? '发送验证码失败:登录服务响应超时,请检查服务器地址和网络后重试' + : '发送验证码失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ); throw error; + } + case 'authServiceUnavailable': { + const payload = failure as AuthServiceUnavailable; + setLoginStatus( + `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, + ); + throw error; + } + case 'unexpectedRejection': { + const payload = failure as UnexpectedRejection; + setLoginStatus( + payload.serverMessage + ? `发送验证码失败:登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}` + : `发送验证码失败:登录服务请求失败(HTTP ${payload.status})`, + ); + throw error; + } + case 'authResponseInvalid': { + const payload = failure as AuthResponseInvalid; + let text: string; + switch (payload.reason) { + case 'notJson': + text = '发送验证码失败:登录服务响应不是合法 JSON'; + break; + case 'invalidBody': + text = '发送验证码失败:登录服务响应结构不符合契约'; + break; + case 'missingRefreshCookie': + text = '发送验证码失败:登录服务未返回新的续期凭据'; + break; + case 'missingUserIdentity': + text = '发送验证码失败:登录服务未返回用户身份'; + break; + case 'serverRejected': + text = payload.serverMessage + ? `发送验证码失败:登录服务拒绝了本次请求:${payload.serverMessage}` + : '发送验证码失败:登录服务拒绝了本次请求'; + break; + default: { + expectNever(payload.reason); + text = '发送验证码失败:登录服务响应不符合契约'; + } + } + setLoginStatus(text); + throw error; + } + case 'clientSessionPersistFailed': { + const payload = failure as ClientSessionPersistFailed; + setLoginStatus( + `发送验证码失败:本机登录凭据读写失败:${payload.detail}`, + ); + throw error; + } + case 'runtimeSessionInstallFailed': { + const payload = failure as RuntimeSessionInstallFailed; + setLoginStatus( + `发送验证码失败:本机运行时会话安装失败:${payload.detail}`, + ); + throw error; + } + case 'authClientInitFailed': { + setLoginStatus('发送验证码失败:创建登录网络客户端失败'); + throw error; + } default: { expectNever(failure); throw error; @@ -722,7 +790,11 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - if (!(error instanceof ClientAuthErrorWrapper)) throw error; + // 本地运行时超时等我们自己的失败也要离开「正在登录」并给出原因,改动前就是如此。 + if (!(error instanceof ClientAuthErrorWrapper)) { + setLoginStatus(error instanceof Error ? error.message : String(error)); + throw error; + } const failure = error.error; switch (failure.type) { // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 @@ -812,15 +884,75 @@ export function AuthenticatedClient({ setLoginStatus('当前账号没有权限执行此操作'); break; } - // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 - case 'authNetworkFailure': - case 'authServiceUnavailable': - case 'unexpectedRejection': - case 'authResponseInvalid': - case 'clientSessionPersistFailed': - case 'runtimeSessionInstallFailed': - case 'authClientInitFailed': + // 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkFailure': { + const payload = failure as AuthNetworkFailure; + setLoginStatus( + payload.reason === 'timeout' + ? '登录失败:登录服务响应超时,请检查服务器地址和网络后重试' + : '登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ); throw error; + } + case 'authServiceUnavailable': { + const payload = failure as AuthServiceUnavailable; + setLoginStatus( + `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, + ); + throw error; + } + case 'unexpectedRejection': { + const payload = failure as UnexpectedRejection; + setLoginStatus( + payload.serverMessage + ? `登录失败:登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}` + : `登录失败:登录服务请求失败(HTTP ${payload.status})`, + ); + throw error; + } + case 'authResponseInvalid': { + const payload = failure as AuthResponseInvalid; + let text: string; + switch (payload.reason) { + case 'notJson': + text = '登录失败:登录服务响应不是合法 JSON'; + break; + case 'invalidBody': + text = '登录失败:登录服务响应结构不符合契约'; + break; + case 'missingRefreshCookie': + text = '登录失败:登录服务未返回新的续期凭据'; + break; + case 'missingUserIdentity': + text = '登录失败:登录服务未返回用户身份'; + break; + case 'serverRejected': + text = payload.serverMessage + ? `登录失败:登录服务拒绝了本次请求:${payload.serverMessage}` + : '登录失败:登录服务拒绝了本次请求'; + break; + default: { + expectNever(payload.reason); + text = '登录失败:登录服务响应不符合契约'; + } + } + setLoginStatus(text); + throw error; + } + case 'clientSessionPersistFailed': { + const payload = failure as ClientSessionPersistFailed; + setLoginStatus(`登录失败:本机登录凭据读写失败:${payload.detail}`); + throw error; + } + case 'runtimeSessionInstallFailed': { + const payload = failure as RuntimeSessionInstallFailed; + setLoginStatus(`登录失败:本机运行时会话安装失败:${payload.detail}`); + throw error; + } + case 'authClientInitFailed': { + setLoginStatus('登录失败:创建登录网络客户端失败'); + throw error; + } default: { expectNever(failure); throw error; diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 9ead2250f..b480a0e0c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -281,7 +281,7 @@ export function registerAuthTests() { expect(screen.queryByLabelText('已登录')).toBeNull(); }); - it('keeps transport details out of the login copy by throwing system variants through', async () => { + it('shows the system variant copy and still throws it through', async () => { const uninstall = installUnhandledRejectionBridge(); const reasons: unknown[] = []; const onRejection = (event: Event) => { @@ -301,7 +301,7 @@ export function registerAuthTests() { renderAuthSurface(invoke); await loginWithCode(); - // 先等系统变体真的经 unhandledrejection 抛出,再断言它没落到登录页文案。 + // 先等系统变体真的经 unhandledrejection 抛出。 await waitFor(() => expect(reasons).toHaveLength(1)); // 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。 expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper); @@ -309,7 +309,12 @@ export function registerAuthTests() { type: 'authNetworkFailure', reason: 'unreachable', }); - expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(); + // 改动前系统失败也会在登录页给出一行提示;这里恢复同样的可见反馈,只是不泄露传输层细节。 + expect( + await screen.findByText( + '登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ), + ).not.toBeNull(); expect(document.body.textContent).not.toContain('ECONNREFUSED'); await screen.findByRole('main', { name: '登录' }); } finally { -- 2.52.0 From f58c71bd68e667c197b4f119745126b1970b7dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 03:02:32 +0800 Subject: [PATCH 48/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9AAGC=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=A4=B1=E8=B4=A5ADR=E4=B8=8D=E5=86=8D=E5=86=99?= =?UTF-8?q?=E6=AD=BB=20catch=20=E6=95=B0=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「后果与边界」改为「必须同时改所有 catch 的 switch」,避免数量写法随实现漂移 --- docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index 975b4efc7..8ca96b39d 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -134,7 +134,7 @@ catch (error) { ## 后果与边界 -- 新增认证命令或新增 `ClientAuthError` 变体,必须同时改两个 catch 的 switch,否则 `tsc` 失败。 +- 新增认证命令或新增 `ClientAuthError` 变体,必须同时改所有 catch 的 switch,否则 `tsc` 失败。 - `platformSession` 续期失败继续按"网络类失败不降级身份、不标权威失败"处理 (`authoritative: false`),与旧 `failed` 分支语义一致。 - 全局 `unhandledrejection` 是系统变体的唯一出口,调用方不再直接调 `captureClientError`; -- 2.52.0 From 62f1a80e872df9cf03772a27d09a50a9fb3ec22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:07:03 +0800 Subject: [PATCH 49/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=A4=B1=E8=B4=A5=E8=BD=BD=E4=BD=93=E6=8A=8A=E8=BD=BD?= =?UTF-8?q?=E8=8D=B7=E5=BA=8F=E5=88=97=E5=8C=96=E8=BF=9Bmessage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 JS 侧载体 ADR:ClientAuthErrorWrapper 构造时把整份载荷 JSON.stringify 进 Error.message,上报事件拿到变体名与载荷,事件指纹按变体区分 - 记录 Tauri 缺陷抛真 Error 时序列化只有 {},但上报链路对真 Error 优先用其自身 message/stack - 同步决策记录与技术方案的载体口径,不再写 Error.message 留空 --- ...‘AGC认证失败的JS侧载体与抛出时机-2026-10-01.md | 17 ++++++++++------- .../shared-memory/decision-log.md | 2 +- ...技术方案】AGC错误报告与诊断上传-2026-08-31.md | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index 8ca96b39d..ee7a83bc1 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -29,9 +29,10 @@ ts-rs 已经把 `ClientAuthError` 生成成判别联合(`src/services/generate 原始拒绝值是普通对象,直接 `throw` 会被上报链路降级成 `String(obj)`;所以包装层把它装进 **已有**的 `ClientAuthErrorWrapper`,载体只持有一个 `ClientAuthError` 类型的 `error` 字段,值就是原始拒绝值(也就是那个 -判别联合)。它**不读、不产任何派生值**:不读变体字段, -不塞 `context`,`Error.message` 留空。上报的 `source` / `action` 由调用 `captureClientError` -时的显式入参决定;catch 里 `error.error as ClientAuthError` 直接分流。 +判别联合),并在构造时把整份载荷 `JSON.stringify` 写进 `Error.message`——上报事件因此拿到的是 +机器事实(变体名与载荷),而不是 `[object Object]`。它**不读任何变体字段、不拼用户文案**: +不塞 `context`,展示文案与上报的 `source` / `action` 都由调用 `captureClientError` 时的显式 +入参决定;catch 里 `error.error as ClientAuthError` 直接分流。 - 只有带载荷的变体才有具名载荷类型:无字段变体在 ts-rs 里就是 `{ type: 'x' }`,不生成文件; 有字段的变体才生成 `X.ts`(可枚举的细分 `reason` 字段自己也是生成的枚举,如 @@ -60,9 +61,10 @@ async function invokeClientAuth(command, args): Promise { 静默吞掉——只是不再在包装层替 Tauri 兜底。 - `requireInvoke()` 放在 `try` 之外:认证桥未安装是我们自己的失败关闭错误,不是命令拒绝,保持 原样抛出(`需要在 Tauri App 内登录`)。 -- **不读、不产任何派生值**:不读变体字段,不注入 - `source` / `action`,`Error.message` 留空;载体只把原始拒绝值原样放进 `error`。展示文案与 - 上报上下文都由 catch 子句里拿到具名载荷的调用方决定。 +- **不读任何变体字段、不拼用户文案**:`Error.message` 是构造时对整份载荷的序列化,不注入 + `source` / `action`;载体把原始拒绝值原样放进 `error`。展示文案与上报上下文都由 catch + 子句里拿到具名载荷的调用方决定。Tauri 缺陷抛出的真 `Error` 序列化后只有 `{}`,但上报链路 + 对真 `Error` 优先用其自身 message/stack。 - 该包装是"Rust 结构化错误 → JS 错误对象"的唯一转换点:不做分类、不读文案判断、不兜底文案。 ### 3. 判定只写在 catch 子句里,用具体变体 @@ -139,7 +141,8 @@ catch (error) { (`authoritative: false`),与旧 `failed` 分支语义一致。 - 全局 `unhandledrejection` 是系统变体的唯一出口,调用方不再直接调 `captureClientError`; 系统变体上报的 `source` 就是该 handler 的显式入参(`unhandledrejection`),载体不再携带 - `action`;结构化拒绝没有 JS `Error.message`,上报文案落回调用方给的默认值。 + `action`;结构化拒绝的 `Error.message` 是构造载体时生成的载荷序列化,`captureClientError` + 直接把它当作事件 `message`,事件指纹因此按变体区分,报告面板呈现的是机器事实。 ## 验收 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 308b6512c..bc5dcbcde 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8,7 +8,7 @@ - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthErrorWrapper.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 有字段变体的载荷文件)。 - 决策(补充):TS 形状**只有带载荷的变体才有具名载荷类型**——无字段变体在 ts-rs 里就是 `{ type: 'x' }`,有字段的变体是 newtype 变体持有同名 `#[ts(export)]` 结构体,生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`;可枚举的细分原因是类型化枚举字段(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`),不是字符串、也不各拆一个顶层变体。前端 `switch (error.type)` 的无字段分支用固定文案,带载荷分支先 `as X` 再读它自己的字段,`reason` 是枚举时再 `switch (payload.reason)`(`default` 同样用 `expectNever`)。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 -- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 +- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`,构造时把整份载荷 `JSON.stringify` 进 `Error.message`,上报事件因此拿到机器事实),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 - 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `reason` / `serverMessage` / `status` / `detail` 拼上本次操作的上下文前缀(`reason` 是枚举时再 `switch (payload.reason)`);Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 - 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 2930da8e5..3e6a24c08 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -11,7 +11,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientAuthErrorWrapper`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 -- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段,无字段变体生成 `{ type }`,带载荷变体生成 `{ type } & 载荷类型`),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`、`Error.message` 留空),不新增手写错误类;判定只写在 catch 子句里,无字段变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名载荷类型(`reason` 是枚举时再 `switch (payload.reason)`)、再用它自己的字段拼上本次操作的上下文前缀,`default` 用 `expectNever` 在编译期挡住漏接变体。 +- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段,无字段变体生成 `{ type }`,带载荷变体生成 `{ type } & 载荷类型`),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`,构造时把整份载荷 `JSON.stringify` 进 `Error.message`,上报事件因此拿到机器事实),不新增手写错误类;判定只写在 catch 子句里,无字段变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名载荷类型(`reason` 是枚举时再 `switch (payload.reason)`)、再用它自己的字段拼上本次操作的上下文前缀,`default` 用 `expectNever` 在编译期挡住漏接变体。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 - 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 -- 2.52.0 From 7673edb04e2ca9d6628ee20f8ea26e6052982ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:09:41 +0800 Subject: [PATCH 50/70] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=BD=BD=E4=BD=93=E6=8A=8A=E8=BD=BD=E8=8D=B7=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E5=8C=96=E8=BF=9Bmessage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClientAuthErrorWrapper 构造时 super(JSON.stringify(error)):结构化拒绝在上报事件里是变体名与载荷,不再落成未知客户端错误 - errorReporting 注释同步:载体 message 已带载荷,Tauri 缺陷抛出的真 Error 仍优先用其自身 message/stack --- .../src/services/clientAuthErrorWrapper.ts | 10 +++++++--- .../src/services/errorReporting.ts | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts b/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts index 0f9b0a4f2..a9432d3b6 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts @@ -15,15 +15,19 @@ export type ClientErrorReportContext = { * 认证命令失败的 JS 侧载体:`error` 就是 Rust 的结构化拒绝(ts-rs 生成的 `ClientAuthError` * 判别联合)。 * - * 本类只把原始拒绝值装成 JS `Error`,不读、不产任何派生值:不读变体字段,不注入 - * `source` / `action`,`Error.message` 留空。分流只看类型化的变体, + * 构造时把整份载荷 `JSON.stringify` 进 `Error.message`:结构化拒绝是普通对象,序列化后 + * 上报事件拿到的是机器事实(变体名与载荷),而不是 `[object Object]`;不读任何变体字段、 + * 不拼用户文案、不注入 `source` / `action`。分流只看类型化的变体, * **不要用文案判断**;上报上下文由调用 `captureClientError` 时的显式入参决定。 + * + * Tauri 缺陷抛出的真 `Error` 序列化只有 `{}`,但上报链路对真 `Error` 优先用其自身 + * message/stack,不受影响。 */ export class ClientAuthErrorWrapper extends Error { readonly error: ClientAuthError; constructor(error: ClientAuthError) { - super(); + super(JSON.stringify(error)); this.name = 'ClientAuthErrorWrapper'; this.error = error; } diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 09c1fb0b1..c8c9b37ea 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -80,8 +80,8 @@ export async function captureClientError( error: unknown, context: Partial = {}, ) { - // ClientAuthErrorWrapper 只把原始拒绝值装成 JS Error:真的 message / stack 在 `error` 字段里, - // 载体自身是空文案。结构化 Rust 拒绝没有 JS Error 语义,落回默认文案。 + // ClientAuthErrorWrapper 构造时已把整份载荷序列化进 message:结构化拒绝在这里直接作为事件 + // 文案上报;真 Error 仍优先用它自己的 message / stack。 const actionError = error instanceof ClientAuthErrorWrapper ? error : null; // 类型上 `error` 是 ClientAuthError;Tauri 在映射外抛 Error 属于它的缺陷,这里按 unknown 兜底取文案/栈。 const carried: unknown = actionError?.error; -- 2.52.0 From 55d8667c071191bf503a30840f2c844a4d6ab482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:09:46 +0800 Subject: [PATCH 51/70] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E8=BD=BD=E4=BD=93?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96=E7=94=A8=E4=BE=8B=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 载体用例断言 message 为载荷序列化;Tauri 缺陷抛真 Error 时是 {},但上报仍优先用其自身 message/stack - 上报用例的 message 断言改为序列化结果,并新增真 Error 优先分支用例 --- .../tests/authFailureReporting.test.tsx | 5 +++- .../tests/clientAuthErrorCarrier.test.ts | 9 ++++---- .../tests/errorReporting.test.ts | 23 +++++++++++++++++-- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index c83a3a5fa..1b7c2bd4e 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -144,7 +144,10 @@ describe('认证失败的上报判据', () => { await waitFor(() => expect(reportCalls()).toHaveLength(1)); expect(reportCalls()[0]?.[1]).toMatchObject({ source: 'unhandledrejection', - message: '未知客户端错误', + message: JSON.stringify({ + type: 'authNetworkFailure', + reason: 'unreachable', + }), }); } finally { uninstall(); diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index ac048cd8f..c343247a6 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -3,8 +3,8 @@ * 认证命令失败的 JS 侧载体。 * * `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体 - * 字段(`serverMessage` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按 - * `error.error.type`。另外用编译期用例钉住变体全集:无字段变体直接取固定文案,带载荷变体先 + * 字段(`serverMessage` 之类不保证每个变体都有)、不注入上下文,载体构造时把整份载荷序列化进 + * `Error.message`;分流只按 `error.error.type`。另外用编译期用例钉住变体全集:无字段变体直接取固定文案,带载荷变体先 * `as` 取具名类型,可枚举的细分再按类型化 reason 分流;漏列任何变体或 reason, * `expectNever(...)` 都编译不过。 */ @@ -147,7 +147,7 @@ describe('认证命令失败的 JS 侧载体', () => { expect(failure).toBeInstanceOf(ClientAuthErrorWrapper); expect((failure as ClientAuthErrorWrapper).error).toBe(rejection); - expect((failure as Error).message).toBe(''); + expect((failure as Error).message).toBe(JSON.stringify(rejection)); }); it('认证桥未安装时保持原样抛出,不包装成命令失败', async () => { @@ -175,7 +175,8 @@ describe('认证命令失败的 JS 侧载体', () => { ).catch((error: unknown) => error); expect(failure).toBeInstanceOf(ClientAuthErrorWrapper); - expect((failure as Error).message).toBe(''); + // Error 没有可枚举属性,序列化就是空对象;上报链路对真 Error 优先用其自身 message/stack。 + expect((failure as Error).message).toBe('{}'); expect((failure as ClientAuthErrorWrapper).error).toBe(rejection); }); }); diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index 26f83c1bd..dc4e8e9c7 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -84,6 +84,7 @@ import { submitErrorReportBatch, subscribeClientErrorEvents, } from '../src/services/errorReporting'; +import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; describe('客户端错误报告池', () => { afterEach(() => { @@ -209,7 +210,7 @@ describe('客户端错误报告池', () => { expect(await getPendingClientErrorEvents()).toEqual([event]); }); - it('解包 ClientAuthErrorWrapper 里的原始拒绝值,上下文只认显式入参', async () => { + it('解包 ClientAuthErrorWrapper,把载体序列化后的 message 原样上报,上下文只认显式入参', async () => { const original = { type: 'authNetworkFailure', reason: 'unreachable' }; await captureClientError(new ClientAuthErrorWrapper(original), { @@ -219,13 +220,31 @@ describe('客户端错误报告池', () => { expect(invoke).toHaveBeenCalledWith('report_client_error', { source: 'unhandledrejection', - message: '未知客户端错误', + message: JSON.stringify(original), stack: expect.any(String), action: 'login', page: undefined, }); }); + it('包装的真 Error 仍优先用它的 message/stack,而不是空对象序列化', async () => { + // Tauri 在映射外抛真 Error 属于它的缺陷:载体照装,但取文案时仍用这颗 Error 自己。 + const original = new Error('IPC 桥接异常'); + + await captureClientError( + new ClientAuthErrorWrapper(original as unknown as ClientAuthError), + { source: 'unhandledrejection' }, + ); + + expect(invoke).toHaveBeenCalledWith('report_client_error', { + source: 'unhandledrejection', + message: 'IPC 桥接异常', + stack: original.stack, + action: undefined, + page: undefined, + }); + }); + it('保留 API 路由但隐藏 URL origin 与查询参数', () => { const sanitized = normalizeDiagnosticText( '请求超时:https://dev.genarrative.world/api/llm/models?token=secret#fragment', -- 2.52.0 From 0803e035b56ab81e1e95ef32a2ea0106c742ab98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:30:14 +0800 Subject: [PATCH 52/70] =?UTF-8?q?=E9=94=99=E8=AF=AF=E4=B8=8A=E6=8A=A5?= =?UTF-8?q?=E5=8F=96=E6=96=87=E6=A1=88=E6=94=B9if-else=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - errorReporting 的嵌套三元改为 if/else 赋值,行为不变,只提高可读性 --- .../src/services/errorReporting.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index c8c9b37ea..c22a703ab 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -85,12 +85,14 @@ export async function captureClientError( const actionError = error instanceof ClientAuthErrorWrapper ? error : null; // 类型上 `error` 是 ClientAuthError;Tauri 在映射外抛 Error 属于它的缺陷,这里按 unknown 兜底取文案/栈。 const carried: unknown = actionError?.error; - const errorValue = - carried instanceof Error - ? carried - : error instanceof Error - ? error - : new Error(String(error)); + let errorValue: Error; + if (carried instanceof Error) { + errorValue = carried; + } else if (error instanceof Error) { + errorValue = error; + } else { + errorValue = new Error(String(error)); + } const message = errorValue.message || '未知客户端错误'; const stack = errorValue.stack?.slice(0, 8_000); return reportClientError({ -- 2.52.0 From 325ebda7eb082196dbd39aa9a7e8333bbd450b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:31:34 +0800 Subject: [PATCH 53/70] =?UTF-8?q?=E8=AE=A4=E8=AF=81=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E6=81=A2=E5=A4=8D=E9=87=8D=E8=AF=95=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E5=8F=AF=E7=94=A8=E6=80=81=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启动超时用例改回 getByRole + disabled=false,避免渲染但禁用的重试按钮也能通过 --- apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index b480a0e0c..71c9baf12 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -134,8 +134,8 @@ export function registerAuthTests() { ), ).not.toBeNull(); expect( - screen.queryByRole('button', { name: '重试登录状态检查' }), - ).not.toBeNull(); + screen.getByRole('button', { name: '重试登录状态检查' }), + ).toHaveProperty('disabled', false); await screen.findByRole('main', { name: '登录' }); } finally { window.removeEventListener('unhandledrejection', onRejection); -- 2.52.0 From 7278023cb771142157bba6bb3ce62043ad416ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:31:50 +0800 Subject: [PATCH 54/70] =?UTF-8?q?=E8=AE=A4=E8=AF=81=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E7=BB=9F=E4=B8=80=E7=94=A8throw=E8=A1=A8?= =?UTF-8?q?=E8=BE=BE=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 业务变体用例的 Promise.reject 改为 throw,与相邻用例风格一致、不与已 resolved 混淆 --- .../tests/appSurface/auth.suite.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 71c9baf12..3f7c5912b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -328,12 +328,10 @@ export function registerAuthTests() { const invoke = vi.fn(async (command: string) => { if (command === 'read_client_auth_state') { reads += 1; - return reads === 1 - ? Promise.reject({ - type: 'serverAddressRejected', - reason: 'notHttps', - }) - : { status: 'unauthenticated' }; + if (reads === 1) { + throw { type: 'serverAddressRejected', reason: 'notHttps' }; + } + return { status: 'unauthenticated' }; } return null; }); -- 2.52.0 From da4645f55e9d50b22353016e4d57ed97685fc38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:33:53 +0800 Subject: [PATCH 55/70] =?UTF-8?q?=E8=AE=A4=E8=AF=81=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E6=8A=BD=E5=87=BAunhandledrejection=E6=8D=95?= =?UTF-8?q?=E8=8E=B7helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 两处重复的桥接安装、reason 收集与拆卸收成 captureWindowRejections() - 顺带修正系统变体用例里过期的载体 message 注释(现在 message 是载荷序列化) --- .../tests/appSurface/auth.suite.ts | 52 +++++++++++-------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 3f7c5912b..375b53b2a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -95,6 +95,26 @@ async function loginWithCode() { fireEvent.click(screen.getByRole('button', { name: '登录' })); } +/** + * 捕获 window 侧 `unhandledrejection`:返回已收到的 `reason` 列表与释放函数, + * 释放时同时摘掉监听和 Node 侧桥接。 + */ +function captureWindowRejections() { + const uninstallBridge = installUnhandledRejectionBridge(); + const reasons: unknown[] = []; + const onRejection = (event: Event) => { + reasons.push((event as Event & { reason?: unknown }).reason); + }; + window.addEventListener('unhandledrejection', onRejection); + return { + reasons, + uninstall() { + window.removeEventListener('unhandledrejection', onRejection); + uninstallBridge(); + }, + }; +} + export function registerAuthTests() { afterEach(() => { resetPlatformSessionForTests(); @@ -102,12 +122,7 @@ export function registerAuthTests() { }); it('throws a startup timeout through to unhandledrejection and leaves the loading state', async () => { - const uninstall = installUnhandledRejectionBridge(); - const reasons: unknown[] = []; - const onRejection = (event: Event) => { - reasons.push((event as Event & { reason?: unknown }).reason); - }; - window.addEventListener('unhandledrejection', onRejection); + const rejections = captureWindowRejections(); try { renderAuthSurface(async (command: string) => { if (command === 'read_client_auth_state') { @@ -119,7 +134,7 @@ export function registerAuthTests() { await waitFor( () => expect( - reasons.some( + rejections.reasons.some( (reason) => reason instanceof Error && reason.message.includes('检查登录状态超时'), @@ -138,8 +153,7 @@ export function registerAuthTests() { ).toHaveProperty('disabled', false); await screen.findByRole('main', { name: '登录' }); } finally { - window.removeEventListener('unhandledrejection', onRejection); - uninstall(); + rejections.uninstall(); } }, 30_000); @@ -282,12 +296,7 @@ export function registerAuthTests() { }); it('shows the system variant copy and still throws it through', async () => { - const uninstall = installUnhandledRejectionBridge(); - const reasons: unknown[] = []; - const onRejection = (event: Event) => { - reasons.push((event as Event & { reason?: unknown }).reason); - }; - window.addEventListener('unhandledrejection', onRejection); + const rejections = captureWindowRejections(); try { const invoke = vi.fn(async (command: string) => { if (command === 'read_client_auth_state') { @@ -302,10 +311,12 @@ export function registerAuthTests() { await loginWithCode(); // 先等系统变体真的经 unhandledrejection 抛出。 - await waitFor(() => expect(reasons).toHaveLength(1)); - // 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。 - expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper); - expect((reasons[0] as ClientAuthErrorWrapper).error).toMatchObject({ + await waitFor(() => expect(rejections.reasons).toHaveLength(1)); + // 载体 message 是载荷序列化(上报链路直接取用);结构化诊断在 error 字段里。 + expect(rejections.reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper); + expect( + (rejections.reasons[0] as ClientAuthErrorWrapper).error, + ).toMatchObject({ type: 'authNetworkFailure', reason: 'unreachable', }); @@ -318,8 +329,7 @@ export function registerAuthTests() { expect(document.body.textContent).not.toContain('ECONNREFUSED'); await screen.findByRole('main', { name: '登录' }); } finally { - window.removeEventListener('unhandledrejection', onRejection); - uninstall(); + rejections.uninstall(); } }); -- 2.52.0 From 7528ba0116aba517b078f6f6016f46e84a1112f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:35:13 +0800 Subject: [PATCH 56/70] =?UTF-8?q?unhandledrejection=E6=A1=A5=E6=8E=A5?= =?UTF-8?q?=E4=BF=9D=E7=95=99once=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用 rawListeners 记录原始监听器,拆卸时把 once 包装按 process.once 装回,普通监听器仍按 process.on 装回 - 未被 window 消费时调用 once 包装包裹的原函数,避免脱离 emitter 直调包装 - 注释记录安装期间新增监听器排在恢复监听器之前的既有边界 --- .../tests/unhandledRejectionBridge.ts | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts index 85dc4c24e..c61cabea4 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts @@ -10,11 +10,31 @@ * 有人接手;没人读说明这条拒绝是意外的,交回安装前的监听器(含 vitest 的失败上报), * 不会被静默吞掉。 * + * 安装前用 `rawListeners` 取原始监听器:`process.once` 注册的包装(`.listener`)在拆卸时 + * 按 `once` 装回去,不会退化成每次拒绝都触发的普通监听器;安装期间新增的监听器仍排在 + * 恢复出来的监听器之前。 + * * 用法:用例内 `const uninstall = installUnhandledRejectionBridge()`,在 `finally` 或 * `afterEach` 里 `uninstall()`。 */ +type RejectionListener = (reason: unknown, promise: Promise) => void; +type RawRejectionListener = RejectionListener & { + listener?: RejectionListener; +}; + +function invokeRejectionListener( + listener: RawRejectionListener, + reason: unknown, + promise: Promise, +) { + // once 包装必须调它包裹的原函数:脱离 emitter 直接调用包装会碰到 this 为空。 + (listener.listener ?? listener)(reason, promise); +} + export function installUnhandledRejectionBridge() { - const originalListeners = process.listeners('unhandledRejection'); + const originalListeners = process.rawListeners( + 'unhandledRejection', + ) as RawRejectionListener[]; process.removeAllListeners('unhandledRejection'); const handleRejection = (reason: unknown, promise: Promise) => { let consumed = false; @@ -28,7 +48,7 @@ export function installUnhandledRejectionBridge() { window.dispatchEvent(event); if (!consumed) { for (const listener of originalListeners) { - listener(reason, promise); + invokeRejectionListener(listener, reason, promise); } } }; @@ -36,7 +56,11 @@ export function installUnhandledRejectionBridge() { return () => { process.removeListener('unhandledRejection', handleRejection); for (const listener of originalListeners) { - process.on('unhandledRejection', listener); + if (listener.listener) { + process.once('unhandledRejection', listener.listener); + } else { + process.on('unhandledRejection', listener); + } } }; } -- 2.52.0 From 5f76e3bb537fd7536da5b647b1491c6fa46bd152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:48:15 +0800 Subject: [PATCH 57/70] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E6=9C=AA=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E6=8B=92=E7=BB=9D=E8=A1=A5=E5=8F=AF=E8=A7=81=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthenticatedClient 三个 catch 的 default 分支在 rethrow 前补一行泛化提示,界面不再停在无解释状态 - 上报判据用例同步断言 Tauri 意外拒绝形状也会显示这条提示 --- apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx | 6 ++++++ .../tests/authFailureReporting.test.tsx | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index a35b82a5d..0eb882bd8 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -459,6 +459,10 @@ export function AuthenticatedClient({ } default: { expectNever(failure); + // 结构化映射外(Tauri 级失败等):仍然原样抛出上报,但界面给一行泛化提示,别停在空白状态。 + const text = '登录服务返回了未识别的错误,请重试或提交错误报告'; + setAuthCheckError(text); + setLoginStatus(text); throw error; } } @@ -723,6 +727,7 @@ export function AuthenticatedClient({ } default: { expectNever(failure); + setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); throw error; } } @@ -955,6 +960,7 @@ export function AuthenticatedClient({ } default: { expectNever(failure); + setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); throw error; } } diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index 1b7c2bd4e..b088f8435 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -172,6 +172,12 @@ describe('认证失败的上报判据', () => { source: 'unhandledrejection', message: 'IPC 桥接不可用', }); + // 映射外的拒绝仍然上报,但界面要给一行泛化提示,不能停在无解释的登录页。 + expect( + await screen.findByText( + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ).not.toBeNull(); } finally { uninstall(); } -- 2.52.0 From f8c32e6346b138eab7eafac5f74063373f35fd29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:50:34 +0800 Subject: [PATCH 58/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E8=B7=AF=E7=94=B1429=E5=BD=92=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E5=8F=98=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 命令错误结构化 ADR 补 429 按路由判定的口径:发码是频控、登录是验证码次数耗尽、其余才落未识别拒绝 - 决策记录同步追加 2026-10-02 的 429 归类 --- docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md | 3 +++ docs/project-memory/shared-memory/decision-log.md | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index a6de13786..261fc15e1 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -53,6 +53,9 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`), 不假装能区分密码长度/手机号格式;**任何地方都不允许对错误文案做判断**。 +- `429` 同样按路由判定:发码路由是频控(`smsCodeThrottled`),登录路由是「验证码错误次数过多」 + (复用 `phoneCodeLoginRejected`,仍是用户可修正的输入问题、不进池),其余路由的 `429` 才落到 + `unexpectedRejection`。 - 本地前置校验只做布尔判定、拿不到更细事实时不编字段:手机号校验 `phone_is_valid` 只回 true/false, 所以 `phoneNumberInvalid` 保持无字段,提示由调用方给固定文案;编不出事实的"细分原因"不硬造。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bc5dcbcde..4c4825cc7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -11,6 +11,7 @@ - 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`,构造时把整份载荷 `JSON.stringify` 进 `Error.message`,上报事件因此拿到机器事实),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 - 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `reason` / `serverMessage` / `status` / `detail` 拼上本次操作的上下文前缀(`reason` 是枚举时再 `switch (payload.reason)`);Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 +- 追加(2026-10-02,429 按路由判定):`/api/auth/phone/login` 验证码错误次数耗尽返回的 429 是用户可修正的输入问题,映射为 `phoneCodeLoginRejected`(复用现有业务变体、不进错误池);发码路由仍是 `smsCodeThrottled`,其余路由的 429 仍是 `unexpectedRejection`。 - 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 ## 2026-10-01 DirectProject 审批拒绝原因留痕 -- 2.52.0 From b54b3794f648078289d6bff01881f16697d7a938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:50:43 +0800 Subject: [PATCH 59/70] =?UTF-8?q?=E7=99=BB=E5=BD=95=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E7=9A=84429=E5=BD=92=E4=B8=9A=E5=8A=A1=E5=8F=98=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map_auth_failure 的 429 改为按路由判定:SendCode 仍是 SmsCodeThrottled,PhoneLogin 映射为 PhoneCodeLoginRejected(用户可修正、不进错误池),其余路由才落 UnexpectedRejection - 补用例覆盖登录 429 与其它路由 429;map_auth_failure 注释同步新口径 --- .../src-tauri/src/auth_session.rs | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 416566950..c65cba3ac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -501,7 +501,8 @@ fn auth_route(route: &str) -> AuthRoute { /// /// 会话路由的 `401/403` 归到权威失效变体,调用方据此清会话;登录路由的 `401` 是用户可修正的 /// 输入问题。可判定的用户输入原因只进业务变体,认不出的才落到 `UnexpectedRejection`。 -/// 网络、5xx 与契约异常必须保留会话。 +/// `429` 也按路由判定:发码路由是频控,登录路由是「验证码错误次数过多」,两者都是用户可修正的 +/// 输入问题;其余路由的 `429` 仍按未识别拒绝处理。网络、5xx 与契约异常必须保留会话。 fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientAuthError { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); let status_code = status.as_u16(); @@ -526,8 +527,17 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA }), }; } - if status == StatusCode::TOO_MANY_REQUESTS && route == AuthRoute::SendCode { - return ClientAuthError::SmsCodeThrottled; + if status == StatusCode::TOO_MANY_REQUESTS { + return match route { + AuthRoute::SendCode => ClientAuthError::SmsCodeThrottled, + AuthRoute::PhoneLogin => { + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { server_message }) + } + _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: status_code, + server_message, + }), + }; } if status == StatusCode::BAD_REQUEST { return match route { @@ -1350,6 +1360,33 @@ mod tests { ); assert_eq!(throttled, ClientAuthError::SmsCodeThrottled); + // 登录路由的 429 是「验证码错误次数过多」:用户可修正的输入问题,不能进系统变体被上报。 + let verify_attempts_exceeded = map_auth_failure( + StatusCode::TOO_MANY_REQUESTS, + r#"{"error":{"message":"验证码错误次数过多,请重新获取验证码"}}"#, + AuthRoute::PhoneLogin, + ); + assert_eq!( + verify_attempts_exceeded, + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { + server_message: Some("验证码错误次数过多,请重新获取验证码".to_string()), + }) + ); + + // 其余路由的 429 仍未识别拒绝:既不冒充业务原因,也不冒充发码频控。 + let unknown_throttle = map_auth_failure( + StatusCode::TOO_MANY_REQUESTS, + r#"{"error":{"message":"限流"}}"#, + AuthRoute::Other, + ); + assert_eq!( + unknown_throttle, + ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: 429, + server_message: Some("限流".to_string()), + }) + ); + let bad_code = map_auth_failure( StatusCode::UNAUTHORIZED, r#"{"error":{"message":"验证码错误"}}"#, -- 2.52.0 From 8edbab9f3ec29a454cbcffbde9be66878bbcfc25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:51:28 +0800 Subject: [PATCH 60/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=9C=AC?= =?UTF-8?q?=E6=9C=BAIO=E5=A4=B1=E8=B4=A5=E5=8E=BB=E6=8E=89detail=E8=BD=BD?= =?UTF-8?q?=E8=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 命令错误结构化 ADR:本机凭据/运行时会话 IO 失败不再把原始错误文本放进载荷,原始 error 只写本地日志 - 认证失败 JS 侧载体 ADR 与决策记录同步去掉 detail 口径,并追加 2026-10-02 的变体改无字段决定 --- .../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md | 5 +++-- .../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md | 4 ++-- docs/project-memory/shared-memory/decision-log.md | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 261fc15e1..51d1bca7e 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -46,8 +46,9 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 Java 的嵌套 `switch`,仍不碰文案)。顶层与 `reason` 的 `default` 都用 `expectNever`,漏接变体或漏接 `reason` 都是**编译错误**。**不假设所有变体都有同一个字段**,也不做任何文案匹配。 - Rust **不预拼用户可见文案**:载荷只装原始事实(类型化的 `reason`、服务端 400 的原文 `serverMessage`、 - HTTP 状态码、本机 IO 的 `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch - 分支按当前操作拼接。 + HTTP 状态码),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。 + 本机凭据 / 运行时会话的 IO 失败不再把原始错误文本放进载荷(它曾把 AppData 路径带进登录页与上报包), + 原始 `error` 只写本地日志;对应变体是无字段变体,前端给固定文案。 - `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。 这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新约定。 - 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用 diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index ee7a83bc1..0d569c88c 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -109,10 +109,10 @@ catch (error) { 细分 `reason` 时,再 `switch (payload.reason)` 在类型化枚举上分流。无字段的 `case` 直接用本 catch 的 固定文案。不写跨变体的通用读取,也不让 Rust 预拼上下文。前缀取自当前 catch 的操作语义(登录、发码、 启动检查各自可以不同),等价于 Java 的 `catch (PasswordLoginRejected e)`。系统变体先按载荷里的原始 - 事实(`reason` / `status` / `detail` / `serverMessage`)给一行可见反馈,再原样抛出;变体名必须逐个 + 事实(`reason` / `status` / `serverMessage`)给一行可见反馈,再原样抛出;变体名必须逐个 列出,`default` 的 `expectNever` 才成立。 - 业务 / 会话变体:Rust 只给可判定事实(无字段变体连字段都没有;带载荷变体给类型化 `reason` / - `serverMessage` / `status` / `detail`),调用方在自己的 catch 里补上本次操作的上下文前缀(例如 + `serverMessage` / `status`),调用方在自己的 catch 里补上本次操作的上下文前缀(例如 「服务器地址非法: 远程地址必须使用 https」、「登录失败: 密码长度需要在 6 到 128 位之间」)。 - 系统变体:调用方处理不了,先给一行可见反馈(载荷原始事实,不建兜底文案层),再**原样 `throw`**。 `onSubmit` / `onClick` 这类 `void` 掉的 handler 抛出的拒绝最终以 `unhandledrejection` 结算,由全局 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4c4825cc7..9449dd1b4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9,9 +9,10 @@ - 决策(补充):TS 形状**只有带载荷的变体才有具名载荷类型**——无字段变体在 ts-rs 里就是 `{ type: 'x' }`,有字段的变体是 newtype 变体持有同名 `#[ts(export)]` 结构体,生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`;可枚举的细分原因是类型化枚举字段(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`),不是字符串、也不各拆一个顶层变体。前端 `switch (error.type)` 的无字段分支用固定文案,带载荷分支先 `as X` 再读它自己的字段,`reason` 是枚举时再 `switch (payload.reason)`(`default` 同样用 `expectNever`)。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 - 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 - 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`,构造时把整份载荷 `JSON.stringify` 进 `Error.message`,上报事件因此拿到机器事实),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 -- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `reason` / `serverMessage` / `status` / `detail` 拼上本次操作的上下文前缀(`reason` 是枚举时再 `switch (payload.reason)`);Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 +- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `reason` / `serverMessage` / `status` 拼上本次操作的上下文前缀(`reason` 是枚举时再 `switch (payload.reason)`);Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 - 追加(2026-10-02,429 按路由判定):`/api/auth/phone/login` 验证码错误次数耗尽返回的 429 是用户可修正的输入问题,映射为 `phoneCodeLoginRejected`(复用现有业务变体、不进错误池);发码路由仍是 `smsCodeThrottled`,其余路由的 429 仍是 `unexpectedRejection`。 +- 追加(2026-10-02,本机 IO 失败去 detail):`clientSessionPersistFailed` / `runtimeSessionInstallFailed` 改为无字段变体,删除会带 AppData 路径的 `detail`;原始 error 只经 `sanitize_diagnostic_message` 写本地日志,登录页用固定文案,上报载荷只保留变体名。 - 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 ## 2026-10-01 DirectProject 审批拒绝原因留痕 -- 2.52.0 From 53e2889bbd2cfd24858328bc1564e1e73251f319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 11:57:31 +0800 Subject: [PATCH 61/70] =?UTF-8?q?=E6=9C=AC=E6=9C=BAIO=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=8E=BB=E6=8E=89detail=E8=BD=BD=E8=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rust 错误枚举:clientSessionPersistFailed / runtimeSessionInstallFailed 改无字段变体,删除会带 AppData 路径的 payload 结构体 - auth_session:本机凭据/运行时会话 IO 失败改成写本地日志后返回无字段变体,不再把原始 error 放进载荷 - 前端:登录检查/发码/登录三处 catch 去掉 as 取 detail,改用本操作的固定文案 - ts-rs 重新生成 ClientAuthError 联合,删除两个已不存在的载荷绑定文件 - 补充 auth_error 用例:两个变体序列化为仅含 type 的无字段形状 --- .../src-tauri/src/auth_error.rs | 62 +++++++------------ .../src-tauri/src/auth_session.rs | 20 +++--- .../src/app/AuthenticatedClient.tsx | 24 ++----- .../src/services/generated/ClientAuthError.ts | 6 +- .../generated/ClientSessionPersistFailed.ts | 6 -- .../generated/RuntimeSessionInstallFailed.ts | 6 -- .../tests/clientAuthErrorCarrier.test.ts | 6 +- 7 files changed, 41 insertions(+), 89 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts delete mode 100644 apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index 8d3a9f55d..13dd6c794 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -7,8 +7,9 @@ //! 响应契约的 5 种破损各自只占一个变体:前端 `switch (failure.type)` 选到类别后,再用 //! `switch (payload.reason)` 在**类型化**的细分上分流,仍然不碰文案。 //! -//! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码、本机 IO 的 -//! `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。 +//! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码),服务端没给原文 +//! 就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。本机 IO 的原始错误只写本地 +//! 日志,不进载荷(避免把 AppData 路径带进登录页与上报包),对应变体无字段、前端给固定文案。 use serde::Serialize; use ts_rs::TS; @@ -55,10 +56,10 @@ pub(crate) enum ClientAuthError { UnexpectedRejection(UnexpectedRejection), /// 登录服务响应的契约破损,细分原因见 [`AuthResponseInvalidReason`]。 AuthResponseInvalid(AuthResponseInvalid), - /// 本机登录凭据文件读写失败。 - ClientSessionPersistFailed(ClientSessionPersistFailed), - /// 本机运行时会话安装 / 清理失败。 - RuntimeSessionInstallFailed(RuntimeSessionInstallFailed), + /// 本机登录凭据文件读写失败;原始错误只写本地日志。 + ClientSessionPersistFailed, + /// 本机运行时会话安装 / 清理失败;原始错误只写本地日志。 + RuntimeSessionInstallFailed, /// 认证网络客户端构建失败。 AuthClientInitFailed, } @@ -186,22 +187,6 @@ pub(crate) struct AuthResponseInvalid { pub(crate) server_message: Option, } -/// 本机凭据文件读写的原始错误明细。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct ClientSessionPersistFailed { - pub(crate) detail: String, -} - -/// 本机运行时会话安装 / 清理的原始错误明细。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct RuntimeSessionInstallFailed { - pub(crate) detail: String, -} - impl ClientAuthError { /// 会话路由的 401/403 是「登录态失效」:调用方据此清会话、按未登录处理, /// 既不给用户报错,也不进错误报告池。 @@ -277,15 +262,11 @@ mod tests { "authResponseInvalid", ), ( - ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { - detail: "x".to_string(), - }), + ClientAuthError::ClientSessionPersistFailed, "clientSessionPersistFailed", ), ( - ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { - detail: "x".to_string(), - }), + ClientAuthError::RuntimeSessionInstallFailed, "runtimeSessionInstallFailed", ), ( @@ -304,9 +285,20 @@ mod tests { #[test] fn unit_variants_serialize_without_payload_fields() { - let value = - serde_json::to_value(ClientAuthError::LoginCodeMissing).expect("serialize auth error"); - assert_eq!(value, serde_json::json!({ "type": "loginCodeMissing" })); + for (error, expected_type) in [ + (ClientAuthError::LoginCodeMissing, "loginCodeMissing"), + ( + ClientAuthError::ClientSessionPersistFailed, + "clientSessionPersistFailed", + ), + ( + ClientAuthError::RuntimeSessionInstallFailed, + "runtimeSessionInstallFailed", + ), + ] { + let value = serde_json::to_value(error).expect("serialize auth error"); + assert_eq!(value, serde_json::json!({ "type": expected_type })); + } } #[test] @@ -354,14 +346,6 @@ mod tests { .expect("serialize auth error"); assert_eq!(invalid["reason"], "serverRejected"); assert_eq!(invalid["serverMessage"], "登录服务请求失败"); - - let persist = serde_json::to_value(ClientAuthError::ClientSessionPersistFailed( - ClientSessionPersistFailed { - detail: "磁盘只读".to_string(), - }, - )) - .expect("serialize auth error"); - assert_eq!(persist["detail"], "磁盘只读"); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index c65cba3ac..d08e697cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -6,9 +6,8 @@ use crate::auth_error::{ AuthNetworkFailure, AuthNetworkReason, AuthResponseInvalid, AuthResponseInvalidReason, - AuthServiceUnavailable, ClientAuthError, ClientSessionPersistFailed, PasswordLoginRejected, - PhoneCodeLoginRejected, RuntimeSessionInstallFailed, SendCodeRejected, ServerAddressReason, - ServerAddressRejected, UnexpectedRejection, + AuthServiceUnavailable, ClientAuthError, PasswordLoginRejected, PhoneCodeLoginRejected, + SendCodeRejected, ServerAddressReason, ServerAddressRejected, UnexpectedRejection, }; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; @@ -222,24 +221,21 @@ fn session_file_path(app: &tauri::AppHandle) -> Result .app_data_dir() .map(|root| root.join(SESSION_FILE_NAME)) .map_err(|error| { - ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { - detail: format!("无法读取 AGC 应用数据目录:{error}"), - }) + app_log!("无法读取 AGC 应用数据目录:{error}"); + ClientAuthError::ClientSessionPersistFailed }) } /// 凭据文件相关的字符串错误统一收口成凭据落盘失败变体。 fn session_persist_error(message: impl Into) -> ClientAuthError { - ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { - detail: message.into(), - }) + app_log!("{}", message.into()); + ClientAuthError::ClientSessionPersistFailed } /// 本机运行时安装 / 清理失败的字符串错误统一收口。 fn runtime_session_error(message: impl Into) -> ClientAuthError { - ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { - detail: message.into(), - }) + app_log!("{}", message.into()); + ClientAuthError::RuntimeSessionInstallFailed } /// 服务地址校验失败统一收口成带类型化原因的变体。 diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 0eb882bd8..b4bb237b4 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -38,10 +38,8 @@ import { import type { AuthNetworkFailure } from '../services/generated/AuthNetworkFailure'; import type { AuthResponseInvalid } from '../services/generated/AuthResponseInvalid'; import type { AuthServiceUnavailable } from '../services/generated/AuthServiceUnavailable'; -import type { ClientSessionPersistFailed } from '../services/generated/ClientSessionPersistFailed'; import type { PasswordLoginRejected } from '../services/generated/PasswordLoginRejected'; import type { PhoneCodeLoginRejected } from '../services/generated/PhoneCodeLoginRejected'; -import type { RuntimeSessionInstallFailed } from '../services/generated/RuntimeSessionInstallFailed'; import type { SendCodeRejected } from '../services/generated/SendCodeRejected'; import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected'; import type { UnexpectedRejection } from '../services/generated/UnexpectedRejection'; @@ -438,15 +436,13 @@ export function AuthenticatedClient({ throw error; } case 'clientSessionPersistFailed': { - const payload = failure as ClientSessionPersistFailed; - const text = `本机登录凭据读写失败:${payload.detail}`; + const text = '本机登录凭据读写失败'; setAuthCheckError(text); setLoginStatus(text); throw error; } case 'runtimeSessionInstallFailed': { - const payload = failure as RuntimeSessionInstallFailed; - const text = `本机运行时会话安装失败:${payload.detail}`; + const text = '本机运行时会话安装失败'; setAuthCheckError(text); setLoginStatus(text); throw error; @@ -708,17 +704,11 @@ export function AuthenticatedClient({ throw error; } case 'clientSessionPersistFailed': { - const payload = failure as ClientSessionPersistFailed; - setLoginStatus( - `发送验证码失败:本机登录凭据读写失败:${payload.detail}`, - ); + setLoginStatus('发送验证码失败:本机登录凭据读写失败'); throw error; } case 'runtimeSessionInstallFailed': { - const payload = failure as RuntimeSessionInstallFailed; - setLoginStatus( - `发送验证码失败:本机运行时会话安装失败:${payload.detail}`, - ); + setLoginStatus('发送验证码失败:本机运行时会话安装失败'); throw error; } case 'authClientInitFailed': { @@ -945,13 +935,11 @@ export function AuthenticatedClient({ throw error; } case 'clientSessionPersistFailed': { - const payload = failure as ClientSessionPersistFailed; - setLoginStatus(`登录失败:本机登录凭据读写失败:${payload.detail}`); + setLoginStatus('登录失败:本机登录凭据读写失败'); throw error; } case 'runtimeSessionInstallFailed': { - const payload = failure as RuntimeSessionInstallFailed; - setLoginStatus(`登录失败:本机运行时会话安装失败:${payload.detail}`); + setLoginStatus('登录失败:本机运行时会话安装失败'); throw error; } case 'authClientInitFailed': { diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts index 6cb924232..8b402ac56 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -2,10 +2,8 @@ import type { AuthNetworkFailure } from './AuthNetworkFailure'; import type { AuthResponseInvalid } from './AuthResponseInvalid'; import type { AuthServiceUnavailable } from './AuthServiceUnavailable'; -import type { ClientSessionPersistFailed } from './ClientSessionPersistFailed'; import type { PasswordLoginRejected } from './PasswordLoginRejected'; import type { PhoneCodeLoginRejected } from './PhoneCodeLoginRejected'; -import type { RuntimeSessionInstallFailed } from './RuntimeSessionInstallFailed'; import type { SendCodeRejected } from './SendCodeRejected'; import type { ServerAddressRejected } from './ServerAddressRejected'; import type { UnexpectedRejection } from './UnexpectedRejection'; @@ -30,6 +28,6 @@ export type ClientAuthError = | ({ type: 'authServiceUnavailable' } & AuthServiceUnavailable) | ({ type: 'unexpectedRejection' } & UnexpectedRejection) | ({ type: 'authResponseInvalid' } & AuthResponseInvalid) - | ({ type: 'clientSessionPersistFailed' } & ClientSessionPersistFailed) - | ({ type: 'runtimeSessionInstallFailed' } & RuntimeSessionInstallFailed) + | { type: 'clientSessionPersistFailed' } + | { type: 'runtimeSessionInstallFailed' } | { type: 'authClientInitFailed' }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts deleted file mode 100644 index 258f43fb0..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 本机凭据文件读写的原始错误明细。 - */ -export type ClientSessionPersistFailed = { detail: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts deleted file mode 100644 index df2940b5d..000000000 --- a/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 本机运行时会话安装 / 清理的原始错误明细。 - */ -export type RuntimeSessionInstallFailed = { detail: string }; diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index c343247a6..ed6a5c902 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -16,10 +16,8 @@ import type { AuthNetworkFailure } from '../src/services/generated/AuthNetworkFa import type { AuthResponseInvalid } from '../src/services/generated/AuthResponseInvalid'; import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; -import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed'; import type { PasswordLoginRejected } from '../src/services/generated/PasswordLoginRejected'; import type { PhoneCodeLoginRejected } from '../src/services/generated/PhoneCodeLoginRejected'; -import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed'; import type { SendCodeRejected } from '../src/services/generated/SendCodeRejected'; import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected'; import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection'; @@ -115,9 +113,9 @@ function variantMessage(error: ClientAuthError): string { } } case 'clientSessionPersistFailed': - return (error as ClientSessionPersistFailed).detail; + return '本机登录凭据读写失败'; case 'runtimeSessionInstallFailed': - return (error as RuntimeSessionInstallFailed).detail; + return '本机运行时会话安装失败'; case 'authClientInitFailed': return '网络客户端构建失败'; default: { -- 2.52.0 From d518bb2ceb095118d73e843832bb857ffbcbcac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 12:06:03 +0800 Subject: [PATCH 62/70] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E7=B1=BB=E5=A4=B1=E8=B4=A5=E8=BD=BD=E8=8D=B7=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=8E=9F=E5=A7=8B=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 命令错误结构化 ADR:本机 IO / 网络客户端构建失败的原始 detail 留在载荷(调用方分流与报告诊断用),界面只用固定文案,路径交给报告侧 sanitize - 认证失败 JS 侧载体 ADR:带 detail 的本机失败是例外,不把原始错误文本贴到界面上 - 决策记录:追加 2026-10-02 口径,取代上一轮"本机 IO 失败不进载荷" --- .../【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md | 8 +++++--- .../【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md | 5 +++-- docs/project-memory/shared-memory/decision-log.md | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 51d1bca7e..105172cce 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -46,9 +46,11 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 Java 的嵌套 `switch`,仍不碰文案)。顶层与 `reason` 的 `default` 都用 `expectNever`,漏接变体或漏接 `reason` 都是**编译错误**。**不假设所有变体都有同一个字段**,也不做任何文案匹配。 - Rust **不预拼用户可见文案**:载荷只装原始事实(类型化的 `reason`、服务端 400 的原文 `serverMessage`、 - HTTP 状态码),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。 - 本机凭据 / 运行时会话的 IO 失败不再把原始错误文本放进载荷(它曾把 AppData 路径带进登录页与上报包), - 原始 `error` 只写本地日志;对应变体是无字段变体,前端给固定文案。 + HTTP 状态码、本机 IO / 网络客户端构建失败的原始 `detail`),服务端没给原文就是 `None`;前缀与句式由 + 前端调用方在自己的 catch 分支按当前操作拼接。**原始错误必须留在载荷里**(调用方据此分流,报告包据此 + 诊断),同时另记一行本地日志;但 `detail` **不直接贴在界面上**:`clientSessionPersistFailed` / + `runtimeSessionInstallFailed` / `authClientInitFailed` 三个 catch 用本操作的固定文案,路径等敏感片段 + 由报告侧的 sanitize 换成占位符。 - `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。 这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新约定。 - 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用 diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md index 0d569c88c..7a8a7aa85 100644 --- a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -109,8 +109,9 @@ catch (error) { 细分 `reason` 时,再 `switch (payload.reason)` 在类型化枚举上分流。无字段的 `case` 直接用本 catch 的 固定文案。不写跨变体的通用读取,也不让 Rust 预拼上下文。前缀取自当前 catch 的操作语义(登录、发码、 启动检查各自可以不同),等价于 Java 的 `catch (PasswordLoginRejected e)`。系统变体先按载荷里的原始 - 事实(`reason` / `status` / `serverMessage`)给一行可见反馈,再原样抛出;变体名必须逐个 - 列出,`default` 的 `expectNever` 才成立。 + 事实(`reason` / `status` / `serverMessage`)给一行可见反馈,再原样抛出;带 `detail` 的本机失败是例外, + 只用固定文案,`detail` 只用于分流与诊断、不贴到界面上——变体名必须逐个列出,`default` 的 + `expectNever` 才成立。 - 业务 / 会话变体:Rust 只给可判定事实(无字段变体连字段都没有;带载荷变体给类型化 `reason` / `serverMessage` / `status`),调用方在自己的 catch 里补上本次操作的上下文前缀(例如 「服务器地址非法: 远程地址必须使用 https」、「登录失败: 密码长度需要在 6 到 128 位之间」)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9449dd1b4..bd188cfd8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -12,7 +12,7 @@ - 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `reason` / `serverMessage` / `status` 拼上本次操作的上下文前缀(`reason` 是枚举时再 `switch (payload.reason)`);Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 - 追加(2026-10-02,429 按路由判定):`/api/auth/phone/login` 验证码错误次数耗尽返回的 429 是用户可修正的输入问题,映射为 `phoneCodeLoginRejected`(复用现有业务变体、不进错误池);发码路由仍是 `smsCodeThrottled`,其余路由的 429 仍是 `unexpectedRejection`。 -- 追加(2026-10-02,本机 IO 失败去 detail):`clientSessionPersistFailed` / `runtimeSessionInstallFailed` 改为无字段变体,删除会带 AppData 路径的 `detail`;原始 error 只经 `sanitize_diagnostic_message` 写本地日志,登录页用固定文案,上报载荷只保留变体名。 +- 追加(2026-10-02,系统类失败保留原始错误载荷):`clientSessionPersistFailed` / `runtimeSessionInstallFailed` / `authClientInitFailed` 都带 `detail: string`(原始 error),既让调用方有机会分流处理,也让报告包带够诊断信息;原始 error 同时经 `app_log!`(落盘前过 `sanitize_diagnostic_message`)记一行本地日志。`detail` 不贴到界面上:三个 catch 用本操作的固定文案(登录检查 / 发码 / 登录各自不同)。取代上一版"本机 IO 失败不进载荷、原始 error 只进日志"。 - 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 ## 2026-10-01 DirectProject 审批拒绝原因留痕 -- 2.52.0 From 7128e865c04a42c2f4716687e1b85daefefc40f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 12:08:51 +0800 Subject: [PATCH 63/70] =?UTF-8?q?=E6=9C=AC=E6=9C=BAIO=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=BD=BD=E8=8D=B7=E6=81=A2=E5=A4=8Ddetail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 错误枚举:clientSessionPersistFailed / runtimeSessionInstallFailed 恢复成带 detail 的载荷变体,重新导出两个 ts-rs 绑定文件 - auth_session:IO 失败把原始 error 放进 detail 的同时,仍记一行本地日志 - 前端维持不展示 detail,继续用本操作的固定文案(登录检查 / 发码 / 登录) - carrier 用例改回对两个载荷变体 as 取 detail,钉住"带载荷变体先取具名类型" --- .../src-tauri/src/auth_error.rs | 64 ++++++++++++------- .../src-tauri/src/auth_session.rs | 20 +++--- .../src/services/generated/ClientAuthError.ts | 6 +- .../generated/ClientSessionPersistFailed.ts | 6 ++ .../generated/RuntimeSessionInstallFailed.ts | 6 ++ .../tests/clientAuthErrorCarrier.test.ts | 6 +- 6 files changed, 73 insertions(+), 35 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index 13dd6c794..4f1ebbe30 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -7,9 +7,10 @@ //! 响应契约的 5 种破损各自只占一个变体:前端 `switch (failure.type)` 选到类别后,再用 //! `switch (payload.reason)` 在**类型化**的细分上分流,仍然不碰文案。 //! -//! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码),服务端没给原文 -//! 就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。本机 IO 的原始错误只写本地 -//! 日志,不进载荷(避免把 AppData 路径带进登录页与上报包),对应变体无字段、前端给固定文案。 +//! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码、本机 IO / +//! 网络客户端构建失败的原始错误 `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在 +//! 自己的 catch 分支按当前操作拼接。`detail` 是给调用方分流与诊断用的原始事实,**不直接贴在界面上**; +//! 报告侧由 sanitize 把路径等替换成占位符。 use serde::Serialize; use ts_rs::TS; @@ -56,10 +57,10 @@ pub(crate) enum ClientAuthError { UnexpectedRejection(UnexpectedRejection), /// 登录服务响应的契约破损,细分原因见 [`AuthResponseInvalidReason`]。 AuthResponseInvalid(AuthResponseInvalid), - /// 本机登录凭据文件读写失败;原始错误只写本地日志。 - ClientSessionPersistFailed, - /// 本机运行时会话安装 / 清理失败;原始错误只写本地日志。 - RuntimeSessionInstallFailed, + /// 本机登录凭据文件读写失败;`detail` 是原始错误,供调用方与诊断用、不直接展示。 + ClientSessionPersistFailed(ClientSessionPersistFailed), + /// 本机运行时会话安装 / 清理失败;`detail` 同上。 + RuntimeSessionInstallFailed(RuntimeSessionInstallFailed), /// 认证网络客户端构建失败。 AuthClientInitFailed, } @@ -187,6 +188,22 @@ pub(crate) struct AuthResponseInvalid { pub(crate) server_message: Option, } +/// 本机凭据文件读写的原始错误明细。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct ClientSessionPersistFailed { + pub(crate) detail: String, +} + +/// 本机运行时会话安装 / 清理的原始错误明细。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct RuntimeSessionInstallFailed { + pub(crate) detail: String, +} + impl ClientAuthError { /// 会话路由的 401/403 是「登录态失效」:调用方据此清会话、按未登录处理, /// 既不给用户报错,也不进错误报告池。 @@ -262,11 +279,15 @@ mod tests { "authResponseInvalid", ), ( - ClientAuthError::ClientSessionPersistFailed, + ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { + detail: "x".to_string(), + }), "clientSessionPersistFailed", ), ( - ClientAuthError::RuntimeSessionInstallFailed, + ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { + detail: "x".to_string(), + }), "runtimeSessionInstallFailed", ), ( @@ -285,20 +306,9 @@ mod tests { #[test] fn unit_variants_serialize_without_payload_fields() { - for (error, expected_type) in [ - (ClientAuthError::LoginCodeMissing, "loginCodeMissing"), - ( - ClientAuthError::ClientSessionPersistFailed, - "clientSessionPersistFailed", - ), - ( - ClientAuthError::RuntimeSessionInstallFailed, - "runtimeSessionInstallFailed", - ), - ] { - let value = serde_json::to_value(error).expect("serialize auth error"); - assert_eq!(value, serde_json::json!({ "type": expected_type })); - } + let value = + serde_json::to_value(ClientAuthError::LoginCodeMissing).expect("serialize auth error"); + assert_eq!(value, serde_json::json!({ "type": "loginCodeMissing" })); } #[test] @@ -346,6 +356,14 @@ mod tests { .expect("serialize auth error"); assert_eq!(invalid["reason"], "serverRejected"); assert_eq!(invalid["serverMessage"], "登录服务请求失败"); + + let persist = serde_json::to_value(ClientAuthError::ClientSessionPersistFailed( + ClientSessionPersistFailed { + detail: "磁盘只读".to_string(), + }, + )) + .expect("serialize auth error"); + assert_eq!(persist["detail"], "磁盘只读"); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index d08e697cb..9a711da2b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -6,8 +6,9 @@ use crate::auth_error::{ AuthNetworkFailure, AuthNetworkReason, AuthResponseInvalid, AuthResponseInvalidReason, - AuthServiceUnavailable, ClientAuthError, PasswordLoginRejected, PhoneCodeLoginRejected, - SendCodeRejected, ServerAddressReason, ServerAddressRejected, UnexpectedRejection, + AuthServiceUnavailable, ClientAuthError, ClientSessionPersistFailed, PasswordLoginRejected, + PhoneCodeLoginRejected, RuntimeSessionInstallFailed, SendCodeRejected, ServerAddressReason, + ServerAddressRejected, UnexpectedRejection, }; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; @@ -221,21 +222,24 @@ fn session_file_path(app: &tauri::AppHandle) -> Result .app_data_dir() .map(|root| root.join(SESSION_FILE_NAME)) .map_err(|error| { - app_log!("无法读取 AGC 应用数据目录:{error}"); - ClientAuthError::ClientSessionPersistFailed + let detail = format!("无法读取 AGC 应用数据目录:{error}"); + app_log!("{detail}"); + ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { detail }) }) } /// 凭据文件相关的字符串错误统一收口成凭据落盘失败变体。 fn session_persist_error(message: impl Into) -> ClientAuthError { - app_log!("{}", message.into()); - ClientAuthError::ClientSessionPersistFailed + let detail = message.into(); + app_log!("{detail}"); + ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { detail }) } /// 本机运行时安装 / 清理失败的字符串错误统一收口。 fn runtime_session_error(message: impl Into) -> ClientAuthError { - app_log!("{}", message.into()); - ClientAuthError::RuntimeSessionInstallFailed + let detail = message.into(); + app_log!("{detail}"); + ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { detail }) } /// 服务地址校验失败统一收口成带类型化原因的变体。 diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts index 8b402ac56..6cb924232 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -2,8 +2,10 @@ import type { AuthNetworkFailure } from './AuthNetworkFailure'; import type { AuthResponseInvalid } from './AuthResponseInvalid'; import type { AuthServiceUnavailable } from './AuthServiceUnavailable'; +import type { ClientSessionPersistFailed } from './ClientSessionPersistFailed'; import type { PasswordLoginRejected } from './PasswordLoginRejected'; import type { PhoneCodeLoginRejected } from './PhoneCodeLoginRejected'; +import type { RuntimeSessionInstallFailed } from './RuntimeSessionInstallFailed'; import type { SendCodeRejected } from './SendCodeRejected'; import type { ServerAddressRejected } from './ServerAddressRejected'; import type { UnexpectedRejection } from './UnexpectedRejection'; @@ -28,6 +30,6 @@ export type ClientAuthError = | ({ type: 'authServiceUnavailable' } & AuthServiceUnavailable) | ({ type: 'unexpectedRejection' } & UnexpectedRejection) | ({ type: 'authResponseInvalid' } & AuthResponseInvalid) - | { type: 'clientSessionPersistFailed' } - | { type: 'runtimeSessionInstallFailed' } + | ({ type: 'clientSessionPersistFailed' } & ClientSessionPersistFailed) + | ({ type: 'runtimeSessionInstallFailed' } & RuntimeSessionInstallFailed) | { type: 'authClientInitFailed' }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts new file mode 100644 index 000000000..258f43fb0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本机凭据文件读写的原始错误明细。 + */ +export type ClientSessionPersistFailed = { detail: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts new file mode 100644 index 000000000..df2940b5d --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本机运行时会话安装 / 清理的原始错误明细。 + */ +export type RuntimeSessionInstallFailed = { detail: string }; diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index ed6a5c902..c343247a6 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -16,8 +16,10 @@ import type { AuthNetworkFailure } from '../src/services/generated/AuthNetworkFa import type { AuthResponseInvalid } from '../src/services/generated/AuthResponseInvalid'; import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; +import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed'; import type { PasswordLoginRejected } from '../src/services/generated/PasswordLoginRejected'; import type { PhoneCodeLoginRejected } from '../src/services/generated/PhoneCodeLoginRejected'; +import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed'; import type { SendCodeRejected } from '../src/services/generated/SendCodeRejected'; import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected'; import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection'; @@ -113,9 +115,9 @@ function variantMessage(error: ClientAuthError): string { } } case 'clientSessionPersistFailed': - return '本机登录凭据读写失败'; + return (error as ClientSessionPersistFailed).detail; case 'runtimeSessionInstallFailed': - return '本机运行时会话安装失败'; + return (error as RuntimeSessionInstallFailed).detail; case 'authClientInitFailed': return '网络客户端构建失败'; default: { -- 2.52.0 From e1e8725dab9a45fa028f2ad9971bb21c93d4d1d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 12:12:50 +0800 Subject: [PATCH 64/70] =?UTF-8?q?AuthClientInitFailed=E5=B8=A6=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=A7=8B=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 错误枚举:authClientInitFailed 从 unit 变体改成带 detail 的载荷变体,导出 AuthClientInitFailed 绑定 - auth_session:build_client 失败时把 reqwest 原始错误放进 detail,不再丢弃 - carrier 用例按带载荷变体 as 取 detail - 前端维持固定文案,不把 detail 贴到界面上 --- .../src-tauri/src/auth_error.rs | 25 ++++++++++++++++--- .../src-tauri/src/auth_session.rs | 14 +++++++---- .../generated/AuthClientInitFailed.ts | 6 +++++ .../src/services/generated/ClientAuthError.ts | 3 ++- .../tests/clientAuthErrorCarrier.test.ts | 3 ++- 5 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index 4f1ebbe30..826cc6e93 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -61,8 +61,8 @@ pub(crate) enum ClientAuthError { ClientSessionPersistFailed(ClientSessionPersistFailed), /// 本机运行时会话安装 / 清理失败;`detail` 同上。 RuntimeSessionInstallFailed(RuntimeSessionInstallFailed), - /// 认证网络客户端构建失败。 - AuthClientInitFailed, + /// 认证网络客户端构建失败;`detail` 是原始错误。 + AuthClientInitFailed(AuthClientInitFailed), } // ---- 可枚举的细分原因:类型化字段,不是字符串 ---- @@ -204,6 +204,14 @@ pub(crate) struct RuntimeSessionInstallFailed { pub(crate) detail: String, } +/// 认证网络客户端构建失败的原始错误明细。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) struct AuthClientInitFailed { + pub(crate) detail: String, +} + impl ClientAuthError { /// 会话路由的 401/403 是「登录态失效」:调用方据此清会话、按未登录处理, /// 既不给用户报错,也不进错误报告池。 @@ -291,7 +299,9 @@ mod tests { "runtimeSessionInstallFailed", ), ( - ClientAuthError::AuthClientInitFailed, + ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { + detail: "x".to_string(), + }), "authClientInitFailed", ), ]; @@ -364,6 +374,15 @@ mod tests { )) .expect("serialize auth error"); assert_eq!(persist["detail"], "磁盘只读"); + + let init = serde_json::to_value(ClientAuthError::AuthClientInitFailed( + AuthClientInitFailed { + detail: "tls handshake failed".to_string(), + }, + )) + .expect("serialize auth error"); + assert_eq!(init["type"], "authClientInitFailed"); + assert_eq!(init["detail"], "tls handshake failed"); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 9a711da2b..026fa176f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -5,10 +5,10 @@ //! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 use crate::auth_error::{ - AuthNetworkFailure, AuthNetworkReason, AuthResponseInvalid, AuthResponseInvalidReason, - AuthServiceUnavailable, ClientAuthError, ClientSessionPersistFailed, PasswordLoginRejected, - PhoneCodeLoginRejected, RuntimeSessionInstallFailed, SendCodeRejected, ServerAddressReason, - ServerAddressRejected, UnexpectedRejection, + AuthClientInitFailed, AuthNetworkFailure, AuthNetworkReason, AuthResponseInvalid, + AuthResponseInvalidReason, AuthServiceUnavailable, ClientAuthError, ClientSessionPersistFailed, + PasswordLoginRejected, PhoneCodeLoginRejected, RuntimeSessionInstallFailed, SendCodeRejected, + ServerAddressReason, ServerAddressRejected, UnexpectedRejection, }; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; @@ -443,7 +443,11 @@ fn build_client() -> Result { .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| ClientAuthError::AuthClientInitFailed) + .map_err(|error| { + ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { + detail: error.to_string(), + }) + }) } fn network_error(error: &reqwest::Error) -> ClientAuthError { diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts b/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts new file mode 100644 index 000000000..73188e2d3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 认证网络客户端构建失败的原始错误明细。 + */ +export type AuthClientInitFailed = { detail: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts index 6cb924232..d52f178dc 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AuthClientInitFailed } from './AuthClientInitFailed'; import type { AuthNetworkFailure } from './AuthNetworkFailure'; import type { AuthResponseInvalid } from './AuthResponseInvalid'; import type { AuthServiceUnavailable } from './AuthServiceUnavailable'; @@ -32,4 +33,4 @@ export type ClientAuthError = | ({ type: 'authResponseInvalid' } & AuthResponseInvalid) | ({ type: 'clientSessionPersistFailed' } & ClientSessionPersistFailed) | ({ type: 'runtimeSessionInstallFailed' } & RuntimeSessionInstallFailed) - | { type: 'authClientInitFailed' }; + | ({ type: 'authClientInitFailed' } & AuthClientInitFailed); diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index c343247a6..4ae2f922b 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'; import { loginClientWithPassword } from '../src/services/clientAuth'; import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper'; +import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed'; import type { AuthNetworkFailure } from '../src/services/generated/AuthNetworkFailure'; import type { AuthResponseInvalid } from '../src/services/generated/AuthResponseInvalid'; import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; @@ -119,7 +120,7 @@ function variantMessage(error: ClientAuthError): string { case 'runtimeSessionInstallFailed': return (error as RuntimeSessionInstallFailed).detail; case 'authClientInitFailed': - return '网络客户端构建失败'; + return (error as AuthClientInitFailed).detail; default: { expectNever(error); return ''; -- 2.52.0 From 74e7209c31ca130d9c6df4dd33c2dcf2533626dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 13:05:40 +0800 Subject: [PATCH 65/70] =?UTF-8?q?=E7=94=9F=E6=88=90=E7=BB=91=E5=AE=9AJSDoc?= =?UTF-8?q?=E5=8E=BB=E6=8E=89Rust=E8=AF=AD=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_error:服务端原文缺失说明从 None 改成 TS 读得懂的 null(PasswordLoginRejected / SendCodeRejected / PhoneCodeLoginRejected) - auth_error:AuthResponseInvalid.server_message 的说明从 AuthResponseInvalidReason::ServerRejected 改成线上字面量 serverRejected - ts-rs 重新生成 4 份载荷绑定 --- apps/ai-game-creator-shell/src-tauri/src/auth_error.rs | 8 ++++---- .../src/services/generated/AuthResponseInvalid.ts | 2 +- .../src/services/generated/PasswordLoginRejected.ts | 2 +- .../src/services/generated/PhoneCodeLoginRejected.ts | 2 +- .../src/services/generated/SendCodeRejected.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index 826cc6e93..97fa9fb07 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -131,7 +131,7 @@ pub(crate) struct ServerAddressRejected { #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct PasswordLoginRejected { - /// 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + /// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 pub(crate) server_message: Option, } @@ -140,7 +140,7 @@ pub(crate) struct PasswordLoginRejected { #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct SendCodeRejected { - /// 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + /// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 pub(crate) server_message: Option, } @@ -149,7 +149,7 @@ pub(crate) struct SendCodeRejected { #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct PhoneCodeLoginRejected { - /// 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + /// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 pub(crate) server_message: Option, } @@ -184,7 +184,7 @@ pub(crate) struct UnexpectedRejection { #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct AuthResponseInvalid { pub(crate) reason: AuthResponseInvalidReason, - /// 只有 [`AuthResponseInvalidReason::ServerRejected`] 可能带服务端原文;其余是 `None`。 + /// 只有 `serverRejected` 可能带服务端原文;其余是 `null`。 pub(crate) server_message: Option, } diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts index 3ecbcc333..b9b57f307 100644 --- a/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts +++ b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts @@ -7,7 +7,7 @@ import type { AuthResponseInvalidReason } from './AuthResponseInvalidReason'; export type AuthResponseInvalid = { reason: AuthResponseInvalidReason; /** - * 只有 [`AuthResponseInvalidReason::ServerRejected`] 可能带服务端原文;其余是 `None`。 + * 只有 `serverRejected` 可能带服务端原文;其余是 `null`。 */ serverMessage: string | null; }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts index 41cd897bd..450754f46 100644 --- a/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts +++ b/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts @@ -5,7 +5,7 @@ */ export type PasswordLoginRejected = { /** - * 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + * 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 */ serverMessage: string | null; }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts index aa37d135b..403eaebbd 100644 --- a/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts +++ b/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts @@ -5,7 +5,7 @@ */ export type PhoneCodeLoginRejected = { /** - * 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + * 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 */ serverMessage: string | null; }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts b/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts index 6d13fba2c..cdf2a07c0 100644 --- a/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts +++ b/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts @@ -5,7 +5,7 @@ */ export type SendCodeRejected = { /** - * 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + * 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 */ serverMessage: string | null; }; -- 2.52.0 From 3d4c42901c0e510e59f53df43c5c25233dea5ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 13:06:30 +0800 Subject: [PATCH 66/70] =?UTF-8?q?=E8=AE=A4=E8=AF=81=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E6=96=AD=E8=A8=80=E7=B2=BE=E7=A1=AE=E5=88=B0?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.suite:启动超时用例先断言恰好捕获 1 条拒绝,再断言它是含「检查登录状态超时」的 Error - 原来的 some(...) 只看有没有命中,无关 unhandledrejection 会被这段捕获静默吞掉、测不出回归 --- .../tests/appSurface/auth.suite.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 375b53b2a..24b61408c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -131,16 +131,12 @@ export function registerAuthTests() { return null; }); - await waitFor( - () => - expect( - rejections.reasons.some( - (reason) => - reason instanceof Error && - reason.message.includes('检查登录状态超时'), - ), - ).toBe(true), - { timeout: 20_000 }, + await waitFor(() => expect(rejections.reasons).toHaveLength(1), { + timeout: 20_000, + }); + expect(rejections.reasons[0]).toBeInstanceOf(Error); + expect((rejections.reasons[0] as Error).message).toContain( + '检查登录状态超时', ); // 启动检查失败在改动前就会显示原因 + 重试按钮;上报是叠加的,不是替换。 expect( -- 2.52.0 From b0f9eb2292b6888b20758ed6a5b395f4b1c530db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 13:06:56 +0800 Subject: [PATCH 67/70] =?UTF-8?q?=E6=8B=92=E7=BB=9D=E6=A1=A5=E4=BA=A4?= =?UTF-8?q?=E5=9B=9E=E5=8E=9F=E7=9B=91=E5=90=AC=E5=99=A8=E6=97=B6=E5=AF=B9?= =?UTF-8?q?=E9=BD=90Node=E7=9A=84this?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unhandledRejectionBridge:交回原监听器改用 Reflect.apply 把 this 绑成 process,和 Node EventEmitter 一致,依赖 this 的原监听器不再拿到 undefined - 补一条用例钉住 this 语义 --- .../tests/unhandledRejectionBridge.test.ts | 15 +++++++++++++++ .../tests/unhandledRejectionBridge.ts | 5 +++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts index 3022d8f4e..9b796227d 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts @@ -71,3 +71,18 @@ test('window 侧读过 reason 的拒绝不再交回原监听器', () => { expect(consumed).toEqual([reason]); expect(forwarded).toEqual([]); }); + +test('交回原监听器时 this 绑成 process,和 Node EventEmitter 一致', () => { + parkProcessListeners(); + const receivers: unknown[] = []; + const listener = function (this: unknown) { + receivers.push(this); + }; + processListeners.push(listener); + process.on('unhandledRejection', listener); + installed.push(installUnhandledRejectionBridge()); + + process.emit('unhandledRejection', new Error('需要交回'), Promise.resolve()); + + expect(receivers).toEqual([process]); +}); diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts index c61cabea4..7e5a5aac6 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts @@ -27,8 +27,9 @@ function invokeRejectionListener( reason: unknown, promise: Promise, ) { - // once 包装必须调它包裹的原函数:脱离 emitter 直接调用包装会碰到 this 为空。 - (listener.listener ?? listener)(reason, promise); + // once 包装必须调它包裹的原函数;Node 的 EventEmitter 把 this 绑成 process, + // 这里用 Reflect.apply 对齐,别让依赖 this 的原监听器拿到 undefined。 + Reflect.apply(listener.listener ?? listener, process, [reason, promise]); } export function installUnhandledRejectionBridge() { -- 2.52.0 From 836f8a7348d619418130c9222e3a6ed65031ec09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 13:35:40 +0800 Subject: [PATCH 68/70] =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E7=99=BB?= =?UTF-8?q?=E5=BD=95401=E5=BD=92phoneCodeLoginRejected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_session:/api/auth/phone/login 的 401(只来自「用户不存在」)改判为 phoneCodeLoginRejected{serverMessage},不再冒充「验证码错误或过期」 - auth_error:退役顶层变体 smsCodeRejected(唯一产生点就是这个错配的 401 分支) - 前端三个 catch 删掉 smsCodeRejected 分支;phoneCodeLoginRejected 文案统一为「验证码登录失败:<服务端原文>」,冒号改全角 - ts-rs 重新生成 ClientAuthError 联合;carrier 用例同步删掉 smsCodeRejected - 文档:ADR + decision-log 记录 401 归属与 smsCodeRejected 退役 --- .../src-tauri/src/auth_error.rs | 7 +++---- .../src-tauri/src/auth_session.rs | 16 +++++++++++---- .../src/app/AuthenticatedClient.tsx | 20 +++---------------- .../src/services/generated/ClientAuthError.ts | 1 - .../tests/clientAuthErrorCarrier.test.ts | 2 -- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 2 ++ .../shared-memory/decision-log.md | 1 + 7 files changed, 21 insertions(+), 28 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index 97fa9fb07..8c62e8deb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -37,10 +37,10 @@ pub(crate) enum ClientAuthError { SendCodeRejected(SendCodeRejected), /// `/api/auth/phone/send-code` 返回 429:发送过于频繁。 SmsCodeThrottled, - /// `/api/auth/phone/login` 返回 400:服务端拒绝本次输入。 + /// `/api/auth/phone/login` 返回 400/401:服务端拒绝本次验证码登录。 + /// + /// 401 目前只来自「用户不存在」;该路由验证通过后会即时建号,所以这条分支实际很少触发。 PhoneCodeLoginRejected(PhoneCodeLoginRejected), - /// `/api/auth/phone/login` 返回 401:验证码错误或过期。 - SmsCodeRejected, // ---- 会话:调用方按"未登录"处理,不给用户报错 ---- /// 会话路由 401:登录态失效。 @@ -259,7 +259,6 @@ mod tests { }), "phoneCodeLoginRejected", ), - (ClientAuthError::SmsCodeRejected, "smsCodeRejected"), (ClientAuthError::SessionInvalidated, "sessionInvalidated"), (ClientAuthError::PermissionDenied, "permissionDenied"), ( diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 026fa176f..d10e65779 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -515,7 +515,9 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA return match route { AuthRoute::Session => ClientAuthError::SessionInvalidated, AuthRoute::PasswordEntry => ClientAuthError::PhoneOrPasswordMismatch, - AuthRoute::PhoneLogin => ClientAuthError::SmsCodeRejected, + AuthRoute::PhoneLogin => { + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { server_message }) + } _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, server_message, @@ -1391,12 +1393,18 @@ mod tests { }) ); - let bad_code = map_auth_failure( + // 登录路由的 401 只来自「用户不存在」:仍是业务变体,保留服务端原文,不能进系统变体。 + let user_not_found = map_auth_failure( StatusCode::UNAUTHORIZED, - r#"{"error":{"message":"验证码错误"}}"#, + r#"{"error":{"message":"用户不存在"}}"#, AuthRoute::PhoneLogin, ); - assert_eq!(bad_code, ClientAuthError::SmsCodeRejected); + assert_eq!( + user_not_found, + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { + server_message: Some("用户不存在".to_string()), + }) + ); } #[test] diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index b4bb237b4..42c8e687f 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -363,13 +363,7 @@ export function AuthenticatedClient({ } case 'phoneCodeLoginRejected': { const payload = failure as PhoneCodeLoginRejected; - const text = `验证码登录失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`; - setAuthCheckError(text); - setLoginStatus(text); - break; - } - case 'smsCodeRejected': { - const text = '验证码错误或已过期'; + const text = `验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`; setAuthCheckError(text); setLoginStatus(text); break; @@ -632,14 +626,10 @@ export function AuthenticatedClient({ case 'phoneCodeLoginRejected': { const payload = failure as PhoneCodeLoginRejected; setLoginStatus( - `验证码登录失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`, + `验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`, ); break; } - case 'smsCodeRejected': { - setLoginStatus('验证码错误或已过期'); - break; - } case 'sessionInvalidated': { setLoginStatus('登录状态已失效,请重新登录'); break; @@ -863,14 +853,10 @@ export function AuthenticatedClient({ case 'phoneCodeLoginRejected': { const payload = failure as PhoneCodeLoginRejected; setLoginStatus( - `验证码登录失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`, + `验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`, ); break; } - case 'smsCodeRejected': { - setLoginStatus('验证码错误或已过期'); - break; - } case 'sessionInvalidated': { setLoginStatus('登录状态已失效,请重新登录'); break; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts index d52f178dc..9e87dc86d 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -24,7 +24,6 @@ export type ClientAuthError = | ({ type: 'sendCodeRejected' } & SendCodeRejected) | { type: 'smsCodeThrottled' } | ({ type: 'phoneCodeLoginRejected' } & PhoneCodeLoginRejected) - | { type: 'smsCodeRejected' } | { type: 'sessionInvalidated' } | { type: 'permissionDenied' } | ({ type: 'authNetworkFailure' } & AuthNetworkFailure) diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index 4ae2f922b..d8b0b27a2 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -73,8 +73,6 @@ function variantMessage(error: ClientAuthError): string { return ( (error as PhoneCodeLoginRejected).serverMessage ?? '验证码登录被拒绝' ); - case 'smsCodeRejected': - return '验证码错误或过期'; case 'sessionInvalidated': return '登录态失效'; case 'permissionDenied': diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 105172cce..3ee3053a5 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -59,6 +59,8 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - `429` 同样按路由判定:发码路由是频控(`smsCodeThrottled`),登录路由是「验证码错误次数过多」 (复用 `phoneCodeLoginRejected`,仍是用户可修正的输入问题、不进池),其余路由的 `429` 才落到 `unexpectedRejection`。 +- `/api/auth/phone/login` 的 `401` 只来自「用户不存在」(验证码错误/失效/过期都是 `400`),同样归 + `phoneCodeLoginRejected`;为此退役的 `smsCodeRejected` 曾把 401 冒充成「验证码错误或过期」,属于错配。 - 本地前置校验只做布尔判定、拿不到更细事实时不编字段:手机号校验 `phone_is_valid` 只回 true/false, 所以 `phoneNumberInvalid` 保持无字段,提示由调用方给固定文案;编不出事实的"细分原因"不硬造。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0a646bdc7..8cd0522d4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -12,6 +12,7 @@ - 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `reason` / `serverMessage` / `status` 拼上本次操作的上下文前缀(`reason` 是枚举时再 `switch (payload.reason)`);Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 - 追加(2026-10-02,429 按路由判定):`/api/auth/phone/login` 验证码错误次数耗尽返回的 429 是用户可修正的输入问题,映射为 `phoneCodeLoginRejected`(复用现有业务变体、不进错误池);发码路由仍是 `smsCodeThrottled`,其余路由的 429 仍是 `unexpectedRejection`。 +- 追加(2026-10-02,401 归 phoneCodeLoginRejected):`/api/auth/phone/login` 的 401 只来自「用户不存在」(验证码错误/失效/过期在服务端都是 400,已由 `phoneCodeLoginRejected { serverMessage }` 带原文);顶层变体 `smsCodeRejected` 退役删除,前端三个 catch 去掉了它那个「验证码错误或已过期」的固定分支,`phoneCodeLoginRejected` 的文案统一为「验证码登录失败:<服务端原文>」。 - 追加(2026-10-02,系统类失败保留原始错误载荷):`clientSessionPersistFailed` / `runtimeSessionInstallFailed` / `authClientInitFailed` 都带 `detail: string`(原始 error),既让调用方有机会分流处理,也让报告包带够诊断信息;原始 error 同时经 `app_log!`(落盘前过 `sanitize_diagnostic_message`)记一行本地日志。`detail` 不贴到界面上:三个 catch 用本操作的固定文案(登录检查 / 发码 / 登录各自不同)。取代上一版"本机 IO 失败不进载荷、原始 error 只进日志"。 - 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 -- 2.52.0 From a51278699600c33cb4f14f9017ab041aec553d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 13:47:03 +0800 Subject: [PATCH 69/70] =?UTF-8?q?=E8=AF=BBbody=E5=A4=B1=E8=B4=A5=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=B7=B2=E7=A1=AE=E8=AE=A4=E7=8A=B6=E6=80=81=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_session:拿到 status 后 response.text() 失败不再一律报网络不可达;非 2xx 按状态码走既有分类(serverMessage 为 None,如 503 → authServiceUnavailable),只有 2xx 响应没收完才归传输层故障 - 抽出 classify_unreadable_body 并补单测(503 / 会话 401 / 400 / 2xx) - 决策记录追加该口径 --- .../src-tauri/src/auth_session.rs | 50 ++++++++++++++++--- .../shared-memory/decision-log.md | 1 + 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index d10e65779..25ea5b770 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -573,6 +573,20 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA }) } +/// body 读取失败时的归类。 +/// +/// 状态码此时已经拿到:非 2xx 按状态码走已有分类(body 没读到,服务端原文就是 `None`), +/// 不把服务端失败误报成传输层故障;只有 2xx 响应没收完才归网络不可达。 +fn classify_unreadable_body(status: StatusCode, route: AuthRoute) -> ClientAuthError { + if status.is_success() { + ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { + reason: AuthNetworkReason::Unreachable, + }) + } else { + map_auth_failure(status, "", route) + } +} + fn response_data(body: &str) -> Result { let value: Value = serde_json::from_str(body) .map_err(|_| response_invalid(AuthResponseInvalidReason::NotJson))?; @@ -656,12 +670,10 @@ async fn request_auth( CookiePolicy::Capture | CookiePolicy::Require => refresh_cookie_from_response(&response), CookiePolicy::Ignore => None, }; - // 拿到状态码后读 body 失败属于传输层故障:按网络不可达上报,不猜契约。 - let text = response.text().await.map_err(|_| { - ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { - reason: AuthNetworkReason::Unreachable, - }) - })?; + let text = match response.text().await { + Ok(text) => text, + Err(_) => return Err(classify_unreadable_body(status, auth_route(route))), + }; if !status.is_success() { return Err(map_auth_failure(status, &text, auth_route(route))); } @@ -1407,6 +1419,32 @@ mod tests { ); } + /// 读 body 失败时状态码已经拿到,不能被压成「网络不可达」。 + #[test] + fn unreadable_body_keeps_the_known_status() { + assert_eq!( + classify_unreadable_body(StatusCode::SERVICE_UNAVAILABLE, AuthRoute::PhoneLogin), + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }) + ); + assert_eq!( + classify_unreadable_body(StatusCode::UNAUTHORIZED, AuthRoute::Session), + ClientAuthError::SessionInvalidated + ); + assert_eq!( + classify_unreadable_body(StatusCode::BAD_REQUEST, AuthRoute::PasswordEntry), + ClientAuthError::PasswordLoginRejected(PasswordLoginRejected { + server_message: None + }) + ); + // 只有 2xx 响应没收完才是传输层故障。 + assert_eq!( + classify_unreadable_body(StatusCode::OK, AuthRoute::Other), + ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { + reason: AuthNetworkReason::Unreachable, + }) + ); + } + #[test] fn keepalive_only_refreshes_after_the_freshness_window() { let issued = 1_000_000_u64; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8cd0522d4..2585c35d1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -13,6 +13,7 @@ - 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 - 追加(2026-10-02,429 按路由判定):`/api/auth/phone/login` 验证码错误次数耗尽返回的 429 是用户可修正的输入问题,映射为 `phoneCodeLoginRejected`(复用现有业务变体、不进错误池);发码路由仍是 `smsCodeThrottled`,其余路由的 429 仍是 `unexpectedRejection`。 - 追加(2026-10-02,401 归 phoneCodeLoginRejected):`/api/auth/phone/login` 的 401 只来自「用户不存在」(验证码错误/失效/过期在服务端都是 400,已由 `phoneCodeLoginRejected { serverMessage }` 带原文);顶层变体 `smsCodeRejected` 退役删除,前端三个 catch 去掉了它那个「验证码错误或已过期」的固定分支,`phoneCodeLoginRejected` 的文案统一为「验证码登录失败:<服务端原文>」。 +- 追加(2026-10-02,读 body 失败按已确认状态码归类):AGC 认证请求拿到 `status` 后 `response.text()` 失败,不再一律压成 `authNetworkFailure { unreachable }`;非 2xx 走既有分类(`serverMessage` 为 `None`,如 503 → `authServiceUnavailable { 503 }`),只有 2xx 响应没收完才算传输层故障。分类收敛在 `classify_unreadable_body`。 - 追加(2026-10-02,系统类失败保留原始错误载荷):`clientSessionPersistFailed` / `runtimeSessionInstallFailed` / `authClientInitFailed` 都带 `detail: string`(原始 error),既让调用方有机会分流处理,也让报告包带够诊断信息;原始 error 同时经 `app_log!`(落盘前过 `sanitize_diagnostic_message`)记一行本地日志。`detail` 不贴到界面上:三个 catch 用本操作的固定文案(登录检查 / 发码 / 登录各自不同)。取代上一版"本机 IO 失败不进载荷、原始 error 只进日志"。 - 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 -- 2.52.0 From 27f7375b5103c9808056f7c7898684b37d8b2a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 13:48:26 +0800 Subject: [PATCH 70/70] =?UTF-8?q?=E6=8B=92=E7=BB=9D=E6=A1=A5=E5=90=88?= =?UTF-8?q?=E6=88=90=E4=BA=8B=E4=BB=B6=E5=B8=A6=E4=B8=8Apromise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unhandledRejectionBridge:合成事件优先用 PromiseRejectionEvent(jsdom 没这个构造器时退回普通 Event),再把 reason / promise 都挂成实例访问器,jsdom 分支下 promise 也可读 - 判定调整为读 reason 或 promise 任一个都算 window 侧接手(原来只认 reason) - 补一条用例:事件带 promise 且读 promise 也算被消费 --- .../tests/unhandledRejectionBridge.test.ts | 17 +++++++ .../tests/unhandledRejectionBridge.ts | 49 +++++++++++++++---- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts index 9b796227d..94287a7cb 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts @@ -86,3 +86,20 @@ test('交回原监听器时 this 绑成 process,和 Node EventEmitter 一致', expect(receivers).toEqual([process]); }); + +test('合成事件同时带 reason 与 promise,读任一个都算被消费', () => { + const forwarded = trackProcessListener(); + let received: unknown = null; + const listener = (event: Event) => { + received = (event as Event & { promise?: unknown }).promise; + }; + windowListeners.push(listener); + window.addEventListener('unhandledrejection', listener); + installed.push(installUnhandledRejectionBridge()); + + const promise = Promise.resolve(); + process.emit('unhandledRejection', new Error('带 promise 的拒绝'), promise); + + expect(received).toBe(promise); + expect(forwarded).toEqual([]); +}); diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts index 7e5a5aac6..41f5a4046 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts @@ -4,10 +4,10 @@ * 生产 WebView 里,`void` 掉的 async handler(`onSubmit` / `onClick`)抛出的拒绝会派发 * `unhandledrejection`,被 `AuthenticatedClient` 的全局 handler 交给错误池。jsdom 既不派发 * 该事件,vitest 又会把未处理的拒绝当成用例失败;需要观察这条链路的用例用它接管 Node - * 监听器,再合成一个带 `reason` 的 window 事件。 + * 监听器,再合成一个与 WebView `PromiseRejectionEvent` 同形(`reason` + `promise`)的 window 事件。 * - * 只抑制**被 window 侧消费**的拒绝:合成事件的 `reason` 是访问器,任何监听方读过它就算 - * 有人接手;没人读说明这条拒绝是意外的,交回安装前的监听器(含 vitest 的失败上报), + * 只抑制**被 window 侧消费**的拒绝:合成事件的 `reason` / `promise` 是访问器,任何监听方读过 + * 任一个就算有人接手;没人读说明这条拒绝是意外的,交回安装前的监听器(含 vitest 的失败上报), * 不会被静默吞掉。 * * 安装前用 `rawListeners` 取原始监听器:`process.once` 注册的包装(`.listener`)在拆卸时 @@ -32,6 +32,41 @@ function invokeRejectionListener( Reflect.apply(listener.listener ?? listener, process, [reason, promise]); } +/** + * 合成一个与 WebView `unhandledrejection` 同形的 window 事件。 + * + * 浏览器里本来就是 `PromiseRejectionEvent`(带 `reason` / `promise`),有构造器就直接用它; + * jsdom 不实现这个类时退回普通 `Event`。两种来源都再把 `reason` / `promise` 覆盖成实例访问器: + * 既保证 jsdom 分支下 `promise` 同样可读,也让「读过任一个字段」都能记成有人接手。 + */ +function createRejectionEvent( + reason: unknown, + promise: Promise, + markConsumed: () => void, +): Event { + const event = + typeof PromiseRejectionEvent === 'function' + ? new PromiseRejectionEvent('unhandledrejection', { promise, reason }) + : new Event('unhandledrejection'); + Object.defineProperties(event, { + reason: { + get() { + markConsumed(); + return reason; + }, + configurable: true, + }, + promise: { + get() { + markConsumed(); + return promise; + }, + configurable: true, + }, + }); + return event; +} + export function installUnhandledRejectionBridge() { const originalListeners = process.rawListeners( 'unhandledRejection', @@ -39,12 +74,8 @@ export function installUnhandledRejectionBridge() { process.removeAllListeners('unhandledRejection'); const handleRejection = (reason: unknown, promise: Promise) => { let consumed = false; - const event = new Event('unhandledrejection'); - Object.defineProperty(event, 'reason', { - get() { - consumed = true; - return reason; - }, + const event = createRejectionEvent(reason, promise, () => { + consumed = true; }); window.dispatchEvent(event); if (!consumed) { -- 2.52.0