Merge branch 'codex/feature-1'

# Conflicts:
#	docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
2026-06-03 03:56:25 +08:00
51 changed files with 3075 additions and 482 deletions

View File

@@ -25,8 +25,8 @@ use shared_contracts::admin::{
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryTypeConfigRequest,
AdminWorkVisibilityListResponse,
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest,
AdminUpsertCreationEntryTypeConfigRequest, AdminWorkVisibilityListResponse,
};
use shared_contracts::creation_entry_config::{
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
@@ -200,6 +200,7 @@ 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>,
@@ -212,6 +213,7 @@ pub async fn admin_get_creation_entry_config(
Ok(json_success_body(
Some(&request_context),
AdminCreationEntryConfigResponse {
event_banners: config.event_banners,
entries: config
.creation_types
.into_iter()
@@ -221,6 +223,7 @@ pub async fn admin_get_creation_entry_config(
))
}
/// 保存单个创作模板入口配置,并返回最新公告与入口快照。
pub async fn admin_upsert_creation_entry_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -235,6 +238,38 @@ pub async fn admin_upsert_creation_entry_config(
Ok(json_success_body(
Some(&request_context),
AdminCreationEntryConfigResponse {
event_banners: config.event_banners,
entries: config
.creation_types
.into_iter()
.map(map_admin_creation_entry_type_config)
.collect(),
},
))
}
/// 保存底部加号创作入口页的多公告表单序列化配置。
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),
AdminCreationEntryConfigResponse {
event_banners: config.event_banners,
entries: config
.creation_types
.into_iter()
@@ -1502,11 +1537,7 @@ mod tests {
};
use axum::{http::StatusCode, response::IntoResponse};
use serde_json::json;
use shared_contracts::admin::{
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
AdminUpsertCreationEntryTypeConfigRequest,
};
use shared_contracts::admin::{AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery};
#[test]
fn normalize_debug_path_rejects_absolute_url() {

View File

@@ -518,6 +518,40 @@ mod tests {
.to_string()
}
/// 中文注释:后台路由测试通过真实登录流程取 token避免绕过鉴权中间件。
async fn read_admin_access_token(app: Router) -> String {
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "root",
"password": "secret123"
})
.to_string(),
))
.expect("admin login request should build"),
)
.await
.expect("admin login request should succeed");
let body = response
.into_body()
.collect()
.await
.expect("admin login body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("admin login payload should be json");
payload["token"]
.as_str()
.expect("admin token should exist")
.to_string()
}
async fn password_login_request(
app: Router,
phone_number: &str,
@@ -3980,6 +4014,91 @@ mod tests {
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
/// 中文注释:验证入口公告表单提交的 HTML 会保存进独立公告配置。
#[tokio::test]
async fn admin_creation_entry_banners_route_saves_html_form_payload() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let app = build_router(AppState::new(config).expect("state should build"));
let admin_token = read_admin_access_token(app.clone()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/creation-entry/config/banners")
.header("authorization", format!("Bearer {admin_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"eventBannersJson": serde_json::json!([
{
"title": "后台表单公告",
"htmlCode": "<section>入口公告 HTML</section>"
}
]).to_string()
})
.to_string(),
))
.expect("banners request should build"),
)
.await
.expect("banners request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("banners body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("banners payload should be json");
assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告");
assert_eq!(payload["eventBanners"][0]["renderMode"], "html");
assert_eq!(
payload["eventBanners"][0]["htmlCode"],
"<section>入口公告 HTML</section>"
);
}
/// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。
#[tokio::test]
async fn admin_creation_entry_banners_route_rejects_script_html() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let app = build_router(AppState::new(config).expect("state should build"));
let admin_token = read_admin_access_token(app.clone()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/creation-entry/config/banners")
.header("authorization", format!("Bearer {admin_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"eventBannersJson": serde_json::json!([
{
"title": "危险公告",
"htmlCode": "<script>alert(1)</script>"
}
]).to_string()
})
.to_string(),
))
.expect("banners request should build"),
)
.await
.expect("banners request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn admin_debug_http_can_probe_healthz_when_authenticated() {
let mut config = AppConfig::default();

View File

@@ -152,7 +152,10 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(),
ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(),
render_mode: "structured".to_string(),
html_code: None,
},
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
updated_at_micros: 0,
})

View File

@@ -1,11 +1,15 @@
use axum::{Router, middleware, routing::get};
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
admin::{
admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows,
admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility,
admin_login, admin_me, admin_overview, admin_update_work_visibility,
admin_upsert_creation_entry_config, require_admin_auth,
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
require_admin_auth,
},
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
@@ -71,6 +75,12 @@ pub fn router(state: AppState) -> Router<AppState> {
require_admin_auth,
)),
)
.route(
"/admin/api/creation-entry/config/banners",
post(admin_upsert_creation_entry_event_banners_config).route_layer(
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/works/visibility",
get(admin_list_work_visibility)

View File

@@ -468,6 +468,45 @@ impl AppState {
}
}
/// 通过 SpacetimeDB 保存创作入口页多公告配置,并同步测试缓存。
pub async fn upsert_creation_entry_event_banners_config(
&self,
input: module_runtime::CreationEntryEventBannersAdminUpsertInput,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
#[cfg(test)]
let test_event_banners_json = input.event_banners_json.clone();
match self
.spacetime_client
.upsert_creation_entry_event_banners_config(input)
.await
{
Ok(config) => {
#[cfg(test)]
self.cache_test_creation_entry_config(config.clone());
Ok(config)
}
#[cfg(test)]
Err(_) => {
let mut config = self.read_test_creation_entry_config();
if let Ok(banners) = module_runtime::decode_creation_entry_event_banner_snapshots(
test_event_banners_json.as_str(),
) {
config.event_banners = banners
.into_iter()
.map(module_runtime::build_creation_entry_event_banner_response)
.collect();
if let Some(first_banner) = config.event_banners.first().cloned() {
config.event_banner = first_banner;
}
self.cache_test_creation_entry_config(config.clone());
}
Ok(config)
}
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn get_creation_entry_config(
&self,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {