feat: add admin creation entry switches
Some checks failed
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-05-11 12:02:39 +08:00
parent d23cf3807d
commit 0461c0ee41
18 changed files with 745 additions and 4 deletions

View File

@@ -18,6 +18,8 @@ use reqwest::Client;
use serde::Deserialize;
use serde_json::{Map, Value};
use shared_contracts::admin::{
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminUpsertCreationEntryTypeConfigRequest,
AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload,
AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload,
AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest,
@@ -194,6 +196,94 @@ pub async fn admin_list_database_table_rows(
Ok(json_success_body(Some(&request_context), response))
}
pub async fn admin_get_creation_entry_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, AppError> {
let config = state.get_creation_entry_config().await.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
AdminCreationEntryConfigResponse {
entries: config
.creation_types
.into_iter()
.map(map_admin_creation_entry_type_config)
.collect(),
},
))
}
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),
AdminCreationEntryConfigResponse {
entries: config
.creation_types
.into_iter()
.map(map_admin_creation_entry_type_config)
.collect(),
},
))
}
fn map_admin_creation_entry_type_config(
entry: shared_contracts::creation_entry_config::CreationEntryTypeResponse,
) -> AdminCreationEntryTypeConfigPayload {
AdminCreationEntryTypeConfigPayload {
id: entry.id,
title: entry.title,
subtitle: entry.subtitle,
badge: entry.badge,
image_src: entry.image_src,
visible: entry.visible,
open: entry.open,
sort_order: entry.sort_order,
updated_at_micros: entry.updated_at_micros,
}
}
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("入口标题不能为空"));
}
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,
})
}
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(),
}))
}
pub async fn require_admin_auth(
State(state): State<AppState>,
mut request: Request,
@@ -1237,7 +1327,9 @@ mod tests {
};
use axum::{http::StatusCode, response::IntoResponse};
use serde_json::json;
use shared_contracts::admin::{AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery};
use shared_contracts::admin::{
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminUpsertCreationEntryTypeConfigRequest,AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery};
#[test]
fn normalize_debug_path_rejects_absolute_url() {

View File

@@ -15,8 +15,9 @@ use tracing::{Level, Span, error, info, info_span, warn};
use crate::{
admin::{
admin_debug_http, admin_list_database_table_rows, admin_list_database_tables,
admin_list_tracking_events, admin_login, admin_me, admin_overview, require_admin_auth,
admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows,
admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me, admin_overview,
admin_upsert_creation_entry_config, require_admin_auth,
},
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
@@ -225,6 +226,15 @@ pub fn build_router(state: AppState) -> Router {
require_admin_auth,
)),
)
.route(
"/admin/api/creation-entry/config",
get(admin_get_creation_entry_config)
.post(admin_upsert_creation_entry_config)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/redeem-codes",
get(admin_list_profile_redeem_codes)

View File

@@ -228,6 +228,27 @@ impl AppState {
&self.refresh_cookie_config
}
pub async fn upsert_creation_entry_type_config(
&self,
input: module_runtime::CreationEntryTypeAdminUpsertInput,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
match self
.spacetime_client
.upsert_creation_entry_type_config(input)
.await
{
Ok(config) => {
#[cfg(test)]
self.cache_test_creation_entry_config(config.clone());
Ok(config)
}
#[cfg(test)]
Err(_) => Ok(self.read_test_creation_entry_config()),
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn get_creation_entry_config(
&self,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {

View File

@@ -88,6 +88,19 @@ pub struct CreationEntryConfigSnapshot {
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryTypeAdminUpsertInput {
pub id: String,
pub title: String,
pub subtitle: String,
pub badge: String,
pub image_src: String,
pub visible: bool,
pub open: bool,
pub sort_order: i32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryConfigProcedureResult {

View File

@@ -10,6 +10,44 @@ pub struct AdminLoginRequest {
}
// 登录成功后返回管理员访问令牌与基础会话信息。
/// 后台创作入口开关列表响应。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminCreationEntryConfigResponse {
pub entries: Vec<AdminCreationEntryTypeConfigPayload>,
}
/// 后台单个创作入口开关配置。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminCreationEntryTypeConfigPayload {
pub id: String,
pub title: String,
pub subtitle: String,
pub badge: String,
pub image_src: String,
pub visible: bool,
pub open: bool,
pub sort_order: i32,
pub updated_at_micros: i64,
}
/// 后台保存创作入口开关配置请求。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertCreationEntryTypeConfigRequest {
pub id: String,
pub title: String,
pub subtitle: String,
pub badge: String,
pub image_src: String,
pub visible: bool,
pub open: bool,
pub sort_order: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminLoginResponse {

View File

@@ -46,6 +46,23 @@ impl From<module_assets::AssetHistoryListInput> for AssetHistoryListInput {
}
}
impl From<module_runtime::CreationEntryTypeAdminUpsertInput>
for CreationEntryTypeAdminUpsertInput
{
fn from(input: module_runtime::CreationEntryTypeAdminUpsertInput) -> Self {
Self {
id: input.id,
title: input.title,
subtitle: input.subtitle,
badge: input.badge,
image_src: input.image_src,
visible: input.visible,
open: input.open,
sort_order: input.sort_order,
}
}
}
impl From<module_runtime::RuntimeSettingGetInput> for RuntimeSettingGetInput {
fn from(input: module_runtime::RuntimeSettingGetInput) -> Self {
Self {

View File

@@ -0,0 +1,22 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CreationEntryTypeAdminUpsertInput {
pub id: String,
pub title: String,
pub subtitle: String,
pub badge: String,
pub image_src: String,
pub visible: bool,
pub open: bool,
pub sort_order: i32,
}
impl __sdk::InModule for CreationEntryTypeAdminUpsertInput {
type Module = super::RemoteModule;
}

View File

@@ -252,6 +252,7 @@ pub mod custom_world_works_list_result_type;
pub mod creation_entry_config_procedure_result_type;
pub mod creation_entry_config_snapshot_type;
pub mod creation_entry_start_card_snapshot_type;
pub mod creation_entry_type_admin_upsert_input_type;
pub mod creation_entry_type_config_snapshot_type;
pub mod creation_entry_type_modal_snapshot_type;
pub mod database_migration_authorize_operator_input_type;
@@ -758,6 +759,7 @@ pub mod update_square_hole_work_procedure;
pub mod update_visual_novel_work_procedure;
pub mod upsert_auth_store_snapshot_procedure;
pub mod upsert_chapter_progression_and_return_procedure;
pub mod upsert_creation_entry_type_config_procedure;
pub mod upsert_chapter_progression_reducer;
pub mod upsert_custom_world_agent_operation_progress_procedure;
pub mod upsert_custom_world_profile_and_return_procedure;
@@ -1054,6 +1056,7 @@ pub use custom_world_works_list_result_type::CustomWorldWorksListResult;
pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult;
pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot;
pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot;
pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput;
pub use creation_entry_type_config_snapshot_type::CreationEntryTypeSnapshot;
pub use creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot;
pub use database_migration_authorize_operator_input_type::DatabaseMigrationAuthorizeOperatorInput;
@@ -1560,6 +1563,7 @@ pub use update_square_hole_work_procedure::update_square_hole_work;
pub use update_visual_novel_work_procedure::update_visual_novel_work;
pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot;
pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return;
pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config;
pub use upsert_chapter_progression_reducer::upsert_chapter_progression;
pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress;
pub use upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return;

View File

@@ -0,0 +1,57 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult;
use super::creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct UpsertCreationEntryTypeConfigArgs {
pub input: CreationEntryTypeAdminUpsertInput,
}
impl __sdk::InModule for UpsertCreationEntryTypeConfigArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `upsert_creation_entry_type_config`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait upsert_creation_entry_type_config {
fn upsert_creation_entry_type_config(&self, input: CreationEntryTypeAdminUpsertInput) {
self.upsert_creation_entry_type_config_then(input, |_, _| {});
}
fn upsert_creation_entry_type_config_then(
&self,
input: CreationEntryTypeAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<CreationEntryConfigProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl upsert_creation_entry_type_config for super::RemoteProcedures {
fn upsert_creation_entry_type_config_then(
&self,
input: CreationEntryTypeAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<CreationEntryConfigProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, CreationEntryConfigProcedureResult>(
"upsert_creation_entry_type_config",
UpsertCreationEntryTypeConfigArgs { input },
__callback,
);
}
}

View File

@@ -17,6 +17,25 @@ impl SpacetimeClient {
.await
}
pub async fn upsert_creation_entry_type_config(
&self,
input: module_runtime::CreationEntryTypeAdminUpsertInput,
) -> Result<CreationEntryConfigRecord, SpacetimeClientError> {
let procedure_input: CreationEntryTypeAdminUpsertInput = input.into();
self.call_after_connect(move |connection, sender| {
connection.procedures().upsert_creation_entry_type_config_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_creation_entry_config_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn get_runtime_settings(
&self,
user_id: String,

View File

@@ -48,6 +48,57 @@ pub fn get_creation_entry_config(
}
}
#[spacetimedb::procedure]
pub fn upsert_creation_entry_type_config(
ctx: &mut ProcedureContext,
input: CreationEntryTypeAdminUpsertInput,
) -> CreationEntryConfigProcedureResult {
match ctx.try_with_tx(|tx| upsert_creation_entry_type_config_in_tx(tx, input.clone())) {
Ok(record) => CreationEntryConfigProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => CreationEntryConfigProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
fn upsert_creation_entry_type_config_in_tx(
ctx: &ReducerContext,
input: CreationEntryTypeAdminUpsertInput,
) -> Result<CreationEntryConfigSnapshot, String> {
seed_creation_entry_config_if_missing(ctx);
let now = ctx.timestamp;
let id = input.id.trim().to_string();
if id.is_empty() {
return Err("入口 ID 不能为空".to_string());
}
if input.title.trim().is_empty() {
return Err("入口标题不能为空".to_string());
}
let row = CreationEntryTypeConfig {
id: id.clone(),
title: input.title.trim().to_string(),
subtitle: input.subtitle.trim().to_string(),
badge: input.badge.trim().to_string(),
image_src: input.image_src.trim().to_string(),
visible: input.visible,
open: input.open,
sort_order: input.sort_order,
updated_at: now,
};
if ctx.db.creation_entry_type_config().id().find(&id).is_some() {
ctx.db.creation_entry_type_config().id().update(row);
} else {
ctx.db.creation_entry_type_config().insert(row);
}
get_or_seed_creation_entry_config_snapshot(ctx)
}
fn get_or_seed_creation_entry_config_snapshot(
ctx: &ReducerContext,
) -> Result<CreationEntryConfigSnapshot, String> {