点赞和改造开关加入后台配置
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user