diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index bd6d441b..3710e85b 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -95,6 +95,8 @@ npm run dev:admin-web 开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。 +生产 `Genarrative-Stdb-Module-Publish` 的备份默认使用 `DATABASE_BACKUP_MODE=async`:流水线在 publish 前先生成本地冷备份,随后继续 publish,并把同一份发布前备份交给后台 Node 进程上传 OSS,避免低带宽 OSS 上传长时间占住部署窗口。需要强制在 publish 前等待打包和上传并让失败阻断发布时,手动选择 `DATABASE_BACKUP_MODE=sync`;已有其他备份窗口且明确接受风险时才选择 `skip`。 + 查看本地 Rust/SpacetimeDB 日志: ```bash diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index a6a0e5c6..3dea8cd4 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -23,6 +23,14 @@ - 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## VectorEngine 图片生成 SendRequest 超时要按传输失败排查 + +- 现象:`external_api_call_failure` 里看到 `failureStage=request_send`、`timeout=true`、`statusCode=null`、`errorSource=client error (SendRequest)`,前端只知道图片生成失败。 +- 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。 +- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId` 定位触发者与草稿 / 作品;`request_send + timeout=true` 优先查请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。若记录有 `502` 或 `429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。 +- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。 +- 关联:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## “我的”页每日任务卡不要硬编码进度 - 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。 @@ -910,6 +918,14 @@ - 验证:运行 `npm run test -- src\services\assetReadUrlService.test.ts src\hooks\useResolvedAssetReadUrl.test.tsx src\components\puzzle-result\PuzzleResultView.test.tsx`,再触发一次真实生成确认 Network 中先请求 `/api/assets/read-url`,图片 `src` 为未追加 `_v` 的签名 URL。 - 关联:`src/services/assetReadUrlService.ts`、`src/components/ResolvedAssetImage.tsx`、`docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md`。 +## 拼图图片生成失败后不要停在 ImageRefining + +- 现象:拼图图片生成失败后,会话仍停留在 `PuzzleAgentStage::ImageRefining`,用户从作品架或生成页恢复时容易被当成生成中/精修中状态,重试入口和失败承接不清晰。 +- 原因:`mark_puzzle_draft_generation_failed_tx` 只把 `PuzzleResultDraft.generation_status` 标成 `failed`,但 session stage 仍沿用旧的 `row.stage`;如果失败前已进入 `ImageRefining`,失败回写不会把会话带回结果草稿态。 +- 处理:失败回写后按失败草稿重新解析 session stage:已发布保持 `Published`,仍满足发布门禁则为 `ReadyToPublish`,否则回到 `DraftReady`;前端生成页文案用“拼图图片生成进度 / 重新生成图片”,避免把失败态误导成还在生成整份草稿。 +- 验证:运行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,以及拼图生成页恢复相关 `RpgEntryFlowShell.agent.interaction.test.tsx` 定向用例。 +- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`。 + ## 本地短信登录页签突然消失 - 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。 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..76c22a5f 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,8 @@ - 旧 view 保留,不删除。 - 旧 view 退到底层 source / 兼容职责。 - 新 `public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。 +- 各玩法 source view 只暴露 `visible=true` 的已发布作品;旧数据迁移默认补 `visible=true`,避免历史作品被误隐藏。 +- 临时运行约束:SpacetimeDB 2.2 下抓大鹅 `match_3_d_gallery_view` 的 `publication_status` 索引过滤在源表更新触发统一 view 刷新时可能初始化 panic;为避免后台隐藏作品打爆 module instance,统一 `public_work_*` view 暂不级联抓大鹅 source view,抓大鹅公开入口先保留玩法专用路径。后续应以 source projection 表替代索引 view 后再重新并入统一 read model。 - 旧 `/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..12609ae9 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/*`。 @@ -70,6 +70,12 @@ npm run check:server-rs-ddd 需要新增路由时,先确认玩法入口配置和 tracking 分类,不要绕过 `app.rs` 的统一中间件、鉴权和入口开关。 +### 认证态用户与会话摘要下发口径 + +- `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id`、`publicUserCode`、`displayName`、`avatarUrl`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`。 +- `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId`、`sessionIds`、`sessionCount`、`clientLabel`、`ipMasked`、`isCurrent`、`createdAt`、`lastSeenAt`、`expiresAt`。 +- 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO,而不是把账号 / 会话快照恢复为全量对象。 + ## api-server 模块化演进规则 `api-server` 的长期方向是从超大 `app.rs` 和超大 handler 文件收敛为按能力组织的 HTTP/BFF Module。后续改造必须保持 HTTP route、DTO、error envelope、SpacetimeDB schema、前端行为和计费语义默认不变;任何 contract 或 schema 变化都要先在当前文档中写清影响范围和迁移计划。 @@ -167,7 +173,7 @@ npm run check:server-rs-ddd - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。 - 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 - OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。 -- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 +- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 - 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。 ## SpacetimeDB 表目录 @@ -257,6 +263,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 +300,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 +364,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 +425,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 +461,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` @@ -466,10 +478,12 @@ npm run check:server-rs-ddd - Rust 结构体:`Match3DRuntimeRunRow` - 源码:`server-rs/crates/spacetime-module/src/match3d/tables.rs` -### `match3d_work_profile` +### `match_3_d_work_profile` - Rust 结构体:`Match3DWorkProfileRow` +- Rust accessor:`match_3_d_work_profile` - 源码:`server-rs/crates/spacetime-module/src/match3d/tables.rs` +- 兼容说明:dev 现有 SpacetimeDB 元数据中的真实表名 / 索引名为 `match_3_d_work_profile` 与 `match_3_d_work_profile_*_idx_btree`。module 内部 accessor 必须与该 canonical name 对齐,避免 Rust SDK 在 `index_id_from_name` 初始化二级索引时查找 `match3d_work_profile_*` 并触发 `No such index` panic。`migration.rs` 仍兼容旧迁移包中的 `match3d_work_profile` 表名补默认字段。 ### SpacetimeDB view:`match_3_d_gallery_view` @@ -477,6 +491,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 +678,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 +731,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 +807,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/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 42395cc2..c8886440 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -203,7 +203,7 @@ UI 相关修改要重点验证: npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service ``` -脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS。`Genarrative-Stdb-Module-Publish` 默认也会在 `spacetime publish` 前执行同一脚本;备份失败会阻断 publish,只有显式勾选 `SKIP_DATABASE_BACKUP` 或脚本参数 `--skip-backup` 才跳过。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 +脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用后台 `node ... --upload-archive ` 上传同一份发布前备份,不等待上传完成。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 生产环境变量模板在 `deploy/env/api-server.env.example`: @@ -283,7 +283,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 - debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。 - api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。 - HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。 -- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、status_class、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。排障时先按 provider / failureStage 聚合,再结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。 +- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、status_class、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` 中记录触发者与草稿 / 作品作用域。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。 - SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 - 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。 - Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。 @@ -342,7 +342,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms 个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。 -外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。常用查询: +外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId 和 profileId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,入口拿不到上下文时允许为空。常用查询: ```sql SELECT event_id, scope_id AS provider, metadata_json, occurred_at @@ -352,6 +352,25 @@ ORDER BY occurred_at DESC LIMIT 50; ``` +按失败阶段、触发者和作品作用域聚合时: + +```sql +SELECT + json_extract(metadata_json, '$.failureStage') AS failure_stage, + user_id, + profile_id, + COUNT(*) AS failures, + MIN(occurred_at) AS first_seen, + MAX(occurred_at) AS last_seen +FROM tracking_event +WHERE event_key = 'external_api_call_failure' +GROUP BY failure_stage, user_id, profile_id +ORDER BY failures DESC, last_seen DESC +LIMIT 100; +``` + +VectorEngine `request_send` 且 `timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource = client error (SendRequest)` 是 Hyper 发送请求阶段的错误来源标签,不等于最终根因。若 `statusCode` 为空,应优先查同一时间窗口的 `api-server` request 日志、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502`、`429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。 + tracking outbox 默认配置: ```env diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 4ca4e204..d5207f03 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -276,7 +276,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 创作 Tab 表单:填写作品标题、简介、主题 / 竞技背景描述、玩家形象描述、对手形象描述、拟声词和难度。拟声词支持换行、逗号、顿号、斜杠或竖线分隔;未手动编辑时随主题 / 形象描述自动重算,手动编辑后保持创作者自定义。 - 草稿编译:`POST /api/creation/bark-battle/drafts` 写入配置 JSON,返回包含 `draftId`、稳定 `workId`、`configVersion` 和 `rulesetVersion` 的草稿结果。 - 生成页:`bark-battle-generating` 自动并行产出玩家形象、对手形象和竞技背景三图;前端生成页 UI 和其它玩法保持同一圆环主视觉,`media/create_bg_video.mp4` 作为固定全屏页面背景层循环静音播放,主进度圆环居中展示总进度,只保留当前步骤名称和当前步骤进度,不再渲染三行槽位列表。视频层需要显式触发播放。三图都走 Bark Battle 专用后端生图接口 `POST /api/creation/bark-battle/images/generate`,由后端按 `player-character`、`opponent-character`、`ui-background` 分别拼装正式提示词、写入 `generated-bark-battle-assets` 私有资产前缀并返回实际 prompt。玩家 / 对手形象提示词必须保持用户形象描述,不强行注入狗相关主体,并要求正面、单个完整形象和透明背景。部分失败也继续进入结果页。 -- 结果页:围绕三图槽位展示错误态与已生成结果,只保留单槽重试、重新生成和上传,不再提供一次生成按钮、音频配置入口或排名配置。 +- 结果页:围绕三图槽位展示错误态与已生成结果,只保留单槽重试、重新生成和上传,不再提供一次生成按钮、音频配置入口或排名配置;生成回写 `partial_failed` 时作品架不再显示整卡“生成中”遮罩,由结果页槽位错误承接失败。 - 手动上传:结果页通过平台资产直传 `/api/assets/direct-upload-tickets` 与 `/api/assets/objects/confirm` 写入私有资产,再把返回的历史 generated 路径写回草稿配置。 - 发布:结果页确认后必须携带草稿返回的同一个 `workId` 和结果页最终 `publishedSnapshot` 调用 `POST /api/creation/bark-battle/works/publish`;SpacetimeDB 发布态的 `config_json` 必须使用该最终快照,works summary 若拿到 `publishedSnapshotJson` 也优先使用最终快照映射封面三图。发布成功后先进入统一作品详情页,再由详情页进入正式 runtime;缺少 `workId` 的旧草稿状态需要重新生成草稿。 - 作品架:Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。 diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index c143df03..ab6cad21 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -27,7 +27,7 @@ pipeline { string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;需与自托管 spacetimedb.service 一致') string(name: 'SPACETIME_RUN_AS_USER', defaultValue: 'spacetimedb', description: '执行 spacetime publish 的本机用户,默认使用自托管服务用户') booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '是否清空数据库后发布') - booleanParam(name: 'SKIP_DATABASE_BACKUP', defaultValue: false, description: '是否跳过 publish 前 OSS 数据库备份;默认不跳过,备份失败会阻断发布') + choice(name: 'DATABASE_BACKUP_MODE', choices: ['async', 'sync', 'skip'], description: '数据库备份策略:async 在 publish 前生成本地冷备份、后台上传 OSS;sync 在 publish 前等待上传完成且失败阻断;skip 跳过') } stages { @@ -139,7 +139,22 @@ pipeline { steps { script { def clearArg = params.CLEAR_DATABASE ? '--clear-database' : '' - def backupArg = params.SKIP_DATABASE_BACKUP ? '--skip-backup' : '' + def backupMode = params.DATABASE_BACKUP_MODE?.trim() ? params.DATABASE_BACKUP_MODE.trim() : 'async' + if (!(backupMode in ['async', 'sync', 'skip'])) { + error("DATABASE_BACKUP_MODE 只能是 async、sync 或 skip: ${backupMode}") + } + def publishScriptPath = 'scripts/deploy/production-stdb-publish.sh' + def publishScriptText = readFile(file: publishScriptPath, encoding: 'UTF-8') + def publishScriptSupportsBackupMode = publishScriptText.contains('--backup-mode') + def backupArg = '' + if (publishScriptSupportsBackupMode) { + backupArg = "--backup-mode \"${backupMode}\"" + } else if (backupMode == 'sync') { + error('当前工作区的 scripts/deploy/production-stdb-publish.sh 还不支持 --backup-mode,无法保证 sync 语义。请先更新工作区脚本后再运行。') + } else { + echo "[Jenkins] 当前工作区脚本还不支持 --backup-mode,async/skip 自动降级为 --skip-backup,避免参数不兼容导致发布失败。" + backupArg = '--skip-backup' + } def rootArg = "--root-dir \"${params.SPACETIME_ROOT_DIR?.trim() ? params.SPACETIME_ROOT_DIR.trim() : '/stdb'}\"" def runAsArg = params.SPACETIME_RUN_AS_USER?.trim() ? "--run-as-user \"${params.SPACETIME_RUN_AS_USER.trim()}\"" diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 918c4c48..4765cd9f 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -4,19 +4,18 @@ export type AuthLoginMethod = 'password' | 'phone' | 'wechat'; export type AuthUser = { id: string; publicUserCode: string; - username: string; displayName: string; avatarUrl: string | null; phoneNumberMasked: string | null; loginMethod: AuthLoginMethod; bindingStatus: AuthBindingStatus; wechatBound: boolean; - createdAt: string; }; export type PublicUserSummary = { id: string; publicUserCode: string; + username: string; displayName: string; avatarUrl: string | null; }; @@ -159,14 +158,7 @@ export type AuthSessionSummary = { sessionId: string; sessionIds: string[]; sessionCount: number; - clientType: string; - clientRuntime: string; - clientPlatform: string; clientLabel: string; - deviceDisplayName: string; - miniProgramAppId: string | null; - miniProgramEnv: string | null; - userAgent: string | null; ipMasked: string | null; isCurrent: boolean; createdAt: string; diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index 03448e27..f5514932 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -227,3 +227,31 @@ export function parseApiErrorMessage(rawText: string, fallbackMessage: string) { return rawText.trim() || fallbackMessage; } + +export function appendApiErrorRequestId( + message: string, + requestId: string | null | undefined, +) { + const trimmedMessage = message.trim() || '请求失败'; + const trimmedRequestId = + typeof requestId === 'string' && requestId.trim() + ? requestId.trim() + : ''; + + if (!trimmedRequestId || trimmedMessage.includes(trimmedRequestId)) { + return trimmedMessage; + } + + return `${trimmedMessage}(requestId: ${trimmedRequestId})`; +} + +export function parseApiErrorMessageWithRequestId( + rawText: string, + fallbackMessage: string, + requestId: string | null | undefined, +) { + return appendApiErrorRequestId( + parseApiErrorMessage(rawText, fallbackMessage), + requestId, + ); +} diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index 9e7bf2e7..5eac405b 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -20,10 +20,12 @@ const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD'; function usage() { console.log(`用法: npm run database:backup:oss -- [--data-dir ] [--work-dir ] [--bucket ] [--object-prefix ] [--keep-local] - node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service] + node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service] [--defer-upload] + node scripts/database-backup-to-oss.mjs --upload-archive 说明: 将 SpacetimeDB 数据目录打包成 .tar.gz,并上传到阿里云 OSS 指定 bucket。 + --defer-upload 只生成本地冷备份和 manifest,不上传;后续用 --upload-archive 异步上传。 默认读取 .env / .env.local / .env.secrets.local;生产服务可传 --env-file /etc/genarrative/api-server.env。 shell 环境变量优先级最高,不会被 env 文件覆盖。 @@ -100,6 +102,11 @@ function parseArgs(argv) { stopService: '', database: '', dryRun: false, + deferUpload: false, + uploadArchive: '', + manifestFile: '', + objectKey: '', + resultFile: '', }; for (let index = 0; index < argv.length; index += 1) { @@ -134,6 +141,9 @@ function parseArgs(argv) { case '--object-prefix': options.objectPrefix = readValue(); break; + case '--object-key': + options.objectKey = readValue(); + break; case '--access-key-id': options.accessKeyId = readValue(); break; @@ -155,6 +165,19 @@ function parseArgs(argv) { case '--dry-run': options.dryRun = true; break; + case '--defer-upload': + options.deferUpload = true; + options.keepLocal = true; + break; + case '--upload-archive': + options.uploadArchive = readValue(); + break; + case '--manifest-file': + options.manifestFile = readValue(); + break; + case '--result-file': + options.resultFile = readValue(); + break; default: throw new Error(`未知参数: ${arg}`); } @@ -260,6 +283,17 @@ function createArchive({dataDir, workDir, fileName}) { return archivePath; } +function writeManifest({manifestPath, payload}) { + writeFileSync(manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +function readManifest(manifestPath) { + if (!existsSync(manifestPath)) { + throw new Error(`备份清单不存在: ${manifestPath}`); + } + return JSON.parse(readFileSync(manifestPath, 'utf8')); +} + function hmac(key, content, encoding) { return createHmac('sha256', key).update(content).digest(encoding); } @@ -372,6 +406,59 @@ async function uploadArchive({archivePath, bucket, endpoint, objectKey, accessKe }; } +async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix}) { + const archivePath = resolvePath(args.uploadArchive); + if (!existsSync(archivePath)) { + throw new Error(`待上传备份文件不存在: ${archivePath}`); + } + + const manifestPath = resolvePath(args.manifestFile || `${archivePath}.manifest.json`); + const manifest = existsSync(manifestPath) ? readManifest(manifestPath) : {}; + const dataDir = firstNonEmpty(manifest.dataDir, env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, DEFAULT_PRODUCTION_DATA_DIR); + const database = firstNonEmpty(args.database, manifest.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir)); + const objectKey = firstNonEmpty(args.objectKey, manifest.objectKey, buildBackupNames({database, dataDir, objectPrefix}).objectKey); + + console.log(`[database-backup] 上传已有备份: ${archivePath}`); + console.log(`[database-backup] 目标对象: oss://${bucket}/${objectKey}`); + + if (args.dryRun) { + console.log('[database-backup] dry-run,仅校验上传配置。'); + return; + } + + const result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret}); + console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`); + + const uploadedAt = new Date().toISOString(); + writeManifest({ + manifestPath, + payload: { + ...manifest, + database, + bucket: result.bucket, + objectKey: result.objectKey, + contentLength: result.contentLength, + etag: result.etag, + uploadedAt, + uploadStatus: 'uploaded', + }, + }); + + if (args.resultFile) { + writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, ...result, uploadedAt}, null, 2)}\n`, 'utf8'); + } + + const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true'; + if (!keepLocal) { + rmSync(archivePath, {force: true}); + rmSync(manifestPath, {force: true}); + console.log('[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。'); + } else { + console.log(`[database-backup] 已保留本地备份: ${archivePath}`); + console.log(`[database-backup] 已保留备份清单: ${manifestPath}`); + } +} + async function main() { const args = parseArgs(process.argv.slice(2)); const env = loadEffectiveEnv(args.envFiles); @@ -400,6 +487,11 @@ async function main() { } } + if (args.uploadArchive) { + await uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix}); + return; + } + const {fileName, objectKey} = buildBackupNames({database, dataDir, objectPrefix}); console.log(`[database-backup] 数据目录: ${dataDir}`); console.log(`[database-backup] 本地临时目录: ${workDir}`); @@ -419,22 +511,47 @@ async function main() { startServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE), serviceStopped); } + const manifestPath = `${archivePath}.manifest.json`; + writeManifest({ + manifestPath, + payload: { + createdAt: new Date().toISOString(), + database, + dataDir, + bucket, + objectKey, + archivePath, + uploadStatus: args.deferUpload ? 'deferred' : 'pending', + }, + }); + + if (args.deferUpload) { + console.log(`[database-backup] 已生成本地冷备份,延后上传: ${archivePath}`); + console.log(`[database-backup] 已写入备份清单: ${manifestPath}`); + if (args.resultFile) { + writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, bucket, objectKey}, null, 2)}\n`, 'utf8'); + } + return; + } + const result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret}); console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`); - const manifestPath = `${archivePath}.manifest.json`; - writeFileSync( + writeManifest({ manifestPath, - `${JSON.stringify({ + payload: { createdAt: new Date().toISOString(), + database, dataDir, bucket: result.bucket, objectKey: result.objectKey, + archivePath, contentLength: result.contentLength, etag: result.etag, - }, null, 2)}\n`, - 'utf8', - ); + uploadedAt: new Date().toISOString(), + uploadStatus: 'uploaded', + }, + }); if (!keepLocal) { rmSync(archivePath, {force: true}); diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index 3bf57898..9373b375 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -331,6 +331,15 @@ mkdir -p "${RELEASE_DIR}" cp "${SOURCE_DIR}/api-server" "${RELEASE_DIR}/api-server" chmod +x "${RELEASE_DIR}/api-server" +SCRIPT_SOURCE_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)/scripts" +mkdir -p "${RELEASE_DIR}/scripts" +if [[ -f "${SCRIPT_SOURCE_DIR}/database-backup-to-oss.mjs" ]]; then + cp "${SCRIPT_SOURCE_DIR}/database-backup-to-oss.mjs" "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs" + chmod 0644 "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs" +else + echo "[production-api-deploy] 未找到数据库备份脚本,release 目录不会包含 scripts/database-backup-to-oss.mjs" >&2 +fi + if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_DIR}/release-manifest.api-server.json" fi diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh index 2b7c0c1b..64f4e9dd 100644 --- a/scripts/deploy/production-stdb-publish.sh +++ b/scripts/deploy/production-stdb-publish.sh @@ -5,14 +5,15 @@ set -euo pipefail usage() { cat <<'EOF' 用法: - ./scripts/deploy/production-stdb-publish.sh --source-dir build/ --database [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--clear-database] [--skip-backup] + ./scripts/deploy/production-stdb-publish.sh --source-dir build/ --database [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--clear-database] [--backup-mode async|sync|skip] 说明: 进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。 默认使用 http://127.0.0.1:3101,避免与部署机本机 Git/Web 服务的 3000 端口冲突。 默认使用 /stdb 作为 spacetime CLI root-dir,并以 spacetimedb 用户发布,避免 root CLI 身份污染自托管实例。 发布时固定追加 --no-config,只使用显式参数,避免工作区或用户目录里的 spacetime 配置干扰目标。 - publish 前默认执行一次 OSS 冷备份;备份失败会阻断 publish。仅明确传入 --skip-backup 时跳过。 + async 模式会在 publish 前先做本地冷备份,再在 publish 完成后后台上传 OSS,避免低带宽上传阻塞部署。 + 如需强制等待备份完成并在失败时阻断 publish,传入 --backup-mode sync。 失败时保留维护模式。 EOF } @@ -44,9 +45,15 @@ SERVER_URL="http://127.0.0.1:3101" SPACETIME_ROOT_DIR="/stdb" RUN_AS_USER="spacetimedb" CLEAR_DATABASE=0 -SKIP_BACKUP=0 +BACKUP_MODE="${GENARRATIVE_STDB_PUBLISH_BACKUP_MODE:-async}" DEPLOY_COMPLETED=0 PUBLISH_TMP_DIR="" +ASYNC_BACKUP_STATUS_FILE="" +ASYNC_BACKUP_SCRIPT="" +ASYNC_BACKUP_ARCHIVE="" +ASYNC_BACKUP_MANIFEST="" +ASYNC_BACKUP_LOG="" +SPACETIME_READY_TIMEOUT_SECONDS="${GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS:-60}" while [[ $# -gt 0 ]]; do case "$1" in @@ -84,9 +91,17 @@ while [[ $# -gt 0 ]]; do shift ;; --skip-backup) - SKIP_BACKUP=1 + BACKUP_MODE="skip" shift ;; + --sync-backup) + BACKUP_MODE="sync" + shift + ;; + --backup-mode) + BACKUP_MODE="${2:?缺少 --backup-mode 的值}" + shift 2 + ;; *) echo "[production-stdb-publish] 未知参数: $1" >&2 usage >&2 @@ -104,11 +119,21 @@ if [[ ! "${SPACETIME_ROOT_DIR}" == /* || "${SPACETIME_ROOT_DIR}" == *".."* ]]; t exit 1 fi +if [[ ! "${BACKUP_MODE}" =~ ^(async|sync|skip)$ ]]; then + echo "[production-stdb-publish] --backup-mode 只能是 async、sync 或 skip: ${BACKUP_MODE}" >&2 + exit 1 +fi + if [[ -n "${RUN_AS_USER}" && ! "${RUN_AS_USER}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then echo "[production-stdb-publish] --run-as-user 只能是本机用户名: ${RUN_AS_USER}" >&2 exit 1 fi +if [[ ! "${SPACETIME_READY_TIMEOUT_SECONDS}" =~ ^[0-9]+$ || "${SPACETIME_READY_TIMEOUT_SECONDS}" -le 0 ]]; then + echo "[production-stdb-publish] GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS 必须是正整数: ${SPACETIME_READY_TIMEOUT_SECONDS}" >&2 + exit 1 +fi + if [[ ! -d "${SOURCE_DIR}" ]]; then echo "[production-stdb-publish] 发布目录不存在: ${SOURCE_DIR}" >&2 exit 1 @@ -123,6 +148,9 @@ fi on_exit() { local exit_code=$? + if [[ "${BACKUP_MODE}" == "async" && -n "${ASYNC_BACKUP_STATUS_FILE}" && -f "${ASYNC_BACKUP_STATUS_FILE}" ]]; then + start_async_backup_upload || true + fi if [[ -n "${PUBLISH_TMP_DIR}" && -d "${PUBLISH_TMP_DIR}" ]]; then rm -rf "${PUBLISH_TMP_DIR}" fi @@ -134,27 +162,107 @@ on_exit() { trap on_exit EXIT -"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}" - -if [[ "${SKIP_BACKUP}" -ne 1 ]]; then - BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs" - if [[ ! -f "${BACKUP_SCRIPT}" ]]; then - BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" +prepare_async_backup() { + ASYNC_BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs" + if [[ ! -f "${ASYNC_BACKUP_SCRIPT}" ]]; then + ASYNC_BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" fi - if [[ ! -f "${BACKUP_SCRIPT}" ]]; then - echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${BACKUP_SCRIPT}" >&2 + if [[ ! -f "${ASYNC_BACKUP_SCRIPT}" ]]; then + echo "[production-stdb-publish] 缺少数据库备份脚本: ${ASYNC_BACKUP_SCRIPT}" >&2 exit 1 fi - echo "[production-stdb-publish] publish 前执行 OSS 冷备份" - node "${BACKUP_SCRIPT}" \ + ASYNC_BACKUP_STATUS_FILE="$(mktemp /tmp/genarrative-stdb-backup-status.XXXXXX.json)" + echo "[production-stdb-publish] publish 前生成本地冷备份,随后会异步上传 OSS" + node "${ASYNC_BACKUP_SCRIPT}" \ --env-file /etc/genarrative/api-server.env \ --data-dir "${SPACETIME_ROOT_DIR}" \ --database "${DATABASE}" \ - --stop-service spacetimedb.service -else - echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份" -fi + --stop-service spacetimedb.service \ + --defer-upload \ + --result-file "${ASYNC_BACKUP_STATUS_FILE}" +} + +start_async_backup_upload() { + if [[ -z "${ASYNC_BACKUP_STATUS_FILE}" || ! -f "${ASYNC_BACKUP_STATUS_FILE}" ]]; then + echo "[production-stdb-publish] 警告:未找到可上传的本地备份状态文件,跳过异步上传" >&2 + return 0 + fi + + ASYNC_BACKUP_ARCHIVE="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.archivePath || "");' "${ASYNC_BACKUP_STATUS_FILE}")" + ASYNC_BACKUP_MANIFEST="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.manifestPath || "");' "${ASYNC_BACKUP_STATUS_FILE}")" + if [[ -z "${ASYNC_BACKUP_ARCHIVE}" || -z "${ASYNC_BACKUP_MANIFEST}" ]]; then + echo "[production-stdb-publish] 警告:备份状态文件缺少 archivePath 或 manifestPath,跳过异步上传" >&2 + return 0 + fi + + mkdir -p "$(dirname "${ASYNC_BACKUP_ARCHIVE}")" + ASYNC_BACKUP_LOG="$(dirname "${ASYNC_BACKUP_ARCHIVE}")/${DATABASE}-upload.log" + echo "[production-stdb-publish] 后台上传本地备份到 OSS: ${ASYNC_BACKUP_ARCHIVE}" + nohup node "${ASYNC_BACKUP_SCRIPT}" \ + --env-file /etc/genarrative/api-server.env \ + --upload-archive "${ASYNC_BACKUP_ARCHIVE}" \ + --manifest-file "${ASYNC_BACKUP_MANIFEST}" \ + >"${ASYNC_BACKUP_LOG}" 2>&1 & + echo "[production-stdb-publish] OSS 后台上传日志: ${ASYNC_BACKUP_LOG}" + rm -f "${ASYNC_BACKUP_STATUS_FILE}" + ASYNC_BACKUP_STATUS_FILE="" +} + +wait_for_spacetime_ready() { + if [[ -z "${SERVER_URL}" ]]; then + echo "[production-stdb-publish] 使用 server alias=${SERVER_ALIAS},跳过 URL 健康检查等待" + return 0 + fi + + local ping_url="${SERVER_URL%/}/v1/ping" + local deadline=$((SECONDS + SPACETIME_READY_TIMEOUT_SECONDS)) + local last_status="" + + echo "[production-stdb-publish] 等待 SpacetimeDB 就绪: ${ping_url},timeout=${SPACETIME_READY_TIMEOUT_SECONDS}s" + while (( SECONDS < deadline )); do + # curl 失败时通常表示服务尚未监听;不立即失败,等待冷备份恢复后的 systemd 启动完成。 + if last_status="$(curl -fsS --max-time 2 "${ping_url}" 2>&1)"; then + echo "[production-stdb-publish] SpacetimeDB 已就绪: ${ping_url}" + return 0 + fi + sleep 2 + done + + echo "[production-stdb-publish] SpacetimeDB 未在超时内就绪: ${ping_url}" >&2 + if [[ -n "${last_status}" ]]; then + echo "[production-stdb-publish] 最后一次健康检查输出: ${last_status}" >&2 + fi + return 1 +} + +"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}" + +case "${BACKUP_MODE}" in + async) + prepare_async_backup + ;; + sync) + BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs" + if [[ ! -f "${BACKUP_SCRIPT}" ]]; then + BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" + fi + if [[ ! -f "${BACKUP_SCRIPT}" ]]; then + echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${BACKUP_SCRIPT}" >&2 + exit 1 + fi + + echo "[production-stdb-publish] publish 前同步执行 OSS 冷备份,失败会阻断发布" + node "${BACKUP_SCRIPT}" \ + --env-file /etc/genarrative/api-server.env \ + --data-dir "${SPACETIME_ROOT_DIR}" \ + --database "${DATABASE}" \ + --stop-service spacetimedb.service + ;; + skip) + echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份" + ;; +esac echo "[production-stdb-publish] 校验 wasm" ( @@ -162,6 +270,8 @@ echo "[production-stdb-publish] 校验 wasm" sha256sum -c spacetime_module.wasm.sha256 ) +wait_for_spacetime_ready + PUBLISH_ARGS=( --root-dir="${SPACETIME_ROOT_DIR}" publish @@ -218,4 +328,5 @@ fi "${SCRIPT_DIR}/maintenance-off.sh" DEPLOY_COMPLETED=1 + echo "[production-stdb-publish] 完成" diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 80ab9045..78a5d8dd 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,24 @@ 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/app.rs b/server-rs/crates/api-server/src/app.rs index 4b0d747c..142d4725 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -658,7 +658,8 @@ mod tests { #[tokio::test] async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() { - let app = build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string()); + let app = + build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string()); let response = app .oneshot( diff --git a/server-rs/crates/api-server/src/auth_payload.rs b/server-rs/crates/api-server/src/auth_payload.rs index cdd52b51..c4cc8673 100644 --- a/server-rs/crates/api-server/src/auth_payload.rs +++ b/server-rs/crates/api-server/src/auth_payload.rs @@ -5,14 +5,12 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload { AuthUserPayload { id: user.id, public_user_code: user.public_user_code, - username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, phone_number_masked: user.phone_number_masked, login_method: user.login_method.as_str().to_string(), binding_status: user.binding_status.as_str().to_string(), wechat_bound: user.wechat_bound, - created_at: user.created_at, } } @@ -20,6 +18,7 @@ pub fn map_public_user_summary_payload(user: AuthUser) -> PublicUserSummaryPaylo PublicUserSummaryPayload { id: user.id, public_user_code: user.public_user_code, + username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, } diff --git a/server-rs/crates/api-server/src/auth_sessions.rs b/server-rs/crates/api-server/src/auth_sessions.rs index de9c70e9..1ddb32ee 100644 --- a/server-rs/crates/api-server/src/auth_sessions.rs +++ b/server-rs/crates/api-server/src/auth_sessions.rs @@ -185,14 +185,7 @@ fn build_session_summary( session_id: representative.session_id.clone(), session_ids, session_count, - client_type: representative.client_info.client_type.clone(), - client_runtime: representative.client_info.client_runtime.clone(), - client_platform: representative.client_info.client_platform.clone(), client_label, - device_display_name: representative.client_info.device_display_name.clone(), - mini_program_app_id: representative.client_info.mini_program_app_id.clone(), - mini_program_env: representative.client_info.mini_program_env.clone(), - user_agent: representative.client_info.user_agent.clone(), ip_masked: mask_ip(representative.client_info.ip.as_deref()), is_current, created_at: group_earliest_created(&group).to_string(), diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index beb9d940..f89affce 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -311,6 +311,7 @@ pub async fn generate_bark_battle_image_asset( async { generate_and_persist_bark_battle_image_asset( &state, + &request_context, &owner_user_id, &slot, draft_id.as_deref(), @@ -1197,6 +1198,7 @@ fn bark_battle_sanitize_path_segment(value: &str, fallback: &str) -> String { async fn generate_and_persist_bark_battle_image_asset( state: &AppState, + request_context: &RequestContext, owner_user_id: &str, slot: &BarkBattleAssetSlot, draft_id: Option<&str>, @@ -1204,7 +1206,11 @@ async fn generate_and_persist_bark_battle_image_asset( prompt: &str, size: &str, ) -> Result { - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + &request_context, + Some(owner_user_id.to_string()), + Some(draft_id.unwrap_or(asset_id).to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index b119453d..5aa1028d 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -94,7 +94,13 @@ pub async fn generate_character_visual( .map_err(|error| character_visual_error_response(&request_context, error))?; let result = async { - let settings = require_openai_image_settings(&state)?; + let settings = require_openai_image_settings(&state)? + .with_external_api_audit_context( + &request_context, + Some(owner_user_id.clone()), + Some(character_id.clone()), + ) + ; let http_client = build_openai_image_http_client(&settings)?; state @@ -318,7 +324,10 @@ pub(crate) async fn generate_character_primary_visual_for_profile( &model, &prompt, )?; - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_actor( + Some(owner_user_id.to_string()), + Some(character_id.clone()), + ); let http_client = build_openai_image_http_client(&settings)?; state .ai_task_service() diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 649999dd..0aa81311 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -553,7 +553,12 @@ pub async fn generate_custom_world_scene_image( "scene_image", asset_id.as_str(), async { - let settings = require_openai_image_settings(&state)?; + let settings = require_openai_image_settings(&state)? + .with_external_api_audit_context( + &request_context, + Some(owner_user_id.to_string()), + normalized.profile_id.clone(), + ); let http_client = build_openai_image_http_client(&settings)?; let reference_image = if let Some(reference_image_src) = normalized.reference_image_src.as_deref() { @@ -675,7 +680,10 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile( }), }; let normalized = normalize_scene_image_request(payload)?; - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_actor( + Some(owner_user_id.to_string()), + normalized.profile_id.clone(), + ); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, @@ -1011,7 +1019,12 @@ pub async fn generate_custom_world_opening_cg( opening_cg_id.as_str(), OPENING_CG_POINTS_COST, async { - let image_settings = require_openai_image_settings(&state)?; + let image_settings = require_openai_image_settings(&state)? + .with_external_api_audit_context( + &request_context, + Some(owner_user_id.clone()), + normalized.profile_id.clone(), + ); let image_http_client = build_openai_image_http_client(&image_settings)?; let video_settings = require_ark_video_settings(&state)?; let video_http_client = build_upstream_http_client(video_settings.request_timeout_ms)?; diff --git a/server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs b/server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs index 5e45bbb7..8d3f6593 100644 --- a/server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs +++ b/server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs @@ -8,9 +8,10 @@ pub(super) async fn generate_opening_cg_storyboard( normalized: &NormalizedOpeningCgRequest, reference_images: &[String], ) -> Result { + let audit_settings = settings.clone(); let generated = create_openai_image_generation( http_client, - settings, + &audit_settings, normalized.storyboard_prompt.as_str(), None, OPENING_CG_STORYBOARD_IMAGE_SIZE, diff --git a/server-rs/crates/api-server/src/edutainment_baby_object.rs b/server-rs/crates/api-server/src/edutainment_baby_object.rs index 5fcd8687..dda85bf5 100644 --- a/server-rs/crates/api-server/src/edutainment_baby_object.rs +++ b/server-rs/crates/api-server/src/edutainment_baby_object.rs @@ -1050,6 +1050,8 @@ mod tests { api_key: "secret".to_string(), request_timeout_ms: 180_000, external_api_audit_state: None, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, }); assert_eq!( diff --git a/server-rs/crates/api-server/src/external_api_audit.rs b/server-rs/crates/api-server/src/external_api_audit.rs index 1d225034..d75b3e56 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -27,6 +27,9 @@ pub(crate) struct ExternalApiFailureDraft { pub(crate) prompt_chars: Option, pub(crate) reference_image_count: Option, pub(crate) image_model: Option<&'static str>, + pub(crate) user_id: Option, + pub(crate) profile_id: Option, + pub(crate) request_id: Option, } impl ExternalApiFailureDraft { @@ -53,6 +56,9 @@ impl ExternalApiFailureDraft { prompt_chars: None, reference_image_count: None, image_model: None, + user_id: None, + profile_id: None, + request_id: None, } } @@ -108,6 +114,21 @@ impl ExternalApiFailureDraft { self.image_model = image_model; self } + + pub(crate) fn with_user_id(mut self, user_id: Option) -> Self { + self.user_id = user_id; + self + } + + pub(crate) fn with_profile_id(mut self, profile_id: Option) -> Self { + self.profile_id = profile_id; + self + } + + pub(crate) fn with_request_id(mut self, request_id: Option) -> Self { + self.request_id = request_id; + self + } } pub(crate) fn build_external_api_failure_draft_from_platform_image_audit( @@ -130,6 +151,9 @@ pub(crate) fn build_external_api_failure_draft_from_platform_image_audit( .with_prompt_chars(audit.prompt_chars) .with_reference_image_count(audit.reference_image_count) .with_image_model(audit.image_model) + .with_user_id(None) + .with_profile_id(None) + .with_request_id(None) } /// 中文注释:下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。 @@ -203,6 +227,9 @@ pub(crate) fn build_external_api_failure_tracking_draft( ); draft.scope_kind = RuntimeTrackingScopeKind::Module; draft.scope_id = failure.provider.to_string(); + draft.user_id = failure.user_id.clone(); + draft.owner_user_id = failure.user_id.clone(); + draft.profile_id = failure.profile_id.clone(); draft.metadata = build_external_api_failure_metadata(failure); draft } @@ -233,6 +260,30 @@ fn build_external_api_failure_metadata(failure: &ExternalApiFailureDraft) -> Val if let Some(image_model) = failure.image_model { metadata["imageModel"] = json!(image_model); } + if let Some(user_id) = failure + .user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + metadata["userId"] = json!(truncate_field(user_id, 1_000)); + } + if let Some(profile_id) = failure + .profile_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + metadata["profileId"] = json!(truncate_field(profile_id, 1_000)); + } + if let Some(request_id) = failure + .request_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + metadata["requestId"] = json!(truncate_field(request_id, 1_000)); + } if let Some(source) = failure .error_source .as_deref() diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 2339e842..910c18f2 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -412,9 +412,18 @@ async fn maybe_generate_jump_hop_assets( .unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-")); payload.profile_id = Some(profile_id.clone()); - let settings = require_openai_image_settings(state).map_err(|error| { - jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) - })?; + let settings = require_openai_image_settings(state) + .map(|settings| { + settings + .with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.clone()), + ) + }) + .map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; let http_client = build_openai_image_http_client(&settings).map_err(|error| { jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) })?; diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index ecd6635e..0c511311 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -172,7 +172,9 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { build_spacetime_unavailable_router(message) } Err(error) => { - return Err(std::io::Error::other(format!("初始化应用状态失败:{error}"))); + return Err(std::io::Error::other(format!( + "初始化应用状态失败:{error}" + ))); } }; diff --git a/server-rs/crates/api-server/src/match3d/handlers.rs b/server-rs/crates/api-server/src/match3d/handlers.rs index b4837ec6..6d41626f 100644 --- a/server-rs/crates/api-server/src/match3d/handlers.rs +++ b/server-rs/crates/api-server/src/match3d/handlers.rs @@ -701,6 +701,7 @@ pub async fn generate_match3d_cover_image( .await?; let generated_cover = generate_match3d_cover_image_asset( &state, + &request_context, &context.owner_user_id, context.session_id.as_str(), profile_id.as_str(), @@ -772,6 +773,7 @@ pub async fn generate_match3d_background_image_for_work( async { let generated_background = generate_match3d_background_image( &state, + &request_context, owner_user_id.as_str(), session_id.as_str(), profile_id.as_str(), @@ -883,6 +885,7 @@ pub async fn generate_match3d_container_image_for_work( async { let generated_container = generate_match3d_container_image( &state, + &request_context, owner_user_id.as_str(), session_id.as_str(), profile_id.as_str(), diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 1365faee..726f0c7e 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -202,6 +202,7 @@ async fn generate_match3d_item_image_assets_in_batches( async move { let material_sheet = generate_match3d_material_sheet_from_level_scene( state, + request_context, owner_user_id, session_id, profile_id, @@ -747,13 +748,19 @@ pub(super) struct Match3DSlicedItemImage { async fn generate_match3d_material_sheet_from_level_scene( state: &AppState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, background_asset: Option<&Match3DGeneratedBackgroundAsset>, ) -> Result { - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)? + .with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let prompt = build_match3d_item_spritesheet_prompt(); let reference = load_match3d_level_scene_reference_image(state, background_asset).await?; diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs index b8f8bc2a..99d4ef06 100644 --- a/server-rs/crates/api-server/src/match3d/works.rs +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -214,6 +214,7 @@ pub(super) async fn ensure_match3d_background_asset( let generated_background = generate_match3d_level_asset_bundle( state, + request_context, owner_user_id, session_id, profile_id, @@ -260,6 +261,7 @@ pub(super) async fn resolve_or_generate_match3d_level_asset_bundle( }; generate_match3d_level_asset_bundle( state, + request_context, owner_user_id, session_id, profile_id, @@ -292,6 +294,7 @@ pub(super) fn build_match3d_item_slug(item_id: &str, item_name: &str) -> String pub(super) async fn generate_match3d_cover_image_asset( state: &AppState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, profile_id: &str, @@ -301,7 +304,12 @@ pub(super) async fn generate_match3d_cover_image_asset( reference_image_srcs: Vec, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)? + .with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let cover_prompt = build_match3d_cover_generation_prompt(config, prompt); let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit( @@ -422,6 +430,7 @@ pub(super) fn build_match3d_cover_reference_generation_prompt( pub(super) async fn generate_match3d_background_image( state: &AppState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, profile_id: &str, @@ -430,6 +439,7 @@ pub(super) async fn generate_match3d_background_image( ) -> Result { generate_match3d_level_asset_bundle( state, + request_context, owner_user_id, session_id, profile_id, @@ -441,6 +451,7 @@ pub(super) async fn generate_match3d_background_image( pub(super) async fn generate_match3d_level_asset_bundle( state: &AppState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, profile_id: &str, @@ -448,7 +459,12 @@ pub(super) async fn generate_match3d_level_asset_bundle( prompt: &str, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)? + .with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let level_scene_prompt = build_match3d_level_scene_generation_prompt(config); @@ -583,6 +599,7 @@ pub(super) async fn generate_match3d_level_asset_bundle( pub(super) async fn generate_match3d_container_image( state: &AppState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, profile_id: &str, @@ -590,7 +607,12 @@ pub(super) async fn generate_match3d_container_image( prompt: &str, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)? + .with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let reference_image = load_match3d_container_reference_image()?; let container_prompt = build_match3d_container_generation_prompt(config, prompt); 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/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index c4e2a0f0..4ecca8b2 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -16,6 +16,7 @@ use crate::{ record_external_api_failure, }, http_error::AppError, + request_context::RequestContext, state::AppState, tracking::record_external_generation_run_after_success, }; @@ -34,6 +35,9 @@ pub(crate) struct OpenAiImageSettings { pub api_key: String, pub request_timeout_ms: u64, pub external_api_audit_state: Option, + pub external_api_audit_user_id: Option, + pub external_api_audit_profile_id: Option, + pub external_api_audit_request_id: Option, } impl std::fmt::Debug for OpenAiImageSettings { @@ -47,6 +51,18 @@ impl std::fmt::Debug for OpenAiImageSettings { "external_api_audit_enabled", &self.external_api_audit_state.is_some(), ) + .field( + "external_api_audit_user_id", + &self.external_api_audit_user_id, + ) + .field( + "external_api_audit_profile_id", + &self.external_api_audit_profile_id, + ) + .field( + "external_api_audit_request_id", + &self.external_api_audit_request_id, + ) .finish() } } @@ -87,6 +103,9 @@ pub(crate) fn require_openai_image_settings( api_key: api_key.to_string(), request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1), external_api_audit_state: Some(state.clone()), + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }) } @@ -240,6 +259,28 @@ pub(crate) fn build_openai_image_request_body( } impl OpenAiImageSettings { + pub(crate) fn with_external_api_audit_actor( + mut self, + user_id: Option, + profile_id: Option, + ) -> Self { + self.external_api_audit_user_id = user_id; + self.external_api_audit_profile_id = profile_id; + self + } + + pub(crate) fn with_external_api_audit_context( + mut self, + request_context: &RequestContext, + user_id: Option, + profile_id: Option, + ) -> Self { + self.external_api_audit_user_id = user_id; + self.external_api_audit_profile_id = profile_id; + self.external_api_audit_request_id = Some(request_context.request_id().to_string()); + self + } + fn provider_settings(&self) -> VectorEngineImageSettings { VectorEngineImageSettings { base_url: self.base_url.clone(), @@ -310,6 +351,10 @@ pub(crate) async fn record_openai_image_failure_if_configured( let Some(draft) = build_openai_image_failure_audit_draft(error) else { return; }; + let draft = draft + .with_user_id(settings.external_api_audit_user_id.clone()) + .with_profile_id(settings.external_api_audit_profile_id.clone()) + .with_request_id(settings.external_api_audit_request_id.clone()); record_external_api_failure(state, draft).await; } @@ -422,12 +467,18 @@ mod tests { api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }; assert_eq!( @@ -447,12 +498,18 @@ mod tests { api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }; assert_eq!( @@ -472,6 +529,9 @@ mod tests { api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }; let http_client = reqwest::Client::new(); diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index d639f8f2..1f3b53db 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -1085,6 +1085,7 @@ pub(crate) fn attach_puzzle_level_asset_bundle( pub(crate) async fn generate_puzzle_initial_ui_background_required( state: &PuzzleApiState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, draft: &PuzzleResultDraftRecord, @@ -1093,6 +1094,7 @@ pub(crate) async fn generate_puzzle_initial_ui_background_required( let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level); let generated = generate_puzzle_ui_background_image( state, + request_context, owner_user_id, session_id, target_level.level_name.as_str(), @@ -1104,6 +1106,7 @@ pub(crate) async fn generate_puzzle_initial_ui_background_required( pub(crate) async fn generate_puzzle_level_asset_bundle_required( state: &PuzzleApiState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, target_level: &PuzzleDraftLevelRecord, @@ -1111,6 +1114,7 @@ pub(crate) async fn generate_puzzle_level_asset_bundle_required( ) -> Result { generate_puzzle_level_asset_bundle( state, + request_context, owner_user_id, session_id, target_level.level_name.as_str(), @@ -1175,6 +1179,7 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>( pub(crate) async fn compile_puzzle_draft_with_initial_cover( state: &PuzzleApiState, + request_context: &RequestContext, session_id: String, owner_user_id: String, prompt_text: Option<&str>, @@ -1195,6 +1200,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( })?; let mut target_level = select_puzzle_level_for_api(&draft, None)?; let fallback_level_name = target_level.level_name.clone(); + let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); let image_prompt = resolve_puzzle_draft_cover_prompt( prompt_text, &target_level.picture_description, @@ -1209,6 +1215,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( let mut candidates = generate_puzzle_image_candidates( state, owner_user_id.as_str(), + Some(profile_id.as_str()), &compiled_session.session_id, &target_level.level_name, &image_prompt, @@ -1262,6 +1269,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( { let asset_bundle = generate_puzzle_level_asset_bundle_required( state, + request_context, owner_user_id.as_str(), compiled_session.session_id.as_str(), &target_level, @@ -1369,7 +1377,6 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( Err(error) } })?; - let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); match state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { @@ -1441,6 +1448,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( state: &PuzzleApiState, + request_context: &RequestContext, session_id: String, owner_user_id: String, prompt_text: Option<&str>, @@ -1544,6 +1552,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( .await?; let asset_bundle = generate_puzzle_level_asset_bundle_required( state, + request_context, owner_user_id.as_str(), compiled_session.session_id.as_str(), &target_level, diff --git a/server-rs/crates/api-server/src/puzzle/generation.rs b/server-rs/crates/api-server/src/puzzle/generation.rs index 5a3d9a2a..a17766f7 100644 --- a/server-rs/crates/api-server/src/puzzle/generation.rs +++ b/server-rs/crates/api-server/src/puzzle/generation.rs @@ -78,6 +78,7 @@ pub(crate) async fn create_uploaded_puzzle_image_candidate( pub(crate) async fn generate_puzzle_image_candidates( state: &PuzzleApiState, owner_user_id: &str, + profile_id: Option<&str>, session_id: &str, level_name: &str, prompt: &str, @@ -150,6 +151,11 @@ pub(crate) async fn generate_puzzle_image_candidates( // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。 // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 let settings = require_puzzle_vector_engine_settings(state)?; + let settings = PuzzleVectorEngineSettings { + external_api_audit_user_id: Some(owner_user_id.to_string()), + external_api_audit_profile_id: profile_id.map(ToOwned::to_owned), + ..settings + }; let vector_engine_started_at = Instant::now(); let generated = if should_use_reference_image_generation { let reference_image = reference_image.as_ref().ok_or_else(|| { @@ -255,12 +261,18 @@ pub(crate) async fn generate_puzzle_image_candidates( pub(crate) async fn generate_puzzle_ui_background_image( state: &PuzzleApiState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, level_name: &str, prompt: &str, ) -> Result { - let settings = require_openai_image_settings(state.root_state())?; + let settings = require_openai_image_settings(state.root_state())? + .with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(session_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, @@ -292,12 +304,18 @@ pub(crate) async fn generate_puzzle_ui_background_image( pub(crate) async fn generate_puzzle_level_asset_bundle( state: &PuzzleApiState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, level_name: &str, puzzle_image: &PuzzleDownloadedImage, ) -> Result { - let settings = require_puzzle_vector_engine_settings(state)?; + let settings = require_puzzle_vector_engine_settings(state)? + .with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(session_id.to_string()), + ); let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?; let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image); let scene_generated = create_puzzle_vector_engine_image_generation( diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 20645497..ab594f07 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -71,12 +71,14 @@ pub async fn generate_puzzle_onboarding_work( let now = current_utc_micros(); let session_id = build_prefixed_uuid_id("puzzle-onboarding-"); + let onboarding_profile_id = format!("onboarding-profile-{now}"); let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await; let tags = generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await; let candidates = generate_puzzle_image_candidates( &state, "onboarding-guest", + Some(onboarding_profile_id.as_str()), session_id.as_str(), naming.level_name.as_str(), prompt_text.as_str(), @@ -132,7 +134,7 @@ pub async fn generate_puzzle_onboarding_work( )); let item = PuzzleWorkProfileRecord { work_id: format!("onboarding-work-{now}"), - profile_id: format!("onboarding-profile-{now}"), + profile_id: onboarding_profile_id, owner_user_id: "onboarding-guest".to_string(), source_session_id: None, author_display_name: "陶泥儿主".to_string(), @@ -675,6 +677,7 @@ pub async fn execute_puzzle_agent_action( async { compile_puzzle_draft_with_initial_cover( &state, + &request_context, compile_session_id.clone(), owner_user_id.clone(), prompt_text, @@ -689,6 +692,7 @@ pub async fn execute_puzzle_agent_action( } else { compile_puzzle_draft_with_uploaded_cover( &state, + &request_context, compile_session_id.clone(), owner_user_id.clone(), prompt_text, @@ -861,9 +865,11 @@ pub async fn execute_puzzle_agent_action( .await?, ] } else { + let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id); generate_puzzle_image_candidates( &state, owner_user_id.as_str(), + Some(profile_id.as_str()), &session.session_id, &target_level.level_name, &prompt, @@ -920,6 +926,7 @@ pub async fn execute_puzzle_agent_action( })?; let asset_bundle = generate_puzzle_level_asset_bundle_required( &state, + &request_context, owner_user_id.as_str(), &session.session_id, &target_level, @@ -1079,6 +1086,7 @@ pub async fn execute_puzzle_agent_action( ); let generated = generate_puzzle_ui_background_image( &state, + &request_context, owner_user_id.as_str(), &session.session_id, &target_level.level_name, diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index d6bde919..4c6f9b2f 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -31,6 +31,9 @@ pub(crate) struct PuzzleVectorEngineSettings { pub(crate) api_key: String, pub(crate) request_timeout_ms: u64, pub(crate) external_api_audit_state: Option, + pub(crate) external_api_audit_user_id: Option, + pub(crate) external_api_audit_profile_id: Option, + pub(crate) external_api_audit_request_id: Option, } pub(crate) struct PuzzleGeneratedImages { @@ -100,8 +103,25 @@ impl PuzzleVectorEngineSettings { api_key: self.api_key.clone(), request_timeout_ms: self.request_timeout_ms, external_api_audit_state: self.external_api_audit_state.clone(), + external_api_audit_user_id: self.external_api_audit_user_id.clone(), + external_api_audit_profile_id: self.external_api_audit_profile_id.clone(), + external_api_audit_request_id: self.external_api_audit_request_id.clone(), } } + + pub(crate) fn with_external_api_audit_context( + mut self, + request_context: &RequestContext, + user_id: Option, + profile_id: Option, + ) -> Self { + self.external_api_audit_user_id = user_id; + self.external_api_audit_profile_id = profile_id; + self.external_api_audit_request_id = + Some(request_context.request_id().to_string()); + self + } + } pub(crate) struct ParsedPuzzleImageDataUrl { @@ -177,6 +197,9 @@ pub(crate) fn require_puzzle_vector_engine_settings( api_key: api_key.to_string(), request_timeout_ms: state.vector_engine_image_request_timeout_ms().max(1), external_api_audit_state: Some(state.root_state().clone()), + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }) } diff --git a/server-rs/crates/api-server/src/square_hole/visual_assets.rs b/server-rs/crates/api-server/src/square_hole/visual_assets.rs index 1a8670be..75ad863e 100644 --- a/server-rs/crates/api-server/src/square_hole/visual_assets.rs +++ b/server-rs/crates/api-server/src/square_hole/visual_assets.rs @@ -62,6 +62,7 @@ pub(super) async fn generate_square_hole_visual_assets_for_session( _ => Some( generate_square_hole_image_data_url( state, + request_context, &owner_user_id, &session_id, profile_id.as_str(), @@ -90,6 +91,7 @@ pub(super) async fn generate_square_hole_visual_assets_for_session( _ => Some( generate_square_hole_image_data_url( state, + request_context, &owner_user_id, &session_id, profile_id.as_str(), @@ -118,6 +120,7 @@ pub(super) async fn generate_square_hole_visual_assets_for_session( option.image_src = Some( generate_square_hole_image_data_url( state, + request_context, &owner_user_id, &session_id, profile_id.as_str(), @@ -145,6 +148,7 @@ pub(super) async fn generate_square_hole_visual_assets_for_session( option.image_src = Some( generate_square_hole_image_data_url( state, + request_context, &owner_user_id, &session_id, profile_id.as_str(), @@ -252,6 +256,7 @@ pub(super) async fn regenerate_square_hole_visual_asset_for_work( work.cover_image_src = Some( generate_square_hole_image_data_url( state, + request_context, owner_user_id.as_str(), synthetic_session_id.as_str(), profile_id.as_str(), @@ -271,6 +276,7 @@ pub(super) async fn regenerate_square_hole_visual_asset_for_work( work.background_image_src = Some( generate_square_hole_image_data_url( state, + request_context, owner_user_id.as_str(), synthetic_session_id.as_str(), profile_id.as_str(), @@ -301,6 +307,7 @@ pub(super) async fn regenerate_square_hole_visual_asset_for_work( option.image_src = Some( generate_square_hole_image_data_url( state, + request_context, owner_user_id.as_str(), synthetic_session_id.as_str(), profile_id.as_str(), @@ -331,6 +338,7 @@ pub(super) async fn regenerate_square_hole_visual_asset_for_work( option.image_src = Some( generate_square_hole_image_data_url( state, + request_context, owner_user_id.as_str(), synthetic_session_id.as_str(), profile_id.as_str(), @@ -380,6 +388,7 @@ pub(super) async fn regenerate_square_hole_visual_asset_for_work( async fn generate_square_hole_image_data_url( state: &AppState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, profile_id: &str, @@ -389,7 +398,12 @@ async fn generate_square_hole_image_data_url( size: &str, failure_context: &str, ) -> Result { - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)? + .with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 6dcf8b9c..59d47d45 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, @@ -1019,7 +1042,9 @@ impl fmt::Display for AppStateInitError { match self { Self::Jwt(error) => write!(f, "{error}"), Self::RefreshCookie(error) => write!(f, "{error}"), - Self::AuthStore(error) | Self::DependencyUnavailable(error) | Self::WechatPay(error) => { + Self::AuthStore(error) + | Self::DependencyUnavailable(error) + | Self::WechatPay(error) => { write!(f, "{error}") } Self::SmsProvider(error) => write!(f, "{error}"), diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index 28e31f2c..a181489e 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -67,6 +67,7 @@ const DEFAULT_HIT_OBJECT_REFERENCE_BYTES: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../../public/wooden-fish/default-hit-object.png" )); +const WOODEN_FISH_AUTHOR_FALLBACK_DISPLAY_NAME: &str = "玩家"; pub async fn create_wooden_fish_session( State(state): State, @@ -80,7 +81,7 @@ pub async fn create_wooden_fish_session( let owner_user_id = authenticated.claims().user_id().to_string(); let session_id = build_prefixed_uuid_id("wooden-fish-session-"); let now = current_utc_micros(); - let draft = build_wooden_fish_draft(&payload); + let draft = build_wooden_fish_draft(&payload, &state).await?; let session = WoodenFishSessionSnapshotResponse { session_id, owner_user_id, @@ -145,6 +146,7 @@ pub async fn execute_wooden_fish_action( let Json(mut payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); + let author_display_name = resolve_author_display_name(&state, &authenticated); maybe_generate_hit_object_asset( &state, &request_context, @@ -156,7 +158,7 @@ pub async fn execute_wooden_fish_action( maybe_generate_hit_sound_asset(&mut payload); let response = state .spacetime_client() - .execute_wooden_fish_action(session_id, owner_user_id, payload) + .execute_wooden_fish_action(session_id, owner_user_id, author_display_name, payload) .await .map_err(|error| { wooden_fish_error_response( @@ -366,12 +368,20 @@ pub async fn get_wooden_fish_gallery_detail( )) } -fn build_wooden_fish_draft(payload: &WoodenFishWorkspaceCreateRequest) -> WoodenFishDraftResponse { - WoodenFishDraftResponse { +async fn build_wooden_fish_draft( + payload: &WoodenFishWorkspaceCreateRequest, + state: &AppState, +) -> Result { + Ok(WoodenFishDraftResponse { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), profile_id: None, - work_title: payload.work_title.trim().to_string(), + work_title: resolve_wooden_fish_work_title( + state, + &payload.work_description, + &payload.hit_object_prompt, + ) + .await?, work_description: payload.work_description.trim().to_string(), theme_tags: normalize_tags(payload.theme_tags.clone()), hit_object_prompt: clean_string(&payload.hit_object_prompt, DEFAULT_HIT_OBJECT_PROMPT), @@ -391,14 +401,13 @@ fn build_wooden_fish_draft(payload: &WoodenFishWorkspaceCreateRequest) -> Wooden .or_else(|| Some(default_wooden_fish_hit_sound_asset())), cover_image_src: None, generation_status: WoodenFishGenerationStatus::Draft, - } + }) } fn validate_workspace_request( request_context: &RequestContext, payload: &WoodenFishWorkspaceCreateRequest, ) -> Result<(), Response> { - ensure_non_empty(request_context, &payload.work_title, "workTitle")?; if payload.template_id.trim() != WOODEN_FISH_TEMPLATE_ID { return Err(wooden_fish_error_response( request_context, @@ -412,6 +421,77 @@ fn validate_workspace_request( Ok(()) } +fn resolve_author_display_name( + state: &AppState, + authenticated: &AuthenticatedAccessToken, +) -> String { + state + .auth_user_service() + .get_user_by_id(authenticated.claims().user_id()) + .ok() + .flatten() + .map(|user| user.display_name) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| WOODEN_FISH_AUTHOR_FALLBACK_DISPLAY_NAME.to_string()) +} + +async fn resolve_wooden_fish_work_title( + state: &AppState, + work_description: &str, + hit_object_prompt: &str, +) -> Result { + let description = clean_string(work_description, hit_object_prompt); + if description.is_empty() { + return Ok(WOODEN_FISH_TEMPLATE_NAME.to_string()); + } + let Some(llm_client) = state.llm_client() else { + return Ok(WOODEN_FISH_TEMPLATE_NAME.to_string()); + }; + let request = platform_llm::LlmTextRequest::new(vec![ + platform_llm::LlmMessage::system( + "你是中文作品标题编辑。请根据敲木鱼作品描述生成一个适合卡片展示的简短中文标题,只输出纯文本,不要 JSON、标点解释或引号。", + ), + platform_llm::LlmMessage::user(format!( + "作品描述:{description}\n\n请生成 2 到 8 个中文字符为主的标题。" + )), + ]) + .with_model(crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(); + let response = llm_client.request_text(request).await; + match response { + Ok(response) => { + let title = normalize_wooden_fish_generated_work_title(response.content.as_str()); + if title.is_empty() { + Ok(WOODEN_FISH_TEMPLATE_NAME.to_string()) + } else { + Ok(title) + } + } + Err(_) => Ok(WOODEN_FISH_TEMPLATE_NAME.to_string()), + } +} + +fn normalize_wooden_fish_generated_work_title(value: &str) -> String { + let normalized = value + .trim() + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .chars() + .filter(|ch| !ch.is_control()) + .collect::(); + let chars = normalized.chars().collect::>(); + if chars.len() <= 8 { + normalized + } else { + chars.into_iter().take(8).collect() + } +} + async fn maybe_generate_hit_object_asset( state: &AppState, request_context: &RequestContext, @@ -446,6 +526,7 @@ async fn maybe_generate_hit_object_asset( let generated = generate_wooden_fish_image_assets( state, + request_context, owner_user_id, session_id, profile_id.as_str(), @@ -579,13 +660,18 @@ struct WoodenFishGeneratedImageAssets { async fn generate_wooden_fish_image_assets( state: &AppState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, profile_id: &str, prompt: &str, hit_object_reference_image_src: Option<&str>, ) -> Result { - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let clean_reference_image_src = hit_object_reference_image_src .map(str::trim) diff --git a/server-rs/crates/api-server/src/work_author.rs b/server-rs/crates/api-server/src/work_author.rs index 2afc2447..c758e3ca 100644 --- a/server-rs/crates/api-server/src/work_author.rs +++ b/server-rs/crates/api-server/src/work_author.rs @@ -98,7 +98,10 @@ pub fn should_rebind_orphan_work_owner( return false; } - !matches!(auth_user_service.get_user_by_id(&owner_user_id), Ok(Some(_))) + !matches!( + auth_user_service.get_user_by_id(&owner_user_id), + Ok(Some(_)) + ) } #[cfg(test)] @@ -137,6 +140,9 @@ mod tests { assert!(should_rebind_orphan_work_owner(&service, "")); assert!(should_rebind_orphan_work_owner(&service, "user_missing")); - assert!(!should_rebind_orphan_work_owner(&service, ORPHAN_WORK_OWNER_USER_ID)); + assert!(!should_rebind_orphan_work_owner( + &service, + ORPHAN_WORK_OWNER_USER_ID + )); } } diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 2608c1cb..66f31450 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -808,12 +808,8 @@ impl AuthUserService { display_name: &str, public_user_code: &str, ) -> Result { - self.store.ensure_orphan_work_owner_user( - user_id, - username, - display_name, - public_user_code, - ) + self.store + .ensure_orphan_work_owner_user(user_id, username, display_name, public_user_code) } pub fn get_user_by_id(&self, user_id: &str) -> Result, LogoutError> { @@ -1020,18 +1016,14 @@ impl InMemoryAuthStore { display_name: &str, public_user_code: &str, ) -> Result { - let user_id = normalize_required_string(user_id).ok_or_else(|| { - PasswordEntryError::Store("孤儿作品占位用户 id 不能为空".to_string()) - })?; - let username = normalize_required_string(username).ok_or_else(|| { - PasswordEntryError::Store("孤儿作品占位用户名不能为空".to_string()) - })?; - let display_name = normalize_required_string(display_name).ok_or_else(|| { - PasswordEntryError::Store("孤儿作品占位展示名不能为空".to_string()) - })?; - let public_user_code = normalize_required_string(public_user_code).ok_or_else(|| { - PasswordEntryError::Store("孤儿作品占位陶泥号不能为空".to_string()) - })?; + let user_id = normalize_required_string(user_id) + .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位用户 id 不能为空".to_string()))?; + let username = normalize_required_string(username) + .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位用户名不能为空".to_string()))?; + let display_name = normalize_required_string(display_name) + .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位展示名不能为空".to_string()))?; + let public_user_code = normalize_required_string(public_user_code) + .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位陶泥号不能为空".to_string()))?; let mut state = self .inner diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 68bf33bf..802cafad 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -140,6 +140,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/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 1e7b2f33..94847336 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -17,14 +17,12 @@ pub struct AuthLoginOptionsResponse { pub struct AuthUserPayload { pub id: String, pub public_user_code: String, - pub username: String, pub display_name: String, pub avatar_url: Option, pub phone_number_masked: Option, pub login_method: String, pub binding_status: String, pub wechat_bound: bool, - pub created_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -32,6 +30,7 @@ pub struct AuthUserPayload { pub struct PublicUserSummaryPayload { pub id: String, pub public_user_code: String, + pub username: String, pub display_name: String, pub avatar_url: Option, } @@ -125,14 +124,7 @@ pub struct AuthSessionSummaryPayload { pub session_id: String, pub session_ids: Vec, pub session_count: u32, - pub client_type: String, - pub client_runtime: String, - pub client_platform: String, pub client_label: String, - pub device_display_name: String, - pub mini_program_app_id: Option, - pub mini_program_env: Option, - pub user_agent: Option, pub ip_masked: Option, pub is_current: bool, pub created_at: String, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f6bb3217..73e0b55f 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -6,8 +6,8 @@ mod mapper; mod telemetry; use mapper::*; pub use mapper::{ - AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, - AiTextChunkRecord, BarkBattleDraftConfigRecord, BarkBattleRunRecord, + AdminWorkVisibilityRecord, AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, + AiTaskStageRecord, AiTextChunkRecord, BarkBattleDraftConfigRecord, BarkBattleRunRecord, BarkBattleRuntimeConfigRecord, BattleStateRecord, BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 3d6fd06a..fa080b9d 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -115,8 +115,8 @@ pub use self::puzzle::{ PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, }; pub use self::runtime::{ - BigFishGameDraftRecord, BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, - BigFishRuntimeRunRecord, CreationEntryConfigRecord, + AdminWorkVisibilityRecord, BigFishGameDraftRecord, BigFishRuntimeEntityRecord, + BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord, CreationEntryConfigRecord, }; pub use self::runtime_profile::{ SquareHoleDropConfirmationRecord, SquareHoleDropFeedbackRecord, SquareHoleRunRecord, @@ -193,7 +193,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..624b706d 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -87,6 +87,60 @@ 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-client/src/wooden_fish.rs b/server-rs/crates/spacetime-client/src/wooden_fish.rs index 1aadc15f..66304b09 100644 --- a/server-rs/crates/spacetime-client/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-client/src/wooden_fish.rs @@ -85,6 +85,7 @@ impl SpacetimeClient { &self, session_id: String, owner_user_id: String, + author_display_name: String, payload: WoodenFishActionRequest, ) -> Result { let current = self @@ -93,6 +94,7 @@ impl SpacetimeClient { let (procedure, _) = build_wooden_fish_action_plan( ¤t, &owner_user_id, + &author_display_name, &payload, current_unix_micros(), )?; @@ -416,6 +418,7 @@ enum WoodenFishAssetRefresh { fn build_wooden_fish_action_plan( current: &WoodenFishSessionSnapshotResponse, owner_user_id: &str, + author_display_name: &str, payload: &WoodenFishActionRequest, now_micros: i64, ) -> Result<(WoodenFishActionProcedure, WoodenFishDraftResponse), SpacetimeClientError> { @@ -440,6 +443,7 @@ fn build_wooden_fish_action_plan( WoodenFishActionProcedure::Compile(build_compile_input( current, owner_user_id, + author_display_name, &profile_id, &mut draft, WoodenFishAssetRefresh::Preserve, @@ -450,6 +454,7 @@ fn build_wooden_fish_action_plan( WoodenFishActionProcedure::Compile(build_compile_input( current, owner_user_id, + author_display_name, &profile_id, &mut draft, WoodenFishAssetRefresh::HitObject, @@ -460,6 +465,7 @@ fn build_wooden_fish_action_plan( WoodenFishActionProcedure::Compile(build_compile_input( current, owner_user_id, + author_display_name, &profile_id, &mut draft, WoodenFishAssetRefresh::HitSound, @@ -577,6 +583,7 @@ fn merge_action_into_draft( fn build_compile_input( current: &WoodenFishSessionSnapshotResponse, owner_user_id: &str, + author_display_name: &str, profile_id: &str, draft: &mut WoodenFishDraftResponse, refresh: WoodenFishAssetRefresh, @@ -611,7 +618,7 @@ fn build_compile_input( session_id: current.session_id.clone(), owner_user_id: owner_user_id.to_string(), profile_id: profile_id.to_string(), - author_display_name: "敲木鱼玩家".to_string(), + author_display_name: author_display_name.trim().to_string(), work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_tags_json: Some(json_string(&draft.theme_tags)?), diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index d4be23ee..8ae86d70 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -66,7 +66,10 @@ fn upsert_auth_snapshot_row( .find(&snapshot_id) .is_some() { - ctx.db.auth_store_snapshot().snapshot_id().delete(&snapshot_id); + ctx.db + .auth_store_snapshot() + .snapshot_id() + .delete(&snapshot_id); } ctx.db.auth_store_snapshot().insert(AuthStoreSnapshot { @@ -106,7 +109,10 @@ fn auth_store_snapshot_wechat_row_id(provider_uid: &str, user_id: &str) -> Strin } fn auth_store_snapshot_union_row_id(union_id: &str, user_id: &str) -> String { - prefixed_snapshot_id(AUTH_STORE_SNAPSHOT_UNION_PREFIX, &format!("{union_id}|{user_id}")) + prefixed_snapshot_id( + AUTH_STORE_SNAPSHOT_UNION_PREFIX, + &format!("{union_id}|{user_id}"), + ) } fn snapshot_has_user_rows(snapshot: &PersistentAuthStoreSnapshot) -> bool { @@ -202,13 +208,7 @@ fn import_auth_store_snapshot_json_value_tx( for stored_user in parsed.users_by_username.into_values() { let user = stored_user.user; let user_id = user.id.clone(); - if ctx - .db - .user_account() - .user_id() - .find(&user_id) - .is_some() - { + if ctx.db.user_account().user_id().find(&user_id).is_some() { ctx.db.user_account().user_id().delete(&user_id); } ctx.db.user_account().insert(UserAccount { @@ -644,10 +644,7 @@ mod tests { PersistentAuthStoreSnapshot { next_user_id: 43, - users_by_username: std::collections::HashMap::from([( - "phone_42".to_string(), - user, - )]), + users_by_username: std::collections::HashMap::from([("phone_42".to_string(), user)]), phone_to_user_id: std::collections::HashMap::from([( "+8613800008000".to_string(), "user_00000042".to_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..d17c6910 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..649c6f8d 100644 --- a/server-rs/crates/spacetime-module/src/match3d.rs +++ b/server-rs/crates/spacetime-module/src/match3d.rs @@ -23,15 +23,16 @@ use spacetimedb::AnonymousViewContext; /// 抓大鹅公开广场列表投影。 /// -/// `match3d_work_profile` 是玩法源表,HTTP gallery 只订阅这个轻量 view, +/// `match_3_d_work_profile` 是玩法源表,HTTP gallery 只订阅这个轻量 view, /// 避免每个公开列表请求重新调用 procedure 扫描和组装全量列表。 #[spacetimedb::view(accessor = match3d_gallery_view, public)] pub fn match3d_gallery_view(ctx: &AnonymousViewContext) -> Vec { let mut items = ctx .db - .match3d_work_profile() + .match_3_d_work_profile() .by_match3d_work_publication_status() .filter(MATCH3D_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) .filter_map(|row| match build_gallery_view_row(&row) { Ok(item) => Some(item), Err(error) => { @@ -500,7 +501,7 @@ fn compile_match3d_draft_tx( validate_config(&config)?; let existing_work = ctx .db - .match3d_work_profile() + .match_3_d_work_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id); @@ -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) } @@ -692,16 +695,16 @@ fn list_match3d_works_tx( ) -> Result, String> { let rows = if input.published_only { ctx.db - .match3d_work_profile() + .match_3_d_work_profile() .by_match3d_work_publication_status() - .filter(&MATCH3D_PUBLICATION_PUBLISHED.to_string()) + .filter(MATCH3D_PUBLICATION_PUBLISHED) .collect::>() } else { require_non_empty(&input.owner_user_id, "match3d owner_user_id")?; ctx.db - .match3d_work_profile() - .by_match3d_work_owner_user_id() - .filter(&input.owner_user_id) + .match_3_d_work_profile() + .iter() + .filter(|row| row.owner_user_id == input.owner_user_id) .collect::>() }; let mut items = rows @@ -723,14 +726,14 @@ fn get_match3d_work_detail_tx( ) -> Result { let row = ctx .db - .match3d_work_profile() + .match_3_d_work_profile() .profile_id() .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED }) - .ok_or_else(|| "match3d_work_profile 不存在".to_string())?; + .ok_or_else(|| "match_3_d_work_profile 不存在".to_string())?; build_work_snapshot(&row) } @@ -740,7 +743,7 @@ fn delete_match3d_work_tx( ) -> Result, String> { let work = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; ctx.db - .match3d_work_profile() + .match_3_d_work_profile() .profile_id() .delete(&work.profile_id); for run in ctx @@ -778,14 +781,14 @@ fn start_match3d_run_tx( } let work = ctx .db - .match3d_work_profile() + .match_3_d_work_profile() .profile_id() .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED }) - .ok_or_else(|| "match3d_work_profile 不存在".to_string())?; + .ok_or_else(|| "match_3_d_work_profile 不存在".to_string())?; let started_at_ms = if input.started_at_ms > 0 { input.started_at_ms } else { @@ -962,11 +965,11 @@ fn find_owned_work( require_non_empty(profile_id, "match3d profile_id")?; require_non_empty(owner_user_id, "match3d owner_user_id")?; ctx.db - .match3d_work_profile() + .match_3_d_work_profile() .profile_id() .find(&profile_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) - .ok_or_else(|| "match3d_work_profile 不存在".to_string()) + .ok_or_else(|| "match_3_d_work_profile 不存在".to_string()) } fn find_owned_run( @@ -1256,17 +1259,17 @@ fn click_result( fn upsert_work(ctx: &ReducerContext, work: Match3DWorkProfileRow) { if ctx .db - .match3d_work_profile() + .match_3_d_work_profile() .profile_id() .find(&work.profile_id) .is_some() { ctx.db - .match3d_work_profile() + .match_3_d_work_profile() .profile_id() .delete(&work.profile_id); } - ctx.db.match3d_work_profile().insert(work); + ctx.db.match_3_d_work_profile().insert(work); } fn replace_session( @@ -1287,10 +1290,10 @@ fn replace_work( next: Match3DWorkProfileRow, ) { ctx.db - .match3d_work_profile() + .match_3_d_work_profile() .profile_id() .delete(¤t.profile_id); - ctx.db.match3d_work_profile().insert(next); + ctx.db.match_3_d_work_profile().insert(next); } fn clone_session(row: &Match3DAgentSessionRow) -> Match3DAgentSessionRow { @@ -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..bc7d131a 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])) @@ -35,7 +37,7 @@ pub struct Match3DAgentMessageRow { } #[spacetimedb::table( - accessor = match3d_work_profile, + accessor = match_3_d_work_profile, index(accessor = by_match3d_work_owner_user_id, btree(columns = [owner_user_id])), index(accessor = by_match3d_work_publication_status, btree(columns = [publication_status])) )] @@ -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..fade23b3 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -16,7 +16,7 @@ use crate::jump_hop::tables::{ jump_hop_agent_session, jump_hop_event, jump_hop_runtime_run, jump_hop_work_profile, }; use crate::match3d::tables::{ - match3d_agent_message, match3d_agent_session, match3d_runtime_run, match3d_work_profile, + match_3_d_work_profile, match3d_agent_message, match3d_agent_session, match3d_runtime_run, }; use crate::puzzle::{ puzzle_agent_message, puzzle_agent_session, puzzle_event, puzzle_leaderboard_entry, @@ -238,7 +238,7 @@ macro_rules! migration_tables { bark_battle_personal_best_projection, match3d_agent_session, match3d_agent_message, - match3d_work_profile, + match_3_d_work_profile, match3d_runtime_run, jump_hop_agent_session, jump_hop_work_profile, @@ -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 == "match3d_work_profile" { + 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 == "match_3_d_work_profile" || 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/public_work.rs b/server-rs/crates/spacetime-module/src/public_work.rs index 5f2ed6f2..98aaa6ce 100644 --- a/server-rs/crates/spacetime-module/src/public_work.rs +++ b/server-rs/crates/spacetime-module/src/public_work.rs @@ -89,11 +89,7 @@ pub fn public_work_detail_entry(ctx: &AnonymousViewContext) -> Vec, - 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) => { @@ -1036,6 +1042,7 @@ fn mark_puzzle_draft_generation_failed_tx( )) } }; + let next_stage = resolve_failed_puzzle_agent_stage(row.stage, &draft); upsert_puzzle_draft_work_profile( ctx, &row.session_id, @@ -1053,7 +1060,7 @@ fn mark_puzzle_draft_generation_failed_tx( seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: row.progress_percent.max(88), - stage: row.stage, + stage: next_stage, anchor_pack_json: row.anchor_pack_json.clone(), draft_json: Some(serialize_json(&draft)), last_assistant_reply: Some(input.error_message), @@ -1072,6 +1079,21 @@ fn mark_puzzle_draft_generation_failed_tx( ) } +fn resolve_failed_puzzle_agent_stage( + current_stage: PuzzleAgentStage, + draft: &PuzzleResultDraft, +) -> PuzzleAgentStage { + if current_stage == PuzzleAgentStage::Published { + return PuzzleAgentStage::Published; + } + + if build_result_preview(draft, Some("陶泥儿主")).publish_ready { + PuzzleAgentStage::ReadyToPublish + } else { + PuzzleAgentStage::DraftReady + } +} + fn save_puzzle_form_draft_tx( ctx: &TxContext, input: PuzzleFormDraftSaveInput, @@ -1578,6 +1600,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 +1813,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 +1902,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 +1970,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 +2422,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 +3035,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 +3068,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 +3393,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 +3432,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, }, ); } @@ -3847,6 +3878,25 @@ mod tests { assert_eq!(draft.work_title, "我的猫街合集"); } + #[test] + fn failed_generation_returns_result_draft_stage_instead_of_refining() { + let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None); + let draft = compile_result_draft_from_seed( + &anchor_pack, + &[], + Some("画面描述:一只猫在雨夜灯牌下回头。"), + ); + + assert_eq!( + resolve_failed_puzzle_agent_stage(PuzzleAgentStage::ImageRefining, &draft), + PuzzleAgentStage::DraftReady + ); + assert_eq!( + resolve_failed_puzzle_agent_stage(PuzzleAgentStage::Published, &draft), + PuzzleAgentStage::Published + ); + } + #[test] fn puzzle_recommendation_score_prefers_same_author_weight() { let left = PuzzleWorkProfile { diff --git a/server-rs/crates/spacetime-module/src/runtime.rs b/server-rs/crates/spacetime-module/src/runtime.rs index 730b89e2..52906c3d 100644 --- a/server-rs/crates/spacetime-module/src/runtime.rs +++ b/server-rs/crates/spacetime-module/src/runtime.rs @@ -1,3 +1,4 @@ +mod admin_work_visibility; pub mod analytics_date_dimension; mod browse_history; pub mod creation_entry_config; @@ -5,6 +6,7 @@ mod profile; mod settings; mod snapshots; +pub use admin_work_visibility::*; pub use analytics_date_dimension::*; pub use browse_history::*; pub use creation_entry_config::*; 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..13ec22a5 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs @@ -0,0 +1,726 @@ +use crate::puzzle::{PuzzleWorkProfileRow, puzzle_work_profile}; +use crate::*; +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() + .iter() + // 中文注释:后台页签是低频管理入口,列表优先保证稳定性,避免二级索引 filter 初始化异常打爆 wasm 实例。 + .filter(|row| row.publication_status == 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() + .iter() + // 中文注释:后台必须能读到所有已发布源表记录,包括已隐藏作品,因此不复用公开 view。 + .filter(|row| row.publication_status == 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() + .iter() + .filter(|row| row.publication_status == 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() + .iter() + .filter(|row| row.publication_status == 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 + .match_3_d_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 + .match_3_d_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 + .match_3_d_work_profile() + .profile_id() + .delete(&profile_id); + ctx.db.match_3_d_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() + .iter() + .filter(|row| row.publication_status == 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() + .iter() + .filter(|row| row.publication_status == 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() + .iter() + .filter(|row| row.stage == 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..33482ac2 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) => { @@ -268,12 +269,13 @@ fn create_wooden_fish_agent_session_tx( .map(parse_config) .transpose()? .unwrap_or_else(|| default_config_from_input(&input)); - let draft = input + let mut draft = input .draft_json .as_deref() .map(parse_json) .transpose()? .unwrap_or_else(|| draft_from_config(&config, None, WOODEN_FISH_GENERATION_DRAFT)); + draft.generation_status = WOODEN_FISH_GENERATION_GENERATING.to_string(); ctx.db .wooden_fish_agent_session() @@ -281,8 +283,8 @@ fn create_wooden_fish_agent_session_tx( session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), current_turn: 0, - progress_percent: 0, - stage: WOODEN_FISH_STAGE_COLLECTING.to_string(), + progress_percent: 1, + stage: WOODEN_FISH_STAGE_GENERATING.to_string(), config_json: to_json_string(&config), draft_json: to_json_string(&draft), published_profile_id: String::new(), @@ -412,6 +414,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 +1316,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( diff --git a/server-rs/crates/spacetime-module/src/wooden_fish/types.rs b/server-rs/crates/spacetime-module/src/wooden_fish/types.rs index 2ab8c0e6..12ebbdca 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish/types.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish/types.rs @@ -4,11 +4,13 @@ use serde::{Deserialize, Serialize}; pub const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish"; pub const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼"; pub const WOODEN_FISH_STAGE_COLLECTING: &str = "Collecting"; +pub const WOODEN_FISH_STAGE_GENERATING: &str = "Generating"; pub const WOODEN_FISH_STAGE_DRAFT_COMPILED: &str = "DraftCompiled"; pub const WOODEN_FISH_STAGE_PUBLISHED: &str = "Published"; pub const WOODEN_FISH_PUBLICATION_DRAFT: &str = "Draft"; pub const WOODEN_FISH_PUBLICATION_PUBLISHED: &str = "Published"; pub const WOODEN_FISH_GENERATION_DRAFT: &str = "draft"; +pub const WOODEN_FISH_GENERATION_GENERATING: &str = "generating"; pub const WOODEN_FISH_GENERATION_READY: &str = "ready"; pub const WOODEN_FISH_EVENT_RUN_STARTED: &str = "run-started"; pub const WOODEN_FISH_EVENT_RUN_CHECKPOINT: &str = "checkpoint"; diff --git a/src/components/asset-studio/characterAssetWorkflowPersistence.ts b/src/components/asset-studio/characterAssetWorkflowPersistence.ts index 73cf74dd..deec8414 100644 --- a/src/components/asset-studio/characterAssetWorkflowPersistence.ts +++ b/src/components/asset-studio/characterAssetWorkflowPersistence.ts @@ -3,6 +3,7 @@ import { postApiJson, } from '../../editor/shared/editorApiClient'; import { + appendApiErrorRequestId, fetchJson, parseApiErrorMessage, } from '../../editor/shared/jsonClient'; @@ -265,7 +266,10 @@ export async function putCharacterRoleAssetWorkflow( if (!response.ok) { throw new Error( - parseApiErrorMessage(responseText, '保存角色资产工坊缓存失败'), + appendApiErrorRequestId( + parseApiErrorMessage(responseText, '保存角色资产工坊缓存失败'), + response.headers.get('x-request-id'), + ), ); } diff --git a/src/components/auth/AccountModal.test.tsx b/src/components/auth/AccountModal.test.tsx index 3b7b2181..cda89c90 100644 --- a/src/components/auth/AccountModal.test.tsx +++ b/src/components/auth/AccountModal.test.tsx @@ -14,7 +14,6 @@ import { AccountModal } from './AccountModal'; const baseUser: AuthUser = { id: 'user-1', - username: 'tester', displayName: '138****8000', avatarUrl: null, publicUserCode: 'user-tester', @@ -22,7 +21,6 @@ const baseUser: AuthUser = { loginMethod: 'phone', bindingStatus: 'active', wechatBound: true, - createdAt: new Date().toISOString(), }; function renderAccountModal(overrides?: { @@ -87,14 +85,7 @@ function buildSession( sessionId: 'usess_1', sessionIds: ['usess_1'], sessionCount: 1, - clientType: 'web_browser', - clientRuntime: 'chrome', - clientPlatform: 'windows', clientLabel: 'Windows / Chrome', - deviceDisplayName: 'Windows / Chrome', - miniProgramAppId: null, - miniProgramEnv: null, - userAgent: 'Mozilla/5.0', ipMasked: '203.0.*.*', isCurrent: false, createdAt: '2026-05-01T10:00:00.000Z', @@ -271,14 +262,7 @@ test('account panel includes merged security devices and audit sections', async sessionId: 'session-1', sessionIds: ['session-1'], sessionCount: 1, - clientType: 'mobile', - clientRuntime: 'ios', - clientPlatform: 'wechat', clientLabel: 'iPhone 15 Pro', - deviceDisplayName: 'iPhone 15 Pro / 微信', - miniProgramAppId: null, - miniProgramEnv: null, - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)', isCurrent: true, createdAt: '2026-04-20T07:30:00.000Z', lastSeenAt: '2026-04-20T09:00:00.000Z', diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index d8341460..353153df 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -94,7 +94,6 @@ vi.mock('./BindPhoneScreen', () => ({ const mockUser: AuthUser = { id: 'user-1', - username: 'tester', displayName: '测试玩家', avatarUrl: null, publicUserCode: 'user-tester', @@ -102,7 +101,6 @@ const mockUser: AuthUser = { loginMethod: 'phone', bindingStatus: 'active', wechatBound: false, - createdAt: new Date().toISOString(), }; beforeEach(() => { @@ -863,14 +861,7 @@ test('auth gate revokes merged session group and refreshes sessions', async () = sessionId: 'usess_remote', sessionIds: ['usess_remote', 'usess_remote_rotated'], sessionCount: 2, - clientType: 'web_browser', - clientRuntime: 'chrome', - clientPlatform: 'windows', clientLabel: 'Windows / Chrome', - deviceDisplayName: 'Windows / Chrome', - miniProgramAppId: null, - miniProgramEnv: null, - userAgent: 'Mozilla/5.0', ipMasked: '203.0.*.*', isCurrent: false, createdAt: '2026-05-01T10:00:00.000Z', diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index 6388732a..180e1e7a 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -1048,7 +1048,7 @@ test('buildCreationWorkShelfItems maps bark battle works with scene role cover a ); }); -test('bark battle draft generating state follows pending assets or missing three images', () => { +test('bark battle draft generating state only follows pending assets', () => { const draft = { workId: 'bark-battle-work-draft', draftId: 'bark-battle-draft-1', @@ -1073,6 +1073,12 @@ test('bark battle draft generating state follows pending assets or missing three expect(hasBarkBattleRequiredImages(draft)).toBe(false); expect(isPersistedBarkBattleDraftGenerating(draft)).toBe(true); + expect( + isPersistedBarkBattleDraftGenerating({ + ...draft, + generationStatus: 'partial_failed', + }), + ).toBe(false); expect( isPersistedBarkBattleDraftGenerating({ ...draft, diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index e44022a2..81300a53 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -1111,10 +1111,9 @@ export function isPersistedBarkBattleDraftGenerating( return false; } - return ( - item.generationStatus === 'pending_assets' || - !hasBarkBattleRequiredImages(item) - ); + // 中文注释:汪汪声浪生成失败后会回写 partial_failed 并进入结果页承接错误槽位, + // 不能因为三图未齐就继续把作品架整卡锁成“生成中”。 + return item.generationStatus === 'pending_assets'; } export function hasBarkBattleRequiredImages(item: BarkBattleWorkSummary) { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 301342ea..32c45498 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1966,13 +1966,65 @@ function buildWoodenFishCreationUrlState(params: { const profileId = normalizeCreationUrlValue( params.work?.summary.profileId ?? params.session?.draft?.profileId, ); + const draftId = profileId ?? sessionId; return { sessionId, profileId, + draftId, workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId), }; } +function buildWoodenFishSessionFromWorkDetail( + work: WoodenFishWorkProfileResponse, + fallbackItem?: WoodenFishWorkSummaryResponse | null, +): WoodenFishSessionSnapshotResponse { + const sessionId = + normalizeCreationUrlValue(work.summary.sourceSessionId) ?? + normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ?? + work.summary.profileId; + return { + sessionId, + ownerUserId: work.summary.ownerUserId, + status: work.summary.generationStatus, + draft: work.draft, + createdAt: work.summary.updatedAt, + updatedAt: work.summary.updatedAt, + }; +} + +function buildWoodenFishPendingSession( + item: WoodenFishWorkSummaryResponse, +): WoodenFishSessionSnapshotResponse { + const sessionId = + normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId; + return { + sessionId, + ownerUserId: item.ownerUserId, + status: item.generationStatus, + draft: { + templateId: 'wooden-fish', + templateName: '敲木鱼', + profileId: item.profileId, + workTitle: item.workTitle, + workDescription: item.workDescription, + themeTags: item.themeTags, + hitObjectPrompt: '', + hitObjectReferenceImageSrc: null, + hitSoundPrompt: null, + floatingWords: ['功德 +1'], + hitObjectAsset: null, + backgroundAsset: null, + backButtonAsset: null, + hitSoundAsset: null, + coverImageSrc: item.coverImageSrc, + generationStatus: item.generationStatus, + }, + createdAt: item.updatedAt, + updatedAt: item.updatedAt, + }; +} + function buildBarkBattleCreationUrlState( draft: BarkBattleDraftConfig | null, ): CreationUrlState { @@ -2094,6 +2146,8 @@ function buildDraftCompletionDialogSource( return formatPlatformTaskCompletionSource('方洞挑战草稿', sourceId); case 'jump-hop': return formatPlatformTaskCompletionSource('跳一跳草稿', sourceId); + case 'wooden-fish': + return formatPlatformTaskCompletionSource('敲木鱼草稿', sourceId); case 'puzzle': return formatPlatformTaskCompletionSource('拼图草稿', sourceId); case 'visual-novel': @@ -2478,6 +2532,37 @@ function buildPendingJumpHopWorks( })); } +function buildPendingWoodenFishWorks( + pending: Record | undefined, + existingItems: readonly WoodenFishWorkSummaryResponse[], +): WoodenFishWorkSummaryResponse[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => ({ + runtimeKind: 'wooden-fish', + workId: `wooden-fish-work-${sessionId}`, + profileId: sessionId, + ownerUserId: '', + sourceSessionId: sessionId, + workTitle: '敲木鱼草稿', + workDescription: '正在生成敲木鱼草稿。', + themeTags: ['敲木鱼'], + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus: state.status === 'generating' ? 'generating' : 'ready', + })); +} + function buildPendingMatch3DWorks( pending: Record | undefined, existingItems: readonly Match3DWorkSummary[], @@ -4510,8 +4595,14 @@ export function PlatformEntryFlowShellImpl({ [jumpHopWorks, pendingDraftShelfItems], ); const woodenFishShelfItems = useMemo( - () => woodenFishWorks, - [woodenFishWorks], + () => [ + ...buildPendingWoodenFishWorks( + pendingDraftShelfItems['wooden-fish'], + woodenFishWorks, + ), + ...woodenFishWorks, + ], + [pendingDraftShelfItems, woodenFishWorks], ); const match3dShelfItems = useMemo( () => [ @@ -8887,6 +8978,35 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishGenerationState(generationState); setIsWoodenFishBusy(true); setSelectionStage('wooden-fish-generating'); + markDraftGenerating('wooden-fish', [created.session.sessionId]); + markPendingDraftGenerating('wooden-fish', created.session.sessionId); + const createdAt = created.session.updatedAt ?? created.session.createdAt; + setWoodenFishWorks((current) => [ + { + runtimeKind: 'wooden-fish', + workId: created.session.sessionId, + profileId: created.session.sessionId, + ownerUserId: created.session.ownerUserId, + sourceSessionId: created.session.sessionId, + workTitle: + payload?.workTitle ?? created.session.draft?.workTitle ?? '敲木鱼', + workDescription: + payload?.workDescription ?? + created.session.draft?.workDescription ?? + '', + themeTags: payload?.themeTags ?? created.session.draft?.themeTags ?? ['敲木鱼'], + coverImageSrc: created.session.draft?.coverImageSrc ?? null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: createdAt, + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + }, + ...current.filter( + (item) => item.sourceSessionId !== created.session.sessionId, + ), + ]); try { const response = await woodenFishClient.executeAction( @@ -8921,6 +9041,32 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishGenerationState( createReadyWoodenFishGenerationState(generationState), ); + if (response.work) { + setWoodenFishWorks((current) => [ + response.work!.summary, + ...current.filter( + (item) => + item.workId !== response.work!.summary.workId && + item.sourceSessionId !== response.work!.summary.sourceSessionId, + ), + ]); + markPendingDraftReady( + 'wooden-fish', + created.session.sessionId, + false, + ); + markDraftReady( + 'wooden-fish', + [ + created.session.sessionId, + response.work.summary.workId, + response.work.summary.profileId, + response.work.summary.sourceSessionId, + ], + false, + ); + void refreshWoodenFishShelf().catch(() => undefined); + } setSelectionStage('wooden-fish-result'); } catch (error) { const errorMessage = resolveRpgCreationErrorMessage( @@ -8955,7 +9101,15 @@ export function PlatformEntryFlowShellImpl({ setIsWoodenFishBusy(false); } }, - [createReadyWoodenFishGenerationState, setSelectionStage], + [ + createReadyWoodenFishGenerationState, + markDraftGenerating, + markDraftReady, + markPendingDraftGenerating, + markPendingDraftReady, + refreshWoodenFishShelf, + setSelectionStage, + ], ); const retryWoodenFishDraftGeneration = useCallback(() => { @@ -11776,18 +11930,43 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishError(null); setPublicWorkDetailError(null); setIsWoodenFishBusy(true); + if (item.generationStatus === 'generating') { + const pendingSession = buildWoodenFishPendingSession(item); + setWoodenFishSession(pendingSession); + setWoodenFishRun(null); + setWoodenFishWork(null); + writeCreationUrlState( + buildWoodenFishCreationUrlState({ session: pendingSession }), + ); + enterCreateTab(); + setSelectionStage('wooden-fish-generating'); + setIsWoodenFishBusy(false); + return; + } try { const detail = await woodenFishClient.getWorkDetail(item.profileId); - setWoodenFishSession(null); + const recoveredSession = buildWoodenFishSessionFromWorkDetail( + detail.item, + item, + ); + setWoodenFishSession(recoveredSession); setWoodenFishRun(null); setWoodenFishWork(detail.item); setWoodenFishRuntimeReturnStage('wooden-fish-result'); + writeCreationUrlState( + buildWoodenFishCreationUrlState({ + session: recoveredSession, + work: detail.item, + }), + ); enterCreateTab(); setSelectionStage('wooden-fish-result'); } catch (error) { setWoodenFishError( resolveRpgCreationErrorMessage(error, '读取敲木鱼草稿失败。'), ); + enterCreateTab(); + setSelectionStage('wooden-fish-generating'); } finally { setIsWoodenFishBusy(false); } @@ -11796,6 +11975,7 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab, markDraftNoticeSeen, openWoodenFishPublicWorkDetail, + writeCreationUrlState, setSelectionStage, ], ); @@ -15114,8 +15294,7 @@ export function PlatformEntryFlowShellImpl({ > diff --git a/src/components/platform-entry/PlatformWorkDetailView.test.tsx b/src/components/platform-entry/PlatformWorkDetailView.test.tsx index fe82e782..5275c565 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.test.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.test.tsx @@ -9,6 +9,7 @@ import { EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, type PlatformEdutainmentGalleryCard, type PlatformPuzzleGalleryCard, + type PlatformWoodenFishGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; @@ -82,6 +83,29 @@ function createBabyObjectMatchEntry(): PlatformEdutainmentGalleryCard { }; } +function createWoodenFishEntry(): PlatformWoodenFishGalleryCard { + return { + sourceType: 'wooden-fish', + workId: 'wooden-fish-work-1', + profileId: 'wooden-fish-profile-1', + publicWorkCode: 'WF-001', + ownerUserId: 'user-wooden-fish', + authorDisplayName: '敲木鱼玩家', + worldName: '莲花木鱼', + subtitle: '敲木鱼', + summaryText: '莲花主题敲木鱼。', + coverImageSrc: null, + themeTags: ['敲木鱼'], + playCount: 12, + remixCount: 0, + likeCount: 4, + recentPlayCount7d: 0, + visibility: 'published', + publishedAt: '2026-05-20T10:00:00.000Z', + updatedAt: '2026-05-20T12:00:00.000Z', + }; +} + afterEach(() => { vi.useRealTimers(); }); @@ -119,7 +143,13 @@ test('PlatformWorkDetailView prefers resolved public user display name', () => { render( { expect(screen.queryByText('137****6613')).toBeNull(); }); +test('PlatformWorkDetailView prefers display name then public user code for wooden fish works', () => { + render( + , + ); + + expect(screen.getByText('公开昵称 · SY-00000004')).toBeTruthy(); + expect(screen.queryByText('phone_00000004')).toBeNull(); + expect(screen.queryByText('敲木鱼玩家')).toBeNull(); + expect(screen.queryByText('公开昵称')).toBeNull(); +}); + test('PlatformWorkDetailView calls like handler', () => { const onLike = vi.fn(); render( diff --git a/src/components/platform-entry/PlatformWorkDetailView.tsx b/src/components/platform-entry/PlatformWorkDetailView.tsx index 65d3c404..5390415a 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.tsx @@ -14,6 +14,7 @@ import { } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; +import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth'; import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes'; import { copyTextToClipboard } from '../../services/clipboard'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; @@ -25,6 +26,7 @@ import { isBarkBattleGalleryEntry, isEdutainmentGalleryEntry, type PlatformPublicGalleryCard, + resolvePlatformWorkAuthorDisplayName, resolvePlatformPublicWorkCode, resolvePlatformWorldCoverSlides, resolvePlatformWorldStats, @@ -32,8 +34,7 @@ import { export interface PlatformWorkDetailViewProps { entry: PlatformPublicGalleryCard; - authorAvatarUrl?: string | null; - authorDisplayName?: string | null; + authorSummary?: PublicUserSummary | null; isBusy: boolean; error?: string | null; visibleCoverCount?: number; @@ -85,8 +86,7 @@ const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200; export function PlatformWorkDetailView({ entry, - authorAvatarUrl, - authorDisplayName, + authorSummary, isBusy, visibleCoverCount = 1, onBack, @@ -108,9 +108,11 @@ export function PlatformWorkDetailView({ const appIconImage = coverSlides[0]?.imageSrc ?? ''; const hasCoverCarousel = coverSlides.length > 1; const publicWorkCode = resolvePlatformPublicWorkCode(entry); - const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; - const resolvedAuthorDisplayName = - authorDisplayName?.trim() || entry.authorDisplayName; + const normalizedAuthorAvatarUrl = authorSummary?.avatarUrl?.trim() ?? ''; + const resolvedAuthorDisplayName = resolvePlatformWorkAuthorDisplayName( + entry, + authorSummary, + ); const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( 'idle', ); diff --git a/src/components/platform-entry/barkBattleWorkCache.ts b/src/components/platform-entry/barkBattleWorkCache.ts index 76d631fb..c6ae45cf 100644 --- a/src/components/platform-entry/barkBattleWorkCache.ts +++ b/src/components/platform-entry/barkBattleWorkCache.ts @@ -1,4 +1,3 @@ -import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth'; import type { BarkBattleDraftConfig, BarkBattleGenerationStatus as SharedBarkBattleGenerationStatus, @@ -58,7 +57,14 @@ export function shouldPreserveLocalBarkBattleWorkOnRefresh( export function buildBarkBattleWorkSummaryFromDraft( draft: BarkBattleDraftConfig, - user: PublicUserSummary | null | undefined, + user: + | { + id: string; + displayName: string; + publicUserCode: string; + } + | null + | undefined, generationStatus: BarkBattleGenerationStatus = 'pending_assets', ): BarkBattleWorkSummary { const workId = draft.workId?.trim() || draft.draftId; diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index e1641ac3..b8047a7f 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -187,6 +187,7 @@ const authServiceMocks = vi.hoisted(() => ({ async (publicUserCode: string): Promise => ({ id: `public-user-${publicUserCode}`, publicUserCode, + username: 'author_user', displayName: '公开作者', avatarUrl: null, }), @@ -195,6 +196,7 @@ const authServiceMocks = vi.hoisted(() => ({ async (userId: string): Promise => ({ id: userId, publicUserCode: `code-${userId}`, + username: 'author_user', displayName: '公开作者', avatarUrl: null, }), @@ -1379,7 +1381,6 @@ const mockSession: CustomWorldAgentSessionSnapshot = { const mockAuthUser: AuthUser = { id: 'user-1', - username: 'tester', displayName: '测试玩家', avatarUrl: null, publicUserCode: 'user-tester', @@ -1387,7 +1388,6 @@ const mockAuthUser: AuthUser = { loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: new Date().toISOString(), }; function buildMockCreativeAgentSession( @@ -4271,7 +4271,7 @@ test('running puzzle form generation creates a new puzzle draft on same template ); expect( await screen.findByRole('progressbar', { - name: '拼图草稿生成进度', + name: '拼图图片生成进度', }), ).toBeTruthy(); @@ -4307,7 +4307,7 @@ test('running puzzle form generation creates a new puzzle draft on same template expect( await screen.findByRole('progressbar', { - name: '拼图草稿生成进度', + name: '拼图图片生成进度', }), ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); @@ -4387,7 +4387,7 @@ test('running puzzle draft opens generation progress from draft tab', async () = ); expect( await screen.findByRole('progressbar', { - name: '拼图草稿生成进度', + name: '拼图图片生成进度', }), ).toBeTruthy(); @@ -4401,7 +4401,7 @@ test('running puzzle draft opens generation progress from draft tab', async () = expect( await screen.findByRole('progressbar', { - name: '拼图草稿生成进度', + name: '拼图图片生成进度', }), ).toBeTruthy(); expect(screen.queryByText('拼图结果页')).toBeNull(); @@ -5388,7 +5388,7 @@ test('embedded puzzle form recovers when compile request times out after backend }); expect(screen.queryByText('执行拼图操作失败。')).toBeNull(); expect(screen.queryByText('请求超时:90000ms')).toBeNull(); - expect(screen.queryByText('拼图草稿生成进度')).toBeNull(); + expect(screen.queryByText('拼图图片生成进度')).toBeNull(); expect(startLocalPuzzleRun).toHaveBeenCalledTimes(1); }); @@ -7414,13 +7414,13 @@ test('persisted generating puzzle draft opens generation progress after refresh' }); expect( await screen.findByRole('progressbar', { - name: '拼图草稿生成进度', + name: '拼图图片生成进度', }), ).toBeTruthy(); expect( Number( screen - .getByRole('progressbar', { name: '拼图草稿生成进度' }) + .getByRole('progressbar', { name: '拼图图片生成进度' }) .getAttribute('aria-valuenow'), ), ).toBe(0); @@ -7479,7 +7479,7 @@ test('persisted generating puzzle draft keeps session polling on the same sessio expect( await screen.findByRole('progressbar', { - name: '拼图草稿生成进度', + name: '拼图图片生成进度', }), ).toBeTruthy(); @@ -7527,7 +7527,7 @@ test('puzzle compile timeout shows failure dialog when reread session is still g expect(within(dialog).getByRole('button', { name: '复制报错' })).toBeTruthy(); expect( await screen.findByRole('progressbar', { - name: '拼图草稿生成进度', + name: '拼图图片生成进度', }), ).toBeTruthy(); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index bd57b40d..9cc830bb 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -141,7 +141,6 @@ const { amountDelta: 10, balanceAfter: 10, sourceType: 'daily_task_reward', - createdAt: '2026-05-03T08:01:00Z', }, center: buildClaimedTaskCenter(), })), @@ -204,9 +203,9 @@ const { amountCents: 600, status: 'paid', paymentChannel: 'mock', + createdAt: '2026-04-25T10:00:00Z', paidAt: '2026-04-25T10:00:00Z', providerTransactionId: null, - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 120, membershipExpiresAt: null, }, @@ -237,9 +236,9 @@ const { amountCents: 600, status: 'paid', paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', paidAt: '2026-04-25T10:01:00Z', providerTransactionId: 'wx-transaction-1', - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 120, membershipExpiresAt: null, }, @@ -276,8 +275,8 @@ const { amountCents: 600, status: 'paid', paymentChannel: 'wechat_mp_virtual', - providerTransactionId: 'wx-transaction-1', createdAt: '2026-04-25T10:00:00Z', + providerTransactionId: 'wx-transaction-1', paidAt: '2026-04-25T10:01:00Z', pointsDelta: 120, membershipExpiresAt: null, @@ -305,14 +304,12 @@ const { amountDelta: -1, balanceAfter: 29, sourceType: 'asset_operation_consume', - createdAt: '2026-04-28T10:00:00Z', }, { id: 'ledger-2', amountDelta: 30, balanceAfter: 30, sourceType: 'invite_invitee_reward', - createdAt: '2026-04-28T09:00:00Z', }, ], })), @@ -328,6 +325,7 @@ const { async (code: string): Promise => ({ id: `id-${code}`, publicUserCode: code, + username: 'author_user', displayName: '公开作者', avatarUrl: null, }), @@ -336,6 +334,7 @@ const { async (userId: string): Promise => ({ id: userId, publicUserCode: `code-${userId}`, + username: 'author_user', displayName: '公开作者', avatarUrl: null, }), @@ -362,14 +361,12 @@ vi.mock('../../services/payment/paymentRedirect', () => ({ mockUpdateAuthProfile.mockResolvedValue({ id: 'user-1', publicUserCode: '100001', - username: 'tester', displayName: '测试玩家', avatarUrl: null, phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: new Date().toISOString(), }); vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ @@ -723,14 +720,12 @@ function ProfileHomeViewHarness({ user: { id: 'user-1', publicUserCode: '100001', - username: 'tester', displayName: '测试玩家', avatarUrl: null, phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: DEFAULT_PROFILE_CREATED_AT, ...userOverrides, }, canAccessProtectedData: true, @@ -911,14 +906,12 @@ function renderLoggedInHomeView( user: { id: 'user-1', publicUserCode: '100001', - username: 'tester', displayName: '测试玩家', avatarUrl: null, phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: new Date().toISOString(), }, canAccessProtectedData: true, openLoginModal: vi.fn(), @@ -1095,7 +1088,6 @@ afterEach(() => { amountDelta: 10, balanceAfter: 10, sourceType: 'daily_task_reward', - createdAt: '2026-05-03T08:01:00Z', }, center: mockBuildTaskCenter({ walletBalance: 10, @@ -1121,14 +1113,12 @@ afterEach(() => { mockUpdateAuthProfile.mockResolvedValue({ id: 'user-1', publicUserCode: '100001', - username: 'tester', displayName: '测试玩家', avatarUrl: null, phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: DEFAULT_PROFILE_CREATED_AT, }); mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR'); mockRedirectToPaymentUrl.mockReset(); @@ -1190,9 +1180,9 @@ test('profile recharge modal shows native qr code on desktop web by default', as amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_native', + createdAt: '2026-04-25T10:00:00Z', paidAt: null, providerTransactionId: null, - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, @@ -1255,9 +1245,9 @@ test('profile recharge modal jumps to h5 payment on mobile web by default', asyn amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_h5', + createdAt: '2026-04-25T10:00:00Z', paidAt: null, providerTransactionId: null, - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, @@ -1367,9 +1357,9 @@ test('profile recharge modal posts virtual payment params in mini program web-vi amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', paidAt: null as string | null, providerTransactionId: null, - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, @@ -1820,9 +1810,9 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', paidAt: null as string | null, providerTransactionId: null, - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, @@ -1859,9 +1849,9 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', paidAt: null, providerTransactionId: null, - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, @@ -1890,9 +1880,9 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb amountCents: 600, status: 'paid' as const, paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', paidAt: '2026-04-25T10:01:00Z', providerTransactionId: 'wx-transaction-2', - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 120, membershipExpiresAt: null, }, @@ -1954,9 +1944,9 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', paidAt: null as string | null, providerTransactionId: null, - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, @@ -2040,9 +2030,9 @@ test('profile recharge modal releases submitting state after cancelled wechat pa amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_mp_virtual', + createdAt: '2026-04-25T10:00:00Z', paidAt: null as string | null, providerTransactionId: null, - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, @@ -2123,9 +2113,9 @@ test('profile native qr confirmation refreshes only after server reports paid', amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_native', + createdAt: '2026-04-25T10:00:00Z', paidAt: null, providerTransactionId: null, - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, @@ -2157,9 +2147,9 @@ test('profile native qr confirmation refreshes only after server reports paid', amountCents: 600, status: 'paid' as const, paymentChannel: 'wechat_native', + createdAt: '2026-04-25T10:00:00Z', paidAt: '2026-04-25T10:01:00Z', providerTransactionId: 'wx-native-1', - createdAt: '2026-04-25T10:00:00Z', pointsDelta: 120, membershipExpiresAt: null, }, @@ -2564,7 +2554,7 @@ test('wallet ledger modal shows empty and error states', async () => { test('profile community shortcut shows reward subtitle and invited users', async () => { const user = userEvent.setup(); - renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() }); + renderProfileView(vi.fn(), {}); expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull(); @@ -2586,7 +2576,6 @@ test('profile page hides legacy redeem invite secondary shortcut for fresh accou renderProfileView( vi.fn(), {}, - { createdAt: buildFreshProfileCreatedAt() }, ); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); @@ -2618,7 +2607,7 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async }); unmount(); - renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' }); + renderProfileView(vi.fn(), {}); const expiredShortcutRegion = screen.getByRole('region', { name: '常用功能', }); @@ -2678,7 +2667,6 @@ test('profile redeem invite query modal submits code after login', async () => { renderProfileView( onRechargeSuccess, {}, - { createdAt: buildFreshProfileCreatedAt() }, ); expect(await screen.findByLabelText('邀请码')).toBeTruthy(); @@ -2837,14 +2825,12 @@ test('logged in create tab shows real wallet balance beside the brand', () => { user: { id: 'user-1', publicUserCode: '100001', - username: 'tester', displayName: '测试玩家', avatarUrl: null, phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: DEFAULT_PROFILE_CREATED_AT, }, canAccessProtectedData: true, openLoginModal: vi.fn(), @@ -3335,14 +3321,12 @@ test('logged in recommend page uses gated recommend detail callback', async () = user: { id: 'user-1', publicUserCode: '100001', - username: 'tester', displayName: '测试玩家', avatarUrl: null, phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: new Date().toISOString(), }, canAccessProtectedData: true, openLoginModal: vi.fn(), @@ -3471,14 +3455,12 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc user: { id: 'user-1', publicUserCode: '100001', - username: 'tester', displayName: '测试玩家', avatarUrl: null, phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: new Date().toISOString(), }, canAccessProtectedData: true, openLoginModal: vi.fn(), @@ -3648,6 +3630,7 @@ test('mobile recommend meta loads real author avatar from public user summary', mockGetPublicAuthUserById.mockResolvedValueOnce({ id: 'user-2', publicUserCode: 'SY-00000002', + username: 'puzzle_user', displayName: '拼图玩家', avatarUrl: 'data:image/png;base64,AUTHOR', }); @@ -3907,14 +3890,12 @@ test('desktop logged in home syncs mobile home modules without square or latest user: { id: 'user-1', publicUserCode: '100001', - username: 'tester', displayName: '测试玩家', avatarUrl: null, phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: new Date().toISOString(), }, canAccessProtectedData: true, openLoginModal: vi.fn(), diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index a83f5a12..e2df6253 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -150,6 +150,7 @@ import { isWoodenFishGalleryEntry, type PlatformPublicGalleryCard, type PlatformWorldCardLike, + resolvePlatformWorkAuthorDisplayName, resolvePlatformPublicWorkCode, resolvePlatformWorldCoverImage, resolvePlatformWorldCoverSlides, @@ -247,7 +248,6 @@ const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024; const AVATAR_OUTPUT_SIZE = 256; const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200; -const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000; const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36; const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180; @@ -615,6 +615,7 @@ function WorldCard({ onClick, className, authorAvatarUrl, + authorSummary, feedCardKey, enableCoverCarousel = false, isCoverCarouselActive = false, @@ -624,6 +625,7 @@ function WorldCard({ onClick: () => void; className?: string; authorAvatarUrl?: string | null; + authorSummary?: PublicUserSummary | null; feedCardKey?: string; enableCoverCarousel?: boolean; isCoverCarouselActive?: boolean; @@ -657,7 +659,10 @@ function WorldCard({ const remixCount = getPlatformWorldRemixCount(entry); const likeCount = getPlatformWorldLikeCount(entry); const typeLabel = describePublicGalleryCardKind(entry); - const authorName = entry.authorDisplayName.trim() || '玩家'; + const authorName = resolvePlatformWorkAuthorDisplayName( + entry, + authorSummary, + ); const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName); const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; const cardLabel = `${entry.worldName},${typeLabel},${formatCompactCount(playCount)}游玩,${formatCompactCount(remixCount)}改造,${formatCompactCount(likeCount)}点赞`; @@ -939,6 +944,7 @@ function RecommendRuntimePreviewCard({ function RecommendSwipeCard({ entry, authorAvatarUrl, + authorSummary, isActive, visual, shareState, @@ -952,6 +958,7 @@ function RecommendSwipeCard({ }: { entry: PlatformPublicGalleryCard; authorAvatarUrl?: string | null; + authorSummary?: PublicUserSummary | null; isActive: boolean; visual: ReactNode; shareState?: 'idle' | 'copied' | 'failed'; @@ -976,6 +983,7 @@ function RecommendSwipeCard({ ) => void; onDragPointerMove?: (event: PointerEvent) => void; onDragPointerUp?: (event: PointerEvent) => void; @@ -1018,7 +1028,10 @@ function RecommendRuntimeMeta({ }) { const likeCount = getPlatformWorldLikeCount(entry); const remixCount = getPlatformWorldRemixCount(entry); - const authorName = entry.authorDisplayName.trim() || '玩家'; + const authorName = resolvePlatformWorkAuthorDisplayName( + entry, + authorSummary, + ); const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName); const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; const displayName = formatPlatformWorkDisplayName(entry.worldName); @@ -1894,28 +1907,28 @@ async function getPublicWorkAuthorSummary( function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) { if (isBigFishGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('大鱼吃小鱼'); } if (isPuzzleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('拼图'); } if (isMatch3DGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('抓大鹅'); } if (isSquareHoleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('方洞挑战'); } if (isJumpHopGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('???'); + return formatPlatformWorkDisplayTag('跳一跳'); } if (isWoodenFishGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('???'); + return formatPlatformWorkDisplayTag('敲木鱼'); } if (isVisualNovelGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('视觉小说'); } if (isBarkBattleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('??'); + return formatPlatformWorkDisplayTag('汪汪声浪'); } if (isEdutainmentGalleryEntry(entry)) { return formatPlatformWorkDisplayTag(entry.templateName); @@ -2214,21 +2227,6 @@ function formatDashboardCount(value: number) { return normalizedValue.toLocaleString('zh-CN'); } -function isWithinProfileInviteRedeemWindow( - createdAt: string | null | undefined, -) { - if (!createdAt) { - return false; - } - - const createdTime = new Date(createdAt).getTime(); - if (Number.isNaN(createdTime)) { - return false; - } - - return Date.now() - createdTime <= PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS; -} - function normalizeProfileInviteQueryCode(value: string | null | undefined) { return (value ?? '') .trim() @@ -2273,16 +2271,13 @@ function buildPublicUserCode(user: AuthUser | null | undefined) { return user.publicUserCode.trim(); } - const raw = - user?.id.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() || - user?.username.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() || - '00000000'; + const raw = user?.id.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() || '00000000'; return `SY-${raw.slice(-8).padStart(8, '0')}`; } function getUserAvatarLabel(user: AuthUser | null | undefined) { - return (user?.displayName || user?.username || '叙') + return (user?.displayName || '叙') .slice(0, 1) .toUpperCase(); } @@ -4198,6 +4193,17 @@ export function RpgEntryHomeView({ }, [publicAuthorSummariesByKey], ); + const getPublicEntryAuthorSummary = useCallback( + (entry: PlatformPublicGalleryCard) => { + const authorLookupKey = buildPublicWorkAuthorLookupKey(entry); + if (!authorLookupKey) { + return null; + } + + return publicAuthorSummariesByKey[authorLookupKey] ?? null; + }, + [publicAuthorSummariesByKey], + ); const activeCategoryGroup = categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? categoryGroups[0] ?? @@ -4930,18 +4936,14 @@ export function RpgEntryHomeView({ }); }, []); useEffect(() => { - if ( - activeTab !== 'profile' || - !isAuthenticated || - !isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt) - ) { + if (activeTab !== 'profile' || !isAuthenticated) { setIsReferralCenterInitialized(false); setReferralCenter(null); return; } loadReferralCenter(); - }, [activeTab, authUi?.user?.createdAt, isAuthenticated, loadReferralCenter]); + }, [activeTab, isAuthenticated, loadReferralCenter]); const openProfilePopupPanel = (panel: ProfileReferralPanel) => { setProfilePopupPanel(panel); setReferralError(null); @@ -5589,6 +5591,9 @@ export function RpgEntryHomeView({ authorAvatarUrl={getPublicEntryAuthorAvatarUrl( previousRecommendEntry, )} + authorSummary={getPublicEntryAuthorSummary( + previousRecommendEntry, + )} isActive={false} visual={ @@ -5630,6 +5638,9 @@ export function RpgEntryHomeView({ authorAvatarUrl={getPublicEntryAuthorAvatarUrl( nextRecommendEntry, )} + authorSummary={getPublicEntryAuthorSummary( + nextRecommendEntry, + )} isActive={false} visual={ onOpenGalleryDetail(entry)} className="w-full" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorSummary={getPublicEntryAuthorSummary(entry)} feedCardKey={cardKey} /> ); @@ -5844,13 +5856,14 @@ export function RpgEntryHomeView({ return ( onOpenGalleryDetail(entry)} - className="w-full" - authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} - feedCardKey={cardKey} - enableCoverCarousel={mobileFeedCarouselEnabled} - isCoverCarouselActive={ + entry={entry} + onClick={() => onOpenGalleryDetail(entry)} + className="w-full" + authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorSummary={getPublicEntryAuthorSummary(entry)} + feedCardKey={cardKey} + enableCoverCarousel={mobileFeedCarouselEnabled} + isCoverCarouselActive={ mobileCenteredCardKey === cardKey } /> @@ -5954,6 +5967,7 @@ export function RpgEntryHomeView({ onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorSummary={getPublicEntryAuthorSummary(entry)} /> ))} @@ -5981,6 +5995,7 @@ export function RpgEntryHomeView({ onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorSummary={getPublicEntryAuthorSummary(entry)} /> ))} {onOpenChildMotionDemo ? ( @@ -6041,6 +6056,7 @@ export function RpgEntryHomeView({ onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorSummary={getPublicEntryAuthorSummary(entry)} /> ))} @@ -6556,6 +6572,7 @@ export function RpgEntryHomeView({ onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorSummary={getPublicEntryAuthorSummary(entry)} /> ))} @@ -6726,6 +6743,7 @@ export function RpgEntryHomeView({ onClick={() => openRecommendGalleryDetail(entry)} className="w-full min-w-0" authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} + authorSummary={getPublicEntryAuthorSummary(entry)} /> ))} diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts index 5c39a03b..33918436 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts @@ -18,6 +18,7 @@ import { mapWoodenFishWorkToPlatformGalleryCard, type PlatformEdutainmentGalleryCard, type PlatformPuzzleGalleryCard, + resolvePlatformWorkAuthorDisplayName, resolvePlatformPublicWorkCode, resolvePlatformWorldFallbackCoverImage, } from './rpgEntryWorldPresentation'; @@ -197,6 +198,45 @@ test('maps wooden fish work to platform gallery card with WF public code', () => expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['敲木鱼']); }); +test('resolves public work author from display name and public user code before stored author name', () => { + const card = mapWoodenFishWorkToPlatformGalleryCard({ + publicWorkCode: 'WF-AUTHOR1', + workId: 'wooden-fish-work-author', + profileId: 'wooden-fish-profile-author', + ownerUserId: 'user-author', + authorDisplayName: '敲木鱼玩家', + workTitle: '莲花木鱼', + workDescription: '莲花主题敲木鱼。', + coverImageSrc: null, + themeTags: ['敲木鱼'], + publicationStatus: 'published', + playCount: 0, + updatedAt: '2026-05-20T00:00:00.000Z', + publishedAt: '2026-05-20T00:00:00.000Z', + generationStatus: 'ready', + }); + + expect( + resolvePlatformWorkAuthorDisplayName(card, { + id: 'user_00000004', + publicUserCode: 'SY-00000004', + username: 'phone_00000004', + displayName: '公开昵称', + avatarUrl: null, + }), + ).toBe('公开昵称 · SY-00000004'); + expect( + resolvePlatformWorkAuthorDisplayName(card, { + id: 'user_00000004', + publicUserCode: '', + username: 'phone_00000004', + displayName: '公开昵称', + avatarUrl: null, + }), + ).toBe('公开昵称'); + expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('敲木鱼玩家'); +}); + test('keeps baby object match public card code and template label intact', () => { const card: PlatformEdutainmentGalleryCard = { sourceType: 'edutainment', diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index 738b1404..ce0fd785 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -1,4 +1,5 @@ import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; @@ -219,6 +220,7 @@ export type PlatformWoodenFishGalleryCard = { sourceSessionId?: string | null; publicWorkCode: string; ownerUserId: string; + authorUsername?: string | null; authorDisplayName: string; worldName: string; subtitle: string; @@ -562,6 +564,10 @@ export function mapWoodenFishWorkToPlatformGalleryCard( ? summary.publicWorkCode : buildWoodenFishPublicWorkCode(summary.profileId), ownerUserId: summary.ownerUserId, + authorUsername: + 'authorUsername' in summary && typeof summary.authorUsername === 'string' + ? summary.authorUsername + : null, authorDisplayName: 'authorDisplayName' in summary ? summary.authorDisplayName : '玩家', worldName: summary.workTitle, @@ -857,6 +863,16 @@ export function formatPlatformWorkDisplayTags( ].slice(0, limit); } +export function resolvePlatformWorkAuthorDisplayName( + entry: PlatformPublicGalleryCard, + authorSummary?: PublicUserSummary | null, +) { + const displayName = authorSummary?.displayName?.trim(); + const publicUserCode = authorSummary?.publicUserCode?.trim(); + + return displayName || publicUserCode || entry.authorDisplayName.trim() || '玩家'; +} + export function buildPlatformWorldDisplayTags( entry: PlatformWorldCardLike, limit = 3, @@ -1063,4 +1079,4 @@ function buildBarkBattleThemeTags(work: BarkBattleWorkSummary) { .map((tag) => tag.trim()) .filter(Boolean) .slice(0, 3); -} +} \ No newline at end of file diff --git a/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx b/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx index 9b6e6445..9cd0e387 100644 --- a/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx +++ b/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx @@ -40,7 +40,6 @@ type WoodenFishWorkspaceFormState = { floatingWords: string[]; }; -const DEFAULT_WORK_TITLE = '今日敲木鱼'; const DEFAULT_THEME_TAGS = ['敲木鱼', '解压']; const DEFAULT_FLOATING_WORDS = ['幸运']; const MAX_FLOATING_WORD_COUNT = 8; @@ -309,8 +308,9 @@ export function WoodenFishWorkspace({ try { const payload: WoodenFishWorkspaceCreateRequest = { templateId: 'wooden-fish', - workTitle: DEFAULT_WORK_TITLE, - workDescription: '', + workTitle: '', + workDescription: + formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT, themeTags: DEFAULT_THEME_TAGS, hitObjectPrompt: formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT, diff --git a/src/editor/shared/editorApiClient.ts b/src/editor/shared/editorApiClient.ts index 06f46d17..64359b78 100644 --- a/src/editor/shared/editorApiClient.ts +++ b/src/editor/shared/editorApiClient.ts @@ -1,4 +1,9 @@ -import { fetchJson, parseApiErrorMessage, saveJsonObject } from './jsonClient'; +import { + appendApiErrorRequestId, + fetchJson, + parseApiErrorMessage, + saveJsonObject, +} from './jsonClient'; export const EDITOR_API_BASE_PATH = '/api/editor'; export const ASSETS_API_BASE_PATH = '/api/assets'; @@ -69,7 +74,7 @@ export async function postApiJson( const responseText = await response.text(); if (!response.ok) { - throw new Error(parseApiErrorMessage(responseText, fallbackMessage)); + throw new Error(appendApiErrorRequestId(parseApiErrorMessage(responseText, fallbackMessage), response.headers.get('x-request-id'))); } return responseText ? (JSON.parse(responseText) as T) : ({} as T); diff --git a/src/editor/shared/jsonClient.ts b/src/editor/shared/jsonClient.ts index 1b279350..107afadd 100644 --- a/src/editor/shared/jsonClient.ts +++ b/src/editor/shared/jsonClient.ts @@ -1,6 +1,7 @@ import { API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION, + appendApiErrorRequestId, parseApiErrorMessage, unwrapApiResponse, } from '../../../packages/shared/src/http'; @@ -17,7 +18,15 @@ export async function fetchJson( const responseText = await response.text(); if (!response.ok) { - throw new Error(parseApiErrorMessage(responseText, `${fallbackMessage}: ${response.status}`)); + throw new Error( + appendApiErrorRequestId( + parseApiErrorMessage( + responseText, + `${fallbackMessage}: ${response.status}`, + ), + response.headers.get('x-request-id'), + ), + ); } return responseText @@ -41,8 +50,13 @@ export async function saveJsonObject( const responseText = await response.text(); if (!response.ok) { - throw new Error(parseApiErrorMessage(responseText, fallbackMessage)); + throw new Error( + appendApiErrorRequestId( + parseApiErrorMessage(responseText, fallbackMessage), + response.headers.get('x-request-id'), + ), + ); } } -export { parseApiErrorMessage }; +export { appendApiErrorRequestId, parseApiErrorMessage }; diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 277444b8..eda0fb45 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -14,7 +14,10 @@ import type { GenerateCustomWorldProfileInput, GenerateCustomWorldProfileOptions, } from '../../packages/shared/src/contracts/runtime'; -import { parseApiErrorMessage } from '../../packages/shared/src/http'; +import { + appendApiErrorRequestId, + parseApiErrorMessage, +} from '../../packages/shared/src/http'; import type { AIResponse, Character, @@ -93,7 +96,12 @@ async function requestPlainTextStream( if (!response.ok) { const responseText = await response.text(); - throw new Error(parseApiErrorMessage(responseText, '流式请求失败')); + throw new Error( + appendApiErrorRequestId( + parseApiErrorMessage(responseText, '流式请求失败'), + response.headers.get('x-request-id'), + ), + ); } if (!response.body) { @@ -488,7 +496,12 @@ export async function streamNpcChatTurn( if (!response.ok) { const responseText = await response.text(); - throw new Error(parseApiErrorMessage(responseText, 'NPC 聊天续写失败')); + throw new Error( + appendApiErrorRequestId( + parseApiErrorMessage(responseText, 'NPC 聊天续写失败'), + response.headers.get('x-request-id'), + ), + ); } if (!response.body) { diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index f969959d..51d11e0e 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -547,6 +547,7 @@ describe('apiClient', () => { routeVersion: 'runtime.v2', }, }); + expect((capturedError as Error).message).toContain('requestId: req-body'); }); it('uses api error details.message as ApiClientError message', async () => { diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 0825dc17..a3eecde0 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -671,9 +671,14 @@ async function buildApiClientError( ) { const responseText = await response.text(); const parsedError = parseApiErrorShape(responseText); + const requestId = + parsedError?.meta.requestId ?? + response.headers.get(REQUEST_ID_HEADER) ?? + undefined; + const baseMessage = parseApiErrorMessage(responseText, fallbackMessage); return new ApiClientError({ - message: parseApiErrorMessage(responseText, fallbackMessage), + message: requestId ? `${baseMessage}(requestId: ${requestId})` : baseMessage, status: response.status, code: parsedError?.code ?? `HTTP_${response.status || 0}`, details: parsedError?.details ?? null, @@ -682,10 +687,7 @@ async function buildApiClientError( parsedError?.meta.apiVersion ?? response.headers.get(API_VERSION_HEADER) ?? API_VERSION, - requestId: - parsedError?.meta.requestId ?? - response.headers.get(REQUEST_ID_HEADER) ?? - undefined, + requestId, routeVersion: parsedError?.meta.routeVersion ?? response.headers.get(ROUTE_VERSION_HEADER) ?? diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts index 4a6e57a3..49812b89 100644 --- a/src/services/assetReadUrlService.ts +++ b/src/services/assetReadUrlService.ts @@ -1,4 +1,7 @@ -import { parseApiErrorMessage } from '../../packages/shared/src/http'; +import { + appendApiErrorRequestId, + parseApiErrorMessage, +} from '../../packages/shared/src/http'; import { ApiClientError, BACKGROUND_AUTH_REQUEST_OPTIONS, @@ -338,7 +341,12 @@ export async function readAssetBytes( if (!response.ok) { const message = await response .text() - .then((text) => parseApiErrorMessage(text, '读取资源内容失败')) + .then((text) => + appendApiErrorRequestId( + parseApiErrorMessage(text, '读取资源内容失败'), + response.headers.get('x-request-id'), + ), + ) .catch(() => ''); throw new Error(message || '读取资源内容失败'); } diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index 8a925fac..068a1aed 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -91,14 +91,12 @@ describe('authService', () => { user: { id: 'user_1', publicUserCode: 'SY-00000001', - username: 'phone_00000001', displayName: '138****8000', avatarUrl: null, phoneNumberMasked: '138****8000', loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: '2026-05-01T00:00:00.000Z', }, }); @@ -128,14 +126,12 @@ describe('authService', () => { user: { id: 'user_1', publicUserCode: 'SY-00000001', - username: 'phone_00000001', displayName: '旅人甲', avatarUrl: 'data:image/png;base64,AAAA', phoneNumberMasked: '138****8000', loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: '2026-05-01T00:00:00.000Z', }, }); @@ -167,14 +163,12 @@ describe('authService', () => { user: { id: 'user_1', publicUserCode: 'SY-00000001', - username: 'phone_00000001', displayName: '旅人甲', avatarUrl: null, phoneNumberMasked: '138****8000', loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: '2026-05-01T00:00:00.000Z', }, }); @@ -254,14 +248,12 @@ describe('authService', () => { user: { id: 'user_phone', publicUserCode: 'SY-00000004', - username: '138****8000', displayName: '138****8000', avatarUrl: null, phoneNumberMasked: '138****8000', loginMethod: 'phone', bindingStatus: 'active', wechatBound: false, - createdAt: '2026-05-01T00:00:00.000Z', }, }); @@ -271,7 +263,7 @@ describe('authService', () => { 'spring-2026', ); - expect(response.user.username).toBe('138****8000'); + expect(response.user.displayName).toBe('138****8000'); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/phone/login', expect.objectContaining({ @@ -333,14 +325,12 @@ describe('authService', () => { user: { id: 'user_wechat', publicUserCode: 'SY-00000005', - username: '138****8000', displayName: '138****8000', avatarUrl: null, phoneNumberMasked: '138****8000', loginMethod: 'wechat', bindingStatus: 'active', wechatBound: true, - createdAt: '2026-05-01T00:00:00.000Z', }, }); @@ -356,14 +346,12 @@ describe('authService', () => { user: { id: 'user_phone', publicUserCode: 'SY-00000006', - username: '139****9000', displayName: '139****9000', avatarUrl: null, phoneNumberMasked: '139****9000', loginMethod: 'phone', bindingStatus: 'active', wechatBound: false, - createdAt: '2026-05-01T00:00:00.000Z', }, }); diff --git a/src/services/creation-agent/creationAgentClientFactory.ts b/src/services/creation-agent/creationAgentClientFactory.ts index dacbbbc7..a7fd288a 100644 --- a/src/services/creation-agent/creationAgentClientFactory.ts +++ b/src/services/creation-agent/creationAgentClientFactory.ts @@ -1,4 +1,4 @@ -import { parseApiErrorMessage } from '../../../packages/shared/src/http'; +import { appendApiErrorRequestId, parseApiErrorMessage } from '../../../packages/shared/src/http'; import type { TextStreamOptions } from '../aiTypes'; import { type ApiRetryOptions, @@ -64,7 +64,7 @@ async function openCreationAgentSsePost( if (!response.ok) { const responseText = await response.text(); - throw new Error(parseApiErrorMessage(responseText, fallbackMessage)); + throw new Error(appendApiErrorRequestId(parseApiErrorMessage(responseText, fallbackMessage), response.headers.get('x-request-id'))); } if (!response.body) { diff --git a/src/services/creative-agent/creativeAgentClient.ts b/src/services/creative-agent/creativeAgentClient.ts index 60c980f6..fc279f7b 100644 --- a/src/services/creative-agent/creativeAgentClient.ts +++ b/src/services/creative-agent/creativeAgentClient.ts @@ -7,7 +7,7 @@ import type { CreativeDraftEditStreamRequest, StreamCreativeAgentMessageRequest, } from '../../../packages/shared/src/contracts/creativeAgent'; -import { parseApiErrorMessage } from '../../../packages/shared/src/http'; +import { appendApiErrorRequestId, parseApiErrorMessage } from '../../../packages/shared/src/http'; import type { TextStreamOptions } from '../aiTypes'; import { fetchWithApiAuth, requestJson } from '../apiClient'; import { @@ -42,7 +42,7 @@ async function openCreativeAgentSsePost( if (!response.ok) { const responseText = await response.text(); - throw new Error(parseApiErrorMessage(responseText, fallbackMessage)); + throw new Error(appendApiErrorRequestId(parseApiErrorMessage(responseText, fallbackMessage), response.headers.get('x-request-id'))); } if (!response.body) { diff --git a/src/services/rpg-creation/rpgCreationRequestHelpers.ts b/src/services/rpg-creation/rpgCreationRequestHelpers.ts index 7e3624f0..2ee363cd 100644 --- a/src/services/rpg-creation/rpgCreationRequestHelpers.ts +++ b/src/services/rpg-creation/rpgCreationRequestHelpers.ts @@ -1,4 +1,4 @@ -import { parseApiErrorMessage } from '../../../packages/shared/src/http'; +import { appendApiErrorRequestId, parseApiErrorMessage } from '../../../packages/shared/src/http'; import { fetchWithApiAuth, requestJson } from '../apiClient'; export async function requestRpgCreationPostJson( @@ -32,7 +32,7 @@ export async function openRpgCreationSsePost( if (!response.ok) { const responseText = await response.text(); - throw new Error(parseApiErrorMessage(responseText, fallbackMessage)); + throw new Error(appendApiErrorRequestId(parseApiErrorMessage(responseText, fallbackMessage), response.headers.get('x-request-id'))); } if (!response.body) { diff --git a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts index b2210823..8f29ffe4 100644 --- a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts +++ b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts @@ -16,7 +16,7 @@ import type { VisualNovelStartRunRequest, VisualNovelWorksResponse, } from '../../../packages/shared/src/contracts/visualNovel'; -import { parseApiErrorMessage } from '../../../packages/shared/src/http'; +import { appendApiErrorRequestId, parseApiErrorMessage } from '../../../packages/shared/src/http'; import type { TextStreamOptions } from '../aiTypes'; import { type ApiRetryOptions, @@ -100,7 +100,7 @@ async function openVisualNovelRuntimeSsePost( if (!response.ok) { const responseText = await response.text(); - throw new Error(parseApiErrorMessage(responseText, fallbackMessage)); + throw new Error(appendApiErrorRequestId(parseApiErrorMessage(responseText, fallbackMessage), response.headers.get('x-request-id'))); } if (!response.body) {