diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index b1e618cdf..1b8d7f9cd 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -13,10 +13,13 @@ import type { AdminOverviewResponse, AdminTrackingEventListQuery, AdminTrackingEventListResponse, + AdminUpdateWorkVisibilityRequest, + AdminUpdateWorkVisibilityResponse, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, + AdminWorkVisibilityListResponse, ApiErrorEnvelope, ApiMeta, ApiSuccessEnvelope, @@ -194,6 +197,27 @@ export function upsertAdminCreationEntryConfig( ); } +export function listAdminWorkVisibility(token: string) { + return request( + '/admin/api/works/visibility', + {token}, + ); +} + +export function updateAdminWorkVisibility( + token: string, + payload: AdminUpdateWorkVisibilityRequest, +) { + return request( + '/admin/api/works/visibility', + { + method: 'POST', + token, + body: payload, + }, + ); +} + export function listProfileRedeemCodes(token: string) { return request( '/admin/api/profile/redeem-codes', diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 00a2a4dfa..6bcb7c11c 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -177,6 +177,36 @@ export interface AdminUpsertCreationEntryTypeConfigRequest { categorySortOrder: number; } +export interface AdminWorkVisibilityEntryPayload { + sourceType: string; + workId: string; + profileId: string; + sourceSessionId?: string | null; + publicWorkCode: string; + ownerUserId: string; + authorDisplayName: string; + title: string; + subtitle: string; + coverImageSrc?: string | null; + visible: boolean; + publishedAtMicros?: number | null; + updatedAtMicros: number; +} + +export interface AdminWorkVisibilityListResponse { + entries: AdminWorkVisibilityEntryPayload[]; +} + +export interface AdminUpdateWorkVisibilityRequest { + sourceType: string; + profileId: string; + visible: boolean; +} + +export interface AdminUpdateWorkVisibilityResponse { + entry: AdminWorkVisibilityEntryPayload; +} + export interface AdminUpsertProfileRedeemCodeRequest { code: string; mode: ProfileRedeemCodeMode; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index f8b1e7923..e6327c482 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -28,6 +28,7 @@ import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage'; import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage'; import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage'; +import {AdminWorkVisibilityPage} from '../pages/AdminWorkVisibilityPage'; import {AdminShell} from './AdminShell'; import type {AdminRouteId} from './adminRoutes'; import {resolveAdminRoute, routeHash} from './adminRoutes'; @@ -205,6 +206,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {routeId === 'work-visibility' ? ( + + ) : null} {routeId === 'tasks' ? ( ; export function AdminShell({ diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 99831be55..c84459aea 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -7,7 +7,8 @@ export type AdminRouteId = | 'invite' | 'tasks' | 'recharge-products' - | 'creation-entry'; + | 'creation-entry' + | 'work-visibility'; export interface AdminRouteDefinition { id: AdminRouteId; @@ -25,6 +26,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ {id: 'tasks', label: '任务配置', hash: '#tasks'}, {id: 'recharge-products', label: '充值商品', hash: '#recharge-products'}, {id: 'creation-entry', label: '入口开关', hash: '#creation-entry'}, + {id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'}, ]; export function resolveAdminRoute(hash: string): AdminRouteId { diff --git a/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx b/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx new file mode 100644 index 000000000..4e02a8459 --- /dev/null +++ b/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx @@ -0,0 +1,269 @@ +import {Eye, EyeOff, RefreshCcw} from 'lucide-react'; +import {useEffect, useMemo, useState} from 'react'; + +import { + listAdminWorkVisibility, + updateAdminWorkVisibility, +} from '../api/adminApiClient'; +import type {AdminWorkVisibilityEntryPayload} from '../api/adminApiTypes'; +import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm'; +import {handlePageError} from './pageUtils'; + +interface AdminWorkVisibilityPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +const sourceLabels: Record = { + puzzle: '拼图', + 'custom-world': '自定义世界', + 'jump-hop': '跳一跳', + 'wooden-fish': '敲木鱼', + match3d: '抓大鹅', + 'square-hole': '方洞挑战', + 'visual-novel': '视觉小说', + 'big-fish': '大鱼吃小鱼', + 'bark-battle': '汪汪声浪', +}; + +export function AdminWorkVisibilityPage({ + token, + onUnauthorized, +}: AdminWorkVisibilityPageProps) { + const [entries, setEntries] = useState([]); + const [keyword, setKeyword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [savingKey, setSavingKey] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const {confirmWrite, confirmDialog} = useAdminWriteConfirm(); + + useEffect(() => { + void refreshEntries(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + const filteredEntries = useMemo(() => { + const normalizedKeyword = keyword.trim().toLowerCase(); + if (!normalizedKeyword) { + return entries; + } + return entries.filter((entry) => + [ + entry.sourceType, + sourceLabels[entry.sourceType] ?? '', + entry.title, + entry.subtitle, + entry.authorDisplayName, + entry.publicWorkCode, + entry.profileId, + entry.workId, + ] + .join(' ') + .toLowerCase() + .includes(normalizedKeyword), + ); + }, [entries, keyword]); + + async function refreshEntries() { + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminWorkVisibility(token); + setEntries(sortEntries(response.entries)); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + } + + async function handleToggle(entry: AdminWorkVisibilityEntryPayload) { + const nextVisible = !entry.visible; + const target = entry.title.trim() || entry.publicWorkCode || entry.profileId; + const confirmed = await confirmWrite({ + action: nextVisible ? '显示作品' : '隐藏作品', + target, + }); + if (!confirmed) { + return; + } + + const rowKey = buildEntryKey(entry); + setSavingKey(rowKey); + setErrorMessage(''); + try { + const response = await updateAdminWorkVisibility(token, { + sourceType: entry.sourceType, + profileId: entry.profileId, + visible: nextVisible, + }); + upsertEntry(response.entry); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setSavingKey(''); + } + } + + function upsertEntry(next: AdminWorkVisibilityEntryPayload) { + setEntries((current) => + sortEntries([ + ...current.filter((entry) => buildEntryKey(entry) !== buildEntryKey(next)), + next, + ]), + ); + } + + return ( +
+
+
+

作品可见性

+
+ +
+ +
+ + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ + + + + + + + + + + + + + {filteredEntries.map((entry) => { + const rowKey = buildEntryKey(entry); + const isSaving = savingKey === rowKey; + return ( + + + + + + + + + + ); + })} + +
玩法作品作者公开码更新时间状态操作
+ + {sourceLabels[entry.sourceType] ?? entry.sourceType} + + + {entry.title || entry.profileId} + {entry.subtitle || entry.profileId} + + {entry.authorDisplayName || '玩家'} + {entry.ownerUserId} + + + {entry.publicWorkCode} + + {entry.profileId} + {formatMicros(entry.updatedAtMicros)} + + {entry.visible ? '显示' : '隐藏'} + + + +
+
+ + {!isLoading && filteredEntries.length === 0 ? ( +
暂无作品
+ ) : null} +
+ + {confirmDialog} +
+ ); +} + +function sortEntries(entries: AdminWorkVisibilityEntryPayload[]) { + return [...entries].sort((left, right) => { + const timeCompare = right.updatedAtMicros - left.updatedAtMicros; + if (timeCompare !== 0) { + return timeCompare; + } + const sourceCompare = left.sourceType.localeCompare(right.sourceType); + if (sourceCompare !== 0) { + return sourceCompare; + } + return left.profileId.localeCompare(right.profileId); + }); +} + +function buildEntryKey(entry: AdminWorkVisibilityEntryPayload) { + return `${entry.sourceType}:${entry.profileId}`; +} + +function formatMicros(value: number) { + if (!Number.isFinite(value)) { + return '-'; + } + const date = new Date(Math.floor(value / 1000)); + if (!Number.isFinite(date.getTime())) { + return '-'; + } + return date.toLocaleString('zh-CN', {hour12: false}); +} diff --git a/docs/superpowers/plans/2026-05-27-admin-work-visibility.md b/docs/superpowers/plans/2026-05-27-admin-work-visibility.md new file mode 100644 index 000000000..a718751c2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-admin-work-visibility.md @@ -0,0 +1,83 @@ +# 作品可见性后台管理 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在后台增加统一作品可见性列表与修改能力,让管理员可以把已发布作品从公开 read model 中隐藏或恢复显示。 + +**Architecture:** 可见性仍以各玩法源表 `visible` 字段为真相源;新增 SpacetimeDB admin procedure 统一列出和更新各玩法作品可见性,`api-server` 只做鉴权、DTO 校验和 BFF 转发,后台前端新增简洁管理页。统一公开 read model 继续只消费 `visible=true` 的 source view,不向公开契约暴露后台字段。 + +**Tech Stack:** Rust server-rs + SpacetimeDB module/procedure + spacetime-client bindings/facade + shared-contracts DTO + React admin-web TypeScript。 + +--- + +### Task 1: 文档契约补齐 + +**Files:** +- Modify: `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` +- Modify: `docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md` + +- [ ] 在 API 路由分组中补充 `/admin/api/works/visibility`。 +- [ ] 在统一公开作品 ReadModel 文档中写清后台只能修改源表 `visible`,隐藏后不进入 `public_work_gallery_entry` / `public_work_detail_entry`。 + +### Task 2: DTO 与后端路由 + +**Files:** +- Modify: `server-rs/crates/shared-contracts/src/admin.rs` +- Modify: `server-rs/crates/api-server/src/admin.rs` +- Modify: `server-rs/crates/api-server/src/app.rs` 或现有 admin module router 文件 + +- [ ] 增加 `AdminWorkVisibilityEntryPayload`、`AdminWorkVisibilityListResponse`、`AdminUpdateWorkVisibilityRequest`、`AdminUpdateWorkVisibilityResponse`。 +- [ ] 新增 `GET /admin/api/works/visibility` handler,必须走 `require_admin_auth`。 +- [ ] 新增 `POST /admin/api/works/visibility` handler,校验 `sourceType`、`profileId` 非空并转发到 SpacetimeDB facade。 + +### Task 3: SpacetimeDB runtime/procedure 与 facade + +**Files:** +- Modify: `server-rs/crates/module-runtime/src/domain.rs` +- Create: `server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs` +- Modify: `server-rs/crates/spacetime-module/src/runtime.rs` +- Modify: `server-rs/crates/spacetime-module/src/lib.rs` +- Modify: `server-rs/crates/spacetime-client/src/runtime.rs` +- Modify: `server-rs/crates/spacetime-client/src/mapper/runtime.rs` + +- [ ] 增加 module-runtime typed input/output 类型。 +- [ ] SpacetimeDB procedure 统一读取各玩法已发布源表/view,并返回可见性列表。 +- [ ] SpacetimeDB procedure 根据 `sourceType + profileId` 修改对应源表 `visible`;`custom-world` 同步 `custom_world_gallery_entry.visible`;`big-fish` 使用 `session_id`,`bark-battle` 使用 `work_id`。 +- [ ] spacetime-client 增加 list/update facade 和 mapper。 + +### Task 4: 后台前端页面 + +**Files:** +- Modify: `apps/admin-web/src/api/adminApiTypes.ts` +- Modify: `apps/admin-web/src/api/adminApiClient.ts` +- Create: `apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx` +- Modify: `apps/admin-web/src/app/adminRoutes.ts` +- Modify: `apps/admin-web/src/app/AdminShell.tsx` +- Modify: `apps/admin-web/src/app/AdminApp.tsx` + +- [ ] 增加 API 类型和 client 方法。 +- [ ] 新增简洁表格页,显示玩法、标题、作者、公开码、更新时间、可见状态。 +- [ ] 修改可见性时使用 `useAdminWriteConfirm` 确认。 +- [ ] 接入后台导航和 route switch。 + +### Task 5: 生成绑定与验证 + +**Files:** +- Generated: `server-rs/crates/spacetime-client/src/module_bindings*` +- Generated: front-end shared bindings if generator updates them + +- [ ] Run: `npm run spacetime:generate`。 +- [ ] Run: `npm run check:spacetime-schema`。 +- [ ] Run: `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`。 +- [ ] Run: `cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 +- [ ] Run: `npm run admin-web:typecheck`。 +- [ ] Run: `npm run check:encoding`。 + +### Task 6: 提交并推送 + +**Files:** +- All changed files + +- [ ] Inspect `git diff` and `git status --short --branch`。 +- [ ] Commit with message `feat: add admin work visibility controls`。 +- [ ] Push current branch `codex/visible-work-field`。 diff --git a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md index 9a7f73219..54a27e26b 100644 --- a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md +++ b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md @@ -43,6 +43,17 @@ 其中 `detail_payload_json` 只承载平台详情页展示扩展,不承载正式 runtime 配置、玩法规则或草稿真相。 +## 后台可见性管理 + +后台通过独立接口管理已发布作品的源表可见性: + +- `GET /admin/api/works/visibility` +- `POST /admin/api/works/visibility` + +后台操作 key 使用统一的 `sourceType + profileId` 组合。`profileId` 在大多数玩法中对应作品 profile;特殊玩法维持既有源表身份:`big-fish` 对应 `session_id`,`bark-battle` 对应 `work_id`。`custom-world` 更新源表时必须同步 `custom_world_gallery_entry.visible`,避免兼容 gallery 缓存与统一公开 read model 出现可见性漂移。 + +该后台能力只修改源表 / source view 过滤事实,不把 `visible` 暴露到公开列表或公开详情契约。隐藏作品后,统一 `public_work_gallery_entry` 与 `public_work_detail_entry` 不再返回该作品;恢复显示后重新进入公开 read model。 + ## 来源与兼容 统一 public view 由现有玩法 source view 组装: diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 3d2ae208b..da4795528 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -51,7 +51,7 @@ npm run check:server-rs-ddd 路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组: - 健康检查:`GET /healthz`。 -- 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、兑换码、邀请码、任务配置和充值商品配置。 +- 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、作品可见性、兑换码、邀请码、任务配置和充值商品配置。 - 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。 - 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请、兑换、存档、历史浏览和游玩统计。 - LLM 与语音:`/api/llm/*`、`/api/speech/volcengine/*`。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 80ab9045f..d66375753 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -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, + Extension(request_context): Extension, + Extension(admin): Extension, +) -> Result, 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, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, 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", diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index 6c5242bdc..345905dc1 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -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 { 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) diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index bfda79d0e..6014b4a73 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -478,6 +478,29 @@ impl AppState { } } + pub async fn list_admin_work_visibility( + &self, + admin_user_id: String, + ) -> Result, 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 + { + 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, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index bc7dff762..174ae1b69 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -139,6 +139,59 @@ pub struct CreationEntryConfigProcedureResult { pub error_message: Option, } +/// 后台作品可见性列表项。 +/// +/// 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, + 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, + pub visible: bool, + pub published_at_micros: Option, + 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, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdminWorkVisibilityProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + /// 分析日期维表的纯领域快照。 /// /// date_key 沿用现有北京时间自然日桶:floor((occurred_at_micros + 8h) / 1d)。 diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index b776f28cd..cdc128a91 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -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, + 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, + pub visible: bool, + pub published_at_micros: Option, + pub updated_at_micros: i64, +} + +/// 后台作品可见性列表响应。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminWorkVisibilityListResponse { + pub entries: Vec, +} + +/// 后台修改作品可见性请求。 +#[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 { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f8a5c00a4..b55cbafd1 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 1dde49d09..086e2eab1 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/mapper/runtime.rs index af558240d..3e146d65f 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs @@ -18,6 +18,61 @@ impl From for CreationEntryTy } } +impl From for AdminWorkVisibilityListInput { + fn from(input: module_runtime::AdminWorkVisibilityListInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + } + } +} + +impl From 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 { + 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 { + 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 for RuntimeSettingGetInput { fn from(input: module_runtime::RuntimeSettingGetInput) -> Self { Self { @@ -114,6 +169,7 @@ impl From 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, 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 { + 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, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 85016f4ce..3cc34eaec 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_work_visibility_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_work_visibility_procedure.rs new file mode 100644 index 000000000..df222e415 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_work_visibility_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AdminWorkVisibilityListProcedureResult>( + "admin_list_work_visibility", + AdminListWorkVisibilityArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_update_work_visibility_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_update_work_visibility_procedure.rs new file mode 100644 index 000000000..4a88c0841 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_update_work_visibility_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AdminWorkVisibilityProcedureResult>( + "admin_update_work_visibility", + AdminUpdateWorkVisibilityArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_list_input_type.rs new file mode 100644 index 000000000..f7e270c35 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_list_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_list_procedure_result_type.rs new file mode 100644 index 000000000..f0a5ff9f2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_list_procedure_result_type.rs @@ -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, + pub error_message: Option, +} + +impl __sdk::InModule for AdminWorkVisibilityListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_procedure_result_type.rs new file mode 100644 index 000000000..f5e05c932 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_procedure_result_type.rs @@ -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, + pub error_message: Option, +} + +impl __sdk::InModule for AdminWorkVisibilityProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_snapshot_type.rs new file mode 100644 index 000000000..a1dd39621 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_snapshot_type.rs @@ -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, + 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, + pub visible: bool, + pub published_at_micros: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for AdminWorkVisibilitySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_update_input_type.rs new file mode 100644 index 000000000..77fa284e4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_work_visibility_update_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 08dbbf301..58ed31fec 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -87,6 +87,61 @@ impl SpacetimeClient { Ok(config) } + pub async fn admin_list_work_visibility( + &self, + admin_user_id: String, + ) -> Result, 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 + { + 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, diff --git a/server-rs/crates/spacetime-module/src/bark_battle/tables.rs b/server-rs/crates/spacetime-module/src/bark_battle/tables.rs index a92b38b58..e3130fff1 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle/tables.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle/tables.rs @@ -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, } diff --git a/server-rs/crates/spacetime-module/src/big_fish/tables.rs b/server-rs/crates/spacetime-module/src/big_fish/tables.rs index ea58dacb6..cf6ac376e 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/tables.rs @@ -30,7 +30,7 @@ pub struct BigFishCreationSession { pub(crate) like_count: u32, #[default(None::)] pub(crate) published_at: Option, - // ??????????????????????????????? + // 后台可见性开关;默认显示,隐藏后不进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index 83c932ab9..2c8d2ace7 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -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, + pub(crate) public_work_code: Option, // 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。 - author_public_user_code: Option, - source_agent_session_id: Option, - publication_status: CustomWorldPublicationStatus, - world_name: String, - subtitle: String, - summary_text: String, - theme_mode: CustomWorldThemeMode, - cover_image_src: Option, - profile_payload_json: String, - playable_npc_count: u32, - landmark_count: u32, + pub(crate) author_public_user_code: Option, + pub(crate) source_agent_session_id: Option, + 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, + 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, + pub(crate) like_count: u32, + pub(crate) author_display_name: String, + pub(crate) published_at: Option, // 软删除后保留 profile 真相,供审计与幂等删除使用。 - deleted_at: Option, - created_at: Timestamp, - updated_at: Timestamp, - // ?????????????????????????? + pub(crate) deleted_at: Option, + 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, - 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, + 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 会话首版只负责把可持久化创作状态落进 SpacetimeDB,LLM 采集与卡片生成后续再接入。 #[spacetimedb::procedure] diff --git a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs index 4b42a4443..31715f0e4 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs @@ -53,7 +53,7 @@ pub struct JumpHopWorkProfileRow { pub(crate) play_count: u32, pub(crate) updated_at: Timestamp, pub(crate) published_at: Option, - // ??????????????????????????????? + // 后台可见性开关;默认显示,隐藏后不进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } diff --git a/server-rs/crates/spacetime-module/src/match3d/tables.rs b/server-rs/crates/spacetime-module/src/match3d/tables.rs index 14f2b1974..bd5840740 100644 --- a/server-rs/crates/spacetime-module/src/match3d/tables.rs +++ b/server-rs/crates/spacetime-module/src/match3d/tables.rs @@ -62,7 +62,7 @@ pub struct Match3DWorkProfileRow { pub(crate) published_at: Option, #[default(None::)] pub(crate) generated_item_assets_json: Option, - // ??????????????????????????????? + // 后台可见性开关;默认显示,隐藏后不进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 498d19c42..994f24e6e 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -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)); diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index a65cafb3b..64731d907 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -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, - author_display_name: String, - work_title: String, - work_description: String, - level_name: String, - summary: String, - theme_tags_json: String, - cover_image_src: Option, - cover_asset_id: Option, - 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, + pub(crate) profile_id: String, + pub(crate) work_id: String, + pub(crate) owner_user_id: String, + pub(crate) source_session_id: Option, + 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, + pub(crate) cover_asset_id: Option, + 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, #[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, } /// 拼图广场公开详情兼容投影。 diff --git a/server-rs/crates/spacetime-module/src/runtime.rs b/server-rs/crates/spacetime-module/src/runtime.rs index 730b89e20..cf85471a3 100644 --- a/server-rs/crates/spacetime-module/src/runtime.rs +++ b/server-rs/crates/spacetime-module/src/runtime.rs @@ -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::*; diff --git a/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs b/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs new file mode 100644 index 000000000..35facd86b --- /dev/null +++ b/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs @@ -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, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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::>() + .into_iter() + .rev() + .collect::(); + 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() +} diff --git a/server-rs/crates/spacetime-module/src/square_hole/tables.rs b/server-rs/crates/spacetime-module/src/square_hole/tables.rs index e738b896e..6be841ac6 100644 --- a/server-rs/crates/spacetime-module/src/square_hole/tables.rs +++ b/server-rs/crates/spacetime-module/src/square_hole/tables.rs @@ -61,7 +61,7 @@ pub struct SquareHoleWorkProfileRow { pub(crate) play_count: u32, pub(crate) updated_at: Timestamp, pub(crate) published_at: Option, - // ??????????????????????????????? + // 后台可见性开关;默认显示,隐藏后不进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } diff --git a/server-rs/crates/spacetime-module/src/visual_novel.rs b/server-rs/crates/spacetime-module/src/visual_novel.rs index 0acd0e5bd..6ba1597f6 100644 --- a/server-rs/crates/spacetime-module/src/visual_novel.rs +++ b/server-rs/crates/spacetime-module/src/visual_novel.rs @@ -95,7 +95,7 @@ pub struct VisualNovelWorkProfileRow { pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, pub(crate) published_at: Option, - // ??????????????????????????????? + // 后台可见性开关;默认显示,隐藏后不进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } diff --git a/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs b/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs index 0002f76b2..306f444d4 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs @@ -51,7 +51,7 @@ pub struct WoodenFishWorkProfileRow { pub(crate) background_asset_json: Option, #[default(None::)] pub(crate) back_button_asset_json: Option, - // ??????????????????????????????? + // 后台可见性开关;默认显示,隐藏后不进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, }