diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
index 612d21826..feb4ce9d3 100644
--- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
+++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
@@ -128,14 +128,17 @@ test('协议与 Agent 模式是独立字段,可按模型单独选择', async (
render();
await screen.findByDisplayValue('claude-opus-5-5');
- const agentMode = screen.getByLabelText('模型 1 Agent 模式') as HTMLSelectElement;
+ const agentMode = screen.getByLabelText(
+ '模型 1 Agent 模式',
+ ) as HTMLSelectElement;
const protocol = screen.getByLabelText('模型 1 协议') as HTMLSelectElement;
expect(agentMode.value).toBe('cc');
expect(protocol.value).toBe('anthropic');
// 执行器下拉里不再混入协议取值。
- expect(
- Array.from(agentMode.options).map((option) => option.value),
- ).toEqual(['codex', 'cc']);
+ expect(Array.from(agentMode.options).map((option) => option.value)).toEqual([
+ 'codex',
+ 'cc',
+ ]);
fireEvent.change(agentMode, { target: { value: 'codex' } });
fireEvent.change(protocol, { target: { value: 'openai_chat' } });
diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs
index a7dda892c..8a79804c2 100644
--- a/apps/ai-game-creator-shell/scripts/check-config.mjs
+++ b/apps/ai-game-creator-shell/scripts/check-config.mjs
@@ -433,6 +433,7 @@ const APP_INVOKE_BARE_CALL_NAMES = new Set([
'directInvoke',
'invokeInput',
'invokeAuthenticatedInput',
+ 'invokeClientAuth',
'invokeDiagnostic',
]);
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..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
@@ -16,8 +16,12 @@
//!
//! 事件载荷(`thread_manager::wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段:
//! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是
-//! 另一种状态模型。跨进程边界(`#[tauri::command]`)同样只给前端一个字符串:那是**序列化**,
-//! 由 `Display` 一处生成;Rust 侧任何地方都不再解析这个字符串。
+//! 另一种状态模型。跨进程边界(`#[tauri::command]`)的**入队失败**载荷是结构化的
+//! [`DirectTurnEnqueueFailure`](变体 + 一条可展示 `message`);`message` 有两条来源:
+//! 不需要留痕的失败用 [`DirectTurnError`] 的 `Display`,需要留痕的宿主 / 环境事实改用
+//! `record_direct_codex_failure` 生成的脱敏诊断收口文案(`direct-codex-failure:v2 …`,同时写
+//! `.agent/runtime/errors` 与错误上报池)。其余只需要一句文案的出口仍由 `Display` 生成。
+//! 无论哪条来源,Rust 侧任何地方都不解析这个字符串。
//!
//! 谁负责产生哪个变体:
//! - 命令入口与回合编排(`direct_runtime`):调用级拒绝、阶段失败;
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..8c62e8deb
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs
@@ -0,0 +1,397 @@
+//! AGC 认证命令的结构化错误:从命令入口到出口只传这一种错误。
+//!
+//! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。
+//!
+//! 顶层只放**调用方要分流的类别**;同一类里可枚举的细分原因收进**类型化 `reason` 字段**,
+//! 而不是各拆一个变体,也不是字符串。例如服务地址校验的 7 种失败、网络失败的超时/不可达、
+//! 响应契约的 5 种破损各自只占一个变体:前端 `switch (failure.type)` 选到类别后,再用
+//! `switch (payload.reason)` 在**类型化**的细分上分流,仍然不碰文案。
+//!
+//! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码、本机 IO /
+//! 网络客户端构建失败的原始错误 `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在
+//! 自己的 catch 分支按当前操作拼接。`detail` 是给调用方分流与诊断用的原始事实,**不直接贴在界面上**;
+//! 报告侧由 sanitize 把路径等替换成占位符。
+
+use serde::Serialize;
+use ts_rs::TS;
+
+/// 变体名就是线上的分流键(`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 {
+ // ---- 业务:用户自己能改,调用方给提示,不上报 ----
+ /// 服务地址校验失败(含渠道范围门禁),细分原因见 [`ServerAddressReason`]。
+ ServerAddressRejected(ServerAddressRejected),
+ /// 本地前置校验:手机号为空、超长或不是纯数字(校验器只回 bool,没有更细的事实)。
+ 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/401:服务端拒绝本次验证码登录。
+ ///
+ /// 401 目前只来自「用户不存在」;该路由验证通过后会即时建号,所以这条分支实际很少触发。
+ PhoneCodeLoginRejected(PhoneCodeLoginRejected),
+
+ // ---- 会话:调用方按"未登录"处理,不给用户报错 ----
+ /// 会话路由 401:登录态失效。
+ SessionInvalidated,
+ /// 会话路由 403:当前账号没有执行此操作的权限。
+ PermissionDenied,
+
+ // ---- 系统:调用方处理不了,带上下文重抛 ----
+ /// 连接登录服务的传输层失败,细分原因见 [`AuthNetworkReason`]。
+ AuthNetworkFailure(AuthNetworkFailure),
+ /// 登录服务 5xx。
+ AuthServiceUnavailable(AuthServiceUnavailable),
+ /// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。
+ UnexpectedRejection(UnexpectedRejection),
+ /// 登录服务响应的契约破损,细分原因见 [`AuthResponseInvalidReason`]。
+ AuthResponseInvalid(AuthResponseInvalid),
+ /// 本机登录凭据文件读写失败;`detail` 是原始错误,供调用方与诊断用、不直接展示。
+ ClientSessionPersistFailed(ClientSessionPersistFailed),
+ /// 本机运行时会话安装 / 清理失败;`detail` 同上。
+ RuntimeSessionInstallFailed(RuntimeSessionInstallFailed),
+ /// 认证网络客户端构建失败;`detail` 是原始错误。
+ AuthClientInitFailed(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")]
+#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
+pub(crate) struct PasswordLoginRejected {
+ /// 服务端原文可能缺失:缺失是 `null`,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 SendCodeRejected {
+ /// 服务端原文可能缺失:缺失是 `null`,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 PhoneCodeLoginRejected {
+ /// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。
+ 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, 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 {
+ pub(crate) status: u16,
+}
+
+/// 其它未识别拒绝的状态码与服务端原文。
+#[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) 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 AuthResponseInvalid {
+ pub(crate) reason: AuthResponseInvalidReason,
+ /// 只有 `serverRejected` 可能带服务端原文;其余是 `null`。
+ 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,
+}
+
+/// 认证网络客户端构建失败的原始错误明细。
+#[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 是「登录态失效」:调用方据此清会话、按未登录处理,
+ /// 既不给用户报错,也不进错误报告池。
+ pub(crate) fn is_authority_failure(&self) -> bool {
+ matches!(self, Self::SessionInvalidated | Self::PermissionDenied)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn wire_variant_names_are_the_frontend_dispatch_keys() {
+ let cases = [
+ (
+ ClientAuthError::ServerAddressRejected(ServerAddressRejected {
+ reason: ServerAddressReason::NotHttps,
+ }),
+ "serverAddressRejected",
+ ),
+ (ClientAuthError::PhoneNumberInvalid, "phoneNumberInvalid"),
+ (ClientAuthError::PasswordMissing, "passwordMissing"),
+ (ClientAuthError::LoginCodeMissing, "loginCodeMissing"),
+ (
+ ClientAuthError::PasswordLoginRejected(PasswordLoginRejected {
+ server_message: Some("x".to_string()),
+ }),
+ "passwordLoginRejected",
+ ),
+ (
+ ClientAuthError::PhoneOrPasswordMismatch,
+ "phoneOrPasswordMismatch",
+ ),
+ (
+ ClientAuthError::SendCodeRejected(SendCodeRejected {
+ server_message: Some("x".to_string()),
+ }),
+ "sendCodeRejected",
+ ),
+ (ClientAuthError::SmsCodeThrottled, "smsCodeThrottled"),
+ (
+ ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected {
+ server_message: Some("x".to_string()),
+ }),
+ "phoneCodeLoginRejected",
+ ),
+ (ClientAuthError::SessionInvalidated, "sessionInvalidated"),
+ (ClientAuthError::PermissionDenied, "permissionDenied"),
+ (
+ ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
+ reason: AuthNetworkReason::Timeout,
+ }),
+ "authNetworkFailure",
+ ),
+ (
+ ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }),
+ "authServiceUnavailable",
+ ),
+ (
+ ClientAuthError::UnexpectedRejection(UnexpectedRejection {
+ status: 409,
+ server_message: None,
+ }),
+ "unexpectedRejection",
+ ),
+ (
+ ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
+ reason: AuthResponseInvalidReason::ServerRejected,
+ server_message: None,
+ }),
+ "authResponseInvalid",
+ ),
+ (
+ ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed {
+ detail: "x".to_string(),
+ }),
+ "clientSessionPersistFailed",
+ ),
+ (
+ ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed {
+ detail: "x".to_string(),
+ }),
+ "runtimeSessionInstallFailed",
+ ),
+ (
+ ClientAuthError::AuthClientInitFailed(AuthClientInitFailed {
+ detail: "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)
+ );
+ }
+ }
+
+ #[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" }));
+ }
+
+ #[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(
+ AuthServiceUnavailable { status: 503 },
+ ))
+ .expect("serialize auth error");
+ assert_eq!(unavailable["type"], "authServiceUnavailable");
+ assert_eq!(unavailable["status"], 503);
+
+ let rejection =
+ serde_json::to_value(ClientAuthError::UnexpectedRejection(UnexpectedRejection {
+ status: 409,
+ server_message: Some("冲突".to_string()),
+ }))
+ .expect("serialize auth error");
+ 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(),
+ },
+ ))
+ .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]
+ 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::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 2b0e3e940..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
@@ -4,6 +4,12 @@
//! 只在 Rust 内存与本进程会话快照里,refresh 凭据只写在 AppData 私有文件里。换号、登出或
//! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。
+use crate::auth_error::{
+ 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};
use reqwest::{header::SET_COOKIE, Method, StatusCode};
@@ -32,26 +38,17 @@ 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_AUTHORITY_ERROR: &str = "authentication-required: 登录状态已失效,请重新登录";
/// 认证态投影:只含状态、用户展示字段与 origin,不含 token 或 refresh 凭据。
#[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,
- /// 失败分类:`network` / `authority` / `contract`;成功或未登录时为 `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,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
@@ -61,18 +58,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)]
@@ -221,42 +217,82 @@ 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| {
+ let detail = format!("无法读取 AGC 应用数据目录:{error}");
+ app_log!("{detail}");
+ ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { detail })
+ })
+}
+
+/// 凭据文件相关的字符串错误统一收口成凭据落盘失败变体。
+fn session_persist_error(message: impl Into) -> ClientAuthError {
+ let detail = message.into();
+ app_log!("{detail}");
+ ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { detail })
+}
+
+/// 本机运行时安装 / 清理失败的字符串错误统一收口。
+fn runtime_session_error(message: impl Into) -> ClientAuthError {
+ let detail = message.into();
+ app_log!("{detail}");
+ ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { detail })
+}
+
+/// 服务地址校验失败统一收口成带类型化原因的变体。
+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。发布构建额外
/// 限制在已知渠道 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(server_address_rejected(ServerAddressReason::EmptyOrTooLong));
}
- let parsed = Url::parse(trimmed).map_err(|_| "陶泥儿服务地址无效".to_string())?;
+ let parsed =
+ Url::parse(trimmed).map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?;
if !parsed.username().is_empty() || parsed.password().is_some() {
- return Err("陶泥儿服务地址不能包含凭据".to_string());
+ return Err(server_address_rejected(ServerAddressReason::HasCredentials));
}
if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some()
{
- return Err("陶泥儿服务地址必须是纯地址,不能带路径或参数".to_string());
+ 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("非本机服务器必须使用 HTTPS".to_string()),
- _ => return Err("陶泥儿服务地址必须是 HTTP(S) 地址".to_string()),
+ "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("服务器地址不在当前构建渠道范围内".to_string());
+ return Err(server_address_rejected(ServerAddressReason::OutsideChannel));
}
Ok(trimmed.to_string())
}
@@ -271,19 +307,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 +349,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 +365,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