点赞和改造开关加入后台配置

This commit is contained in:
2026-06-10 14:36:56 +08:00
parent 9db467d23f
commit e29992cf01
33 changed files with 1644 additions and 380 deletions

View File

@@ -26,7 +26,8 @@ use shared_contracts::admin::{
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest,
AdminUpsertCreationEntryTypeConfigRequest, AdminWorkVisibilityListResponse,
AdminUpsertCreationEntryTypeConfigRequest, AdminUpsertPublicWorkInteractionConfigRequest,
AdminWorkVisibilityListResponse,
};
use shared_contracts::creation_entry_config::{
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
@@ -212,14 +213,7 @@ pub async fn admin_get_creation_entry_config(
.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()
.map(map_admin_creation_entry_type_config)
.collect(),
},
build_admin_creation_entry_config_response(config),
))
}
@@ -237,14 +231,7 @@ pub async fn admin_upsert_creation_entry_config(
.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()
.map(map_admin_creation_entry_type_config)
.collect(),
},
build_admin_creation_entry_config_response(config),
))
}
@@ -268,14 +255,45 @@ pub async fn admin_upsert_creation_entry_event_banners_config(
.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()
.map(map_admin_creation_entry_type_config)
.collect(),
},
build_admin_creation_entry_config_response(config),
))
}
/// 保存公开作品详情页点赞 / 改造能力配置。
pub async fn admin_upsert_public_work_interaction_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertPublicWorkInteractionConfigRequest>,
) -> Result<Json<Value>, AppError> {
let snapshots = payload
.public_work_interactions
.into_iter()
.map(
|entry| module_runtime::PublicWorkInteractionConfigSnapshot {
source_type: entry.source_type,
like_enabled: entry.like_enabled,
remix_enabled: entry.remix_enabled,
like_disabled_message: entry.like_disabled_message,
remix_disabled_message: entry.remix_disabled_message,
},
)
.collect::<Vec<_>>();
let public_work_interactions_json =
module_runtime::encode_public_work_interaction_config_snapshots(&snapshots)
.and_then(|json| module_runtime::normalize_public_work_interaction_config_json(&json))
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
let config = state
.upsert_public_work_interaction_config(
module_runtime::PublicWorkInteractionConfigAdminUpsertInput {
public_work_interactions_json,
},
)
.await
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
build_admin_creation_entry_config_response(config),
))
}
@@ -313,6 +331,20 @@ pub async fn admin_update_work_visibility(
))
}
fn build_admin_creation_entry_config_response(
config: shared_contracts::creation_entry_config::CreationEntryConfigResponse,
) -> AdminCreationEntryConfigResponse {
AdminCreationEntryConfigResponse {
event_banners: config.event_banners,
public_work_interactions: config.public_work_interactions,
entries: config
.creation_types
.into_iter()
.map(map_admin_creation_entry_type_config)
.collect(),
}
}
fn map_admin_creation_entry_type_config(
entry: shared_contracts::creation_entry_config::CreationEntryTypeResponse,
) -> AdminCreationEntryTypeConfigPayload {

View File

@@ -17,7 +17,9 @@ use tracing::{Level, Span, error, info_span};
use crate::{
auth::AuthenticatedAccessToken,
backpressure::limit_concurrent_requests,
creation_entry_config::require_creation_entry_route_enabled,
creation_entry_config::{
require_creation_entry_route_enabled, require_public_work_interaction_enabled,
},
error_middleware::normalize_error_response,
http_error::AppError,
modules,
@@ -57,6 +59,10 @@ pub fn build_router(state: AppState) -> Router {
state.clone(),
require_creation_entry_route_enabled,
))
.layer(middleware::from_fn_with_state(
state.clone(),
require_public_work_interaction_enabled,
))
// HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。
.layer(middleware::from_fn_with_state(
BackpressureState::from_ref(&state),
@@ -590,6 +596,37 @@ mod tests {
);
}
#[tokio::test]
async fn disabled_public_work_like_returns_service_unavailable() {
let state = AppState::new(AppConfig::default()).expect("state should build");
state.set_test_public_work_interaction_enabled(
"puzzle",
crate::creation_entry_config::PublicWorkInteractionAction::Like,
false,
);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/puzzle/gallery/profile-1/like")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = read_json_response(response).await;
assert_eq!(
body["error"]["details"]["reason"],
"public_work_interaction_disabled"
);
assert_eq!(body["error"]["details"]["sourceType"], "puzzle");
assert_eq!(body["error"]["details"]["action"], "like");
}
#[tokio::test]
async fn disabled_visual_novel_creation_route_returns_service_unavailable() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -4228,6 +4265,62 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
/// 中文注释:验证后台作品互动配置保存后回到同一份入口配置响应。
#[tokio::test]
async fn admin_public_work_interactions_route_saves_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/interactions")
.header("authorization", format!("Bearer {admin_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"publicWorkInteractions": [
{
"sourceType": "puzzle",
"likeEnabled": false,
"remixEnabled": true,
"likeDisabledMessage": "拼图点赞维护中。",
"remixDisabledMessage": "拼图作品改造暂不可用。"
}
]
})
.to_string(),
))
.expect("interactions request should build"),
)
.await
.expect("interactions request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("interactions body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("interactions payload should be json");
let puzzle = payload["publicWorkInteractions"]
.as_array()
.expect("interactions should be array")
.iter()
.find(|item| item["sourceType"] == "puzzle")
.expect("puzzle interaction should exist");
assert_eq!(puzzle["likeEnabled"], false);
assert_eq!(puzzle["remixEnabled"], true);
assert_eq!(puzzle["likeDisabledMessage"], "拼图点赞维护中。");
}
#[tokio::test]
async fn admin_debug_http_can_probe_healthz_when_authenticated() {
let mut config = AppConfig::default();

View File

@@ -17,6 +17,21 @@ use crate::{
state::AppState,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PublicWorkInteractionAction {
Like,
Remix,
}
impl PublicWorkInteractionAction {
fn as_str(self) -> &'static str {
match self {
Self::Like => "like",
Self::Remix => "remix",
}
}
}
/// 中文注释:入口配置由 SpacetimeDB 表提供api-server 只负责读取同一份配置并熔断运行态路由。
pub async fn get_creation_entry_config_handler(
State(state): State<AppState>,
@@ -71,6 +86,68 @@ pub async fn require_creation_entry_route_enabled(
next.run(request).await
}
/// 中文注释:公开作品互动配置只拦点赞 / 改造动作,不影响作品详情读取和正式游玩。
pub async fn require_public_work_interaction_enabled(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
let path = request.uri().path();
if let Some((source_type, action)) = resolve_public_work_interaction_route(path) {
match state
.is_public_work_interaction_enabled(source_type, action)
.await
{
Ok(true) => {}
Ok(false) => {
return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("该作品互动暂不可用")
.with_details(json!({
"reason": "public_work_interaction_disabled",
"sourceType": source_type,
"action": action.as_str(),
}))
.into();
}
Err(error) => {
return AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("读取作品互动配置失败")
.with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
.into();
}
}
}
next.run(request).await
}
pub(crate) fn resolve_public_work_interaction_route(
path: &str,
) -> Option<(&'static str, PublicWorkInteractionAction)> {
let action = if path.ends_with("/like") {
PublicWorkInteractionAction::Like
} else if path.ends_with("/remix") {
PublicWorkInteractionAction::Remix
} else {
return None;
};
if path.starts_with("/api/runtime/custom-world-gallery/") {
return Some(("custom-world", action));
}
if path.starts_with("/api/runtime/big-fish/gallery/") {
return Some(("big-fish", action));
}
if path.starts_with("/api/runtime/puzzle/gallery/") {
return Some(("puzzle", action));
}
None
}
pub(crate) fn resolve_creation_entry_mud_point_cost_from_config(
config: &CreationEntryConfigResponse,
creation_type_id: &str,
@@ -142,6 +219,9 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
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,
public_work_interactions_json: Some(
module_runtime::default_public_work_interaction_config_json(),
),
})
}
@@ -242,6 +322,28 @@ mod tests {
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
}
#[test]
fn resolves_public_work_interaction_routes() {
assert_eq!(
resolve_public_work_interaction_route("/api/runtime/puzzle/gallery/profile-1/like"),
Some(("puzzle", PublicWorkInteractionAction::Like)),
);
assert_eq!(
resolve_public_work_interaction_route(
"/api/runtime/custom-world-gallery/user-1/profile-1/remix"
),
Some(("custom-world", PublicWorkInteractionAction::Remix)),
);
assert_eq!(
resolve_public_work_interaction_route("/api/runtime/puzzle/gallery/profile-1"),
None,
);
assert_eq!(
resolve_public_work_interaction_route("/api/runtime/wooden-fish/runs/run-1"),
None,
);
}
#[test]
fn resolves_mud_point_cost_from_unified_creation_spec() {
let mut config = test_creation_entry_config_response();

View File

@@ -9,7 +9,7 @@ use crate::{
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, admin_upsert_creation_entry_event_banners_config,
require_admin_auth,
admin_upsert_public_work_interaction_config, require_admin_auth,
},
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
@@ -81,6 +81,12 @@ pub fn router(state: AppState) -> Router<AppState> {
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/creation-entry/config/interactions",
post(admin_upsert_public_work_interaction_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

@@ -559,6 +559,44 @@ impl AppState {
}
}
/// 通过 SpacetimeDB 保存公开作品互动配置,并同步测试缓存。
pub async fn upsert_public_work_interaction_config(
&self,
input: module_runtime::PublicWorkInteractionConfigAdminUpsertInput,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
#[cfg(test)]
let test_interactions_json = input.public_work_interactions_json.clone();
match self
.spacetime_client
.upsert_public_work_interaction_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(interactions) =
module_runtime::decode_public_work_interaction_config_snapshots(
test_interactions_json.as_str(),
)
{
config.public_work_interactions = interactions
.into_iter()
.map(module_runtime::build_public_work_interaction_config_response)
.collect();
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> {
@@ -619,6 +657,53 @@ impl AppState {
.unwrap_or(true))
}
pub async fn is_public_work_interaction_enabled(
&self,
source_type: &str,
action: crate::creation_entry_config::PublicWorkInteractionAction,
) -> Result<bool, SpacetimeClientError> {
let config = self.get_creation_entry_config().await?;
Ok(config
.public_work_interactions
.iter()
.find(|item| item.source_type == source_type)
.map(|item| match action {
crate::creation_entry_config::PublicWorkInteractionAction::Like => {
item.like_enabled
}
crate::creation_entry_config::PublicWorkInteractionAction::Remix => {
item.remix_enabled
}
})
.unwrap_or(true))
}
#[cfg(test)]
pub(crate) fn set_test_public_work_interaction_enabled(
&self,
source_type: impl AsRef<str>,
action: crate::creation_entry_config::PublicWorkInteractionAction,
enabled: bool,
) {
let source_type = source_type.as_ref();
let mut config = self.read_test_creation_entry_config();
if let Some(item) = config
.public_work_interactions
.iter_mut()
.find(|item| item.source_type == source_type)
{
match action {
crate::creation_entry_config::PublicWorkInteractionAction::Like => {
item.like_enabled = enabled;
}
crate::creation_entry_config::PublicWorkInteractionAction::Remix => {
item.remix_enabled = enabled;
}
}
}
self.cache_test_creation_entry_config(config);
}
#[cfg(test)]
pub(crate) fn set_test_creation_entry_route_enabled(
&self,

View File

@@ -11,7 +11,7 @@ use crate::errors::RuntimeProfileFieldError;
use crate::format_utc_micros;
use shared_contracts::creation_entry_config::{
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse, PublicWorkInteractionConfigResponse,
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
};
@@ -27,6 +27,9 @@ pub fn build_creation_entry_config_response(
.first()
.cloned()
.unwrap_or_else(|| build_creation_entry_event_banner_response(snapshot.event_banner));
let public_work_interactions = resolve_public_work_interaction_config_responses(
snapshot.public_work_interactions_json.as_deref(),
);
CreationEntryConfigResponse {
start_card: CreationEntryStartCardResponse {
@@ -41,6 +44,7 @@ pub fn build_creation_entry_config_response(
},
event_banner,
event_banners,
public_work_interactions,
creation_types: snapshot
.creation_types
.into_iter()
@@ -69,6 +73,223 @@ pub fn build_creation_entry_config_response(
}
}
/// 返回公开作品点赞 / 改造默认矩阵,保持历史前端硬编码能力不变。
pub fn default_public_work_interaction_config_snapshots() -> Vec<PublicWorkInteractionConfigSnapshot>
{
vec![
public_work_interaction_config(
"custom-world",
true,
true,
"RPG 作品暂不支持点赞。",
"RPG 作品暂不支持改造。",
),
public_work_interaction_config(
"big-fish",
true,
true,
"摸鱼点赞暂不可用。",
"摸鱼作品改造暂不可用。",
),
public_work_interaction_config(
"puzzle",
true,
true,
"拼图点赞暂不可用。",
"拼图作品改造暂不可用。",
),
public_work_interaction_config(
"puzzle-clear",
false,
false,
"拼消消点赞将在后续版本开放。",
"拼消消作品改造将在后续版本开放。",
),
public_work_interaction_config(
"jump-hop",
false,
false,
"作品类型 jump-hop 暂不支持点赞。",
"跳一跳作品改造将在后续版本开放。",
),
public_work_interaction_config(
"wooden-fish",
false,
false,
"作品类型 wooden-fish 暂不支持点赞。",
"敲木鱼作品改造将在后续版本开放。",
),
public_work_interaction_config(
"match3d",
false,
false,
"作品类型 match3d 暂不支持点赞。",
"抓大鹅作品改造将在后续版本开放。",
),
public_work_interaction_config(
"square-hole",
false,
false,
"方洞挑战点赞将在后续版本开放。",
"方洞挑战作品改造将在后续版本开放。",
),
public_work_interaction_config(
"visual-novel",
false,
false,
"视觉小说点赞将在后续版本开放。",
"视觉小说作品改造将在后续版本开放。",
),
public_work_interaction_config(
"bark-battle",
false,
false,
"汪汪声浪点赞将在后续版本开放。",
"汪汪声浪作品改造将在后续版本开放。",
),
public_work_interaction_config(
"edutainment",
false,
false,
"宝贝识物点赞将在后续版本开放。",
"宝贝识物作品改造将在创作链路接入后开放。",
),
]
}
fn public_work_interaction_config(
source_type: &str,
like_enabled: bool,
remix_enabled: bool,
like_disabled_message: &str,
remix_disabled_message: &str,
) -> PublicWorkInteractionConfigSnapshot {
PublicWorkInteractionConfigSnapshot {
source_type: source_type.to_string(),
like_enabled,
remix_enabled,
like_disabled_message: like_disabled_message.to_string(),
remix_disabled_message: remix_disabled_message.to_string(),
}
}
/// 生成默认公开作品互动配置 JSON供 SpacetimeDB 表字段持久化。
pub fn default_public_work_interaction_config_json() -> String {
encode_public_work_interaction_config_snapshots(
&default_public_work_interaction_config_snapshots(),
)
.unwrap_or_else(|_| "[]".to_string())
}
/// 校验并归一后台公开作品互动配置 JSON。
pub fn normalize_public_work_interaction_config_json(input: &str) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(default_public_work_interaction_config_json());
}
let configs = decode_public_work_interaction_config_snapshots(trimmed)?;
encode_public_work_interaction_config_snapshots(&configs)
}
/// 解析公开作品互动配置 JSON并补齐缺失 sourceType 的默认项。
pub fn decode_public_work_interaction_config_snapshots(
input: &str,
) -> Result<Vec<PublicWorkInteractionConfigSnapshot>, String> {
let raw_entries = serde_json::from_str::<Vec<PublicWorkInteractionConfigResponse>>(input)
.map_err(|error| format!("作品互动配置 JSON 非法:{error}"))?;
if raw_entries.len() > PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT {
return Err(format!(
"作品互动配置最多允许 {}",
PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT
));
}
let defaults = default_public_work_interaction_config_snapshots();
let default_by_source = defaults
.iter()
.map(|item| (item.source_type.clone(), item.clone()))
.collect::<BTreeMap<_, _>>();
let mut overrides = BTreeMap::<String, PublicWorkInteractionConfigSnapshot>::new();
for (index, entry) in raw_entries.into_iter().enumerate() {
let source_type = entry.source_type.trim().to_string();
let Some(default_entry) = default_by_source.get(&source_type) else {
return Err(format!("{} 条作品类型非法:{}", index + 1, source_type));
};
if overrides.contains_key(&source_type) {
return Err(format!("作品互动配置 sourceType 重复:{source_type}"));
}
overrides.insert(
source_type.clone(),
PublicWorkInteractionConfigSnapshot {
source_type,
like_enabled: entry.like_enabled,
remix_enabled: entry.remix_enabled,
like_disabled_message: normalize_interaction_message(
entry.like_disabled_message,
&default_entry.like_disabled_message,
),
remix_disabled_message: normalize_interaction_message(
entry.remix_disabled_message,
&default_entry.remix_disabled_message,
),
},
);
}
Ok(defaults
.into_iter()
.map(|item| overrides.remove(&item.source_type).unwrap_or(item))
.collect())
}
fn normalize_interaction_message(value: String, fallback: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
/// 把公开作品互动领域快照编码为稳定 JSON。
pub fn encode_public_work_interaction_config_snapshots(
configs: &[PublicWorkInteractionConfigSnapshot],
) -> Result<String, String> {
let responses = configs
.iter()
.cloned()
.map(build_public_work_interaction_config_response)
.collect::<Vec<_>>();
serde_json::to_string_pretty(&responses)
.map_err(|error| format!("作品互动配置 JSON 序列化失败:{error}"))
}
/// 根据持久化 JSON 得到前台可消费的公开作品互动矩阵。
pub fn resolve_public_work_interaction_config_responses(
public_work_interactions_json: Option<&str>,
) -> Vec<PublicWorkInteractionConfigResponse> {
public_work_interactions_json
.and_then(|raw| decode_public_work_interaction_config_snapshots(raw).ok())
.unwrap_or_else(default_public_work_interaction_config_snapshots)
.into_iter()
.map(build_public_work_interaction_config_response)
.collect()
}
pub fn build_public_work_interaction_config_response(
config: PublicWorkInteractionConfigSnapshot,
) -> PublicWorkInteractionConfigResponse {
PublicWorkInteractionConfigResponse {
source_type: config.source_type,
like_enabled: config.like_enabled,
remix_enabled: config.remix_enabled,
like_disabled_message: config.like_disabled_message,
remix_disabled_message: config.remix_disabled_message,
}
}
/// 返回平台默认公告配置,用于空库种子和旧库兜底。
pub fn default_creation_entry_event_banner_snapshots() -> Vec<CreationEntryEventBannerSnapshot> {
vec![CreationEntryEventBannerSnapshot {

View File

@@ -65,6 +65,8 @@ pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59";
pub const CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT: usize = 8;
/// 单条 HTML 公告的代码大小上限,避免后台误贴超大片段拖慢入口页。
pub const CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES: usize = 12_000;
/// 公开作品互动配置最多允许覆盖的 sourceType 数量。
pub const PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT: usize = 32;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -96,6 +98,17 @@ pub struct CreationEntryEventBannerSnapshot {
pub html_code: Option<String>,
}
/// 单类公开作品互动配置,控制作品详情页点赞 / 改造入口与后端动作熔断。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublicWorkInteractionConfigSnapshot {
pub source_type: String,
pub like_enabled: bool,
pub remix_enabled: bool,
pub like_disabled_message: String,
pub remix_disabled_message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryTypeSnapshot {
@@ -126,6 +139,8 @@ pub struct CreationEntryConfigSnapshot {
pub event_banners_json: Option<String>,
pub creation_types: Vec<CreationEntryTypeSnapshot>,
pub updated_at_micros: i64,
/// 公开作品点赞 / 改造能力 JSON 配置;旧库为空时由应用层兜底。
pub public_work_interactions_json: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -153,6 +168,14 @@ pub struct CreationEntryEventBannersAdminUpsertInput {
pub event_banners_json: String,
}
/// 后台保存公开作品互动能力表单序列化结果的领域输入。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublicWorkInteractionConfigAdminUpsertInput {
/// 持久化字段沿用 JSON 字符串,内容由后台表单生成。
pub public_work_interactions_json: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryConfigProcedureResult {

View File

@@ -531,6 +531,7 @@ mod tests {
),
}],
updated_at_micros: 1,
public_work_interactions_json: Some(default_public_work_interaction_config_json()),
});
let puzzle = response
.creation_types
@@ -547,6 +548,37 @@ mod tests {
);
}
#[test]
fn public_work_interaction_config_defaults_and_overrides() {
let defaults = resolve_public_work_interaction_config_responses(None);
let puzzle = defaults
.iter()
.find(|item| item.source_type == "puzzle")
.expect("puzzle interaction should exist");
assert!(puzzle.like_enabled);
assert!(puzzle.remix_enabled);
let normalized = normalize_public_work_interaction_config_json(
r#"[{
"sourceType": "puzzle",
"likeEnabled": false,
"remixEnabled": true,
"likeDisabledMessage": "拼图点赞维护中。",
"remixDisabledMessage": ""
}]"#,
)
.expect("interaction config should normalize");
let resolved = resolve_public_work_interaction_config_responses(Some(&normalized));
let puzzle = resolved
.iter()
.find(|item| item.source_type == "puzzle")
.expect("puzzle interaction should exist");
assert!(!puzzle.like_enabled);
assert_eq!(puzzle.like_disabled_message, "拼图点赞维护中。");
assert_eq!(puzzle.remix_disabled_message, "拼图作品改造暂不可用。");
}
#[test]
fn normalized_clamps_music_volume_into_valid_range() {
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);

View File

@@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::creation_entry_config::{CreationEntryEventBannerResponse, UnifiedCreationSpecResponse};
use crate::creation_entry_config::{
CreationEntryEventBannerResponse, PublicWorkInteractionConfigResponse,
UnifiedCreationSpecResponse,
};
// 管理后台协议统一收口在 shared-contracts避免页面脚本和 Rust handler 各自手拼字段。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -20,6 +23,8 @@ pub struct AdminCreationEntryConfigResponse {
pub entries: Vec<AdminCreationEntryTypeConfigPayload>,
/// 底部加号创作入口页的后台公告列表。
pub event_banners: Vec<CreationEntryEventBannerResponse>,
/// 公开作品详情页点赞 / 改造能力配置。
pub public_work_interactions: Vec<PublicWorkInteractionConfigResponse>,
}
/// 后台单个创作入口开关配置。
@@ -69,6 +74,13 @@ pub struct AdminUpsertCreationEntryEventBannersRequest {
pub event_banners_json: String,
}
/// 后台保存公开作品点赞 / 改造能力配置请求。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertPublicWorkInteractionConfigRequest {
pub public_work_interactions: Vec<PublicWorkInteractionConfigResponse>,
}
/// 后台作品可见性列表项。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]

View File

@@ -12,6 +12,7 @@ pub struct CreationEntryConfigResponse {
pub type_modal: CreationEntryTypeModalResponse,
pub event_banner: CreationEntryEventBannerResponse,
pub event_banners: Vec<CreationEntryEventBannerResponse>,
pub public_work_interactions: Vec<PublicWorkInteractionConfigResponse>,
pub creation_types: Vec<CreationEntryTypeResponse>,
}
@@ -57,6 +58,20 @@ pub fn default_creation_entry_event_banner_render_mode() -> String {
"structured".to_string()
}
/// 单类公开作品互动能力配置。
///
/// 后台可以关闭已接入的点赞 / 改造能力;未接入后端动作的玩法即使误开,
/// 前端仍会按实际能力矩阵返回不可用提示。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicWorkInteractionConfigResponse {
pub source_type: String,
pub like_enabled: bool,
pub remix_enabled: bool,
pub like_disabled_message: String,
pub remix_disabled_message: String,
}
/// 单个创作模板入口配置,决定底部加号入口中的分类、排序和开放状态。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -547,6 +562,13 @@ mod tests {
render_mode: "html".to_string(),
html_code: Some("<section>ok</section>".to_string()),
}],
public_work_interactions: vec![PublicWorkInteractionConfigResponse {
source_type: "puzzle".to_string(),
like_enabled: true,
remix_enabled: true,
like_disabled_message: "拼图点赞暂不可用。".to_string(),
remix_disabled_message: "拼图作品改造暂不可用。".to_string(),
}],
creation_types: Vec::new(),
};
let value = serde_json::to_value(response).expect("response should serialize");
@@ -558,5 +580,6 @@ mod tests {
);
assert!(value.get("event_banner").is_none());
assert!(value.get("eventBanner").is_some());
assert_eq!(value["publicWorkInteractions"][0]["sourceType"], "puzzle");
}
}

View File

@@ -30,6 +30,17 @@ impl From<module_runtime::CreationEntryEventBannersAdminUpsertInput>
}
}
/// 将业务层公开作品互动配置保存输入转换为 SpacetimeDB 生成绑定类型。
impl From<module_runtime::PublicWorkInteractionConfigAdminUpsertInput>
for PublicWorkInteractionConfigAdminUpsertInput
{
fn from(input: module_runtime::PublicWorkInteractionConfigAdminUpsertInput) -> Self {
Self {
public_work_interactions_json: input.public_work_interactions_json,
}
}
}
impl From<module_runtime::AdminWorkVisibilityListInput> for AdminWorkVisibilityListInput {
fn from(input: module_runtime::AdminWorkVisibilityListInput) -> Self {
Self {
@@ -323,6 +334,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
})
.collect(),
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
public_work_interactions_json: header.public_work_interactions_json,
},
)
}
@@ -376,6 +388,7 @@ fn map_creation_entry_config_snapshot(
})
.collect(),
updated_at_micros: snapshot.updated_at_micros,
public_work_interactions_json: snapshot.public_work_interactions_json,
}
}
@@ -432,6 +445,7 @@ mod tests {
event_starts_at_text: None,
event_ends_at_text: None,
event_banners_json: None,
public_work_interactions_json: None,
}
}
@@ -514,6 +528,7 @@ mod tests {
unified_creation_spec_json: None,
}],
updated_at_micros: 1_000_000,
public_work_interactions_json: None,
});
let jump_hop = record

View File

@@ -591,6 +591,7 @@ pub mod public_work_detail_entry_table;
pub mod public_work_detail_entry_type;
pub mod public_work_gallery_entry_table;
pub mod public_work_gallery_entry_type;
pub mod public_work_interaction_config_admin_upsert_input_type;
pub mod public_work_like_table;
pub mod public_work_like_type;
pub mod public_work_play_daily_stat_table;
@@ -1022,6 +1023,7 @@ pub mod upsert_custom_world_profile_reducer;
pub mod upsert_npc_state_and_return_procedure;
pub mod upsert_npc_state_reducer;
pub mod upsert_platform_browse_history_and_return_procedure;
pub mod upsert_public_work_interaction_config_procedure;
pub mod upsert_runtime_setting_and_return_procedure;
pub mod upsert_runtime_snapshot_and_return_procedure;
pub mod upsert_visual_novel_run_snapshot_procedure;
@@ -1697,6 +1699,7 @@ pub use public_work_detail_entry_table::*;
pub use public_work_detail_entry_type::PublicWorkDetailEntry;
pub use public_work_gallery_entry_table::*;
pub use public_work_gallery_entry_type::PublicWorkGalleryEntry;
pub use public_work_interaction_config_admin_upsert_input_type::PublicWorkInteractionConfigAdminUpsertInput;
pub use public_work_like_table::*;
pub use public_work_like_type::PublicWorkLike;
pub use public_work_play_daily_stat_table::*;
@@ -2128,6 +2131,7 @@ pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile;
pub use upsert_npc_state_and_return_procedure::upsert_npc_state_and_return;
pub use upsert_npc_state_reducer::upsert_npc_state;
pub use upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return;
pub use upsert_public_work_interaction_config_procedure::upsert_public_work_interaction_config;
pub use upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return;
pub use upsert_runtime_snapshot_and_return_procedure::upsert_runtime_snapshot_and_return;
pub use upsert_visual_novel_run_snapshot_procedure::upsert_visual_novel_run_snapshot;

View File

@@ -19,6 +19,7 @@ pub struct CreationEntryConfigSnapshot {
pub event_banners_json: Option<String>,
pub creation_types: Vec<CreationEntryTypeSnapshot>,
pub updated_at_micros: i64,
pub public_work_interactions_json: Option<String>,
}
impl __sdk::InModule for CreationEntryConfigSnapshot {

View File

@@ -22,6 +22,7 @@ pub struct CreationEntryConfig {
pub event_starts_at_text: Option<String>,
pub event_ends_at_text: Option<String>,
pub event_banners_json: Option<String>,
pub public_work_interactions_json: Option<String>,
}
impl __sdk::InModule for CreationEntryConfig {
@@ -47,6 +48,8 @@ pub struct CreationEntryConfigCols {
pub event_starts_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
pub event_ends_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
pub event_banners_json: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
pub public_work_interactions_json:
__sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
}
impl __sdk::__query_builder::HasCols for CreationEntryConfig {
@@ -77,6 +80,10 @@ impl __sdk::__query_builder::HasCols for CreationEntryConfig {
),
event_ends_at_text: __sdk::__query_builder::Col::new(table_name, "event_ends_at_text"),
event_banners_json: __sdk::__query_builder::Col::new(table_name, "event_banners_json"),
public_work_interactions_json: __sdk::__query_builder::Col::new(
table_name,
"public_work_interactions_json",
),
}
}
}

View File

@@ -115,6 +115,34 @@ impl SpacetimeClient {
Ok(config)
}
/// 调用 SpacetimeDB procedure 保存公开作品互动配置并刷新缓存。
pub async fn upsert_public_work_interaction_config(
&self,
input: module_runtime::PublicWorkInteractionConfigAdminUpsertInput,
) -> Result<CreationEntryConfigRecord, SpacetimeClientError> {
let procedure_input: PublicWorkInteractionConfigAdminUpsertInput = input.into();
let config = self
.call_after_connect(
"upsert_public_work_interaction_config",
move |connection, sender| {
connection
.procedures()
.upsert_public_work_interaction_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?;
self.cache_creation_entry_config(config.clone()).await;
Ok(config)
}
pub async fn admin_list_work_visibility(
&self,
admin_user_id: String,

View File

@@ -1193,6 +1193,9 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("event_banners_json".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("public_work_interactions_json".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "creation_entry_type_config" {

View File

@@ -26,6 +26,9 @@ pub struct CreationEntryConfig {
/// 底部加号创作入口页的多 banner JSON 配置,旧单条字段仅用于兼容。
#[default(None::<String>)]
pub(crate) event_banners_json: Option<String>,
/// 公开作品点赞 / 改造能力配置,旧库为空时由读取层按默认矩阵兜底。
#[default(None::<String>)]
pub(crate) public_work_interactions_json: Option<String>,
}
#[spacetimedb::table(
@@ -109,6 +112,26 @@ pub fn upsert_creation_entry_event_banners_config(
}
}
#[spacetimedb::procedure]
/// 后台保存公开作品点赞 / 改造能力配置的过程入口。
pub fn upsert_public_work_interaction_config(
ctx: &mut ProcedureContext,
input: PublicWorkInteractionConfigAdminUpsertInput,
) -> CreationEntryConfigProcedureResult {
match ctx.try_with_tx(|tx| upsert_public_work_interaction_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,
@@ -171,6 +194,33 @@ fn upsert_creation_entry_event_banners_config_in_tx(
get_or_seed_creation_entry_config_snapshot(ctx)
}
/// 在事务内归一化公开作品互动配置 JSON 并更新全局入口配置表头。
fn upsert_public_work_interaction_config_in_tx(
ctx: &ReducerContext,
input: PublicWorkInteractionConfigAdminUpsertInput,
) -> Result<CreationEntryConfigSnapshot, String> {
seed_creation_entry_config_if_missing(ctx);
let now = ctx.timestamp;
let config_id = CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string();
let Some(header) = ctx.db.creation_entry_config().config_id().find(&config_id) else {
return Err("创作入口配置初始化失败".to_string());
};
let public_work_interactions_json =
module_runtime::normalize_public_work_interaction_config_json(
&input.public_work_interactions_json,
)?;
ctx.db
.creation_entry_config()
.config_id()
.update(CreationEntryConfig {
updated_at: now,
public_work_interactions_json: Some(public_work_interactions_json),
..header
});
get_or_seed_creation_entry_config_snapshot(ctx)
}
fn get_or_seed_creation_entry_config_snapshot(
ctx: &ReducerContext,
) -> Result<CreationEntryConfigSnapshot, String> {
@@ -247,6 +297,7 @@ fn get_or_seed_creation_entry_config_snapshot(
event_banners_json: header.event_banners_json,
creation_types,
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
public_work_interactions_json: header.public_work_interactions_json,
})
}
@@ -276,6 +327,9 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
event_starts_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string()),
event_ends_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string()),
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
public_work_interactions_json: Some(
module_runtime::default_public_work_interaction_config_json(),
),
});
}