Files
Genarrative/server-rs/crates/api-server/src/state.rs
T
lhk229 56b65a430d 收紧编辑器图片持久化的上传边界
OSS 共享客户端扩展为读写共用并按上传方向放宽整体超时
画板生成图片持久化的 PUT 与 HEAD 改走共享客户端
补齐持久化写路径的共享客户端回归断言

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 05:40:51 +00:00

2548 lines
89 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.
#[cfg(test)]
use std::sync::Mutex;
use std::{
collections::BTreeMap,
error::Error,
fmt,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use axum::extract::FromRef;
use module_ai::{AiTaskService, InMemoryAiTaskStore};
use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
RefreshSessionService, WechatAuthService, WechatAuthStateService,
};
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_matting::{MattingClient, MattingConfig};
use platform_oss::{OssClient, OssConfig, OssError};
use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient};
use spacetime_client::{
EditorGenerationModelPricingRecord, EditorGenerationPricingConfigRecord,
EditorGenerationPricingConfigUpsertRecordInput, EditorGenerationPricingTierRecord,
SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError, SpacetimeClientHealthSnapshot,
};
use time::OffsetDateTime;
use tokio::sync::{Semaphore, broadcast};
use tracing::{info, warn};
use crate::config::AppConfig;
use crate::editor_generation_config::{
EditorGenerationModelPricing, EditorGenerationPricingConfig, EditorGenerationPricingError,
EditorGenerationPricingStore, EditorGenerationPricingUnit,
};
use crate::tracking_outbox::TrackingOutbox;
use crate::wallet_refund_outbox::WalletRefundOutbox;
use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error};
use crate::wechat::provider::build_wechat_provider;
use crate::work_author::{
ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID,
};
const ADMIN_ROLE: &str = "admin";
const EDITOR_AGENT_LLM_MAX_RETRIES: u32 = 1;
const EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS: u64 = 60_000;
pub(crate) const CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY: usize = 8;
// P=8:父侧成功图片读取/解码槽。配 N=16 是内存与出口吞吐的折中,
// 极端完整 body 内存按 (N + P) × 32 MiB 评估(见调度方案 §9.2)。
pub(crate) const BGFILTER_IMAGE_VALIDATION_MAX_CONCURRENCY: usize = 8;
pub type HttpRequestPermitPool = Semaphore;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HttpRequestPermitPoolKind {
Default,
Admin,
}
impl HttpRequestPermitPoolKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::Admin => "admin",
}
}
}
#[derive(Clone, Debug)]
pub struct HttpRequestPermitPools {
default: Option<Arc<HttpRequestPermitPool>>,
admin: Option<Arc<HttpRequestPermitPool>>,
}
impl HttpRequestPermitPools {
fn from_config(config: &AppConfig) -> Self {
Self {
default: config
.max_concurrent_requests
.map(HttpRequestPermitPool::new)
.map(Arc::new),
admin: config
.admin_max_concurrent_requests
.map(HttpRequestPermitPool::new)
.map(Arc::new),
}
}
pub fn pool(
&self,
kind: HttpRequestPermitPoolKind,
) -> Option<(HttpRequestPermitPoolKind, Arc<HttpRequestPermitPool>)> {
let selected = match kind {
HttpRequestPermitPoolKind::Default => self.default.clone(),
HttpRequestPermitPoolKind::Admin => self.admin.clone(),
};
selected.map(|pool| (kind, pool)).or_else(|| {
self.default
.clone()
.map(|pool| (HttpRequestPermitPoolKind::Default, pool))
})
}
}
#[derive(Clone, Debug)]
pub struct BackpressureState {
permit_pools: HttpRequestPermitPools,
}
impl BackpressureState {
pub fn request_permit_pool(
&self,
kind: HttpRequestPermitPoolKind,
) -> Option<(HttpRequestPermitPoolKind, Arc<HttpRequestPermitPool>)> {
self.permit_pools.pool(kind)
}
}
#[derive(Clone, Debug)]
pub struct AppState(Arc<AppStateInner>);
impl std::ops::Deref for AppState {
type Target = AppStateInner;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl FromRef<AppState> for BackpressureState {
fn from_ref(state: &AppState) -> Self {
Self {
permit_pools: state.http_request_permit_pools(),
}
}
}
#[derive(Clone, Debug)]
#[cfg(any())]
pub struct PuzzleApiState {
root_state: AppState,
spacetime_client: SpacetimeClient,
puzzle_gallery_cache: PuzzleGalleryCache,
oss_client: Option<OssClient>,
auth_user_service: AuthUserService,
llm_client: Option<LlmClient>,
creative_agent_gpt5_client: Option<LlmClient>,
creation_agent_llm_web_search_enabled: bool,
vector_engine_image_request_timeout_ms: u64,
}
#[cfg(any())]
impl PuzzleApiState {
pub fn root_state(&self) -> &AppState {
&self.root_state
}
pub fn spacetime_client(&self) -> &SpacetimeClient {
&self.spacetime_client
}
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
&self.puzzle_gallery_cache
}
pub fn oss_client(&self) -> Option<&OssClient> {
self.oss_client.as_ref()
}
pub fn auth_user_service(&self) -> &AuthUserService {
&self.auth_user_service
}
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 creation_agent_llm_web_search_enabled(&self) -> bool {
self.creation_agent_llm_web_search_enabled
}
pub fn vector_engine_image_request_timeout_ms(&self) -> u64 {
self.vector_engine_image_request_timeout_ms
}
pub fn vector_engine_base_url(&self) -> &str {
self.root_state.config.vector_engine_base_url.as_str()
}
pub fn vector_engine_api_key(&self) -> Option<&str> {
self.root_state.config.vector_engine_api_key.as_deref()
}
}
#[cfg(any())]
impl FromRef<AppState> for PuzzleApiState {
fn from_ref(state: &AppState) -> Self {
// 中文注释:拼图路由只暴露本能力需要的依赖快照,避免 handler 直接看见完整 AppState。
Self {
root_state: state.clone(),
spacetime_client: state.spacetime_client.clone(),
puzzle_gallery_cache: state.puzzle_gallery_cache.clone(),
oss_client: state.oss_client.clone(),
auth_user_service: state.auth_user_service.clone(),
llm_client: state.llm_client.clone(),
creative_agent_gpt5_client: state.creative_agent_gpt5_client.clone(),
creation_agent_llm_web_search_enabled: state
.config
.creation_agent_llm_web_search_enabled,
vector_engine_image_request_timeout_ms: state
.config
.vector_engine_image_request_timeout_ms,
}
}
}
// Axum/Hyper 会在路由树和连接 service 上频繁 clone stateAppState 外层必须保持浅拷贝。
#[derive(Debug)]
pub struct AppStateInner {
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
#[allow(dead_code)]
pub config: AppConfig,
ready: AtomicBool,
/// 本进程是否已至少一次连通 BgFilter worker(收到任意 HTTP 响应即算)。
/// 连接失败重试用它区分冷启动窗口(开机/首连,宽限退避)与运行中途故障(快速收口)。
bgfilter_worker_reached: AtomicBool,
http_request_permit_pools: HttpRequestPermitPools,
auth_jwt_config: JwtConfig,
admin_runtime: Option<AdminRuntime>,
refresh_cookie_config: RefreshCookieConfig,
#[cfg(any())]
test_creation_entry_config: Arc<Mutex<Option<CreationEntryConfigResponse>>>,
#[cfg(test)]
test_feature_gate_config: Arc<Mutex<Option<Vec<module_runtime::FeatureGateConfigSnapshot>>>>,
#[cfg(test)]
test_spacetime_health: Arc<Mutex<Option<SpacetimeClientHealthSnapshot>>>,
oss_client: Option<OssClient>,
#[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,
wechat_client: WechatClient,
wechat_pay_client: WechatPayClient,
#[cfg_attr(not(test), allow(dead_code))]
ai_task_service: AiTaskService,
spacetime_client: SpacetimeClient,
#[cfg(any())]
puzzle_gallery_cache: PuzzleGalleryCache,
tracking_outbox: Option<Arc<TrackingOutbox>>,
wallet_refund_outbox: Option<Arc<WalletRefundOutbox>>,
editor_generation_pricing_store: EditorGenerationPricingStore,
llm_client: Option<LlmClient>,
editor_agent_llm_client: Option<LlmClient>,
matting_client: Option<MattingClient>,
bgfilter_provider_http_client: reqwest::Client,
bgfilter_worker_http_client: reqwest::Client,
bgfilter_image_validation_limiter: Arc<Semaphore>,
character_animation_oss_http_client: reqwest::Client,
character_animation_oss_io_limiter: Arc<Semaphore>,
editor_oss_http_client: reqwest::Client,
#[cfg(any())]
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
#[cfg(any())]
creative_agent_sessions: Arc<Mutex<HashMap<String, CreativeAgentSessionRuntimeRecord>>>,
profile_recharge_order_updates: broadcast::Sender<String>,
#[cfg(any())]
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
}
#[derive(Clone, Debug)]
#[cfg(any())]
struct CreativeAgentSessionRuntimeRecord {
owner_user_id: String,
snapshot: CreativeAgentSessionSnapshot,
}
// 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。
#[derive(Clone, Debug)]
pub struct AdminRuntime {
username: Arc<str>,
password: Arc<str>,
subject: Arc<str>,
display_name: Arc<str>,
token_ttl_seconds: u64,
jwt_config: JwtConfig,
}
#[derive(Clone, Debug)]
pub struct AdminClaims {
pub subject: String,
pub username: String,
pub display_name: String,
pub roles: Vec<String>,
pub token_version: u64,
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<String>,
pub account_role: String,
pub tab_permissions: Vec<String>,
pub action_permissions: Vec<String>,
pub issued_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
#[derive(Debug)]
pub enum AppStateInitError {
Jwt(JwtError),
RefreshCookie(RefreshCookieError),
AuthStore(String),
DependencyUnavailable(String),
SmsProvider(SmsProviderError),
WechatPay(String),
Oss(OssError),
Llm(LlmError),
}
fn editor_generation_pricing_to_records(
config: &EditorGenerationPricingConfig,
) -> Result<Vec<EditorGenerationModelPricingRecord>, EditorGenerationPricingError> {
config.validate()?;
Ok(config
.models
.iter()
.map(|(model, pricing)| EditorGenerationModelPricingRecord {
model: model.clone(),
unit: match pricing.unit {
EditorGenerationPricingUnit::PerGeneration => "perGeneration",
EditorGenerationPricingUnit::PerSecond => "perSecond",
}
.to_string(),
price: pricing.price,
prices: pricing
.prices
.iter()
.map(|(key, price)| EditorGenerationPricingTierRecord {
key: key.clone(),
price: *price,
})
.collect(),
})
.collect())
}
fn editor_generation_pricing_from_record(
record: EditorGenerationPricingConfigRecord,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
let mut models = BTreeMap::new();
for pricing in record.models {
let unit = match pricing.unit.as_str() {
"perGeneration" => EditorGenerationPricingUnit::PerGeneration,
"perSecond" => EditorGenerationPricingUnit::PerSecond,
other => {
return Err(EditorGenerationPricingError::Invalid(format!(
"SpacetimeDB 模型定价配置 models.{}.unit 不合法:{other}",
pricing.model
)));
}
};
let mut prices = BTreeMap::new();
for tier in pricing.prices {
if prices.insert(tier.key.clone(), tier.price).is_some() {
return Err(EditorGenerationPricingError::Invalid(format!(
"SpacetimeDB 模型定价配置 models.{} 包含重复档位 {}",
pricing.model, tier.key
)));
}
}
let model = pricing.model;
if models
.insert(
model.clone(),
EditorGenerationModelPricing {
unit,
price: pricing.price,
prices,
},
)
.is_some()
{
return Err(EditorGenerationPricingError::Invalid(format!(
"SpacetimeDB 模型定价配置包含重复模型 {model}"
)));
}
}
let config = EditorGenerationPricingConfig { models };
config.validate()?;
Ok(config)
}
fn editor_generation_pricing_upsert_input(
config: &AppConfig,
admin_user_id: String,
models: Vec<EditorGenerationModelPricingRecord>,
updated_at_micros: i64,
) -> EditorGenerationPricingConfigUpsertRecordInput {
EditorGenerationPricingConfigUpsertRecordInput {
admin_user_id,
models,
updated_at_micros,
bootstrap_secret: config
.spacetime_runtime_service_bootstrap_secret
.clone()
.unwrap_or_default(),
}
}
impl AppState {
#[cfg(test)]
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
Self::new_with_empty_auth_store(config)
}
pub fn new_with_empty_auth_store(config: AppConfig) -> Result<Self, AppStateInitError> {
// 中文注释:api-server 不再把本地 auth-store.json 当作用户认证真相源,启动恢复只允许来自 SpacetimeDB。
Self::new_with_auth_store(config, InMemoryAuthStore::default())
}
fn new_with_auth_store(
config: AppConfig,
auth_store: InMemoryAuthStore,
) -> Result<Self, AppStateInitError> {
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());
auth_user_service
.ensure_orphan_work_owner_user(
ORPHAN_WORK_OWNER_USER_ID,
ORPHAN_WORK_OWNER_USER_ID,
ORPHAN_WORK_AUTHOR_DISPLAY_NAME,
ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE,
)
.map_err(|error| AppStateInitError::AuthStore(error.to_string()))?;
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 wechat_client = build_wechat_client(&config);
let wechat_pay_client = WechatPayClient::from_config(&build_wechat_pay_config(&config))
.map_err(map_wechat_pay_init_error)?;
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(spacetime_client_config_for_process(&config));
let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone());
let wallet_refund_outbox =
WalletRefundOutbox::from_config(&config, spacetime_client.clone());
let editor_generation_pricing_store = EditorGenerationPricingStore::load(
config.editor_generation_pricing_override_path.clone(),
)
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
let llm_client = build_llm_client(&config)?;
let editor_agent_llm_client = build_editor_agent_llm_client(&config)?;
let matting_client = build_matting_client(&config)?;
let bgfilter_provider_http_client = build_bgfilter_provider_http_client(&config)?;
let bgfilter_worker_http_client = build_bgfilter_worker_http_client(&config)?;
let bgfilter_image_validation_concurrency = if config.process_role.runs_bgfilter_worker() {
// 子 worker 已由 provider N 限流;图片校验槽与 N 对齐,避免引入第二个隐藏吞吐上限。
config.bgfilter_worker_concurrency.max(1)
} else {
// 父 API / external-generation worker 固定限制解码并发,避免动画响应同时进入 blocking pool。
BGFILTER_IMAGE_VALIDATION_MAX_CONCURRENCY
};
let bgfilter_image_validation_limiter = Arc::new(Semaphore::new(
bgfilter_image_validation_concurrency.min(Semaphore::MAX_PERMITS),
));
let character_animation_oss_http_client = build_character_animation_oss_http_client()?;
let character_animation_oss_io_limiter =
Arc::new(Semaphore::new(CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY));
let editor_oss_http_client = build_editor_oss_http_client()?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
let (profile_recharge_order_updates, _) = broadcast::channel(128);
Ok(Self(Arc::new(AppStateInner {
config,
ready: AtomicBool::new(true),
bgfilter_worker_reached: AtomicBool::new(false),
http_request_permit_pools,
auth_jwt_config,
admin_runtime,
refresh_cookie_config,
#[cfg(any())]
test_creation_entry_config: Arc::new(Mutex::new(Some(
crate::creation_entry_config::test_creation_entry_config_response(),
))),
#[cfg(test)]
test_feature_gate_config: Arc::new(Mutex::new(Some(vec![]))),
#[cfg(test)]
test_spacetime_health: Arc::new(Mutex::new(Some(
SpacetimeClientHealthSnapshot::healthy_for_test(),
))),
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,
wechat_client,
wechat_pay_client,
ai_task_service,
spacetime_client,
#[cfg(any())]
puzzle_gallery_cache: PuzzleGalleryCache::new(),
tracking_outbox,
wallet_refund_outbox,
editor_generation_pricing_store,
llm_client,
editor_agent_llm_client,
matting_client,
bgfilter_provider_http_client,
bgfilter_worker_http_client,
bgfilter_image_validation_limiter,
character_animation_oss_http_client,
character_animation_oss_io_limiter,
editor_oss_http_client,
#[cfg(any())]
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
#[cfg(any())]
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
profile_recharge_order_updates,
#[cfg(any())]
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 fn http_request_permit_pools(&self) -> HttpRequestPermitPools {
self.http_request_permit_pools.clone()
}
pub(crate) async fn editor_generation_pricing(
&self,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
#[cfg(test)]
{
return self.editor_generation_pricing_store.snapshot();
}
#[cfg(not(test))]
match self
.spacetime_client
.get_editor_generation_pricing_config()
.await
{
Ok(Some(record)) => {
let pricing = editor_generation_pricing_from_record(record)?;
self.editor_generation_pricing_store
.replace(pricing.clone())?;
Ok(pricing)
}
Ok(None) => self.seed_editor_generation_pricing_config().await,
Err(error) => {
warn!(
error = %error,
"读取 SpacetimeDB 模型定价配置失败,使用本地缓存兜底"
);
self.editor_generation_pricing_store.snapshot()
}
}
}
pub(crate) async fn save_editor_generation_pricing(
&self,
admin_user_id: String,
next: EditorGenerationPricingConfig,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
#[cfg(test)]
{
let _ = admin_user_id;
return self.editor_generation_pricing_store.replace(next);
}
#[cfg(not(test))]
{
let models = editor_generation_pricing_to_records(&next)?;
let record = self
.spacetime_client
.upsert_editor_generation_pricing_config(editor_generation_pricing_upsert_input(
&self.config,
admin_user_id,
models,
crate::editor_project::current_utc_micros(),
))
.await
.map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?;
let pricing = editor_generation_pricing_from_record(record)?;
self.editor_generation_pricing_store
.replace(pricing.clone())?;
Ok(pricing)
}
}
pub(crate) async fn ensure_editor_generation_runtime_service_identity(
&self,
) -> Result<(), EditorGenerationPricingError> {
self.seed_editor_generation_pricing_config().await?;
Ok(())
}
#[cfg_attr(test, allow(dead_code))]
async fn seed_editor_generation_pricing_config(
&self,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
let fallback = self.editor_generation_pricing_store.snapshot()?;
let models = editor_generation_pricing_to_records(&fallback)?;
let record = self
.spacetime_client
.initialize_editor_generation_pricing_config_if_missing(
editor_generation_pricing_upsert_input(
&self.config,
"system:editor-generation-pricing".to_string(),
models,
crate::editor_project::current_utc_micros(),
),
)
.await
.map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?;
let pricing = editor_generation_pricing_from_record(record)?;
self.editor_generation_pricing_store
.replace(pricing.clone())?;
Ok(pricing)
}
pub fn is_ready(&self) -> bool {
self.ready.load(Ordering::Acquire)
}
pub fn mark_not_ready(&self) {
self.ready.store(false, Ordering::Release);
}
pub async fn spacetime_health_check(&self) -> SpacetimeClientHealthSnapshot {
#[cfg(test)]
if let Some(snapshot) = self
.test_spacetime_health
.lock()
.expect("test spacetime health should lock")
.clone()
{
return snapshot;
}
self.spacetime_client
.health_check(self.config.spacetime_health_check_timeout)
.await
}
#[cfg(test)]
pub(crate) fn set_test_spacetime_health(&self, snapshot: SpacetimeClientHealthSnapshot) {
*self
.test_spacetime_health
.lock()
.expect("test spacetime health should lock") = Some(snapshot);
}
#[cfg(any())]
pub async fn upsert_creation_entry_type_config(
&self,
input: module_runtime::CreationEntryTypeAdminUpsertInput,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
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),
}
}
/// 通过 SpacetimeDB 保存创作入口页多公告配置,并同步测试缓存。
#[cfg(any())]
pub async fn upsert_creation_entry_event_banners_config(
&self,
input: module_runtime::CreationEntryEventBannersAdminUpsertInput,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
#[cfg(test)]
let test_event_banners_json = input.event_banners_json.clone();
match self
.spacetime_client
.upsert_creation_entry_event_banners_config(input)
.await
{
Ok(config) => {
#[cfg(test)]
self.cache_test_creation_entry_config(config.clone());
Ok(config)
}
#[cfg(test)]
Err(_) => {
let mut config = self.read_test_creation_entry_config();
if let Ok(banners) = module_runtime::decode_creation_entry_event_banner_snapshots(
test_event_banners_json.as_str(),
) {
config.event_banners = banners
.into_iter()
.map(module_runtime::build_creation_entry_event_banner_response)
.collect();
if let Some(first_banner) = config.event_banners.first().cloned() {
config.event_banner = first_banner;
}
self.cache_test_creation_entry_config(config.clone());
}
Ok(config)
}
#[cfg(not(test))]
Err(error) => Err(error),
}
}
/// 通过 SpacetimeDB 保存公开作品互动配置,并同步测试缓存。
#[cfg(any())]
pub async fn upsert_public_work_interaction_config(
&self,
input: module_runtime::PublicWorkInteractionConfigAdminUpsertInput,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
#[cfg(test)]
let test_interactions_json = input.public_work_interactions_json.clone();
match self
.spacetime_client
.upsert_public_work_interaction_config(input)
.await
{
Ok(config) => {
#[cfg(test)]
self.cache_test_creation_entry_config(config.clone());
Ok(config)
}
#[cfg(test)]
Err(_) => {
let mut config = self.read_test_creation_entry_config();
if let Ok(interactions) =
module_runtime::decode_public_work_interaction_config_snapshots(
test_interactions_json.as_str(),
)
{
config.public_work_interactions = interactions
.into_iter()
.map(module_runtime::build_public_work_interaction_config_response)
.collect();
self.cache_test_creation_entry_config(config.clone());
}
Ok(config)
}
#[cfg(not(test))]
Err(error) => Err(error),
}
}
#[cfg(any())]
pub async fn get_creation_entry_config(
&self,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
match self.spacetime_client.get_creation_entry_config().await {
Ok(config) => {
#[cfg(test)]
self.cache_test_creation_entry_config(config.clone());
Ok(config)
}
#[cfg(debug_assertions)]
Err(error) if is_missing_creation_entry_config_procedure(&error) => {
warn!(
error = %error,
"本地 SpacetimeDB 缺少创作入口配置 procedure,使用后端默认入口配置兜底"
);
Ok(crate::creation_entry_config::default_creation_entry_config_response())
}
#[cfg(test)]
Err(_) => Ok(self.read_test_creation_entry_config()),
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn get_feature_gate_config(
&self,
) -> Result<Vec<module_runtime::FeatureGateConfigSnapshot>, SpacetimeClientError> {
match self.spacetime_client.get_feature_gate_config().await {
Ok(config) => {
#[cfg(test)]
self.cache_test_feature_gate_config(config.clone());
Ok(config)
}
#[cfg(debug_assertions)]
Err(error) if is_missing_feature_gate_config_procedure(&error) => {
warn!(
error = %error,
"本地 SpacetimeDB 缺少灰度配置 procedure,使用空灰度配置兜底"
);
Ok(vec![])
}
#[cfg(test)]
Err(_) => Ok(self.read_test_feature_gate_config()),
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn upsert_feature_gate_config(
&self,
input: module_runtime::FeatureGateConfigAdminUpsertInput,
) -> Result<Vec<module_runtime::FeatureGateConfigSnapshot>, SpacetimeClientError> {
#[cfg(test)]
let test_input = input.clone();
match self
.spacetime_client
.upsert_feature_gate_config(input)
.await
{
Ok(config) => {
#[cfg(test)]
self.cache_test_feature_gate_config(config.clone());
Ok(config)
}
#[cfg(test)]
Err(_) => {
let normalized =
module_runtime::normalize_feature_gate_admin_upsert_input(test_input)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?;
let mut config = self.read_test_feature_gate_config();
let now_micros = crate::editor_project::current_utc_micros();
let record = module_runtime::FeatureGateConfigSnapshot {
gate_key: normalized.gate_key.clone(),
enabled: normalized.enabled,
rollout_percent: normalized.rollout_percent,
allow_user_ids: normalized.allow_user_ids,
allow_user_tags: normalized.allow_user_tags,
deny_user_ids: normalized.deny_user_ids,
description: normalized.description,
updated_at_micros: now_micros,
};
if let Some(existing) = config
.iter_mut()
.find(|item| item.gate_key == normalized.gate_key)
{
*existing = record;
} else {
config.push(record);
}
config.sort_by(|left, right| left.gate_key.cmp(&right.gate_key));
self.cache_test_feature_gate_config(config.clone());
Ok(config)
}
#[cfg(not(test))]
Err(error) => Err(error),
}
}
#[cfg(any())]
pub async fn get_creation_entry_config_for_user(
&self,
user_id: Option<&str>,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
let config = self.get_creation_entry_config().await?;
let gates = self.get_feature_gate_config().await?;
let user_context = self
.feature_gate_user_context(
user_id,
creation_entry_feature_gates_require_user_tags(&config, &gates),
)
.await;
Ok(
module_runtime::apply_feature_gates_to_creation_entry_config(
config,
&gates,
&user_context,
),
)
}
pub async fn is_image_editor_agent_sidebar_enabled_for_user(
&self,
user_id: Option<&str>,
) -> Result<bool, SpacetimeClientError> {
if !self.config.image_editor_agent_sidebar_enabled {
return Ok(false);
}
let gates = self.get_feature_gate_config().await?;
let gate = gates
.iter()
.find(|item| item.gate_key == module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY);
let user_context = self
.feature_gate_user_context(
user_id,
gate.map(feature_gate_requires_user_tags).unwrap_or(false),
)
.await;
Ok(module_runtime::is_feature_gate_allowed(gate, &user_context))
}
#[cfg(any())]
pub async fn list_admin_work_visibility(
&self,
admin_user_id: String,
) -> Result<Vec<shared_contracts::admin::AdminWorkVisibilityEntryPayload>, SpacetimeClientError>
{
self.spacetime_client
.admin_list_work_visibility(admin_user_id)
.await
}
#[cfg(any())]
pub async fn update_admin_work_visibility(
&self,
admin_user_id: String,
source_type: String,
profile_id: String,
visible: bool,
) -> Result<shared_contracts::admin::AdminWorkVisibilityEntryPayload, SpacetimeClientError>
{
self.spacetime_client
.admin_update_work_visibility(admin_user_id, source_type, profile_id, visible)
.await
}
#[cfg(any())]
pub async fn is_creation_entry_route_enabled_for_user(
&self,
creation_type_id: &str,
user_id: Option<&str>,
) -> Result<bool, SpacetimeClientError> {
let config = self.get_creation_entry_config_for_user(user_id).await?;
Ok(config
.creation_types
.iter()
.find(|item| item.id == creation_type_id)
.map(|item| item.open)
.unwrap_or(true))
}
async fn feature_gate_user_context(
&self,
user_id: Option<&str>,
include_user_tags: bool,
) -> module_runtime::FeatureGateUserContext {
let user_id = user_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let user_tags = if include_user_tags {
match user_id.as_ref() {
Some(user_id) => match self.spacetime_client.get_user_tags(user_id.clone()).await {
Ok(tags) => tags,
Err(error) => {
warn!(
user_id = %user_id,
error = %error,
"读取灰度用户标签失败,按无标签继续判定"
);
vec![]
}
},
None => vec![],
}
} else {
vec![]
};
module_runtime::FeatureGateUserContext { user_id, user_tags }
}
#[cfg(any())]
pub async fn is_public_work_interaction_enabled(
&self,
source_type: &str,
action: crate::creation_entry_config::PublicWorkInteractionAction,
) -> Result<bool, SpacetimeClientError> {
let config = self.get_creation_entry_config().await?;
Ok(config
.public_work_interactions
.iter()
.find(|item| item.source_type == source_type)
.map(|item| match action {
crate::creation_entry_config::PublicWorkInteractionAction::Like => {
item.like_enabled
}
crate::creation_entry_config::PublicWorkInteractionAction::Remix => {
item.remix_enabled
}
})
.unwrap_or(true))
}
#[cfg(any())]
pub(crate) fn set_test_public_work_interaction_enabled(
&self,
source_type: impl AsRef<str>,
action: crate::creation_entry_config::PublicWorkInteractionAction,
enabled: bool,
) {
let source_type = source_type.as_ref();
let mut config = self.read_test_creation_entry_config();
if let Some(item) = config
.public_work_interactions
.iter_mut()
.find(|item| item.source_type == source_type)
{
match action {
crate::creation_entry_config::PublicWorkInteractionAction::Like => {
item.like_enabled = enabled;
}
crate::creation_entry_config::PublicWorkInteractionAction::Remix => {
item.remix_enabled = enabled;
}
}
}
self.cache_test_creation_entry_config(config);
}
#[cfg(any())]
pub(crate) fn set_test_creation_entry_route_enabled(
&self,
creation_type_id: impl AsRef<str>,
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),
category_id: module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string(),
category_label: module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL
.to_string(),
category_sort_order: 0,
updated_at_micros: 0,
unified_creation_spec:
shared_contracts::creation_entry_config::build_phase1_unified_creation_spec(
creation_type_id,
),
},
);
}
self.cache_test_creation_entry_config(config);
}
#[cfg(test)]
pub(crate) fn set_test_feature_gate_config(
&self,
config: Vec<module_runtime::FeatureGateConfigSnapshot>,
) {
self.cache_test_feature_gate_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_tables_to_spacetime(&self) -> Result<(), SpacetimeClientError> {
#[cfg(test)]
return Ok(());
#[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()))?;
#[cfg(not(test))]
let projection = self
.auth_store
.export_projection_view(updated_at_micros)
.map_err(SpacetimeClientError::Runtime)?;
// 当前仍由 module-auth 的进程内工作集执行业务规则;这里只用 typed projection 同步正式认证表。
#[cfg(not(test))]
if let Err(error) = self
.spacetime_client
.sync_auth_store_projection(projection)
.await
{
warn!(
error = %error,
"认证投影同步 SpacetimeDB 正式表失败,当前认证流程中止"
);
return Err(error);
}
#[cfg(not(test))]
Ok(())
}
pub async fn try_restore_auth_store_from_spacetime(
config: AppConfig,
) -> Result<Self, AppStateInitError> {
let spacetime_client =
SpacetimeClient::new(spacetime_client_config_for_startup_restore(&config));
let mut spacetime_restore_available = false;
let mut restore_errors = Vec::new();
match spacetime_client
.export_auth_store_projection_from_tables()
.await
{
Ok(projection) => {
spacetime_restore_available = true;
if let Some(candidate) = auth_store_candidate_from_projection_view(
projection,
AuthStoreRestoreSource::SpacetimeTables,
)? {
let state = Self::new_with_auth_store(config, candidate.auth_store)?;
info!(
source = candidate.source.as_str(),
updated_at_micros = candidate.updated_at_micros,
"已恢复认证投影"
);
return Ok(state);
}
}
Err(error) => {
warn!(error = %error, "从 SpacetimeDB 表恢复认证投影失败");
restore_errors.push(error.to_string());
}
}
if !spacetime_restore_available {
return Err(AppStateInitError::DependencyUnavailable(format!(
"SpacetimeDB 认证投影恢复不可用:{}",
restore_errors.join("; ")
)));
}
Self::new_with_empty_auth_store(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
}
pub fn wechat_client(&self) -> &WechatClient {
&self.wechat_client
}
pub fn wechat_pay_client(&self) -> &WechatPayClient {
&self.wechat_pay_client
}
pub fn wechat_pay_refund_reconciliation_enabled(&self) -> bool {
self.config.wechat_pay_refund_reconciliation_enabled
}
#[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
}
#[cfg(any())]
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
&self.puzzle_gallery_cache
}
pub fn tracking_outbox(&self) -> Option<Arc<TrackingOutbox>> {
self.tracking_outbox.clone()
}
pub fn wallet_refund_outbox(&self) -> Option<Arc<WalletRefundOutbox>> {
self.wallet_refund_outbox.clone()
}
pub fn llm_client(&self) -> Option<&LlmClient> {
self.llm_client.as_ref()
}
pub fn editor_agent_llm_client(&self) -> Option<&LlmClient> {
self.editor_agent_llm_client.as_ref()
}
pub fn matting_client(&self) -> Option<&MattingClient> {
self.matting_client.as_ref()
}
pub fn bgfilter_provider_http_client(&self) -> &reqwest::Client {
&self.bgfilter_provider_http_client
}
pub fn bgfilter_worker_http_client(&self) -> &reqwest::Client {
&self.bgfilter_worker_http_client
}
pub fn bgfilter_worker_reached(&self) -> bool {
self.bgfilter_worker_reached.load(Ordering::Relaxed)
}
pub fn mark_bgfilter_worker_reached(&self) {
self.bgfilter_worker_reached.store(true, Ordering::Relaxed);
}
pub fn bgfilter_image_validation_limiter(&self) -> Arc<Semaphore> {
self.bgfilter_image_validation_limiter.clone()
}
pub fn character_animation_oss_http_client(&self) -> &reqwest::Client {
&self.character_animation_oss_http_client
}
pub fn character_animation_oss_io_limiter(&self) -> Arc<Semaphore> {
self.character_animation_oss_io_limiter.clone()
}
pub fn editor_oss_http_client(&self) -> &reqwest::Client {
&self.editor_oss_http_client
}
#[cfg(any())]
pub fn creative_agent_executor(&self) -> Arc<MockLangChainRustAgentExecutor> {
self.creative_agent_executor.clone()
}
pub fn subscribe_profile_recharge_order_updates(
&self,
) -> tokio::sync::broadcast::Receiver<String> {
self.profile_recharge_order_updates.subscribe()
}
pub fn publish_profile_recharge_order_update(&self, order_id: impl Into<String>) {
let _ = self.profile_recharge_order_updates.send(order_id.into());
}
#[cfg(any())]
pub fn get_creative_agent_session(
&self,
session_id: &str,
owner_user_id: &str,
) -> Option<CreativeAgentSessionSnapshot> {
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())
}
#[cfg(any())]
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,
},
);
}
#[cfg(any())]
pub async fn get_runtime_snapshot_record(
&self,
user_id: String,
) -> Result<Option<RuntimeSnapshotRecord>, 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),
}
}
#[cfg(any())]
pub async fn put_runtime_snapshot_record(
&self,
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
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),
}
}
#[cfg(any())]
pub async fn delete_runtime_snapshot_record(
&self,
user_id: String,
) -> Result<bool, SpacetimeClientError> {
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),
}
}
}
fn feature_gate_requires_user_tags(gate: &module_runtime::FeatureGateConfigSnapshot) -> bool {
gate.enabled && !gate.allow_user_tags.is_empty()
}
#[cfg(any())]
fn creation_entry_feature_gates_require_user_tags(
config: &CreationEntryConfigResponse,
gates: &[module_runtime::FeatureGateConfigSnapshot],
) -> bool {
let gate_keys = config
.creation_types
.iter()
.map(|entry| module_runtime::creation_entry_feature_gate_key(&entry.id))
.collect::<HashSet<_>>();
gates
.iter()
.any(|gate| gate_keys.contains(&gate.gate_key) && feature_gate_requires_user_tags(gate))
}
#[cfg(test)]
impl AppState {
pub(crate) fn seed_test_refresh_session_for_user(
&self,
user: &module_auth::AuthUser,
seed: &str,
) -> String {
let session = self
.refresh_session_service()
.create_session(
module_auth::CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: platform_auth::hash_refresh_session_token(&format!(
"test-refresh-token-{seed}"
)),
issued_by_provider: module_auth::AuthLoginMethod::Password,
client_info: module_auth::RefreshSessionClientInfo {
client_type: "web_browser".to_string(),
client_runtime: "test".to_string(),
client_platform: "test".to_string(),
client_instance_id: Some(seed.to_string()),
device_fingerprint: Some(format!("test-device-{seed}")),
device_display_name: "Test Browser".to_string(),
mini_program_app_id: None,
mini_program_env: None,
user_agent: Some("GenarrativeApiServerTest/1.0".to_string()),
ip: Some("127.0.0.1".to_string()),
},
},
OffsetDateTime::now_utc(),
)
.expect("test refresh session should create");
session.session.session_id
}
pub(crate) fn seed_test_refresh_session_for_user_id(
&self,
user_id: &str,
seed: &str,
) -> String {
let user = self
.auth_user_service()
.get_user_by_id(user_id)
.expect("test user lookup should succeed")
.expect("test user should exist");
self.seed_test_refresh_session_for_user(&user, seed)
}
#[cfg(any())]
fn cache_test_creation_entry_config(&self, config: CreationEntryConfigResponse) {
*self
.test_creation_entry_config
.lock()
.expect("test creation entry config should lock") = Some(config);
}
#[cfg(any())]
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)
}
#[cfg(test)]
fn cache_test_feature_gate_config(
&self,
config: Vec<module_runtime::FeatureGateConfigSnapshot>,
) {
*self
.test_feature_gate_config
.lock()
.expect("test feature gate config should lock") = Some(config);
}
#[cfg(test)]
fn read_test_feature_gate_config(&self) -> Vec<module_runtime::FeatureGateConfigSnapshot> {
self.test_feature_gate_config
.lock()
.expect("test feature gate config should lock")
.clone()
.unwrap_or_default()
}
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 {
country_code: None,
pure_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 {
country_code: None,
pure_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
}
#[cfg(any())]
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);
}
#[cfg(any())]
fn read_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
self.test_runtime_snapshot_store
.lock()
.expect("test runtime snapshot store should lock")
.get(user_id)
.cloned()
}
#[cfg(any())]
fn remove_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
self.test_runtime_snapshot_store
.lock()
.expect("test runtime snapshot store should lock")
.remove(user_id)
}
#[cfg(any())]
fn build_test_runtime_snapshot_record(
&self,
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
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,
}
impl AuthStoreRestoreSource {
fn as_str(self) -> &'static str {
match self {
Self::SpacetimeTables => "spacetime_tables",
}
}
}
#[derive(Debug)]
struct AuthStoreRestoreCandidate {
source: AuthStoreRestoreSource,
updated_at_micros: Option<i64>,
auth_store: InMemoryAuthStore,
}
fn auth_store_candidate_from_projection_view(
projection: module_auth::AuthStoreProjectionView,
source: AuthStoreRestoreSource,
) -> Result<Option<AuthStoreRestoreCandidate>, AppStateInitError> {
if projection.users.is_empty()
&& projection.identities.is_empty()
&& projection.refresh_sessions.is_empty()
{
return Ok(None);
}
let updated_at_micros = Some(projection.updated_at_micros);
let auth_store = InMemoryAuthStore::from_projection_view(projection)
.map_err(AppStateInitError::AuthStore)?;
Ok(Some(AuthStoreRestoreCandidate {
source,
updated_at_micros,
auth_store,
}))
}
fn spacetime_client_config_for_process(config: &AppConfig) -> SpacetimeClientConfig {
let runs_http = config.process_role.runs_http();
SpacetimeClientConfig {
server_url: config.spacetime_server_url.clone(),
database: config.spacetime_database.clone(),
token: config.spacetime_token.clone(),
pool_size: if runs_http {
config.spacetime_pool_size
} else {
1
},
procedure_timeout: config.spacetime_procedure_timeout,
subscribe_cached_read_models: runs_http,
}
}
fn spacetime_client_config_for_startup_restore(config: &AppConfig) -> SpacetimeClientConfig {
SpacetimeClientConfig {
server_url: config.spacetime_server_url.clone(),
database: config.spacetime_database.clone(),
token: config.spacetime_token.clone(),
pool_size: 1,
procedure_timeout: config.spacetime_procedure_timeout,
subscribe_cached_read_models: false,
}
}
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)
| Self::DependencyUnavailable(error)
| Self::WechatPay(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<JwtError> for AppStateInitError {
fn from(value: JwtError) -> Self {
Self::Jwt(value)
}
}
impl From<RefreshCookieError> for AppStateInitError {
fn from(value: RefreshCookieError) -> Self {
Self::RefreshCookie(value)
}
}
impl From<SmsProviderError> for AppStateInitError {
fn from(value: SmsProviderError) -> Self {
Self::SmsProvider(value)
}
}
impl From<OssError> for AppStateInitError {
fn from(value: OssError) -> Self {
Self::Oss(value)
}
}
impl From<LlmError> 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 subject(&self) -> &str {
&self.subject
}
pub fn display_name(&self) -> &str {
&self.display_name
}
pub fn build_claims(&self, now: OffsetDateTime) -> Result<AdminClaims, String> {
self.build_account_claims(
self.subject.to_string(),
self.username.to_string(),
self.display_name.to_string(),
vec![ADMIN_ROLE.to_string(), "owner".to_string()],
1,
now,
)
}
pub fn build_account_claims(
&self,
subject: String,
username: String,
display_name: String,
roles: Vec<String>,
token_version: u64,
now: OffsetDateTime,
) -> Result<AdminClaims, String> {
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,
username,
display_name,
roles,
token_version,
issued_at: now,
expires_at,
})
}
pub fn sign_token(&self, claims: &AdminClaims) -> Result<String, String> {
let jwt_claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: claims.subject.clone(),
session_id: format!("admin-session:{}:{}", claims.subject, claims.token_version),
provider: AuthProvider::Password,
roles: claims.roles.clone(),
token_version: claims.token_version,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some(claims.display_name.clone()),
},
&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<AccessTokenClaims, String> {
verify_access_token(token, &self.jwt_config).map_err(|error| error.to_string())
}
pub fn validate_claims(&self, claims: &AccessTokenClaims) -> Result<AdminSession, String> {
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(),
account_role: "owner".to_string(),
tab_permissions: Vec::new(),
action_permissions: Vec::new(),
issued_at,
expires_at,
})
}
pub fn validate_account_claims(
&self,
claims: &AccessTokenClaims,
expected_subject: &str,
expected_token_version: u64,
username: String,
display_name: String,
tab_permissions: Vec<String>,
action_permissions: Vec<String>,
) -> Result<AdminSession, String> {
if claims.user_id() != expected_subject {
return Err("后台管理员主体不匹配".to_string());
}
if !claims.roles.iter().any(|role| role == ADMIN_ROLE) {
return Err("当前令牌不是管理员令牌".to_string());
}
if claims.token_version() != expected_token_version {
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,
display_name,
roles: claims.roles.clone(),
account_role: "member".to_string(),
tab_permissions,
action_permissions,
issued_at,
expires_at,
})
}
pub fn is_owner_subject(&self, subject: &str) -> bool {
subject == self.subject.as_ref()
}
pub fn build_session(&self, claims: &AdminClaims) -> AdminSession {
AdminSession {
subject: claims.subject.clone(),
username: claims.username.clone(),
display_name: claims.display_name.clone(),
roles: claims.roles.clone(),
account_role: "owner".to_string(),
tab_permissions: Vec::new(),
action_permissions: Vec::new(),
issued_at: claims.issued_at,
expires_at: claims.expires_at,
}
}
}
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
let oss_fields = [
("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()),
("ALIYUN_OSS_ENDPOINT", config.oss_endpoint.as_deref()),
(
"ALIYUN_OSS_ACCESS_KEY_ID",
config.oss_access_key_id.as_deref(),
),
(
"ALIYUN_OSS_ACCESS_KEY_SECRET",
config.oss_access_key_secret.as_deref(),
),
];
let has_any_oss_field = oss_fields
.iter()
.any(|(_, value)| value.is_some_and(|value| !value.trim().is_empty()));
if !has_any_oss_field {
return Ok(None);
}
let missing_fields = oss_fields
.iter()
.filter_map(|(name, value)| match value {
Some(value) if !value.trim().is_empty() => None,
_ => Some(*name),
})
.collect::<Vec<_>>();
if !missing_fields.is_empty() {
warn!(
missing_fields = %missing_fields.join(","),
"OSS 环境变量配置不完整,跳过 OSS 客户端初始化"
);
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_matting_client(config: &AppConfig) -> Result<Option<MattingClient>, AppStateInitError> {
if !config.aliyun_matting_enabled {
return Ok(None);
}
let (Some(access_key_id), Some(access_key_secret)) = (
config
.aliyun_matting_access_key_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty()),
config
.aliyun_matting_access_key_secret
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty()),
) else {
warn!("阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化");
return Ok(None);
};
let matting_config = MattingConfig::with_timeout(
config.aliyun_matting_endpoint.clone(),
access_key_id.to_string(),
access_key_secret.to_string(),
config.aliyun_matting_request_timeout_ms,
)
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
MattingClient::new(matting_config)
.map(Some)
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))
}
fn build_bgfilter_provider_http_client(
config: &AppConfig,
) -> Result<reqwest::Client, AppStateInitError> {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_millis(
config.bgfilter_provider_attempt_timeout_ms().max(1),
))
.connect_timeout(std::time::Duration::from_secs(30))
.pool_idle_timeout(std::time::Duration::from_secs(300))
.pool_max_idle_per_host(64)
.tcp_keepalive(std::time::Duration::from_secs(60))
.build()
.map_err(|error| {
AppStateInitError::DependencyUnavailable(format!(
"构建 BgFilter provider HTTP 客户端失败:{error}"
))
})
}
fn build_bgfilter_worker_http_client(
config: &AppConfig,
) -> Result<reqwest::Client, AppStateInitError> {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(std::time::Duration::from_millis(
config.bgfilter_worker_connect_timeout_ms.max(1),
))
.pool_idle_timeout(std::time::Duration::from_secs(300))
.pool_max_idle_per_host(128)
.tcp_keepalive(std::time::Duration::from_secs(60))
.build()
.map_err(|error| {
AppStateInitError::DependencyUnavailable(format!(
"构建 BgFilter 内部 worker HTTP 客户端失败:{error}"
))
})
}
fn build_character_animation_oss_http_client() -> Result<reqwest::Client, AppStateInitError> {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.timeout(std::time::Duration::from_secs(60))
.pool_idle_timeout(std::time::Duration::from_secs(300))
.pool_max_idle_per_host(8)
.tcp_keepalive(std::time::Duration::from_secs(60))
.build()
.map_err(|error| {
AppStateInitError::DependencyUnavailable(format!(
"初始化角色动画 OSS HTTP Client 失败:{error}"
))
})
}
// 中文注释:编辑器图片的 OSS 读写此前每次调用都 `reqwest::Client::new()`,既没有任何
// 超时也没有连接复用——一个半开或黑洞的连接可以无限期挂住,同时占着 HTTP 准入许可和
// 已经读入的最多 32 MiB 图片缓冲。这里收口成进程级共享客户端,给整段请求(含 body
// 流式读写)一个绝对上界,作为所有调用方的兜底。读写共用一个客户端,两个方向都受同一份
// EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES 约束,合用也能让 GET / PUT / HEAD 复用连接池。
//
// connect 取 10s:同区域 OSS 建连是百毫秒级,30s 只会让不可达端点多占 20s 槽位。
// total 取 120s:按较慢的写方向定尺寸——32 MiB 上传在 60s 内要求持续约 4.4 Mbps
// 留一倍余量避免误伤正常流量。读方向不因此变松:完美像素的 GET 另受 30s 处理预算约束,
// 客户端超时只是兜底。
fn build_editor_oss_http_client() -> Result<reqwest::Client, AppStateInitError> {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(120))
.pool_idle_timeout(std::time::Duration::from_secs(300))
.pool_max_idle_per_host(8)
.tcp_keepalive(std::time::Duration::from_secs(60))
.build()
.map_err(|error| {
AppStateInitError::DependencyUnavailable(format!(
"初始化编辑器 OSS HTTP 客户端失败:{error}"
))
})
}
fn build_wechat_client(config: &AppConfig) -> WechatClient {
WechatClient::new(WechatConfig {
app_id: config.wechat_mini_program_app_id.clone(),
app_secret: config.wechat_mini_program_app_secret.clone(),
stable_access_token_endpoint: config.wechat_stable_access_token_endpoint.clone(),
virtual_payment_query_order_endpoint: config
.wechat_mini_program_virtual_payment_query_order_endpoint
.clone(),
virtual_payment_notify_provide_goods_endpoint: config
.wechat_mini_program_virtual_payment_notify_provide_goods_endpoint
.clone(),
})
}
fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, 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_editor_agent_llm_client(
config: &AppConfig,
) -> Result<Option<LlmClient>, AppStateInitError> {
// 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine。
let Some(api_key) = config
.vector_engine_api_key
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let base_url = if config.vector_engine_base_url.ends_with("/v1") {
config.vector_engine_base_url.clone()
} else {
format!("{}/v1", config.vector_engine_base_url.trim_end_matches('/'))
};
let llm_config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
base_url,
api_key.to_string(),
platform_llm::EDITOR_AGENT_GPT5_MODEL.to_string(),
config.llm_request_timeout_ms,
config.llm_max_retries.min(EDITOR_AGENT_LLM_MAX_RETRIES),
config
.llm_retry_backoff_ms
.min(EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS),
)?;
Ok(Some(LlmClient::new(llm_config)?))
}
// 只有在用户名和密码都已配置时才启用后台,避免半配置状态暴露伪入口。
fn build_admin_runtime(
config: &AppConfig,
base_jwt_config: &JwtConfig,
) -> Result<Option<AdminRuntime>, 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::<str>::from(username),
password: Arc::<str>::from(password),
subject: Arc::<str>::from(format!("admin:{username}")),
display_name: Arc::<str>::from(format!("管理员 {username}")),
token_ttl_seconds: config.admin_token_ttl_seconds,
jwt_config,
}))
}
#[cfg(any())]
fn is_missing_creation_entry_config_procedure(error: &SpacetimeClientError) -> bool {
match error {
SpacetimeClientError::Procedure(message) => message.contains("No such procedure"),
_ => false,
}
}
#[cfg(debug_assertions)]
fn is_missing_feature_gate_config_procedure(error: &SpacetimeClientError) -> bool {
match error {
SpacetimeClientError::Procedure(message) => message.contains("No such procedure"),
_ => false,
}
}
#[cfg(test)]
mod tests {
use module_ai::{AiTaskKind, generate_ai_task_id};
use spacetime_client::SpacetimeClientStage;
use super::*;
#[test]
fn app_state_reuses_character_animation_oss_client_and_eight_permits() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert!(std::ptr::eq(
state.character_animation_oss_http_client(),
state.character_animation_oss_http_client(),
));
assert_eq!(
state
.character_animation_oss_io_limiter()
.available_permits(),
CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY
);
}
#[test]
fn app_state_reuses_editor_oss_client() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert!(std::ptr::eq(
state.editor_oss_http_client(),
state.editor_oss_http_client(),
));
}
#[test]
fn bgfilter_image_validation_limiter_is_bounded_per_process_role() {
let parent = AppState::new(AppConfig::default()).expect("parent state should build");
assert_eq!(
parent
.bgfilter_image_validation_limiter()
.available_permits(),
BGFILTER_IMAGE_VALIDATION_MAX_CONCURRENCY
);
let worker = AppState::new(AppConfig {
process_role: crate::config::ProcessRole::BgfilterWorker,
bgfilter_worker_concurrency: 6,
..AppConfig::default()
})
.expect("worker state should build");
assert_eq!(
worker
.bgfilter_image_validation_limiter()
.available_permits(),
6
);
}
#[test]
fn editor_generation_pricing_typed_record_round_trips() {
let expected = crate::editor_generation_config::parse_editor_generation_pricing_json(
crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON,
"test default pricing",
)
.expect("default pricing should parse");
let record = EditorGenerationPricingConfigRecord {
config_id: "global".to_string(),
models: editor_generation_pricing_to_records(&expected)
.expect("pricing should map to records"),
updated_by_admin_user_id: Some("admin:test".to_string()),
updated_at: "2026-07-10T00:00:00Z".to_string(),
updated_at_micros: 1,
};
let actual = editor_generation_pricing_from_record(record)
.expect("typed pricing record should map back");
assert_eq!(actual, expected);
}
#[test]
fn editor_generation_pricing_upsert_input_uses_runtime_service_bootstrap_secret() {
let mut config = AppConfig::default();
let without_secret = editor_generation_pricing_upsert_input(
&config,
"admin:test".to_string(),
Vec::new(),
123,
);
assert_eq!(without_secret.bootstrap_secret, "");
config.spacetime_runtime_service_bootstrap_secret = Some("11".repeat(32));
let with_secret = editor_generation_pricing_upsert_input(
&config,
"admin:test".to_string(),
Vec::new(),
123,
);
assert_eq!(with_secret.bootstrap_secret, "11".repeat(32));
assert_eq!(with_secret.admin_user_id, "admin:test");
assert_eq!(with_secret.updated_at_micros, 123);
}
#[test]
fn editor_generation_pricing_typed_record_rejects_duplicate_model() {
let config = crate::editor_generation_config::parse_editor_generation_pricing_json(
crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON,
"test default pricing",
)
.expect("default pricing should parse");
let mut models =
editor_generation_pricing_to_records(&config).expect("pricing should map to records");
models.push(models[0].clone());
let record = EditorGenerationPricingConfigRecord {
config_id: "global".to_string(),
models,
updated_by_admin_user_id: None,
updated_at: "2026-07-10T00:00:00Z".to_string(),
updated_at_micros: 1,
};
let error =
editor_generation_pricing_from_record(record).expect_err("duplicate model should fail");
assert!(error.to_string().contains("重复模型"));
}
#[cfg(any())]
#[test]
fn detects_missing_creation_entry_config_procedure_for_debug_fallback() {
assert!(is_missing_creation_entry_config_procedure(
&SpacetimeClientError::Procedure(
"No such procedure: get_creation_entry_config".to_string(),
),
));
assert!(is_missing_creation_entry_config_procedure(
&SpacetimeClientError::Procedure("No such procedure".to_string()),
));
assert!(!is_missing_creation_entry_config_procedure(
&SpacetimeClientError::Timeout(SpacetimeClientStage::ProcedureResult),
));
}
#[test]
fn detects_missing_feature_gate_config_procedure_for_debug_fallback() {
assert!(is_missing_feature_gate_config_procedure(
&SpacetimeClientError::Procedure(
"No such procedure: get_feature_gate_config".to_string(),
),
));
assert!(!is_missing_feature_gate_config_procedure(
&SpacetimeClientError::Timeout(SpacetimeClientStage::ProcedureResult),
));
}
#[test]
fn feature_gate_user_tags_are_only_required_for_enabled_tag_allowlist() {
let mut gate = test_feature_gate("image-editor:agent-sidebar");
assert!(!feature_gate_requires_user_tags(&gate));
gate.allow_user_tags = vec!["beta".to_string()];
assert!(feature_gate_requires_user_tags(&gate));
gate.enabled = false;
assert!(!feature_gate_requires_user_tags(&gate));
}
#[cfg(any())]
#[test]
fn creation_entry_tag_lookup_only_needs_matching_enabled_tag_gate() {
let config = crate::creation_entry_config::test_creation_entry_config_response();
let mut unrelated_gate = test_feature_gate("image-editor:agent-sidebar");
unrelated_gate.allow_user_tags = vec!["beta".to_string()];
assert!(!creation_entry_feature_gates_require_user_tags(
&config,
&[unrelated_gate],
));
let mut disabled_gate = test_feature_gate("creation-entry:puzzle");
disabled_gate.enabled = false;
disabled_gate.allow_user_tags = vec!["beta".to_string()];
assert!(!creation_entry_feature_gates_require_user_tags(
&config,
&[disabled_gate],
));
let mut matching_gate = test_feature_gate("creation-entry:puzzle");
matching_gate.allow_user_tags = vec!["beta".to_string()];
assert!(creation_entry_feature_gates_require_user_tags(
&config,
&[matching_gate],
));
}
#[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.editor_agent_llm_client().is_none());
}
#[test]
fn app_state_skips_oss_client_when_oss_config_is_partial() {
let mut config = AppConfig::default();
config.oss_bucket = Some("genarrative-assets".to_string());
config.oss_endpoint = Some("oss-cn-hangzhou.aliyuncs.com".to_string());
let state = AppState::new(config).expect("state should build with partial oss config");
assert!(state.oss_client().is_none());
}
#[test]
fn app_state_builds_editor_agent_llm_client_from_vector_engine_settings() {
let mut config = AppConfig::default();
config.llm_api_key = None;
config.llm_max_retries = 2;
config.llm_retry_backoff_ms = 120_000;
config.vector_engine_base_url = "https://api.vectorengine.test".to_string();
config.vector_engine_api_key = Some("ve-key".to_string());
let state = AppState::new(config).expect("state should build");
let client = state
.editor_agent_llm_client()
.expect("editor agent LLM client should exist");
assert_eq!(
client.config().model(),
platform_llm::EDITOR_AGENT_GPT5_MODEL
);
assert_eq!(
client.config().chat_completions_url(),
"https://api.vectorengine.test/v1/chat/completions"
);
assert!(!client.config().official_fallback());
assert_eq!(client.config().max_retries(), 1);
assert_eq!(client.config().retry_backoff_ms(), 60_000);
}
fn test_feature_gate(gate_key: &str) -> module_runtime::FeatureGateConfigSnapshot {
module_runtime::FeatureGateConfigSnapshot {
gate_key: gate_key.to_string(),
enabled: true,
rollout_percent: 0,
allow_user_ids: vec![],
allow_user_tags: vec![],
deny_user_ids: vec![],
description: String::new(),
updated_at_micros: 1,
}
}
#[cfg(any())]
#[test]
fn puzzle_api_state_exposes_puzzle_dependency_snapshot() {
let mut config = AppConfig::default();
config.creation_agent_llm_web_search_enabled = false;
config.vector_engine_image_request_timeout_ms = 987_654;
let state = AppState::new(config).expect("state should build");
let puzzle_state: PuzzleApiState = FromRef::from_ref(&state);
assert!(!puzzle_state.creation_agent_llm_web_search_enabled());
assert_eq!(
puzzle_state.vector_engine_image_request_timeout_ms(),
987_654
);
assert!(puzzle_state.llm_client().is_none());
assert!(puzzle_state.creative_agent_gpt5_client().is_none());
assert!(puzzle_state.oss_client().is_none());
}
}