a03f13d0a5
原问题
```
历史角色动作是一个兼容例外:
后端允许它没有 editor_project_resource 资源行,只要布局自身保存了完整图片序列帧。
```
- 把生成动作的原视频asstetkind改为 video, 只把图片序列作为action
- db asset新增字段image_sequence_frames_json image_seq_duration_ms等, (原来动作的这些数据存在generation-input中,并不合适)
修改了externaljob,把这部分数据正确填写到数据库中。
- 为避免到处fallback,做了数据库迁移, 脚本: `scripts/spacetime-normalize-editor-character-actions.mjs` 手动进行过画布+素材库中动作序列帧+原始视频 迁移的测试
- 移除preview_video_path字段, reason:
>preview video
→ separate resource/asset with assetKind="video"
final transparent action
→ assetKind="character-animation"
→ source_resource_id points to the preview-video resource
→ image_sequence_frames_json contains the actual playable result
>
> So preview_video_path on the final action duplicates the source video resource’s image_src.
- 移除每个frame的index字段 原因: 这个只用于后端内部处理时有一个并发请求, 每个赋一个index方便收集, 后续没有再用到,且与数组本身重复
- 清除副产品preview video的generation_input_json, 因为会影响改造功能, 迁移后预览视频不提供改造(参数), 只有序列帧动作有改造
仍存在的共性问题: #134
- 导出:
下载改为完整序列帧, 封面不再作为fallback, 部分帧读取失败时行为:仍生成 ZIP,并记录失败帧, 不变
画布放置:
before:

after:

精选模块: 主页展示, 审核部分UI:


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