a8c3f8cf0f
后台埋点查询支持日期筛选和导出全部模式 新增后台真实 event key 列表接口 任务配置页改用真实 user 埋点 key 候选 更新后台埋点查询文档和定向测试
3264 lines
115 KiB
Rust
3264 lines
115 KiB
Rust
use std::{
|
||
collections::{BTreeMap, BTreeSet},
|
||
fs,
|
||
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||
};
|
||
|
||
use axum::{
|
||
Json,
|
||
extract::{Extension, Query, Request, State},
|
||
http::{
|
||
HeaderMap, HeaderName, HeaderValue, Method, StatusCode,
|
||
header::{AUTHORIZATION, CONTENT_TYPE},
|
||
},
|
||
middleware::Next,
|
||
response::Response,
|
||
};
|
||
use reqwest::Client;
|
||
use serde::Deserialize;
|
||
use serde_json::{Map, Value};
|
||
use shared_contracts::admin::{
|
||
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
|
||
AdminDashboardBreakdownRowPayload, AdminDashboardChartBucketPayload,
|
||
AdminDashboardChartPayload, AdminDashboardMetricsPayload, AdminDashboardOperationMetricPayload,
|
||
AdminDashboardOperationsPayload, AdminDashboardQuery, AdminDashboardRangePayload,
|
||
AdminDashboardResponse, AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse,
|
||
AdminDatabaseTableRowPayload, AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse,
|
||
AdminDatabaseTableStatPayload, AdminDebugHeaderInput, AdminDebugHttpRequest,
|
||
AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse, AdminMeResponse,
|
||
AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload,
|
||
AdminTrackingEventEntryPayload, AdminTrackingEventKeyListResponse,
|
||
AdminTrackingEventKeyPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse,
|
||
AdminUpdateWorkVisibilityRequest, AdminUpdateWorkVisibilityResponse,
|
||
AdminUpsertCreationEntryEventBannersRequest, AdminUpsertCreationEntryTypeConfigRequest,
|
||
AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse,
|
||
};
|
||
use shared_contracts::creation_entry_config::{
|
||
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
|
||
};
|
||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||
|
||
use crate::{
|
||
api_response::json_success_body,
|
||
editor_generation_config::EditorGenerationPricingConfig,
|
||
http_error::AppError,
|
||
request_context::RequestContext,
|
||
state::{AdminRuntime, AppState},
|
||
};
|
||
|
||
// 首版调试台只允许有限大小的请求体,避免把后台当作通用代理大包转发器。
|
||
const MAX_DEBUG_BODY_BYTES: usize = 128 * 1024;
|
||
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_DASHBOARD_ROW_LIMIT: u32 = 50_000;
|
||
const ADMIN_DASHBOARD_MAX_PERIOD_DAYS: i64 = 366;
|
||
const ADMIN_DASHBOARD_ACTIVE_WINDOW_MICROS: i64 = 5 * 60 * 1_000_000;
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct AuthenticatedAdmin {
|
||
session: AdminSessionPayload,
|
||
}
|
||
|
||
#[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 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();
|
||
let submitted_password = payload.password.trim();
|
||
if expected_username.is_empty() || expected_password.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||
);
|
||
}
|
||
|
||
if submitted_username != expected_username || submitted_password != expected_password {
|
||
return Err(
|
||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("管理员用户名或密码错误")
|
||
);
|
||
}
|
||
|
||
let now = OffsetDateTime::now_utc();
|
||
let claims = runtime.build_claims(now).map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error)
|
||
})?;
|
||
let token = runtime.sign_token(&claims).map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
AdminLoginResponse {
|
||
token,
|
||
admin: build_admin_session_payload(runtime.build_session(&claims)),
|
||
},
|
||
))
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
/// 读取后台创作入口配置,包含模板入口和底部加号入口页公告。
|
||
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),
|
||
))
|
||
}
|
||
|
||
/// 保存单个创作模板入口配置,并返回最新公告与入口快照。
|
||
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),
|
||
))
|
||
}
|
||
|
||
/// 保存底部加号创作入口页的多公告表单序列化配置。
|
||
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),
|
||
))
|
||
}
|
||
|
||
/// 保存公开作品详情页点赞 / 改造能力配置。
|
||
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_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()
|
||
.map_err(map_admin_editor_generation_pricing_error)?;
|
||
Ok(json_success_body(Some(&request_context), pricing))
|
||
}
|
||
|
||
/// 后台保存画布生成模型定价配置,写入运行时 override 文件。
|
||
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(payload)
|
||
.map_err(map_admin_editor_generation_pricing_error)?;
|
||
Ok(json_success_body(Some(&request_context), pricing))
|
||
}
|
||
|
||
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 },
|
||
))
|
||
}
|
||
|
||
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 },
|
||
))
|
||
}
|
||
|
||
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(),
|
||
}
|
||
}
|
||
|
||
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 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_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> {
|
||
// 后台鉴权必须同时满足:令牌验签通过、主体匹配当前管理员、roles 含 admin。
|
||
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))?;
|
||
|
||
let admin_session = runtime
|
||
.validate_claims(&claims)
|
||
.map_err(|error| AppError::from_status(StatusCode::FORBIDDEN).with_message(error))?;
|
||
|
||
request
|
||
.extensions_mut()
|
||
.insert(AuthenticatedAdmin::new(build_admin_session_payload(
|
||
admin_session,
|
||
)));
|
||
Ok(next.run(request).await)
|
||
}
|
||
|
||
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 AdminDashboardRegistrationStats {
|
||
new_registered_users: AdminDashboardSeries,
|
||
warnings: Vec<String>,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct AdminDashboardTrackingStats {
|
||
visit_count: AdminDashboardSeries,
|
||
visit_users: u64,
|
||
total_visit_count: u64,
|
||
total_visit_users: u64,
|
||
current_users: u64,
|
||
module_visit_breakdown: BTreeMap<String, u64>,
|
||
warnings: Vec<String>,
|
||
}
|
||
|
||
async fn build_admin_dashboard(
|
||
state: &AppState,
|
||
query: AdminDashboardQuery,
|
||
) -> Result<AdminDashboardResponse, AppError> {
|
||
let range = resolve_admin_dashboard_range(query)?;
|
||
let mut warnings = Vec::new();
|
||
|
||
let total_registered_users = match fetch_admin_dashboard_count(
|
||
state,
|
||
"SELECT COUNT(*) AS row_count FROM profile_dashboard_state",
|
||
)
|
||
.await
|
||
{
|
||
Ok(value) => value,
|
||
Err(message) => {
|
||
warnings.push(format!("注册用户总数读取失败:{message}"));
|
||
0
|
||
}
|
||
};
|
||
|
||
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 registration_stats = fetch_admin_dashboard_registration_stats(state, &range).await;
|
||
warnings.extend(registration_stats.warnings.iter().cloned());
|
||
|
||
let tracking_stats = fetch_admin_dashboard_tracking_stats(state, &range).await;
|
||
warnings.extend(tracking_stats.warnings.iter().cloned());
|
||
|
||
let generated_assets = asset_stats.generated_assets.total();
|
||
let consumed_mud_points = wallet_stats.consumed_mud_points.total();
|
||
let new_registered_users = registration_stats.new_registered_users.total();
|
||
let metrics = AdminDashboardMetricsPayload {
|
||
generated_assets,
|
||
consumed_mud_points,
|
||
total_registered_users,
|
||
new_registered_users,
|
||
visit_users: tracking_stats.visit_users,
|
||
total_visit_users: tracking_stats.total_visit_users,
|
||
visit_count: tracking_stats.visit_count.total(),
|
||
total_visit_count: tracking_stats.total_visit_count,
|
||
current_users: tracking_stats.current_users,
|
||
};
|
||
|
||
let charts = vec![
|
||
build_admin_dashboard_chart(
|
||
"generated-assets",
|
||
"生产素材",
|
||
"个",
|
||
&asset_stats.generated_assets,
|
||
&range,
|
||
),
|
||
build_admin_dashboard_chart(
|
||
"consumed-mud-points",
|
||
"消耗泥点",
|
||
"泥点",
|
||
&wallet_stats.consumed_mud_points,
|
||
&range,
|
||
),
|
||
build_admin_dashboard_chart(
|
||
"visit-users",
|
||
"访问人数",
|
||
"人",
|
||
&AdminDashboardSeries::single_total(&range, tracking_stats.visit_users),
|
||
&range,
|
||
),
|
||
build_admin_dashboard_chart(
|
||
"visit-count",
|
||
"访问次数",
|
||
"次",
|
||
&tracking_stats.visit_count,
|
||
&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,
|
||
tracking_stats.module_visit_breakdown,
|
||
),
|
||
warnings,
|
||
generated_at: module_runtime::format_utc_micros(current_admin_dashboard_utc_micros()),
|
||
})
|
||
}
|
||
|
||
fn resolve_admin_dashboard_range(
|
||
query: AdminDashboardQuery,
|
||
) -> Result<AdminDashboardRangeResolved, AppError> {
|
||
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);
|
||
}
|
||
|
||
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 => {
|
||
module_runtime::runtime_profile_beijing_day_key(current_admin_dashboard_utc_micros())
|
||
}
|
||
};
|
||
let dimension = module_runtime::build_analytics_date_dimension_from_date_key(anchor_date_key);
|
||
let (start_date_key, 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"),
|
||
};
|
||
Ok(AdminDashboardRangeResolved {
|
||
granularity,
|
||
anchor_date_key,
|
||
start_date_key,
|
||
end_date_key,
|
||
})
|
||
}
|
||
|
||
fn resolve_admin_dashboard_period_range(
|
||
query: AdminDashboardQuery,
|
||
) -> 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
|
||
)),
|
||
);
|
||
}
|
||
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_registration_stats(
|
||
state: &AppState,
|
||
range: &AdminDashboardRangeResolved,
|
||
) -> AdminDashboardRegistrationStats {
|
||
let mut stats = AdminDashboardRegistrationStats::default();
|
||
let sql = format!(
|
||
"SELECT created_at FROM profile_dashboard_state 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());
|
||
apply_admin_dashboard_registration_rows(&mut stats, range, rows.iter());
|
||
stats
|
||
}
|
||
|
||
fn apply_admin_dashboard_registration_rows<'a, I>(
|
||
stats: &mut AdminDashboardRegistrationStats,
|
||
range: &AdminDashboardRangeResolved,
|
||
rows: I,
|
||
) where
|
||
I: IntoIterator<Item = &'a Value>,
|
||
{
|
||
for row in rows {
|
||
let Some(columns) = row.as_array() else {
|
||
continue;
|
||
};
|
||
let Some(created_at_micros) = columns.first().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.new_registered_users.add(day_key, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn fetch_admin_dashboard_tracking_stats(
|
||
state: &AppState,
|
||
range: &AdminDashboardRangeResolved,
|
||
) -> AdminDashboardTrackingStats {
|
||
let mut stats = AdminDashboardTrackingStats::default();
|
||
let daily_sql = format!(
|
||
"SELECT event_key, scope_kind, scope_id, day_key, count FROM tracking_daily_stat LIMIT {}",
|
||
ADMIN_DASHBOARD_ROW_LIMIT
|
||
);
|
||
let daily_rows = match fetch_admin_dashboard_rows(state, &daily_sql).await {
|
||
Ok(rows) => rows,
|
||
Err(message) => {
|
||
stats
|
||
.warnings
|
||
.push(format!("访问聚合统计读取失败:{message}"));
|
||
Vec::new()
|
||
}
|
||
};
|
||
warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "访问聚合", daily_rows.len());
|
||
let mut period_users = BTreeSet::new();
|
||
let mut total_users = BTreeSet::new();
|
||
for row in daily_rows {
|
||
let Some(columns) = row.as_array() else {
|
||
continue;
|
||
};
|
||
let scope_kind = columns
|
||
.get(1)
|
||
.and_then(tracking_scope_kind_to_string)
|
||
.unwrap_or_default();
|
||
let scope_id = columns.get(2).and_then(value_to_string).unwrap_or_default();
|
||
let day_key = columns.get(3).and_then(value_to_i64).unwrap_or_default();
|
||
let count = columns.get(4).and_then(value_to_u64).unwrap_or_default();
|
||
if scope_kind == "site" {
|
||
stats.total_visit_count = stats.total_visit_count.saturating_add(count);
|
||
}
|
||
if scope_kind == "user" && !scope_id.trim().is_empty() && scope_id != "anonymous" {
|
||
total_users.insert(scope_id.clone());
|
||
}
|
||
if range.contains_day_key(day_key) {
|
||
if scope_kind == "site" {
|
||
stats.visit_count.add(day_key, count);
|
||
}
|
||
if scope_kind == "user" && !scope_id.trim().is_empty() && scope_id != "anonymous" {
|
||
period_users.insert(scope_id);
|
||
}
|
||
}
|
||
}
|
||
stats.visit_users = period_users.len() as u64;
|
||
stats.total_visit_users = total_users.len() as u64;
|
||
|
||
let event_sql = format!(
|
||
"SELECT user_id, module_key, day_key, occurred_at FROM tracking_event WHERE day_key >= {} AND day_key <= {} LIMIT {}",
|
||
range.start_date_key, range.end_date_key, ADMIN_DASHBOARD_ROW_LIMIT
|
||
);
|
||
let event_rows = match fetch_admin_dashboard_rows(state, &event_sql).await {
|
||
Ok(rows) => rows,
|
||
Err(message) => {
|
||
stats
|
||
.warnings
|
||
.push(format!("访问明细统计读取失败:{message}"));
|
||
Vec::new()
|
||
}
|
||
};
|
||
warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "访问明细", event_rows.len());
|
||
for row in event_rows {
|
||
let Some(columns) = row.as_array() else {
|
||
continue;
|
||
};
|
||
let module_key = columns
|
||
.get(1)
|
||
.and_then(value_to_string)
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or_else(|| "unknown".to_string());
|
||
*stats.module_visit_breakdown.entry(module_key).or_insert(0) += 1;
|
||
}
|
||
|
||
let active_since_micros =
|
||
current_admin_dashboard_utc_micros().saturating_sub(ADMIN_DASHBOARD_ACTIVE_WINDOW_MICROS);
|
||
let active_start_day_key = module_runtime::runtime_profile_beijing_day_key(active_since_micros);
|
||
let active_end_day_key =
|
||
module_runtime::runtime_profile_beijing_day_key(current_admin_dashboard_utc_micros());
|
||
let current_sql = format!(
|
||
"SELECT user_id, occurred_at FROM tracking_event WHERE day_key >= {} AND day_key <= {} LIMIT {}",
|
||
active_start_day_key, active_end_day_key, ADMIN_DASHBOARD_ROW_LIMIT
|
||
);
|
||
let current_rows = match fetch_admin_dashboard_rows(state, ¤t_sql).await {
|
||
Ok(rows) => rows,
|
||
Err(message) => {
|
||
stats
|
||
.warnings
|
||
.push(format!("当前使用人数读取失败:{message}"));
|
||
Vec::new()
|
||
}
|
||
};
|
||
warn_if_dashboard_rows_may_be_truncated(
|
||
&mut stats.warnings,
|
||
"当前使用人数",
|
||
current_rows.len(),
|
||
);
|
||
let mut current_users = BTreeSet::new();
|
||
for row in current_rows {
|
||
let Some(columns) = row.as_array() else {
|
||
continue;
|
||
};
|
||
let Some(occurred_at_micros) = columns.get(1).and_then(timestamp_value_to_micros) else {
|
||
continue;
|
||
};
|
||
if occurred_at_micros >= active_since_micros {
|
||
if let Some(user_id) = columns
|
||
.first()
|
||
.and_then(value_to_string)
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty() && value != "anonymous")
|
||
{
|
||
current_users.insert(user_id);
|
||
}
|
||
}
|
||
}
|
||
stats.current_users = current_users.len() as u64;
|
||
stats
|
||
}
|
||
|
||
async fn fetch_admin_dashboard_count(state: &AppState, sql: &str) -> Result<u64, 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);
|
||
fetch_spacetime_sql_count(&client, server_root, database, token.as_deref(), sql).await
|
||
}
|
||
|
||
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,
|
||
range: &AdminDashboardRangeResolved,
|
||
) -> AdminDashboardChartPayload {
|
||
AdminDashboardChartPayload {
|
||
id: id.to_string(),
|
||
title: title.to_string(),
|
||
unit: unit.to_string(),
|
||
total: series.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 single_total(range: &AdminDashboardRangeResolved, value: u64) -> Self {
|
||
let mut series = Self::default();
|
||
series.add(range.anchor_date_key, value);
|
||
series
|
||
}
|
||
}
|
||
|
||
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::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 limit = clamp_admin_database_table_limit(query.limit);
|
||
let sql = format!("SELECT * FROM {table_name} LIMIT {limit}");
|
||
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!(
|
||
"表数据读取失败:{}",
|
||
normalize_table_count_error(&error)
|
||
))
|
||
})?;
|
||
let mut 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}"))
|
||
})?;
|
||
apply_admin_database_table_filters(&mut response.rows, &query)?;
|
||
response.total_returned = response.rows.len();
|
||
Ok(response)
|
||
}
|
||
|
||
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 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(),
|
||
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",
|
||
_ => 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}"))
|
||
}
|
||
|
||
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 value_to_u64(value: &Value) -> Option<u64> {
|
||
match value {
|
||
Value::Number(number) => number
|
||
.as_u64()
|
||
.or_else(|| number.as_i64().and_then(|value| u64::try_from(value).ok())),
|
||
Value::String(text) => text.trim().parse::<u64>().ok(),
|
||
Value::Object(object) => object.get("some").and_then(value_to_u64),
|
||
Value::Array(items) if items.len() == 1 => items.first().and_then(value_to_u64),
|
||
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_u64)
|
||
} 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);
|
||
}
|
||
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 {
|
||
AdminSessionPayload {
|
||
subject: session.subject,
|
||
username: session.username,
|
||
display_name: session.display_name,
|
||
roles: session.roles,
|
||
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, AdminDashboardRegistrationStats, AdminDashboardSeries,
|
||
apply_admin_dashboard_registration_rows, apply_admin_database_table_filters,
|
||
build_admin_database_table_row, 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, is_safe_spacetime_table_name, normalize_debug_path,
|
||
normalize_table_count_error, 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, resolve_admin_dashboard_range,
|
||
timestamp_value_to_micros, trim_preview, wallet_ledger_source_type_to_string,
|
||
};
|
||
use axum::{http::StatusCode, response::IntoResponse};
|
||
use serde_json::json;
|
||
use shared_contracts::admin::{
|
||
AdminDashboardQuery, AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
|
||
};
|
||
|
||
#[test]
|
||
fn normalize_debug_path_rejects_absolute_url() {
|
||
let error =
|
||
normalize_debug_path("https://example.com/api").expect_err("absolute url should fail");
|
||
|
||
assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST);
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_debug_path_rejects_admin_login_route() {
|
||
let error =
|
||
normalize_debug_path("/admin/api/login").expect_err("admin login route should fail");
|
||
|
||
assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST);
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_debug_path_accepts_healthz() {
|
||
let path = normalize_debug_path("/healthz").expect("healthz path should pass validation");
|
||
|
||
assert_eq!(path, "/healthz");
|
||
}
|
||
|
||
#[test]
|
||
fn build_debug_base_url_rewrites_wildcard_ipv4_to_loopback() {
|
||
let url = build_debug_base_url("0.0.0.0", 3200);
|
||
|
||
assert_eq!(url, "http://127.0.0.1:3200");
|
||
}
|
||
|
||
#[test]
|
||
fn build_debug_base_url_wraps_ipv6_host() {
|
||
let url = build_debug_base_url("::1", 3200);
|
||
|
||
assert_eq!(url, "http://[::1]:3200");
|
||
}
|
||
|
||
#[test]
|
||
fn trim_preview_limits_length() {
|
||
let text = "a".repeat(5000);
|
||
|
||
assert_eq!(trim_preview(&text).chars().count(), 4000);
|
||
}
|
||
|
||
#[test]
|
||
fn build_spacetime_schema_url_includes_required_version_query() {
|
||
let url = build_spacetime_schema_url("http://127.0.0.1:3101", "xushi-p4wfr");
|
||
|
||
assert_eq!(
|
||
url,
|
||
"http://127.0.0.1:3101/v1/database/xushi-p4wfr/schema?version=9"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn is_safe_spacetime_table_name_accepts_schema_identifiers() {
|
||
assert!(is_safe_spacetime_table_name("runtime_setting"));
|
||
assert!(is_safe_spacetime_table_name("_private_table"));
|
||
assert!(is_safe_spacetime_table_name("AiTaskStage2"));
|
||
}
|
||
|
||
#[test]
|
||
fn is_safe_spacetime_table_name_rejects_sql_fragments() {
|
||
assert!(!is_safe_spacetime_table_name(""));
|
||
assert!(!is_safe_spacetime_table_name("bad-name"));
|
||
assert!(!is_safe_spacetime_table_name("1bad"));
|
||
assert!(!is_safe_spacetime_table_name("runtime_setting;DROP"));
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_table_count_error_hides_private_table_http_noise() {
|
||
let error = "HTTP 400:no such table: `runtime_setting`. If the table exists, it may be marked private.";
|
||
|
||
assert_eq!(
|
||
normalize_table_count_error(error),
|
||
"不可统计(private 或当前身份不可见)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_table_count_error_keeps_other_errors() {
|
||
let error = "SQL 请求失败:connection refused";
|
||
|
||
assert_eq!(normalize_table_count_error(error), error);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_spacetime_sql_count_response_accepts_statement_array_rows() {
|
||
let payload = json!([
|
||
{
|
||
"schema": {
|
||
"elements": [
|
||
{
|
||
"name": {
|
||
"some": "row_count"
|
||
},
|
||
"algebraic_type": {
|
||
"U64": []
|
||
}
|
||
}
|
||
]
|
||
},
|
||
"rows": [[7]],
|
||
"total_duration_micros": 116,
|
||
"stats": {
|
||
"rows_inserted": 0,
|
||
"rows_deleted": 0,
|
||
"rows_updated": 0
|
||
}
|
||
}
|
||
]);
|
||
|
||
let count =
|
||
parse_spacetime_sql_count_response(payload).expect("statement array should parse");
|
||
|
||
assert_eq!(count, 7);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_spacetime_sql_count_response_uses_schema_column_index() {
|
||
let payload = json!([
|
||
{
|
||
"schema": {
|
||
"elements": [
|
||
{
|
||
"name": {
|
||
"some": "table_name"
|
||
}
|
||
},
|
||
{
|
||
"name": {
|
||
"some": "row_count"
|
||
}
|
||
}
|
||
]
|
||
},
|
||
"rows": [["runtime_setting", "12"]]
|
||
}
|
||
]);
|
||
|
||
let count =
|
||
parse_spacetime_sql_count_response(payload).expect("schema column index should parse");
|
||
|
||
assert_eq!(count, 12);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_spacetime_sql_count_response_keeps_object_row_compatibility() {
|
||
let payload = json!({
|
||
"rows": [
|
||
{
|
||
"row_count": "3"
|
||
}
|
||
]
|
||
});
|
||
|
||
let count = parse_spacetime_sql_count_response(payload).expect("object row should parse");
|
||
|
||
assert_eq!(count, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_admin_dashboard_range_supports_day_week_month() {
|
||
let day = resolve_admin_dashboard_range(AdminDashboardQuery {
|
||
granularity: Some("day".to_string()),
|
||
anchor: Some("2026-06-23".to_string()),
|
||
..Default::default()
|
||
})
|
||
.expect("day range should resolve");
|
||
assert_eq!(day.granularity, AdminDashboardGranularity::Day);
|
||
assert_eq!(
|
||
day.start_date_key,
|
||
module_runtime::parse_analytics_calendar_date_key("2026-06-23").unwrap()
|
||
);
|
||
assert_eq!(day.end_date_key, day.start_date_key);
|
||
|
||
let week = resolve_admin_dashboard_range(AdminDashboardQuery {
|
||
granularity: Some("week".to_string()),
|
||
anchor: Some("2026-06-23".to_string()),
|
||
..Default::default()
|
||
})
|
||
.expect("week range should resolve");
|
||
assert_eq!(
|
||
week.start_date_key,
|
||
module_runtime::parse_analytics_calendar_date_key("2026-06-22").unwrap()
|
||
);
|
||
assert_eq!(
|
||
week.end_date_key,
|
||
module_runtime::parse_analytics_calendar_date_key("2026-06-28").unwrap()
|
||
);
|
||
|
||
let month = resolve_admin_dashboard_range(AdminDashboardQuery {
|
||
granularity: Some("month".to_string()),
|
||
anchor: Some("2026-06-23".to_string()),
|
||
..Default::default()
|
||
})
|
||
.expect("month range should resolve");
|
||
assert_eq!(
|
||
month.start_date_key,
|
||
module_runtime::parse_analytics_calendar_date_key("2026-06-01").unwrap()
|
||
);
|
||
assert_eq!(
|
||
month.end_date_key,
|
||
module_runtime::parse_analytics_calendar_date_key("2026-06-30").unwrap()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_admin_dashboard_range_supports_custom_period() {
|
||
let period = resolve_admin_dashboard_range(AdminDashboardQuery {
|
||
granularity: Some("period".to_string()),
|
||
start_date: Some("2026-06-10".to_string()),
|
||
end_date: Some("2026-06-18".to_string()),
|
||
..Default::default()
|
||
})
|
||
.expect("period range should resolve");
|
||
|
||
assert_eq!(period.granularity, AdminDashboardGranularity::Period);
|
||
assert_eq!(
|
||
period.start_date_key,
|
||
module_runtime::parse_analytics_calendar_date_key("2026-06-10").unwrap()
|
||
);
|
||
assert_eq!(
|
||
period.end_date_key,
|
||
module_runtime::parse_analytics_calendar_date_key("2026-06-18").unwrap()
|
||
);
|
||
assert_eq!(period.anchor_date_key, period.end_date_key);
|
||
}
|
||
|
||
#[test]
|
||
fn apply_admin_dashboard_registration_rows_counts_created_accounts_in_range() {
|
||
let range = 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");
|
||
let rows = vec![
|
||
json!(["2026-06-09T15:59:59Z"]),
|
||
json!(["2026-06-10T00:30:00Z"]),
|
||
json!(["2026-06-18T15:59:59Z"]),
|
||
json!(["2026-06-18T16:00:00Z"]),
|
||
json!(["bad-timestamp"]),
|
||
];
|
||
let mut stats = AdminDashboardRegistrationStats {
|
||
new_registered_users: AdminDashboardSeries::default(),
|
||
warnings: Vec::new(),
|
||
};
|
||
|
||
apply_admin_dashboard_registration_rows(&mut stats, &range, rows.iter());
|
||
|
||
assert_eq!(stats.new_registered_users.total(), 2);
|
||
}
|
||
|
||
#[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 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()),
|
||
limit: None,
|
||
},
|
||
)
|
||
.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()),
|
||
limit: None,
|
||
},
|
||
)
|
||
.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, "后台测试");
|
||
}
|
||
}
|