use std::{ collections::HashMap, error::Error, fmt, fs, sync::{Arc, Mutex}, time::{SystemTime, UNIX_EPOCH}, }; use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_auth::{ AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, RefreshSessionService, WechatAuthService, WechatAuthStateService, }; use module_runtime::RuntimeSnapshotRecord; #[cfg(test)] use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; use platform_agent::MockLangChainRustAgentExecutor; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token, }; use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider}; use platform_oss::{OssClient, OssConfig, OssError}; use serde_json::Value; use shared_contracts::creation_entry_config::CreationEntryConfigResponse; use shared_contracts::creative_agent::CreativeAgentSessionSnapshot; use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError}; use time::OffsetDateTime; use tracing::{info, warn}; use crate::config::AppConfig; use crate::wechat_provider::build_wechat_provider; const ADMIN_ROLE: &str = "admin"; // 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。 #[derive(Clone, Debug)] pub struct AppState { // 配置会在后续中间件、路由和平台适配接入时逐步消费。 #[allow(dead_code)] pub config: AppConfig, auth_jwt_config: JwtConfig, admin_runtime: Option, refresh_cookie_config: RefreshCookieConfig, #[cfg(test)] test_creation_entry_config: Arc>>, oss_client: Option, #[cfg_attr(test, allow(dead_code))] auth_store: InMemoryAuthStore, password_entry_service: PasswordEntryService, refresh_session_service: RefreshSessionService, auth_user_service: AuthUserService, phone_auth_service: PhoneAuthService, wechat_auth_state_service: WechatAuthStateService, wechat_auth_service: WechatAuthService, wechat_provider: WechatProvider, #[cfg_attr(not(test), allow(dead_code))] ai_task_service: AiTaskService, spacetime_client: SpacetimeClient, llm_client: Option, creative_agent_gpt5_client: Option, creative_agent_executor: Arc, // Phase 1 任务 E 的 creative session facade 暂存在 api-server。 // creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。 creative_agent_sessions: Arc>>, #[cfg(test)] // 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。 test_runtime_snapshot_store: Arc>>, } #[derive(Clone, Debug)] struct CreativeAgentSessionRuntimeRecord { owner_user_id: String, snapshot: CreativeAgentSessionSnapshot, } // 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。 #[derive(Clone, Debug)] pub struct AdminRuntime { username: Arc, password: Arc, subject: Arc, display_name: Arc, token_ttl_seconds: u64, jwt_config: JwtConfig, } #[derive(Clone, Debug)] pub struct AdminClaims { pub subject: String, pub username: String, pub issued_at: OffsetDateTime, pub expires_at: OffsetDateTime, } #[derive(Clone, Debug)] pub struct AdminSession { pub subject: String, pub username: String, pub display_name: String, pub roles: Vec, pub issued_at: OffsetDateTime, pub expires_at: OffsetDateTime, } #[derive(Debug)] pub enum AppStateInitError { Jwt(JwtError), RefreshCookie(RefreshCookieError), AuthStore(String), SmsProvider(SmsProviderError), Oss(OssError), Llm(LlmError), } impl AppState { pub fn new(config: AppConfig) -> Result { #[cfg(test)] let auth_store = InMemoryAuthStore::default(); #[cfg(not(test))] let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone()) .map_err(AppStateInitError::AuthStore)?; Self::new_with_auth_store(config, auth_store) } fn new_with_auth_store( config: AppConfig, auth_store: InMemoryAuthStore, ) -> Result { let auth_jwt_config = JwtConfig::new( config.jwt_issuer.clone(), config.jwt_secret.clone(), config.jwt_access_token_ttl_seconds, )?; let admin_runtime = build_admin_runtime(&config, &auth_jwt_config)?; let refresh_cookie_same_site = RefreshCookieSameSite::parse(&config.refresh_cookie_same_site).ok_or( RefreshCookieError::InvalidConfig("refresh cookie SameSite 取值非法"), )?; let refresh_cookie_config = RefreshCookieConfig::new( config.refresh_cookie_name.clone(), config.refresh_cookie_path.clone(), config.refresh_cookie_secure, refresh_cookie_same_site, config.refresh_session_ttl_days, )?; let oss_client = build_oss_client(&config)?; let sms_provider = SmsAuthProvider::new(SmsAuthConfig::new( SmsAuthProviderKind::parse(&config.sms_auth_provider).ok_or_else(|| { SmsProviderError::InvalidConfig("短信 provider 配置非法".to_string()) })?, config.sms_endpoint.clone(), config.sms_access_key_id.clone(), config.sms_access_key_secret.clone(), config.sms_sign_name.clone(), config.sms_template_code.clone(), config.sms_template_param_key.clone(), config.sms_country_code.clone(), config.sms_scheme_name.clone(), config.sms_code_length, config.sms_code_type, config.sms_valid_time_seconds, config.sms_interval_seconds, config.sms_duplicate_policy, config.sms_case_auth_policy, config.sms_return_verify_code, config.sms_mock_verify_code.clone(), )?)?; let password_entry_service = PasswordEntryService::new(auth_store.clone()); let auth_user_service = AuthUserService::new(auth_store.clone()); let phone_auth_service = PhoneAuthService::new(auth_store.clone(), sms_provider); let wechat_auth_state_service = WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes); let wechat_auth_service = WechatAuthService::new(auth_store.clone()); let wechat_provider = build_wechat_provider(&config); let refresh_session_service = RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days); // AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。 let ai_task_service = AiTaskService::new(InMemoryAiTaskStore::default()); let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig { server_url: config.spacetime_server_url.clone(), database: config.spacetime_database.clone(), token: config.spacetime_token.clone(), pool_size: config.spacetime_pool_size, procedure_timeout: config.spacetime_procedure_timeout, }); let llm_client = build_llm_client(&config)?; let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; Ok(Self { config, auth_jwt_config, admin_runtime, refresh_cookie_config, #[cfg(test)] test_creation_entry_config: Arc::new(Mutex::new(Some( crate::creation_entry_config::test_creation_entry_config_response(), ))), oss_client, auth_store, password_entry_service, refresh_session_service, auth_user_service, phone_auth_service, wechat_auth_state_service, wechat_auth_service, wechat_provider, ai_task_service, spacetime_client, llm_client, creative_agent_gpt5_client, creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())), #[cfg(test)] test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())), }) } pub fn auth_jwt_config(&self) -> &JwtConfig { &self.auth_jwt_config } pub fn admin_runtime(&self) -> Option<&AdminRuntime> { self.admin_runtime.as_ref() } pub fn refresh_cookie_config(&self) -> &RefreshCookieConfig { &self.refresh_cookie_config } pub async fn upsert_creation_entry_type_config( &self, input: module_runtime::CreationEntryTypeAdminUpsertInput, ) -> Result { match self .spacetime_client .upsert_creation_entry_type_config(input) .await { Ok(config) => { #[cfg(test)] self.cache_test_creation_entry_config(config.clone()); Ok(config) } #[cfg(test)] Err(_) => Ok(self.read_test_creation_entry_config()), #[cfg(not(test))] Err(error) => Err(error), } } pub async fn get_creation_entry_config( &self, ) -> Result { match self.spacetime_client.get_creation_entry_config().await { Ok(config) => { #[cfg(test)] self.cache_test_creation_entry_config(config.clone()); Ok(config) } #[cfg(test)] Err(_) => Ok(self.read_test_creation_entry_config()), #[cfg(not(test))] Err(error) => Err(error), } } pub async fn is_creation_entry_route_enabled( &self, creation_type_id: &str, ) -> Result { let config = self.get_creation_entry_config().await?; Ok(config .creation_types .iter() .find(|item| item.id == creation_type_id) .map(|item| item.open) .unwrap_or(true)) } #[cfg(test)] pub(crate) fn set_test_creation_entry_route_enabled( &self, creation_type_id: impl AsRef, enabled: bool, ) { let creation_type_id = creation_type_id.as_ref(); let mut config = self.read_test_creation_entry_config(); if let Some(item) = config .creation_types .iter_mut() .find(|item| item.id == creation_type_id) { item.open = enabled; } else { config.creation_types.push( shared_contracts::creation_entry_config::CreationEntryTypeResponse { id: creation_type_id.to_string(), title: creation_type_id.to_string(), subtitle: String::new(), badge: String::new(), image_src: format!("/creation-type-references/{creation_type_id}.webp"), visible: enabled, open: enabled, sort_order: i32::try_from(config.creation_types.len()).unwrap_or(i32::MAX), updated_at_micros: 0, }, ); } self.cache_test_creation_entry_config(config); } pub fn oss_client(&self) -> Option<&OssClient> { self.oss_client.as_ref() } pub fn password_entry_service(&self) -> &PasswordEntryService { &self.password_entry_service } pub async fn sync_auth_store_snapshot_to_spacetime(&self) -> Result<(), SpacetimeClientError> { #[cfg(test)] return Ok(()); #[cfg(not(test))] let snapshot_json = self .auth_store .export_snapshot_json() .map_err(SpacetimeClientError::Runtime)?; #[cfg(not(test))] let updated_at_micros = i64::try_from( OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000, ) .map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?; // 本地 auth_store 是当前认证请求的即时真相源;SpacetimeDB 快照用于跨进程恢复。 // 远端数据库挂起或网络异常时,只降级远端恢复能力,不能让已成功的登录/刷新/退出回滚为失败。 #[cfg(not(test))] if let Err(error) = self .spacetime_client .upsert_auth_store_snapshot(snapshot_json, updated_at_micros) .await { warn!( error = %error, "认证快照写入 SpacetimeDB 失败,当前认证流程继续" ); return Ok(()); } // 写入快照后尝试拆入正式认证表;失败只影响远端表恢复,不阻断当前认证响应。 #[cfg(not(test))] if let Err(error) = self.spacetime_client.import_auth_store_snapshot().await { warn!( error = %error, "认证快照导入 SpacetimeDB 正式表失败,当前认证流程继续" ); return Ok(()); } #[cfg(not(test))] Ok(()) } pub async fn try_restore_auth_store_from_spacetime( config: AppConfig, ) -> Result { let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig { server_url: config.spacetime_server_url.clone(), database: config.spacetime_database.clone(), token: config.spacetime_token.clone(), pool_size: config.spacetime_pool_size, procedure_timeout: config.spacetime_procedure_timeout, }); let mut candidates = Vec::new(); match spacetime_client .export_auth_store_snapshot_from_tables() .await { Ok(snapshot) => { if let Some(candidate) = auth_store_candidate_from_snapshot_record( snapshot, AuthStoreRestoreSource::SpacetimeTables, )? { candidates.push(candidate); } } Err(error) => { warn!(error = %error, "从 SpacetimeDB 表恢复认证快照失败"); } } match spacetime_client.get_auth_store_snapshot().await { Ok(snapshot) => { if let Some(candidate) = auth_store_candidate_from_snapshot_record( snapshot, AuthStoreRestoreSource::SpacetimeSnapshot, )? { candidates.push(candidate); } } Err(error) => { warn!(error = %error, "从 SpacetimeDB 快照记录恢复认证快照失败"); } } if let Some(candidate) = auth_store_candidate_from_local_file(&config)? { candidates.push(candidate); } if let Some(candidate) = select_auth_store_restore_candidate(candidates) { let source = candidate.source; let should_sync_to_spacetime = source == AuthStoreRestoreSource::LocalFile; let state = Self::new_with_auth_store(config, candidate.auth_store)?; info!( source = source.as_str(), updated_at_micros = candidate.updated_at_micros, "已恢复认证快照" ); if should_sync_to_spacetime { if let Err(error) = state.sync_auth_store_snapshot_to_spacetime().await { warn!( error = %error, "本地认证快照回写 SpacetimeDB 失败,当前启动继续" ); } } return Ok(state); } Self::new(config) } pub fn refresh_session_service(&self) -> &RefreshSessionService { &self.refresh_session_service } pub fn auth_user_service(&self) -> &AuthUserService { &self.auth_user_service } pub fn phone_auth_service(&self) -> &PhoneAuthService { &self.phone_auth_service } pub fn wechat_auth_state_service(&self) -> &WechatAuthStateService { &self.wechat_auth_state_service } pub fn wechat_auth_service(&self) -> &WechatAuthService { &self.wechat_auth_service } pub fn wechat_provider(&self) -> &WechatProvider { &self.wechat_provider } #[cfg_attr(not(test), allow(dead_code))] pub fn ai_task_service(&self) -> &AiTaskService { &self.ai_task_service } pub fn spacetime_client(&self) -> &SpacetimeClient { &self.spacetime_client } pub fn llm_client(&self) -> Option<&LlmClient> { self.llm_client.as_ref() } pub fn creative_agent_gpt5_client(&self) -> Option<&LlmClient> { self.creative_agent_gpt5_client.as_ref() } pub fn creative_agent_executor(&self) -> Arc { self.creative_agent_executor.clone() } pub fn get_creative_agent_session( &self, session_id: &str, owner_user_id: &str, ) -> Option { self.creative_agent_sessions .lock() .expect("creative agent session store should lock") .get(session_id) .filter(|record| record.owner_user_id == owner_user_id) .map(|record| record.snapshot.clone()) } pub fn put_creative_agent_session( &self, owner_user_id: String, session: CreativeAgentSessionSnapshot, ) { self.creative_agent_sessions .lock() .expect("creative agent session store should lock") .insert( session.session_id.clone(), CreativeAgentSessionRuntimeRecord { owner_user_id, snapshot: session, }, ); } pub async fn get_runtime_snapshot_record( &self, user_id: String, ) -> Result, SpacetimeClientError> { match self .spacetime_client .get_runtime_snapshot(user_id.clone()) .await { Ok(record) => { #[cfg(test)] if let Some(snapshot) = record.as_ref() { self.cache_test_runtime_snapshot(snapshot.clone()); } Ok(record) } #[cfg(test)] Err(_) => Ok(self.read_test_runtime_snapshot(user_id.as_str())), #[cfg(not(test))] Err(error) => Err(error), } } pub async fn put_runtime_snapshot_record( &self, user_id: String, saved_at_micros: i64, bottom_tab: String, game_state: Value, current_story: Option, updated_at_micros: i64, ) -> Result { match self .spacetime_client .put_runtime_snapshot( user_id.clone(), saved_at_micros, bottom_tab.clone(), game_state.clone(), current_story.clone(), updated_at_micros, ) .await { Ok(record) => { #[cfg(test)] self.cache_test_runtime_snapshot(record.clone()); Ok(record) } #[cfg(test)] Err(_) => { let snapshot = self.build_test_runtime_snapshot_record( user_id, saved_at_micros, bottom_tab, game_state, current_story, updated_at_micros, )?; self.cache_test_runtime_snapshot(snapshot.clone()); Ok(snapshot) } #[cfg(not(test))] Err(error) => Err(error), } } pub async fn delete_runtime_snapshot_record( &self, user_id: String, ) -> Result { match self .spacetime_client .delete_runtime_snapshot(user_id.clone()) .await { Ok(deleted) => { #[cfg(test)] if deleted { self.remove_test_runtime_snapshot(user_id.as_str()); } Ok(deleted) } #[cfg(test)] Err(_) => Ok(self .remove_test_runtime_snapshot(user_id.as_str()) .is_some()), #[cfg(not(test))] Err(error) => Err(error), } } } #[cfg(test)] impl AppState { fn cache_test_creation_entry_config(&self, config: CreationEntryConfigResponse) { *self .test_creation_entry_config .lock() .expect("test creation entry config should lock") = Some(config); } fn read_test_creation_entry_config(&self) -> CreationEntryConfigResponse { self.test_creation_entry_config .lock() .expect("test creation entry config should lock") .clone() .unwrap_or_else(crate::creation_entry_config::test_creation_entry_config_response) } pub(crate) async fn seed_test_phone_user_with_password( &self, phone_number: &str, password: &str, ) -> module_auth::AuthUser { let now = OffsetDateTime::now_utc(); self.phone_auth_service() .send_code( module_auth::SendPhoneCodeInput { phone_number: phone_number.to_string(), scene: module_auth::PhoneAuthScene::Login, }, now, ) .await .expect("test phone code should send"); let user = self .phone_auth_service() .login( module_auth::PhoneLoginInput { phone_number: phone_number.to_string(), verify_code: "123456".to_string(), }, now + time::Duration::seconds(1), ) .await .expect("test phone login should create user") .user; let changed = self .password_entry_service() .change_password(module_auth::ChangePasswordInput { user_id: user.id.clone(), current_password: None, new_password: password.to_string(), }) .await .expect("test password should set"); changed.user } fn cache_test_runtime_snapshot(&self, record: RuntimeSnapshotRecord) { self.test_runtime_snapshot_store .lock() .expect("test runtime snapshot store should lock") .insert(record.user_id.clone(), record); } fn read_test_runtime_snapshot(&self, user_id: &str) -> Option { self.test_runtime_snapshot_store .lock() .expect("test runtime snapshot store should lock") .get(user_id) .cloned() } fn remove_test_runtime_snapshot(&self, user_id: &str) -> Option { self.test_runtime_snapshot_store .lock() .expect("test runtime snapshot store should lock") .remove(user_id) } fn build_test_runtime_snapshot_record( &self, user_id: String, saved_at_micros: i64, bottom_tab: String, game_state: Value, current_story: Option, updated_at_micros: i64, ) -> Result { let previous = self.read_test_runtime_snapshot(user_id.as_str()); let game_state_json = serde_json::to_string(&game_state).map_err(|error| { SpacetimeClientError::Runtime(format!("测试快照 game_state 序列化失败: {error}")) })?; let current_story_json = current_story .as_ref() .map(serde_json::to_string) .transpose() .map_err(|error| { SpacetimeClientError::Runtime(format!("测试快照 current_story 序列化失败: {error}")) })?; Ok(RuntimeSnapshotRecord { user_id, version: SAVE_SNAPSHOT_VERSION, saved_at: format_utc_micros(saved_at_micros), saved_at_micros, bottom_tab, game_state, current_story, game_state_json, current_story_json, created_at_micros: previous .as_ref() .map(|record| record.created_at_micros) .unwrap_or(updated_at_micros), updated_at_micros, }) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AuthStoreRestoreSource { SpacetimeTables, SpacetimeSnapshot, LocalFile, } impl AuthStoreRestoreSource { fn as_str(self) -> &'static str { match self { Self::SpacetimeTables => "spacetime_tables", Self::SpacetimeSnapshot => "spacetime_snapshot", Self::LocalFile => "local_file", } } } #[derive(Debug)] struct AuthStoreRestoreCandidate { source: AuthStoreRestoreSource, updated_at_micros: Option, auth_store: InMemoryAuthStore, } fn auth_store_candidate_from_snapshot_record( snapshot: spacetime_client::AuthStoreSnapshotRecord, source: AuthStoreRestoreSource, ) -> Result, AppStateInitError> { let Some(snapshot_json) = snapshot .snapshot_json .filter(|value| !value.trim().is_empty()) else { return Ok(None); }; let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) .map_err(AppStateInitError::AuthStore)?; Ok(Some(AuthStoreRestoreCandidate { source, updated_at_micros: snapshot.updated_at_micros, auth_store, })) } fn auth_store_candidate_from_local_file( config: &AppConfig, ) -> Result, AppStateInitError> { if !config.auth_store_path.is_file() { return Ok(None); } let updated_at_micros = fs::metadata(&config.auth_store_path) .ok() .and_then(|metadata| metadata.modified().ok()) .and_then(system_time_to_unix_micros); let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone()) .map_err(AppStateInitError::AuthStore)?; Ok(Some(AuthStoreRestoreCandidate { source: AuthStoreRestoreSource::LocalFile, updated_at_micros, auth_store, })) } fn system_time_to_unix_micros(system_time: SystemTime) -> Option { let duration = system_time.duration_since(UNIX_EPOCH).ok()?; i64::try_from(duration.as_micros()).ok() } fn select_auth_store_restore_candidate( candidates: Vec, ) -> Option { candidates.into_iter().max_by_key(|candidate| { ( candidate.updated_at_micros.unwrap_or(i64::MIN), auth_store_restore_source_priority(candidate.source), ) }) } fn auth_store_restore_source_priority(source: AuthStoreRestoreSource) -> u8 { match source { AuthStoreRestoreSource::SpacetimeSnapshot => 3, AuthStoreRestoreSource::SpacetimeTables => 2, AuthStoreRestoreSource::LocalFile => 1, } } impl fmt::Display for AppStateInitError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Jwt(error) => write!(f, "{error}"), Self::RefreshCookie(error) => write!(f, "{error}"), Self::AuthStore(error) => write!(f, "{error}"), Self::SmsProvider(error) => write!(f, "{error}"), Self::Oss(error) => write!(f, "{error}"), Self::Llm(error) => write!(f, "{error}"), } } } impl Error for AppStateInitError {} impl From for AppStateInitError { fn from(value: JwtError) -> Self { Self::Jwt(value) } } impl From for AppStateInitError { fn from(value: RefreshCookieError) -> Self { Self::RefreshCookie(value) } } impl From for AppStateInitError { fn from(value: SmsProviderError) -> Self { Self::SmsProvider(value) } } impl From for AppStateInitError { fn from(value: OssError) -> Self { Self::Oss(value) } } impl From for AppStateInitError { fn from(value: LlmError) -> Self { Self::Llm(value) } } impl AdminRuntime { pub fn is_enabled(&self) -> bool { !self.username.trim().is_empty() && !self.password.trim().is_empty() } pub fn username(&self) -> &str { &self.username } pub fn password(&self) -> &str { &self.password } pub fn build_claims(&self, now: OffsetDateTime) -> Result { let expires_at = now .checked_add(time::Duration::seconds( i64::try_from(self.token_ttl_seconds) .map_err(|_| "后台 token TTL 超出 i64 上限".to_string())?, )) .ok_or_else(|| "后台 token 过期时间计算溢出".to_string())?; Ok(AdminClaims { subject: self.subject.to_string(), username: self.username.to_string(), issued_at: now, expires_at, }) } pub fn sign_token(&self, claims: &AdminClaims) -> Result { let jwt_claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: claims.subject.clone(), session_id: format!("admin-session-{}", claims.username), provider: AuthProvider::Password, roles: vec![ADMIN_ROLE.to_string()], token_version: 1, phone_verified: false, binding_status: BindingStatus::Active, display_name: Some(self.display_name.to_string()), }, &self.jwt_config, claims.issued_at, ) .map_err(|error| error.to_string())?; sign_access_token(&jwt_claims, &self.jwt_config).map_err(|error| error.to_string()) } pub fn verify_token(&self, token: &str) -> Result { verify_access_token(token, &self.jwt_config).map_err(|error| error.to_string()) } pub fn validate_claims(&self, claims: &AccessTokenClaims) -> Result { if claims.user_id() != self.subject.as_ref() { return Err("后台管理员主体不匹配".to_string()); } if !claims.roles.iter().any(|role| role == ADMIN_ROLE) { return Err("当前令牌不是管理员令牌".to_string()); } let issued_at = OffsetDateTime::from_unix_timestamp(claims.iat as i64) .map_err(|_| "后台令牌签发时间无效".to_string())?; let expires_at = OffsetDateTime::from_unix_timestamp(claims.exp as i64) .map_err(|_| "后台令牌过期时间无效".to_string())?; Ok(AdminSession { subject: claims.user_id().to_string(), username: self.username.to_string(), display_name: self.display_name.to_string(), roles: claims.roles.clone(), issued_at, expires_at, }) } pub fn build_session(&self, claims: &AdminClaims) -> AdminSession { AdminSession { subject: claims.subject.clone(), username: claims.username.clone(), display_name: self.display_name.to_string(), roles: vec![ADMIN_ROLE.to_string()], issued_at: claims.issued_at, expires_at: claims.expires_at, } } } fn build_oss_client(config: &AppConfig) -> Result, AppStateInitError> { let has_any_oss_field = config.oss_bucket.is_some() || config.oss_endpoint.is_some() || config.oss_access_key_id.is_some() || config.oss_access_key_secret.is_some(); if !has_any_oss_field { return Ok(None); } let oss_config = OssConfig::new( config.oss_bucket.clone().unwrap_or_default(), config.oss_endpoint.clone().unwrap_or_default(), config.oss_access_key_id.clone().unwrap_or_default(), config.oss_access_key_secret.clone().unwrap_or_default(), config.oss_read_expire_seconds, config.oss_post_expire_seconds, config.oss_post_max_size_bytes, config.oss_success_action_status, )?; Ok(Some(OssClient::new(oss_config))) } fn build_llm_client(config: &AppConfig) -> Result, AppStateInitError> { let Some(api_key) = config .llm_api_key .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) else { return Ok(None); }; let llm_config = LlmConfig::new( config.llm_provider, config.llm_base_url.clone(), api_key.to_string(), config.llm_model.clone(), config.llm_request_timeout_ms, config.llm_max_retries, config.llm_retry_backoff_ms, )?; Ok(Some(LlmClient::new(llm_config)?)) } fn build_creative_agent_gpt5_client( config: &AppConfig, ) -> Result, AppStateInitError> { let Some(api_key) = config .apimart_api_key .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) else { return Ok(None); }; let llm_config = LlmConfig::new( LlmProvider::OpenAiCompatible, config.apimart_base_url.clone(), api_key.to_string(), platform_agent::CREATIVE_AGENT_GPT5_MODEL.to_string(), config.llm_request_timeout_ms, 0, config.llm_retry_backoff_ms, )? .with_official_fallback(true); Ok(Some(LlmClient::new(llm_config)?)) } // 只有在用户名和密码都已配置时才启用后台,避免半配置状态暴露伪入口。 fn build_admin_runtime( config: &AppConfig, base_jwt_config: &JwtConfig, ) -> Result, AppStateInitError> { let Some(username) = config .admin_username .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) else { return Ok(None); }; let Some(password) = config .admin_password .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) else { return Ok(None); }; let jwt_config = JwtConfig::new( base_jwt_config.issuer().to_string(), config.jwt_secret.clone(), config.admin_token_ttl_seconds, )?; Ok(Some(AdminRuntime { username: Arc::::from(username), password: Arc::::from(password), subject: Arc::::from(format!("admin:{username}")), display_name: Arc::::from(format!("管理员 {username}")), token_ttl_seconds: config.admin_token_ttl_seconds, jwt_config, })) } #[cfg(test)] mod tests { use module_ai::{AiTaskKind, generate_ai_task_id}; use super::*; #[test] fn app_state_exposes_usable_ai_task_service() { let state = AppState::new(AppConfig::default()).expect("state should build"); let task_id = generate_ai_task_id(1_713_680_000_000_000); let created = state .ai_task_service() .create_task(module_ai::AiTaskCreateInput { task_id: task_id.clone(), task_kind: AiTaskKind::StoryGeneration, owner_user_id: "user_001".to_string(), request_label: "营地开场".to_string(), source_module: "story".to_string(), source_entity_id: Some("storysess_001".to_string()), request_payload_json: Some("{\"scene\":\"camp\"}".to_string()), stages: AiTaskKind::StoryGeneration.default_stage_blueprints(), created_at_micros: 1_713_680_000_000_000, }) .expect("ai task should create"); assert_eq!(created.task_id, task_id); assert_eq!(created.task_kind, AiTaskKind::StoryGeneration); assert_eq!(created.stages.len(), 4); } #[test] fn app_state_skips_llm_client_when_api_key_missing() { let state = AppState::new(AppConfig::default()).expect("state should build"); assert!(state.llm_client().is_none()); assert!(state.creative_agent_gpt5_client().is_none()); } #[test] fn app_state_builds_creative_agent_gpt5_client_from_apimart_settings() { let mut config = AppConfig::default(); config.llm_api_key = None; config.apimart_base_url = "https://api.apimart.test/v1".to_string(); config.apimart_api_key = Some("apimart-key".to_string()); let state = AppState::new(config).expect("state should build"); let client = state .creative_agent_gpt5_client() .expect("creative agent gpt5 client should exist"); assert_eq!( client.config().model(), platform_agent::CREATIVE_AGENT_GPT5_MODEL ); assert_eq!( client.config().responses_url(), "https://api.apimart.test/v1/responses" ); assert!(client.config().official_fallback()); } }