文档:AGC认证错误改为顶层类别加类型化reason

- 更新 AGC 命令错误结构化 ADR:顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 reason 字段,不再拆成几十个顶层变体、也不用字符串
- 更新 AGC 认证失败 JS 侧载体 ADR:带载荷分支先 as 取具名类型,reason 是枚举时再 switch(payload.reason),两处 default 都用 expectNever
- 同步错误报告技术方案、README 索引、决策记录与踩坑的拍平变体口径
This commit is contained in:
2026-10-02 02:42:15 +08:00
parent 3d6eade206
commit 9fe692ba87
6 changed files with 45 additions and 28 deletions
@@ -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` 仍是通用
@@ -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 加了变体而这里