Files
Genarrative/server-rs/crates/api-server/src/admin.rs
k88936 a03f13d0a5
Project CI / Repository checks (push) Successful in 1m28s
Project CI / Frontend tests (push) Successful in 3m19s
Project CI / Backend tests (push) Successful in 4m3s
Project CI / Native shell tests (push) Successful in 13m58s
Fix/动作 前端展示下载+后端数据结构+精选 的问题 (#117)
原问题

```
历史角色动作是一个兼容例外:
后端允许它没有 editor_project_resource 资源行,只要布局自身保存了完整图片序列帧。
```

- 把生成动作的原视频asstetkind改为 video, 只把图片序列作为action
- db asset新增字段image_sequence_frames_json image_seq_duration_ms等, (原来动作的这些数据存在generation-input中,并不合适)
修改了externaljob,把这部分数据正确填写到数据库中。
- 为避免到处fallback,做了数据库迁移, 脚本: `scripts/spacetime-normalize-editor-character-actions.mjs`   手动进行过画布+素材库中动作序列帧+原始视频 迁移的测试

- 移除preview_video_path字段,  reason:
>preview video
    → separate resource/asset with assetKind="video"
  final transparent action
    → assetKind="character-animation"
    → source_resource_id points to the preview-video resource
    → image_sequence_frames_json contains the actual playable result
>
> So preview_video_path on the final action duplicates the source video resource’s image_src.
- 移除每个frame的index字段 原因: 这个只用于后端内部处理时有一个并发请求, 每个赋一个index方便收集, 后续没有再用到,且与数组本身重复

- 清除副产品preview video的generation_input_json,  因为会影响改造功能, 迁移后预览视频不提供改造(参数), 只有序列帧动作有改造

仍存在的共性问题: #134

- 导出:
下载改为完整序列帧, 封面不再作为fallback, 部分帧读取失败时行为:仍生成 ZIP,并记录失败帧, 不变

画布放置:
before:
![shotmd-1785207959-compressed.webp](/attachments/e56454f0-49a3-4a8a-951f-c21960975f99)
after:
![shotmd-1785231365-compressed.webp](/attachments/886f07df-c25b-4ecc-acc9-160e1795a991)
精选模块: 主页展示, 审核部分UI:
![shotmd-1785500046-compressed.webp](/attachments/9ec3b679-0045-494a-9f20-134f7e73fecf)
![shotmd-1785499780-compressed.webp](/attachments/42396e63-5f26-4853-a367-0f3c15111add)
这两处序列帧们的加载设计为惰式的, 只有hover和单独preview才会全部加载

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/117
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-06 16:57:38 +08:00

5733 lines
207 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> = 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<String, String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SpacetimeDatabaseInfoResponse {
database_identity: Option<String>,
owner_identity: Option<String>,
host_type: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SpacetimeSchemaResponse {
tables: Option<Vec<SpacetimeSchemaTable>>,
}
#[derive(Debug, Deserialize)]
struct SpacetimeSchemaTable {
name: Option<String>,
}
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<AdminDisplayNameDirectory, spacetime_client::SpacetimeClientError> {
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<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<AdminLoginRequest>,
) -> Result<Json<Value>, 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<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Json<Value> {
json_success_body(
Some(&request_context),
AdminMeResponse {
admin: admin.session().clone(),
},
)
}
pub async fn admin_overview(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Query(query): Query<AdminDashboardQuery>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminDebugHttpRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Query(query): Query<AdminTrackingEventListQuery>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
axum::extract::Path(table_name): axum::extract::Path<String>,
Query(query): Query<AdminDatabaseTableRowsQuery>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertCreationEntryTypeConfigRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertCreationEntryEventBannersRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertPublicWorkInteractionConfigRequest>,
) -> Result<Json<Value>, 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::<Vec<_>>();
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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertFeatureGateConfigRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<EditorGenerationPricingConfig>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpdateWorkVisibilityRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Query(query): Query<GetReadUrlQuery>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Query(query): Query<AdminEditorAssetListQuery>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Query(query): Query<AdminEditorShowcaseListQuery>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminEditorShowcaseReviewRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminEditorShowcaseDisplayRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertEditorShowcaseCampaignRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminCreateEditorShowcaseCampaignImageUploadTicketRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminConfirmEditorShowcaseCampaignImageUploadRequest>,
) -> Result<Json<Value>, 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<ConfirmAssetObjectRequest, AppError> {
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<module_runtime::FeatureGateConfigSnapshot>,
) -> 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<Vec<AdminEditorImageSequenceFramePayload>> {
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<String> {
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<i64>,
created_before_micros: Option<i64>,
}
fn resolve_admin_editor_asset_date_range(
query: &AdminEditorAssetListQuery,
) -> Result<AdminEditorAssetDateRange, String> {
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<AdminEditorAssetRecord>,
) -> Vec<Vec<AdminEditorAssetRecord>> {
let mut groups = Vec::<Vec<AdminEditorAssetRecord>>::new();
let mut indexes = BTreeMap::<String, usize>::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<AdminEditorAssetRecord>,
page_size: usize,
) -> Result<(Vec<Vec<AdminEditorAssetRecord>>, Option<String>), 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<AdminEditorAssetRecord>,
) -> Option<AdminEditorAssetPayload> {
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::<Vec<_>>();
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<String, String> {
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::<i64>().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<String>,
mut keyword: Option<String>,
) -> Result<Option<(Option<String>, Option<String>)>, 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<String> {
let compact = value
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.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<Option<String>, 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<String>) -> Option<String> {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
#[cfg(any())]
fn validate_admin_creation_entry_config(
payload: AdminUpsertCreationEntryTypeConfigRequest,
) -> Result<module_runtime::CreationEntryTypeAdminUpsertInput, AppError> {
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::FeatureGateConfigAdminUpsertInput, AppError> {
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<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
// 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<String, AppError> {
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<bool, AppError> {
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<String, AppError> {
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<AdminOverviewResponse, AppError> {
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::<SpacetimeDatabaseInfoResponse>(
&client,
&format!("{server_root}/v1/database/{database}"),
token,
)
.await
.map_err(|error| fetch_errors.push(format!("数据库信息读取失败:{error}")))
.ok()
.flatten();
let schema = fetch_spacetime_json::<SpacetimeSchemaResponse>(
&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<i64, u64>,
}
#[derive(Default)]
struct AdminDashboardAssetStats {
generated_assets: AdminDashboardSeries,
asset_kind_breakdown: BTreeMap<String, u64>,
warnings: Vec<String>,
}
#[derive(Default)]
struct AdminDashboardWalletStats {
consumed_mud_points: AdminDashboardSeries,
recharged_mud_points: u64,
warnings: Vec<String>,
}
#[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<String, u64>,
new_user_payment_conversion: AdminDashboardPaymentConversionPayload,
}
async fn build_admin_dashboard(
state: &AppState,
query: AdminDashboardQuery,
) -> Result<AdminDashboardResponse, AppError> {
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<AdminDashboardRangeResolved, AppError> {
resolve_admin_dashboard_range_at(query, current_admin_dashboard_utc_micros())
}
fn resolve_admin_dashboard_range_at(
query: AdminDashboardQuery,
now_micros: i64,
) -> Result<AdminDashboardRangeResolved, AppError> {
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<AdminDashboardRangeResolved, AppError> {
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<i64, AppError> {
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<AdminDashboardUserStats, AppError> {
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<AdminDashboardStatsRecord, SpacetimeClientError>,
) -> Result<AdminDashboardUserStats, AppError> {
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<Vec<Value>, 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<String, u64>,
module_visit_breakdown: BTreeMap<String, u64>,
) -> 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<String, u64>,
limit: usize,
) -> Vec<AdminDashboardBreakdownRowPayload> {
let mut rows = values.into_iter().collect::<Vec<_>>();
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<String>, 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::<Vec<_>>().join("/"),
AdminDashboardGranularity::Month => date
.rsplit('-')
.next()
.map(ToOwned::to_owned)
.unwrap_or(date),
AdminDashboardGranularity::Period => date.split('-').skip(1).collect::<Vec<_>>().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<i64> {
(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<T>(
client: &Client,
url: &str,
token: Option<&str>,
) -> Result<Option<T>, 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::<T>()
.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<u64, String> {
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::<Value>()
.await
.map_err(|error| format!("SQL 响应解析失败:{error}"))?;
parse_spacetime_sql_count_response(payload)
}
fn parse_spacetime_sql_count_response(payload: Value) -> Result<u64, String> {
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<u64, String> {
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<u64, String> {
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<u64, String> {
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<String, Value>) -> Result<u64, String> {
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<usize> {
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<u64, String> {
match value {
Value::Number(number) => number
.as_u64()
.ok_or_else(|| "count 字段不是无符号整数".to_string()),
Value::String(text) => text
.trim()
.parse::<u64>()
.map_err(|error| format!("count 字段解析失败:{error}")),
_ => Err("count 字段类型非法".to_string()),
}
}
async fn fetch_admin_database_table_list(
state: &AppState,
) -> Result<AdminDatabaseTableListResponse, AppError> {
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<AdminDatabaseTableRowsResponse, AppError> {
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<SpacetimeSchemaResponse>, Vec<String>, Vec<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 mut fetch_errors = Vec::new();
let schema = fetch_spacetime_json::<SpacetimeSchemaResponse>(
&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<String> {
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::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn resolve_admin_spacetime_sql_token(state: &AppState) -> Option<String> {
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>) -> 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>) -> 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<AdminDatabaseTableRowsResponse, AppError> {
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<AdminDatabaseTableRowPayload>,
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::<Vec<_>>();
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<AdminDatabaseTableRowsResponse, String> {
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::<Vec<_>>();
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<Value, String> {
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<String> {
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::<Vec<_>>()
})
.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<Value> {
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<u64> {
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<AdminDatabaseTableRowPayload>,
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::<Value>(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<String, Value>,
) -> 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<Vec<AdminTrackingEventEntryPayload>, 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<Vec<AdminTrackingEventKeyPayload>, 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<String, String> {
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<i64, String> {
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<String> {
// 本地开发清库后会通过 `/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<u32>, 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<Value, String> {
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::<Value>()
.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<Value, String> {
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::<Value>(&body).map_err(|error| format!("SQL 响应解析失败:{error}"))
}
fn append_spacetime_sql_response_chunk(
body: &mut Vec<u8>,
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<Vec<AdminTrackingEventEntryPayload>, String> {
let rows = extract_first_sql_rows(payload)?;
rows.iter()
.map(parse_admin_tracking_event_row)
.collect::<Result<Vec<_>, _>>()
.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<Vec<AdminTrackingEventKeyPayload>, String> {
let rows = extract_first_sql_rows(payload)?;
let mut by_event_key: BTreeMap<String, BTreeSet<String>> = 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<Vec<Value>, 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<AdminTrackingEventEntryPayload, String> {
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<String, String> {
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<String> {
columns.get(index).and_then(value_to_string)
}
fn required_i64_column(columns: &[Value], index: usize, field_name: &str) -> Result<i64, String> {
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<String> {
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<String> {
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<i64> {
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::<i64>().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<i64> {
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<i64> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(value) = trimmed.parse::<i64>() {
return Some(value);
}
if let Some((seconds, micros)) = trimmed
.strip_suffix('Z')
.and_then(|value| value.split_once('.'))
{
if let Ok(seconds) = seconds.parse::<i64>()
&& !micros.is_empty()
&& micros.len() <= 6
&& micros.chars().all(|character| character.is_ascii_digit())
{
let micros = format!("{micros:0<6}").parse::<i128>().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<String> {
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<String> {
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<String> {
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<AdminDebugHttpResponse, AppError> {
// 调试请求始终回打当前 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::<Vec<_>>();
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::<Value>(&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::<IpAddr>() {
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::<Ipv6Addr>().is_ok() {
return format!("[{host}]");
}
host.to_string()
}
fn normalize_debug_path(path: &str) -> Result<String, AppError> {
// 只允许 `/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::<String>()
}
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::<Vec<_>>();
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 400no 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::<u64>(),
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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()
);
}
}