use std::{ cmp::Ordering, collections::{BTreeMap, BTreeSet}, fs, net::{IpAddr, Ipv4Addr, Ipv6Addr}, time::Duration, }; use axum::{ Json, extract::{Extension, Query, Request, State}, http::{ HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header::{AUTHORIZATION, CONTENT_TYPE}, }, middleware::Next, response::Response, }; use platform_auth::{hash_password, verify_password}; use reqwest::Client; use serde::Deserialize; use serde_json::{Map, Value}; use shared_contracts::admin::{ ADMIN_ACTION_PERMISSIONS, ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE, ADMIN_TAB_PERMISSIONS, AdminAccountRole, AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, AdminDashboardBreakdownRowPayload, AdminDashboardChartBucketPayload, AdminDashboardChartPayload, AdminDashboardMetricsPayload, AdminDashboardOperationMetricPayload, AdminDashboardOperationsPayload, AdminDashboardPaymentConversionPayload, AdminDashboardQuery, AdminDashboardRangePayload, AdminDashboardResponse, AdminDashboardRetentionPayload, AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload, AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminDirectUploadTicketPayload, AdminEditorAssetListQuery, AdminEditorAssetListResponse, AdminEditorAssetPayload, AdminEditorImageSequenceFramePayload, AdminEditorShowcaseAssetPayload, AdminEditorShowcaseAssetResponse, AdminEditorShowcaseCampaignPayload, AdminEditorShowcaseCampaignResponse, AdminEditorShowcaseDisplayRequest, AdminEditorShowcaseListQuery, AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest, AdminFeatureGateConfigPayload, AdminFeatureGateConfigResponse, AdminLoginRequest, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventKeyListResponse, AdminTrackingEventKeyPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse, AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest, }; #[cfg(any())] use shared_contracts::admin::{ AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, AdminUpdateWorkVisibilityRequest, AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest, AdminUpsertCreationEntryTypeConfigRequest, AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse, }; use shared_contracts::assets::{ ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, CreateDirectUploadTicketRequest, DirectUploadObjectAccess, GetReadUrlQuery, }; #[cfg(any())] use shared_contracts::creation_entry_config::{ encode_unified_creation_spec_response, validate_unified_creation_spec_for_play, }; use spacetime_client::{ AdminDashboardStatsRecord, AdminDashboardStatsRecordInput, AdminEditorAssetListRecordInput, AdminEditorAssetRecord, EditorShowcaseAssetAdminListRecordInput, EditorShowcaseAssetAdminReviewRecordInput, EditorShowcaseAssetDisplayUpdateRecordInput, EditorShowcaseAssetRecord, EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, EditorShowcaseCampaignConfigUpsertRecordInput, SpacetimeClientError, }; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::sync::{OnceCell, Semaphore}; use crate::{ admin_accounts::normalize_admin_account_username, api_response::json_success_body, assets::{ AssetReadAuthorization, confirm_asset_object_for_owner, create_direct_upload_ticket_for_owner, get_asset_read_url_with_query, }, editor_generation_config::EditorGenerationPricingConfig, editor_project::{ current_utc_micros, normalize_editor_image_sequence_frames_value, resolve_editor_asset_kind, sanitize_editor_generation_inputs, }, http_error::AppError, request_context::RequestContext, state::{AdminRuntime, AppState}, tracking::{TrackingEventDraft, record_tracking_event_after_success}, work_author::resolve_work_author_by_user_id, }; // 首版调试台只允许有限大小的请求体,避免把后台当作通用代理大包转发器。 const MAX_DEBUG_BODY_BYTES: usize = 128 * 1024; const ADMIN_PASSWORD_MAX_CONCURRENT_OPERATIONS: usize = 4; static ADMIN_PASSWORD_OPERATION_LIMITER: Semaphore = Semaphore::const_new(ADMIN_PASSWORD_MAX_CONCURRENT_OPERATIONS); static ADMIN_DUMMY_PASSWORD_HASH: OnceCell = OnceCell::const_new(); const BLOCKED_DEBUG_HEADERS: &[&str] = &[ "host", "content-length", "connection", "transfer-encoding", "expect", ]; // SpacetimeDB 2.x 的 schema HTTP API 要求显式传入 BSATN JSON 版本。 // 后台总览只读取表名,固定使用当前 CLI 2.1.0 兼容的版本参数即可。 const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9"; const ADMIN_TRACKING_EVENT_DEFAULT_LIMIT: u32 = 200; const ADMIN_TRACKING_EVENT_MAX_LIMIT: u32 = 1000; const ADMIN_TRACKING_EVENT_EXPORT_LIMIT: u32 = 100_000; const ADMIN_TRACKING_EVENT_KEY_SCAN_LIMIT: u32 = 50_000; const ADMIN_DATABASE_TABLE_DEFAULT_LIMIT: u32 = 100; const ADMIN_DATABASE_TABLE_MAX_LIMIT: u32 = 500; const ADMIN_DATABASE_TABLE_SCAN_LIMIT: u32 = 50_000; const ADMIN_DATABASE_TABLE_MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024; const ADMIN_DATABASE_TABLE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); const ADMIN_DASHBOARD_ROW_LIMIT: u32 = 50_000; const ADMIN_DASHBOARD_MAX_PERIOD_DAYS: i64 = 366; const ADMIN_EDITOR_ASSET_DEFAULT_LIMIT: u32 = 80; const ADMIN_EDITOR_ASSET_MAX_LIMIT: u32 = 200; const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024; const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX: &str = "generated-character-drafts/editor/showcase-campaign/"; const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_ASSET_KIND: &str = "editor_showcase_campaign_image"; #[derive(Clone, Debug)] pub struct AuthenticatedAdmin { session: AdminSessionPayload, } #[derive(Clone, Debug, Default)] pub struct AdminDisplayNameDirectory { display_names: BTreeMap, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SpacetimeDatabaseInfoResponse { database_identity: Option, owner_identity: Option, host_type: Option, } #[derive(Debug, Deserialize)] struct SpacetimeSchemaResponse { tables: Option>, } #[derive(Debug, Deserialize)] struct SpacetimeSchemaTable { name: Option, } impl AuthenticatedAdmin { pub fn new(session: AdminSessionPayload) -> Self { Self { session } } pub fn session(&self) -> &AdminSessionPayload { &self.session } pub fn can(&self, action_permission: &str) -> bool { self.session.account_role == AdminAccountRole::Owner || self .session .action_permissions .iter() .any(|permission| permission == action_permission) } } impl AdminDisplayNameDirectory { fn insert(&mut self, subject: &str, display_name: &str) { let subject = subject.trim(); let display_name = display_name.trim(); if subject.is_empty() || display_name.is_empty() { return; } self.display_names .insert(subject.to_string(), display_name.to_string()); self.display_names .insert(subject.to_ascii_lowercase(), display_name.to_string()); } pub fn resolve(&self, subject: &str) -> String { let subject = subject.trim(); self.display_names .get(subject) .or_else(|| self.display_names.get(&subject.to_ascii_lowercase())) .cloned() .unwrap_or_else(|| "已停用管理员".to_string()) } } pub async fn load_admin_display_name_directory( state: &AppState, ) -> Result { let mut directory = AdminDisplayNameDirectory::default(); if let Some(runtime) = state.admin_runtime() { directory.insert(runtime.subject(), runtime.display_name()); directory.insert(runtime.username(), runtime.display_name()); } for account in state.spacetime_client().list_admin_accounts().await? { directory.insert(&account.account_id, &account.display_name); directory.insert(&account.username, &account.display_name); } Ok(directory) } pub async fn admin_login( State(state): State, Extension(request_context): Extension, Json(payload): Json, ) -> Result, AppError> { let runtime = state.admin_runtime().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用") })?; let expected_username = runtime.username().trim(); let expected_password = runtime.password().trim(); let submitted_username = payload.username.trim().to_ascii_lowercase(); let submitted_password = payload.password.as_str(); if expected_username.is_empty() || expected_password.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用") ); } let now = OffsetDateTime::now_utc(); let (claims, tab_permissions, action_permissions) = if submitted_username == expected_username.to_ascii_lowercase() { if submitted_password.trim() != expected_password { verify_admin_password(admin_dummy_password_hash().await?, submitted_password) .await .ok(); return Err(admin_login_rejected()); } ( runtime.build_claims(now).map_err(internal_admin_error)?, ADMIN_TAB_PERMISSIONS .iter() .map(|permission| (*permission).to_string()) .collect(), ADMIN_ACTION_PERMISSIONS .iter() .map(|permission| (*permission).to_string()) .collect(), ) } else { let submitted_username = match normalize_admin_account_username(&submitted_username) { Ok(username) => username, Err(_) => { verify_admin_password(admin_dummy_password_hash().await?, submitted_password) .await .ok(); return Err(admin_login_rejected()); } }; let account = match state .spacetime_client() .get_admin_account_by_username(submitted_username) .await { Ok(account) => Some(account), Err(error) if is_admin_account_not_found(&error) => None, Err(_) => { return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("后台账号服务暂不可用")); } }; let password_hash = match account.as_ref().filter(|account| account.account.enabled) { Some(account) => account.password_hash.as_str(), None => admin_dummy_password_hash().await?, }; let password_matches = verify_admin_password(password_hash, submitted_password) .await .unwrap_or(false); let Some(account) = account.filter(|account| account.account.enabled && password_matches) else { return Err(admin_login_rejected()); }; let account = account.account; let tab_permissions = account.tab_permissions.clone(); let action_permissions = account.action_permissions.clone(); ( runtime .build_account_claims( account.account_id, account.username, account.display_name, vec!["admin".to_string(), "member".to_string()], account.token_version, now, ) .map_err(internal_admin_error)?, tab_permissions, action_permissions, ) }; let token = runtime.sign_token(&claims).map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error) })?; let token_claims = runtime.verify_token(&token).map_err(internal_admin_error)?; let session = if claims.roles.iter().any(|role| role == "owner") { runtime.build_session(&claims) } else { runtime .validate_account_claims( &token_claims, &claims.subject, claims.token_version, claims.username.clone(), claims.display_name.clone(), tab_permissions, action_permissions, ) .map_err(internal_admin_error)? }; Ok(json_success_body( Some(&request_context), AdminLoginResponse { token, admin: build_admin_session_payload(session), }, )) } pub async fn admin_me( Extension(request_context): Extension, Extension(admin): Extension, ) -> Json { json_success_body( Some(&request_context), AdminMeResponse { admin: admin.session().clone(), }, ) } pub async fn admin_overview( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, ) -> Result, AppError> { let runtime = state.admin_runtime().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用") })?; let overview = build_admin_overview(&state, runtime).await?; Ok(json_success_body(Some(&request_context), overview)) } pub async fn admin_dashboard( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Query(query): Query, ) -> Result, AppError> { let dashboard = build_admin_dashboard(&state, query).await?; Ok(json_success_body(Some(&request_context), dashboard)) } pub async fn admin_debug_http( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Json(payload): Json, ) -> Result, AppError> { let response = execute_admin_debug_http(&state, payload).await?; Ok(json_success_body(Some(&request_context), response)) } pub async fn admin_list_tracking_events( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Query(query): Query, ) -> Result, AppError> { let entries = fetch_admin_tracking_events(&state, query).await?; Ok(json_success_body( Some(&request_context), AdminTrackingEventListResponse { entries }, )) } pub async fn admin_list_tracking_event_keys( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, ) -> Result, AppError> { let event_keys = fetch_admin_tracking_event_keys(&state).await?; Ok(json_success_body( Some(&request_context), AdminTrackingEventKeyListResponse { event_keys }, )) } pub async fn admin_list_database_tables( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, ) -> Result, AppError> { let response = fetch_admin_database_table_list(&state).await?; Ok(json_success_body(Some(&request_context), response)) } pub async fn admin_list_database_table_rows( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, axum::extract::Path(table_name): axum::extract::Path, Query(query): Query, ) -> Result, AppError> { let response = fetch_admin_database_table_rows(&state, &table_name, query).await?; Ok(json_success_body(Some(&request_context), response)) } /// 读取后台创作入口配置,包含模板入口和底部加号入口页公告。 #[cfg(any())] pub async fn admin_get_creation_entry_config( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, ) -> Result, AppError> { let config = state .get_creation_entry_config() .await .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), build_admin_creation_entry_config_response(config), )) } /// 保存单个创作模板入口配置,并返回最新公告与入口快照。 #[cfg(any())] pub async fn admin_upsert_creation_entry_config( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Json(payload): Json, ) -> Result, AppError> { let entry = validate_admin_creation_entry_config(payload)?; let config = state .upsert_creation_entry_type_config(entry) .await .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), build_admin_creation_entry_config_response(config), )) } /// 保存底部加号创作入口页的多公告表单序列化配置。 #[cfg(any())] pub async fn admin_upsert_creation_entry_event_banners_config( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Json(payload): Json, ) -> Result, AppError> { let normalized_json = module_runtime::normalize_creation_entry_event_banners_json(&payload.event_banners_json) .map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?; let config = state .upsert_creation_entry_event_banners_config( module_runtime::CreationEntryEventBannersAdminUpsertInput { event_banners_json: normalized_json, }, ) .await .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), build_admin_creation_entry_config_response(config), )) } /// 保存公开作品详情页点赞 / 改造能力配置。 #[cfg(any())] pub async fn admin_upsert_public_work_interaction_config( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Json(payload): Json, ) -> Result, AppError> { let snapshots = payload .public_work_interactions .into_iter() .map( |entry| module_runtime::PublicWorkInteractionConfigSnapshot { source_type: entry.source_type, like_enabled: entry.like_enabled, remix_enabled: entry.remix_enabled, like_disabled_message: entry.like_disabled_message, remix_disabled_message: entry.remix_disabled_message, }, ) .collect::>(); let public_work_interactions_json = module_runtime::encode_public_work_interaction_config_snapshots(&snapshots) .and_then(|json| module_runtime::normalize_public_work_interaction_config_json(&json)) .map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?; let config = state .upsert_public_work_interaction_config( module_runtime::PublicWorkInteractionConfigAdminUpsertInput { public_work_interactions_json, }, ) .await .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), build_admin_creation_entry_config_response(config), )) } /// 后台读取通用灰度配置。 pub async fn admin_get_feature_gate_config( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, ) -> Result, AppError> { let gates = state .get_feature_gate_config() .await .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), build_admin_feature_gate_config_response(gates), )) } /// 后台保存单个灰度 gate 配置。 pub async fn admin_upsert_feature_gate_config( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Json(payload): Json, ) -> Result, AppError> { let input = validate_admin_feature_gate_config(payload)?; let gates = state .upsert_feature_gate_config(input) .await .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), build_admin_feature_gate_config_response(gates), )) } /// 后台读取画布生成模型定价配置。 pub async fn admin_get_editor_generation_pricing( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, ) -> Result, AppError> { let pricing = state .editor_generation_pricing() .await .map_err(map_admin_editor_generation_pricing_error)?; Ok(json_success_body(Some(&request_context), pricing)) } /// 后台保存画布生成模型定价配置,写入 SpacetimeDB 全局配置表。 pub async fn admin_upsert_editor_generation_pricing( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, AppError> { let pricing = state .save_editor_generation_pricing(admin.session().subject.clone(), payload) .await .map_err(map_admin_editor_generation_pricing_error)?; Ok(json_success_body(Some(&request_context), pricing)) } #[cfg(any())] pub async fn admin_list_work_visibility( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, ) -> Result, AppError> { let admin_user_id = admin.session().subject.clone(); let entries = state .list_admin_work_visibility(admin_user_id) .await .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), AdminWorkVisibilityListResponse { entries }, )) } #[cfg(any())] pub async fn admin_update_work_visibility( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, AppError> { let entry = validate_admin_work_visibility(payload)?; let admin_user_id = admin.session().subject.clone(); let record = state .update_admin_work_visibility(admin_user_id, entry.0, entry.1, entry.2) .await .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), AdminUpdateWorkVisibilityResponse { entry: record }, )) } pub async fn admin_get_asset_read_url( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Query(query): Query, ) -> Result, AppError> { let audit = build_admin_asset_read_url_audit(admin.session().subject.as_str(), &query); let response = get_asset_read_url_with_query( &state, &request_context, query, AssetReadAuthorization::Admin, ) .await?; record_tracking_event_after_success(&state, &request_context, audit).await; Ok(response) } fn build_admin_asset_read_url_audit( admin_subject: &str, query: &GetReadUrlQuery, ) -> TrackingEventDraft { let admin_subject = admin_subject.trim().to_string(); let mut draft = TrackingEventDraft::new("admin_asset_read_url", "admin"); draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::Module; draft.scope_id = "admin".to_string(); draft.user_id = Some(admin_subject.clone()); draft.metadata = serde_json::json!({ "adminSubject": admin_subject, "objectKey": query.object_key.as_deref().map(str::trim), "legacyPublicPath": query.legacy_public_path.as_deref().map(str::trim), "expireSeconds": query.expire_seconds, }); draft } pub async fn admin_list_editor_assets( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Query(query): Query, ) -> Result, AppError> { let page_size = query .limit .unwrap_or(ADMIN_EDITOR_ASSET_DEFAULT_LIMIT) .clamp(1, ADMIN_EDITOR_ASSET_MAX_LIMIT); let date_range = resolve_admin_editor_asset_date_range(&query) .map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?; let cursor = normalize_admin_optional(query.cursor); validate_admin_editor_asset_cursor(cursor.as_deref()) .map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?; let Some((owner_user_id, keyword)) = resolve_admin_editor_asset_filters( &state, normalize_admin_optional(query.owner_user_id), normalize_admin_optional(query.keyword), )? else { return Ok(json_success_body( Some(&request_context), AdminEditorAssetListResponse { entries: Vec::new(), next_cursor: None, }, )); }; let records = state .spacetime_client() .admin_list_editor_assets(AdminEditorAssetListRecordInput { limit: page_size + 1, cursor, owner_user_id, keyword, created_after_micros: date_range.created_after_micros, created_before_micros: date_range.created_before_micros, }) .await .map_err(map_admin_spacetime_error)?; let (groups, next_cursor) = paginate_admin_editor_asset_records(records, page_size as usize) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error) })?; Ok(json_success_body( Some(&request_context), AdminEditorAssetListResponse { entries: groups .into_iter() .filter_map(|records| admin_editor_asset_group_payload(&state, records)) .collect(), next_cursor, }, )) } pub async fn admin_list_editor_showcase_assets( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Query(query): Query, ) -> Result, AppError> { let page_size = query.limit.unwrap_or(80).clamp(1, 200); let mut records = state .spacetime_client() .admin_list_editor_showcase_assets(EditorShowcaseAssetAdminListRecordInput { limit: page_size + 1, cursor: normalize_admin_optional(query.cursor), owner_user_id: normalize_admin_optional(query.owner_user_id), review_status: normalize_admin_optional(query.review_status), submitted_after_micros: query .submitted_after .as_deref() .and_then(parse_timestamp_text_to_micros), submitted_before_micros: query .submitted_before .as_deref() .and_then(parse_timestamp_text_to_micros), }) .await .map_err(map_admin_spacetime_error)?; let next_cursor = if records.len() > page_size as usize { records .get(page_size.saturating_sub(1) as usize) .and_then(editor_showcase_admin_cursor) } else { None }; records.truncate(page_size as usize); Ok(json_success_body( Some(&request_context), AdminEditorShowcaseListResponse { entries: records .into_iter() .map(|record| admin_editor_showcase_asset_payload_from_record(&state, record)) .collect(), next_cursor, }, )) } pub async fn admin_review_editor_showcase_asset( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, AppError> { let admin_user_id = admin.session().subject.clone(); let record = state .spacetime_client() .admin_review_editor_showcase_asset(EditorShowcaseAssetAdminReviewRecordInput { showcase_id: payload.showcase_id, admin_user_id, review_status: payload.review_status, review_note: payload.review_note, updated_at_micros: current_utc_micros(), }) .await .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), AdminEditorShowcaseAssetResponse { entry: admin_editor_showcase_asset_payload_from_record(&state, record), }, )) } pub async fn admin_update_editor_showcase_asset_display( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, AppError> { let record = state .spacetime_client() .update_editor_showcase_asset_display(EditorShowcaseAssetDisplayUpdateRecordInput { showcase_id: payload.showcase_id, admin_user_id: admin.session().subject.clone(), display_enabled: payload.display_enabled, updated_at_micros: current_utc_micros(), showcase_category: payload.showcase_category, }) .await .map_err(map_admin_spacetime_error)?; Ok(json_success_body( Some(&request_context), AdminEditorShowcaseAssetResponse { entry: admin_editor_showcase_asset_payload_from_record(&state, record), }, )) } pub async fn admin_get_editor_showcase_campaign( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, ) -> Result, AppError> { let campaign = state .spacetime_client() .get_editor_showcase_campaign_config(EditorShowcaseCampaignConfigGetRecordInput { include_disabled: true, }) .await .map_err(map_admin_spacetime_error)? .map(admin_editor_showcase_campaign_payload_from_record); Ok(json_success_body( Some(&request_context), AdminEditorShowcaseCampaignResponse { campaign }, )) } pub async fn admin_upsert_editor_showcase_campaign( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, AppError> { let campaign = state .spacetime_client() .upsert_editor_showcase_campaign_config(EditorShowcaseCampaignConfigUpsertRecordInput { admin_user_id: admin.session().subject.clone(), enabled: payload.enabled, title: payload.title, image_src: payload.image_src, image_object_key: payload.image_object_key, image_width: payload.image_width, image_height: payload.image_height, prompt: payload.prompt, author: payload.author, cost_text: payload.cost_text, updated_at_micros: current_utc_micros(), }) .await .map_err(map_admin_spacetime_error)? .map(admin_editor_showcase_campaign_payload_from_record); Ok(json_success_body( Some(&request_context), AdminEditorShowcaseCampaignResponse { campaign }, )) } pub async fn admin_create_editor_showcase_campaign_image_upload_ticket( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, AppError> { let file_name = payload.file_name.trim(); if file_name.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("图片文件名不能为空") ); } let content_type = payload.content_type.trim(); if !content_type.starts_with("image/") { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡只能上传图片") ); } if payload.content_length == 0 { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("图片文件不能为空")); } if payload.content_length > ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡图片不能超过 20MB") ); } let ticket = create_direct_upload_ticket_for_owner( &state, &request_context, admin.session().subject.as_str(), CreateDirectUploadTicketRequest { legacy_prefix: "generated-character-drafts".to_string(), path_segments: vec![ "editor".to_string(), "showcase-campaign".to_string(), current_utc_micros().to_string(), ], file_name: file_name.to_string(), content_type: Some(content_type.to_string()), access: Some(DirectUploadObjectAccess::Private), metadata: BTreeMap::from([ ( "asset_kind".to_string(), "editor_showcase_campaign_image".to_string(), ), ( "source".to_string(), "admin_editor_showcase_campaign".to_string(), ), ]), max_size_bytes: Some(payload.content_length), expire_seconds: None, success_action_status: Some(200), }, ) .await?; let axum::Json(value) = ticket; let payload_value = value.get("data").cloned().unwrap_or(value); let response: shared_contracts::assets::CreateDirectUploadTicketResponse = serde_json::from_value(payload_value).map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details( serde_json::json!({ "message": format!("活动卡上传凭证响应解析失败:{error}"), }), ) })?; Ok(json_success_body( Some(&request_context), AdminCreateEditorShowcaseCampaignImageUploadTicketResponse { upload: admin_direct_upload_ticket_payload_from_asset_upload(response.upload), }, )) } pub async fn admin_confirm_editor_showcase_campaign_image_upload( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, AppError> { let confirm_request = build_admin_editor_showcase_campaign_image_confirm_request(payload)?; confirm_asset_object_for_owner( &state, &request_context, admin.session().subject.as_str(), confirm_request, ) .await } fn build_admin_editor_showcase_campaign_image_confirm_request( payload: AdminConfirmEditorShowcaseCampaignImageUploadRequest, ) -> Result { let bucket = payload.bucket.trim(); if bucket.is_empty() { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("活动卡图片 bucket 不能为空")); } let object_key = payload.object_key.trim().trim_start_matches('/'); if !object_key.starts_with(ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX) || object_key.len() == ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX.len() { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("活动卡图片 Object Key 不属于后台活动卡目录")); } let content_type = payload.content_type.trim(); if !content_type.starts_with("image/") { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡只能确认图片对象") ); } if payload.content_length == 0 { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡图片不能为空") ); } if payload.content_length > ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡图片不能超过 20MB") ); } Ok(ConfirmAssetObjectRequest { bucket: Some(bucket.to_string()), object_key: object_key.to_string(), content_type: Some(content_type.to_string()), content_length: Some(payload.content_length), content_hash: None, asset_kind: ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_ASSET_KIND.to_string(), access_policy: Some(ConfirmAssetObjectAccessPolicy::Private), source_job_id: None, owner_user_id: None, profile_id: None, entity_id: None, }) } #[cfg(any())] fn build_admin_creation_entry_config_response( config: shared_contracts::creation_entry_config::CreationEntryConfigResponse, ) -> AdminCreationEntryConfigResponse { AdminCreationEntryConfigResponse { event_banners: config.event_banners, public_work_interactions: config.public_work_interactions, entries: config .creation_types .into_iter() .map(map_admin_creation_entry_type_config) .collect(), } } #[cfg(any())] fn map_admin_creation_entry_type_config( entry: shared_contracts::creation_entry_config::CreationEntryTypeResponse, ) -> AdminCreationEntryTypeConfigPayload { AdminCreationEntryTypeConfigPayload { id: entry.id, title: entry.title, subtitle: entry.subtitle, badge: entry.badge, image_src: entry.image_src, visible: entry.visible, open: entry.open, sort_order: entry.sort_order, category_id: entry.category_id, category_label: entry.category_label, category_sort_order: entry.category_sort_order, updated_at_micros: entry.updated_at_micros, unified_creation_spec: entry.unified_creation_spec, } } fn build_admin_feature_gate_config_response( mut gates: Vec, ) -> AdminFeatureGateConfigResponse { gates.sort_by(|left, right| left.gate_key.cmp(&right.gate_key)); AdminFeatureGateConfigResponse { gates: gates .into_iter() .map(map_admin_feature_gate_config) .collect(), } } fn map_admin_feature_gate_config( gate: module_runtime::FeatureGateConfigSnapshot, ) -> AdminFeatureGateConfigPayload { AdminFeatureGateConfigPayload { gate_key: gate.gate_key, enabled: gate.enabled, rollout_percent: gate.rollout_percent, allow_user_ids: gate.allow_user_ids, allow_user_tags: gate.allow_user_tags, deny_user_ids: gate.deny_user_ids, description: gate.description, updated_at: module_runtime::format_utc_micros(gate.updated_at_micros), } } fn normalize_admin_editor_image_sequence_frames( frames: &Value, ) -> Option> { let normalized = normalize_editor_image_sequence_frames_value(frames)?; serde_json::from_value(normalized).ok() } fn admin_editor_showcase_asset_payload_from_record( state: &AppState, record: EditorShowcaseAssetRecord, ) -> AdminEditorShowcaseAssetPayload { let author = resolve_work_author_by_user_id(state, &record.owner_user_id, None, None); let formal_image_sequence_frames = record .image_sequence_frames .as_ref() .and_then(normalize_admin_editor_image_sequence_frames); let asset_kind = resolve_editor_asset_kind( record.asset_kind, record.image_src.as_str(), record.object_key.as_deref(), ); AdminEditorShowcaseAssetPayload { showcase_id: record.showcase_id, asset_id: record.asset_id, owner_user_id: record.owner_user_id, author_display_name: Some(author.display_name), author_public_user_code: author.public_user_code, label: record.label, image_src: record.image_src, object_key: record.object_key, width: record.width, height: record.height, prompt: record.prompt, actual_prompt: record.actual_prompt, model: record.model, provider: record.provider, task_id: record.task_id, asset_kind, generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs), thumbnail_src: record.thumbnail_src, generation_cost_mud_points: record.generation_cost_mud_points, refund_mud_points: record.refund_mud_points, review_status: record.review_status, display_enabled: record.display_enabled, like_count: record.like_count, asset_deleted_while_pending: record.asset_deleted_while_pending, reviewed_by_admin_user_id: record.reviewed_by_admin_user_id, review_note: record.review_note, refund_ledger_id: record.refund_ledger_id, refund_completed_at: record.refund_completed_at, submitted_at: record.submitted_at, reviewed_at: record.reviewed_at, approved_at: record.approved_at, rejected_at: record.rejected_at, updated_at: record.updated_at, showcase_category: record.showcase_category, image_sequence_frames: formal_image_sequence_frames, image_sequence_duration_ms: record.image_sequence_duration_ms, } } fn admin_editor_showcase_campaign_payload_from_record( record: EditorShowcaseCampaignConfigRecord, ) -> AdminEditorShowcaseCampaignPayload { AdminEditorShowcaseCampaignPayload { enabled: record.enabled, title: record.title, image_src: record.image_src, image_object_key: record.image_object_key, image_width: record.image_width, image_height: record.image_height, prompt: record.prompt, author: record.author, cost_text: record.cost_text, updated_at: record.updated_at, } } fn admin_direct_upload_ticket_payload_from_asset_upload( upload: shared_contracts::assets::DirectUploadTicketPayload, ) -> AdminDirectUploadTicketPayload { let mut form_fields = BTreeMap::from([ ("key".to_string(), upload.form_fields.key), ("policy".to_string(), upload.form_fields.policy), ( "x-oss-signature-version".to_string(), upload.form_fields.signature_version, ), ( "x-oss-credential".to_string(), upload.form_fields.credential, ), ("x-oss-date".to_string(), upload.form_fields.date), ("x-oss-signature".to_string(), upload.form_fields.signature), ( "success_action_status".to_string(), upload.form_fields.success_action_status, ), ]); if let Some(content_type) = upload.form_fields.content_type { form_fields.insert("Content-Type".to_string(), content_type); } if let Some(cache_control) = upload.form_fields.cache_control { form_fields.insert("Cache-Control".to_string(), cache_control); } form_fields.extend(upload.form_fields.metadata); AdminDirectUploadTicketPayload { bucket: upload.bucket, host: upload.host, object_key: upload.object_key, legacy_public_path: upload.legacy_public_path, form_fields, } } fn editor_showcase_admin_cursor(record: &EditorShowcaseAssetRecord) -> Option { let submitted_at_micros = parse_timestamp_text_to_micros(record.submitted_at.as_str())?; Some(format!("{submitted_at_micros}:{}", record.showcase_id)) } struct AdminEditorAssetDateRange { created_after_micros: Option, created_before_micros: Option, } fn resolve_admin_editor_asset_date_range( query: &AdminEditorAssetListQuery, ) -> Result { let created_after_micros = normalize_admin_optional(query.created_after.clone()) .map(|value| { parse_timestamp_text_to_micros(&value) .ok_or_else(|| "createdAfter 格式非法".to_string()) }) .transpose()?; let created_before_micros = normalize_admin_optional(query.created_before.clone()) .map(|value| { parse_timestamp_text_to_micros(&value) .ok_or_else(|| "createdBefore 格式非法".to_string()) }) .transpose()?; if matches!( (created_after_micros, created_before_micros), (Some(created_after_micros), Some(created_before_micros)) if created_after_micros > created_before_micros ) { return Err("createdAfter 不能晚于 createdBefore".to_string()); } Ok(AdminEditorAssetDateRange { created_after_micros, created_before_micros, }) } fn admin_editor_asset_payload_from_record( state: &AppState, record: AdminEditorAssetRecord, ) -> AdminEditorAssetPayload { let author = resolve_work_author_by_user_id(state, &record.owner_user_id, None, None); let generator = resolve_admin_editor_asset_generator(&record); let formal_image_sequence_frames = record .image_sequence_frames .as_ref() .and_then(normalize_admin_editor_image_sequence_frames); let asset_kind = resolve_editor_asset_kind( record.asset_kind, record.image_src.as_str(), record.object_key.as_deref(), ); AdminEditorAssetPayload { asset_id: record.asset_id, owner_user_id: record.owner_user_id, author_display_name: Some(author.display_name), author_public_user_code: author.public_user_code, folder_id: record.folder_id, label: record.label, asset_object_id: record.asset_object_id, image_src: record.image_src, object_key: record.object_key, width: record.width, height: record.height, source_type: record.source_type, prompt: record.prompt, actual_prompt: record.actual_prompt, model: record.model, provider: record.provider, task_id: record.task_id, group_task_id: record.group_task_id, asset_kind, generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs), source_resource_id: record.source_resource_id, thumbnail_src: record.thumbnail_src, generation_cost_mud_points: record.generation_cost_mud_points, created_at: record.created_at, updated_at: record.updated_at, task_generator: generator.clone(), task_cost_mud_points: record.generation_cost_mud_points, generator, children: Vec::new(), image_sequence_frames: formal_image_sequence_frames, image_sequence_duration_ms: record.image_sequence_duration_ms, } } fn group_admin_editor_asset_records( records: Vec, ) -> Vec> { let mut groups = Vec::>::new(); let mut indexes = BTreeMap::::new(); for record in records { let key = record .group_task_id .as_deref() .or(record.task_id.as_deref()) .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| format!("owner:{}:task:{value}", record.owner_user_id)) .unwrap_or_else(|| format!("asset:{}", record.asset_id)); let index = if let Some(index) = indexes.get(&key).copied() { index } else { let index = groups.len(); groups.push(Vec::new()); indexes.insert(key, index); index }; groups[index].push(record); } groups } fn paginate_admin_editor_asset_records( records: Vec, page_size: usize, ) -> Result<(Vec>, Option), String> { let mut groups = group_admin_editor_asset_records(records); let next_cursor = if groups.len() > page_size { let cursor_record = groups .get(page_size.saturating_sub(1)) .and_then(|group| group.first()) .ok_or_else(|| "生成素材分页游标失败:分页末项不存在".to_string())?; Some(admin_editor_asset_cursor(cursor_record)?) } else { None }; groups.truncate(page_size); Ok((groups, next_cursor)) } fn admin_editor_asset_group_payload( state: &AppState, mut records: Vec, ) -> Option { if records.is_empty() { return None; } let task_generator = resolve_admin_editor_asset_task_generator(records.as_slice()); let task_cost_mud_points = records .iter() .map(|record| record.generation_cost_mud_points) .max() .unwrap_or_default(); let primary = records.remove(0); let mut payload = admin_editor_asset_payload_from_record(state, primary); payload.task_generator = task_generator; payload.task_cost_mud_points = task_cost_mud_points; payload.children = records .into_iter() .map(|record| admin_editor_asset_payload_from_record(state, record)) .collect(); Some(payload) } fn resolve_admin_editor_asset_task_generator(records: &[AdminEditorAssetRecord]) -> String { let paths = records .iter() .filter_map(|record| record.object_key.as_deref()) .map(str::to_ascii_lowercase) .collect::>(); if paths.iter().any(|path| path.contains("/character-images/")) { return "角色生成".to_string(); } if paths.iter().any(|path| { path.contains("/ui-design-spritesheets/") || path.contains("/ui-design-assets/") }) { return "UI素材提取".to_string(); } if paths.iter().any(|path| { path.contains("/icon-spritesheets/") || path.contains("/icon-spritesheet-assets/") }) { return "图标图集生成".to_string(); } records .first() .map(resolve_admin_editor_asset_generator) .unwrap_or_else(|| "未知生成器".to_string()) } fn resolve_admin_editor_asset_generator(record: &AdminEditorAssetRecord) -> String { let object_key = record .object_key .as_deref() .unwrap_or_default() .to_ascii_lowercase(); let provider_source = object_key.contains("green-screen-source") || object_key.contains("provider-source") || object_key.contains("/animation/editor-character-animation/"); if object_key.contains("/animation/editor-character-animation/") { return "角色动作视频生成".to_string(); } if object_key.contains("/character-images/") { return if provider_source { "角色生图" } else { "角色抠图" } .to_string(); } if object_key.contains("/ui-design-assets/") || object_key.contains("/icon-spritesheet-assets/") { return "图集拆分".to_string(); } if object_key.contains("/ui-design-spritesheets/") { return if provider_source { "UI素材生成" } else { "UI图集抠图" } .to_string(); } if object_key.contains("/icon-spritesheets/") { return if provider_source { "图标图集生成" } else { "图标图集抠图" } .to_string(); } if object_key.contains("/edit-images/") { return "快速编辑".to_string(); } if object_key.contains("/spec-images/") { return "规范生成".to_string(); } if object_key.contains("/publication-materials/") { return "宣发素材生成".to_string(); } if object_key.contains("/generated-images/") { return "图片生成".to_string(); } match record.asset_kind.as_deref() { Some("character-animation") => { if provider_source { "角色动作视频生成" } else { "角色动作逐帧处理" } } Some("sound-effect") => "音效生成", Some("background-music") => "背景音乐生成", Some("video") => "视频生成", Some("icon") => "图集拆分", Some("icon-spritesheet") => "图标图集生成", Some("character") => "角色生成", Some("ui-design") => "UI设计生成", _ => "未知生成器", } .to_string() } fn admin_editor_asset_cursor(record: &AdminEditorAssetRecord) -> Result { let created_at_micros = parse_timestamp_text_to_micros(record.created_at.as_str()) .ok_or_else(|| format!("素材 {} 的创建时间格式非法", record.asset_id))?; Ok(format!("{created_at_micros}:{}", record.asset_id)) } fn validate_admin_editor_asset_cursor(cursor: Option<&str>) -> Result<(), String> { let Some(cursor) = cursor else { return Ok(()); }; let Some((created_at_micros, asset_id)) = cursor.split_once(':') else { return Err("cursor 格式非法".to_string()); }; if created_at_micros.parse::().is_err() || asset_id.trim().is_empty() { return Err("cursor 格式非法".to_string()); } Ok(()) } fn resolve_admin_editor_asset_filters( state: &AppState, mut owner_user_id: Option, mut keyword: Option, ) -> Result, Option)>, AppError> { if let Some(public_user_code) = owner_user_id .as_deref() .and_then(normalize_admin_public_user_code_candidate) { owner_user_id = resolve_admin_editor_asset_public_user_id(state, &public_user_code)?; if owner_user_id.is_none() { return Ok(None); } } if let Some(public_user_code) = keyword .as_deref() .and_then(normalize_admin_public_user_code_candidate) { let Some(keyword_owner_user_id) = resolve_admin_editor_asset_public_user_id(state, &public_user_code)? else { return Ok(None); }; if owner_user_id .as_ref() .is_some_and(|owner_user_id| owner_user_id != &keyword_owner_user_id) { return Ok(None); } owner_user_id = Some(keyword_owner_user_id); keyword = None; } Ok(Some((owner_user_id, keyword))) } fn normalize_admin_public_user_code_candidate(value: &str) -> Option { let compact = value .trim() .chars() .filter(|character| character.is_ascii_alphanumeric()) .collect::() .to_ascii_uppercase(); let digits = compact.strip_prefix("SY")?; if digits.is_empty() || !digits.chars().all(|character| character.is_ascii_digit()) { return None; } module_auth::normalize_public_user_code(value).ok() } fn resolve_admin_editor_asset_public_user_id( state: &AppState, public_user_code: &str, ) -> Result, AppError> { state .auth_user_service() .get_user_by_public_user_code(public_user_code) .map(|user| user.map(|user| user.id)) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY) .with_message(format!("读取素材作者认证信息失败:{error}")) }) } fn normalize_admin_optional(value: Option) -> Option { value .map(|item| item.trim().to_string()) .filter(|item| !item.is_empty()) } #[cfg(any())] fn validate_admin_creation_entry_config( payload: AdminUpsertCreationEntryTypeConfigRequest, ) -> Result { let id = payload.id.trim().to_string(); if id.is_empty() { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("入口 ID 不能为空")); } let title = payload.title.trim().to_string(); if title.is_empty() { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("入口标题不能为空")); } let unified_creation_spec = match payload.unified_creation_spec { Some(spec) => { validate_unified_creation_spec_for_play(&id, &spec).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_message(error) })?; Some(spec) } None => None, }; let unified_creation_spec_json = unified_creation_spec .as_ref() .map(|spec| { encode_unified_creation_spec_response(spec) .map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)) }) .transpose()?; Ok(module_runtime::CreationEntryTypeAdminUpsertInput { id, title, subtitle: payload.subtitle.trim().to_string(), badge: payload.badge.trim().to_string(), image_src: payload.image_src.trim().to_string(), visible: payload.visible, open: payload.open, sort_order: payload.sort_order, category_id: payload.category_id.trim().to_string(), category_label: payload.category_label.trim().to_string(), category_sort_order: payload.category_sort_order, unified_creation_spec_json, }) } fn validate_admin_feature_gate_config( payload: AdminUpsertFeatureGateConfigRequest, ) -> Result { module_runtime::normalize_feature_gate_admin_upsert_input( module_runtime::FeatureGateConfigAdminUpsertInput { gate_key: payload.gate_key, enabled: payload.enabled, rollout_percent: payload.rollout_percent, allow_user_ids: payload.allow_user_ids, allow_user_tags: payload.allow_user_tags, deny_user_ids: payload.deny_user_ids, description: payload.description, }, ) .map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)) } #[cfg(any())] fn validate_admin_work_visibility( payload: AdminUpdateWorkVisibilityRequest, ) -> Result<(String, String, bool), AppError> { let source_type = payload.source_type.trim().to_string(); if source_type.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("sourceType 不能为空") ); } let profile_id = payload.profile_id.trim().to_string(); if profile_id.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("profileId 不能为空") ); } Ok((source_type, profile_id, payload.visible)) } fn map_admin_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(serde_json::json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn map_admin_editor_generation_pricing_error( error: crate::editor_generation_config::EditorGenerationPricingError, ) -> AppError { let status = match error { crate::editor_generation_config::EditorGenerationPricingError::Invalid(_) => { StatusCode::BAD_REQUEST } _ => StatusCode::INTERNAL_SERVER_ERROR, }; AppError::from_status(status).with_details(serde_json::json!({ "provider": "editor-generation-pricing", "message": error.to_string(), })) } pub async fn require_admin_auth( State(state): State, mut request: Request, next: Next, ) -> Result { // member 的启停、版本和权限以当前数据库记录为准,JWT 不承载授权真相。 let runtime = state.admin_runtime().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用") })?; let bearer_token = extract_bearer_token(request.headers())?; let claims = runtime .verify_token(&bearer_token) .map_err(|error| AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error))?; if !claims.roles.iter().any(|role| role == "admin") { return Err( AppError::from_status(StatusCode::FORBIDDEN).with_message("当前令牌不是管理员令牌") ); } let admin_session = if runtime.is_owner_subject(claims.user_id()) { runtime.validate_claims(&claims) } else { let account = state .spacetime_client() .get_admin_account_by_id(claims.user_id().to_string()) .await .map_err(|error| { if is_admin_account_not_found(&error) { AppError::from_status(StatusCode::UNAUTHORIZED) .with_message("后台登录状态已失效") } else { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("后台账号服务暂不可用") } })?; if !account.enabled { return Err( AppError::from_status(StatusCode::UNAUTHORIZED).with_message("后台登录状态已失效") ); } runtime.validate_account_claims( &claims, &account.account_id, account.token_version, account.username, account.display_name, account.tab_permissions, account.action_permissions, ) } .map_err(|error| AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error))?; enforce_admin_request_permission( &admin_session.account_role, &admin_session.tab_permissions, &admin_session.action_permissions, request.method(), request.uri().path(), )?; request .extensions_mut() .insert(AuthenticatedAdmin::new(build_admin_session_payload( admin_session, ))); Ok(next.run(request).await) } fn admin_login_rejected() -> AppError { AppError::from_status(StatusCode::UNAUTHORIZED).with_message("管理员用户名或密码错误") } fn internal_admin_error(error: String) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error) } pub(crate) async fn hash_admin_password(password: &str) -> Result { let _permit = ADMIN_PASSWORD_OPERATION_LIMITER .acquire() .await .map_err(|_| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("后台密码服务暂不可用") })?; hash_password(password).await.map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) }) } async fn verify_admin_password(password_hash: &str, password: &str) -> Result { let _permit = ADMIN_PASSWORD_OPERATION_LIMITER .acquire() .await .map_err(|_| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("后台密码服务暂不可用") })?; verify_password(password_hash, password) .await .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) }) } async fn admin_dummy_password_hash() -> Result<&'static str, AppError> { ADMIN_DUMMY_PASSWORD_HASH .get_or_try_init(|| async { hash_admin_password("genarrative-admin-dummy-password").await }) .await .map(String::as_str) } fn is_admin_account_not_found(error: &SpacetimeClientError) -> bool { matches!( error, SpacetimeClientError::Procedure(message) if message == "后台账号不存在" ) } enum AdminPermissionRequirement { Authenticated, AnyTab(&'static [&'static str]), Action(&'static str), OwnerOnly, } fn enforce_admin_request_permission( account_role: &str, tab_permissions: &[String], action_permissions: &[String], method: &Method, path: &str, ) -> Result<(), AppError> { if account_role == "owner" { return Ok(()); } let requirement = admin_permission_requirement(method, path); let allowed = match requirement { AdminPermissionRequirement::Authenticated => true, AdminPermissionRequirement::OwnerOnly => false, AdminPermissionRequirement::AnyTab(required) => required .iter() .any(|permission| tab_permissions.iter().any(|owned| owned == permission)), AdminPermissionRequirement::Action(required) => action_permissions .iter() .any(|permission| permission == required), }; if allowed { Ok(()) } else { Err(AppError::from_status(StatusCode::FORBIDDEN).with_message("当前后台账号无权执行该操作")) } } fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermissionRequirement { use AdminPermissionRequirement::{Action, AnyTab, Authenticated, OwnerOnly}; match path { "/admin/api/me" => Authenticated, "/admin/api/dashboard" => AnyTab(&["dashboard"]), "/admin/api/overview" => AnyTab(&["overview"]), "/admin/api/debug/http" => AnyTab(&["debug"]), "/admin/api/tracking/events" => AnyTab(&["tracking"]), "/admin/api/tracking/event-keys" => AnyTab(&["tracking", "tasks"]), "/admin/api/feature-gates" => AnyTab(&["gray-release"]), "/admin/api/editor-generation-pricing" => AnyTab(&["editor-generation-pricing"]), "/admin/api/editor-assets" => AnyTab(&["editor-assets"]), "/admin/api/assets/read-url" => AnyTab(&["editor-assets", "editor-showcase"]), path if path.starts_with("/admin/api/editor-showcase/") => AnyTab(&["editor-showcase"]), "/admin/api/profile/redeem-codes" | "/admin/api/profile/redeem-codes/disable" => { AnyTab(&["redeem"]) } "/admin/api/profile/invite-codes" => AnyTab(&["invite"]), "/admin/api/profile/tasks" | "/admin/api/profile/tasks/disable" => AnyTab(&["tasks"]), "/admin/api/profile/wallet-config" => AnyTab(&["profile-wallet"]), "/admin/api/profile/recharge-products" => AnyTab(&["recharge-products"]), path if path.starts_with("/admin/api/profile/recharge-") => AnyTab(&["recharge-orders"]), "/admin/api/profile/wallet-restriction" => AnyTab(&["recharge-orders"]), "/admin/api/profile/users/reconcile-consumption" => { Action(ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE) } "/admin/api/profile/users/initialize-consumption-projections" => OwnerOnly, "/admin/api/profile/users/detail" => AnyTab(&[ "tables", "tracking", "recharge-orders", "editor-showcase", "editor-assets", ]), path if path == "/admin/api/database/tables" || path.starts_with("/admin/api/database/tables/") => { AnyTab(&["tables"]) } path if path == "/admin/api/accounts" || path.starts_with("/admin/api/accounts/") => { OwnerOnly } _ => OwnerOnly, } } fn extract_bearer_token(headers: &HeaderMap) -> Result { let authorization = headers .get(AUTHORIZATION) .and_then(|value| value.to_str().ok()) .map(str::trim) .ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?; let token = authorization .strip_prefix("Bearer ") .or_else(|| authorization.strip_prefix("bearer ")) .map(str::trim) .filter(|token| !token.is_empty()) .ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?; Ok(token.to_string()) } async fn build_admin_overview( state: &AppState, runtime: &AdminRuntime, ) -> Result { let service = AdminServiceOverviewPayload { bind_host: state.config.bind_host.clone(), bind_port: state.config.bind_port, jwt_issuer: state.config.jwt_issuer.clone(), admin_enabled: runtime.is_enabled(), spacetime_server_url: state.config.spacetime_server_url.clone(), spacetime_database: state.config.spacetime_database.clone(), }; let database = fetch_database_overview(state).await; Ok(AdminOverviewResponse { service, database }) } async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPayload { // 概览直接读取 SpacetimeDB HTTP API,保证后台看到的是真实数据库元信息而不是本地缓存。 let client = Client::new(); let server_root = state.config.spacetime_server_url.trim_end_matches('/'); let database = state.config.spacetime_database.trim(); let token = state .config .spacetime_token .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); let mut fetch_errors = Vec::new(); let database_info = fetch_spacetime_json::( &client, &format!("{server_root}/v1/database/{database}"), token, ) .await .map_err(|error| fetch_errors.push(format!("数据库信息读取失败:{error}"))) .ok() .flatten(); let schema = fetch_spacetime_json::( &client, &build_spacetime_schema_url(server_root, database), token, ) .await .map_err(|error| fetch_errors.push(format!("数据库 schema 读取失败:{error}"))) .ok() .flatten(); let schema_table_names = extract_schema_table_names(schema.as_ref()); let mut table_stats = Vec::new(); for table_name in &schema_table_names { if !is_safe_spacetime_table_name(table_name) { table_stats.push(AdminDatabaseTableStatPayload { table_name: table_name.clone(), row_count: None, error_message: Some("表名不适合 SQL 统计".to_string()), }); continue; } let sql = format!("SELECT COUNT(*) AS row_count FROM {table_name}"); match fetch_spacetime_sql_count(&client, server_root, database, token, &sql).await { Ok(row_count) => table_stats.push(AdminDatabaseTableStatPayload { table_name: table_name.clone(), row_count: Some(row_count), error_message: None, }), Err(error) => { table_stats.push(AdminDatabaseTableStatPayload { table_name: table_name.clone(), row_count: None, error_message: Some(normalize_table_count_error(&error)), }); } } } AdminDatabaseOverviewPayload { database_identity: database_info .as_ref() .and_then(|value| value.database_identity.clone()), owner_identity: database_info .as_ref() .and_then(|value| value.owner_identity.clone()), host_type: database_info .as_ref() .and_then(|value| value.host_type.clone()), schema_table_names, table_stats, fetch_errors, } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AdminDashboardGranularity { Day, Week, Month, Period, } struct AdminDashboardRangeResolved { granularity: AdminDashboardGranularity, anchor_date_key: i64, start_date_key: i64, end_date_key: i64, } #[derive(Default)] struct AdminDashboardSeries { buckets: BTreeMap, } #[derive(Default)] struct AdminDashboardAssetStats { generated_assets: AdminDashboardSeries, asset_kind_breakdown: BTreeMap, warnings: Vec, } #[derive(Default)] struct AdminDashboardWalletStats { consumed_mud_points: AdminDashboardSeries, recharged_mud_points: u64, warnings: Vec, } #[derive(Default)] struct AdminDashboardUserStats { total_registered_users: u64, new_registered_users: AdminDashboardSeries, day1_retention: AdminDashboardRetentionPayload, day7_retention: AdminDashboardRetentionPayload, visit_count: AdminDashboardSeries, visit_users_by_day: AdminDashboardSeries, visit_users: u64, total_visit_count: u64, total_visit_users: u64, current_users: u64, module_visit_breakdown: BTreeMap, new_user_payment_conversion: AdminDashboardPaymentConversionPayload, } async fn build_admin_dashboard( state: &AppState, query: AdminDashboardQuery, ) -> Result { let now_micros = current_admin_dashboard_utc_micros(); let range = resolve_admin_dashboard_range_at(query, now_micros)?; let mut warnings = Vec::new(); let asset_stats = fetch_admin_dashboard_asset_stats(state, &range).await; warnings.extend(asset_stats.warnings.iter().cloned()); let wallet_stats = fetch_admin_dashboard_wallet_stats(state, &range).await; warnings.extend(wallet_stats.warnings.iter().cloned()); let user_stats = fetch_admin_dashboard_user_stats(state, &range, now_micros).await?; let generated_assets = asset_stats.generated_assets.total(); let consumed_mud_points = wallet_stats.consumed_mud_points.total(); let new_registered_users = user_stats.new_registered_users.total(); let metrics = AdminDashboardMetricsPayload { generated_assets, consumed_mud_points, total_registered_users: user_stats.total_registered_users, new_registered_users, day1_retention: user_stats.day1_retention.clone(), day7_retention: user_stats.day7_retention.clone(), visit_users: user_stats.visit_users, total_visit_users: user_stats.total_visit_users, visit_count: user_stats.visit_count.total(), total_visit_count: user_stats.total_visit_count, current_users: user_stats.current_users, new_user_payment_conversion: user_stats.new_user_payment_conversion.clone(), }; let charts = vec![ build_admin_dashboard_chart( "generated-assets", "生产素材", "个", &asset_stats.generated_assets, asset_stats.generated_assets.total(), &range, ), build_admin_dashboard_chart( "consumed-mud-points", "消耗泥点", "泥点", &wallet_stats.consumed_mud_points, wallet_stats.consumed_mud_points.total(), &range, ), build_admin_dashboard_chart( "visit-users", "每日访问人数", "人", &user_stats.visit_users_by_day, user_stats.visit_users, &range, ), build_admin_dashboard_chart( "visit-count", "访问次数", "次", &user_stats.visit_count, user_stats.visit_count.total(), &range, ), ]; Ok(AdminDashboardResponse { range: build_admin_dashboard_range_payload(&range), metrics: metrics.clone(), charts, operations: build_admin_dashboard_operations( &metrics, wallet_stats.recharged_mud_points, asset_stats.asset_kind_breakdown, user_stats.module_visit_breakdown, ), warnings, generated_at: module_runtime::format_utc_micros(now_micros), }) } #[cfg(test)] fn resolve_admin_dashboard_range( query: AdminDashboardQuery, ) -> Result { resolve_admin_dashboard_range_at(query, current_admin_dashboard_utc_micros()) } fn resolve_admin_dashboard_range_at( query: AdminDashboardQuery, now_micros: i64, ) -> Result { let today_date_key = module_runtime::runtime_profile_beijing_day_key(now_micros); let granularity = match query .granularity .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("day") .to_ascii_lowercase() .as_str() { "day" => AdminDashboardGranularity::Day, "week" => AdminDashboardGranularity::Week, "month" => AdminDashboardGranularity::Month, "period" => AdminDashboardGranularity::Period, _ => { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("dashboard granularity 只支持 day/week/month/period")); } }; if matches!(granularity, AdminDashboardGranularity::Period) { return resolve_admin_dashboard_period_range(query, today_date_key); } let anchor_date_key = match query .anchor .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { Some(anchor) => { module_runtime::parse_analytics_calendar_date_key(anchor).map_err(|_| { AppError::from_status(StatusCode::BAD_REQUEST) .with_message("dashboard anchor 必须是合法 YYYY-MM-DD 日期") })? } None => today_date_key, }; if anchor_date_key > today_date_key { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("dashboard anchor 不能晚于今天")); } let dimension = module_runtime::build_analytics_date_dimension_from_date_key(anchor_date_key); let (start_date_key, natural_end_date_key) = match granularity { AdminDashboardGranularity::Day => (anchor_date_key, anchor_date_key), AdminDashboardGranularity::Week => { (dimension.week_start_date_key, dimension.week_end_date_key) } AdminDashboardGranularity::Month => { (dimension.month_start_date_key, dimension.month_end_date_key) } AdminDashboardGranularity::Period => unreachable!("period range is resolved above"), }; let end_date_key = natural_end_date_key.min(today_date_key); Ok(AdminDashboardRangeResolved { granularity, anchor_date_key, start_date_key, end_date_key, }) } fn resolve_admin_dashboard_period_range( query: AdminDashboardQuery, today_date_key: i64, ) -> Result { let start_date_key = parse_admin_dashboard_date_param( query.start_date.as_deref(), "dashboard startDate 必须是合法 YYYY-MM-DD 日期", )?; let end_date_key = parse_admin_dashboard_date_param( query.end_date.as_deref(), "dashboard endDate 必须是合法 YYYY-MM-DD 日期", )?; if start_date_key > end_date_key { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("dashboard startDate 不能晚于 endDate")); } if end_date_key - start_date_key + 1 > ADMIN_DASHBOARD_MAX_PERIOD_DAYS { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message(format!( "dashboard 自定义时段最多支持 {} 天", ADMIN_DASHBOARD_MAX_PERIOD_DAYS )), ); } if start_date_key > today_date_key { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("dashboard startDate 不能晚于今天")); } let end_date_key = end_date_key.min(today_date_key); Ok(AdminDashboardRangeResolved { granularity: AdminDashboardGranularity::Period, anchor_date_key: end_date_key, start_date_key, end_date_key, }) } fn parse_admin_dashboard_date_param( value: Option<&str>, error_message: &str, ) -> Result { let date = value .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_message(error_message.to_string()) })?; module_runtime::parse_analytics_calendar_date_key(date).map_err(|_| { AppError::from_status(StatusCode::BAD_REQUEST).with_message(error_message.to_string()) }) } fn build_admin_dashboard_range_payload( range: &AdminDashboardRangeResolved, ) -> AdminDashboardRangePayload { let anchor = admin_dashboard_date_label(range.anchor_date_key); let start = admin_dashboard_date_label(range.start_date_key); let end = admin_dashboard_date_label(range.end_date_key); let period_label = match range.granularity { AdminDashboardGranularity::Day => anchor.clone(), AdminDashboardGranularity::Week => format!("{start} 至 {end}"), AdminDashboardGranularity::Month => { let dimension = module_runtime::build_analytics_date_dimension_from_date_key(range.anchor_date_key); let month_key = dimension.month_key; format!("{:04}-{:02}", month_key / 100, month_key % 100) } AdminDashboardGranularity::Period => format!("{start} 至 {end}"), }; AdminDashboardRangePayload { granularity: range.granularity.as_str().to_string(), anchor_date: anchor, period_start_date: start, period_end_date: end, period_label, } } async fn fetch_admin_dashboard_asset_stats( state: &AppState, range: &AdminDashboardRangeResolved, ) -> AdminDashboardAssetStats { let mut stats = AdminDashboardAssetStats::default(); let sql = format!( "SELECT asset_kind, created_at FROM editor_project_resource WHERE source_type = 'generated' LIMIT {}", ADMIN_DASHBOARD_ROW_LIMIT ); let rows = match fetch_admin_dashboard_rows(state, &sql).await { Ok(rows) => rows, Err(message) => { stats .warnings .push(format!("生产素材统计读取失败:{message}")); return stats; } }; warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "生产素材", rows.len()); for row in rows { let Some(columns) = row.as_array() else { continue; }; let asset_kind = columns .first() .and_then(value_to_string) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "未分类".to_string()); let Some(created_at_micros) = columns.get(1).and_then(timestamp_value_to_micros) else { continue; }; let day_key = module_runtime::runtime_profile_beijing_day_key(created_at_micros); if range.contains_day_key(day_key) { stats.generated_assets.add(day_key, 1); *stats.asset_kind_breakdown.entry(asset_kind).or_insert(0) += 1; } } stats } async fn fetch_admin_dashboard_wallet_stats( state: &AppState, range: &AdminDashboardRangeResolved, ) -> AdminDashboardWalletStats { let mut stats = AdminDashboardWalletStats::default(); let sql = format!( "SELECT amount_delta, source_type, created_at FROM profile_wallet_ledger LIMIT {}", ADMIN_DASHBOARD_ROW_LIMIT ); let rows = match fetch_admin_dashboard_rows(state, &sql).await { Ok(rows) => rows, Err(message) => { stats .warnings .push(format!("泥点流水统计读取失败:{message}")); return stats; } }; warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "泥点流水", rows.len()); for row in rows { let Some(columns) = row.as_array() else { continue; }; let amount_delta = columns.get(0).and_then(value_to_i64).unwrap_or(0); let source_type = columns .get(1) .and_then(wallet_ledger_source_type_to_string) .unwrap_or_default(); let Some(created_at_micros) = columns.get(2).and_then(timestamp_value_to_micros) else { continue; }; let day_key = module_runtime::runtime_profile_beijing_day_key(created_at_micros); if !range.contains_day_key(day_key) { continue; } match source_type.as_str() { "asset_operation_consume" if amount_delta < 0 => { stats .consumed_mud_points .add(day_key, amount_delta.unsigned_abs()); } "points_recharge" if amount_delta > 0 => { stats.recharged_mud_points = stats .recharged_mud_points .saturating_add(amount_delta as u64); } _ => {} } } stats } async fn fetch_admin_dashboard_user_stats( state: &AppState, range: &AdminDashboardRangeResolved, now_micros: i64, ) -> Result { let input = AdminDashboardStatsRecordInput { start_day_key: range.start_date_key, end_day_key: range.end_date_key, now_micros, }; admin_dashboard_user_stats_from_result( state .spacetime_client() .get_admin_dashboard_stats(input) .await, ) } fn admin_dashboard_user_stats_from_result( result: Result, ) -> Result { result .map(admin_dashboard_user_stats_from_record) .map_err(map_admin_spacetime_error) } fn admin_dashboard_user_stats_from_record( record: AdminDashboardStatsRecord, ) -> AdminDashboardUserStats { let mut stats = AdminDashboardUserStats { total_registered_users: record.total_registered_users, day1_retention: AdminDashboardRetentionPayload { eligible_users: record.day1_retention.eligible_users, retained_users: record.day1_retention.retained_users, rate_basis_points: record.day1_retention.rate_basis_points, }, day7_retention: AdminDashboardRetentionPayload { eligible_users: record.day7_retention.eligible_users, retained_users: record.day7_retention.retained_users, rate_basis_points: record.day7_retention.rate_basis_points, }, visit_users: record.visit_users, total_visit_count: record.total_visit_count, total_visit_users: record.total_visit_users, current_users: record.current_users, module_visit_breakdown: record .module_visit_breakdown .into_iter() .map(|entry| (entry.key, entry.count)) .collect(), new_user_payment_conversion: AdminDashboardPaymentConversionPayload { paid_users: record.new_user_payment_conversion.paid_users, new_registered_users: record.new_user_payment_conversion.new_registered_users, rate_basis_points: record.new_user_payment_conversion.rate_basis_points, }, ..Default::default() }; for entry in record.new_registered_users { stats.new_registered_users.add(entry.day_key, entry.count); } for entry in record.visit_count { stats.visit_count.add(entry.day_key, entry.count); } for entry in record.visit_users_by_day { stats.visit_users_by_day.add(entry.day_key, entry.count); } stats } async fn fetch_admin_dashboard_rows(state: &AppState, sql: &str) -> Result, String> { let client = Client::new(); let server_root = state.config.spacetime_server_url.trim_end_matches('/'); let database = state.config.spacetime_database.trim(); let token = resolve_admin_spacetime_sql_token(state); let payload = fetch_spacetime_sql_json(&client, server_root, database, token.as_deref(), sql).await?; extract_first_sql_rows(payload) } fn build_admin_dashboard_chart( id: &str, title: &str, unit: &str, series: &AdminDashboardSeries, total: u64, range: &AdminDashboardRangeResolved, ) -> AdminDashboardChartPayload { AdminDashboardChartPayload { id: id.to_string(), title: title.to_string(), unit: unit.to_string(), total, buckets: range .day_keys() .into_iter() .map(|day_key| AdminDashboardChartBucketPayload { key: admin_dashboard_date_label(day_key), label: admin_dashboard_bucket_label(day_key, range.granularity), value: series.value(day_key), }) .collect(), } } fn build_admin_dashboard_operations( metrics: &AdminDashboardMetricsPayload, recharged_mud_points: u64, asset_kind_breakdown: BTreeMap, module_visit_breakdown: BTreeMap, ) -> AdminDashboardOperationsPayload { AdminDashboardOperationsPayload { cards: vec![ AdminDashboardOperationMetricPayload { id: "total-registered-users".to_string(), label: "注册用户".to_string(), value: metrics.total_registered_users, unit: "人".to_string(), }, AdminDashboardOperationMetricPayload { id: "period-visit-users".to_string(), label: "访问人数".to_string(), value: metrics.visit_users, unit: "人".to_string(), }, AdminDashboardOperationMetricPayload { id: "period-generated-assets".to_string(), label: "生产素材".to_string(), value: metrics.generated_assets, unit: "个".to_string(), }, AdminDashboardOperationMetricPayload { id: "period-recharged-mud-points".to_string(), label: "充值入账泥点".to_string(), value: recharged_mud_points, unit: "泥点".to_string(), }, ], asset_kind_breakdown: top_dashboard_breakdown_rows(asset_kind_breakdown, 8), module_visit_breakdown: top_dashboard_breakdown_rows(module_visit_breakdown, 8), } } fn top_dashboard_breakdown_rows( values: BTreeMap, limit: usize, ) -> Vec { let mut rows = values.into_iter().collect::>(); rows.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); rows.into_iter() .take(limit) .map(|(key, value)| AdminDashboardBreakdownRowPayload { label: dashboard_breakdown_label(&key), key, value, }) .collect() } fn dashboard_breakdown_label(key: &str) -> String { match key { "editor_generated_image" => "画板图片".to_string(), "editor_character_image" => "角色形象".to_string(), "editor_spec_image" => "生成规范".to_string(), "editor_quick_edit_image" => "快速编辑".to_string(), "editor_ui_design_image" => "UI 设计图".to_string(), "publication-material" => "宣发素材".to_string(), "icon-spritesheet" => "图标图集".to_string(), "icon" => "图标".to_string(), "profile" => "个人中心".to_string(), "asset" => "素材".to_string(), "auth" => "认证".to_string(), "puzzle" => "拼图".to_string(), "match3d" => "抓大鹅".to_string(), "square-hole" => "方洞挑战".to_string(), "creative-agent" => "创作 Agent".to_string(), "external-generation" => "外部生成".to_string(), "unknown" => "未分类".to_string(), value => value.to_string(), } } fn warn_if_dashboard_rows_may_be_truncated(warnings: &mut Vec, label: &str, len: usize) { if len >= ADMIN_DASHBOARD_ROW_LIMIT as usize { warnings.push(format!( "{label}统计已达到单次读取上限 {} 行,展示结果可能低于真实值", ADMIN_DASHBOARD_ROW_LIMIT )); } } fn admin_dashboard_date_label(day_key: i64) -> String { module_runtime::build_analytics_date_dimension_from_date_key(day_key).calendar_date } fn admin_dashboard_bucket_label(day_key: i64, granularity: AdminDashboardGranularity) -> String { let date = admin_dashboard_date_label(day_key); match granularity { AdminDashboardGranularity::Day => date, AdminDashboardGranularity::Week => date.split('-').skip(1).collect::>().join("/"), AdminDashboardGranularity::Month => date .rsplit('-') .next() .map(ToOwned::to_owned) .unwrap_or(date), AdminDashboardGranularity::Period => date.split('-').skip(1).collect::>().join("/"), } } fn current_admin_dashboard_utc_micros() -> i64 { (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64 } impl AdminDashboardGranularity { fn as_str(self) -> &'static str { match self { Self::Day => "day", Self::Week => "week", Self::Month => "month", Self::Period => "period", } } } impl AdminDashboardRangeResolved { fn contains_day_key(&self, day_key: i64) -> bool { day_key >= self.start_date_key && day_key <= self.end_date_key } fn day_keys(&self) -> Vec { (self.start_date_key..=self.end_date_key).collect() } } impl AdminDashboardSeries { fn add(&mut self, day_key: i64, value: u64) { *self.buckets.entry(day_key).or_insert(0) = self .buckets .get(&day_key) .copied() .unwrap_or(0) .saturating_add(value); } fn value(&self, day_key: i64) -> u64 { self.buckets.get(&day_key).copied().unwrap_or(0) } fn total(&self) -> u64 { self.buckets.values().copied().sum() } } fn build_spacetime_schema_url(server_root: &str, database: &str) -> String { format!("{server_root}/v1/database/{database}/schema?{SPACETIME_SCHEMA_VERSION_QUERY}") } // 表名来自 schema,但进入 SQL 前仍做最小标识符校验,避免未来 schema 来源变化时扩大风险面。 fn is_safe_spacetime_table_name(table_name: &str) -> bool { let mut chars = table_name.chars(); let Some(first) = chars.next() else { return false; }; if !(first == '_' || first.is_ascii_alphabetic()) { return false; } chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) } // private 表在 SpacetimeDB SQL 下会表现为不可见,后台只展示可理解状态,不暴露整段 HTTP 噪音。 fn normalize_table_count_error(error: &str) -> String { let normalized = error.to_ascii_lowercase(); if normalized.contains("marked private") || normalized.contains("no such table") { return "不可统计(private 或当前身份不可见)".to_string(); } error.to_string() } async fn fetch_spacetime_json( client: &Client, url: &str, token: Option<&str>, ) -> Result, String> where T: for<'de> Deserialize<'de>, { let mut request = client.get(url); if let Some(token) = token { request = request.bearer_auth(token); } let response = request .send() .await .map_err(|error| format!("请求失败:{error}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("HTTP {}:{}", status.as_u16(), trim_preview(&body))); } response .json::() .await .map(Some) .map_err(|error| format!("响应解析失败:{error}")) } async fn fetch_spacetime_sql_count( client: &Client, server_root: &str, database: &str, token: Option<&str>, sql: &str, ) -> Result { let mut request = client .post(format!("{server_root}/v1/database/{database}/sql")) .header(CONTENT_TYPE, "text/plain; charset=utf-8") .body(sql.to_string()); if let Some(token) = token { request = request.bearer_auth(token); } let response = request .send() .await .map_err(|error| format!("SQL 请求失败:{error}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("HTTP {}:{}", status.as_u16(), trim_preview(&body))); } let payload = response .json::() .await .map_err(|error| format!("SQL 响应解析失败:{error}"))?; parse_spacetime_sql_count_response(payload) } fn parse_spacetime_sql_count_response(payload: Value) -> Result { match payload { // SpacetimeDB 2.x /sql 返回 statement result 数组,每个 result 内含 schema 与 rows。 Value::Array(statements) => { let statement = statements .into_iter() .next() .ok_or_else(|| "SQL 结果为空".to_string())?; extract_sql_count_from_statement(statement) } // 保留兼容旧对象形状,便于本地/远端 API 小版本差异时仍能读取计数。 Value::Object(statement) => extract_sql_count_from_statement(Value::Object(statement)), _ => Err("SQL 响应格式非法".to_string()), } } fn extract_sql_count_from_statement(statement: Value) -> Result { let Value::Object(mut statement) = statement else { return Err("SQL statement 结果格式非法".to_string()); }; let schema = statement.remove("schema"); let rows = statement .remove("rows") .ok_or_else(|| "SQL 响应缺少 rows 字段".to_string())?; extract_sql_count_from_rows(rows, schema.as_ref()) } fn extract_sql_count_from_rows(rows: Value, schema: Option<&Value>) -> Result { let Value::Array(rows) = rows else { return Err("SQL rows 字段格式非法".to_string()); }; let row = rows.first().ok_or_else(|| "SQL 结果为空".to_string())?; extract_sql_count_from_row(row, schema) } fn extract_sql_count_from_row(row: &Value, schema: Option<&Value>) -> Result { match row { Value::Object(columns) => extract_sql_count(columns), Value::Array(values) => { let count_index = schema.and_then(find_sql_count_column_index).unwrap_or(0); values .get(count_index) .ok_or_else(|| "SQL 结果缺少 count 字段".to_string()) .and_then(parse_count_value) } value => parse_count_value(value), } } fn extract_sql_count(columns: &serde_json::Map) -> Result { for key in ["row_count", "count", "COUNT(*)"] { if let Some(value) = columns.get(key) { return parse_count_value(value); } } columns .values() .next() .ok_or_else(|| "SQL 结果缺少 count 字段".to_string()) .and_then(parse_count_value) } fn find_sql_count_column_index(schema: &Value) -> Option { let elements = schema.get("elements")?.as_array()?; elements.iter().position(|element| { element .get("name") .and_then(extract_sql_schema_name) .map(|name| matches!(name, "row_count" | "count" | "COUNT(*)")) .unwrap_or(false) }) } fn extract_sql_schema_name(value: &Value) -> Option<&str> { match value { Value::String(text) => Some(text.as_str()), Value::Object(object) => object.get("some").and_then(Value::as_str), _ => None, } } fn parse_count_value(value: &Value) -> Result { match value { Value::Number(number) => number .as_u64() .ok_or_else(|| "count 字段不是无符号整数".to_string()), Value::String(text) => text .trim() .parse::() .map_err(|error| format!("count 字段解析失败:{error}")), _ => Err("count 字段类型非法".to_string()), } } async fn fetch_admin_database_table_list( state: &AppState, ) -> Result { let (_, tables, fetch_errors) = fetch_admin_database_schema_tables(state).await; Ok(AdminDatabaseTableListResponse { tables, fetch_errors, }) } async fn fetch_admin_database_table_rows( state: &AppState, table_name: &str, query: AdminDatabaseTableRowsQuery, ) -> Result { let table_name = table_name.trim(); if !is_safe_spacetime_table_name(table_name) { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("表名不合法")); } let (_, tables, _) = fetch_admin_database_schema_tables(state).await; if !tables.iter().any(|name| name == table_name) { return Err(AppError::from_status(StatusCode::NOT_FOUND).with_message("表不存在")); } let client = Client::builder() .timeout(ADMIN_DATABASE_TABLE_REQUEST_TIMEOUT) .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_message(format!("表查询客户端初始化失败:{error}")) })?; let server_root = state.config.spacetime_server_url.trim_end_matches('/'); let database = state.config.spacetime_database.trim(); let token = resolve_admin_spacetime_sql_token(state); let limit = clamp_admin_database_table_limit(query.limit); let sql_limit = resolve_admin_database_table_sql_limit(); let sql = format!("SELECT * FROM {table_name} LIMIT {sql_limit}"); let payload = fetch_spacetime_sql_json_limited( &client, server_root, database, token.as_deref(), &sql, ADMIN_DATABASE_TABLE_MAX_RESPONSE_BYTES, ) .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!( "表数据读取失败:{}", normalize_table_count_error(&error) )) })?; let response = parse_admin_database_table_rows_sql_response(table_name, limit, payload) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY) .with_message(format!("表数据解析失败:{error}")) })?; finalize_admin_database_table_rows_response(response, &query) } async fn fetch_admin_database_schema_tables( state: &AppState, ) -> (Option, Vec, Vec) { let client = Client::new(); let server_root = state.config.spacetime_server_url.trim_end_matches('/'); let database = state.config.spacetime_database.trim(); let token = resolve_admin_spacetime_sql_token(state); let mut fetch_errors = Vec::new(); let schema = fetch_spacetime_json::( &client, &build_spacetime_schema_url(server_root, database), token.as_deref(), ) .await .map_err(|error| fetch_errors.push(format!("数据库 schema 读取失败:{error}"))) .ok() .flatten(); let tables = extract_schema_table_names(schema.as_ref()); (schema, tables, fetch_errors) } fn extract_schema_table_names(schema: Option<&SpacetimeSchemaResponse>) -> Vec { schema .and_then(|value| value.tables.as_ref()) .map(|tables| { tables .iter() .filter_map(|table| table.name.as_deref()) .map(str::trim) .filter(|name| !name.is_empty()) .map(ToOwned::to_owned) .collect::>() .into_iter() .collect::>() }) .unwrap_or_default() } fn resolve_admin_spacetime_sql_token(state: &AppState) -> Option { state .config .spacetime_token .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .or_else(load_local_spacetime_cli_token) } fn clamp_admin_database_table_limit(limit: Option) -> u32 { limit .unwrap_or(ADMIN_DATABASE_TABLE_DEFAULT_LIMIT) .clamp(1, ADMIN_DATABASE_TABLE_MAX_LIMIT) } fn clamp_admin_database_table_page(page: Option) -> u32 { page.unwrap_or(1).max(1) } fn resolve_admin_database_table_sql_limit() -> u32 { ADMIN_DATABASE_TABLE_SCAN_LIMIT + 1 } fn finalize_admin_database_table_rows_response( mut response: AdminDatabaseTableRowsResponse, query: &AdminDatabaseTableRowsQuery, ) -> Result { let limit = clamp_admin_database_table_limit(query.limit); let requested_page = clamp_admin_database_table_page(query.page); let scan_limit_reached = response.rows.len() > ADMIN_DATABASE_TABLE_SCAN_LIMIT as usize; response .rows .truncate(ADMIN_DATABASE_TABLE_SCAN_LIMIT as usize); let scanned_count = response.rows.len(); apply_admin_database_table_filters(&mut response.rows, query)?; sort_admin_database_table_rows(&mut response.rows, &response.columns, query)?; let total_matched = response.rows.len(); let total_pages = total_matched.div_ceil(limit as usize).max(1); let page = requested_page.min(total_pages as u32); let start = (page.saturating_sub(1) as usize).saturating_mul(limit as usize); response.rows = response .rows .into_iter() .skip(start) .take(limit as usize) .collect(); response.total_returned = response.rows.len(); response.total_matched = total_matched; response.limit = limit; response.page = page; response.scanned_count = scanned_count; response.scan_limit = ADMIN_DATABASE_TABLE_SCAN_LIMIT; response.scan_limit_reached = scan_limit_reached; Ok(response) } fn sort_admin_database_table_rows( rows: &mut Vec, columns: &[String], query: &AdminDatabaseTableRowsQuery, ) -> Result<(), AppError> { let sort_column = normalized_non_empty(query.sort_column.as_deref()); let sort_direction = normalized_non_empty(query.sort_direction.as_deref()); let Some(sort_column) = sort_column else { if sort_direction.is_some() { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("sortDirection 必须与 sortColumn 一起使用")); } rows.sort_by_cached_key(admin_database_table_row_stable_key); return Ok(()); }; if !columns.iter().any(|column| column == sort_column) { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message(format!("排序字段不存在:{sort_column}"))); } let descending = match sort_direction .unwrap_or("asc") .to_ascii_lowercase() .as_str() { "asc" => false, "desc" => true, _ => { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("sortDirection 必须是 asc 或 desc")); } }; let mut keyed_rows = rows .drain(..) .map(|row| { let sort_value = row .cells .get(sort_column) .map(admin_database_table_sort_value) .unwrap_or(AdminDatabaseTableSortValue::Empty); let stable_key = admin_database_table_row_stable_key(&row); (row, sort_value, stable_key) }) .collect::>(); keyed_rows.sort_by(|left, right| { compare_admin_database_table_sort_values(&left.1, &right.1, descending) .then_with(|| left.2.cmp(&right.2)) }); rows.extend(keyed_rows.into_iter().map(|(row, _, _)| row)); Ok(()) } #[derive(Debug)] enum AdminDatabaseTableSortValue { Number(f64), Boolean(bool), Text(String), Empty, } fn admin_database_table_sort_value(value: &Value) -> AdminDatabaseTableSortValue { match value { Value::Null => AdminDatabaseTableSortValue::Empty, Value::Number(number) => number .as_f64() .map(AdminDatabaseTableSortValue::Number) .unwrap_or_else(|| AdminDatabaseTableSortValue::Text(number.to_string())), Value::Bool(value) => AdminDatabaseTableSortValue::Boolean(*value), Value::String(value) if value.trim().is_empty() => AdminDatabaseTableSortValue::Empty, Value::String(value) => AdminDatabaseTableSortValue::Text(value.trim().to_lowercase()), value => AdminDatabaseTableSortValue::Text(value.to_string().to_lowercase()), } } fn compare_admin_database_table_sort_values( left: &AdminDatabaseTableSortValue, right: &AdminDatabaseTableSortValue, descending: bool, ) -> Ordering { if matches!(left, AdminDatabaseTableSortValue::Empty) { return if matches!(right, AdminDatabaseTableSortValue::Empty) { Ordering::Equal } else { Ordering::Greater }; } if matches!(right, AdminDatabaseTableSortValue::Empty) { return Ordering::Less; } let ordering = match (left, right) { (AdminDatabaseTableSortValue::Number(left), AdminDatabaseTableSortValue::Number(right)) => { left.partial_cmp(right).unwrap_or(Ordering::Equal) } ( AdminDatabaseTableSortValue::Boolean(left), AdminDatabaseTableSortValue::Boolean(right), ) => left.cmp(right), (AdminDatabaseTableSortValue::Text(left), AdminDatabaseTableSortValue::Text(right)) => { left.cmp(right) } _ => admin_database_table_sort_kind(left).cmp(&admin_database_table_sort_kind(right)), }; if descending { ordering.reverse() } else { ordering } } fn admin_database_table_sort_kind(value: &AdminDatabaseTableSortValue) -> u8 { match value { AdminDatabaseTableSortValue::Number(_) => 0, AdminDatabaseTableSortValue::Boolean(_) => 1, AdminDatabaseTableSortValue::Text(_) => 2, AdminDatabaseTableSortValue::Empty => 3, } } fn admin_database_table_row_stable_key(row: &AdminDatabaseTableRowPayload) -> String { serde_json::to_string(&row.raw).unwrap_or_else(|_| row.cells.to_string()) } fn parse_admin_database_table_rows_sql_response( table_name: &str, limit: u32, payload: Value, ) -> Result { let statement = extract_first_sql_statement(payload)?; let columns = extract_sql_statement_columns(&statement); let rows_value = statement .get("rows") .ok_or_else(|| "SQL 响应缺少 rows 字段".to_string())?; let row_values = rows_value .as_array() .ok_or_else(|| "SQL rows 字段格式非法".to_string())?; let rows = row_values .iter() .map(|row| build_admin_database_table_row_for_table(table_name, row, &columns)) .collect::>(); Ok(AdminDatabaseTableRowsResponse { table_name: table_name.to_string(), columns, total_returned: rows.len(), total_matched: rows.len(), page: 1, scanned_count: rows.len(), scan_limit: ADMIN_DATABASE_TABLE_SCAN_LIMIT, scan_limit_reached: false, rows, limit, }) } fn extract_first_sql_statement(payload: Value) -> Result { match payload { Value::Array(statements) => statements .into_iter() .next() .ok_or_else(|| "SQL 结果为空".to_string()), Value::Object(statement) => Ok(Value::Object(statement)), _ => Err("SQL 响应格式非法".to_string()), } } fn extract_sql_statement_columns(statement: &Value) -> Vec { statement .get("schema") .and_then(|schema| schema.get("elements")) .and_then(Value::as_array) .map(|elements| { elements .iter() .enumerate() .map(|(index, element)| { element .get("name") .and_then(extract_sql_schema_name) .map(ToOwned::to_owned) .unwrap_or_else(|| format!("col_{}", index + 1)) }) .collect::>() }) .unwrap_or_default() } #[cfg(test)] fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload { build_admin_database_table_row_for_table("", row, columns) } fn build_admin_database_table_row_for_table( table_name: &str, row: &Value, columns: &[String], ) -> AdminDatabaseTableRowPayload { let raw = normalize_admin_database_table_row_raw(table_name, row, columns); let mut cells = Map::new(); if let Some(values) = row.as_array() { for (index, value) in values.iter().enumerate() { let key = columns .get(index) .cloned() .unwrap_or_else(|| format!("col_{}", index + 1)); cells.insert( key.clone(), normalize_admin_database_table_cell(table_name, &key, value), ); } } else if let Some(object) = row.as_object() { for (key, value) in object { cells.insert( key.clone(), normalize_admin_database_table_cell(table_name, key, value), ); } } AdminDatabaseTableRowPayload { cells: Value::Object(cells), raw, } } fn normalize_admin_database_table_row_raw( table_name: &str, row: &Value, columns: &[String], ) -> Value { if let Some(values) = row.as_array() { return Value::Array( values .iter() .enumerate() .map(|(index, value)| { let key = columns.get(index).map(String::as_str).unwrap_or_default(); normalize_admin_database_table_cell(table_name, key, value) }) .collect(), ); } if let Some(object) = row.as_object() { return Value::Object( object .iter() .map(|(key, value)| { ( key.clone(), normalize_admin_database_table_cell(table_name, key, value), ) }) .collect(), ); } normalize_admin_database_value(row) } fn normalize_admin_database_table_cell( table_name: &str, column_name: &str, value: &Value, ) -> Value { if let Some(enum_value) = normalize_admin_database_known_enum(table_name, column_name, value) { return enum_value; } normalize_admin_database_value(value) } fn normalize_admin_database_known_enum( table_name: &str, column_name: &str, value: &Value, ) -> Option { let variant_index = extract_sats_enum_variant_index(value)?; let label = match (table_name, column_name) { ("profile_recharge_order", "kind") => match variant_index { 0 => "points", 1 => "membership", _ => return None, }, ("profile_recharge_order", "status") => match variant_index { 0 => "pending", 1 => "paid", 2 => "failed", 3 => "closed", 4 => "refunded", 5 => "expired", _ => return None, }, _ => return None, }; Some(Value::String(label.to_string())) } fn extract_sats_enum_variant_index(value: &Value) -> Option { let items = value.as_array()?; if items.len() != 2 { return None; } items.first()?.as_u64() } fn normalize_admin_database_value(value: &Value) -> Value { match value { Value::Array(items) if items.len() == 1 => normalize_admin_database_value(&items[0]), Value::Array(items) if items.len() == 2 => { if let Some(index) = items.first().and_then(Value::as_u64) { if index == 0 { return items .get(1) .map(normalize_admin_database_value) .unwrap_or(Value::Null); } if index == 1 && items.get(1).and_then(Value::as_array).is_some() { return Value::Null; } } Value::Array(items.iter().map(normalize_admin_database_value).collect()) } Value::Array(items) => { Value::Array(items.iter().map(normalize_admin_database_value).collect()) } Value::Object(object) => { if let Some(value) = object.get("some") { return normalize_admin_database_value(value); } Value::Object( object .iter() .map(|(key, value)| (key.clone(), normalize_admin_database_value(value))) .collect(), ) } _ => value.clone(), } } fn apply_admin_database_table_filters( rows: &mut Vec, query: &AdminDatabaseTableRowsQuery, ) -> Result<(), AppError> { if let Some(search) = normalized_non_empty(query.search.as_deref()) { let needle = search.to_ascii_lowercase(); rows.retain(|row| row.cells.to_string().to_ascii_lowercase().contains(&needle)); } if let Some(filters) = normalized_non_empty(query.filters.as_deref()) { let parsed = serde_json::from_str::(filters).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST) .with_message(format!("筛选 JSON 解析失败:{error}")) })?; let object = parsed.as_object().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST) .with_message("筛选条件必须是 JSON object") })?; rows.retain(|row| row_matches_admin_database_filters(row, object)); } Ok(()) } fn row_matches_admin_database_filters( row: &AdminDatabaseTableRowPayload, filters: &Map, ) -> bool { let Some(cells) = row.cells.as_object() else { return filters.is_empty(); }; filters.iter().all(|(key, expected)| { cells .get(key) .map(|actual| admin_database_filter_value_matches(actual, expected)) .unwrap_or(false) }) } fn admin_database_filter_value_matches(actual: &Value, expected: &Value) -> bool { if actual == expected { return true; } if let Some(expected_text) = expected.as_str() { return value_to_string(actual) .map(|actual_text| actual_text == expected_text) .unwrap_or(false); } false } async fn fetch_admin_tracking_events( state: &AppState, query: AdminTrackingEventListQuery, ) -> Result, AppError> { let client = Client::new(); let server_root = state.config.spacetime_server_url.trim_end_matches('/'); let database = state.config.spacetime_database.trim(); let token = state .config .spacetime_token .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .or_else(load_local_spacetime_cli_token); let sql = build_admin_tracking_events_sql(&query) .map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?; let payload = fetch_spacetime_sql_json(&client, server_root, database, token.as_deref(), &sql) .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY) .with_message(format!("埋点数据读取失败:{error}")) })?; parse_admin_tracking_events_sql_response(payload).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY) .with_message(format!("埋点数据解析失败:{error}")) }) } async fn fetch_admin_tracking_event_keys( state: &AppState, ) -> Result, AppError> { let client = Client::new(); let server_root = state.config.spacetime_server_url.trim_end_matches('/'); let database = state.config.spacetime_database.trim(); let token = state .config .spacetime_token .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .or_else(load_local_spacetime_cli_token); let sql = build_admin_tracking_event_keys_sql(); let payload = fetch_spacetime_sql_json(&client, server_root, database, token.as_deref(), &sql) .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY) .with_message(format!("埋点 key 读取失败:{error}")) })?; parse_admin_tracking_event_keys_sql_response(payload).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY) .with_message(format!("埋点 key 解析失败:{error}")) }) } fn build_admin_tracking_events_sql(query: &AdminTrackingEventListQuery) -> Result { let mut conditions = Vec::new(); let start_date_key = normalized_non_empty(query.start_date.as_deref()) .map(|value| parse_admin_tracking_date_key(value, "startDate")) .transpose()?; let end_date_key = normalized_non_empty(query.end_date.as_deref()) .map(|value| parse_admin_tracking_date_key(value, "endDate")) .transpose()?; if matches!( (start_date_key, end_date_key), (Some(start_date_key), Some(end_date_key)) if start_date_key > end_date_key ) { return Err("startDate 不能晚于 endDate".to_string()); } if let Some(value) = normalized_non_empty(query.event_key.as_deref()) { conditions.push(format!("event_key = {}", quote_sql_string(value))); } if let Some(value) = normalized_non_empty(query.user_id.as_deref()) { conditions.push(format!("user_id = {}", quote_sql_string(value))); } if let Some(value) = normalized_non_empty(query.scope_kind.as_deref()) { let scope_kind = normalize_admin_tracking_scope_kind(value)?; conditions.push(format!("scope_kind = {}", quote_sql_string(scope_kind))); } if let Some(value) = normalized_non_empty(query.scope_id.as_deref()) { conditions.push(format!("scope_id = {}", quote_sql_string(value))); } if let Some(value) = start_date_key { conditions.push(format!("day_key >= {value}")); } if let Some(value) = end_date_key { conditions.push(format!("day_key <= {value}")); } let where_clause = if conditions.is_empty() { String::new() } else { format!(" WHERE {}", conditions.join(" AND ")) }; let limit = clamp_admin_tracking_event_limit(query.limit, query.export_all.unwrap_or(false)); Ok(format!( "SELECT event_id, event_key, scope_kind, scope_id, day_key, user_id, owner_user_id, profile_id, module_key, metadata_json, occurred_at FROM tracking_event{where_clause} LIMIT {limit}" )) } fn build_admin_tracking_event_keys_sql() -> String { format!( "SELECT event_key, scope_kind FROM tracking_event LIMIT {}", ADMIN_TRACKING_EVENT_KEY_SCAN_LIMIT ) } fn parse_admin_tracking_date_key(value: &str, field_name: &str) -> Result { module_runtime::parse_analytics_calendar_date_key(value) .map_err(|_| format!("{field_name} 必须是 YYYY-MM-DD")) } fn normalized_non_empty(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } fn load_local_spacetime_cli_token() -> Option { // 本地开发清库后会通过 `/v1/identity` 重新登录 CLI;这里复用 CLI token,确保 SQL 可读取 private 表。 let content = fs::read_to_string(".spacetimedb/local/config/cli.toml") .or_else(|_| fs::read_to_string("server-rs/.spacetimedb/local/config/cli.toml")) .ok()?; content.lines().find_map(|line| { let value = line.trim().strip_prefix("spacetimedb_token = ")?; Some(value.trim().trim_matches('"').to_string()).filter(|token| !token.is_empty()) }) } fn quote_sql_string(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } fn normalize_admin_tracking_scope_kind(value: &str) -> Result<&'static str, String> { match value.trim().to_ascii_lowercase().as_str() { "site" => Ok("site"), "work" => Ok("work"), "module" => Ok("module"), "user" => Ok("user"), _ => Err("scopeKind 必须是 site/work/module/user".to_string()), } } fn clamp_admin_tracking_event_limit(limit: Option, export_all: bool) -> u32 { let max_limit = if export_all { ADMIN_TRACKING_EVENT_EXPORT_LIMIT } else { ADMIN_TRACKING_EVENT_MAX_LIMIT }; limit .unwrap_or(ADMIN_TRACKING_EVENT_DEFAULT_LIMIT) .clamp(1, max_limit) } async fn fetch_spacetime_sql_json( client: &Client, server_root: &str, database: &str, token: Option<&str>, sql: &str, ) -> Result { let mut request = client .post(format!("{server_root}/v1/database/{database}/sql")) .header(CONTENT_TYPE, "text/plain; charset=utf-8") .body(sql.to_string()); if let Some(token) = token { request = request.bearer_auth(token); } let response = request .send() .await .map_err(|error| format!("SQL 请求失败:{error}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("HTTP {}:{}", status.as_u16(), trim_preview(&body))); } response .json::() .await .map_err(|error| format!("SQL 响应解析失败:{error}")) } async fn fetch_spacetime_sql_json_limited( client: &Client, server_root: &str, database: &str, token: Option<&str>, sql: &str, max_response_bytes: usize, ) -> Result { let mut request = client .post(format!("{server_root}/v1/database/{database}/sql")) .header(CONTENT_TYPE, "text/plain; charset=utf-8") .timeout(ADMIN_DATABASE_TABLE_REQUEST_TIMEOUT) .body(sql.to_string()); if let Some(token) = token { request = request.bearer_auth(token); } let mut response = request .send() .await .map_err(|error| format!("SQL 请求失败:{error}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("HTTP {}:{}", status.as_u16(), trim_preview(&body))); } if response .content_length() .is_some_and(|length| length > max_response_bytes as u64) { return Err(format!( "SQL 响应超过 {} MiB 上限", max_response_bytes / (1024 * 1024) )); } let mut body = Vec::new(); while let Some(chunk) = response .chunk() .await .map_err(|error| format!("SQL 响应读取失败:{error}"))? { append_spacetime_sql_response_chunk(&mut body, &chunk, max_response_bytes)?; } serde_json::from_slice::(&body).map_err(|error| format!("SQL 响应解析失败:{error}")) } fn append_spacetime_sql_response_chunk( body: &mut Vec, chunk: &[u8], max_response_bytes: usize, ) -> Result<(), String> { if body.len().saturating_add(chunk.len()) > max_response_bytes { return Err(format!( "SQL 响应超过 {} MiB 上限", max_response_bytes / (1024 * 1024) )); } body.extend_from_slice(chunk); Ok(()) } fn parse_admin_tracking_events_sql_response( payload: Value, ) -> Result, String> { let rows = extract_first_sql_rows(payload)?; rows.iter() .map(parse_admin_tracking_event_row) .collect::, _>>() .map(|mut entries| { // SpacetimeDB 2.2 的 HTTP SQL 暂不支持 ORDER BY;后台在 API 层按发生时间倒序收口。 entries.sort_by(|left, right| right.occurred_at.cmp(&left.occurred_at)); entries }) } fn parse_admin_tracking_event_keys_sql_response( payload: Value, ) -> Result, String> { let rows = extract_first_sql_rows(payload)?; let mut by_event_key: BTreeMap> = BTreeMap::new(); for row in rows { let columns = row .as_array() .ok_or_else(|| "埋点 key 行格式非法".to_string())?; let event_key = required_string_column(columns, 0, "event_key")?; let scope_kind = tracking_scope_kind_to_string( columns .get(1) .ok_or_else(|| "埋点 key 行缺少 scope_kind".to_string())?, ) .ok_or_else(|| "埋点 key 行 scope_kind 类型非法".to_string())?; by_event_key .entry(event_key) .or_default() .insert(scope_kind); } Ok(by_event_key .into_iter() .map(|(event_key, scope_kinds)| AdminTrackingEventKeyPayload { event_title: admin_tracking_event_title(&event_key).to_string(), event_key, scope_kinds: scope_kinds.into_iter().collect(), }) .collect()) } fn extract_first_sql_rows(payload: Value) -> Result, String> { let statement = match payload { Value::Array(statements) => statements .into_iter() .next() .ok_or_else(|| "SQL 结果为空".to_string())?, Value::Object(statement) => Value::Object(statement), _ => return Err("SQL 响应格式非法".to_string()), }; let Value::Object(mut statement) = statement else { return Err("SQL statement 结果格式非法".to_string()); }; let rows = statement .remove("rows") .ok_or_else(|| "SQL 响应缺少 rows 字段".to_string())?; match rows { Value::Array(rows) => Ok(rows), _ => Err("SQL rows 字段格式非法".to_string()), } } fn parse_admin_tracking_event_row(row: &Value) -> Result { let columns = row.as_array().ok_or_else(|| "埋点行格式非法".to_string())?; let event_key = required_string_column(columns, 1, "event_key")?; Ok(AdminTrackingEventEntryPayload { event_id: required_string_column(columns, 0, "event_id")?, event_title: admin_tracking_event_title(&event_key).to_string(), event_key, scope_kind: tracking_scope_kind_to_string( columns .get(2) .ok_or_else(|| "埋点行缺少 scope_kind".to_string())?, ) .ok_or_else(|| "埋点行 scope_kind 类型非法".to_string())?, scope_id: required_string_column(columns, 3, "scope_id")?, day_key: required_i64_column(columns, 4, "day_key")?, user_id: optional_string_column(columns, 5), owner_user_id: optional_string_column(columns, 6), profile_id: optional_string_column(columns, 7), module_key: optional_string_column(columns, 8), metadata_json: required_string_column(columns, 9, "metadata_json")?, occurred_at: timestamp_to_display_string( columns .get(10) .ok_or_else(|| "埋点行缺少 occurred_at".to_string())?, ) .ok_or_else(|| "埋点行 occurred_at 不是字符串".to_string())?, }) } fn required_string_column( columns: &[Value], index: usize, field_name: &str, ) -> Result { value_to_string( columns .get(index) .ok_or_else(|| format!("埋点行缺少 {field_name}"))?, ) .ok_or_else(|| format!("埋点行 {field_name} 不是字符串")) } fn optional_string_column(columns: &[Value], index: usize) -> Option { columns.get(index).and_then(value_to_string) } fn required_i64_column(columns: &[Value], index: usize, field_name: &str) -> Result { let value = columns .get(index) .ok_or_else(|| format!("埋点行缺少 {field_name}"))?; value_to_i64(value).ok_or_else(|| format!("埋点行 {field_name} 类型非法")) } fn value_to_string(value: &Value) -> Option { match value { Value::Null => None, Value::String(text) => Some(text.clone()), Value::Object(object) => object.get("some").and_then(value_to_string), Value::Number(number) => Some(number.to_string()), Value::Bool(value) => Some(value.to_string()), Value::Array(items) => value_array_to_string(items), } } fn value_array_to_string(items: &[Value]) -> Option { if items.len() == 2 { if let Some(index) = items.first().and_then(Value::as_u64) { if index == 0 { return items.get(1).and_then(value_to_string); } if index == 1 && items.get(1).and_then(Value::as_array).is_some() { return None; } } } Some(Value::Array(items.to_vec()).to_string()) } fn value_to_i64(value: &Value) -> Option { match value { Value::Number(number) => number .as_i64() .or_else(|| number.as_u64().and_then(|value| i64::try_from(value).ok())), Value::String(text) => text.trim().parse::().ok(), Value::Object(object) => object.get("some").and_then(value_to_i64), Value::Array(items) if items.len() == 1 => items.first().and_then(value_to_i64), Value::Array(items) if items.len() == 2 => { let tag = items.first().and_then(Value::as_u64)?; if tag == 0 { items.get(1).and_then(value_to_i64) } else { None } } _ => None, } } fn timestamp_value_to_micros(value: &Value) -> Option { value_to_i64(value).or_else(|| { let text = value_to_string(value)?; parse_timestamp_text_to_micros(&text) }) } fn parse_timestamp_text_to_micros(text: &str) -> Option { let trimmed = text.trim(); if trimmed.is_empty() { return None; } if let Ok(value) = trimmed.parse::() { return Some(value); } if let Some((seconds, micros)) = trimmed .strip_suffix('Z') .and_then(|value| value.split_once('.')) { if let Ok(seconds) = seconds.parse::() && !micros.is_empty() && micros.len() <= 6 && micros.chars().all(|character| character.is_ascii_digit()) { let micros = format!("{micros:0<6}").parse::().ok()?; let value = i128::from(seconds) .checked_mul(1_000_000)? .checked_add(micros)?; return i64::try_from(value).ok(); } } OffsetDateTime::parse(trimmed, &Rfc3339) .ok() .and_then(|timestamp| i64::try_from(timestamp.unix_timestamp_nanos() / 1_000).ok()) } fn tracking_scope_kind_to_string(value: &Value) -> Option { match value { Value::String(text) => Some(text.clone()), Value::Object(object) => object .get("tag") .or_else(|| object.get("variant")) .or_else(|| object.get("name")) .and_then(value_to_string), Value::Array(items) => { let index = items.first().and_then(Value::as_u64)?; Some( match index { 0 => "site", 1 => "work", 2 => "module", 3 => "user", _ => return Some(Value::Array(items.to_vec()).to_string()), } .to_string(), ) } _ => value_to_string(value), } } fn wallet_ledger_source_type_to_string(value: &Value) -> Option { match value { Value::String(text) => Some(normalize_wallet_ledger_source_type_text(text)), Value::Object(object) => object .get("tag") .or_else(|| object.get("variant")) .or_else(|| object.get("name")) .and_then(value_to_string) .map(|value| normalize_wallet_ledger_source_type_text(&value)), Value::Array(items) => { let index = items.first().and_then(Value::as_u64)?; Some( match index { 0 => "snapshot_sync", 1 => "new_user_registration_reward", 2 => "invite_inviter_reward", 3 => "invite_invitee_reward", 4 => "points_recharge", 5 => "asset_operation_consume", 6 => "asset_operation_refund", 7 => "redeem_code_reward", 8 => "puzzle_author_incentive_claim", 9 => "daily_task_reward", _ => return Some(Value::Array(items.to_vec()).to_string()), } .to_string(), ) } _ => value_to_string(value).map(|value| normalize_wallet_ledger_source_type_text(&value)), } } fn normalize_wallet_ledger_source_type_text(value: &str) -> String { let trimmed = value.trim(); let mut normalized = String::new(); for (index, character) in trimmed.chars().enumerate() { if character.is_ascii_uppercase() { if index > 0 { normalized.push('_'); } normalized.push(character.to_ascii_lowercase()); } else if character == '-' || character == ' ' { normalized.push('_'); } else { normalized.push(character); } } normalized } fn timestamp_to_display_string(value: &Value) -> Option { match value { Value::Array(items) if items.len() == 1 => items.first().and_then(value_to_string), _ => value_to_string(value), } } fn admin_tracking_event_title(event_key: &str) -> &str { match event_key { "auth_login_options_view" => "登录方式查看", "auth_phone_code_send" => "发送手机验证码", "daily_login" => "每日登录", "auth_phone_login_success" => "手机号登录成功", "auth_me_view" => "当前账号查看", "auth_sessions_view" => "登录会话查看", "auth_revoke_session" => "撤销登录会话", "auth_refresh_success" => "登录续期成功", "auth_logout" => "退出登录", "auth_logout_all" => "退出全部会话", "auth_wechat_bind_phone_success" => "微信绑定手机成功", "profile_identity_update" => "资料更新", "profile_dashboard_view" => "个人看板查看", "wallet_ledger_view" => "钱包流水查看", "recharge_center_view" => "充值中心查看", "recharge_order_create" => "充值订单创建", "feedback_submit" => "反馈提交", "invite_center_view" => "邀请中心查看", "referral_invite_code_redeem" => "邀请码绑定", "redeem_code_submit" => "兑换码提交", "task_center_view" => "任务中心查看", "task_reward_claim" => "任务奖励领取", "save_archive_list_view" => "存档列表查看", "save_archive_detail_view" => "存档详情查看", "browse_history_view" => "浏览历史查看", "browse_history_record" => "浏览历史写入", "browse_history_clear" => "浏览历史清空", "play_stats_view" => "游玩统计查看", "profile_analytics_metric_view" => "个人指标查看", "ai_task_create" => "AI 任务创建", "ai_task_start" => "AI 任务启动", "ai_task_stage_start" => "AI 阶段启动", "ai_task_chunk_append" => "AI 分片追加", "ai_task_stage_complete" => "AI 阶段完成", "ai_task_reference_attach" => "AI 结果引用绑定", "ai_task_complete" => "AI 任务完成", "ai_task_fail" => "AI 任务失败标记", "ai_task_cancel" => "AI 任务取消", "asset_upload_ticket_create" => "资产上传票据创建", "asset_sts_credentials_create" => "资产 STS 凭证创建", "asset_upload_confirm" => "资产上传确认", "asset_bind" => "资产绑定", "asset_character_visual_generate" => "角色形象生成", "asset_character_visual_publish" => "角色形象发布", "asset_character_animation_generate" => "角色动画生成", "asset_character_animation_publish" => "角色动画发布", "asset_character_animation_import" => "角色动画视频导入", "asset_character_workflow_cache_save" => "角色工作流缓存保存", "asset_history_view" => "资产历史查看", "llm_request" => "LLM 请求", "speech_config_view" => "语音配置查看", "asr_stream_start" => "ASR 流启动", "tts_bidirection_start" => "TTS 双向流启动", "tts_sse_start" => "TTS SSE 启动", "runtime_settings_view" => "运行设置查看", "runtime_settings_update" => "运行设置更新", "runtime_snapshot_view" => "运行快照查看", "runtime_snapshot_save" => "运行快照保存", "runtime_snapshot_delete" => "运行快照删除", "puzzle_route_success" => "拼图路由成功", "match3d_route_success" => "抓大鹅路由成功", "square_hole_route_success" => "方洞路由成功", "custom_world_route_success" => "自定义世界路由成功", "creative_agent_route_success" => "创意 Agent 路由成功", "work_play_start" => "作品开始游玩", "external_generation_run" => "外部生成执行", _ => event_key, } } async fn execute_admin_debug_http( state: &AppState, payload: AdminDebugHttpRequest, ) -> Result { // 调试请求始终回打当前 api-server,同源受控,不允许作为外部代理使用。 let method = Method::from_bytes(payload.method.trim().as_bytes()).map_err(|_| { AppError::from_status(StatusCode::BAD_REQUEST).with_message("HTTP 方法不合法") })?; let path = normalize_debug_path(&payload.path)?; let base_url = build_debug_base_url(&state.config.bind_host, state.config.bind_port); let target_url = format!("{base_url}{path}"); let body_text = payload.body.unwrap_or_default(); if body_text.len() > MAX_DEBUG_BODY_BYTES { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试请求体超过长度限制") ); } let client = Client::new(); let mut request = client.request(method, &target_url); if !body_text.is_empty() { request = request.body(body_text.clone()); } for header in payload.headers.unwrap_or_default() { let header_name = header.name.trim().to_ascii_lowercase(); if BLOCKED_DEBUG_HEADERS .iter() .any(|blocked| *blocked == header_name) { continue; } let name = HeaderName::from_bytes(header_name.as_bytes()).map_err(|_| { AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试请求头名称不合法") })?; let value = HeaderValue::from_str(header.value.trim()).map_err(|_| { AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试请求头值不合法") })?; request = request.header(name, value); } let response = request.send().await.map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY) .with_message(format!("调试请求失败:{error}")) })?; let status = response.status(); let headers = response .headers() .iter() .map(|(name, value)| AdminDebugHeaderInput { name: name.to_string(), value: value.to_str().unwrap_or_default().to_string(), }) .collect::>(); let response_body = response.bytes().await.map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY) .with_message(format!("调试响应读取失败:{error}")) })?; let body_preview = build_body_preview(&response_body); let body_json = serde_json::from_slice::(&response_body).ok(); Ok(AdminDebugHttpResponse { status: status.as_u16(), status_text: status.canonical_reason().unwrap_or("Unknown").to_string(), headers, body_text: body_preview, body_json, }) } fn build_debug_base_url(bind_host: &str, bind_port: u16) -> String { let debug_host = resolve_debug_host(bind_host); let authority_host = format_http_authority_host(&debug_host); format!("http://{authority_host}:{bind_port}") } fn resolve_debug_host(bind_host: &str) -> String { let trimmed = bind_host.trim(); if trimmed.is_empty() { return Ipv4Addr::LOCALHOST.to_string(); } match trimmed.parse::() { Ok(IpAddr::V4(ip)) if ip.is_unspecified() => Ipv4Addr::LOCALHOST.to_string(), Ok(IpAddr::V6(ip)) if ip.is_unspecified() => Ipv6Addr::LOCALHOST.to_string(), Ok(ip) => ip.to_string(), Err(_) => trimmed.to_string(), } } fn format_http_authority_host(host: &str) -> String { if host.starts_with('[') && host.ends_with(']') { return host.to_string(); } if host.parse::().is_ok() { return format!("[{host}]"); } host.to_string() } fn normalize_debug_path(path: &str) -> Result { // 只允许 `/xxx` 形式的同源相对路径,明确拒绝绝对 URL 与后台登录接口。 let trimmed = path.trim(); if trimmed.is_empty() { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径不能为空")); } if trimmed.starts_with("http://") || trimmed.starts_with("https://") { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("只允许调试同源相对路径") ); } if !trimmed.starts_with('/') { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径必须以 / 开头") ); } if trimmed == "/admin/api/login" { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_message("禁止调试后台登录接口") ); } Ok(trimmed.to_string()) } fn build_body_preview(bytes: &[u8]) -> String { if bytes.is_empty() { return String::new(); } let text = String::from_utf8_lossy(bytes).to_string(); trim_preview(&text) } fn trim_preview(text: &str) -> String { let trimmed = text.trim(); if trimmed.chars().count() <= 4000 { return trimmed.to_string(); } trimmed.chars().take(4000).collect::() } fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSessionPayload { let account_role = if session.account_role == "owner" { AdminAccountRole::Owner } else { AdminAccountRole::Member }; let tab_permissions = if account_role == AdminAccountRole::Owner { ADMIN_TAB_PERMISSIONS .iter() .map(|permission| (*permission).to_string()) .collect() } else { session.tab_permissions }; let action_permissions = if account_role == AdminAccountRole::Owner { ADMIN_ACTION_PERMISSIONS .iter() .map(|permission| (*permission).to_string()) .collect() } else { session.action_permissions }; AdminSessionPayload { subject: session.subject, username: session.username, display_name: session.display_name, roles: session.roles, account_role, tab_permissions, action_permissions, issued_at: session .issued_at .format(&Rfc3339) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()), expires_at: session .expires_at .format(&Rfc3339) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()), } } #[cfg(test)] mod tests { use super::{ AdminDashboardGranularity, AdminDisplayNameDirectory, EditorShowcaseAssetRecord, admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result, admin_editor_asset_group_payload, admin_editor_asset_payload_from_record, admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk, apply_admin_database_table_filters, build_admin_asset_read_url_audit, build_admin_dashboard_chart, build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request, build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, clamp_admin_tracking_event_limit, enforce_admin_request_permission, finalize_admin_database_table_rows_response, group_admin_editor_asset_records, hash_admin_password, is_admin_account_not_found, is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error, paginate_admin_editor_asset_records, parse_admin_database_table_rows_sql_response, parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, parse_timestamp_text_to_micros, resolve_admin_dashboard_range, resolve_admin_dashboard_range_at, resolve_admin_database_table_sql_limit, resolve_admin_editor_asset_filters, timestamp_value_to_micros, trim_preview, validate_admin_editor_asset_cursor, verify_admin_password, wallet_ledger_source_type_to_string, }; use axum::{ http::{Method, StatusCode}, response::IntoResponse, }; use serde_json::json; use shared_contracts::admin::{ AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminDashboardQuery, AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery, }; use shared_contracts::assets::{ConfirmAssetObjectAccessPolicy, GetReadUrlQuery}; use spacetime_client::{ AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord, AdminDashboardPaymentConversionRecord, AdminDashboardRetentionRecord, AdminDashboardStatsRecord, AdminEditorAssetRecord, SpacetimeClientError, SpacetimeClientStage, }; use crate::{config::AppConfig, state::AppState}; fn test_admin_editor_showcase_asset_record(owner_user_id: &str) -> EditorShowcaseAssetRecord { EditorShowcaseAssetRecord { showcase_id: "showcase-1".to_string(), asset_id: "asset-1".to_string(), owner_user_id: owner_user_id.to_string(), label: "精选素材".to_string(), asset_object_id: None, image_src: "/generated-character-drafts/editor/spec.png".to_string(), object_key: Some("generated-character-drafts/editor/spec.png".to_string()), width: 1024, height: 1024, source_type: "generated".to_string(), prompt: None, actual_prompt: None, model: None, provider: None, task_id: None, source_resource_id: None, asset_kind: Some("character".to_string()), generation_inputs: None, thumbnail_src: None, generation_cost_mud_points: 12, refund_mud_points: 6, review_status: "pending".to_string(), display_enabled: false, like_count: 0, asset_deleted_while_pending: false, reviewed_by_admin_user_id: None, review_note: None, refund_ledger_id: None, refund_completed_at: None, submitted_at: "2026-07-04T10:00:00.000Z".to_string(), reviewed_at: None, approved_at: None, rejected_at: None, updated_at: "2026-07-04T10:00:00.000Z".to_string(), showcase_category: None, image_sequence_frames: None, image_sequence_duration_ms: None, } } fn test_admin_editor_asset_record( asset_id: &str, task_id: &str, label: &str, object_key: &str, generation_cost_mud_points: u64, ) -> AdminEditorAssetRecord { AdminEditorAssetRecord { asset_id: asset_id.to_string(), owner_user_id: "user-1".to_string(), folder_id: "project".to_string(), label: label.to_string(), asset_object_id: Some(format!("object-{asset_id}")), image_src: format!("/{object_key}"), object_key: Some(object_key.to_string()), width: 512, height: 512, source_type: "generated".to_string(), prompt: Some(label.to_string()), actual_prompt: None, model: Some("gpt-image-2".to_string()), provider: Some("VectorEngine".to_string()), task_id: Some(task_id.to_string()), group_task_id: None, asset_kind: Some("character".to_string()), generation_inputs: None, source_resource_id: None, thumbnail_src: None, generation_cost_mud_points, created_at: "2026-07-04T10:00:00Z".to_string(), updated_at: "2026-07-04T10:00:00Z".to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, } } #[test] fn admin_editor_asset_payload_keeps_internal_matting_audit_metadata() { let state = AppState::new(AppConfig::default()).expect("state should build"); let mut record = test_admin_editor_asset_record( "asset-matting", "task-matting", "抠图结果", "generated-character-drafts/editor/matting.png", 0, ); record.model = Some("BgFilter complex".to_string()); record.provider = Some("BgFilter".to_string()); record.generation_inputs = Some(json!({ "fields": [], "references": [], "screenColorHex": "#CFEFFF", "mattingProvider": "BgFilter", "mattingModel": "birefnet" })); let payload = admin_editor_asset_payload_from_record(&state, record); let value = serde_json::to_value(payload).expect("admin payload should serialize"); assert_eq!(value["model"], json!("BgFilter complex")); assert_eq!(value["provider"], json!("BgFilter")); assert_eq!( value["generationInputs"]["screenColorHex"], json!("#CFEFFF") ); assert_eq!( value["generationInputs"]["mattingProvider"], json!("BgFilter") ); assert_eq!(value["generationInputs"]["mattingModel"], json!("birefnet")); } #[test] fn admin_asset_read_url_audit_keeps_actor_and_target_without_signed_url() { let draft = build_admin_asset_read_url_audit( " admin:root ", &GetReadUrlQuery { object_key: Some(" generated-editor-videos/demo.mp4 ".to_string()), legacy_public_path: None, expire_seconds: Some(300), }, ); assert_eq!(draft.event_key, "admin_asset_read_url"); assert_eq!(draft.scope_id, "admin"); assert_eq!(draft.user_id.as_deref(), Some("admin:root")); assert_eq!(draft.metadata["adminSubject"], json!("admin:root")); assert_eq!( draft.metadata["objectKey"], json!("generated-editor-videos/demo.mp4") ); assert_eq!(draft.metadata["expireSeconds"], json!(300)); assert!(draft.metadata.get("signedUrl").is_none()); } #[test] fn campaign_image_confirm_is_scoped_to_private_activity_card_objects() { let request = build_admin_editor_showcase_campaign_image_confirm_request( AdminConfirmEditorShowcaseCampaignImageUploadRequest { bucket: " genarrative-release ".to_string(), object_key: " /generated-character-drafts/editor/showcase-campaign/current/card.png " .to_string(), content_type: " image/png ".to_string(), content_length: 1024, }, ) .expect("valid activity card upload should build a confirm request"); assert_eq!(request.bucket.as_deref(), Some("genarrative-release")); assert_eq!( request.object_key, "generated-character-drafts/editor/showcase-campaign/current/card.png" ); assert_eq!(request.content_type.as_deref(), Some("image/png")); assert_eq!(request.content_length, Some(1024)); assert_eq!(request.asset_kind, "editor_showcase_campaign_image"); assert_eq!( request.access_policy, Some(ConfirmAssetObjectAccessPolicy::Private) ); assert!(request.owner_user_id.is_none()); } #[test] fn campaign_image_confirm_rejects_objects_outside_activity_card_directory() { let error = build_admin_editor_showcase_campaign_image_confirm_request( AdminConfirmEditorShowcaseCampaignImageUploadRequest { bucket: "genarrative-release".to_string(), object_key: "generated-character-drafts/editor/asset-library/private.png" .to_string(), content_type: "image/png".to_string(), content_length: 1024, }, ) .expect_err("unrelated editor objects must not use the activity card confirm route"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); } #[test] fn campaign_image_confirm_rejects_invalid_media_and_size() { for payload in [ AdminConfirmEditorShowcaseCampaignImageUploadRequest { bucket: "genarrative-release".to_string(), object_key: "generated-character-drafts/editor/showcase-campaign/current/card.txt" .to_string(), content_type: "text/plain".to_string(), content_length: 1024, }, AdminConfirmEditorShowcaseCampaignImageUploadRequest { bucket: "genarrative-release".to_string(), object_key: "generated-character-drafts/editor/showcase-campaign/current/empty.png" .to_string(), content_type: "image/png".to_string(), content_length: 0, }, AdminConfirmEditorShowcaseCampaignImageUploadRequest { bucket: "genarrative-release".to_string(), object_key: "generated-character-drafts/editor/showcase-campaign/current/oversized.png" .to_string(), content_type: "image/png".to_string(), content_length: super::ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES + 1, }, ] { let error = build_admin_editor_showcase_campaign_image_confirm_request(payload) .expect_err("invalid activity card uploads must fail before OSS HEAD"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); } } #[test] fn admin_editor_showcase_asset_payload_resolves_author_profile() { let state = AppState::new(AppConfig::default()).expect("state should build"); state .auth_user_service() .ensure_orphan_work_owner_user("user-author-1", "author-1", "作者昵称", "SY-00000042") .expect("author should be inserted"); let mut record = test_admin_editor_showcase_asset_record("user-author-1"); record.thumbnail_src = Some("/generated-character-drafts/editor/spec-thumb.png".to_string()); let payload = admin_editor_showcase_asset_payload_from_record(&state, record); assert_eq!(payload.owner_user_id, "user-author-1"); assert_eq!(payload.author_display_name.as_deref(), Some("作者昵称")); assert_eq!( payload.author_public_user_code.as_deref(), Some("SY-00000042") ); assert_eq!( payload.thumbnail_src.as_deref(), Some("/generated-character-drafts/editor/spec-thumb.png") ); } #[test] fn admin_editor_showcase_payload_uses_formal_action_fields_only() { let state = AppState::new(AppConfig::default()).expect("state should build"); let formal_frames = json!([ { "imageSrc": "/generated/action/formal-01.png", "objectKey": "generated/action/formal-01.png", "assetObjectId": "asset-object-formal-01", "width": 192, "height": 256 }, { "imageSrc": "/generated/action/formal-02.png", "objectKey": "generated/action/formal-02.png", "assetObjectId": "asset-object-formal-02", "width": 192, "height": 256 } ]); let mut record = test_admin_editor_showcase_asset_record("user-author-1"); record.asset_kind = Some("character-animation".to_string()); record.image_sequence_frames = Some(formal_frames.clone()); record.image_sequence_duration_ms = Some(6_000); record.generation_inputs = Some(json!({ "fields": [{ "title": "动作", "value": "挥手" }] })); let payload = admin_editor_showcase_asset_payload_from_record(&state, record); assert_eq!(payload.asset_kind.as_deref(), Some("character-animation")); assert_eq!( serde_json::to_value(payload.image_sequence_frames) .expect("formal frames should serialize"), formal_frames ); assert_eq!(payload.image_sequence_duration_ms, Some(6_000)); assert_eq!( payload.generation_inputs, Some(json!({ "fields": [{ "title": "动作", "value": "挥手" }] })) ); } #[test] fn admin_editor_asset_payload_preserves_formal_character_action_metadata() { let state = AppState::new(AppConfig::default()).expect("state should build"); let frames = json!([ { "imageSrc": "/generated/action/frame-01.png", "objectKey": "generated/action/frame-01.png", "assetObjectId": "asset-object-frame-01", "width": 192, "height": 256 }, { "imageSrc": "/generated/action/frame-02.png", "objectKey": "generated/action/frame-02.png", "assetObjectId": "asset-object-frame-02", "width": 192, "height": 256 } ]); let mut record = test_admin_editor_asset_record( "action-asset", "action-task", "角色动作", "generated/action/frame-01.png", 12, ); record.asset_kind = Some("character-animation".to_string()); record.image_sequence_frames = Some(frames.clone()); record.image_sequence_duration_ms = Some(5_000); let payload = admin_editor_asset_payload_from_record(&state, record); assert_eq!( serde_json::to_value(payload.image_sequence_frames) .expect("formal frames should serialize"), frames ); assert_eq!(payload.image_sequence_duration_ms, Some(5_000)); } #[test] fn admin_editor_asset_group_nests_each_intermediate_output_under_final_asset() { let final_output = test_admin_editor_asset_record( "asset-final", "task-1", "角色抠图结果", "generated-character-drafts/editor/character-images/character-task-1.png", 0, ); let provider_source = test_admin_editor_asset_record( "asset-source", "task-1", "角色生成原图", "generated-character-drafts/editor/character-images/character-provider-source-task-1.png", 9, ); let mut other_task = test_admin_editor_asset_record( "asset-other", "task-1", "其他素材", "generated-images/editor/image-task-1.png", 3, ); other_task.owner_user_id = "user-2".to_string(); let mut groups = group_admin_editor_asset_records(vec![final_output, provider_source, other_task]); assert_eq!(groups.len(), 2); assert_eq!(groups[0].len(), 2); let state = AppState::new(AppConfig::default()).expect("state should build"); let payload = admin_editor_asset_group_payload(&state, groups.remove(0)) .expect("group payload should exist"); assert_eq!(payload.asset_id, "asset-final"); assert_eq!(payload.task_generator, "角色生成"); assert_eq!(payload.task_cost_mud_points, 9); assert_eq!(payload.generation_cost_mud_points, 0); assert_eq!(payload.children.len(), 1); assert_eq!(payload.children[0].asset_id, "asset-source"); assert_eq!(payload.children[0].asset_kind.as_deref(), Some("character")); assert_eq!(payload.children[0].generator, "角色生图"); assert_eq!(payload.children[0].generation_cost_mud_points, 9); } #[test] fn admin_editor_asset_group_uses_group_task_id_without_hiding_operation_task_id() { let final_output = test_admin_editor_asset_record( "asset-final", "vector-engine-source-task-1", "图标素材图集", "generated-icon-spritesheets/editor/spritesheet-task-1.png", 9, ); let mut manual_slice = test_admin_editor_asset_record( "asset-slice", "editor-atlas-split-operation-1", "素材 1", "generated-icon-spritesheet-assets/editor/slice-1.png", 0, ); manual_slice.group_task_id = Some("vector-engine-source-task-1".to_string()); let mut groups = group_admin_editor_asset_records(vec![final_output, manual_slice]); assert_eq!(groups.len(), 1); let state = AppState::new(AppConfig::default()).expect("state should build"); let payload = admin_editor_asset_group_payload(&state, groups.remove(0)) .expect("group payload should exist"); assert_eq!( payload.task_id.as_deref(), Some("vector-engine-source-task-1") ); assert_eq!(payload.children.len(), 1); assert_eq!( payload.children[0].task_id.as_deref(), Some("editor-atlas-split-operation-1") ); assert_eq!( payload.children[0].group_task_id.as_deref(), Some("vector-engine-source-task-1") ); } #[test] fn admin_editor_asset_filters_resolve_public_user_code_before_querying_assets() { let state = AppState::new(AppConfig::default()).expect("state should build"); state .auth_user_service() .ensure_orphan_work_owner_user("user-author-42", "author-42", "作者昵称", "SY-00000042") .expect("author should be inserted"); state .auth_user_service() .ensure_orphan_work_owner_user("user-author-43", "author-43", "另一作者", "SY-00000043") .expect("second author should be inserted"); let owner_filter = resolve_admin_editor_asset_filters( &state, Some("SY-00000042".to_string()), Some("角色".to_string()), ) .expect("owner filter should resolve") .expect("owner should exist"); assert_eq!(owner_filter.0.as_deref(), Some("user-author-42")); assert_eq!(owner_filter.1.as_deref(), Some("角色")); let keyword_filter = resolve_admin_editor_asset_filters(&state, None, Some("sy00000042".to_string())) .expect("keyword filter should resolve") .expect("owner should exist"); assert_eq!(keyword_filter.0.as_deref(), Some("user-author-42")); assert_eq!(keyword_filter.1, None); let matching_filters = resolve_admin_editor_asset_filters( &state, Some("user-author-42".to_string()), Some("SY-00000042".to_string()), ) .expect("matching filters should resolve") .expect("matching author should exist"); assert_eq!(matching_filters.0.as_deref(), Some("user-author-42")); assert_eq!(matching_filters.1, None); assert_eq!( resolve_admin_editor_asset_filters( &state, Some("user-author-42".to_string()), Some("SY-00000043".to_string()), ) .expect("conflicting filters should resolve to an empty query"), None ); assert_eq!( resolve_admin_editor_asset_filters(&state, None, Some("SY-00009999".to_string()),) .expect("missing author should produce an empty query"), None ); assert_eq!( resolve_admin_editor_asset_filters(&state, Some("SY-00009999".to_string()), None,) .expect("missing owner should produce an empty query"), None ); } #[test] fn admin_editor_asset_cursor_accepts_shared_seconds_micros_timestamp() { let mut record = test_admin_editor_asset_record( "asset-page-end", "task-page-end", "分页末项", "generated-images/editor/page-end.png", 3, ); record.created_at = "1784087532.408003Z".to_string(); assert_eq!( super::admin_editor_asset_cursor(&record).expect("cursor should build"), "1784087532408003:asset-page-end" ); assert_eq!( parse_timestamp_text_to_micros("2026-07-15T11:52:12+08:00"), Some(1_784_087_532_000_000) ); assert_eq!( parse_timestamp_text_to_micros("-9223372036855.224192Z"), Some(i64::MIN) ); record.created_at = "invalid-timestamp".to_string(); assert!(super::admin_editor_asset_cursor(&record).is_err()); } #[test] fn admin_editor_asset_cursor_rejects_malformed_input_instead_of_repeating_first_page() { assert!(validate_admin_editor_asset_cursor(None).is_ok()); assert!(validate_admin_editor_asset_cursor(Some("123:asset-1")).is_ok()); assert!(validate_admin_editor_asset_cursor(Some("invalid")).is_err()); assert!(validate_admin_editor_asset_cursor(Some("abc:asset-1")).is_err()); assert!(validate_admin_editor_asset_cursor(Some("123: ")).is_err()); } #[test] fn admin_editor_asset_page_returns_cursor_for_eighty_first_task() { let mut records = (0..81) .map(|index| { test_admin_editor_asset_record( format!("asset-{index}").as_str(), format!("task-{index}").as_str(), format!("素材 {index}").as_str(), format!("generated-images/editor/{index}.png").as_str(), 3, ) }) .collect::>(); records[79].created_at = "1784087532.408003Z".to_string(); let (groups, next_cursor) = paginate_admin_editor_asset_records(records, 80).expect("page should build"); assert_eq!(groups.len(), 80); assert_eq!(next_cursor.as_deref(), Some("1784087532408003:asset-79")); assert!( groups .iter() .flatten() .all(|record| record.asset_id != "asset-80") ); } #[test] fn normalize_debug_path_rejects_absolute_url() { let error = normalize_debug_path("https://example.com/api").expect_err("absolute url should fail"); assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); } #[test] fn normalize_debug_path_rejects_admin_login_route() { let error = normalize_debug_path("/admin/api/login").expect_err("admin login route should fail"); assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); } #[test] fn normalize_debug_path_accepts_healthz() { let path = normalize_debug_path("/healthz").expect("healthz path should pass validation"); assert_eq!(path, "/healthz"); } #[test] fn build_debug_base_url_rewrites_wildcard_ipv4_to_loopback() { let url = build_debug_base_url("0.0.0.0", 3200); assert_eq!(url, "http://127.0.0.1:3200"); } #[test] fn build_debug_base_url_wraps_ipv6_host() { let url = build_debug_base_url("::1", 3200); assert_eq!(url, "http://[::1]:3200"); } #[test] fn trim_preview_limits_length() { let text = "a".repeat(5000); assert_eq!(trim_preview(&text).chars().count(), 4000); } #[test] fn build_spacetime_schema_url_includes_required_version_query() { let url = build_spacetime_schema_url("http://127.0.0.1:3101", "xushi-p4wfr"); assert_eq!( url, "http://127.0.0.1:3101/v1/database/xushi-p4wfr/schema?version=9" ); } #[test] fn is_safe_spacetime_table_name_accepts_schema_identifiers() { assert!(is_safe_spacetime_table_name("runtime_setting")); assert!(is_safe_spacetime_table_name("_private_table")); assert!(is_safe_spacetime_table_name("AiTaskStage2")); } #[test] fn is_safe_spacetime_table_name_rejects_sql_fragments() { assert!(!is_safe_spacetime_table_name("")); assert!(!is_safe_spacetime_table_name("bad-name")); assert!(!is_safe_spacetime_table_name("1bad")); assert!(!is_safe_spacetime_table_name("runtime_setting;DROP")); } #[test] fn normalize_table_count_error_hides_private_table_http_noise() { let error = "HTTP 400:no such table: `runtime_setting`. If the table exists, it may be marked private."; assert_eq!( normalize_table_count_error(error), "不可统计(private 或当前身份不可见)" ); } #[test] fn normalize_table_count_error_keeps_other_errors() { let error = "SQL 请求失败:connection refused"; assert_eq!(normalize_table_count_error(error), error); } #[test] fn parse_spacetime_sql_count_response_accepts_statement_array_rows() { let payload = json!([ { "schema": { "elements": [ { "name": { "some": "row_count" }, "algebraic_type": { "U64": [] } } ] }, "rows": [[7]], "total_duration_micros": 116, "stats": { "rows_inserted": 0, "rows_deleted": 0, "rows_updated": 0 } } ]); let count = parse_spacetime_sql_count_response(payload).expect("statement array should parse"); assert_eq!(count, 7); } #[test] fn parse_spacetime_sql_count_response_uses_schema_column_index() { let payload = json!([ { "schema": { "elements": [ { "name": { "some": "table_name" } }, { "name": { "some": "row_count" } } ] }, "rows": [["runtime_setting", "12"]] } ]); let count = parse_spacetime_sql_count_response(payload).expect("schema column index should parse"); assert_eq!(count, 12); } #[test] fn parse_spacetime_sql_count_response_keeps_object_row_compatibility() { let payload = json!({ "rows": [ { "row_count": "3" } ] }); let count = parse_spacetime_sql_count_response(payload).expect("object row should parse"); assert_eq!(count, 3); } #[test] fn resolve_admin_dashboard_range_supports_day_week_month() { let day = resolve_admin_dashboard_range(AdminDashboardQuery { granularity: Some("day".to_string()), anchor: Some("2026-06-23".to_string()), ..Default::default() }) .expect("day range should resolve"); assert_eq!(day.granularity, AdminDashboardGranularity::Day); assert_eq!( day.start_date_key, module_runtime::parse_analytics_calendar_date_key("2026-06-23").unwrap() ); assert_eq!(day.end_date_key, day.start_date_key); let week = resolve_admin_dashboard_range(AdminDashboardQuery { granularity: Some("week".to_string()), anchor: Some("2026-06-23".to_string()), ..Default::default() }) .expect("week range should resolve"); assert_eq!( week.start_date_key, module_runtime::parse_analytics_calendar_date_key("2026-06-22").unwrap() ); assert_eq!( week.end_date_key, module_runtime::parse_analytics_calendar_date_key("2026-06-28").unwrap() ); let month = resolve_admin_dashboard_range(AdminDashboardQuery { granularity: Some("month".to_string()), anchor: Some("2026-06-23".to_string()), ..Default::default() }) .expect("month range should resolve"); assert_eq!( month.start_date_key, module_runtime::parse_analytics_calendar_date_key("2026-06-01").unwrap() ); assert_eq!( month.end_date_key, module_runtime::parse_analytics_calendar_date_key("2026-06-30").unwrap() ); } #[test] fn resolve_admin_dashboard_range_supports_custom_period() { let period = resolve_admin_dashboard_range(AdminDashboardQuery { granularity: Some("period".to_string()), start_date: Some("2026-06-10".to_string()), end_date: Some("2026-06-18".to_string()), ..Default::default() }) .expect("period range should resolve"); assert_eq!(period.granularity, AdminDashboardGranularity::Period); assert_eq!( period.start_date_key, module_runtime::parse_analytics_calendar_date_key("2026-06-10").unwrap() ); assert_eq!( period.end_date_key, module_runtime::parse_analytics_calendar_date_key("2026-06-18").unwrap() ); assert_eq!(period.anchor_date_key, period.end_date_key); } #[test] fn dashboard_user_stats_maps_authoritative_projection() { let stats = admin_dashboard_user_stats_from_record(AdminDashboardStatsRecord { total_registered_users: 591, new_registered_users: vec![AdminDashboardDailyCountRecord { day_key: 100, count: 3, }], visit_count: vec![AdminDashboardDailyCountRecord { day_key: 100, count: 50_001, }], visit_users_by_day: vec![AdminDashboardDailyCountRecord { day_key: 100, count: 335, }], visit_users: 335, total_visit_count: 70_005, total_visit_users: 410, current_users: 2, day1_retention: AdminDashboardRetentionRecord { eligible_users: 100, retained_users: 37, rate_basis_points: 3_700, }, day7_retention: AdminDashboardRetentionRecord { eligible_users: 80, retained_users: 16, rate_basis_points: 2_000, }, module_visit_breakdown: vec![AdminDashboardBreakdownRecord { key: "profile".to_string(), count: 50_001, }], new_user_payment_conversion: AdminDashboardPaymentConversionRecord { paid_users: 12, new_registered_users: 30, rate_basis_points: 4_000, }, }); assert_eq!(stats.total_registered_users, 591); assert_eq!(stats.visit_count.total(), 50_001); assert_eq!(stats.total_visit_count, 70_005); assert_eq!(stats.total_visit_users, 410); assert_eq!(stats.day1_retention.rate_basis_points, 3_700); assert_eq!(stats.module_visit_breakdown["profile"], 50_001); assert_eq!(stats.new_user_payment_conversion.paid_users, 12); assert_eq!(stats.new_user_payment_conversion.new_registered_users, 30); assert_eq!(stats.new_user_payment_conversion.rate_basis_points, 4_000); } #[test] fn dashboard_user_stats_failure_is_not_converted_to_zero_values() { let error = match admin_dashboard_user_stats_from_result(Err( SpacetimeClientError::Timeout(SpacetimeClientStage::ProcedureResult), )) { Ok(_) => panic!("failed authoritative stats must fail the dashboard request"), Err(error) => error, }; assert_eq!(error.into_response().status(), StatusCode::BAD_GATEWAY); } #[test] fn dashboard_current_ranges_stop_at_beijing_today() { const DAY_MICROS: i64 = 86_400_000_000; let today = module_runtime::parse_analytics_calendar_date_key("2026-07-14").unwrap(); let now_micros = today * DAY_MICROS; let month = resolve_admin_dashboard_range_at( AdminDashboardQuery { granularity: Some("month".to_string()), anchor: Some("2026-07-14".to_string()), ..Default::default() }, now_micros, ) .expect("current month should resolve"); let period = resolve_admin_dashboard_range_at( AdminDashboardQuery { granularity: Some("period".to_string()), start_date: Some("2026-07-01".to_string()), end_date: Some("2026-07-31".to_string()), ..Default::default() }, now_micros, ) .expect("current period should clamp"); assert_eq!(month.end_date_key, today); assert_eq!(period.end_date_key, today); assert_eq!(month.day_keys().len(), 14); assert_eq!(period.day_keys().len(), 14); } #[test] fn dashboard_daily_visit_chart_keeps_period_unique_total() { let range = resolve_admin_dashboard_range_at( AdminDashboardQuery { granularity: Some("period".to_string()), start_date: Some("2026-07-01".to_string()), end_date: Some("2026-07-02".to_string()), ..Default::default() }, module_runtime::parse_analytics_calendar_date_key("2026-07-03").unwrap() * 86_400_000_000, ) .expect("period should resolve"); let mut daily_users = super::AdminDashboardSeries::default(); daily_users.add(range.start_date_key, 3); daily_users.add(range.end_date_key, 2); let chart = build_admin_dashboard_chart( "visit-users", "每日访问人数", "人", &daily_users, 4, &range, ); assert_eq!(chart.total, 4); assert_eq!( chart.buckets.iter().map(|bucket| bucket.value).sum::(), 5 ); } #[test] fn timestamp_value_to_micros_accepts_sql_shapes() { assert_eq!( timestamp_value_to_micros(&json!(1778207451731746_i64)), Some(1778207451731746) ); assert_eq!( timestamp_value_to_micros(&json!([1778207451731746_i64])), Some(1778207451731746) ); assert_eq!( timestamp_value_to_micros(&json!("2026-05-07T08:30:51.731746Z")), Some(1778142651731746) ); } #[test] fn wallet_ledger_source_type_to_string_maps_sats_enum() { assert_eq!( wallet_ledger_source_type_to_string(&json!([5, []])).as_deref(), Some("asset_operation_consume") ); assert_eq!( wallet_ledger_source_type_to_string(&json!("AssetOperationConsume")).as_deref(), Some("asset_operation_consume") ); } #[test] fn clamp_admin_database_table_limit_uses_default_and_bounds() { assert_eq!(clamp_admin_database_table_limit(None), 100); assert_eq!(clamp_admin_database_table_limit(Some(0)), 1); assert_eq!(clamp_admin_database_table_limit(Some(800)), 500); } #[test] fn admin_database_table_query_scans_before_filtering_and_paginates_after_matching() { let rows = (1..=101) .map(|index| json!([format!("user-{index}"), format!("name-{index}")])) .collect::>(); let response = parse_admin_database_table_rows_sql_response( "profile", 10, json!([{ "schema": { "elements": [ {"name": {"some": "user_id"}}, {"name": {"some": "name"}} ] }, "rows": rows }]), ) .expect("scanned rows should parse"); let query = AdminDatabaseTableRowsQuery { limit: Some(10), page: Some(1), search: Some("user-101".to_string()), filters: None, ..Default::default() }; assert_eq!(resolve_admin_database_table_sql_limit(), 50_001); let response = finalize_admin_database_table_rows_response(response, &query) .expect("filtering and pagination should succeed"); assert_eq!(response.total_matched, 1); assert_eq!(response.total_returned, 1); assert_eq!(response.rows[0].cells["user_id"], json!("user-101")); } #[test] fn admin_database_table_query_sorts_full_candidate_set_before_returning_requested_page() { let rows = [40, 10, 50, 20, 60, 30] .into_iter() .enumerate() .map(|(index, points)| json!([format!("user-{index}"), points])) .collect::>(); let response = parse_admin_database_table_rows_sql_response( "profile", 2, json!([{ "schema": {"elements": [ {"name": {"some": "user_id"}}, {"name": {"some": "points"}} ]}, "rows": rows }]), ) .expect("page prefix should parse"); let query = AdminDatabaseTableRowsQuery { limit: Some(2), page: Some(2), sort_column: Some("points".to_string()), sort_direction: Some("asc".to_string()), ..Default::default() }; let response = finalize_admin_database_table_rows_response(response, &query) .expect("pagination should succeed"); assert_eq!(response.page, 2); assert_eq!(response.total_matched, 6); assert_eq!(response.total_returned, 2); assert_eq!(response.rows[0].cells["points"], json!(30)); assert_eq!(response.rows[1].cells["points"], json!(40)); let descending_response = parse_admin_database_table_rows_sql_response( "profile", 2, json!([{ "schema": {"elements": [ {"name": {"some": "user_id"}}, {"name": {"some": "points"}} ]}, "rows": [ ["user-0", 40], ["user-1", 10], ["user-2", 50], ["user-3", 20], ["user-4", 60], ["user-5", 30] ] }]), ) .expect("descending rows should parse"); let descending_response = finalize_admin_database_table_rows_response( descending_response, &AdminDatabaseTableRowsQuery { limit: Some(2), page: Some(2), sort_column: Some("points".to_string()), sort_direction: Some("desc".to_string()), ..Default::default() }, ) .expect("descending pagination should succeed"); assert_eq!(descending_response.rows[0].cells["points"], json!(40)); assert_eq!(descending_response.rows[1].cells["points"], json!(30)); } #[test] fn admin_database_table_query_response_body_rejects_chunks_over_byte_budget() { let mut body = vec![0; 7]; append_spacetime_sql_response_chunk(&mut body, &[1], 8) .expect("chunk within byte budget should append"); let error = append_spacetime_sql_response_chunk(&mut body, &[2], 8) .expect_err("chunk over byte budget should fail"); assert_eq!(body.len(), 8); assert!(error.contains("SQL 响应超过")); } #[test] fn admin_database_table_query_uses_sentinel_to_mark_and_truncate_scan_limit() { let rows = (1..=50_001) .map(|index| json!([format!("user-{index:05}")])) .collect::>(); let response = parse_admin_database_table_rows_sql_response( "profile", 10, json!([{ "schema": {"elements": [{"name": {"some": "user_id"}}]}, "rows": rows }]), ) .expect("rows should parse"); let query = AdminDatabaseTableRowsQuery { limit: Some(10), page: Some(1), ..Default::default() }; let response = finalize_admin_database_table_rows_response(response, &query) .expect("response should finalize"); assert!(response.scan_limit_reached); assert_eq!(response.scanned_count, 50_000); assert_eq!(response.total_matched, 50_000); assert_eq!(response.scan_limit, 50_000); } #[test] fn admin_database_table_query_clamps_page_and_keeps_empty_results_on_page_one() { let response = parse_admin_database_table_rows_sql_response( "profile", 2, json!([{ "schema": {"elements": [{"name": {"some": "user_id"}}]}, "rows": [["user-1"], ["user-2"], ["user-3"]] }]), ) .expect("rows should parse"); let response = finalize_admin_database_table_rows_response( response, &AdminDatabaseTableRowsQuery { limit: Some(2), page: Some(99), ..Default::default() }, ) .expect("out of range page should clamp"); assert_eq!(response.page, 2); assert_eq!(response.total_returned, 1); let empty_response = parse_admin_database_table_rows_sql_response( "profile", 2, json!([{ "schema": {"elements": [{"name": {"some": "user_id"}}]}, "rows": [] }]), ) .expect("empty rows should parse"); let empty_response = finalize_admin_database_table_rows_response( empty_response, &AdminDatabaseTableRowsQuery { limit: Some(2), page: Some(99), ..Default::default() }, ) .expect("empty result should stay on page one"); assert_eq!(empty_response.page, 1); assert_eq!(empty_response.total_matched, 0); assert_eq!(empty_response.total_returned, 0); } #[test] fn parse_admin_database_table_rows_sql_response_maps_schema_columns() { let payload = json!([ { "schema": { "elements": [ {"name": {"some": "user_id"}}, {"name": {"some": "points"}} ] }, "rows": [["u1", 12]] } ]); let response = parse_admin_database_table_rows_sql_response("profile_wallet", 100, payload) .expect("table rows should parse"); assert_eq!(response.table_name, "profile_wallet"); assert_eq!(response.columns, vec!["user_id", "points"]); assert_eq!(response.total_returned, 1); assert_eq!(response.rows[0].cells["user_id"], json!("u1")); assert_eq!(response.rows[0].cells["points"], json!(12)); } #[test] fn parse_admin_database_table_rows_sql_response_maps_recharge_order_enum_cells() { let payload = json!([ { "schema": { "elements": [ {"name": {"some": "order_id"}}, {"name": {"some": "kind"}}, {"name": {"some": "status"}}, {"name": {"some": "paid_at"}} ] }, "rows": [[ "recharge:user_00000001:1778757456811099:points_60", [0, []], [0, []], [1, []] ]] } ]); let response = parse_admin_database_table_rows_sql_response("profile_recharge_order", 100, payload) .expect("recharge order rows should parse"); let cells = &response.rows[0].cells; assert_eq!(cells["kind"], json!("points")); assert_eq!(cells["status"], json!("pending")); assert_eq!(cells["paid_at"], json!(null)); assert_eq!( response.rows[0].raw, json!([ "recharge:user_00000001:1778757456811099:points_60", "points", "pending", null ]) ); } #[test] fn build_admin_database_table_row_normalizes_optional_sats_values() { let row = build_admin_database_table_row( &json!([[0, "u1"], [1, []]]), &["user_id".to_string(), "deleted_at".to_string()], ); assert_eq!(row.cells["user_id"], json!("u1")); assert_eq!(row.cells["deleted_at"], json!(null)); } #[test] fn apply_admin_database_table_filters_supports_search_and_json_filters() { let mut rows = vec![ build_admin_database_table_row( &json!(["u1", "alice", 12]), &[ "user_id".to_string(), "name".to_string(), "points".to_string(), ], ), build_admin_database_table_row( &json!(["u2", "bob", 8]), &[ "user_id".to_string(), "name".to_string(), "points".to_string(), ], ), ]; apply_admin_database_table_filters( &mut rows, &AdminDatabaseTableRowsQuery { search: Some("ali".to_string()), filters: Some(r#"{"points":12}"#.to_string()), ..Default::default() }, ) .expect("filters should apply"); assert_eq!(rows.len(), 1); assert_eq!(rows[0].cells["user_id"], json!("u1")); } #[test] fn apply_admin_database_table_filters_rejects_non_object_filter() { let mut rows = vec![build_admin_database_table_row( &json!(["u1"]), &["user_id".to_string()], )]; let error = apply_admin_database_table_filters( &mut rows, &AdminDatabaseTableRowsQuery { search: None, filters: Some("[]".to_string()), ..Default::default() }, ) .expect_err("non object filter should fail"); assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); } #[test] fn build_admin_tracking_events_sql_quotes_filters_and_clamps_limit() { let sql = build_admin_tracking_events_sql(&AdminTrackingEventListQuery { event_key: Some("daily'login".to_string()), user_id: Some("user-1".to_string()), scope_kind: Some("USER".to_string()), scope_id: Some("scope-1".to_string()), start_date: Some("2026-06-01".to_string()), end_date: Some("2026-06-30".to_string()), limit: Some(2000), export_all: None, }) .expect("tracking sql should build"); assert!(sql.contains("event_key = 'daily''login'")); assert!(sql.contains("user_id = 'user-1'")); assert!(sql.contains("scope_kind = 'user'")); assert!(sql.contains("scope_id = 'scope-1'")); assert!(sql.contains(&format!( "day_key >= {}", module_runtime::parse_analytics_calendar_date_key("2026-06-01").unwrap() ))); assert!(sql.contains(&format!( "day_key <= {}", module_runtime::parse_analytics_calendar_date_key("2026-06-30").unwrap() ))); assert!(!sql.contains("ORDER BY")); assert!(sql.ends_with("LIMIT 1000")); } #[test] fn build_admin_tracking_events_sql_uses_export_limit_when_requested() { let sql = build_admin_tracking_events_sql(&AdminTrackingEventListQuery { limit: Some(1_000_000), export_all: Some(true), ..Default::default() }) .expect("tracking export sql should build"); assert!(sql.ends_with("LIMIT 100000")); } #[test] fn clamp_admin_tracking_event_limit_uses_default_and_bounds() { assert_eq!(clamp_admin_tracking_event_limit(None, false), 200); assert_eq!(clamp_admin_tracking_event_limit(Some(0), false), 1); assert_eq!(clamp_admin_tracking_event_limit(Some(1001), false), 1000); assert_eq!( clamp_admin_tracking_event_limit(Some(1_000_000), true), 100_000 ); } #[test] fn build_admin_tracking_event_keys_sql_scans_tracking_event_keys() { let sql = build_admin_tracking_event_keys_sql(); assert_eq!( sql, "SELECT event_key, scope_kind FROM tracking_event LIMIT 50000" ); } #[test] fn parse_admin_tracking_event_keys_sql_response_dedupes_scope_kinds() { let payload = json!([ { "rows": [ ["daily_login", "user"], ["daily_login", [3, []]], ["work_play_start", "work"] ] } ]); let event_keys = parse_admin_tracking_event_keys_sql_response(payload) .expect("tracking event keys should parse"); assert_eq!(event_keys.len(), 2); assert_eq!(event_keys[0].event_key, "daily_login"); assert_eq!(event_keys[0].event_title, "每日登录"); assert_eq!(event_keys[0].scope_kinds, vec!["user"]); assert_eq!(event_keys[1].event_key, "work_play_start"); assert_eq!(event_keys[1].scope_kinds, vec!["work"]); } #[test] fn parse_admin_tracking_events_sql_response_accepts_statement_array_rows() { let payload = json!([ { "rows": [[ "event-1", "daily_login", "user", "user-1", 20580, {"some": "user-1"}, null, {"some": "profile-1"}, "profile", "{\"source\":\"task\"}", "2026-05-07T00:00:00Z" ]] } ]); let entries = parse_admin_tracking_events_sql_response(payload).expect("tracking rows should parse"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].event_id, "event-1"); assert_eq!(entries[0].event_title, "每日登录"); assert_eq!(entries[0].user_id.as_deref(), Some("user-1")); assert_eq!(entries[0].profile_id.as_deref(), Some("profile-1")); assert_eq!(entries[0].module_key.as_deref(), Some("profile")); } #[test] fn parse_admin_tracking_events_sql_response_normalizes_sats_values() { let payload = json!([ { "rows": [[ "event-1", "daily_login", [3, []], "user-1", 20580, [0, "user-1"], [1, []], [0, "profile-1"], [0, "profile"], "{}", [1778207451731746i64] ]] } ]); let entries = parse_admin_tracking_events_sql_response(payload).expect("tracking rows should parse"); assert_eq!(entries[0].scope_kind, "user"); assert_eq!(entries[0].user_id.as_deref(), Some("user-1")); assert_eq!(entries[0].owner_user_id, None); assert_eq!(entries[0].profile_id.as_deref(), Some("profile-1")); assert_eq!(entries[0].module_key.as_deref(), Some("profile")); assert_eq!(entries[0].occurred_at, "1778207451731746"); } #[test] fn parse_admin_tracking_events_sql_response_sorts_by_occurred_at_desc() { let payload = json!([ { "rows": [ [ "event-old", "daily_login", "user", "user-1", 20580, {"some": "user-1"}, null, {"some": "profile-1"}, "profile", "{}", "2026-05-07T00:00:00Z" ], [ "event-new", "daily_login", "user", "user-1", 20580, {"some": "user-1"}, null, {"some": "profile-1"}, "profile", "{}", "2026-05-07T01:00:00Z" ] ] } ]); let entries = parse_admin_tracking_events_sql_response(payload).expect("tracking rows should parse"); assert_eq!(entries[0].event_id, "event-new"); assert_eq!(entries[1].event_id, "event-old"); } #[test] fn build_body_preview_handles_utf8() { let preview = build_body_preview("后台测试".as_bytes()); assert_eq!(preview, "后台测试"); } #[test] fn admin_display_name_directory_never_exposes_internal_subject() { let mut directory = AdminDisplayNameDirectory::default(); directory.insert("admin:owner", "后台负责人"); directory.insert("admin-account-member-1", "兑换码运营"); directory.insert("operator", "运营一组"); assert_eq!(directory.resolve("admin:owner"), "后台负责人"); assert_eq!(directory.resolve("admin-account-member-1"), "兑换码运营"); assert_eq!(directory.resolve(" Operator "), "运营一组"); assert_eq!(directory.resolve("admin-account-missing"), "已停用管理员"); } #[tokio::test] async fn admin_password_helpers_hash_and_verify_off_executor() { let hash = hash_admin_password("member-password") .await .expect("password should hash"); assert!( verify_admin_password(&hash, "member-password") .await .expect("password should verify") ); assert!( !verify_admin_password(&hash, "wrong-password") .await .expect("wrong password should compare") ); } #[test] fn only_exact_admin_account_absence_invalidates_member_session() { assert!(is_admin_account_not_found( &SpacetimeClientError::Procedure("后台账号不存在".to_string()) )); assert!(!is_admin_account_not_found(&SpacetimeClientError::Timeout( SpacetimeClientStage::ProcedureResult ))); assert!(!is_admin_account_not_found( &SpacetimeClientError::ConnectDropped )); assert!(!is_admin_account_not_found( &SpacetimeClientError::Procedure("后台账号不存在:依赖异常".to_string()) )); } #[test] fn admin_tab_permissions_cover_shared_and_sensitive_routes() { assert!( enforce_admin_request_permission( "member", &["tasks".to_string()], &[], &Method::GET, "/admin/api/tracking/event-keys", ) .is_ok() ); assert!( enforce_admin_request_permission( "member", &["tracking".to_string()], &[], &Method::POST, "/admin/api/profile/wallet-restriction", ) .is_err() ); } #[test] fn every_assignable_admin_tab_has_a_backend_route() { let cases = [ ("dashboard", Method::GET, "/admin/api/dashboard"), ("overview", Method::GET, "/admin/api/overview"), ("tables", Method::GET, "/admin/api/database/tables"), ("debug", Method::POST, "/admin/api/debug/http"), ("tracking", Method::GET, "/admin/api/tracking/events"), ("gray-release", Method::GET, "/admin/api/feature-gates"), ("redeem", Method::GET, "/admin/api/profile/redeem-codes"), ("invite", Method::GET, "/admin/api/profile/invite-codes"), ( "profile-wallet", Method::GET, "/admin/api/profile/wallet-config", ), ("tasks", Method::GET, "/admin/api/profile/tasks"), ( "recharge-products", Method::GET, "/admin/api/profile/recharge-products", ), ( "recharge-orders", Method::GET, "/admin/api/profile/recharge-orders", ), ( "editor-generation-pricing", Method::GET, "/admin/api/editor-generation-pricing", ), ( "editor-showcase", Method::GET, "/admin/api/editor-showcase/assets", ), ("editor-assets", Method::GET, "/admin/api/editor-assets"), ]; for (permission, method, path) in cases { assert!( enforce_admin_request_permission( "member", &[permission.to_string()], &[], &method, path, ) .is_ok(), "{permission} should authorize {method} {path}" ); } } #[test] fn wallet_consumption_reconcile_requires_its_standalone_action_permission() { assert!( enforce_admin_request_permission( "member", &["recharge-orders".to_string()], &[], &Method::POST, "/admin/api/profile/users/reconcile-consumption", ) .is_err() ); assert!( enforce_admin_request_permission( "member", &[], &["profile-wallet-consumption-reconcile".to_string()], &Method::POST, "/admin/api/profile/users/reconcile-consumption", ) .is_ok() ); assert!( enforce_admin_request_permission( "member", &[], &["profile-wallet-consumption-reconcile".to_string()], &Method::POST, "/admin/api/profile/users/initialize-consumption-projections", ) .is_err() ); } #[test] fn unknown_admin_routes_are_owner_only() { assert!( enforce_admin_request_permission( "owner", &[], &[], &Method::GET, "/admin/api/future-route", ) .is_ok() ); assert!( enforce_admin_request_permission( "member", &["dashboard".to_string()], &[], &Method::GET, "/admin/api/future-route", ) .is_err() ); } }