迁移后端认证与拆分 Spacetime 客户端

This commit is contained in:
2026-04-24 14:10:11 +08:00
parent ef53028be5
commit 4f369617c7
55 changed files with 9206 additions and 343 deletions

View File

@@ -0,0 +1,122 @@
use crate::*;
const AUTH_STORE_SNAPSHOT_ID: &str = "default";
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AuthStoreSnapshotRecord {
pub snapshot_json: Option<String>,
pub updated_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AuthStoreSnapshotUpsertInput {
pub snapshot_json: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AuthStoreSnapshotProcedureResult {
pub ok: bool,
pub record: Option<AuthStoreSnapshotRecord>,
pub error_message: Option<String>,
}
#[spacetimedb::table(accessor = auth_store_snapshot)]
pub struct AuthStoreSnapshot {
#[primary_key]
pub(crate) snapshot_id: String,
pub(crate) snapshot_json: String,
pub(crate) updated_at: Timestamp,
}
// Axum 启动恢复认证状态时读取当前快照;记录不存在代表尚未产生登录态。
#[spacetimedb::procedure]
pub fn get_auth_store_snapshot(ctx: &mut ProcedureContext) -> AuthStoreSnapshotProcedureResult {
match ctx.try_with_tx(|tx| get_auth_store_snapshot_tx(tx)) {
Ok(record) => AuthStoreSnapshotProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AuthStoreSnapshotProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// Axum 每次鉴权仓储变更后覆盖写入整份快照,后续拆表阶段再替换为细粒度 reducer。
#[spacetimedb::procedure]
pub fn upsert_auth_store_snapshot(
ctx: &mut ProcedureContext,
input: AuthStoreSnapshotUpsertInput,
) -> AuthStoreSnapshotProcedureResult {
match ctx.try_with_tx(|tx| upsert_auth_store_snapshot_tx(tx, input.clone())) {
Ok(record) => AuthStoreSnapshotProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AuthStoreSnapshotProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
fn get_auth_store_snapshot_tx(ctx: &ReducerContext) -> Result<AuthStoreSnapshotRecord, String> {
Ok(
match ctx
.db
.auth_store_snapshot()
.snapshot_id()
.find(&AUTH_STORE_SNAPSHOT_ID.to_string())
{
Some(row) => AuthStoreSnapshotRecord {
snapshot_json: Some(row.snapshot_json),
updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()),
},
None => AuthStoreSnapshotRecord {
snapshot_json: None,
updated_at_micros: None,
},
},
)
}
fn upsert_auth_store_snapshot_tx(
ctx: &ReducerContext,
input: AuthStoreSnapshotUpsertInput,
) -> Result<AuthStoreSnapshotRecord, String> {
let snapshot_json = input.snapshot_json.trim().to_string();
if snapshot_json.is_empty() {
return Err("认证快照 JSON 不能为空".to_string());
}
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
if ctx
.db
.auth_store_snapshot()
.snapshot_id()
.find(&AUTH_STORE_SNAPSHOT_ID.to_string())
.is_some()
{
ctx.db
.auth_store_snapshot()
.snapshot_id()
.delete(&AUTH_STORE_SNAPSHOT_ID.to_string());
}
ctx.db.auth_store_snapshot().insert(AuthStoreSnapshot {
snapshot_id: AUTH_STORE_SNAPSHOT_ID.to_string(),
snapshot_json: snapshot_json.clone(),
updated_at,
});
Ok(AuthStoreSnapshotRecord {
snapshot_json: Some(snapshot_json),
updated_at_micros: Some(input.updated_at_micros),
})
}

View File

@@ -21,9 +21,11 @@ use module_quest::{
pub(crate) use serde_json::{Map as JsonMap, Value as JsonValue, json};
pub(crate) use shared_kernel::format_timestamp_micros;
pub use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp};
use std::collections::HashSet;
mod ai;
mod asset_metadata;
mod auth;
mod big_fish;
mod domain_types;
mod entry;
@@ -32,6 +34,7 @@ mod runtime;
pub use ai::*;
pub use asset_metadata::*;
pub use auth::*;
pub use big_fish::*;
pub use domain_types::*;
pub use entry::*;
@@ -2733,10 +2736,12 @@ fn list_custom_world_work_snapshots(
validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?;
let mut items = Vec::new();
let mut active_agent_session_ids = HashSet::new();
for session in ctx.db.custom_world_agent_session().iter().filter(|row| {
row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published
}) {
active_agent_session_ids.insert(session.session_id.clone());
let gate = build_custom_world_publish_gate_from_session(&session);
let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref());
let title = resolve_session_work_title(&session, draft_profile.as_ref());
@@ -2780,6 +2785,7 @@ fn list_custom_world_work_snapshots(
.custom_world_profile()
.iter()
.filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none())
.filter(|row| should_include_custom_world_profile_work(row, &active_agent_session_ids))
{
items.push(CustomWorldWorkSummarySnapshot {
work_id: format!("published:{}", profile.profile_id),
@@ -2834,6 +2840,24 @@ fn list_custom_world_work_snapshots(
Ok(items)
}
fn should_include_custom_world_profile_work(
row: &CustomWorldProfile,
active_agent_session_ids: &HashSet<String>,
) -> bool {
// 已发布 profile 是正式作品;即使来源会话还存在,也必须保留独立入口。
if row.publication_status == CustomWorldPublicationStatus::Published {
return true;
}
// 未发布 profile 若来源于仍可继续聊天的 Agent 会话,只是同一草稿的编译产物,
// works 里保留 agent_session 即可,避免草稿分组显示两份同名作品。
row.source_agent_session_id
.as_ref()
.map_or(true, |session_id| {
!active_agent_session_ids.contains(session_id)
})
}
fn get_custom_world_agent_card_detail_tx(
ctx: &ReducerContext,
input: CustomWorldAgentCardDetailGetInput,
@@ -5996,6 +6020,113 @@ mod tests {
));
}
#[test]
fn custom_world_works_hides_compiled_draft_profile_when_agent_session_is_active() {
fn build_test_custom_world_profile(
profile_id: &str,
source_agent_session_id: Option<&str>,
publication_status: CustomWorldPublicationStatus,
) -> CustomWorldProfile {
CustomWorldProfile {
profile_id: profile_id.to_string(),
owner_user_id: "user-1".to_string(),
public_work_code: if publication_status == CustomWorldPublicationStatus::Published {
Some("CW-00000001".to_string())
} else {
None
},
author_public_user_code: None,
source_agent_session_id: source_agent_session_id.map(str::to_string),
publication_status,
world_name: "潮雾列岛".to_string(),
subtitle: String::new(),
summary_text: String::new(),
theme_mode: CustomWorldThemeMode::Mythic,
cover_image_src: None,
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
author_display_name: "玩家".to_string(),
published_at: if publication_status == CustomWorldPublicationStatus::Published {
Some(Timestamp::from_micros_since_unix_epoch(2))
} else {
None
},
deleted_at: None,
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
}
}
let draft_profile = build_test_custom_world_profile(
"profile-1",
Some("session-1"),
CustomWorldPublicationStatus::Draft,
);
let orphan_draft_profile = build_test_custom_world_profile(
"profile-2",
Some("session-2"),
CustomWorldPublicationStatus::Draft,
);
let published_profile = build_test_custom_world_profile(
"profile-3",
Some("session-1"),
CustomWorldPublicationStatus::Published,
);
let mut active_agent_session_ids = HashSet::new();
active_agent_session_ids.insert("session-1".to_string());
assert!(!should_include_custom_world_profile_work(
&draft_profile,
&active_agent_session_ids,
));
assert!(should_include_custom_world_profile_work(
&orphan_draft_profile,
&active_agent_session_ids,
));
assert!(should_include_custom_world_profile_work(
&published_profile,
&active_agent_session_ids,
));
}
#[test]
fn custom_world_works_keeps_compiled_draft_profile_without_active_agent_session() {
let draft_profile = CustomWorldProfile {
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
public_work_code: None,
author_public_user_code: None,
source_agent_session_id: Some("session-1".to_string()),
publication_status: CustomWorldPublicationStatus::Draft,
world_name: "潮雾列岛".to_string(),
subtitle: String::new(),
summary_text: String::new(),
theme_mode: CustomWorldThemeMode::Mythic,
cover_image_src: None,
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
author_display_name: "玩家".to_string(),
published_at: None,
deleted_at: None,
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
};
let mut active_agent_session_ids = HashSet::new();
assert!(should_include_custom_world_profile_work(
&draft_profile,
&active_agent_session_ids,
));
active_agent_session_ids.insert("session-2".to_string());
assert!(should_include_custom_world_profile_work(
&draft_profile,
&active_agent_session_ids,
));
}
#[test]
fn summarize_publish_gate_accepts_current_agent_result_schema() {
let draft_profile = serde_json::from_str::<JsonValue>(