feat: add admin work visibility controls

This commit is contained in:
kdletters
2026-05-28 00:49:45 +08:00
parent 8e96c8a67c
commit dbbd48083a
38 changed files with 1807 additions and 89 deletions

View File

@@ -24,7 +24,9 @@ use shared_contracts::admin::{
AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest,
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
AdminTrackingEventListResponse, AdminUpsertCreationEntryTypeConfigRequest,
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryTypeConfigRequest,
AdminWorkVisibilityListResponse,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
@@ -239,6 +241,40 @@ pub async fn admin_upsert_creation_entry_config(
))
}
pub async fn admin_list_work_visibility(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, AppError> {
let admin_user_id = admin.session().subject.clone();
let entries = state
.list_admin_work_visibility(admin_user_id)
.await
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
AdminWorkVisibilityListResponse { entries },
))
}
pub async fn admin_update_work_visibility(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpdateWorkVisibilityRequest>,
) -> Result<Json<Value>, AppError> {
let entry = validate_admin_work_visibility(payload)?;
let admin_user_id = admin.session().subject.clone();
let record = state
.update_admin_work_visibility(admin_user_id, entry.0, entry.1, entry.2)
.await
.map_err(map_admin_spacetime_error)?;
Ok(json_success_body(
Some(&request_context),
AdminUpdateWorkVisibilityResponse { entry: record },
))
}
fn map_admin_creation_entry_type_config(
entry: shared_contracts::creation_entry_config::CreationEntryTypeResponse,
) -> AdminCreationEntryTypeConfigPayload {
@@ -284,6 +320,20 @@ fn validate_admin_creation_entry_config(
})
}
fn validate_admin_work_visibility(
payload: AdminUpdateWorkVisibilityRequest,
) -> Result<(String, String, bool), AppError> {
let source_type = payload.source_type.trim().to_string();
if source_type.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("sourceType 不能为空"));
}
let profile_id = payload.profile_id.trim().to_string();
if profile_id.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("profileId 不能为空"));
}
Ok((source_type, profile_id, payload.visible))
}
fn map_admin_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(serde_json::json!({
"provider": "spacetimedb",

View File

@@ -3,8 +3,9 @@ use axum::{Router, middleware, routing::get};
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_login, admin_me,
admin_overview, admin_upsert_creation_entry_config, require_admin_auth,
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,
},
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
@@ -70,6 +71,15 @@ pub fn router(state: AppState) -> Router<AppState> {
require_admin_auth,
)),
)
.route(
"/admin/api/works/visibility",
get(admin_list_work_visibility)
.post(admin_update_work_visibility)
.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

@@ -478,6 +478,29 @@ impl AppState {
}
}
pub async fn list_admin_work_visibility(
&self,
admin_user_id: String,
) -> Result<Vec<shared_contracts::admin::AdminWorkVisibilityEntryPayload>, SpacetimeClientError>
{
self.spacetime_client
.admin_list_work_visibility(admin_user_id)
.await
}
pub async fn update_admin_work_visibility(
&self,
admin_user_id: String,
source_type: String,
profile_id: String,
visible: bool,
) -> Result<shared_contracts::admin::AdminWorkVisibilityEntryPayload, SpacetimeClientError>
{
self.spacetime_client
.admin_update_work_visibility(admin_user_id, source_type, profile_id, visible)
.await
}
pub async fn is_creation_entry_route_enabled(
&self,
creation_type_id: &str,

View File

@@ -139,6 +139,59 @@ pub struct CreationEntryConfigProcedureResult {
pub error_message: Option<String>,
}
/// 后台作品可见性列表项。
///
/// source_type/profile_id 是后台统一操作键;少数玩法的 profile_id 会映射到底层
/// session_id 或 work_id避免后台了解每个源表的主键差异。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminWorkVisibilitySnapshot {
pub source_type: String,
pub work_id: String,
pub profile_id: String,
pub source_session_id: Option<String>,
pub public_work_code: String,
pub owner_user_id: String,
pub author_display_name: String,
pub title: String,
pub subtitle: String,
pub cover_image_src: Option<String>,
pub visible: bool,
pub published_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminWorkVisibilityListInput {
pub admin_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminWorkVisibilityUpdateInput {
pub admin_user_id: String,
pub source_type: String,
pub profile_id: String,
pub visible: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminWorkVisibilityListProcedureResult {
pub ok: bool,
pub entries: Vec<AdminWorkVisibilitySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminWorkVisibilityProcedureResult {
pub ok: bool,
pub record: Option<AdminWorkVisibilitySnapshot>,
pub error_message: Option<String>,
}
/// 分析日期维表的纯领域快照。
///
/// date_key 沿用现有北京时间自然日桶floor((occurred_at_micros + 8h) / 1d)。

View File

@@ -53,6 +53,48 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest {
pub category_sort_order: i32,
}
/// 后台作品可见性列表项。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminWorkVisibilityEntryPayload {
pub source_type: String,
pub work_id: String,
pub profile_id: String,
pub source_session_id: Option<String>,
pub public_work_code: String,
pub owner_user_id: String,
pub author_display_name: String,
pub title: String,
pub subtitle: String,
pub cover_image_src: Option<String>,
pub visible: bool,
pub published_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
/// 后台作品可见性列表响应。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminWorkVisibilityListResponse {
pub entries: Vec<AdminWorkVisibilityEntryPayload>,
}
/// 后台修改作品可见性请求。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpdateWorkVisibilityRequest {
pub source_type: String,
pub profile_id: String,
pub visible: bool,
}
/// 后台修改作品可见性响应。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpdateWorkVisibilityResponse {
pub entry: AdminWorkVisibilityEntryPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminLoginResponse {

View File

@@ -14,9 +14,10 @@ pub use mapper::{
BigFishDraftCompileRecordInput, BigFishGameDraftRecord, BigFishInputSubmitRecordInput,
BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput, BigFishMessageFinalizeRecordInput,
BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRunStartRecordInput,
BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record,
BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, CreationEntryConfigRecord,
AdminWorkVisibilityRecord, BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord,
BigFishRuntimeRunRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord,
BigFishVector2Record, BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord,
CreationEntryConfigRecord,
CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput,
CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput,
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,

View File

@@ -115,6 +115,7 @@ pub use self::puzzle::{
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
};
pub use self::runtime::{
AdminWorkVisibilityRecord,
BigFishGameDraftRecord, BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord,
BigFishRuntimeRunRecord, CreationEntryConfigRecord,
};
@@ -193,7 +194,9 @@ pub(crate) use self::puzzle::{
parse_puzzle_agent_stage_record,
};
pub(crate) use self::runtime::{
build_creation_entry_config_record_from_rows, map_creation_entry_config_procedure_result,
build_admin_work_visibility_list_input, build_admin_work_visibility_update_input,
build_creation_entry_config_record_from_rows, map_admin_work_visibility_list_procedure_result,
map_admin_work_visibility_procedure_result, map_creation_entry_config_procedure_result,
map_runtime_setting_procedure_result, map_runtime_snapshot_delete_procedure_result,
map_runtime_snapshot_procedure_result, map_runtime_snapshot_required_procedure_result,
map_runtime_tracking_event_batch_procedure_result, map_runtime_tracking_event_procedure_result,

View File

@@ -18,6 +18,61 @@ impl From<module_runtime::CreationEntryTypeAdminUpsertInput> for CreationEntryTy
}
}
impl From<module_runtime::AdminWorkVisibilityListInput> for AdminWorkVisibilityListInput {
fn from(input: module_runtime::AdminWorkVisibilityListInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
}
}
}
impl From<module_runtime::AdminWorkVisibilityUpdateInput> for AdminWorkVisibilityUpdateInput {
fn from(input: module_runtime::AdminWorkVisibilityUpdateInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
source_type: input.source_type,
profile_id: input.profile_id,
visible: input.visible,
}
}
}
pub(crate) fn build_admin_work_visibility_list_input(
admin_user_id: String,
) -> Result<module_runtime::AdminWorkVisibilityListInput, String> {
let admin_user_id = admin_user_id.trim().to_string();
if admin_user_id.is_empty() {
return Err("adminUserId 不能为空".to_string());
}
Ok(module_runtime::AdminWorkVisibilityListInput { admin_user_id })
}
pub(crate) fn build_admin_work_visibility_update_input(
admin_user_id: String,
source_type: String,
profile_id: String,
visible: bool,
) -> Result<module_runtime::AdminWorkVisibilityUpdateInput, String> {
let admin_user_id = admin_user_id.trim().to_string();
if admin_user_id.is_empty() {
return Err("adminUserId 不能为空".to_string());
}
let source_type = source_type.trim().to_string();
if source_type.is_empty() {
return Err("sourceType 不能为空".to_string());
}
let profile_id = profile_id.trim().to_string();
if profile_id.is_empty() {
return Err("profileId 不能为空".to_string());
}
Ok(module_runtime::AdminWorkVisibilityUpdateInput {
admin_user_id,
source_type,
profile_id,
visible,
})
}
impl From<module_runtime::RuntimeSettingGetInput> for RuntimeSettingGetInput {
fn from(input: module_runtime::RuntimeSettingGetInput) -> Self {
Self {
@@ -114,6 +169,7 @@ impl From<module_runtime::RuntimeTrackingEventInput> for RuntimeTrackingEventInp
pub type CreationEntryConfigRecord =
shared_contracts::creation_entry_config::CreationEntryConfigResponse;
pub type AdminWorkVisibilityRecord = shared_contracts::admin::AdminWorkVisibilityEntryPayload;
pub(crate) fn map_creation_entry_config_procedure_result(
result: CreationEntryConfigProcedureResult,
@@ -131,6 +187,51 @@ pub(crate) fn map_creation_entry_config_procedure_result(
))
}
pub(crate) fn map_admin_work_visibility_list_procedure_result(
result: AdminWorkVisibilityListProcedureResult,
) -> Result<Vec<AdminWorkVisibilityRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result
.entries
.into_iter()
.map(map_admin_work_visibility_snapshot)
.collect())
}
pub(crate) fn map_admin_work_visibility_procedure_result(
result: AdminWorkVisibilityProcedureResult,
) -> Result<AdminWorkVisibilityRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
result
.record
.map(map_admin_work_visibility_snapshot)
.ok_or_else(|| SpacetimeClientError::missing_snapshot("后台作品可见性快照"))
}
fn map_admin_work_visibility_snapshot(
snapshot: AdminWorkVisibilitySnapshot,
) -> AdminWorkVisibilityRecord {
AdminWorkVisibilityRecord {
source_type: snapshot.source_type,
work_id: snapshot.work_id,
profile_id: snapshot.profile_id,
source_session_id: snapshot.source_session_id,
public_work_code: snapshot.public_work_code,
owner_user_id: snapshot.owner_user_id,
author_display_name: snapshot.author_display_name,
title: snapshot.title,
subtitle: snapshot.subtitle,
cover_image_src: snapshot.cover_image_src,
visible: snapshot.visible,
published_at_micros: snapshot.published_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn build_creation_entry_config_record_from_rows(
header: CreationEntryConfig,
mut creation_types: Vec<CreationEntryTypeConfig>,

View File

@@ -14,10 +14,17 @@ pub mod admin_list_profile_invite_codes_procedure;
pub mod admin_list_profile_recharge_products_procedure;
pub mod admin_list_profile_redeem_codes_procedure;
pub mod admin_list_profile_task_configs_procedure;
pub mod admin_list_work_visibility_procedure;
pub mod admin_update_work_visibility_procedure;
pub mod admin_upsert_profile_invite_code_procedure;
pub mod admin_upsert_profile_recharge_product_procedure;
pub mod admin_upsert_profile_redeem_code_procedure;
pub mod admin_upsert_profile_task_config_procedure;
pub mod admin_work_visibility_list_input_type;
pub mod admin_work_visibility_list_procedure_result_type;
pub mod admin_work_visibility_procedure_result_type;
pub mod admin_work_visibility_snapshot_type;
pub mod admin_work_visibility_update_input_type;
pub mod advance_puzzle_next_level_procedure;
pub mod ai_result_reference_input_type;
pub mod ai_result_reference_kind_type;
@@ -1049,10 +1056,17 @@ pub use admin_list_profile_invite_codes_procedure::admin_list_profile_invite_cod
pub use admin_list_profile_recharge_products_procedure::admin_list_profile_recharge_products;
pub use admin_list_profile_redeem_codes_procedure::admin_list_profile_redeem_codes;
pub use admin_list_profile_task_configs_procedure::admin_list_profile_task_configs;
pub use admin_list_work_visibility_procedure::admin_list_work_visibility;
pub use admin_update_work_visibility_procedure::admin_update_work_visibility;
pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code;
pub use admin_upsert_profile_recharge_product_procedure::admin_upsert_profile_recharge_product;
pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code;
pub use admin_upsert_profile_task_config_procedure::admin_upsert_profile_task_config;
pub use admin_work_visibility_list_input_type::AdminWorkVisibilityListInput;
pub use admin_work_visibility_list_procedure_result_type::AdminWorkVisibilityListProcedureResult;
pub use admin_work_visibility_procedure_result_type::AdminWorkVisibilityProcedureResult;
pub use admin_work_visibility_snapshot_type::AdminWorkVisibilitySnapshot;
pub use admin_work_visibility_update_input_type::AdminWorkVisibilityUpdateInput;
pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level;
pub use ai_result_reference_input_type::AiResultReferenceInput;
pub use ai_result_reference_kind_type::AiResultReferenceKind;

View File

@@ -0,0 +1,59 @@
// 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::admin_work_visibility_list_input_type::AdminWorkVisibilityListInput;
use super::admin_work_visibility_list_procedure_result_type::AdminWorkVisibilityListProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminListWorkVisibilityArgs {
pub input: AdminWorkVisibilityListInput,
}
impl __sdk::InModule for AdminListWorkVisibilityArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_list_work_visibility`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_list_work_visibility {
fn admin_list_work_visibility(&self, input: AdminWorkVisibilityListInput) {
self.admin_list_work_visibility_then(input, |_, _| {});
}
fn admin_list_work_visibility_then(
&self,
input: AdminWorkVisibilityListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AdminWorkVisibilityListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_list_work_visibility for super::RemoteProcedures {
fn admin_list_work_visibility_then(
&self,
input: AdminWorkVisibilityListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AdminWorkVisibilityListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AdminWorkVisibilityListProcedureResult>(
"admin_list_work_visibility",
AdminListWorkVisibilityArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// 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::admin_work_visibility_procedure_result_type::AdminWorkVisibilityProcedureResult;
use super::admin_work_visibility_update_input_type::AdminWorkVisibilityUpdateInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminUpdateWorkVisibilityArgs {
pub input: AdminWorkVisibilityUpdateInput,
}
impl __sdk::InModule for AdminUpdateWorkVisibilityArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_update_work_visibility`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_update_work_visibility {
fn admin_update_work_visibility(&self, input: AdminWorkVisibilityUpdateInput) {
self.admin_update_work_visibility_then(input, |_, _| {});
}
fn admin_update_work_visibility_then(
&self,
input: AdminWorkVisibilityUpdateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AdminWorkVisibilityProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_update_work_visibility for super::RemoteProcedures {
fn admin_update_work_visibility_then(
&self,
input: AdminWorkVisibilityUpdateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AdminWorkVisibilityProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AdminWorkVisibilityProcedureResult>(
"admin_update_work_visibility",
AdminUpdateWorkVisibilityArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,15 @@
// 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 AdminWorkVisibilityListInput {
pub admin_user_id: String,
}
impl __sdk::InModule for AdminWorkVisibilityListInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// 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::admin_work_visibility_snapshot_type::AdminWorkVisibilitySnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AdminWorkVisibilityListProcedureResult {
pub ok: bool,
pub entries: Vec<AdminWorkVisibilitySnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for AdminWorkVisibilityListProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// 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::admin_work_visibility_snapshot_type::AdminWorkVisibilitySnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AdminWorkVisibilityProcedureResult {
pub ok: bool,
pub record: Option<AdminWorkVisibilitySnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for AdminWorkVisibilityProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,27 @@
// 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 AdminWorkVisibilitySnapshot {
pub source_type: String,
pub work_id: String,
pub profile_id: String,
pub source_session_id: Option<String>,
pub public_work_code: String,
pub owner_user_id: String,
pub author_display_name: String,
pub title: String,
pub subtitle: String,
pub cover_image_src: Option<String>,
pub visible: bool,
pub published_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for AdminWorkVisibilitySnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,18 @@
// 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 AdminWorkVisibilityUpdateInput {
pub admin_user_id: String,
pub source_type: String,
pub profile_id: String,
pub visible: bool,
}
impl __sdk::InModule for AdminWorkVisibilityUpdateInput {
type Module = super::RemoteModule;
}

View File

@@ -87,6 +87,61 @@ impl SpacetimeClient {
Ok(config)
}
pub async fn admin_list_work_visibility(
&self,
admin_user_id: String,
) -> Result<Vec<shared_contracts::admin::AdminWorkVisibilityEntryPayload>, SpacetimeClientError>
{
let procedure_input = build_admin_work_visibility_list_input(admin_user_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect("admin_list_work_visibility", move |connection, sender| {
connection
.procedures()
.admin_list_work_visibility_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_admin_work_visibility_list_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_update_work_visibility(
&self,
admin_user_id: String,
source_type: String,
profile_id: String,
visible: bool,
) -> Result<shared_contracts::admin::AdminWorkVisibilityEntryPayload, SpacetimeClientError>
{
let procedure_input = build_admin_work_visibility_update_input(
admin_user_id,
source_type,
profile_id,
visible,
)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(
"admin_update_work_visibility",
move |connection, sender| {
connection
.procedures()
.admin_update_work_visibility_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_admin_work_visibility_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn get_runtime_settings(
&self,
user_id: String,

View File

@@ -42,7 +42,7 @@ pub struct BarkBattlePublishedConfigRow {
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Timestamp,
// ???????????????????????????????
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}

View File

@@ -30,7 +30,7 @@ pub struct BigFishCreationSession {
pub(crate) like_count: u32,
#[default(None::<Timestamp>)]
pub(crate) published_at: Option<Timestamp>,
// ???????????????????????????????
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}

View File

@@ -14,39 +14,39 @@ const WORK_VISIBLE_DEFAULT: bool = true;
)]
pub struct CustomWorldProfile {
#[primary_key]
profile_id: String,
pub(crate) profile_id: String,
// 当前 profile 承接 library / publish / enter-world 的正式世界工件真相。
owner_user_id: String,
pub(crate) owner_user_id: String,
// 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。
public_work_code: Option<String>,
pub(crate) public_work_code: Option<String>,
// 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
author_public_user_code: Option<String>,
source_agent_session_id: Option<String>,
publication_status: CustomWorldPublicationStatus,
world_name: String,
subtitle: String,
summary_text: String,
theme_mode: CustomWorldThemeMode,
cover_image_src: Option<String>,
profile_payload_json: String,
playable_npc_count: u32,
landmark_count: u32,
pub(crate) author_public_user_code: Option<String>,
pub(crate) source_agent_session_id: Option<String>,
pub(crate) publication_status: CustomWorldPublicationStatus,
pub(crate) world_name: String,
pub(crate) subtitle: String,
pub(crate) summary_text: String,
pub(crate) theme_mode: CustomWorldThemeMode,
pub(crate) cover_image_src: Option<String>,
pub(crate) profile_payload_json: String,
pub(crate) playable_npc_count: u32,
pub(crate) landmark_count: u32,
// 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。
#[default(0)]
play_count: u32,
pub(crate) play_count: u32,
#[default(0)]
remix_count: u32,
pub(crate) remix_count: u32,
#[default(0)]
like_count: u32,
author_display_name: String,
published_at: Option<Timestamp>,
pub(crate) like_count: u32,
pub(crate) author_display_name: String,
pub(crate) published_at: Option<Timestamp>,
// 软删除后保留 profile 真相,供审计与幂等删除使用。
deleted_at: Option<Timestamp>,
created_at: Timestamp,
updated_at: Timestamp,
// ??????????????????????????
pub(crate) deleted_at: Option<Timestamp>,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
visible: bool,
pub(crate) visible: bool,
}
#[spacetimedb::table(
@@ -175,31 +175,31 @@ pub struct CustomWorldDraftCard {
)]
pub struct CustomWorldGalleryEntry {
#[primary_key]
profile_id: String,
pub(crate) profile_id: String,
// 画廊是公开订阅读模型,不再运行时从 profile 即席拼装。
owner_user_id: String,
public_work_code: String,
author_public_user_code: String,
author_display_name: String,
world_name: String,
subtitle: String,
summary_text: String,
cover_image_src: Option<String>,
theme_mode: CustomWorldThemeMode,
playable_npc_count: u32,
landmark_count: u32,
pub(crate) owner_user_id: String,
pub(crate) public_work_code: String,
pub(crate) author_public_user_code: String,
pub(crate) author_display_name: String,
pub(crate) world_name: String,
pub(crate) subtitle: String,
pub(crate) summary_text: String,
pub(crate) cover_image_src: Option<String>,
pub(crate) theme_mode: CustomWorldThemeMode,
pub(crate) playable_npc_count: u32,
pub(crate) landmark_count: u32,
// 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。
#[default(0)]
play_count: u32,
pub(crate) play_count: u32,
#[default(0)]
remix_count: u32,
pub(crate) remix_count: u32,
#[default(0)]
like_count: u32,
published_at: Timestamp,
updated_at: Timestamp,
// ??????????????????????????
pub(crate) like_count: u32,
pub(crate) published_at: Timestamp,
pub(crate) updated_at: Timestamp,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
visible: bool,
pub(crate) visible: bool,
}
// Agent 会话首版只负责把可持久化创作状态落进 SpacetimeDBLLM 采集与卡片生成后续再接入。
#[spacetimedb::procedure]

View File

@@ -53,7 +53,7 @@ pub struct JumpHopWorkProfileRow {
pub(crate) play_count: u32,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
// ???????????????????????????????
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}

View File

@@ -62,7 +62,7 @@ pub struct Match3DWorkProfileRow {
pub(crate) published_at: Option<Timestamp>,
#[default(None::<String>)]
pub(crate) generated_item_assets_json: Option<String>,
// ???????????????????????????????
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}

View File

@@ -1243,6 +1243,10 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
}
if table_name == "custom_world_profile" || table_name == "custom_world_gallery_entry" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
// 中文注释:自定义世界公开互动计数字段晚于基础作品表加入,旧迁移包按 0 兼容。
object
.entry("play_count".to_string())
@@ -1257,7 +1261,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
}
if table_name == "puzzle_work_profile" {
if let Some(object) = next_value.as_object_mut() {
// ???????????????????????????????
// 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
@@ -1298,6 +1302,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(fallback_description);
}
}
if table_name == "big_fish_creation_session" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:作品可见性字段晚于大鱼吃小鱼创作会话表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
}
}
if matches!(
table_name,
"jump_hop_work_profile"
@@ -1306,7 +1318,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
| "bark_battle_published_config"
) {
if let Some(object) = next_value.as_object_mut() {
// ???????????????????????????????
// 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
@@ -1314,7 +1326,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
}
if table_name == "match3d_work_profile" {
if let Some(object) = next_value.as_object_mut() {
// ???????????????????????????????
// 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
@@ -1326,7 +1338,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
}
if table_name == "wooden_fish_work_profile" {
if let Some(object) = next_value.as_object_mut() {
// ???????????????????????????????
// 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));

View File

@@ -85,37 +85,37 @@ pub struct PuzzleAgentMessageRow {
)]
pub struct PuzzleWorkProfileRow {
#[primary_key]
profile_id: String,
work_id: String,
owner_user_id: String,
source_session_id: Option<String>,
author_display_name: String,
work_title: String,
work_description: String,
level_name: String,
summary: String,
theme_tags_json: String,
cover_image_src: Option<String>,
cover_asset_id: Option<String>,
levels_json: String,
publication_status: PuzzlePublicationStatus,
play_count: u32,
anchor_pack_json: String,
publish_ready: bool,
created_at: Timestamp,
updated_at: Timestamp,
published_at: Option<Timestamp>,
pub(crate) profile_id: String,
pub(crate) work_id: String,
pub(crate) owner_user_id: String,
pub(crate) source_session_id: Option<String>,
pub(crate) author_display_name: String,
pub(crate) work_title: String,
pub(crate) work_description: String,
pub(crate) level_name: String,
pub(crate) summary: String,
pub(crate) theme_tags_json: String,
pub(crate) cover_image_src: Option<String>,
pub(crate) cover_asset_id: Option<String>,
pub(crate) levels_json: String,
pub(crate) publication_status: PuzzlePublicationStatus,
pub(crate) play_count: u32,
pub(crate) anchor_pack_json: String,
pub(crate) publish_ready: bool,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
#[default(0)]
remix_count: u32,
pub(crate) remix_count: u32,
#[default(0)]
like_count: u32,
pub(crate) like_count: u32,
#[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)]
point_incentive_total_half_points: u64,
pub(crate) point_incentive_total_half_points: u64,
#[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)]
point_incentive_claimed_points: u64,
// ???????????????????????????????
pub(crate) point_incentive_claimed_points: u64,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
visible: bool,
pub(crate) visible: bool,
}
/// 拼图广场公开详情兼容投影。

View File

@@ -1,4 +1,5 @@
pub mod analytics_date_dimension;
mod admin_work_visibility;
mod browse_history;
pub mod creation_entry_config;
mod profile;
@@ -6,6 +7,7 @@ mod settings;
mod snapshots;
pub use analytics_date_dimension::*;
pub use admin_work_visibility::*;
pub use browse_history::*;
pub use creation_entry_config::*;
pub use profile::*;

View File

@@ -0,0 +1,708 @@
use crate::*;
use crate::puzzle::{PuzzleWorkProfileRow, puzzle_work_profile};
use module_custom_world::CustomWorldPublicationStatus;
use module_puzzle::PuzzlePublicationStatus;
const SOURCE_TYPE_PUZZLE: &str = "puzzle";
const SOURCE_TYPE_CUSTOM_WORLD: &str = "custom-world";
const SOURCE_TYPE_JUMP_HOP: &str = "jump-hop";
const SOURCE_TYPE_WOODEN_FISH: &str = "wooden-fish";
const SOURCE_TYPE_MATCH3D: &str = "match3d";
const SOURCE_TYPE_SQUARE_HOLE: &str = "square-hole";
const SOURCE_TYPE_VISUAL_NOVEL: &str = "visual-novel";
const SOURCE_TYPE_BIG_FISH: &str = "big-fish";
const SOURCE_TYPE_BARK_BATTLE: &str = "bark-battle";
/// 后台作品可见性列表。
///
/// 中文注释:后台必须能看到 hidden 作品,不能复用 public_work_* view否则隐藏后无法恢复。
#[spacetimedb::procedure]
pub fn admin_list_work_visibility(
ctx: &mut ProcedureContext,
input: AdminWorkVisibilityListInput,
) -> AdminWorkVisibilityListProcedureResult {
match ctx.try_with_tx(|tx| list_work_visibility_tx(tx, input.clone())) {
Ok(entries) => AdminWorkVisibilityListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => AdminWorkVisibilityListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
/// 后台修改单个作品可见性。
#[spacetimedb::procedure]
pub fn admin_update_work_visibility(
ctx: &mut ProcedureContext,
input: AdminWorkVisibilityUpdateInput,
) -> AdminWorkVisibilityProcedureResult {
match ctx.try_with_tx(|tx| update_work_visibility_tx(tx, input.clone())) {
Ok(record) => AdminWorkVisibilityProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AdminWorkVisibilityProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
fn list_work_visibility_tx(
ctx: &ReducerContext,
input: AdminWorkVisibilityListInput,
) -> Result<Vec<AdminWorkVisibilitySnapshot>, String> {
require_admin_user_id(&input.admin_user_id)?;
let mut entries = Vec::new();
entries.extend(list_puzzle_work_visibility(ctx));
entries.extend(list_custom_world_work_visibility(ctx));
entries.extend(list_jump_hop_work_visibility(ctx));
entries.extend(list_wooden_fish_work_visibility(ctx));
entries.extend(list_match3d_work_visibility(ctx));
entries.extend(list_square_hole_work_visibility(ctx));
entries.extend(list_visual_novel_work_visibility(ctx));
entries.extend(list_big_fish_work_visibility(ctx));
entries.extend(list_bark_battle_work_visibility(ctx));
sort_work_visibility_entries(&mut entries);
Ok(entries)
}
fn update_work_visibility_tx(
ctx: &ReducerContext,
input: AdminWorkVisibilityUpdateInput,
) -> Result<AdminWorkVisibilitySnapshot, String> {
require_admin_user_id(&input.admin_user_id)?;
let source_type = normalize_source_type(&input.source_type)?;
let profile_id = normalize_required_text(&input.profile_id, "profileId")?;
match source_type.as_str() {
SOURCE_TYPE_PUZZLE => update_puzzle_work_visibility(ctx, &profile_id, input.visible),
SOURCE_TYPE_CUSTOM_WORLD => {
update_custom_world_work_visibility(ctx, &profile_id, input.visible)
}
SOURCE_TYPE_JUMP_HOP => update_jump_hop_work_visibility(ctx, &profile_id, input.visible),
SOURCE_TYPE_WOODEN_FISH => {
update_wooden_fish_work_visibility(ctx, &profile_id, input.visible)
}
SOURCE_TYPE_MATCH3D => update_match3d_work_visibility(ctx, &profile_id, input.visible),
SOURCE_TYPE_SQUARE_HOLE => update_square_hole_work_visibility(ctx, &profile_id, input.visible),
SOURCE_TYPE_VISUAL_NOVEL => {
update_visual_novel_work_visibility(ctx, &profile_id, input.visible)
}
SOURCE_TYPE_BIG_FISH => update_big_fish_work_visibility(ctx, &profile_id, input.visible),
SOURCE_TYPE_BARK_BATTLE => {
update_bark_battle_work_visibility(ctx, &profile_id, input.visible)
}
_ => Err(format!("不支持的作品类型:{source_type}")),
}
}
fn list_puzzle_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.map(|row| puzzle_work_visibility_snapshot(&row))
.collect()
}
fn update_puzzle_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "拼图作品不存在".to_string())?;
if row.publication_status != PuzzlePublicationStatus::Published {
return Err("只能修改已发布拼图作品可见性".to_string());
}
let next = PuzzleWorkProfileRow { visible, ..row };
ctx.db
.puzzle_work_profile()
.profile_id()
.delete(&next.profile_id);
ctx.db.puzzle_work_profile().insert(next);
let updated = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "拼图作品可见性更新失败".to_string())?;
Ok(puzzle_work_visibility_snapshot(&updated))
}
fn puzzle_work_visibility_snapshot(row: &PuzzleWorkProfileRow) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_PUZZLE.to_string(),
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
source_session_id: row.source_session_id.clone(),
public_work_code: build_prefixed_public_work_code("PZ", &row.profile_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: choose_non_empty(&[row.work_title.as_str(), row.level_name.as_str()]),
subtitle: "拼图关卡".to_string(),
cover_image_src: row.cover_image_src.clone(),
visible: row.visible,
published_at_micros: row.published_at.map(|value| value.to_micros_since_unix_epoch()),
updated_at_micros: sort_time,
}
}
fn list_custom_world_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.custom_world_profile()
.by_custom_world_profile_publication_status()
.filter(CustomWorldPublicationStatus::Published)
.filter(|row| row.deleted_at.is_none())
.map(|row| custom_world_work_visibility_snapshot(&row))
.collect()
}
fn update_custom_world_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.custom_world_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "自定义世界作品不存在".to_string())?;
if row.publication_status != CustomWorldPublicationStatus::Published || row.deleted_at.is_some()
{
return Err("只能修改已发布自定义世界作品可见性".to_string());
}
let next = CustomWorldProfile { visible, ..row };
let snapshot = custom_world_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.custom_world_profile()
.profile_id()
.delete(&profile_id);
ctx.db.custom_world_profile().insert(next);
if let Some(gallery) = ctx
.db
.custom_world_gallery_entry()
.profile_id()
.find(&profile_id)
{
let next_gallery = CustomWorldGalleryEntry { visible, ..gallery };
let gallery_profile_id = next_gallery.profile_id.clone();
ctx.db
.custom_world_gallery_entry()
.profile_id()
.delete(&gallery_profile_id);
ctx.db.custom_world_gallery_entry().insert(next_gallery);
}
Ok(snapshot)
}
fn custom_world_work_visibility_snapshot(row: &CustomWorldProfile) -> AdminWorkVisibilitySnapshot {
let public_work_code = row
.public_work_code
.clone()
.unwrap_or_else(|| build_custom_world_public_work_code(&row.profile_id));
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_CUSTOM_WORLD.to_string(),
work_id: format!("custom-world:{}", row.profile_id),
profile_id: row.profile_id.clone(),
source_session_id: row.source_agent_session_id.clone(),
public_work_code,
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.world_name.clone(),
subtitle: row.subtitle.clone(),
cover_image_src: row.cover_image_src.clone(),
visible: row.visible,
published_at_micros: row.published_at.map(|value| value.to_micros_since_unix_epoch()),
updated_at_micros: sort_time,
}
}
fn list_jump_hop_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.jump_hop_work_profile()
.by_jump_hop_work_publication_status()
.filter(JUMP_HOP_PUBLICATION_PUBLISHED)
.map(|row| jump_hop_work_visibility_snapshot(&row))
.collect()
}
fn update_jump_hop_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.jump_hop_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "跳一跳作品不存在".to_string())?;
if row.publication_status != JUMP_HOP_PUBLICATION_PUBLISHED {
return Err("只能修改已发布跳一跳作品可见性".to_string());
}
let next = JumpHopWorkProfileRow { visible, ..row };
let snapshot = jump_hop_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.jump_hop_work_profile()
.profile_id()
.delete(&profile_id);
ctx.db.jump_hop_work_profile().insert(next);
Ok(snapshot)
}
fn jump_hop_work_visibility_snapshot(row: &JumpHopWorkProfileRow) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_JUMP_HOP.to_string(),
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()),
public_work_code: build_prefixed_public_work_code("JH", &row.profile_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.work_title.clone(),
subtitle: "跳一跳".to_string(),
cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()),
visible: row.visible,
published_at_micros: row.published_at.map(|value| value.to_micros_since_unix_epoch()),
updated_at_micros: sort_time,
}
}
fn list_wooden_fish_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.wooden_fish_work_profile()
.by_wooden_fish_work_publication_status()
.filter(WOODEN_FISH_PUBLICATION_PUBLISHED)
.map(|row| wooden_fish_work_visibility_snapshot(&row))
.collect()
}
fn update_wooden_fish_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.wooden_fish_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "敲木鱼作品不存在".to_string())?;
if row.publication_status != WOODEN_FISH_PUBLICATION_PUBLISHED {
return Err("只能修改已发布敲木鱼作品可见性".to_string());
}
let next = WoodenFishWorkProfileRow { visible, ..row };
let snapshot = wooden_fish_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.wooden_fish_work_profile()
.profile_id()
.delete(&profile_id);
ctx.db.wooden_fish_work_profile().insert(next);
Ok(snapshot)
}
fn wooden_fish_work_visibility_snapshot(
row: &WoodenFishWorkProfileRow,
) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_WOODEN_FISH.to_string(),
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()),
public_work_code: build_prefixed_public_work_code("WF", &row.profile_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.work_title.clone(),
subtitle: "敲木鱼".to_string(),
cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()),
visible: row.visible,
published_at_micros: row.published_at.map(|value| value.to_micros_since_unix_epoch()),
updated_at_micros: sort_time,
}
}
fn list_match3d_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.match3d_work_profile()
.by_match3d_work_publication_status()
.filter(MATCH3D_PUBLICATION_PUBLISHED)
.map(|row| match3d_work_visibility_snapshot(&row))
.collect()
}
fn update_match3d_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.match3d_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "抓大鹅作品不存在".to_string())?;
if row.publication_status != MATCH3D_PUBLICATION_PUBLISHED {
return Err("只能修改已发布抓大鹅作品可见性".to_string());
}
let next = Match3DWorkProfileRow { visible, ..row };
let snapshot = match3d_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.match3d_work_profile()
.profile_id()
.delete(&profile_id);
ctx.db.match3d_work_profile().insert(next);
Ok(snapshot)
}
fn match3d_work_visibility_snapshot(row: &Match3DWorkProfileRow) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_MATCH3D.to_string(),
work_id: row.profile_id.clone(),
profile_id: row.profile_id.clone(),
source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()),
public_work_code: build_prefixed_public_work_code("M3", &row.profile_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.game_name.clone(),
subtitle: "抓大鹅".to_string(),
cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()),
visible: row.visible,
published_at_micros: row.published_at.map(|value| value.to_micros_since_unix_epoch()),
updated_at_micros: sort_time,
}
}
fn list_square_hole_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.square_hole_work_profile()
.by_square_hole_work_publication_status()
.filter(SQUARE_HOLE_PUBLICATION_PUBLISHED)
.map(|row| square_hole_work_visibility_snapshot(&row))
.collect()
}
fn update_square_hole_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.square_hole_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "方洞挑战作品不存在".to_string())?;
if row.publication_status != SQUARE_HOLE_PUBLICATION_PUBLISHED {
return Err("只能修改已发布方洞挑战作品可见性".to_string());
}
let next = SquareHoleWorkProfileRow { visible, ..row };
let snapshot = square_hole_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.square_hole_work_profile()
.profile_id()
.delete(&profile_id);
ctx.db.square_hole_work_profile().insert(next);
Ok(snapshot)
}
fn square_hole_work_visibility_snapshot(
row: &SquareHoleWorkProfileRow,
) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_SQUARE_HOLE.to_string(),
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()),
public_work_code: build_prefixed_public_work_code("SH", &row.profile_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.game_name.clone(),
subtitle: "方洞挑战".to_string(),
cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()),
visible: row.visible,
published_at_micros: row.published_at.map(|value| value.to_micros_since_unix_epoch()),
updated_at_micros: sort_time,
}
}
fn list_visual_novel_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.visual_novel_work_profile()
.by_visual_novel_work_publication_status()
.filter(VISUAL_NOVEL_PUBLICATION_PUBLISHED)
.map(|row| visual_novel_work_visibility_snapshot(&row))
.collect()
}
fn update_visual_novel_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.visual_novel_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "视觉小说作品不存在".to_string())?;
if row.publication_status != VISUAL_NOVEL_PUBLICATION_PUBLISHED {
return Err("只能修改已发布视觉小说作品可见性".to_string());
}
let next = VisualNovelWorkProfileRow { visible, ..row };
let snapshot = visual_novel_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.visual_novel_work_profile()
.profile_id()
.delete(&profile_id);
ctx.db.visual_novel_work_profile().insert(next);
Ok(snapshot)
}
fn visual_novel_work_visibility_snapshot(
row: &VisualNovelWorkProfileRow,
) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_VISUAL_NOVEL.to_string(),
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()),
public_work_code: build_prefixed_public_work_code("VN", &row.profile_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.work_title.clone(),
subtitle: "视觉小说".to_string(),
cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()),
visible: row.visible,
published_at_micros: row.published_at.map(|value| value.to_micros_since_unix_epoch()),
updated_at_micros: sort_time,
}
}
fn list_big_fish_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.big_fish_creation_session()
.by_big_fish_session_stage()
.filter(BigFishCreationStage::Published)
.map(|row| big_fish_work_visibility_snapshot(&row))
.collect()
}
fn update_big_fish_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&profile_id.to_string())
.ok_or_else(|| "大鱼吃小鱼作品不存在".to_string())?;
if row.stage != BigFishCreationStage::Published {
return Err("只能修改已发布大鱼吃小鱼作品可见性".to_string());
}
let next = BigFishCreationSession { visible, ..row };
let snapshot = big_fish_work_visibility_snapshot(&next);
let session_id = next.session_id.clone();
ctx.db
.big_fish_creation_session()
.session_id()
.delete(&session_id);
ctx.db.big_fish_creation_session().insert(next);
Ok(snapshot)
}
fn big_fish_work_visibility_snapshot(
row: &BigFishCreationSession,
) -> AdminWorkVisibilitySnapshot {
let published_at = row.published_at.map(|value| value.to_micros_since_unix_epoch());
let updated_at = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_BIG_FISH.to_string(),
work_id: row.session_id.clone(),
profile_id: row.session_id.clone(),
source_session_id: Some(row.session_id.clone()),
public_work_code: build_prefixed_public_work_code("BF", &row.session_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: "玩家".to_string(),
title: "大鱼吃小鱼".to_string(),
subtitle: "成长挑战".to_string(),
cover_image_src: None,
visible: row.visible,
published_at_micros: published_at,
updated_at_micros: updated_at,
}
}
fn list_bark_battle_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.bark_battle_published_config()
.iter()
.map(|row| bark_battle_work_visibility_snapshot(&row))
.collect()
}
fn update_bark_battle_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.bark_battle_published_config()
.work_id()
.find(&profile_id.to_string())
.ok_or_else(|| "汪汪声浪作品不存在".to_string())?;
let next = BarkBattlePublishedConfigRow { visible, ..row };
ctx.db.bark_battle_published_config().delete(next.clone());
ctx.db.bark_battle_published_config().insert(next.clone());
Ok(bark_battle_work_visibility_snapshot(&next))
}
fn bark_battle_work_visibility_snapshot(
row: &BarkBattlePublishedConfigRow,
) -> AdminWorkVisibilitySnapshot {
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_BARK_BATTLE.to_string(),
work_id: row.work_id.clone(),
profile_id: row.work_id.clone(),
source_session_id: row.source_draft_id.clone(),
public_work_code: build_bark_battle_public_work_code(&row.work_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: "玩家".to_string(),
title: "汪汪声浪".to_string(),
subtitle: row.difficulty_preset.clone(),
cover_image_src: None,
visible: row.visible,
published_at_micros: Some(row.published_at.to_micros_since_unix_epoch()),
updated_at_micros: timestamp_sort_micros(Some(row.published_at), row.updated_at),
}
}
fn require_admin_user_id(value: &str) -> Result<(), String> {
normalize_required_text(value, "adminUserId").map(|_| ())
}
fn normalize_source_type(value: &str) -> Result<String, String> {
let normalized = normalize_required_text(value, "sourceType")?
.to_ascii_lowercase()
.replace('_', "-");
let source_type = match normalized.as_str() {
"match-3-d" | "match-3d" | "match3-d" => SOURCE_TYPE_MATCH3D,
other => other,
};
Ok(source_type.to_string())
}
fn normalize_required_text(value: &str, field_name: &str) -> Result<String, String> {
let normalized = value.trim();
if normalized.is_empty() {
return Err(format!("{field_name} 不能为空"));
}
Ok(normalized.to_string())
}
fn sort_work_visibility_entries(entries: &mut [AdminWorkVisibilitySnapshot]) {
entries.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.source_type.cmp(&right.source_type))
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
}
fn timestamp_sort_micros(published_at: Option<Timestamp>, updated_at: Timestamp) -> i64 {
published_at
.unwrap_or(updated_at)
.to_micros_since_unix_epoch()
}
fn build_prefixed_public_work_code(prefix: &str, value: &str) -> String {
let normalized = normalize_public_code_text(value);
let fallback = if normalized.is_empty() {
"00000000".to_string()
} else {
normalized
};
let suffix = last_eight_padded(&fallback);
format!("{prefix}-{suffix}")
}
fn build_bark_battle_public_work_code(work_id: &str) -> String {
let normalized = normalize_public_code_text(work_id);
let without_prefix = normalized
.strip_prefix("BB")
.map(ToString::to_string)
.unwrap_or_else(|| normalized.clone());
let fallback = if without_prefix.is_empty() {
if normalized.is_empty() {
"00000000".to_string()
} else {
normalized
}
} else {
without_prefix
};
format!("BB-{}", last_eight_padded(&fallback))
}
fn normalize_public_code_text(value: &str) -> String {
value
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_uppercase)
.collect()
}
fn last_eight_padded(value: &str) -> String {
let suffix = value
.chars()
.rev()
.take(8)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<String>();
format!("{suffix:0>8}")
}
fn choose_non_empty(values: &[&str]) -> String {
values
.iter()
.map(|value| value.trim())
.find(|value| !value.is_empty())
.unwrap_or_default()
.to_string()
}

View File

@@ -61,7 +61,7 @@ pub struct SquareHoleWorkProfileRow {
pub(crate) play_count: u32,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
// ???????????????????????????????
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}

View File

@@ -95,7 +95,7 @@ pub struct VisualNovelWorkProfileRow {
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
// ???????????????????????????????
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}

View File

@@ -51,7 +51,7 @@ pub struct WoodenFishWorkProfileRow {
pub(crate) background_asset_json: Option<String>,
#[default(None::<String>)]
pub(crate) back_button_asset_json: Option<String>,
// ???????????????????????????????
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}