Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs
T
kdletters 2449e77461
Project CI / Repository checks (push) Successful in 4m24s
Project CI / Frontend tests (push) Successful in 5m10s
Project CI / Backend tests (push) Successful in 5m37s
Project CI / Native shell tests (push) Successful in 13m9s
修复切换账号后画布资源不可编辑 (#182)
## 变更

- 新增按服务 origin、平台 userId / Developer Key 摘要和本地 projectId 分区的 External Editor 项目绑定
- 新增按当前 principal、远端项目、本地 assetId、源 SHA-256、媒体类型和 canonical kind 分区的资源绑定
- manifest 中的 canvasProjectId / resourceId / assetObjectId 仅保留来源信息,不再作为当前账号的可编辑授权
- 切换账号后从本地正式资源重新上传、confirm、登记;图片、视频、角色动画、素材画布参考、art-spec 派生和 Direct 恢复统一使用当前账号绑定
- prepared / accepted / running 账本继续冻结原 principal;账号变化或远端结果不确定时停止补偿并保留现场等待对账
- 同步技术方案、decision log 和 pitfalls

## Review 结论

- 两路独立代码 review 均未发现剩余 P0-P2
- Review 发现并关闭了 max-pass 测试误放宽问题,恢复为绑定最大轮次的强断言
- 首轮 CI 暴露两处本 PR import 排序错误,已在独立提交 09486f142 中修复并复核

## 验证

- Rust 完整测试:2301 passed,0 failed,16 ignored
- Rust 集成与构建测试:5 + 2 + 14 passed
- repository-ci 本地同构门禁通过:lint、typecheck、139 表 SpacetimeDB schema guard、403 个 appSurface 测试、web/admin-web build
- External Editor procedure 真实 smoke 通过:精确重放、冲突、删除 fail-close、并发和孤儿检查
- Encoding check:5594 files
- git diff --check 通过
- Gitea Project CI run 1253:Repository checks、Frontend、Backend、Native shell tests 全部通过
- 当前 head a0b8415be 已合并 origin/master 44ee28c43,PR 无冲突

## 后续依赖

PR #176 暴露了这一公共账号身份缺陷。该 PR 合并后,#176 需要 rebase,并删除或接入其局部 canonical cache,不能保留第二套账号绑定系统。

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/182
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-08-24 11:33:37 +08:00

617 lines
20 KiB
Rust

use sha2::{Digest, Sha256};
use std::sync::{Mutex, OnceLock};
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PlatformSessionSnapshot {
pub(crate) user_id: String,
pub(crate) access_token: String,
pub(crate) api_base_url: String,
pub(crate) generation: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EditorApiMode {
PlatformAccount,
ExternalDeveloper,
}
pub(crate) fn editor_api_mode_for_build(
debug_assertions: bool,
game_chat_release_feature: bool,
) -> EditorApiMode {
if !debug_assertions && game_chat_release_feature {
EditorApiMode::ExternalDeveloper
} else {
EditorApiMode::PlatformAccount
}
}
pub(crate) fn editor_api_mode() -> EditorApiMode {
editor_api_mode_for_build(cfg!(debug_assertions), cfg!(feature = "game-chat-release"))
}
#[derive(Default)]
struct PlatformSessionState {
generation: u64,
snapshot: Option<PlatformSessionSnapshot>,
}
static PLATFORM_SESSION: OnceLock<Mutex<PlatformSessionState>> = OnceLock::new();
fn platform_session() -> &'static Mutex<PlatformSessionState> {
PLATFORM_SESSION.get_or_init(|| Mutex::new(PlatformSessionState::default()))
}
fn install_platform_session_in(
current: &mut PlatformSessionState,
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) {
if generation < current.generation {
return;
}
if generation == current.generation {
if current.snapshot.as_ref().is_some_and(|snapshot| {
snapshot.user_id == user_id
&& snapshot.access_token == access_token
&& snapshot.api_base_url == api_base_url
}) {
return;
}
// Equal-generation retries may only repeat the exact committed snapshot. In
// particular, a late install cannot revive a generation that was cleared.
return;
}
current.generation = generation;
current.snapshot = Some(PlatformSessionSnapshot {
user_id: user_id.to_string(),
access_token: access_token.to_string(),
api_base_url: api_base_url.to_string(),
generation,
});
}
fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) {
if generation < current.generation {
return;
}
current.generation = generation;
current.snapshot = None;
}
pub(crate) fn install_platform_session(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
let snapshot =
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
install_platform_session_in(
&mut current,
&snapshot.user_id,
&snapshot.access_token,
&snapshot.api_base_url,
snapshot.generation,
);
Ok(())
}
fn validated_platform_session_snapshot(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<PlatformSessionSnapshot, String> {
if editor_api_mode() == EditorApiMode::ExternalDeveloper {
return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".to_string());
}
let user_id = user_id.trim();
let access_token = access_token.trim();
let api_base_url = normalize_platform_api_base_url(api_base_url)?;
if user_id.is_empty() || user_id.len() > 256 {
return Err("陶泥儿登录用户身份无效".to_string());
}
if access_token.is_empty() || access_token.len() > 16 * 1024 {
return Err("陶泥儿登录凭据无效".to_string());
}
Ok(PlatformSessionSnapshot {
user_id: user_id.to_string(),
access_token: access_token.to_string(),
api_base_url,
generation,
})
}
pub(crate) fn validate_platform_session_input(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation).map(|_| ())
}
pub(crate) fn replace_platform_session_for_gui_owner(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
let snapshot =
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
current.generation = snapshot.generation;
current.snapshot = Some(snapshot);
Ok(())
}
pub(crate) fn install_platform_session_checked(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
let snapshot =
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
install_platform_session_in(
&mut current,
&snapshot.user_id,
&snapshot.access_token,
&snapshot.api_base_url,
snapshot.generation,
);
if current.snapshot.as_ref() == Some(&snapshot) {
Ok(())
} else {
Err("authentication-required: 平台登录态 generation 已过期或主体冲突".to_string())
}
}
fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
let value = value.trim().trim_end_matches('/');
let parsed = url::Url::parse(value).map_err(|_| "陶泥儿服务地址无效".to_string())?;
if !matches!(parsed.scheme(), "http" | "https")
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
|| parsed.path() != "/"
{
return Err("陶泥儿服务地址必须是纯 HTTP(S) origin".to_string());
}
let host = parsed
.host_str()
.ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?;
let loopback = host == "localhost"
|| host == "127.0.0.1"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback());
if parsed.scheme() == "http" && !loopback {
return Err("陶泥儿服务地址不在受信任白名单内".to_string());
}
Ok(value.to_string())
}
pub(crate) fn clear_platform_session(generation: u64) {
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
clear_platform_session_in(&mut current, generation);
}
pub(crate) fn clear_platform_session_for_gui_owner(generation: u64) {
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
current.generation = generation;
current.snapshot = None;
}
pub(crate) fn clear_platform_session_checked(generation: u64) -> Result<(), String> {
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
clear_platform_session_in(&mut current, generation);
if current.generation == generation && current.snapshot.is_none() {
Ok(())
} else {
Err("authentication-required: 平台登出 generation 已过期".to_string())
}
}
pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.snapshot
.clone()
}
pub(crate) fn current_platform_session_generation() -> u64 {
platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.generation
}
pub(crate) fn validate_platform_session_snapshot(
expected: &PlatformSessionSnapshot,
) -> Result<(), String> {
if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) {
Ok(())
} else {
Err(
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
.to_string(),
)
}
}
pub(crate) fn with_validated_platform_session_fingerprint<T>(
expected_user_id: &str,
expected_api_base_url: &str,
expected_generation: u64,
expected_access_token_sha256: &str,
action: impl FnOnce() -> Result<T, String>,
) -> Result<T, String> {
let lease = acquire_validated_platform_session_fingerprint(
expected_user_id,
expected_api_base_url,
expected_generation,
expected_access_token_sha256,
)?;
let result = action();
drop(lease);
result
}
pub(crate) struct ValidatedPlatformSessionLease {
_guard: std::sync::MutexGuard<'static, PlatformSessionState>,
}
pub(crate) fn acquire_validated_platform_session_fingerprint(
expected_user_id: &str,
expected_api_base_url: &str,
expected_generation: u64,
expected_access_token_sha256: &str,
) -> Result<ValidatedPlatformSessionLease, String> {
let current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let matches = current.snapshot.as_ref().is_some_and(|snapshot| {
snapshot.user_id == expected_user_id
&& snapshot.api_base_url == expected_api_base_url
&& snapshot.generation == expected_generation
&& format!("{:x}", Sha256::digest(snapshot.access_token.as_bytes()))
== expected_access_token_sha256
});
if !matches {
return Err(
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
.to_string(),
);
}
Ok(ValidatedPlatformSessionLease { _guard: current })
}
fn platform_session_snapshot_matches(
current: Option<&PlatformSessionSnapshot>,
expected: &PlatformSessionSnapshot,
) -> bool {
current == Some(expected)
}
pub(crate) fn platform_session_is_available() -> bool {
current_platform_session().is_some()
}
pub(crate) fn platform_session_service_identity() -> Option<String> {
current_platform_session()
.map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id))
}
#[cfg(test)]
static PLATFORM_SESSION_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
#[cfg(test)]
pub(crate) struct TestPlatformSessionGuard {
_isolation: std::sync::MutexGuard<'static, ()>,
previous: Option<PlatformSessionState>,
}
#[cfg(test)]
impl Drop for TestPlatformSessionGuard {
fn drop(&mut self) {
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
*current = self
.previous
.take()
.expect("platform session test guard must restore its prior state");
}
}
/// Installs a scoped platform-account session for tests that must exercise the
/// production-default client authentication path. The previous process state
/// is restored on drop so the fixture cannot leak credentials or account
/// identity to another test.
#[cfg(test)]
pub(crate) fn install_test_platform_session(
user_id: &str,
access_token: &str,
api_base_url: &str,
) -> TestPlatformSessionGuard {
let isolation = PLATFORM_SESSION_TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let previous = std::mem::take(&mut *current);
*current = PlatformSessionState {
generation: 1,
snapshot: Some(PlatformSessionSnapshot {
user_id: user_id.to_string(),
access_token: access_token.to_string(),
api_base_url: api_base_url.to_string(),
generation: 1,
}),
};
drop(current);
TestPlatformSessionGuard {
_isolation: isolation,
previous: Some(previous),
}
}
/// Holds the shared test-session lock while presenting an explicit logged-out
/// state. Tests that assert missing-login behavior must use this guard so they
/// cannot observe a platform session installed by another parallel test.
#[cfg(test)]
pub(crate) fn clear_test_platform_session() -> TestPlatformSessionGuard {
let isolation = PLATFORM_SESSION_TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let previous = std::mem::take(&mut *current);
*current = PlatformSessionState::default();
drop(current);
TestPlatformSessionGuard {
_isolation: isolation,
previous: Some(previous),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cleared_generation_rejects_late_install_and_older_clear() {
let mut state = PlatformSessionState::default();
install_platform_session_in(
&mut state,
"user-a",
"token-a",
"https://dev.genarrative.world",
1,
);
clear_platform_session_in(&mut state, 2);
install_platform_session_in(
&mut state,
"user-a",
"late-token-a",
"https://dev.genarrative.world",
1,
);
install_platform_session_in(
&mut state,
"user-a",
"same-generation-token",
"https://dev.genarrative.world",
2,
);
assert!(state.snapshot.is_none());
assert_eq!(state.generation, 2);
install_platform_session_in(
&mut state,
"user-b",
"token-b",
"https://dev.genarrative.world",
3,
);
clear_platform_session_in(&mut state, 2);
assert_eq!(
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
Some("user-b")
);
assert_eq!(state.generation, 3);
}
#[test]
fn equal_generation_only_accepts_the_exact_idempotent_snapshot() {
let mut state = PlatformSessionState::default();
install_platform_session_in(
&mut state,
"user-a",
"token-a",
"https://dev.genarrative.world",
4,
);
install_platform_session_in(
&mut state,
"user-a",
"token-a",
"https://dev.genarrative.world",
4,
);
install_platform_session_in(
&mut state,
"user-b",
"token-b",
"https://dev.genarrative.world",
4,
);
assert_eq!(
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
Some("user-a")
);
assert_eq!(
state
.snapshot
.as_ref()
.map(|value| value.access_token.as_str()),
Some("token-a")
);
}
#[test]
fn current_generation_preserves_the_floor_after_session_clear() {
let _session = clear_test_platform_session();
install_platform_session(
"generation-floor-user",
"generation-floor-token",
"https://dev.genarrative.world",
41,
)
.expect("install session generation floor");
clear_platform_session(42);
assert_eq!(current_platform_session_generation(), 42);
assert!(current_platform_session().is_none());
}
#[test]
fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() {
assert_eq!(
editor_api_mode_for_build(true, false),
EditorApiMode::PlatformAccount
);
assert_eq!(
editor_api_mode_for_build(true, true),
EditorApiMode::PlatformAccount
);
assert_eq!(
editor_api_mode_for_build(false, false),
EditorApiMode::PlatformAccount
);
assert_eq!(
editor_api_mode_for_build(false, true),
EditorApiMode::ExternalDeveloper
);
}
#[test]
fn custom_server_selection_accepts_https_origins_and_rejects_plain_http() {
assert_eq!(
normalize_platform_api_base_url("https://staging.example.com/"),
Ok("https://staging.example.com".to_string())
);
assert_eq!(
normalize_platform_api_base_url("http://127.0.0.1:8080/"),
Ok("http://127.0.0.1:8080".to_string())
);
assert!(normalize_platform_api_base_url("http://staging.example.com").is_err());
assert!(normalize_platform_api_base_url("https://staging.example.com/api").is_err());
}
#[test]
fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() {
let expected = PlatformSessionSnapshot {
user_id: "user-a".to_string(),
access_token: "token-a".to_string(),
api_base_url: "https://dev.genarrative.world".to_string(),
generation: 4,
};
assert!(platform_session_snapshot_matches(
Some(&expected),
&expected
));
for current in [
None,
Some(PlatformSessionSnapshot {
user_id: "user-b".to_string(),
..expected.clone()
}),
Some(PlatformSessionSnapshot {
access_token: "token-b".to_string(),
generation: 5,
..expected.clone()
}),
] {
assert!(!platform_session_snapshot_matches(
current.as_ref(),
&expected
));
}
}
#[test]
fn validated_session_lease_linearizes_local_commit_with_account_switch() {
let _session = install_test_platform_session(
"lease-user-a",
"lease-token-a",
"https://dev.genarrative.world",
);
let expected = current_platform_session().expect("current lease session");
let token_sha256 = format!("{:x}", Sha256::digest(expected.access_token.as_bytes()));
let lease = acquire_validated_platform_session_fingerprint(
&expected.user_id,
&expected.api_base_url,
expected.generation,
&token_sha256,
)
.expect("acquire validated session lease");
let (started_sender, started_receiver) = std::sync::mpsc::channel();
let (finished_sender, finished_receiver) = std::sync::mpsc::channel();
let switcher = std::thread::spawn(move || {
started_sender.send(()).expect("signal account switch");
install_platform_session(
"lease-user-b",
"lease-token-b",
"https://dev.genarrative.world",
2,
)
.expect("switch account after lease release");
finished_sender.send(()).expect("signal switched account");
});
started_receiver
.recv_timeout(std::time::Duration::from_secs(1))
.expect("switcher started");
assert!(
finished_receiver
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"account switch must wait until the synchronous local commit lease is released"
);
drop(lease);
finished_receiver
.recv_timeout(std::time::Duration::from_secs(1))
.expect("account switch completed after lease release");
switcher.join().expect("join account switcher");
assert_eq!(
current_platform_session().map(|session| session.user_id),
Some("lease-user-b".to_string())
);
}
}