diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index b1e618cd..1b8d7f9c 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 00a2a4df..6bcb7c11 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 f8b1e792..e6327c48 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 99831be5..c84459ae 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 00000000..4e02a845 --- /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 00000000..a718751c --- /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 faa08e20..54a27e26 100644 --- a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md +++ b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md @@ -39,8 +39,21 @@ - `sort_time_micros` - `detail_payload_json` +作品源表新增 `visible` 可见性字段,默认 `true`。`visible` 属于源表 / source view 过滤条件,不作为统一公开契约默认返回字段;当 `visible=false` 时,对应作品不得进入 `public_work_gallery_entry` 和 `public_work_detail_entry`。 + 其中 `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 组装: @@ -63,6 +76,7 @@ - 旧 view 保留,不删除。 - 旧 view 退到底层 source / 兼容职责。 - 新 `public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。 +- 各玩法 source view 只暴露 `visible=true` 的已发布作品;旧数据迁移默认补 `visible=true`,避免历史作品被误隐藏。 - 旧 `/api/runtime//gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。 - 旧详情 / runtime / 点赞 / 游玩 / Remix 仍走玩法专用路径。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 642b66cf..106c0edd 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/*`。 @@ -257,6 +257,7 @@ npm run check:server-rs-ddd - Rust 结构体:`BarkBattlePublishedConfigRow` - 源码:`server-rs/crates/spacetime-module/src/bark_battle/tables.rs` +- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 ### `bark_battle_runtime_run` @@ -293,6 +294,7 @@ npm run check:server-rs-ddd - Rust 结构体:`BigFishCreationSession` - 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs` - 索引:`by_big_fish_session_owner_user_id`、`by_big_fish_session_stage`。公开广场 view 使用 `by_big_fish_session_stage` 读取已发布会话,避免扫整表。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 ### `big_fish_event` @@ -356,11 +358,13 @@ npm run check:server-rs-ddd - Rust 结构体:`CustomWorldGalleryEntry` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` - 作用:自定义世界公开 source 读模型。统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该投影并映射成跨玩法契约;`/api/runtime/custom-world-gallery` 保留旧 HTTP shape,并从统一 public cache 映射回旧 DTO。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 ### `custom_world_profile` - Rust 结构体:`CustomWorldProfile` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` +- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 ### `custom_world_session` @@ -415,6 +419,7 @@ npm run check:server-rs-ddd - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/jump_hop.rs` - 说明:跳一跳公开详情兼容投影,包含作品、路径和素材字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 ### `wooden_fish_agent_session` @@ -450,6 +455,7 @@ npm run check:server-rs-ddd - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/wooden_fish.rs` - 说明:敲木鱼公开详情兼容投影,包含敲击物图案、背景环境图、主题返回按钮图、敲击音效和飘字配置;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 ### `match3d_agent_message` @@ -477,6 +483,7 @@ npm run check:server-rs-ddd - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/match3d.rs` - 说明:抓大鹅公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 ### `npc_state` @@ -663,6 +670,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; 结构化创作和 RPG 的 LLM JSON 链路默认不启用 Responses `web_search`;只有在明确需要联网增强时,才通过 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED` 显式打开。否则未开通工具的上游会先吐自然语言再返回 `ToolNotOpen`,这类失败要按上游工具不可用处理,不要误判成模型返回结果解析失败。 统一公开作品 BFF 路由是 `GET /api/public-works` 与 `GET /api/public-works/{publicWorkCode}`,响应契约由 `shared-contracts::public_work` 和 `packages/shared/src/contracts/publicWork.ts` 共同维护。前端首期仍走 BFF HTTP,不直接订阅 SpacetimeDB;后续若允许浏览器直连订阅,也只能订阅 `public_work_gallery_entry` / `public_work_detail_entry` 这类稳定公开 read model,不能订阅 `puzzle_work_profile`、`custom_world_profile` 等源表后自行拼装列表。设计细节见 `docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 ### `quest_log` @@ -715,6 +723,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/square_hole.rs` - 说明:方洞挑战公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 ### `story_event` @@ -790,3 +799,4 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs` - 说明:视觉小说公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 80ab9045..d6637575 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 6c5242bd..345905dc 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 6dcf8b9c..d79393fe 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -489,6 +489,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 bc7dff76..174ae1b6 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 b776f28c..cdc128a9 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 f6bb3217..22a42518 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 3d6fd06a..848781f1 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 af558240..3e146d65 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 a07934b1..e153434f 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; @@ -1046,10 +1053,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 00000000..df222e41 --- /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 00000000..4a88c084 --- /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 00000000..f7e270c3 --- /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 00000000..f0a5ff9f --- /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 00000000..f5e05c93 --- /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 00000000..a1dd3962 --- /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 00000000..77fa284e --- /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/module_bindings/bark_battle_published_config_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_published_config_row_type.rs index c442c6bc..64f11bac 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_published_config_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_published_config_row_type.rs @@ -19,6 +19,7 @@ pub struct BarkBattlePublishedConfigRow { pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, pub published_at: __sdk::Timestamp, + pub visible: bool, } impl __sdk::InModule for BarkBattlePublishedConfigRow { @@ -41,6 +42,7 @@ pub struct BarkBattlePublishedConfigRowCols { pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col, + pub visible: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for BarkBattlePublishedConfigRow { @@ -65,6 +67,7 @@ impl __sdk::__query_builder::HasCols for BarkBattlePublishedConfigRow { created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + visible: __sdk::__query_builder::Col::new(table_name, "visible"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs index d87690de..25aaff8c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs @@ -26,6 +26,7 @@ pub struct BigFishCreationSession { pub remix_count: u32, pub like_count: u32, pub published_at: Option<__sdk::Timestamp>, + pub visible: bool, } impl __sdk::InModule for BigFishCreationSession { @@ -53,6 +54,7 @@ pub struct BigFishCreationSessionCols { pub remix_count: __sdk::__query_builder::Col, pub like_count: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col>, + pub visible: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for BigFishCreationSession { @@ -82,6 +84,7 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession { remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + visible: __sdk::__query_builder::Col::new(table_name, "visible"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs index 971fd3b2..4e8a9b2a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs @@ -26,6 +26,7 @@ pub struct CustomWorldGalleryEntry { pub like_count: u32, pub published_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, + pub visible: bool, } impl __sdk::InModule for CustomWorldGalleryEntry { @@ -53,6 +54,7 @@ pub struct CustomWorldGalleryEntryCols { pub like_count: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub visible: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for CustomWorldGalleryEntry { @@ -82,6 +84,7 @@ impl __sdk::__query_builder::HasCols for CustomWorldGalleryEntry { like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + visible: __sdk::__query_builder::Col::new(table_name, "visible"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs index 4ad1e730..bbed3f0d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs @@ -32,6 +32,7 @@ pub struct CustomWorldProfile { pub deleted_at: Option<__sdk::Timestamp>, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, + pub visible: bool, } impl __sdk::InModule for CustomWorldProfile { @@ -65,6 +66,7 @@ pub struct CustomWorldProfileCols { pub deleted_at: __sdk::__query_builder::Col>, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub visible: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for CustomWorldProfile { @@ -105,6 +107,7 @@ impl __sdk::__query_builder::HasCols for CustomWorldProfile { deleted_at: __sdk::__query_builder::Col::new(table_name, "deleted_at"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + visible: __sdk::__query_builder::Col::new(table_name, "visible"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs index b7bbd776..660ea530 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs @@ -31,6 +31,7 @@ pub struct JumpHopWorkProfileRow { pub play_count: u32, pub updated_at: __sdk::Timestamp, pub published_at: Option<__sdk::Timestamp>, + pub visible: bool, } impl __sdk::InModule for JumpHopWorkProfileRow { @@ -65,6 +66,7 @@ pub struct JumpHopWorkProfileRowCols { pub play_count: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col>, + pub visible: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow { @@ -104,6 +106,7 @@ impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow { play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + visible: __sdk::__query_builder::Col::new(table_name, "visible"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_profile_row_type.rs index 03ff94b9..aafe2065 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_profile_row_type.rs @@ -25,6 +25,7 @@ pub struct Match3DWorkProfileRow { pub updated_at: __sdk::Timestamp, pub published_at: Option<__sdk::Timestamp>, pub generated_item_assets_json: Option, + pub visible: bool, } impl __sdk::InModule for Match3DWorkProfileRow { @@ -54,6 +55,7 @@ pub struct Match3DWorkProfileRowCols { pub published_at: __sdk::__query_builder::Col>, pub generated_item_assets_json: __sdk::__query_builder::Col>, + pub visible: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for Match3DWorkProfileRow { @@ -84,6 +86,7 @@ impl __sdk::__query_builder::HasCols for Match3DWorkProfileRow { table_name, "generated_item_assets_json", ), + visible: __sdk::__query_builder::Col::new(table_name, "visible"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs index a2ebd389..db43f53a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs @@ -33,6 +33,7 @@ pub struct PuzzleWorkProfileRow { pub like_count: u32, pub point_incentive_total_half_points: u64, pub point_incentive_claimed_points: u64, + pub visible: bool, } impl __sdk::InModule for PuzzleWorkProfileRow { @@ -68,6 +69,7 @@ pub struct PuzzleWorkProfileRowCols { pub like_count: __sdk::__query_builder::Col, pub point_incentive_total_half_points: __sdk::__query_builder::Col, pub point_incentive_claimed_points: __sdk::__query_builder::Col, + pub visible: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for PuzzleWorkProfileRow { @@ -107,6 +109,7 @@ impl __sdk::__query_builder::HasCols for PuzzleWorkProfileRow { table_name, "point_incentive_claimed_points", ), + visible: __sdk::__query_builder::Col::new(table_name, "visible"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_profile_row_type.rs index b61c6f20..9798d7da 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_profile_row_type.rs @@ -25,6 +25,7 @@ pub struct SquareHoleWorkProfileRow { pub play_count: u32, pub updated_at: __sdk::Timestamp, pub published_at: Option<__sdk::Timestamp>, + pub visible: bool, } impl __sdk::InModule for SquareHoleWorkProfileRow { @@ -54,6 +55,7 @@ pub struct SquareHoleWorkProfileRowCols { pub updated_at: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col>, + pub visible: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for SquareHoleWorkProfileRow { @@ -81,6 +83,7 @@ impl __sdk::__query_builder::HasCols for SquareHoleWorkProfileRow { play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + visible: __sdk::__query_builder::Col::new(table_name, "visible"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_row_type.rs index 941c07c6..d4556acb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_row_type.rs @@ -24,6 +24,7 @@ pub struct VisualNovelWorkProfileRow { pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, pub published_at: Option<__sdk::Timestamp>, + pub visible: bool, } impl __sdk::InModule for VisualNovelWorkProfileRow { @@ -52,6 +53,7 @@ pub struct VisualNovelWorkProfileRowCols { pub updated_at: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col>, + pub visible: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for VisualNovelWorkProfileRow { @@ -81,6 +83,7 @@ impl __sdk::__query_builder::HasCols for VisualNovelWorkProfileRow { created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + visible: __sdk::__query_builder::Col::new(table_name, "visible"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_row_type.rs index 456316a8..5803a598 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_row_type.rs @@ -29,6 +29,7 @@ pub struct WoodenFishWorkProfileRow { pub published_at: Option<__sdk::Timestamp>, pub background_asset_json: Option, pub back_button_asset_json: Option, + pub visible: bool, } impl __sdk::InModule for WoodenFishWorkProfileRow { @@ -65,6 +66,7 @@ pub struct WoodenFishWorkProfileRowCols { __sdk::__query_builder::Col>, pub back_button_asset_json: __sdk::__query_builder::Col>, + pub visible: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for WoodenFishWorkProfileRow { @@ -114,6 +116,7 @@ impl __sdk::__query_builder::HasCols for WoodenFishWorkProfileRow { table_name, "back_button_asset_json", ), + visible: __sdk::__query_builder::Col::new(table_name, "visible"), } } } diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 08dbbf30..58ed31fe 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.rs b/server-rs/crates/spacetime-module/src/bark_battle.rs index 96763abd..2a19b32a 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle.rs @@ -20,6 +20,7 @@ pub fn bark_battle_gallery_view(ctx: &AnonymousViewContext) -> Vec Some(item), Err(error) => { @@ -260,6 +261,7 @@ fn publish_bark_battle_work_tx( created_at: published_at, updated_at: published_at, published_at, + visible: true, }; let mut published = published; match ctx 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 9b436e6a..e3130fff 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle/tables.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle/tables.rs @@ -1,5 +1,7 @@ use crate::*; +const WORK_VISIBLE_DEFAULT: bool = true; + #[spacetimedb::table( accessor = bark_battle_draft_config, index(accessor = by_bark_battle_draft_owner_user_id, btree(columns = [owner_user_id])), @@ -40,6 +42,9 @@ 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, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/big_fish/assets.rs b/server-rs/crates/spacetime-module/src/big_fish/assets.rs index 1da68d3c..9c7fda1f 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/assets.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/assets.rs @@ -129,6 +129,7 @@ pub(crate) fn generate_big_fish_asset_tx( published_at: session.published_at, created_at: session.created_at, updated_at, + visible: session.visible, }; replace_big_fish_session(ctx, &session, next_session); for event in readiness.events { @@ -200,6 +201,7 @@ pub(crate) fn publish_big_fish_game_tx( published_at: Some(published_at), created_at: session.created_at, updated_at: published_at, + visible: session.visible, }; replace_big_fish_session(ctx, &session, next_session); for event in readiness.events { diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 5ae78d2a..359afe4f 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -23,6 +23,7 @@ pub fn big_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec Some(snapshot), Err(error) => { @@ -284,6 +285,7 @@ pub(crate) fn create_big_fish_session_tx( published_at: None, created_at, updated_at: created_at, + visible: true, }); ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { message_id: input.welcome_message_id, @@ -492,6 +494,7 @@ pub(crate) fn submit_big_fish_message_tx( published_at: session.published_at, created_at: session.created_at, updated_at: submitted_at, + visible: session.visible, }; replace_big_fish_session(ctx, &session, next_session); @@ -542,6 +545,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( published_at: session.published_at, created_at: session.created_at, updated_at, + visible: session.visible, }; replace_big_fish_session(ctx, &session, next_session); return Err(error_message.to_string()); @@ -600,6 +604,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( published_at: session.published_at, created_at: session.created_at, updated_at, + visible: session.visible, }; replace_big_fish_session(ctx, &session, next_session); @@ -667,6 +672,7 @@ pub(crate) fn compile_big_fish_draft_tx( published_at: session.published_at, created_at: session.created_at, updated_at: compiled_at, + visible: session.visible, }; replace_big_fish_session(ctx, &session, next_session); for event in readiness.events { @@ -768,6 +774,7 @@ pub(crate) fn record_big_fish_play_tx( published_at: session.published_at, created_at: session.created_at, updated_at: played_at, + visible: session.visible, }; replace_big_fish_session(ctx, &session, next_session); @@ -821,6 +828,7 @@ pub(crate) fn record_big_fish_like_tx( published_at: session.published_at, created_at: session.created_at, updated_at: liked_at, + visible: session.visible, }; replace_big_fish_session(ctx, &session, next_session); } @@ -888,6 +896,7 @@ fn remix_big_fish_work_tx( published_at: source.published_at, created_at: source.created_at, updated_at: remixed_at, + visible: source.visible, }; replace_big_fish_session(ctx, &source, next_source); @@ -909,6 +918,7 @@ fn remix_big_fish_work_tx( published_at: None, created_at: remixed_at, updated_at: remixed_at, + visible: true, }; ctx.db.big_fish_creation_session().insert(target_session); ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { @@ -1238,6 +1248,7 @@ mod tests { }, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), + visible: true, } } 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 fa480120..cf6ac376 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/tables.rs @@ -1,5 +1,7 @@ use crate::*; +const WORK_VISIBLE_DEFAULT: bool = true; + #[spacetimedb::table( accessor = big_fish_creation_session, index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id])), @@ -28,6 +30,9 @@ pub struct BigFishCreationSession { pub(crate) like_count: u32, #[default(None::)] pub(crate) published_at: Option, + // 后台可见性开关;默认显示,隐藏后不进入公开列表。 + #[default(WORK_VISIBLE_DEFAULT)] + pub(crate) visible: bool, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index 06133e32..2c8d2ace 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -2,6 +2,8 @@ use crate::*; use spacetimedb::AnonymousViewContext; use std::collections::{HashMap, HashSet}; +const WORK_VISIBLE_DEFAULT: bool = true; + #[spacetimedb::table( accessor = custom_world_profile, index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])), @@ -12,36 +14,39 @@ use std::collections::{HashMap, HashSet}; )] 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)] + pub(crate) visible: bool, } #[spacetimedb::table( @@ -170,28 +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)] + pub(crate) visible: bool, } // Agent 会话首版只负责把可持久化创作状态落进 SpacetimeDB,LLM 采集与卡片生成后续再接入。 #[spacetimedb::procedure] @@ -1229,6 +1237,7 @@ fn upsert_custom_world_profile_record( deleted_at: None, created_at: existing.created_at, updated_at, + visible: existing.visible, } } None => CustomWorldProfile { @@ -1254,6 +1263,7 @@ fn upsert_custom_world_profile_record( deleted_at: None, created_at: updated_at, updated_at, + visible: true, }, }; @@ -1401,6 +1411,7 @@ fn publish_custom_world_profile_record( deleted_at: None, created_at: existing.created_at, updated_at: published_at, + visible: existing.visible, }; let inserted = ctx.db.custom_world_profile().insert(next_row); @@ -1467,6 +1478,7 @@ fn unpublish_custom_world_profile_record( deleted_at: None, created_at: existing.created_at, updated_at, + visible: existing.visible, }; let inserted = ctx.db.custom_world_profile().insert(next_row); @@ -1529,6 +1541,7 @@ fn delete_custom_world_profile_record( deleted_at: Some(deleted_at), created_at: existing.created_at, updated_at: deleted_at, + visible: existing.visible, }; let _ = ctx.db.custom_world_profile().insert(next_row); @@ -1651,6 +1664,7 @@ fn get_custom_world_gallery_detail_record( row.owner_user_id == input.owner_user_id && row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() + && row.visible }); let gallery_entry = ctx @@ -1745,6 +1759,7 @@ fn remix_custom_world_profile_record( .filter(|row| { row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() + && row.visible && row.published_at.is_some() }) .ok_or_else(|| "custom_world 已发布源作品不存在,无法改编".to_string())?; @@ -1777,6 +1792,7 @@ fn remix_custom_world_profile_record( deleted_at: source.deleted_at, created_at: source.created_at, updated_at: remixed_at, + visible: source.visible, }; let updated_source = ctx.db.custom_world_profile().insert(next_source); let source_gallery = sync_custom_world_gallery_entry_from_profile(ctx, &updated_source)?; @@ -1805,6 +1821,7 @@ fn remix_custom_world_profile_record( deleted_at: None, created_at: remixed_at, updated_at: remixed_at, + visible: true, }; if let Some(existing_target) = ctx @@ -1845,6 +1862,7 @@ fn record_custom_world_profile_play_record( .filter(|row| { row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() + && row.visible && row.published_at.is_some() }) .ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?; @@ -1887,6 +1905,7 @@ fn record_custom_world_profile_play_record( deleted_at: existing.deleted_at, created_at: existing.created_at, updated_at: played_at, + visible: existing.visible, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; @@ -1916,6 +1935,7 @@ fn record_custom_world_profile_like_record( .filter(|row| { row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() + && row.visible && row.published_at.is_some() }) .ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?; @@ -1967,6 +1987,7 @@ fn record_custom_world_profile_like_record( deleted_at: existing.deleted_at, created_at: existing.created_at, updated_at: liked_at, + visible: existing.visible, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; @@ -2582,6 +2603,7 @@ fn is_same_agent_draft_profile_candidate( ) -> bool { row.owner_user_id == owner_user_id && row.deleted_at.is_none() + && row.visible && row.publication_status == CustomWorldPublicationStatus::Draft && row.source_agent_session_id.as_deref() == Some(source_agent_session_id) } @@ -4841,6 +4863,7 @@ fn sync_custom_world_gallery_entry_from_profile( like_count: profile.like_count, published_at, updated_at: profile.updated_at, + visible: profile.visible, }; let inserted = ctx.db.custom_world_gallery_entry().insert(row); @@ -4854,7 +4877,7 @@ fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), .custom_world_profile() .by_custom_world_profile_publication_status() .filter(CustomWorldPublicationStatus::Published) - .filter(|profile| profile.deleted_at.is_none()) + .filter(|profile| profile.deleted_at.is_none() && profile.visible) .collect::>(); for profile in published_profiles { @@ -4926,6 +4949,7 @@ fn ensure_custom_world_profile_public_fields( deleted_at: profile.deleted_at, created_at: profile.created_at, updated_at: profile.updated_at, + visible: profile.visible, }; ctx.db.custom_world_profile().insert(next_row) @@ -4955,6 +4979,7 @@ fn build_custom_world_profile_row_copy(profile: &CustomWorldProfile) -> CustomWo deleted_at: profile.deleted_at, created_at: profile.created_at, updated_at: profile.updated_at, + visible: profile.visible, } } @@ -4997,7 +5022,7 @@ pub(crate) fn custom_world_public_profile_snapshots( .custom_world_profile() .by_custom_world_profile_publication_status() .filter(CustomWorldPublicationStatus::Published) - .filter(|row| row.deleted_at.is_none()) + .filter(|row| row.deleted_at.is_none() && row.visible) .map(|row| build_custom_world_profile_snapshot(&row)) .collect::>(); @@ -5156,6 +5181,7 @@ pub(crate) fn custom_world_public_gallery_snapshots( .custom_world_gallery_entry() .by_custom_world_gallery_owner_user_id() .filter(""..) + .filter(|row| row.visible) .map(|row| { build_custom_world_gallery_entry_snapshot_with_recent_counts(&row, &HashMap::new()) }) @@ -5377,6 +5403,7 @@ mod tests { deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), + visible: true, }; let deleted = CustomWorldProfile { profile_id: "profile-1".to_string(), @@ -5401,6 +5428,7 @@ mod tests { deleted_at: Some(Timestamp::from_micros_since_unix_epoch(2)), created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), + visible: true, }; let published = CustomWorldProfile { profile_id: "profile-1".to_string(), @@ -5425,6 +5453,7 @@ mod tests { deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), + visible: true, }; assert!(is_same_agent_draft_profile_candidate( @@ -5552,6 +5581,7 @@ mod tests { deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), + visible: true, }; let mut active_agent_session_ids = HashSet::new(); diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index 0209f748..6f73da86 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -20,6 +20,7 @@ pub fn jump_hop_gallery_view(ctx: &AnonymousViewContext) -> Vec Some(item), Err(error) => { @@ -401,6 +402,7 @@ fn compile_jump_hop_draft_tx( play_count: 0, updated_at: compiled_at, published_at: None, + visible: true, }; upsert_work(ctx, row); replace_session( @@ -1163,6 +1165,7 @@ fn clone_work(row: &JumpHopWorkProfileRow) -> JumpHopWorkProfileRow { play_count: row.play_count, updated_at: row.updated_at, published_at: row.published_at, + visible: row.visible, } } 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 74ef94d6..31715f0e 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs @@ -1,5 +1,7 @@ use crate::*; +const WORK_VISIBLE_DEFAULT: bool = true; + #[spacetimedb::table( accessor = jump_hop_agent_session, index(accessor = by_jump_hop_agent_session_owner_user_id, btree(columns = [owner_user_id])) @@ -51,6 +53,9 @@ 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, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/match3d.rs b/server-rs/crates/spacetime-module/src/match3d.rs index b4154fc2..d473c7fd 100644 --- a/server-rs/crates/spacetime-module/src/match3d.rs +++ b/server-rs/crates/spacetime-module/src/match3d.rs @@ -32,6 +32,7 @@ pub fn match3d_gallery_view(ctx: &AnonymousViewContext) -> Vec Some(item), Err(error) => { @@ -571,6 +572,7 @@ fn compile_match3d_draft_tx( updated_at: compiled_at, published_at: previous_published_at, generated_item_assets_json, + visible: true, }; upsert_work(ctx, work); replace_session( @@ -643,6 +645,7 @@ fn build_updated_match3d_work_row( updated_at, published_at: current.published_at, generated_item_assets_json: current.generated_item_assets_json.clone(), + visible: current.visible, }; Ok(next) } @@ -1330,6 +1333,7 @@ fn clone_work(row: &Match3DWorkProfileRow) -> Match3DWorkProfileRow { updated_at: row.updated_at, published_at: row.published_at, generated_item_assets_json: row.generated_item_assets_json.clone(), + visible: row.visible, } } @@ -1885,6 +1889,7 @@ mod tests { updated_at: Timestamp::from_micros_since_unix_epoch(1), published_at: None, generated_item_assets_json: None, + visible: true, }; let snapshot = build_initial_run_snapshot("run-1", &work, 10, None); assert_eq!(snapshot.total_item_count, 12); @@ -1924,6 +1929,7 @@ mod tests { r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"# .to_string(), ), + visible: true, }; let snapshot = build_work_snapshot(&work).expect("work snapshot should build"); @@ -1969,6 +1975,7 @@ mod tests { r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"# .to_string(), ), + visible: true, }; let preserved = @@ -2038,6 +2045,7 @@ mod tests { r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"# .to_string(), ), + visible: true, }; let input = Match3DWorkUpdateInput { profile_id: existing.profile_id.clone(), @@ -2097,6 +2105,7 @@ mod tests { r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"},{"itemId":"match3d-item-2","itemName":"苹果","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"},{"imageSrc":"/v5.png"}],"status":"model_ready"},{"itemId":"match3d-item-3","itemName":"香蕉","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"}],"status":"image_ready"}]"# .to_string(), ), + visible: true, }; let error = validate_publishable_work(&base_work).unwrap_err(); @@ -2156,6 +2165,7 @@ mod tests { updated_at: Timestamp::from_micros_since_unix_epoch(1), published_at: None, generated_item_assets_json: None, + visible: true, }; let input_game_name = None; diff --git a/server-rs/crates/spacetime-module/src/match3d/tables.rs b/server-rs/crates/spacetime-module/src/match3d/tables.rs index 950432cd..bd584074 100644 --- a/server-rs/crates/spacetime-module/src/match3d/tables.rs +++ b/server-rs/crates/spacetime-module/src/match3d/tables.rs @@ -1,5 +1,7 @@ use crate::*; +const WORK_VISIBLE_DEFAULT: bool = true; + #[spacetimedb::table( accessor = match3d_agent_session, index(accessor = by_match3d_agent_session_owner_user_id, btree(columns = [owner_user_id])) @@ -60,6 +62,9 @@ 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, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 443133f5..994f24e6 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,6 +1261,10 @@ 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)); // 中文注释:拼图公开互动计数晚于基础作品表加入,旧迁移包按 0 兼容。 object .entry("play_count".to_string()) @@ -1294,8 +1302,34 @@ 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" + | "square_hole_work_profile" + | "visual_novel_work_profile" + | "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)); + } + } 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)); // 中文注释:抓大鹅生成素材字段晚于基础作品表加入,旧迁移包按未生成素材兼容。 object .entry("generated_item_assets_json".to_string()) @@ -1304,6 +1338,10 @@ 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)); // 中文注释:敲木鱼背景环境图晚于首版作品表加入,旧迁移包按未生成背景兼容。 object .entry("background_asset_json".to_string()) diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 80a73e2f..1b7c42e5 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -38,6 +38,7 @@ use spacetimedb::{ use crate::auth::user_account; const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0; +const WORK_VISIBLE_DEFAULT: bool = true; /// 拼图 Agent session 真相表。 /// 当前只保存结构化字段与 JSON 草稿,不提前拆出更多编辑态子表。 @@ -84,34 +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)] + pub(crate) visible: bool, } /// 拼图广场公开详情兼容投影。 @@ -125,6 +129,7 @@ pub fn puzzle_gallery_view(ctx: &AnonymousViewContext) -> Vec .puzzle_work_profile() .by_puzzle_work_publication_status() .filter(PuzzlePublicationStatus::Published) + .filter(|row| row.visible) .filter_map( |row| match build_puzzle_work_profile_from_row_without_recent_count(&row) { Ok(profile) => Some(profile), @@ -154,6 +159,7 @@ pub fn puzzle_gallery_card_view(ctx: &AnonymousViewContext) -> Vec Some(item), Err(error) => { @@ -1578,6 +1584,7 @@ fn update_puzzle_work_tx( created_at: row.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros), published_at: row.published_at, + visible: row.visible, }; replace_puzzle_work_profile(ctx, &row, next_row); sync_puzzle_source_session_draft_from_work(ctx, &row, &preview_draft, input.updated_at_micros)?; @@ -1790,6 +1797,7 @@ fn record_puzzle_work_like_tx( created_at: row.created_at, updated_at: liked_at, published_at: row.published_at, + visible: row.visible, }; replace_puzzle_work_profile(ctx, &row, next_row); ctx.db @@ -1878,6 +1886,7 @@ fn remix_puzzle_work_tx( created_at: source.created_at, updated_at: remixed_at, published_at: source.published_at, + visible: source.visible, }, ); @@ -1945,6 +1954,7 @@ fn remix_puzzle_work_tx( created_at: remixed_at, updated_at: remixed_at, published_at: None, + visible: true, }); get_puzzle_agent_session_tx( @@ -2396,6 +2406,7 @@ fn claim_puzzle_work_point_incentive_tx( created_at: row.created_at, updated_at: claimed_at, published_at: row.published_at, + visible: row.visible, }; replace_puzzle_work_profile(ctx, &row, next_row); @@ -3008,6 +3019,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re published_at: profile .published_at_micros .map(Timestamp::from_micros_since_unix_epoch), + visible: existing.visible, }, ); return Ok(()); @@ -3040,6 +3052,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re published_at: profile .published_at_micros .map(Timestamp::from_micros_since_unix_epoch), + visible: true, }); Ok(()) } @@ -3364,6 +3377,7 @@ fn accrue_puzzle_point_incentive( created_at: row.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), published_at: row.published_at, + visible: row.visible, }, ); Ok(()) @@ -3402,6 +3416,7 @@ fn increment_puzzle_profile_play_count( created_at: row.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), published_at: row.published_at, + visible: row.visible, }, ); } diff --git a/server-rs/crates/spacetime-module/src/runtime.rs b/server-rs/crates/spacetime-module/src/runtime.rs index 730b89e2..cf85471a 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 00000000..35facd86 --- /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.rs b/server-rs/crates/spacetime-module/src/square_hole.rs index 4358722a..27f16684 100644 --- a/server-rs/crates/spacetime-module/src/square_hole.rs +++ b/server-rs/crates/spacetime-module/src/square_hole.rs @@ -39,6 +39,7 @@ pub fn square_hole_gallery_view(ctx: &AnonymousViewContext) -> Vec Some(item), Err(error) => { @@ -537,6 +538,7 @@ fn compile_square_hole_draft_tx( play_count: 0, updated_at: compiled_at, published_at: None, + visible: true, }; upsert_work(ctx, work); replace_session( @@ -614,6 +616,7 @@ fn update_square_hole_work_tx( play_count: current.play_count, updated_at, published_at: current.published_at, + visible: current.visible, }; let snapshot = build_work_snapshot(&next)?; replace_work(ctx, ¤t, next); @@ -1141,6 +1144,7 @@ fn clone_work(row: &SquareHoleWorkProfileRow) -> SquareHoleWorkProfileRow { play_count: row.play_count, updated_at: row.updated_at, published_at: row.published_at, + visible: row.visible, } } 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 59600a8d..6be841ac 100644 --- a/server-rs/crates/spacetime-module/src/square_hole/tables.rs +++ b/server-rs/crates/spacetime-module/src/square_hole/tables.rs @@ -1,5 +1,7 @@ use crate::*; +const WORK_VISIBLE_DEFAULT: bool = true; + #[spacetimedb::table( accessor = square_hole_agent_session, index(accessor = by_square_hole_agent_session_owner_user_id, btree(columns = [owner_user_id])) @@ -59,6 +61,9 @@ 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, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/visual_novel.rs b/server-rs/crates/spacetime-module/src/visual_novel.rs index f377e312..6ba1597f 100644 --- a/server-rs/crates/spacetime-module/src/visual_novel.rs +++ b/server-rs/crates/spacetime-module/src/visual_novel.rs @@ -1,8 +1,9 @@ use crate::*; -use serde::Serialize; use serde::de::DeserializeOwned; use spacetimedb::AnonymousViewContext; +const WORK_VISIBLE_DEFAULT: bool = true; + pub const VISUAL_NOVEL_SOURCE_IDEA: &str = "idea"; pub const VISUAL_NOVEL_SOURCE_DOCUMENT: &str = "document"; pub const VISUAL_NOVEL_SOURCE_BLANK: &str = "blank"; @@ -94,6 +95,9 @@ 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, } /// 视觉小说运行态 run 表。 @@ -178,6 +182,7 @@ pub fn visual_novel_gallery_view(ctx: &AnonymousViewContext) -> Vec Some(item), Err(error) => { @@ -421,13 +426,13 @@ pub struct VisualNovelRuntimeEventProcedureResult { pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)] pub struct VisualNovelJsonField { pub key: String, pub value: VisualNovelJsonValue, } -#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)] pub enum VisualNovelJsonValue { Null, Bool(bool), @@ -437,7 +442,7 @@ pub enum VisualNovelJsonValue { Object(Vec), } -#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelAgentMessageSnapshot { pub message_id: String, @@ -448,7 +453,7 @@ pub struct VisualNovelAgentMessageSnapshot { pub created_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelAgentSessionSnapshot { pub session_id: String, @@ -468,7 +473,7 @@ pub struct VisualNovelAgentSessionSnapshot { pub updated_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelWorkSnapshot { pub work_id: String, @@ -490,7 +495,7 @@ pub struct VisualNovelWorkSnapshot { pub published_at_micros: Option, } -#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRuntimeHistoryEntrySnapshot { pub entry_id: String, @@ -506,7 +511,7 @@ pub struct VisualNovelRuntimeHistoryEntrySnapshot { pub created_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRunSnapshot { pub run_id: String, @@ -526,7 +531,7 @@ pub struct VisualNovelRunSnapshot { pub updated_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRuntimeEventSnapshot { pub event_id: String, @@ -1029,6 +1034,7 @@ fn compile_visual_novel_work_profile_tx( created_at: compiled_at, updated_at: compiled_at, published_at: None, + visible: true, }; upsert_work(ctx, work); replace_session( @@ -1731,6 +1737,7 @@ fn clone_work(row: &VisualNovelWorkProfileRow) -> VisualNovelWorkProfileRow { created_at: row.created_at, updated_at: row.updated_at, published_at: row.published_at, + visible: row.visible, } } @@ -1971,7 +1978,7 @@ fn parse_json(value: &str, label: &str) -> Result(value: &T) -> String { +fn to_json_string(value: &T) -> String { serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) } diff --git a/server-rs/crates/spacetime-module/src/wooden_fish.rs b/server-rs/crates/spacetime-module/src/wooden_fish.rs index 18232d9e..ea60b99f 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish.rs @@ -24,6 +24,7 @@ pub fn wooden_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec Some(item), Err(error) => { @@ -412,6 +413,7 @@ fn compile_wooden_fish_draft_tx( published_at: None, background_asset_json: background_asset.as_ref().map(to_json_string), back_button_asset_json: back_button_asset.as_ref().map(to_json_string), + visible: true, }; upsert_work(ctx, row); let config = config_from_draft(&draft); @@ -1313,6 +1315,7 @@ fn clone_work(row: &WoodenFishWorkProfileRow) -> WoodenFishWorkProfileRow { play_count: row.play_count, updated_at: row.updated_at, published_at: row.published_at, + visible: row.visible, } } 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 74268598..306f444d 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs @@ -1,5 +1,7 @@ use crate::*; +const WORK_VISIBLE_DEFAULT: bool = true; + #[spacetimedb::table( accessor = wooden_fish_agent_session, index(accessor = by_wooden_fish_agent_session_owner_user_id, btree(columns = [owner_user_id])) @@ -49,6 +51,9 @@ 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, } #[spacetimedb::table(