迁移后端认证与拆分 Spacetime 客户端
This commit is contained in:
122
server-rs/crates/spacetime-module/src/auth.rs
Normal file
122
server-rs/crates/spacetime-module/src/auth.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user