Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs
T
kdletters 75ca16739a 修复 AGC 登录丢失会话主体导致的身份无效
- auth_session.rs 新增 SessionIdentity:登录主体取自响应用户投影、续期主体取自已持久化凭据文件,漏传主体不再是能写出来的状态
- complete_login 改用 SessionIdentity::Login(payload.user),删掉原先写死的空 user_id 字符串
- commit_authenticated_session 在写 client-session.json 之前解析并校验主体,空主体失败关闭且不再留下半截会话文件
- 新增 validated_session_user_id:主体 trim 归一化,空值报「登录失败:登录服务未返回用户身份」
- 补 auth_session 定向用例三条:登录响应供主体、续期只带主体、空主体失败关闭
2026-09-29 12:14:59 +08:00

1466 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AGC 认证会话:凭据持有、登录/续期/登出 HTTP 与本地运行时会话安装。
//!
//! 渲染层只表达「用哪个手机号/密码/验证码登录」「哪个 origin」这类用户意图;access token
//! 只在 Rust 内存与本进程会话快照里,refresh 凭据只写在 AppData 私有文件里。换号、登出或
//! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。
use crate::http_client::agc_main_site_client_builder;
use crate::platform_session::{current_platform_session, PlatformSessionSnapshot};
use reqwest::{header::SET_COOKIE, Method, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use shared_contracts::auth::AuthUserPayload;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use tauri::{Emitter, Manager};
use url::Url;
pub(crate) const CLIENT_AUTH_STATE_CHANGED_EVENT: &str = "agc-client-auth-state-changed";
const SESSION_FILE_NAME: &str = "client-session.json";
const SESSION_SCHEMA_VERSION: &str = "agc-client-session.v1";
const RELEASE_ORIGIN: &str = "https://www.genarrative.world";
const DEVELOPMENT_ORIGIN: &str = "https://dev.genarrative.world";
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_ORIGIN_CHARS: usize = 2_048;
const MAX_PHONE_CHARS: usize = 32;
const MAX_SECRET_CHARS: usize = 8_192;
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`。
pub(crate) status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) user: Option<AuthUserPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) api_base_url: Option<String>,
/// 失败分类:`network` / `authority` / `contract`;成功或未登录时为 `None`。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) error_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) error_message: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ClientLoginCodeView {
pub(crate) cooldown_seconds: u64,
pub(crate) expires_in_seconds: u64,
}
/// 续期结果。`stale` 表示续期期间身份已经变化,调用方不得重放旧身份请求。
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ClientAuthRefreshView {
/// `refreshed` / `unauthenticated` / `stale` / `failed`。
pub(crate) status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) user: Option<AuthUserPayload>,
#[serde(default)]
pub(crate) authoritative: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) error_message: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ClientSessionFile {
#[serde(default = "session_schema_version")]
schema_version: String,
#[serde(default)]
api_base_url: String,
#[serde(default)]
user_id: String,
#[serde(default)]
refresh_cookie_name: String,
#[serde(default)]
refresh_cookie_value: String,
}
impl ClientSessionFile {
fn is_complete(&self) -> bool {
!self.api_base_url.is_empty()
&& !self.user_id.is_empty()
&& !self.refresh_cookie_name.is_empty()
&& !self.refresh_cookie_value.is_empty()
}
}
fn session_schema_version() -> String {
SESSION_SCHEMA_VERSION.to_string()
}
/// 进程内的会话代次与最近一次登录身份。
///
/// access token 不在这里:它只在 `platform_session` 快照中,避免出现两份凭据副本。
#[derive(Default)]
struct AuthState {
session: Option<ClientSessionFile>,
identity_generation: u64,
revision: u64,
counters_seeded: bool,
/// 当前 access token 的签发时间(本进程视角),用于按到期时间自助续期。
access_token_issued_at: Option<u64>,
}
static AUTH_STATE: std::sync::OnceLock<Mutex<AuthState>> = std::sync::OnceLock::new();
static REFRESH_LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
static AUTH_APP_HANDLE: std::sync::OnceLock<tauri::AppHandle> = std::sync::OnceLock::new();
/// 会话保活:Direct 回合可能横跨图片生成、构建和浏览器验证,出站调用前必须保证 access
/// token 仍然新鲜。保持时间比旧的「渲染层每 5 分钟刷新一次」更短,避免边界抖动。
const CLIENT_SESSION_REFRESH_AFTER_MILLIS: u64 = 4 * 60 * 1_000;
const CLIENT_SESSION_KEEPALIVE_TICK: Duration = Duration::from_secs(30);
/// 保存 AppHandle,供 Rust 自己发起的会话保活使用(渲染层不再参与保活调度)。
pub(crate) fn initialize_auth_session(app: &tauri::AppHandle) {
let _ = AUTH_APP_HANDLE.set(app.clone());
}
fn unix_millis_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or_default()
}
/// 是否需要为当前会话续期:不知道签发时间时按「需要」处理。
fn session_needs_refresh(access_token_issued_at: Option<u64>, now_millis: u64) -> bool {
match access_token_issued_at {
None => true,
Some(issued_at) => {
now_millis.saturating_sub(issued_at) >= CLIENT_SESSION_REFRESH_AFTER_MILLIS
}
}
}
fn client_session_needs_refresh() -> bool {
let issued_at = auth_state()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.access_token_issued_at;
// 没有本进程会话(未登录 / 已登出)时不触发续期。
current_platform_session().is_some() && session_needs_refresh(issued_at, unix_millis_now())
}
/// Direct 回合期间由 Rust 持有的会话保活任务;drop 即停止。
#[derive(Debug)]
pub(crate) struct ClientSessionKeepalive {
task: tauri::async_runtime::JoinHandle<()>,
}
impl Drop for ClientSessionKeepalive {
fn drop(&mut self) {
self.task.abort();
}
}
/// 启动 Rust 侧会话保活;没有 AppHandle(未初始化)时返回 `None`。
pub(crate) fn spawn_client_session_keepalive() -> Option<ClientSessionKeepalive> {
let app = AUTH_APP_HANDLE.get()?.clone();
let task = tauri::async_runtime::spawn(async move {
let mut ticker = tokio::time::interval(CLIENT_SESSION_KEEPALIVE_TICK);
loop {
ticker.tick().await;
if !client_session_needs_refresh() {
continue;
}
let _ = refresh_session_inner(&app, None).await;
}
});
Some(ClientSessionKeepalive { task })
}
fn auth_state() -> &'static Mutex<AuthState> {
AUTH_STATE.get_or_init(|| Mutex::new(AuthState::default()))
}
fn refresh_lock() -> &'static tokio::sync::Mutex<()> {
REFRESH_LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TokenUserResponse {
token: String,
user: AuthUserPayload,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TokenResponse {
token: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MeResponse {
#[serde(default)]
user: Option<AuthUserPayload>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SendCodeResponse {
#[serde(default)]
cooldown_seconds: u64,
#[serde(default)]
expires_in_seconds: u64,
}
fn session_file_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map(|root| root.join(SESSION_FILE_NAME))
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
}
/// 校验并归一化平台服务 origin。
///
/// 只接受纯 origin:不允许凭据、路径、查询或 fragment;非本机必须 HTTPS。发布构建额外
/// 限制在已知渠道 origin 内,避免调试期写入的自定义地址在正式包里被继续使用。
pub(crate) fn validate_client_api_base_url(value: &str) -> Result<String, String> {
let trimmed = value.trim().trim_end_matches('/');
if trimmed.is_empty() || trimmed.chars().count() > MAX_ORIGIN_CHARS {
return Err("陶泥儿服务地址无效".to_string());
}
let parsed = Url::parse(trimmed).map_err(|_| "陶泥儿服务地址无效".to_string())?;
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("陶泥儿服务地址不能包含凭据".to_string());
}
if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some()
{
return Err("陶泥儿服务地址必须是纯地址,不能带路径或参数".to_string());
}
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()),
}
if !cfg!(debug_assertions)
&& !matches!(trimmed, RELEASE_ORIGIN | DEVELOPMENT_ORIGIN)
&& !matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]")
{
return Err("服务器地址不在当前构建渠道范围内".to_string());
}
Ok(trimmed.to_string())
}
fn read_session_file_at(path: &Path) -> Option<ClientSessionFile> {
let metadata = fs::symlink_metadata(path).ok()?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return None;
}
let content = fs::read_to_string(path).ok()?;
let file = serde_json::from_str::<ClientSessionFile>(&content).ok()?;
file.is_complete().then_some(file)
}
fn write_session_file_at(path: &Path, session: Option<&ClientSessionFile>) -> Result<(), String> {
let Some(session) = session else {
if fs::symlink_metadata(path).is_ok() {
fs::remove_file(path).map_err(|error| format!("清除客户端登录凭据失败:{error}"))?;
}
return Ok(());
};
let parent = path
.parent()
.ok_or_else(|| "客户端登录凭据缺少父目录".to_string())?;
crate::ensure_game_creator_private_directory_tree(parent, "客户端登录凭据目录")?;
let content = serde_json::to_string_pretty(session)
.map_err(|error| format!("序列化客户端登录凭据失败:{error}"))?;
let temp_path = path.with_file_name(format!(
".{}.tmp.{}.{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or(SESSION_FILE_NAME),
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let mut options = OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
#[cfg(windows)]
{
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}"))?;
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);
}
let write_result = handle
.write_all(format!("{content}\n").as_bytes())
.and_then(|_| handle.sync_all());
drop(handle);
if let Err(error) = write_result {
let _ = fs::remove_file(&temp_path);
return Err(format!("写入客户端登录凭据失败:{error}"));
}
if fs::symlink_metadata(path).is_ok() {
crate::prepare_game_creator_private_path_for_read(path, false, "客户端登录凭据")?;
#[cfg(windows)]
fs::remove_file(path).map_err(|error| {
let _ = fs::remove_file(&temp_path);
format!("替换客户端登录凭据失败:{error}")
})?;
}
if let Err(error) = fs::rename(&temp_path, path) {
let _ = fs::remove_file(&temp_path);
return Err(format!("提交客户端登录凭据失败:{error}"));
}
Ok(())
}
fn require_app_session(app: &tauri::AppHandle) -> Result<Option<ClientSessionFile>, String> {
let path = session_file_path(app)?;
let mut state = auth_state()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if state.session.is_none() {
state.session = read_session_file_at(&path);
}
Ok(state.session.clone())
}
/// 推进下一次原生写入代次:换号与登出推进身份代次,同一身份续期只推进 revision。
fn reserve_session_write(identity_change: bool) -> (u64, u64) {
let mut state = auth_state()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !state.counters_seeded {
let floor = crate::platform_session::current_platform_session_write_state();
state.identity_generation = state.identity_generation.max(floor.identity_generation);
state.revision = state.revision.max(floor.revision);
state.counters_seeded = true;
}
state.revision = state.revision.saturating_add(1);
if identity_change {
state.identity_generation = state.identity_generation.saturating_add(1);
}
(state.identity_generation, state.revision)
}
fn current_session_origin() -> Option<String> {
current_platform_session().map(|snapshot| snapshot.api_base_url)
}
fn endpoint(origin: &str, route: &str) -> Result<String, String> {
let mut url = Url::parse(&format!("{}/", origin.trim_end_matches('/')))
.map_err(|_| "陶泥儿服务地址无效".to_string())?;
{
let mut segments = url
.path_segments_mut()
.map_err(|_| "陶泥儿服务地址无效".to_string())?;
for segment in route.trim_start_matches('/').split('/') {
if segment.is_empty() {
continue;
}
segments.push(segment);
}
}
Ok(url.to_string())
}
fn build_client() -> Result<reqwest::Client, String> {
agc_main_site_client_builder()
.connect_timeout(Duration::from_secs(10))
.timeout(HTTP_TIMEOUT)
.build()
.map_err(|_| "创建登录网络客户端失败".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 error_message(body: &str) -> Option<String> {
let value = serde_json::from_str::<Value>(body).ok()?;
let error = value.get("error").unwrap_or(&value);
error
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
.map(ToString::to_string)
}
/// 认证路由语义:会话路由的 401/403 是权威失效,登录路由的 401/403 是用户可修正的输入问题。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AuthRouteKind {
Login,
Session,
}
fn auth_route_kind(route: &str) -> AuthRouteKind {
if route.ends_with("/me") || route.ends_with("/refresh") {
AuthRouteKind::Session
} else {
AuthRouteKind::Login
}
}
/// 把一次认证 HTTP 响应归类成稳定文案。
///
/// 会话路由的 `401/403` 带 `authentication-required` / `permission-denied` 前缀,调用方
/// 可以据此清会话;登录路由保留服务端原因(「手机号或密码错误」),不能被改写成登录失效。
/// 网络、5xx 与契约异常必须保留会话。
fn map_auth_failure(status: StatusCode, body: &str, fallback: &str, kind: AuthRouteKind) -> String {
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
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(),
};
}
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(),
};
}
let detail = error_message(body).unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
format!("{fallback}:{detail}")
}
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<Value, String> {
let value: Value =
serde_json::from_str(body).map_err(|_| 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())
));
}
Ok(value.get("data").cloned().unwrap_or(value))
}
/// 从响应的 `Set-Cookie` 里取 refresh 凭据。
///
/// 服务端在登出时下发空值清理 cookie;空值表示「没有 refresh 凭据」,不能当成有效凭据。
fn refresh_cookie_from_response(response: &reqwest::Response) -> Option<(String, String)> {
for value in response.headers().get_all(SET_COOKIE) {
let Ok(raw) = value.to_str() else { continue };
let Some((name, remainder)) = raw.split_once('=') else {
continue;
};
let name = name.trim();
if name.is_empty() {
continue;
}
let cookie_value = remainder.split(';').next().unwrap_or_default().trim();
if cookie_value.is_empty() {
return None;
}
return Some((name.to_string(), cookie_value.to_string()));
}
None
}
#[derive(Clone, Copy)]
enum CookiePolicy {
Capture,
Require,
Ignore,
}
struct AuthResponse {
data: Value,
refresh_cookie: Option<(String, String)>,
}
async fn request_auth(
client: &reqwest::Client,
origin: &str,
route: &str,
body: Option<Value>,
bearer: Option<&str>,
refresh_cookie: Option<&(String, String)>,
policy: CookiePolicy,
fallback: &str,
) -> Result<AuthResponse, String> {
let method = match policy {
// 读取类路由用 GET;写入类路由是 POST。
CookiePolicy::Ignore if route.ends_with("/me") => Method::GET,
_ => Method::POST,
};
let mut request = client
.request(method, endpoint(origin, route)?)
.header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE)
.header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION)
.header(reqwest::header::ACCEPT, "application/json");
if let Some(bearer) = bearer {
request = request.bearer_auth(bearer);
}
if let Some((name, value)) = refresh_cookie {
request = request.header(reqwest::header::COOKIE, format!("{name}={value}"));
}
if let Some(body) = body {
request = request.json(&body);
}
let response = request
.send()
.await
.map_err(|error| network_error_message(&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(|_| format!("{fallback}:读取响应失败"))?;
if !status.is_success() {
return Err(map_auth_failure(
status,
&text,
fallback,
auth_route_kind(route),
));
}
if matches!(policy, CookiePolicy::Require) && captured.is_none() {
return Err("result-unknown: 登录服务未返回新的续期凭据,已停止使用旧凭据".to_string());
}
Ok(AuthResponse {
data: response_data(&text, fallback)?,
refresh_cookie: captured,
})
}
/// 一次会话写入的身份来源。
///
/// 登录路径的主体只能来自本次登录响应的用户投影,续期路径只能来自已持久化的凭据文件;
/// 把来源编码进类型后,「漏传主体」不再是能写出来的状态:`resolve` 是唯一入口。
enum SessionIdentity {
/// 登录:主体与展示字段都取自响应里的用户投影。
Login(AuthUserPayload),
/// 续期:只有已持久化的主体,展示字段随后用新凭据复核。
Persisted(String),
}
impl SessionIdentity {
/// 拆出会话主体与展示用的用户投影:空主体在这里失败关闭,既不能写凭据文件,也不能
/// 装进本进程会话。
fn resolve(self) -> Result<(String, Option<AuthUserPayload>), String> {
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)),
}
}
}
/// 持久化新凭据并安装本进程会话。
async fn commit_authenticated_session(
app: &tauri::AppHandle,
origin: &str,
identity: SessionIdentity,
token: String,
refresh_cookie: (String, String),
identity_change: bool,
) -> Result<AuthUserPayload, String> {
if token.chars().count() > MAX_SECRET_CHARS {
return Err("登录服务返回的凭据无效".to_string());
}
// 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。
let (user_id, known_user) = identity.resolve()?;
let session = ClientSessionFile {
schema_version: session_schema_version(),
api_base_url: origin.to_string(),
user_id: user_id.clone(),
refresh_cookie_name: refresh_cookie.0,
refresh_cookie_value: refresh_cookie.1,
};
let path = session_file_path(app)?;
write_session_file_at(&path, Some(&session))?;
{
let mut state = auth_state()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state.session = Some(session);
state.access_token_issued_at = Some(unix_millis_now());
}
let (identity_generation, revision) = reserve_session_write(identity_change);
crate::commands::install_client_session_locally(
user_id.clone(),
token,
origin.to_string(),
identity_generation,
revision,
)
.await?;
// 续期路径只知道 user_id:展示字段随后会用新 token 通过 /api/auth/me 复核。
Ok(known_user.unwrap_or_else(|| AuthUserPayload {
id: user_id,
public_user_code: String::new(),
display_name: String::new(),
avatar_url: None,
phone_number: None,
phone_number_masked: None,
login_method: String::new(),
binding_status: String::new(),
wechat_bound: false,
wechat_display_name: None,
wechat_account: None,
}))
}
async fn clear_authenticated_session(app: &tauri::AppHandle) -> Result<(), String> {
let path = session_file_path(app)?;
write_session_file_at(&path, None)?;
{
let mut state = auth_state()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state.session = None;
state.access_token_issued_at = None;
}
let (identity_generation, revision) = reserve_session_write(true);
crate::commands::clear_client_session_locally(identity_generation, revision).await
}
fn auth_state_view(
status: &str,
user: Option<AuthUserPayload>,
api_base_url: Option<String>,
) -> ClientAuthStateView {
ClientAuthStateView {
status: status.to_string(),
user,
api_base_url,
error_kind: None,
error_message: None,
}
}
fn emit_auth_state(app: &tauri::AppHandle, view: &ClientAuthStateView) {
let _ = app.emit(CLIENT_AUTH_STATE_CHANGED_EVENT, view.clone());
}
fn phone_is_valid(phone: &str) -> bool {
!phone.is_empty()
&& phone.chars().count() <= MAX_PHONE_CHARS
&& phone.chars().all(|character| character.is_ascii_digit())
}
/// 归一化会话主体:凭据文件与进程快照都靠它判定「是谁」。
///
/// 主体缺失时不能退化成空串,否则本机凭据文件不完整、安装会话还会以「陶泥儿登录用户
/// 身份无效」失败关闭;这里先给出登录语义的明确原因。
fn validated_session_user_id(user_id: &str) -> Result<String, String> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Err("登录失败:登录服务未返回用户身份".to_string());
}
Ok(user_id.to_string())
}
/// 等锁期间别的调用者已经轮换过凭据:复用最新会话,不再轮换第二次。
fn credential_rotated_elsewhere(
before: &ClientSessionFile,
latest: Option<&ClientSessionFile>,
) -> bool {
latest.is_some_and(|latest| {
latest.api_base_url == before.api_base_url
&& latest.user_id == before.user_id
&& latest.refresh_cookie_value != before.refresh_cookie_value
})
}
async fn fetch_current_user(
client: &reqwest::Client,
snapshot: &PlatformSessionSnapshot,
) -> Result<Option<AuthUserPayload>, String> {
let response = request_auth(
client,
&snapshot.api_base_url,
"/api/auth/me",
None,
Some(&snapshot.access_token),
None,
CookiePolicy::Ignore,
"读取当前用户失败",
)
.await?;
let me: MeResponse = serde_json::from_value(response.data)
.map_err(|_| "读取当前用户失败:响应格式无效".to_string())?;
Ok(me.user)
}
/// 用当前 refresh 凭据换一次新 access token,并刷新本进程会话。
///
/// 并发调用复用同一次续期:等锁期间身份代次已经推进时直接返回最新会话,不再轮换第二次
/// refresh 凭据。
async fn refresh_session_inner(
app: &tauri::AppHandle,
expected_user_id: Option<&str>,
) -> Result<ClientAuthRefreshView, String> {
let Some(session) = require_app_session(app)? else {
return Ok(ClientAuthRefreshView {
status: "unauthenticated".to_string(),
user: None,
authoritative: true,
error_message: None,
});
};
let _guard = refresh_lock().lock().await;
let client = build_client()?;
// 双检:等锁期间另一个调用者可能已经完成续期,此时直接复用新会话,
// 不再用刚轮换过的凭据再换一次。
if credential_rotated_elsewhere(&session, require_app_session(app)?.as_ref()) {
if let Some(snapshot) = current_platform_session() {
if snapshot.api_base_url == session.api_base_url && snapshot.user_id == session.user_id
{
if let Ok(Some(user)) = fetch_current_user(&client, &snapshot).await {
return Ok(ClientAuthRefreshView {
status: "refreshed".to_string(),
user: Some(user),
authoritative: false,
error_message: None,
});
}
}
}
}
let cookie = (
session.refresh_cookie_name.clone(),
session.refresh_cookie_value.clone(),
);
let refreshed = request_auth(
&client,
&session.api_base_url,
"/api/auth/refresh",
None,
None,
Some(&cookie),
CookiePolicy::Require,
"刷新登录状态失败",
)
.await;
let refreshed = match refreshed {
Ok(response) => response,
Err(error) => {
if is_authority_failure(&error) {
clear_authenticated_session(app).await?;
let view = auth_state_view("unauthenticated", None, None);
emit_auth_state(app, &view);
return Ok(ClientAuthRefreshView {
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),
});
}
};
let token: TokenResponse = serde_json::from_value(refreshed.data)
.map_err(|_| "刷新登录状态失败:凭据响应格式无效".to_string())?;
let install_token = token.token.clone();
let new_cookie = refreshed
.refresh_cookie
.ok_or_else(|| "刷新登录状态失败:缺少新的续期凭据".to_string())?;
commit_authenticated_session(
app,
&session.api_base_url,
SessionIdentity::Persisted(session.user_id.clone()),
install_token,
new_cookie,
false,
)
.await?;
// 续期只换凭据:立刻用新 token 复核身份展示字段,避免把空投影当成用户信息。
let resolved = match current_platform_session() {
Some(snapshot) => fetch_current_user(&client, &snapshot).await,
None => Ok(None),
};
match resolved {
Ok(Some(user)) => {
if expected_user_id.is_some_and(|expected| expected != user.id) {
return Ok(ClientAuthRefreshView {
status: "stale".to_string(),
user: None,
authoritative: false,
error_message: None,
});
}
let view = auth_state_view(
"authenticated",
Some(user.clone()),
Some(session.api_base_url.clone()),
);
emit_auth_state(app, &view);
Ok(ClientAuthRefreshView {
status: "refreshed".to_string(),
user: Some(user),
authoritative: false,
error_message: None,
})
}
Ok(None) => {
clear_authenticated_session(app).await?;
let view = auth_state_view("unauthenticated", None, None);
emit_auth_state(app, &view);
Ok(ClientAuthRefreshView {
status: "unauthenticated".to_string(),
user: None,
authoritative: true,
error_message: None,
})
}
Err(error) if is_authority_failure(&error) => {
clear_authenticated_session(app).await?;
let view = auth_state_view("unauthenticated", None, None);
emit_auth_state(app, &view);
Ok(ClientAuthRefreshView {
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),
}),
}
}
/// 读取登录态投影:没有凭据就直接报告未登录,有凭据则先续期再复核当前用户。
#[tauri::command]
pub(crate) async fn read_client_auth_state(
app: tauri::AppHandle,
expected_api_base_url: Option<String>,
) -> Result<ClientAuthStateView, String> {
let Some(session) = require_app_session(&app)? else {
if let Some(snapshot) = current_platform_session() {
// 凭据文件缺失但本进程仍有会话(例如同一次启动内刚登录):以会话为准。
let client = build_client()?;
if let Ok(Some(user)) = fetch_current_user(&client, &snapshot).await {
return Ok(auth_state_view(
"authenticated",
Some(user),
Some(snapshot.api_base_url),
));
}
}
return Ok(auth_state_view("unauthenticated", None, None));
};
let expected = match expected_api_base_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(value) => validate_client_api_base_url(value)?,
None => session.api_base_url.clone(),
};
if expected != session.api_base_url {
// 凭据属于另一个 origin:不跨 origin 续期,也不删除文件(用户可能只是切了渠道)。
return Ok(auth_state_view("unauthenticated", None, None));
}
match refresh_session_inner(&app, Some(session.user_id.as_str())).await {
Ok(refresh) => match refresh.status.as_str() {
"refreshed" => Ok(auth_state_view(
"authenticated",
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_kind: Some("network".to_string()),
error_message: refresh.error_message,
}),
},
Err(error) => Ok(ClientAuthStateView {
status: "unavailable".to_string(),
user: None,
api_base_url: Some(session.api_base_url),
error_kind: Some("network".to_string()),
error_message: Some(error),
}),
}
}
#[tauri::command]
pub(crate) async fn refresh_client_auth_session(
app: tauri::AppHandle,
expected_user_id: Option<String>,
) -> Result<ClientAuthRefreshView, String> {
let expected = expected_user_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
refresh_session_inner(&app, expected.as_deref()).await
}
#[tauri::command]
pub(crate) async fn send_client_phone_login_code(
api_base_url: String,
phone: String,
) -> Result<ClientLoginCodeView, String> {
let origin = validate_client_api_base_url(&api_base_url)?;
let phone = phone.trim();
if !phone_is_valid(phone) {
return Err("请输入正确的手机号".to_string());
}
let client = build_client()?;
let response = request_auth(
&client,
&origin,
"/api/auth/phone/send-code",
Some(serde_json::json!({
"countryCode": "86",
"purePhoneNumber": phone,
"scene": "login",
})),
None,
None,
CookiePolicy::Ignore,
"发送验证码失败",
)
.await?;
let payload: SendCodeResponse = serde_json::from_value(response.data)
.map_err(|_| "发送验证码失败:响应格式无效".to_string())?;
Ok(ClientLoginCodeView {
cooldown_seconds: payload.cooldown_seconds,
expires_in_seconds: payload.expires_in_seconds,
})
}
async fn complete_login(
app: &tauri::AppHandle,
origin: &str,
response: AuthResponse,
) -> Result<AuthUserPayload, String> {
let payload: TokenUserResponse = serde_json::from_value(response.data)
.map_err(|_| "登录失败:登录服务响应格式无效".to_string())?;
let cookie = response
.refresh_cookie
.ok_or_else(|| "登录失败:登录服务未返回续期凭据".to_string())?;
let user = commit_authenticated_session(
app,
origin,
// 会话主体只能来自本次登录响应:空主体既不能写进凭据文件,也不能装进本进程会话。
SessionIdentity::Login(payload.user),
payload.token,
cookie,
true,
)
.await?;
let view = auth_state_view(
"authenticated",
Some(user.clone()),
Some(origin.to_string()),
);
emit_auth_state(app, &view);
Ok(user)
}
#[tauri::command]
pub(crate) async fn login_client_with_password(
app: tauri::AppHandle,
api_base_url: String,
phone: String,
password: String,
) -> Result<AuthUserPayload, String> {
let origin = validate_client_api_base_url(&api_base_url)?;
let phone = phone.trim();
if !phone_is_valid(phone) {
return Err("请输入正确的手机号".to_string());
}
if password.trim().is_empty() {
return Err("请输入密码".to_string());
}
let client = build_client()?;
let response = request_auth(
&client,
&origin,
"/api/auth/entry",
Some(serde_json::json!({
"countryCode": "86",
"purePhoneNumber": phone,
"password": password.trim(),
})),
None,
None,
CookiePolicy::Capture,
"登录失败",
)
.await?;
complete_login(&app, &origin, response).await
}
#[tauri::command]
pub(crate) async fn login_client_with_phone_code(
app: tauri::AppHandle,
api_base_url: String,
phone: String,
code: String,
) -> Result<AuthUserPayload, String> {
let origin = validate_client_api_base_url(&api_base_url)?;
let phone = phone.trim();
if !phone_is_valid(phone) {
return Err("请输入正确的手机号".to_string());
}
if code.trim().is_empty() {
return Err("请输入验证码".to_string());
}
let client = build_client()?;
let response = request_auth(
&client,
&origin,
"/api/auth/phone/login",
Some(serde_json::json!({
"countryCode": "86",
"purePhoneNumber": phone,
"code": code.trim(),
})),
None,
None,
CookiePolicy::Capture,
"登录失败",
)
.await?;
complete_login(&app, &origin, response).await
}
/// 登出:先尝试服务端撤销,再无条件清掉本地凭据与本进程会话。
#[tauri::command]
pub(crate) async fn logout_client_session(app: tauri::AppHandle) -> Result<(), String> {
let origin = current_session_origin()
.or_else(|| {
auth_state()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.session
.as_ref()
.map(|session| session.api_base_url.clone())
})
.unwrap_or_else(|| DEVELOPMENT_ORIGIN.to_string());
if let Ok(client) = build_client() {
if let Some(snapshot) = current_platform_session() {
let _ = request_auth(
&client,
&origin,
"/api/auth/logout",
None,
Some(&snapshot.access_token),
None,
CookiePolicy::Ignore,
"退出登录失败",
)
.await;
}
}
clear_authenticated_session(&app).await?;
let view = auth_state_view("unauthenticated", None, None);
emit_auth_state(&app, &view);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_origins_are_always_accepted_and_normalized() {
assert_eq!(
validate_client_api_base_url("https://dev.genarrative.world/").expect("dev origin"),
DEVELOPMENT_ORIGIN
);
assert_eq!(
validate_client_api_base_url(RELEASE_ORIGIN).expect("release origin"),
RELEASE_ORIGIN
);
}
#[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("http://127.0.0.1:10001").expect("loopback debug"),
"http://127.0.0.1:10001"
);
}
#[test]
fn phones_must_be_digits_within_the_bound() {
assert!(phone_is_valid("13800000000"));
assert!(!phone_is_valid(""));
assert!(!phone_is_valid("138 0000 0000"));
assert!(!phone_is_valid("13800000000x"));
assert!(!phone_is_valid(&"9".repeat(MAX_PHONE_CHARS + 1)));
}
#[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);
assert!(is_authority_failure(&map_auth_failure(
StatusCode::UNAUTHORIZED,
"{}",
"刷新失败",
AuthRouteKind::Session
)));
assert!(is_authority_failure(&map_auth_failure(
StatusCode::FORBIDDEN,
r#"{"error":{"message":"无权"}}"#,
"刷新失败",
AuthRouteKind::Session
)));
let transient = map_auth_failure(
StatusCode::INTERNAL_SERVER_ERROR,
"{}",
"刷新失败",
AuthRouteKind::Session,
);
assert!(!is_authority_failure(&transient));
assert!(transient.starts_with("刷新失败"));
assert!(!is_authority_failure(AUTH_NETWORK_ERROR));
assert!(is_authority_failure(AUTH_AUTHORITY_ERROR));
}
#[test]
fn login_routes_keep_the_server_reason_instead_of_reporting_expiry() {
let wrong_password = map_auth_failure(
StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"手机号或密码错误"}}"#,
"登录失败",
AuthRouteKind::Login,
);
assert_eq!(wrong_password, "手机号或密码错误");
assert!(!is_authority_failure(&wrong_password));
let missing_reason = map_auth_failure(
StatusCode::UNAUTHORIZED,
"{}",
"登录失败",
AuthRouteKind::Login,
);
assert_eq!(missing_reason, "登录失败");
}
#[test]
fn keepalive_only_refreshes_after_the_freshness_window() {
let issued = 1_000_000_u64;
assert!(!session_needs_refresh(
Some(issued),
issued + CLIENT_SESSION_REFRESH_AFTER_MILLIS - 1
));
assert!(session_needs_refresh(
Some(issued),
issued + CLIENT_SESSION_REFRESH_AFTER_MILLIS
));
// 未知签发时间按「需要续期」处理,不能把过期凭据当成新鲜凭据。
assert!(session_needs_refresh(None, issued));
// 时钟回拨按「新鲜」处理(饱和减法不下溢,不会造成刷新风暴);
// 真过期时仍有 401 续期链路兜底。
assert!(!session_needs_refresh(Some(issued), 0));
}
#[test]
fn session_keepalive_does_not_start_without_an_app_handle() {
// 测试进程从未调用 `initialize_auth_session`:此时不能启动后台循环,
// 更不能留下一个没人能停止的保活任务。
assert!(AUTH_APP_HANDLE.get().is_none());
assert!(spawn_client_session_keepalive().is_none());
}
#[test]
fn concurrent_refresh_reuses_a_session_rotated_while_waiting() {
let before = ClientSessionFile {
schema_version: session_schema_version(),
api_base_url: DEVELOPMENT_ORIGIN.to_string(),
user_id: "user-1".to_string(),
refresh_cookie_name: "genarrative_refresh_session".to_string(),
refresh_cookie_value: "cookie-a".to_string(),
};
let rotated = ClientSessionFile {
refresh_cookie_value: "cookie-b".to_string(),
..before.clone()
};
assert!(credential_rotated_elsewhere(&before, Some(&rotated)));
assert!(!credential_rotated_elsewhere(&before, Some(&before)));
assert!(!credential_rotated_elsewhere(&before, None));
let other_user = ClientSessionFile {
user_id: "user-2".to_string(),
..rotated.clone()
};
assert!(!credential_rotated_elsewhere(&before, Some(&other_user)));
let other_origin = ClientSessionFile {
api_base_url: RELEASE_ORIGIN.to_string(),
..rotated
};
assert!(!credential_rotated_elsewhere(&before, Some(&other_origin)));
}
#[test]
fn auth_state_events_never_carry_credentials() {
let view = ClientAuthStateView {
status: "authenticated".to_string(),
user: Some(AuthUserPayload {
id: "user-1".to_string(),
public_user_code: "tn-1".to_string(),
display_name: "测试".to_string(),
avatar_url: None,
phone_number: None,
phone_number_masked: Some("138****0000".to_string()),
login_method: "phone".to_string(),
binding_status: "active".to_string(),
wechat_bound: false,
wechat_display_name: None,
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");
for forbidden in [
"token",
"Token",
"cookie",
"Cookie",
"refresh",
"accessToken",
"client-session.json",
] {
assert!(
!serialized.contains(forbidden),
"登录态事件不能包含 {forbidden}:{serialized}"
);
}
assert!(serialized.contains("138****0000"));
assert!(serialized.contains(DEVELOPMENT_ORIGIN));
}
#[test]
fn session_files_must_be_complete_and_round_trip_atomically() {
let directory = tempfile::tempdir().expect("session fixture");
let path = directory.path().join(SESSION_FILE_NAME);
let incomplete = ClientSessionFile {
schema_version: session_schema_version(),
api_base_url: DEVELOPMENT_ORIGIN.to_string(),
user_id: String::new(),
refresh_cookie_name: "genarrative_refresh_session".to_string(),
refresh_cookie_value: "value".to_string(),
};
assert!(!incomplete.is_complete());
write_session_file_at(&path, Some(&incomplete)).expect("write incomplete session");
assert!(read_session_file_at(&path).is_none());
assert!(fs::read_to_string(&path)
.expect("incomplete file stays for diagnosis")
.contains(SESSION_SCHEMA_VERSION));
let complete = ClientSessionFile {
user_id: "user-1".to_string(),
..incomplete
};
write_session_file_at(&path, Some(&complete)).expect("write session");
assert_eq!(read_session_file_at(&path), Some(complete));
write_session_file_at(&path, None).expect("clear session");
assert!(read_session_file_at(&path).is_none());
assert!(!path.exists());
}
#[test]
fn corrupt_or_foreign_session_files_never_authenticate() {
let directory = tempfile::tempdir().expect("session fixture");
let path = directory.path().join(SESSION_FILE_NAME);
// 不存在:未登录。
assert!(read_session_file_at(&path).is_none());
// 坏 JSON:不能被当成凭据。
fs::write(&path, b"{not json").expect("malformed session fixture");
assert!(read_session_file_at(&path).is_none());
// 字段缺失:整份凭据按无效处理,不允许"半个会话"进入续期链路。
fs::write(
&path,
br#"{"schemaVersion":"agc-client-session.v1","apiBaseUrl":"https://dev.genarrative.world","userId":"user-1"}"#,
)
.expect("incomplete session fixture");
assert!(read_session_file_at(&path).is_none());
// 空值 refresh cookie(登出清理形态):同样不算凭据。
fs::write(
&path,
br#"{"schemaVersion":"agc-client-session.v1","apiBaseUrl":"https://dev.genarrative.world","userId":"user-1","refreshCookieName":"genarrative_refresh_session","refreshCookieValue":""}"#,
)
.expect("cleared session fixture");
assert!(read_session_file_at(&path).is_none());
// 符号链接形式没有覆盖在这里:它由 `write_session_file_at` 的私有路径门禁拒绝,
// 且 Windows 上无法用普通文件 API 构造。
}
#[test]
fn refresh_cookie_parsing_keeps_name_and_value_and_ignores_clears() {
let with_cookie = http::Response::builder()
.status(200)
.header(
SET_COOKIE,
"genarrative_refresh_session=abc123; Path=/; HttpOnly; SameSite=Lax",
)
.body(String::new())
.expect("fixture response");
let response = reqwest::Response::from(with_cookie);
assert_eq!(
refresh_cookie_from_response(&response),
Some((
"genarrative_refresh_session".to_string(),
"abc123".to_string()
))
);
let cleared = http::Response::builder()
.status(200)
.header(
SET_COOKIE,
"genarrative_refresh_session=; Path=/; Max-Age=0; HttpOnly",
)
.body(String::new())
.expect("clear response");
assert_eq!(
refresh_cookie_from_response(&reqwest::Response::from(cleared)),
None
);
}
#[test]
fn session_subject_is_required_and_normalized() {
assert_eq!(
validated_session_user_id("user-1").expect("user id"),
"user-1"
);
// 服务端主体前后的空白不能带进凭据文件或进程快照。
assert_eq!(
validated_session_user_id(" user-1 ").expect("user id"),
"user-1"
);
// 空主体必须在登录路径就以登录语义失败:安装会话时的「陶泥儿登录用户身份无效」
// 是内部不变式,不是用户能理解的登录失败原因。
assert_eq!(
validated_session_user_id(" ").unwrap_err(),
"登录失败:登录服务未返回用户身份"
);
}
#[test]
fn login_response_supplies_the_session_subject() {
// 回归:登录响应解析出的 user.id 就是本机会话主体,安装会话时不能丢掉它。
let payload: TokenUserResponse = serde_json::from_value(serde_json::json!({
"token": "token-a",
"user": {
"id": "user-2",
"publicUserCode": "tn-2",
"displayName": "测试",
"avatarUrl": null,
"phoneNumber": null,
"phoneNumberMasked": null,
"loginMethod": "password",
"bindingStatus": "active",
"wechatBound": false,
"wechatDisplayName": null,
"wechatAccount": null
}
}))
.expect("login response fixture");
let (user_id, known_user) = SessionIdentity::Login(payload.user)
.resolve()
.expect("login identity");
assert_eq!(user_id, "user-2");
// 登录路径同时带回展示投影;续期路径只有主体,展示字段随后用新凭据复核。
assert_eq!(known_user.map(|user| user.id).as_deref(), Some("user-2"));
let (user_id, known_user) = SessionIdentity::Persisted(" user-3 ".to_string())
.resolve()
.expect("refresh identity");
assert_eq!(user_id, "user-3");
assert!(known_user.is_none());
}
#[test]
fn session_identity_rejects_a_missing_subject() {
let payload: TokenUserResponse = serde_json::from_value(serde_json::json!({
"token": "token-a",
"user": {
"id": " ",
"publicUserCode": "tn-2",
"displayName": "测试",
"avatarUrl": null,
"phoneNumber": null,
"phoneNumberMasked": null,
"loginMethod": "password",
"bindingStatus": "active",
"wechatBound": false,
"wechatDisplayName": null,
"wechatAccount": null
}
}))
.expect("login response fixture");
// 没有主体的登录响应在写凭据文件之前就以登录语义失败关闭。
assert_eq!(
SessionIdentity::Login(payload.user).resolve().unwrap_err(),
"登录失败:登录服务未返回用户身份"
);
// 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。
assert!(SessionIdentity::Persisted(String::new()).resolve().is_err());
}
}